package cli import ( "context" "fmt" "io" "path/filepath" "strconv" "strings" "time" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/redaction" "github.com/Gitlawb/zero/internal/selfverify" "github.com/Gitlawb/zero/internal/testrunner" "github.com/Gitlawb/zero/internal/verify" "github.com/Gitlawb/zero/internal/worktrees" "github.com/Gitlawb/zero/internal/zerogit" "github.com/Gitlawb/zero/internal/zeroruntime" ) type worktreeCommandOptions struct { json bool name string baseDir string cwd string } type verifyCommandOptions struct { json bool cwd string only []string timeoutMS int attempts int } type changesCommandOptions struct { json bool cwd string baseRef string message string hasMessage bool dryRun bool maxDiffBytes int remote string force bool title string body string fill bool draft bool yes bool auto bool } func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { command := "prepare" if len(args) > 0 && !strings.HasPrefix(args[0], "-") { command = strings.ToLower(strings.TrimSpace(args[0])) args = args[1:] } if command == "help" { if err := writeWorktreesHelp(stdout); err != nil { return exitCrash } return exitSuccess } if command == "release" { return runWorktreesRelease(args, stdout, stderr, deps) } if command != "prepare" { return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees command %q", command)) } options, help, err := parseWorktreeCommandArgs(args) if err != nil { return writeExecUsageError(stderr, err.Error()) } if help { if err := writeWorktreesHelp(stdout); err != nil { return exitCrash } return exitSuccess } workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps) if err != nil { return writeExecUsageError(stderr, err.Error()) } result, err := deps.prepareWorktree(context.Background(), worktrees.Options{ Cwd: workspaceRoot, Name: options.name, BaseDir: options.baseDir, Now: deps.now, }) if err != nil { return writeExecUsageError(stderr, err.Error()) } safeResult := redactWorktreeResult(result) if options.json { if err := writePrettyJSON(stdout, safeResult); err != nil { return exitCrash } return exitSuccess } if _, err := fmt.Fprintln(stdout, formatWorktreeResult(safeResult)); err != nil { return exitCrash } return exitSuccess } // runWorktreesRelease unlocks a worktree `zero worktrees prepare` created, so // Zero's automatic Clean pass can reclaim it once it goes stale. Prepare locks // every worktree it creates and nothing else in that command's own lifetime // unlocks it (its process exits right after printing the path, long before // whatever external caller actually finishes using the worktree), so that // caller is responsible for running this once it is done. func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { var path string var cwdFlag string for index := 0; index < len(args); index++ { arg := args[index] switch { case arg == "-h" || arg == "--help" || arg == "help": if err := writeWorktreesHelp(stdout); err != nil { return exitCrash } return exitSuccess case arg == "-C" || arg == "--cwd": value, next, err := nextFlagValue(args, index, arg) if err != nil { return writeExecUsageError(stderr, err.Error()) } cwdFlag = value index = next case strings.HasPrefix(arg, "--cwd="): cwdFlag = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd=")) case strings.HasPrefix(arg, "-"): return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees release flag %q", arg)) default: if path != "" { return writeExecUsageError(stderr, "worktree path was provided more than once") } path = arg } } path = strings.TrimSpace(path) if path == "" { return writeExecUsageError(stderr, "worktrees release requires a worktree path") } // git worktree unlock matches against the path git recorded when the // worktree was created, so a relative argument here (resolved against // whatever directory the caller happens to be running in, not // necessarily the worktree's own) can fail to match. Resolve to absolute // up front so the unlock target is unambiguous regardless of cwd. absPath, err := filepath.Abs(path) if err != nil { return writeExecUsageError(stderr, redactCLIString(err.Error())) } // Release falls back to Options.Cwd as git's working directory when the // worktree directory itself was deleted by hand instead of released; with // no Cwd wired in, that recovery path runs `git worktree unlock` from // wherever the caller happens to be, which outside the source repository // leaves the orphaned lock behind forever (Clean skips locked entries). // -C/--cwd names the source repository explicitly for exactly that case, // since the deleted worktree path itself carries no way back to the repo; // without the flag, resolve the launch directory best-effort (the // ordinary existing-path case does not need it, so a resolution failure // is not an error here). releaseOptions := worktrees.Options{} if cwdFlag != "" { workspaceRoot, err := resolveWorkspaceRoot(cwdFlag, deps) if err != nil { return writeExecUsageError(stderr, redactCLIString(err.Error())) } releaseOptions.Cwd = workspaceRoot } else if workspaceRoot, rootErr := resolveWorkspaceRoot("", deps); rootErr == nil { releaseOptions.Cwd = workspaceRoot } if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil { return writeExecUsageError(stderr, redactCLIString(err.Error())) } // Print the absolute path that was released so relative arguments still // show an unambiguous target in the confirmation line. if _, err := fmt.Fprintf(stdout, "released %s\n", redactCLIString(absPath)); err != nil { return exitCrash } return exitSuccess } func runVerifyCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { options, help, err := parseVerifyCommandArgs(args) if err != nil { return writeExecUsageError(stderr, err.Error()) } if help { if err := writeVerifyHelp(stdout); err != nil { return exitCrash } return exitSuccess } workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps) if err != nil { return writeExecUsageError(stderr, err.Error()) } plan, err := deps.detectVerifyPlan(workspaceRoot) if err != nil { return writeExecUsageError(stderr, err.Error()) } if options.attempts > 1 { loopReport := deps.runSelfVerify(context.Background(), plan, selfverify.Options{ RunOptions: verify.RunOptions{ Only: options.only, TimeoutMS: options.timeoutMS, Now: deps.now, }, MaxAttempts: options.attempts, }) safeLoopReport := redactVerifyLoopReport(loopReport) if options.json { if err := writePrettyJSON(stdout, selfverify.SnapshotFromReport(safeLoopReport)); err != nil { return exitCrash } } else if _, err := fmt.Fprintln(stdout, formatVerifyLoopReport(safeLoopReport)); err != nil { return exitCrash } if !loopReport.OK { return exitProvider } return exitSuccess } report := deps.runVerify(context.Background(), plan, verify.RunOptions{ Only: options.only, TimeoutMS: options.timeoutMS, Now: deps.now, }) safeReport := redactVerifyReport(report) if options.json { if err := writePrettyJSON(stdout, verify.SnapshotFromReport(safeReport)); err != nil { return exitCrash } } else if _, err := fmt.Fprintln(stdout, formatVerifyReport(safeReport)); err != nil { return exitCrash } if !report.OK { return exitProvider } return exitSuccess } func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { command := "inspect" if len(args) > 0 && !strings.HasPrefix(args[0], "-") { command = strings.ToLower(strings.TrimSpace(args[0])) args = args[1:] } if command == "help" { if err := writeChangesHelp(stdout); err != nil { return exitCrash } return exitSuccess } options, help, err := parseChangesArgs(args, command) if err != nil { return writeExecUsageError(stderr, err.Error()) } if help { if err := writeChangesHelp(stdout); err != nil { return exitCrash } return exitSuccess } workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps) if err != nil { return writeExecUsageError(stderr, err.Error()) } switch command { case "inspect", "status": summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{ Cwd: workspaceRoot, BaseRef: options.baseRef, MaxDiffBytes: options.maxDiffBytes, }) if err != nil { return writeExecUsageError(stderr, err.Error()) } safeSummary := redactChangeSummary(summary) if options.json { if err := writePrettyJSON(stdout, zerogit.SnapshotFromSummary(safeSummary)); err != nil { return exitCrash } return exitSuccess } if _, err := fmt.Fprintln(stdout, formatChangeSummary(safeSummary)); err != nil { return exitCrash } return exitSuccess case "commit": var message string if options.auto { summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{ Cwd: workspaceRoot, MaxDiffBytes: options.maxDiffBytes, }) if err != nil { return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err)) } if summary.Clean { return writeExecUsageError(stderr, "no changes to commit") } resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{}) if err != nil { return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err)) } if !config.HasProviderProfile(resolved.Provider) { return writeExecUsageError(stderr, "no provider configured for auto-commit message") } provider, err := deps.newProvider(resolved.Provider) if err != nil { return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err)) } if !options.json { if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil { return exitCrash } } safeSummary := redactChangeSummary(summary) genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary) if err != nil { return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err)) } message = msg } else { message = options.message } result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{ Cwd: workspaceRoot, Message: message, DryRun: options.dryRun, MaxDiffBytes: options.maxDiffBytes, }) if err != nil { return writeExecUsageError(stderr, err.Error()) } safeResult := redactCommitResult(result) if options.json { if err := writePrettyJSON(stdout, safeResult); err != nil { return exitCrash } return exitSuccess } if _, err := fmt.Fprintln(stdout, formatCommitResult(safeResult)); err != nil { return exitCrash } return exitSuccess case "push": return runChangesPush(args, stdout, stderr, deps) case "pr", "pull-request": return runChangesPR(args, stdout, stderr, deps) default: return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command)) } } func parseWorktreeCommandArgs(args []string) (worktreeCommandOptions, bool, error) { options := worktreeCommandOptions{} for index := 0; index < len(args); index++ { arg := args[index] switch { case arg == "-h" || arg == "--help" || arg == "help": return options, true, nil case arg == "--json": options.json = true case arg == "--name": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } if err := setWorktreeName(&options, value); err != nil { return options, false, err } index = next case strings.HasPrefix(arg, "--name="): if err := setWorktreeName(&options, strings.TrimPrefix(arg, "--name=")); err != nil { return options, false, err } case arg == "--dir": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.baseDir = value index = next case strings.HasPrefix(arg, "--dir="): options.baseDir = strings.TrimSpace(strings.TrimPrefix(arg, "--dir=")) case arg == "-C" || arg == "--cwd": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.cwd = value index = next case strings.HasPrefix(arg, "--cwd="): options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd=")) case strings.HasPrefix(arg, "-"): return options, false, execUsageError{fmt.Sprintf("unknown worktrees flag %q", arg)} default: if err := setWorktreeName(&options, arg); err != nil { return options, false, err } } } return options, false, nil } func setWorktreeName(options *worktreeCommandOptions, value string) error { name := strings.TrimSpace(value) if name == "" { return nil } if options.name != "" { return execUsageError{"worktree name was provided more than once"} } options.name = name return nil } func parseVerifyCommandArgs(args []string) (verifyCommandOptions, bool, error) { options := verifyCommandOptions{} for index := 0; index < len(args); index++ { arg := args[index] switch { case arg == "-h" || arg == "--help" || arg == "help": return options, true, nil case arg == "--json": options.json = true case arg == "-C" || arg == "--cwd": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.cwd = value index = next case strings.HasPrefix(arg, "--cwd="): options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd=")) case arg == "--only": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.only = append(options.only, parseToolList(value)...) index = next case strings.HasPrefix(arg, "--only="): options.only = append(options.only, parseToolList(strings.TrimSpace(strings.TrimPrefix(arg, "--only=")))...) case arg == "--timeout-ms": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } timeoutMS, err := parsePositiveIntFlag("--timeout-ms", value) if err != nil { return options, false, err } options.timeoutMS = timeoutMS index = next case strings.HasPrefix(arg, "--timeout-ms="): timeoutMS, err := parsePositiveIntFlag("--timeout-ms", strings.TrimSpace(strings.TrimPrefix(arg, "--timeout-ms="))) if err != nil { return options, false, err } options.timeoutMS = timeoutMS case arg == "--attempts": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } attempts, err := parsePositiveIntFlag("--attempts", value) if err != nil { return options, false, err } options.attempts = attempts index = next case strings.HasPrefix(arg, "--attempts="): attempts, err := parsePositiveIntFlag("--attempts", strings.TrimSpace(strings.TrimPrefix(arg, "--attempts="))) if err != nil { return options, false, err } options.attempts = attempts case strings.HasPrefix(arg, "-"): return options, false, execUsageError{fmt.Sprintf("unknown verify flag %q", arg)} default: return options, false, execUsageError{fmt.Sprintf("unexpected verify argument %q", arg)} } } return options, false, nil } func parseChangesArgs(args []string, command string) (changesCommandOptions, bool, error) { options := changesCommandOptions{} for index := 0; index < len(args); index++ { arg := args[index] switch { case arg == "-h" || arg == "--help" || arg == "help": return options, true, nil case arg == "--json": options.json = true case arg == "--dry-run": options.dryRun = true case arg == "-C" || arg == "--cwd": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.cwd = value index = next case strings.HasPrefix(arg, "--cwd="): options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd=")) case arg == "--base": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.baseRef = strings.TrimSpace(value) index = next case strings.HasPrefix(arg, "--base="): v := strings.TrimSpace(strings.TrimPrefix(arg, "--base=")) if v == "" || flagValueLooksLikeOption(v) { return options, false, execUsageError{"--base requires a value"} } options.baseRef = v case arg == "-m" || arg == "--message": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.message = value options.hasMessage = true index = next case strings.HasPrefix(arg, "--message="): options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message=")) options.hasMessage = true case arg == "--diff-bytes": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } maxDiffBytes, err := parsePositiveIntFlag("--diff-bytes", value) if err != nil { return options, false, err } options.maxDiffBytes = maxDiffBytes index = next case strings.HasPrefix(arg, "--diff-bytes="): maxDiffBytes, err := parsePositiveIntFlag("--diff-bytes", strings.TrimSpace(strings.TrimPrefix(arg, "--diff-bytes="))) if err != nil { return options, false, err } options.maxDiffBytes = maxDiffBytes case arg == "--remote": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.remote = strings.TrimSpace(value) index = next case strings.HasPrefix(arg, "--remote="): options.remote = strings.TrimSpace(strings.TrimPrefix(arg, "--remote=")) case arg == "--force": options.force = true case arg == "--title": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.title = value index = next case strings.HasPrefix(arg, "--title="): options.title = strings.TrimPrefix(arg, "--title=") case arg == "--body": value, next, err := nextFlagValue(args, index, arg) if err != nil { return options, false, err } options.body = value index = next case strings.HasPrefix(arg, "--body="): options.body = strings.TrimPrefix(arg, "--body=") case arg == "--fill": options.fill = true case arg == "--draft": options.draft = true case arg == "--yes": options.yes = true case arg == "-a" || arg == "--auto": options.auto = true case strings.HasPrefix(arg, "-"): return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)} default: return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)} } } if command != "commit" && options.message != "" { return options, false, execUsageError{"--message is only valid with `zero changes commit`"} } if command != "commit" && (options.hasMessage || options.dryRun || options.auto) { return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"} } if command == "commit" && options.hasMessage && options.auto { return options, false, execUsageError{"cannot specify both --message and --auto"} } if command != "commit" && command != "push" && options.dryRun { return options, false, execUsageError{"--dry-run is only valid with commit or push"} } if command != "inspect" && options.baseRef != "" { return options, false, execUsageError{"--base is only valid with `zero changes inspect`"} } if command != "push" && command != "pr" && (options.remote != "" || options.force) { return options, false, execUsageError{"--remote and --force are only valid with push or pr"} } if command != "pr" && (options.title != "" || options.body != "" || options.fill || options.draft) { return options, false, execUsageError{"--title, --body, --fill, and --draft are only valid with `zero changes pr`"} } if command != "push" && command != "pr" && options.yes { return options, false, execUsageError{"--yes is only valid with push or pr"} } return options, false, nil } func parsePositiveIntFlag(flag string, value string) (int, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return 0, execUsageError{flag + " requires a value"} } number, err := strconv.Atoi(trimmed) if err != nil || number <= 0 { return 0, execUsageError{fmt.Sprintf("invalid %s %q. Expected a positive integer.", flag, value)} } return number, nil } func redactWorktreeResult(result worktrees.Result) worktrees.Result { result.Name = redactCLIString(result.Name) result.Path = redactCLIString(result.Path) result.RepoRoot = redactCLIString(result.RepoRoot) result.SourceBranch = redactCLIString(result.SourceBranch) result.SourceCommit = redactCLIString(result.SourceCommit) return result } func redactVerifyReport(report verify.Report) verify.Report { report.Root = redactCLIString(report.Root) report.Results = append([]verify.Result{}, report.Results...) for index := range report.Results { report.Results[index].Stdout = redactCLIString(report.Results[index].Stdout) report.Results[index].Stderr = redactCLIString(report.Results[index].Stderr) report.Results[index].Error = redactCLIString(report.Results[index].Error) if report.Results[index].OutputSummary != nil { summary := *report.Results[index].OutputSummary summary.Lines = append([]string{}, summary.Lines...) for lineIndex := range summary.Lines { summary.Lines[lineIndex] = redactCLIString(summary.Lines[lineIndex]) } report.Results[index].OutputSummary = &summary } if report.Results[index].TestSummary != nil { summary := *report.Results[index].TestSummary summary.Failures = append([]testrunner.Failure{}, summary.Failures...) for failureIndex := range summary.Failures { summary.Failures[failureIndex].Name = redactCLIString(summary.Failures[failureIndex].Name) summary.Failures[failureIndex].File = redactCLIString(summary.Failures[failureIndex].File) summary.Failures[failureIndex].Message = redactCLIString(summary.Failures[failureIndex].Message) } report.Results[index].TestSummary = &summary } } return report } func redactVerifyLoopReport(report selfverify.Report) selfverify.Report { report.Root = redactCLIString(report.Root) report.Error = redactCLIString(report.Error) for index := range report.Attempts { report.Attempts[index].Report = redactVerifyReport(report.Attempts[index].Report) if report.Attempts[index].Remediation != nil { remediation := *report.Attempts[index].Remediation remediation.StartedAt = redactCLIString(remediation.StartedAt) remediation.EndedAt = redactCLIString(remediation.EndedAt) remediation.Message = redactCLIString(remediation.Message) remediation.Error = redactCLIString(remediation.Error) report.Attempts[index].Remediation = &remediation } } return report } func redactChangeSummary(summary zerogit.ChangeSummary) zerogit.ChangeSummary { summary.Root = redactCLIString(summary.Root) summary.Base = redactCLIString(summary.Base) summary.Branch = redactCLIString(summary.Branch) summary.Commit = redactCLIString(summary.Commit) summary.DiffStat = redactCLIString(summary.DiffStat) summary.Diff = redactCLIString(summary.Diff) for index := range summary.Files { summary.Files[index].Path = redactCLIString(summary.Files[index].Path) summary.Files[index].Status = redactCLIString(summary.Files[index].Status) } return summary } func redactCommitResult(result zerogit.CommitResult) zerogit.CommitResult { result.Root = redactCLIString(result.Root) result.Message = redactCLIString(result.Message) result.CommitHash = redactCLIString(result.CommitHash) result.Before = redactChangeSummary(result.Before) return result } func redactCLIString(value string) string { // Keep ordinary paths visible; these commands report useful locations. // Central redaction still removes secret-looking tokens embedded in paths. return redaction.RedactString(value, redaction.Options{}) } func formatWorktreeResult(result worktrees.Result) string { lines := []string{ "Zero worktree ready", "name: " + result.Name, "path: " + result.Path, "repo: " + result.RepoRoot, } if result.SourceBranch != "" { lines = append(lines, "branch: "+result.SourceBranch) } if result.SourceCommit != "" { lines = append(lines, "commit: "+result.SourceCommit) } if result.Reused { lines = append(lines, "reused: true") } return strings.Join(lines, "\n") } func formatVerifyReport(report verify.Report) string { lines := []string{ "Zero verification", "root: " + report.Root, fmt.Sprintf("summary: %d total, %d passed, %d failed, %d errors", report.Summary.Total, report.Summary.Passed, report.Summary.Failed, report.Summary.Errors), } if len(report.Results) == 0 { lines = append(lines, " (no checks detected)") return strings.Join(lines, "\n") } for _, result := range report.Results { lines = append(lines, fmt.Sprintf(" [%s] %s - %s", result.Status, result.ID, strings.Join(result.Command, " "))) if result.TestSummary != nil { lines = append(lines, formatVerifyTestSummary(result.TestSummary)) for _, failure := range result.TestSummary.Failures { if failure.Name == "" { continue } detail := failure.Name if failure.File != "" { detail += " at " + failure.File } lines = append(lines, " failure: "+detail) } } if result.Error != "" { lines = append(lines, " error: "+result.Error) } } return strings.Join(lines, "\n") } func formatVerifyTestSummary(summary *testrunner.Summary) string { line := fmt.Sprintf(" tests: %d total, %d passed, %d failed", summary.Total, summary.Passed, summary.Failed) if summary.Skipped > 0 { line += fmt.Sprintf(", %d skipped", summary.Skipped) } return line } func formatVerifyLoopReport(report selfverify.Report) string { lines := []string{ "Zero self-verification", } if report.Root != "" { lines = append(lines, "root: "+report.Root) } lines = append(lines, fmt.Sprintf("attempts: %d", len(report.Attempts)), "stop: "+string(report.StopReason), fmt.Sprintf("summary: %d total, %d passed, %d failed, %d errors", report.Summary.Total, report.Summary.Passed, report.Summary.Failed, report.Summary.Errors), ) for _, attempt := range report.Attempts { status := "failed" if attempt.Report.OK { status = "passed" } lines = append(lines, fmt.Sprintf(" attempt %d: %s", attempt.Number, status)) if attempt.Remediation != nil { lines = append(lines, " remediation: "+formatRemediation(*attempt.Remediation)) } } if report.Error != "" { lines = append(lines, "error: "+report.Error) } return strings.Join(lines, "\n") } func formatRemediation(remediation selfverify.Remediation) string { status := "not applied" if remediation.Applied { status = "applied" } details := []string{status} if remediation.Message != "" { details = append(details, remediation.Message) } if remediation.Error != "" { details = append(details, "error: "+remediation.Error) } return strings.Join(details, " - ") } func formatChangeSummary(summary zerogit.ChangeSummary) string { lines := []string{ "Zero changes", "root: " + summary.Root, fmt.Sprintf("files: %d changed", len(summary.Files)), } if summary.Branch != "" { lines = append(lines, "branch: "+summary.Branch) } if summary.Base != "" { lines = append(lines, "base: "+summary.Base) } if summary.Commit != "" { lines = append(lines, "commit: "+summary.Commit) } if summary.Clean { lines = append(lines, "clean: true") return strings.Join(lines, "\n") } for _, file := range summary.Files { lines = append(lines, fmt.Sprintf(" [%s] %s", file.Status, file.Path)) } return strings.Join(lines, "\n") } func formatCommitResult(result zerogit.CommitResult) string { lines := []string{ "Zero changes commit", "root: " + result.Root, "message: " + result.Message, fmt.Sprintf("dry-run: %t", result.DryRun), fmt.Sprintf("committed: %t", result.Committed), fmt.Sprintf("files: %d changed", len(result.Before.Files)), } if result.CommitHash != "" { lines = append(lines, "commit: "+result.CommitHash) } return strings.Join(lines, "\n") } func writeWorktreesHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero worktrees prepare [flags] [name] zero worktrees release [flags] Prepares an isolated git worktree for a Zero task. prepare locks the worktree it creates so Zero's automatic cleanup never removes it out from under you. release unlocks a worktree prepare created, once you are done with it, so cleanup can reclaim it later if it goes stale. If the worktree directory was deleted by hand, run release with -C pointing at the source repository so the orphaned lock can still be cleared. prepare flags: --name Worktree name; defaults to a timestamped task name --dir Base directory for Zero worktrees -C, --cwd Source repository directory --json Print JSON output -h, --help Show this help release flags: -C, --cwd Source repository directory; needed when the worktree directory was already deleted and you are not running inside the source repository -h, --help Show this help `) return err } func writeVerifyHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero verify [flags] Detects and runs local verification checks for the workspace. Flags: -C, --cwd Workspace directory --only Run only matching check ids --timeout-ms Per-check timeout in milliseconds --attempts Run a bounded self-verification loop --json Print JSON output -h, --help Show this help `) return err } func writeChangesHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero changes inspect [flags] zero changes commit [flags] zero changes push [flags] zero changes pr [flags] Inspects, commits, pushes, and creates pull requests for local git changes. Flags: -C, --cwd Workspace directory --base Diff against ...HEAD instead of the working tree --diff-bytes Maximum diff bytes to include -m, --message Commit message for `+"`zero changes commit`"+` --dry-run Preview commit metadata / push without mutating git state --remote Remote to push to (defaults to upstream tracked branch or origin) --force Use force-with-lease when pushing --title PR title --body PR body --fill Automatically populate PR title and body from commits --draft Create PR as a draft --yes Confirm pushing to a default/protected branch -a, --auto Auto-generate commit message using LLM (use --dry-run to preview) --dry-run Preview commit metadata without mutating git state --json Print JSON output -h, --help Show this help `) return err } func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { options, help, err := parseChangesArgs(args, "push") if err != nil { return writeExecUsageError(stderr, err.Error()) } if help { if err := writeChangesHelp(stdout); err != nil { return exitCrash } return exitSuccess } workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps) if err != nil { return writeExecUsageError(stderr, err.Error()) } result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{ Cwd: workspaceRoot, Remote: options.remote, Force: options.force, DryRun: options.dryRun, AllowPushDefaultBranch: options.yes, }) if err != nil { return writeExecUsageError(stderr, err.Error()) } if options.json { if err := writePrettyJSON(stdout, result); err != nil { return exitCrash } return exitSuccess } dryRunStr := "" if options.dryRun { dryRunStr = " (dry run)" } if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s%s\n", result.Branch, result.Remote, dryRunStr); err != nil { return exitCrash } if result.Output != "" { if _, err := fmt.Fprintln(stdout, result.Output); err != nil { return exitCrash } } return exitSuccess } func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { options, help, err := parseChangesArgs(args, "pr") if err != nil { return writeExecUsageError(stderr, err.Error()) } if help { if err := writeChangesHelp(stdout); err != nil { return exitCrash } return exitSuccess } if !options.fill && options.title == "" { return writeExecUsageError(stderr, "must provide either --fill or --title to run non-interactively") } workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps) if err != nil { return writeExecUsageError(stderr, err.Error()) } if !options.json { if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil { return exitCrash } } pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{ Cwd: workspaceRoot, Remote: options.remote, Force: options.force, AllowPushDefaultBranch: options.yes, }) if err != nil { return writeExecUsageError(stderr, fmt.Sprintf("auto-push failed: %v", err)) } if !options.json { if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s\n", pushResult.Branch, pushResult.Remote); err != nil { return exitCrash } } prResult, err := deps.createPR(context.Background(), zerogit.PROptions{ Cwd: workspaceRoot, Fill: options.fill, Draft: options.draft, Title: options.title, Body: options.body, }) if err != nil { return writeExecUsageError(stderr, err.Error()) } if options.json { type prJSONResult struct { Branch string `json:"branch"` Remote string `json:"remote"` Output string `json:"output"` } res := prJSONResult{ Branch: pushResult.Branch, Remote: pushResult.Remote, Output: strings.TrimSpace(prResult.Output), } if err := writePrettyJSON(stdout, res); err != nil { return exitCrash } return exitSuccess } if _, err := fmt.Fprintln(stdout, strings.TrimSpace(prResult.Output)); err != nil { return exitCrash } return exitSuccess } func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) { var promptBuilder strings.Builder promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n") promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n") promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n") promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n") promptBuilder.WriteString("Git Diff:\n") promptBuilder.WriteString(summary.Diff) request := zeroruntime.CompletionRequest{ Messages: []zeroruntime.Message{ {Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()}, }, } stream, err := provider.StreamCompletion(ctx, request) if err != nil { return "", err } collected := zeroruntime.CollectStream(ctx, stream) if collected.Error != "" { return "", fmt.Errorf("%s", collected.Error) } msg := strings.TrimSpace(collected.Text) if strings.HasPrefix(msg, "```") { if idx := strings.Index(msg, "\n"); idx != -1 { msg = msg[idx+1:] } } msg = strings.TrimSuffix(msg, "```") msg = strings.TrimSpace(msg) if msg == "" { return "", fmt.Errorf("provider returned empty commit message") } return msg, nil }