Files
zero/internal/zerogit/zerogit.go
Kunj SHAH 2312abe5dd feat: add zero changes push and pr subcommands, and extra repo-info metrics (#391)
* feat: add zero changes push and pr subcommands, and extra repo-info metrics

* fix: address review feedback on changes command and repoinfo

* style: format repoinfo.go with gofmt

* fix: address remaining review feedback — JSON tags, -- separator, PR validation, tests

* fix: address CodeRabbit + review feedback — default-branch guard, --yes flag, branch parsing, typed JSON, missing tests

- Add default-branch guard: Push rejects main/master unless --yes is passed
- Add --yes flag to changes push/pr to allow pushing to protected branches
- Fix BranchCount parsing: skip detached HEAD lines and strip worktree + prefix
- Replace ad-hoc map with typed struct for PR --json output
- Add push failure short-circuit test for pr workflow
- Add --dry-run rejection test for pr workflow
- Add CreatePR dir assertion in zerogit_test
- Add --yes validation test for commit
- Add RejectsDefaultBranch and AllowDefaultBranchWithYes tests

* test: add positive-path test for --yes flag in changes push

* fix: resolve remote HEAD dynamically instead of hardcoded main/master

* style: run gofmt on changed files

* retrigger CI

* fix: resolve syntax error in parseChangesArgs
2026-07-05 17:03:29 +08:00

685 lines
19 KiB
Go

package zerogit
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/redaction"
)
type Runner func(context.Context, string, ...string) (CommandResult, error)
type EnvRunner func(context.Context, string, []string, ...string) (CommandResult, error)
type CommandResult struct {
Stdout string
Stderr string
ExitCode int
}
type InspectOptions struct {
Cwd string
BaseRef string
MaxDiffBytes int
RunGit Runner
RunGitEnv EnvRunner
}
type CommitOptions struct {
Cwd string
Message string
DryRun bool
MaxDiffBytes int
RunGit Runner
RunGitEnv EnvRunner
}
type FileChange struct {
Path string `json:"path"`
Status string `json:"status"`
Staged bool `json:"staged,omitempty"`
Unstaged bool `json:"unstaged,omitempty"`
Untracked bool `json:"untracked,omitempty"`
}
type ChangeSummary struct {
Root string `json:"root"`
Branch string `json:"branch,omitempty"`
Base string `json:"base,omitempty"`
Commit string `json:"commit,omitempty"`
Clean bool `json:"clean"`
Files []FileChange `json:"files"`
DiffStat string `json:"diffStat,omitempty"`
Diff string `json:"diff,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
type CommitResult struct {
Root string `json:"root"`
Message string `json:"message"`
DryRun bool `json:"dryRun"`
Committed bool `json:"committed"`
CommitHash string `json:"commitHash,omitempty"`
Before ChangeSummary `json:"before"`
}
const defaultMaxDiffBytes = 120000
func Inspect(ctx context.Context, options InspectOptions) (ChangeSummary, error) {
cwd, err := resolveCwd(options.Cwd)
if err != nil {
return ChangeSummary{}, err
}
runGit, runGitEnv := resolveRunners(options.RunGit, options.RunGitEnv)
root, err := gitOutput(ctx, runGit, cwd, "rev-parse", "--show-toplevel")
if err != nil {
return ChangeSummary{}, fmt.Errorf("not a git repository: %w", err)
}
root = filepath.Clean(root)
branch, _ := gitOutput(ctx, runGit, root, "rev-parse", "--abbrev-ref", "HEAD")
commit, _ := gitOutput(ctx, runGit, root, "rev-parse", "--short", "HEAD")
maxDiffBytes := firstPositive(options.MaxDiffBytes, defaultMaxDiffBytes)
base := strings.TrimSpace(options.BaseRef)
if base != "" {
nameStatus, err := gitRawOutput(ctx, runGit, root, "diff", "--name-status", base+"...HEAD", "--")
if err != nil {
return ChangeSummary{}, fmt.Errorf("inspect base diff status: %w", err)
}
diffStat, err := gitRawOutput(ctx, runGit, root, "diff", "--stat", base+"...HEAD", "--")
if err != nil {
return ChangeSummary{}, fmt.Errorf("inspect base diff stat: %w", err)
}
diff, err := gitRawOutput(ctx, runGit, root, "diff", base+"...HEAD", "--")
if err != nil {
return ChangeSummary{}, fmt.Errorf("inspect base diff: %w", err)
}
redactedDiff, truncated := truncateString(redactText(diff), maxDiffBytes)
files := parseNameStatus(nameStatus)
return ChangeSummary{
Root: root,
Branch: redactText(branch),
Base: redactText(base),
Commit: redactText(commit),
Clean: len(files) == 0,
Files: files,
DiffStat: redactText(diffStat),
Diff: redactedDiff,
Truncated: truncated,
}, nil
}
status, err := gitRawOutput(ctx, runGit, root, "status", "--porcelain", "-z", "--untracked-files=all")
if err != nil {
return ChangeSummary{}, fmt.Errorf("inspect git status: %w", err)
}
diffStat, diff, err := stagedSnapshotDiff(ctx, runGitEnv, root)
if err != nil {
return ChangeSummary{}, err
}
redactedDiff, truncated := truncateString(redactText(diff), maxDiffBytes)
files := parseStatus(status)
return ChangeSummary{
Root: root,
Branch: redactText(branch),
Commit: redactText(commit),
Clean: len(files) == 0,
Files: files,
DiffStat: redactText(diffStat),
Diff: redactedDiff,
Truncated: truncated,
}, nil
}
func Commit(ctx context.Context, options CommitOptions) (CommitResult, error) {
summary, err := Inspect(ctx, InspectOptions{
Cwd: options.Cwd,
MaxDiffBytes: options.MaxDiffBytes,
RunGit: options.RunGit,
RunGitEnv: options.RunGitEnv,
})
if err != nil {
return CommitResult{}, err
}
if summary.Clean {
return CommitResult{}, fmt.Errorf("no changes to commit")
}
message := strings.TrimSpace(options.Message)
if message == "" {
message = GenerateMessage(summary)
}
if err := ValidateMessage(message); err != nil {
return CommitResult{}, err
}
result := CommitResult{
Root: summary.Root,
Message: message,
DryRun: options.DryRun,
Committed: false,
Before: summary,
}
if options.DryRun {
return result, nil
}
runGit, _ := resolveRunners(options.RunGit, options.RunGitEnv)
if _, err := gitOutput(ctx, runGit, summary.Root, "add", "-A"); err != nil {
return CommitResult{}, fmt.Errorf("stage changes: %w", err)
}
if _, err := gitOutput(ctx, runGit, summary.Root, "commit", "-m", message); err != nil {
return CommitResult{}, fmt.Errorf("commit changes: %w", err)
}
hash, err := gitOutput(ctx, runGit, summary.Root, "rev-parse", "--short", "HEAD")
if err != nil {
return CommitResult{}, fmt.Errorf("resolve created commit: %w", err)
}
result.Committed = true
result.CommitHash = redactText(hash)
return result, nil
}
func GenerateMessage(summary ChangeSummary) string {
count := len(summary.Files)
switch count {
case 0:
return "Update workspace"
case 1:
return truncateSubject("Update " + summary.Files[0].Path)
default:
return fmt.Sprintf("Update %d files", count)
}
}
func ValidateMessage(message string) error {
trimmed := strings.TrimSpace(message)
if trimmed == "" {
return fmt.Errorf("commit message is required")
}
firstLine := strings.Split(trimmed, "\n")[0]
// Count runes, not bytes, so a valid non-ASCII subject under the limit is not
// rejected for spilling past 72 bytes.
if utf8.RuneCountInString(firstLine) > 72 {
return fmt.Errorf("commit message subject must be 72 characters or fewer")
}
return nil
}
// parseStatus parses NUL-delimited `git status --porcelain -z` output. The -z
// form is used instead of the default --short because it never C-quotes paths
// (so non-ASCII or whitespace filenames arrive verbatim rather than as a
// "\303\251"-escaped, double-quoted token) and because it emits a rename/copy as
// two NUL-separated fields — `XY <dest>\0<src>` — letting us record the
// destination path and skip the source instead of mistaking the whole
// `dest -> src` string for a single filename.
func parseStatus(status string) []FileChange {
files := []FileChange{}
fields := strings.Split(status, "\x00")
for i := 0; i < len(fields); i++ {
entry := fields[i]
if len(entry) < 3 {
continue
}
code := entry[:2]
// Format is exactly `XY<space>PATH`; -z never quotes or pads PATH, so it
// is taken verbatim (no TrimSpace, which would corrupt names with leading
// or trailing spaces).
path := entry[3:]
// A rename/copy is followed by a separate NUL-terminated field holding the
// original path; consume it so it is not parsed as its own entry. This
// entry's own path is the destination.
//
// Only the INDEX column (code[0]) is checked, never the worktree column
// (code[1]): porcelain v1 -z reports a rename/copy (and emits the extra
// source field) only in the index column. A worktree-only rename is shown as
// a delete + untracked pair (" D old\0?? new\0"), NOT "R" in code[1] — so
// consuming on code[1]=='R'/'C' would never match real git output and would
// only risk mis-consuming the next entry on malformed input. (Verified
// empirically: git mv → "R new\0old\0"; plain mv → " D old\0?? new\0".)
if code[0] == 'R' || code[0] == 'C' {
i++
}
if path == "" {
continue
}
files = append(files, FileChange{
Path: redactText(path),
Status: statusName(code),
Staged: code[0] != ' ' && code[0] != '?',
Unstaged: code[1] != ' ' && code[1] != '?',
Untracked: code == "??",
})
}
return files
}
func parseNameStatus(output string) []FileChange {
files := []FileChange{}
for _, line := range strings.Split(output, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 2 {
continue
}
code := strings.TrimSpace(fields[0])
if code == "" {
continue
}
path := strings.TrimSpace(fields[len(fields)-1])
if path == "" {
continue
}
files = append(files, FileChange{
Path: redactText(path),
Status: nameStatusName(code[:1]),
})
}
return files
}
func nameStatusName(letter string) string {
switch letter {
case "A":
return "added"
case "D":
return "deleted"
case "R":
return "renamed"
case "C":
return "copied"
case "U":
return "conflicted"
case "T":
return "modified"
default:
return "modified"
}
}
func statusName(code string) string {
if code == "??" {
return "untracked"
}
if strings.Contains(code, "U") {
return "conflicted"
}
if code[0] == 'A' || code[1] == 'A' {
return "added"
}
if code[0] == 'D' || code[1] == 'D' {
return "deleted"
}
if code[0] == 'R' || code[1] == 'R' {
return "renamed"
}
if code[0] == 'C' || code[1] == 'C' {
return "copied"
}
return "modified"
}
func resolveCwd(cwd string) (string, error) {
if strings.TrimSpace(cwd) == "" {
var err error
cwd, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve git cwd: %w", err)
}
}
absolute, err := filepath.Abs(cwd)
if err != nil {
return "", fmt.Errorf("resolve git cwd: %w", err)
}
info, err := os.Stat(absolute)
if err != nil || !info.IsDir() {
return "", fmt.Errorf("git cwd must be an existing directory: %s", absolute)
}
return filepath.Clean(absolute), nil
}
func gitOutput(ctx context.Context, runGit Runner, dir string, args ...string) (string, error) {
output, err := gitRawOutput(ctx, runGit, dir, args...)
if err != nil {
return "", err
}
return strings.TrimSpace(output), nil
}
func gitRawOutput(ctx context.Context, runGit Runner, dir string, args ...string) (string, error) {
result, err := runGit(ctx, dir, args...)
return gitResultOutput(result, err)
}
func gitRawOutputEnv(ctx context.Context, runGit EnvRunner, dir string, env []string, args ...string) (string, error) {
result, err := runGit(ctx, dir, env, args...)
return gitResultOutput(result, err)
}
func gitResultOutput(result CommandResult, err error) (string, error) {
if err != nil {
return "", err
}
if result.ExitCode != 0 {
message := strings.TrimSpace(firstNonEmpty(result.Stderr, result.Stdout))
if message == "" {
message = fmt.Sprintf("git exited with code %d", result.ExitCode)
}
return "", fmt.Errorf("%s", redactText(message))
}
return result.Stdout, nil
}
func stagedSnapshotDiff(ctx context.Context, runGit EnvRunner, root string) (string, string, error) {
tempDir, err := os.MkdirTemp("", "zero-git-index-")
if err != nil {
return "", "", fmt.Errorf("prepare preview index: %w", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
env := []string{"GIT_INDEX_FILE=" + filepath.Join(tempDir, "index")}
if _, err := gitRawOutputEnv(ctx, runGit, root, env, "rev-parse", "--verify", "HEAD"); err != nil {
if _, emptyErr := gitRawOutputEnv(ctx, runGit, root, env, "read-tree", "--empty"); emptyErr != nil {
return "", "", fmt.Errorf("prepare empty preview index: %w", emptyErr)
}
} else if _, err := gitRawOutputEnv(ctx, runGit, root, env, "read-tree", "HEAD"); err != nil {
return "", "", fmt.Errorf("prepare preview index from HEAD: %w", err)
}
if _, err := gitRawOutputEnv(ctx, runGit, root, env, "add", "-A"); err != nil {
return "", "", fmt.Errorf("stage preview index: %w", err)
}
diffStat, err := gitRawOutputEnv(ctx, runGit, root, env, "diff", "--cached", "--stat", "--")
if err != nil {
return "", "", fmt.Errorf("inspect git diff stat: %w", err)
}
diff, err := gitRawOutputEnv(ctx, runGit, root, env, "diff", "--cached", "--")
if err != nil {
return "", "", fmt.Errorf("inspect git diff: %w", err)
}
return diffStat, diff, nil
}
func defaultRunGit(ctx context.Context, dir string, args ...string) (CommandResult, error) {
return defaultRunGitEnv(ctx, dir, nil, args...)
}
func defaultRunGitEnv(ctx context.Context, dir string, env []string, args ...string) (CommandResult, error) {
command := exec.CommandContext(ctx, "git", args...)
command.Dir = dir
if len(env) > 0 {
command.Env = append(os.Environ(), env...)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
err := command.Run()
exitCode := 0
if err != nil {
exitCode = -1
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
err = nil
}
}
return CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, err
}
func resolveRunners(runGit Runner, runGitEnv EnvRunner) (Runner, EnvRunner) {
if runGit == nil {
runGit = defaultRunGit
if runGitEnv == nil {
runGitEnv = defaultRunGitEnv
}
} else if runGitEnv == nil {
// A plain Runner has no env parameter, so an env-bearing call (e.g.
// stagedSnapshotDiff's GIT_INDEX_FILE isolation) cannot thread its env
// through it; env is intentionally dropped on this adapter. This branch is
// reached ONLY when a caller supplies a custom Runner without a matching
// EnvRunner — in practice, tests with a fake Runner that intercepts every
// git call. Production callers leave both nil and get
// defaultRunGit/defaultRunGitEnv above, which honor env, so GIT_INDEX_FILE
// isolation holds on the real path. A custom Runner that also needs env
// isolation must supply a RunGitEnv alongside it.
runGitEnv = func(ctx context.Context, dir string, _ []string, args ...string) (CommandResult, error) {
return runGit(ctx, dir, args...)
}
}
return runGit, runGitEnv
}
func truncateString(value string, maxBytes int) (string, bool) {
if maxBytes <= 0 || len(value) <= maxBytes {
return value, false
}
suffix := "\n[truncated]"
if maxBytes <= len(suffix) {
return suffix[:maxBytes], true
}
head := cutGitRuneBoundary(value, maxBytes-len(suffix))
if strings.Contains(value, redaction.RedactedSecret) && !strings.Contains(head, redaction.RedactedSecret) {
marker := "\n" + redaction.RedactedSecret
budget := maxBytes - len(suffix) - len(marker)
if budget <= 0 {
allowed := maxBytes - len(suffix)
if allowed > len(redaction.RedactedSecret) {
allowed = len(redaction.RedactedSecret)
}
return redaction.RedactedSecret[:allowed] + suffix, true
}
return cutGitRuneBoundary(value, budget) + marker + suffix, true
}
return head + suffix, true
}
// cutGitRuneBoundary truncates to at most n bytes on a rune boundary so
// truncated git output and subjects stay valid UTF-8.
func cutGitRuneBoundary(s string, n int) string {
if n <= 0 {
return ""
}
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
func truncateSubject(value string) string {
// Count runes, not bytes: a 72-byte limit rejected valid non-ASCII
// subjects and the byte slice could cut a rune in half.
runes := []rune(value)
if len(runes) <= 72 {
return value
}
return strings.TrimSpace(string(runes[:69])) + "..."
}
func redactText(value string) string {
return redaction.RedactString(value, redaction.Options{})
}
func firstPositive(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
type PushOptions struct {
Cwd string
Remote string
Branch string
Force bool
DryRun bool
AllowPushDefaultBranch bool
RunGit Runner
RunGitEnv EnvRunner
}
type PushResult struct {
Remote string `json:"remote"`
Branch string `json:"branch"`
Output string `json:"output"`
}
func Push(ctx context.Context, options PushOptions) (PushResult, error) {
cwd, err := resolveCwd(options.Cwd)
if err != nil {
return PushResult{}, err
}
runGit, _ := resolveRunners(options.RunGit, options.RunGitEnv)
root, err := gitOutput(ctx, runGit, cwd, "rev-parse", "--show-toplevel")
if err != nil {
return PushResult{}, fmt.Errorf("not a git repository: %w", err)
}
root = filepath.Clean(root)
branch := strings.TrimSpace(options.Branch)
if branch == "" {
branch, err = gitOutput(ctx, runGit, root, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return PushResult{}, fmt.Errorf("resolve current branch: %w", err)
}
}
if branch == "HEAD" {
return PushResult{}, fmt.Errorf("cannot push: not currently on a branch")
}
remote := strings.TrimSpace(options.Remote)
if remote == "" {
if upstream, err := gitOutput(ctx, runGit, root, "config", "branch."+branch+".remote"); err == nil && upstream != "" {
remote = upstream
} else {
remote = "origin"
}
}
if !options.AllowPushDefaultBranch {
if isDefaultBranch(ctx, runGit, root, remote, branch) {
return PushResult{}, fmt.Errorf("refusing to push to %q (default/protected branch); use --yes to override", branch)
}
}
args := []string{"push"}
if options.DryRun {
args = append(args, "--dry-run")
}
if options.Force {
args = append(args, "--force-with-lease")
}
args = append(args, "-u", "--", remote, branch)
output, err := gitRawOutput(ctx, runGit, root, args...)
if err != nil {
return PushResult{}, fmt.Errorf("push: %w", err)
}
return PushResult{
Remote: remote,
Branch: branch,
Output: output,
}, nil
}
func isDefaultBranch(ctx context.Context, runGit Runner, dir, remote, branch string) bool {
if out, err := gitOutput(ctx, runGit, dir, "ls-remote", "--symref", remote, "HEAD"); err == nil {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "ref: refs/heads/") && strings.HasSuffix(line, "\tHEAD") {
symref := strings.TrimPrefix(line, "ref: refs/heads/")
symref = strings.TrimSuffix(symref, "\tHEAD")
return branch == symref
}
}
}
return branch == "main" || branch == "master"
}
type PROptions struct {
Cwd string
Fill bool
Draft bool
Title string
Body string
RunGH Runner
}
type PRResult struct {
Output string `json:"output"`
}
func CreatePR(ctx context.Context, options PROptions) (PRResult, error) {
cwd, err := resolveCwd(options.Cwd)
if err != nil {
return PRResult{}, err
}
runGH := options.RunGH
if runGH == nil {
runGH = func(ctx context.Context, dir string, args ...string) (CommandResult, error) {
cmd := exec.CommandContext(ctx, "gh", args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
var exitCode int
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
exitCode = -1
}
}
return CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, err
}
}
prArgs := []string{"pr", "create"}
if options.Fill {
prArgs = append(prArgs, "--fill")
}
if options.Draft {
prArgs = append(prArgs, "--draft")
}
if options.Title != "" {
prArgs = append(prArgs, "--title", options.Title)
}
if options.Body != "" {
prArgs = append(prArgs, "--body", options.Body)
}
res, err := runGH(ctx, cwd, prArgs...)
if err != nil {
return PRResult{}, fmt.Errorf("gh pr create failed: %w\n%s", err, res.Stderr)
}
return PRResult{
Output: res.Stdout,
}, nil
}