Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README | LICENSE

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 }