git.go (5159B)
1 package jci 2 3 import ( 4 "bytes" 5 "errors" 6 "fmt" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "strings" 11 ) 12 13 // gitError is returned whenever a git sub-process exits with a non-zero status. 14 // It carries the original exit code together with the raw stdout and stderr so 15 // callers (and users) can see exactly what git printed. 16 type gitError struct { 17 Args []string 18 ExitCode int 19 Stdout string 20 Stderr string 21 } 22 23 func (e *gitError) Error() string { 24 return fmt.Sprintf("git %s: exit %d\nstdout: %s\nstderr: %s", 25 strings.Join(e.Args, " "), e.ExitCode, e.Stdout, e.Stderr) 26 } 27 28 // gitCmd runs a git command (optionally with a pre-configured *exec.Cmd so the 29 // caller can set Dir/Env/Stdin) and returns trimmed stdout. On failure it 30 // always returns a *gitError with exit code + both streams. 31 func gitCmd(cmd *exec.Cmd) (string, error) { 32 var stdout, stderr bytes.Buffer 33 cmd.Stdout = &stdout 34 cmd.Stderr = &stderr 35 err := cmd.Run() 36 args := cmd.Args[1:] // strip the leading "git" 37 if err != nil { 38 exitCode := -1 39 var exitErr *exec.ExitError 40 if errors.As(err, &exitErr) { 41 exitCode = exitErr.ExitCode() 42 } 43 return "", &gitError{ 44 Args: args, 45 ExitCode: exitCode, 46 Stdout: stdout.String(), 47 Stderr: stderr.String(), 48 } 49 } 50 return strings.TrimSpace(stdout.String()), nil 51 } 52 53 // git runs a plain git command and returns stdout. 54 func git(args ...string) (string, error) { 55 return gitCmd(exec.Command("git", args...)) 56 } 57 58 // GetCurrentCommit returns the current HEAD commit hash 59 func GetCurrentCommit() (string, error) { 60 return git("rev-parse", "HEAD") 61 } 62 63 // GetRepoRoot returns the root directory of the git repository 64 func GetRepoRoot() (string, error) { 65 return git("rev-parse", "--show-toplevel") 66 } 67 68 // RefExists checks if a ref exists 69 func RefExists(ref string) bool { 70 _, err := git("rev-parse", "--verify", ref) 71 return err == nil 72 } 73 74 // StoreTree stores a directory as a tree object and creates a commit under refs/jci-runs/<commit>/<runID> 75 func StoreTree(dir string, commit string, message string, runID string) error { 76 repoRoot, err := GetRepoRoot() 77 if err != nil { 78 return err 79 } 80 81 // We need to use git hash-object and mktree to build a tree 82 // from files outside the repo 83 treeID, err := hashDir(dir, repoRoot) 84 if err != nil { 85 return fmt.Errorf("failed to hash directory: %w", err) 86 } 87 88 // Create commit from tree. 89 // git commit-tree requires author/committer identity. In CI environments 90 // (especially shallow clones) git config may be absent, so we inject 91 // fallback env vars while still honouring any values already set. 92 commitTreeCmd := exec.Command("git", "commit-tree", treeID, "-m", message) 93 commitTreeCmd.Dir = repoRoot 94 commitTreeCmd.Env = append(os.Environ(), gitIdentityEnv()...) 95 commitID, err := gitCmd(commitTreeCmd) 96 if err != nil { 97 return err 98 } 99 100 // Update ref: refs/jci-runs/<commit>/<runid> 101 ref := "refs/jci-runs/" + commit + "/" + runID 102 if _, err := git("update-ref", ref, commitID); err != nil { 103 return err 104 } 105 106 return nil 107 } 108 109 // hashDir recursively hashes a directory and returns its tree ID 110 func hashDir(dir string, repoRoot string) (string, error) { 111 entries, err := os.ReadDir(dir) 112 if err != nil { 113 return "", err 114 } 115 116 var treeEntries []string 117 118 for _, entry := range entries { 119 path := filepath.Join(dir, entry.Name()) 120 121 if entry.IsDir() { 122 // Recursively hash subdirectory 123 subTreeID, err := hashDir(path, repoRoot) 124 if err != nil { 125 return "", err 126 } 127 treeEntries = append(treeEntries, fmt.Sprintf("040000 tree %s\t%s", subTreeID, entry.Name())) 128 } else { 129 // Hash file 130 cmd := exec.Command("git", "hash-object", "-w", path) 131 cmd.Dir = repoRoot 132 blobID, err := gitCmd(cmd) 133 if err != nil { 134 return "", err 135 } 136 137 // Get file mode 138 info, err := entry.Info() 139 if err != nil { 140 return "", err 141 } 142 mode := "100644" 143 if info.Mode()&0111 != 0 { 144 mode = "100755" 145 } 146 treeEntries = append(treeEntries, fmt.Sprintf("%s blob %s\t%s", mode, blobID, entry.Name())) 147 } 148 } 149 150 // Create tree from entries 151 treeInput := strings.Join(treeEntries, "\n") 152 if treeInput != "" { 153 treeInput += "\n" 154 } 155 156 cmd := exec.Command("git", "mktree") 157 cmd.Dir = repoRoot 158 cmd.Stdin = strings.NewReader(treeInput) 159 return gitCmd(cmd) 160 } 161 162 // gitIdentityEnv returns GIT_AUTHOR_* / GIT_COMMITTER_* env vars with safe 163 // fallback values so that git commit-tree works even when git config has no 164 // user identity set (common in CI shallow-clone environments). 165 func gitIdentityEnv() []string { 166 getOrDefault := func(envKey, fallback string) string { 167 if v := os.Getenv(envKey); v != "" { 168 return v 169 } 170 return fallback 171 } 172 name := getOrDefault("GIT_AUTHOR_NAME", "jci") 173 email := getOrDefault("GIT_AUTHOR_EMAIL", "jci@localhost") 174 return []string{ 175 "GIT_AUTHOR_NAME=" + name, 176 "GIT_AUTHOR_EMAIL=" + email, 177 "GIT_COMMITTER_NAME=" + name, 178 "GIT_COMMITTER_EMAIL=" + email, 179 } 180 } 181 182 // ListJCIRunRefs returns all refs under refs/jci-runs/ 183 func ListJCIRunRefs() ([]string, error) { 184 out, err := git("for-each-ref", "--format=%(refname)", "refs/jci-runs/") 185 if err != nil { 186 return nil, err 187 } 188 if out == "" { 189 return nil, nil 190 } 191 return strings.Split(out, "\n"), nil 192 }