// Package git wraps the subprocess `git` calls the harness needs. // Every call gracefully degrades: a non-git directory returns // HasGit=false rather than an error, so the pipeline can mark the // git phase degraded without halting. package git import ( "context" "fmt" "os/exec" "path/filepath" "strings" "time" ) // Info is the git metadata bundle for repo-intake. type Info struct { HasGit bool `json:"has_git"` CurrentBranch string `json:"current_branch,omitempty"` LatestCommit string `json:"latest_commit,omitempty"` Status string `json:"status,omitempty"` // raw `git status -s` output Errors []string `json:"errors,omitempty"` } // Inspect runs the read-only git probes. Times out after 5s per call // so a hung git process can't stall the pipeline. Never mutates the // target repo. func Inspect(ctx context.Context, repoPath string) Info { out := Info{} abs, _ := filepath.Abs(repoPath) gitDir := filepath.Join(abs, ".git") if _, err := exec.LookPath("git"); err != nil { out.Errors = append(out.Errors, "git binary not in PATH") return out } // `.git` may be a file (worktree pointer) or a dir. `git rev-parse` // is the canonical "is this a repo?" probe. cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() cmd := exec.CommandContext(cctx, "git", "-C", abs, "rev-parse", "--git-dir") if err := cmd.Run(); err != nil { // Only annotate when .git looked present — otherwise it's just // a non-git target, not an error. if _, statErr := exec.LookPath("git"); statErr == nil { _ = gitDir } return out } out.HasGit = true out.CurrentBranch = runGit(ctx, abs, "rev-parse", "--abbrev-ref", "HEAD") out.LatestCommit = runGit(ctx, abs, "rev-parse", "HEAD") out.Status = runGit(ctx, abs, "status", "-s") return out } func runGit(ctx context.Context, dir string, args ...string) string { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() full := append([]string{"-C", dir}, args...) out, err := exec.CommandContext(cctx, "git", full...).Output() if err != nil { return "" } return strings.TrimSpace(string(out)) } // ChangedFiles returns the set of repo-relative paths the diff // subcommand should scan. Includes (in priority order): // - unstaged changes (`git diff --name-only`) // - staged changes (`git diff --cached --name-only`) // - branch diff against base (`git diff --name-only base..HEAD`) // // base is auto-detected: prefer "main" then "master" then HEAD~1. // Returns dedup'd, stable-ordered list. Empty list when there's // nothing to review (clean tree, no commits ahead of base). func ChangedFiles(ctx context.Context, repoPath string) ([]string, error) { if _, err := exec.LookPath("git"); err != nil { return nil, fmt.Errorf("git not in PATH") } abs, _ := filepath.Abs(repoPath) cctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := exec.CommandContext(cctx, "git", "-C", abs, "rev-parse", "--git-dir").Run(); err != nil { return nil, fmt.Errorf("not a git repository: %s", repoPath) } seen := map[string]bool{} var out []string add := func(s string) { s = strings.TrimSpace(s) if s == "" || seen[s] { return } seen[s] = true out = append(out, s) } // Unstaged for _, line := range strings.Split(runGit(ctx, abs, "diff", "--name-only"), "\n") { add(line) } // Staged for _, line := range strings.Split(runGit(ctx, abs, "diff", "--cached", "--name-only"), "\n") { add(line) } // vs base — try main, master, then HEAD~1 for _, base := range []string{"main", "master"} { if runGit(ctx, abs, "rev-parse", "--verify", base) != "" { for _, line := range strings.Split(runGit(ctx, abs, "diff", "--name-only", base+"...HEAD"), "\n") { add(line) } break } } return out, nil } // fmt + filepath are already imported indirectly; this var keeps // the import list clean if those packages get unused after a refactor. var _ = fmt.Sprintf var _ = filepath.Join