Files
zero/internal/cli/workflows.go
T
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

1007 lines
32 KiB
Go

package cli
import (
"context"
"fmt"
"io"
"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 != "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
}
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]
Prepares an isolated git worktree for a Zero task.
Flags:
--name <name> Worktree name; defaults to a timestamped task name
--dir <path> Base directory for Zero worktrees
-C, --cwd <path> Source repository directory
--json Print JSON output
-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 <path> Workspace directory
--only <ids> Run only matching check ids
--timeout-ms <n> Per-check timeout in milliseconds
--attempts <n> 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 <path> Workspace directory
--base <ref> Diff against <ref>...HEAD instead of the working tree
--diff-bytes <n> Maximum diff bytes to include
-m, --message <text> Commit message for `+"`zero changes commit`"+`
--dry-run Preview commit metadata / push without mutating git state
--remote <name> Remote to push to (defaults to upstream tracked branch or origin)
--force Use force-with-lease when pushing
--title <text> PR title
--body <text> 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
}