mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* feat: add --auto flag to changes commit for LLM-generated commit messages * fix: address CodeRabbit review suggestions on auto-commit message generation * fix: resolve auto-commit message and flag validation tests * fix: add 60s timeout for LLM call in --auto and surface --dry-run in help Addresses gnanam1990 non-blocking feedback: - Wrap provider call with context.WithTimeout (60s) to prevent hung provider - Note --dry-run in --auto help text as preview escape hatch
806 lines
28 KiB
Go
806 lines
28 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
func TestRunWorktreesPrepareTextAndJSON(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
base := t.TempDir()
|
|
prepared := worktrees.Result{
|
|
Name: "agent-task",
|
|
Path: filepath.Join(base, "agent-task"),
|
|
RepoRoot: cwd,
|
|
SourceBranch: "main",
|
|
SourceCommit: "abc1234",
|
|
}
|
|
|
|
for _, args := range [][]string{
|
|
{"worktrees", "prepare", "--name", "agent-task", "--dir", base},
|
|
{"worktrees", "prepare", "--name=agent-task", "--dir=" + base, "--json"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) {
|
|
if options.Cwd != cwd || options.Name != "agent-task" || options.BaseDir != base {
|
|
t.Fatalf("unexpected worktree options: %#v", options)
|
|
}
|
|
return prepared, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if strings.Contains(strings.Join(args, " "), "--json") {
|
|
var decoded worktrees.Result
|
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
|
t.Fatalf("decode worktree JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if decoded.Path != prepared.Path || decoded.Name != prepared.Name {
|
|
t.Fatalf("unexpected JSON result: %#v", decoded)
|
|
}
|
|
} else if !strings.Contains(stdout.String(), "Zero worktree ready") || !strings.Contains(stdout.String(), prepared.Path) {
|
|
t.Fatalf("unexpected worktree text output: %q", stdout.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunWorktreesPrepareReportsErrors(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"worktrees", "prepare", "--name", "bad"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
prepareWorktree: func(context.Context, worktrees.Options) (worktrees.Result, error) {
|
|
return worktrees.Result{}, errors.New("not a git repository")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "not a git repository") {
|
|
t.Fatalf("expected worktree error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunWorktreesPrepareRedactsPathsInOutput(t *testing.T) {
|
|
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
|
cwd := filepath.Join(t.TempDir(), secret, "repo")
|
|
if err := os.MkdirAll(cwd, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prepared := worktrees.Result{
|
|
Name: "agent-task",
|
|
Path: filepath.Join(t.TempDir(), secret, "agent-task"),
|
|
RepoRoot: cwd,
|
|
SourceBranch: "feature/" + secret,
|
|
SourceCommit: "abc1234",
|
|
}
|
|
|
|
for _, args := range [][]string{
|
|
{"worktrees", "prepare", "--name", "agent-task"},
|
|
{"worktrees", "prepare", "--name", "agent-task", "--json"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
prepareWorktree: func(context.Context, worktrees.Options) (worktrees.Result, error) {
|
|
return prepared, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), secret) {
|
|
t.Fatalf("worktree output leaked secret path segment: %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), redaction.RedactedSecret) {
|
|
t.Fatalf("expected redaction marker in worktree output, got %q", stdout.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunWorktreesPrepareRejectsDuplicateNames(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"worktrees", "prepare", "--name", "first", "second"}, &stdout, &stderr, appDeps{
|
|
prepareWorktree: func(context.Context, worktrees.Options) (worktrees.Result, error) {
|
|
t.Fatal("prepareWorktree should not be called for invalid flags")
|
|
return worktrees.Result{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "worktree name was provided more than once") {
|
|
t.Fatalf("expected duplicate name error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunVerifyTextAndJSON(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
plan := verify.Plan{Root: cwd, Checks: []verify.Check{{ID: "go.test", Name: "Go tests", Command: []string{"go", "test", "./..."}}}}
|
|
report := verify.Report{
|
|
Root: cwd,
|
|
StartedAt: "2026-06-05T11:00:00Z",
|
|
EndedAt: "2026-06-05T11:00:01Z",
|
|
OK: true,
|
|
Summary: verify.Summary{Total: 1, Passed: 1},
|
|
Results: []verify.Result{{
|
|
ID: "go.test",
|
|
Name: "Go tests",
|
|
Command: []string{"go", "test", "./..."},
|
|
Status: verify.StatusPass,
|
|
ExitCode: 0,
|
|
Stdout: "ok",
|
|
TestSummary: &testrunner.Summary{
|
|
Framework: testrunner.FrameworkGo,
|
|
Total: 2,
|
|
Passed: 1,
|
|
Failed: 1,
|
|
Failures: []testrunner.Failure{{Name: "TestBroken"}},
|
|
},
|
|
}},
|
|
}
|
|
|
|
for _, args := range [][]string{
|
|
{"verify"},
|
|
{"verify", "--json", "--only", "go.test"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
detectVerifyPlan: func(root string) (verify.Plan, error) {
|
|
if root != cwd {
|
|
t.Fatalf("verify root = %q, want %q", root, cwd)
|
|
}
|
|
return plan, nil
|
|
},
|
|
runVerify: func(ctx context.Context, gotPlan verify.Plan, options verify.RunOptions) verify.Report {
|
|
if gotPlan.Root != cwd {
|
|
t.Fatalf("plan root = %q, want %q", gotPlan.Root, cwd)
|
|
}
|
|
if strings.Contains(strings.Join(args, " "), "--only") && (len(options.Only) != 1 || options.Only[0] != "go.test") {
|
|
t.Fatalf("Only = %#v, want go.test", options.Only)
|
|
}
|
|
return report
|
|
},
|
|
now: fixedCLITime("2026-06-05T11:00:00Z"),
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if strings.Contains(strings.Join(args, " "), "--json") {
|
|
var decoded verify.Report
|
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
|
t.Fatalf("decode verify JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if !decoded.OK || decoded.Summary.Passed != 1 {
|
|
t.Fatalf("unexpected verify JSON: %#v", decoded)
|
|
}
|
|
if decoded.Root != cwd {
|
|
t.Fatalf("decoded verify root = %q, want %q", decoded.Root, cwd)
|
|
}
|
|
var snapshot verify.ReportSnapshot
|
|
if err := json.Unmarshal(stdout.Bytes(), &snapshot); err != nil {
|
|
t.Fatalf("decode verify snapshot JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if snapshot.Contract != verify.ReportContractVersion || len(snapshot.Events) == 0 {
|
|
t.Fatalf("verify JSON did not expose runtime contract: %#v", snapshot)
|
|
}
|
|
} else if !strings.Contains(stdout.String(), "Zero verification") || !strings.Contains(stdout.String(), "go.test") || !strings.Contains(stdout.String(), cwd) || !strings.Contains(stdout.String(), "tests: 2 total, 1 passed, 1 failed") {
|
|
t.Fatalf("unexpected verify text output: %q", stdout.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunVerifyRedactsWorkspacePathsInOutput(t *testing.T) {
|
|
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
|
cwd := filepath.Join(t.TempDir(), secret, "workspace")
|
|
if err := os.MkdirAll(cwd, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan := verify.Plan{Root: cwd}
|
|
report := verify.Report{
|
|
Root: cwd,
|
|
StartedAt: "2026-06-05T11:05:00Z",
|
|
EndedAt: "2026-06-05T11:05:01Z",
|
|
OK: true,
|
|
Summary: verify.Summary{},
|
|
Results: []verify.Result{{
|
|
ID: "go.test",
|
|
Name: "Go tests",
|
|
Command: []string{"go", "test", "./..."},
|
|
Status: verify.StatusFail,
|
|
OutputSummary: &verify.OutputSummary{Lines: []string{"failure at " + secret}},
|
|
TestSummary: &testrunner.Summary{
|
|
Framework: testrunner.FrameworkGo,
|
|
Total: 1,
|
|
Failed: 1,
|
|
Failures: []testrunner.Failure{{
|
|
Name: secret,
|
|
File: filepath.Join(secret, "secret_test.go:12"),
|
|
Message: "token " + secret,
|
|
}},
|
|
},
|
|
}},
|
|
}
|
|
|
|
for _, args := range [][]string{
|
|
{"verify"},
|
|
{"verify", "--json"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
detectVerifyPlan: func(root string) (verify.Plan, error) {
|
|
if root != cwd {
|
|
t.Fatalf("verify root = %q, want %q", root, cwd)
|
|
}
|
|
return plan, nil
|
|
},
|
|
runVerify: func(context.Context, verify.Plan, verify.RunOptions) verify.Report {
|
|
return report
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), secret) {
|
|
t.Fatalf("verify output leaked secret path segment: %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), redaction.RedactedSecret) {
|
|
t.Fatalf("expected redaction marker in verify output, got %q", stdout.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunVerifyReturnsProviderExitWhenChecksFail(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
report := verify.Report{
|
|
Root: cwd,
|
|
OK: false,
|
|
Summary: verify.Summary{Total: 1, Failed: 1},
|
|
Results: []verify.Result{{
|
|
ID: "bun.test",
|
|
Name: "Bun tests",
|
|
Command: []string{"bun", "test"},
|
|
Status: verify.StatusFail,
|
|
ExitCode: 1,
|
|
}},
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"verify"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
detectVerifyPlan: func(string) (verify.Plan, error) {
|
|
return verify.Plan{Root: cwd, Checks: []verify.Check{{ID: "bun.test", Name: "Bun tests", Command: []string{"bun", "test"}}}}, nil
|
|
},
|
|
runVerify: func(context.Context, verify.Plan, verify.RunOptions) verify.Report { return report },
|
|
})
|
|
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("expected provider-style failure exit %d, got %d", exitProvider, exitCode)
|
|
}
|
|
if !strings.Contains(stdout.String(), "failed") {
|
|
t.Fatalf("expected failure summary in stdout, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunVerifyAttemptsUsesSelfVerifyLoop(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
plan := verify.Plan{Root: cwd, Checks: []verify.Check{{ID: "go.test", Name: "Go tests", Command: []string{"go", "test", "./..."}}}}
|
|
loopReport := selfverify.Report{
|
|
Root: cwd,
|
|
OK: true,
|
|
StopReason: selfverify.StopReasonPassed,
|
|
Attempts: []selfverify.Attempt{
|
|
{Number: 1, Report: verify.Report{Root: cwd, OK: false, Summary: verify.Summary{Total: 1, Failed: 1}}},
|
|
{Number: 2, Report: verify.Report{Root: cwd, OK: true, Summary: verify.Summary{Total: 1, Passed: 1}}},
|
|
},
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"verify", "--attempts", "2", "--json"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
detectVerifyPlan: func(root string) (verify.Plan, error) {
|
|
if root != cwd {
|
|
t.Fatalf("verify root = %q, want %q", root, cwd)
|
|
}
|
|
return plan, nil
|
|
},
|
|
runSelfVerify: func(ctx context.Context, gotPlan verify.Plan, options selfverify.Options) selfverify.Report {
|
|
if options.MaxAttempts != 2 {
|
|
t.Fatalf("MaxAttempts = %d, want 2", options.MaxAttempts)
|
|
}
|
|
return loopReport
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
var decoded selfverify.Report
|
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
|
t.Fatalf("decode verify loop JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if len(decoded.Attempts) != 2 || !decoded.OK || decoded.StopReason != selfverify.StopReasonPassed {
|
|
t.Fatalf("unexpected loop JSON: %#v", decoded)
|
|
}
|
|
var snapshot selfverify.LoopSnapshot
|
|
if err := json.Unmarshal(stdout.Bytes(), &snapshot); err != nil {
|
|
t.Fatalf("decode verify loop snapshot JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if snapshot.Contract != selfverify.LoopContractVersion || len(snapshot.Events) == 0 {
|
|
t.Fatalf("verify loop JSON did not expose runtime contract: %#v", snapshot)
|
|
}
|
|
}
|
|
|
|
func TestRunVerifyAttemptsFormatsSelfVerifyText(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
plan := verify.Plan{Root: cwd, Checks: []verify.Check{{ID: "go.test", Name: "Go tests", Command: []string{"go", "test", "./..."}}}}
|
|
loopReport := selfverify.Report{
|
|
Root: cwd,
|
|
OK: true,
|
|
StopReason: selfverify.StopReasonPassed,
|
|
Summary: verify.Summary{Total: 1, Passed: 1},
|
|
Attempts: []selfverify.Attempt{
|
|
{
|
|
Number: 1,
|
|
Report: verify.Report{Root: cwd, OK: false, Summary: verify.Summary{Total: 1, Failed: 1}},
|
|
Remediation: &selfverify.Remediation{Applied: true, Message: "prepared retry"},
|
|
},
|
|
{Number: 2, Report: verify.Report{Root: cwd, OK: true, Summary: verify.Summary{Total: 1, Passed: 1}}},
|
|
},
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"verify", "--attempts=2"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
detectVerifyPlan: func(string) (verify.Plan, error) {
|
|
return plan, nil
|
|
},
|
|
runSelfVerify: func(context.Context, verify.Plan, selfverify.Options) selfverify.Report {
|
|
return loopReport
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
output := stdout.String()
|
|
for _, want := range []string{"Zero self-verification", "root: " + cwd, "stop: passed", "attempt 1: failed", "remediation: applied - prepared retry", "attempt 2: passed"} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("expected %q in output: %q", want, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunChangesInspectAndCommit(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
summary := zerogit.ChangeSummary{
|
|
Root: cwd,
|
|
Branch: "main",
|
|
Commit: "abc1234",
|
|
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified", Unstaged: true}},
|
|
}
|
|
commit := zerogit.CommitResult{
|
|
Root: cwd,
|
|
Message: "Update README",
|
|
DryRun: true,
|
|
Committed: false,
|
|
Before: summary,
|
|
CommitHash: "",
|
|
}
|
|
|
|
t.Run("inspect json", func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"changes", "inspect", "--json"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
|
if options.Cwd != cwd {
|
|
t.Fatalf("inspect cwd = %q, want %q", options.Cwd, cwd)
|
|
}
|
|
return summary, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
var decoded zerogit.ChangeSummary
|
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
|
t.Fatalf("decode changes JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if len(decoded.Files) != 1 || decoded.Files[0].Path != "README.md" {
|
|
t.Fatalf("unexpected changes JSON: %#v", decoded)
|
|
}
|
|
var snapshot zerogit.ChangeSnapshot
|
|
if err := json.Unmarshal(stdout.Bytes(), &snapshot); err != nil {
|
|
t.Fatalf("decode changes snapshot JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if snapshot.Contract != zerogit.ChangeContractVersion || len(snapshot.Events) == 0 {
|
|
t.Fatalf("changes JSON did not expose runtime contract: %#v", snapshot)
|
|
}
|
|
})
|
|
|
|
t.Run("commit dry-run", func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"changes", "commit", "--message", "Update README", "--dry-run"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
|
|
if options.Cwd != cwd || options.Message != "Update README" || !options.DryRun {
|
|
t.Fatalf("unexpected commit options: %#v", options)
|
|
}
|
|
return commit, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Zero changes commit") || !strings.Contains(stdout.String(), "dry-run: true") {
|
|
t.Fatalf("unexpected changes commit output: %q", stdout.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunChangesInspectThreadsBaseRef(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
summary := zerogit.ChangeSummary{
|
|
Root: cwd,
|
|
Branch: "feature",
|
|
Base: "main",
|
|
Files: []zerogit.FileChange{{Path: "feature.md", Status: "added"}},
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"changes", "inspect", "--base", "main"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
|
if options.BaseRef != "main" {
|
|
t.Fatalf("InspectOptions.BaseRef = %q, want main", options.BaseRef)
|
|
}
|
|
return summary, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "base: main") {
|
|
t.Fatalf("expected base line in output, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunChangesCommitRejectsBaseRef(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"changes", "commit", "--base", "main"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
commitChanges: func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error) {
|
|
t.Fatal("commitChanges should not be called when --base is rejected")
|
|
return zerogit.CommitResult{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--base") {
|
|
t.Fatalf("expected --base rejection error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestWriteChangesHelpMentionsBase(t *testing.T) {
|
|
var out bytes.Buffer
|
|
if err := writeChangesHelp(&out); err != nil {
|
|
t.Fatalf("writeChangesHelp error: %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), "--base") {
|
|
t.Fatalf("expected --base in changes help, got %q", out.String())
|
|
}
|
|
}
|
|
|
|
func TestParseChangesArgsBaseRef(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"--base", "main"},
|
|
{"--base=main"},
|
|
} {
|
|
options, help, err := parseChangesArgs(args, "inspect")
|
|
if err != nil {
|
|
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
|
|
}
|
|
if help {
|
|
t.Fatalf("parseChangesArgs(%v) returned help", args)
|
|
}
|
|
if options.baseRef != "main" {
|
|
t.Fatalf("baseRef = %q, want main (args %v)", options.baseRef, args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseChangesArgsRejectsBaseOnCommit(t *testing.T) {
|
|
_, _, err := parseChangesArgs([]string{"--base", "main"}, "commit")
|
|
if err == nil || !strings.Contains(err.Error(), "--base") {
|
|
t.Fatalf("expected --base rejection on commit, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseChangesArgsRequiresBaseValue(t *testing.T) {
|
|
if _, _, err := parseChangesArgs([]string{"--base"}, "inspect"); err == nil {
|
|
t.Fatalf("expected error when --base has no value")
|
|
}
|
|
}
|
|
|
|
func TestRunExecWorktreeUsesPreparedWorkspace(t *testing.T) {
|
|
root := t.TempDir()
|
|
worktreeDir := t.TempDir()
|
|
var resolvedWorkspace string
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return root, nil },
|
|
prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) {
|
|
if options.Cwd != root || options.Name != "task-a" {
|
|
t.Fatalf("unexpected worktree options: %#v", options)
|
|
}
|
|
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234"}, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
resolvedWorkspace = workspaceRoot
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if resolvedWorkspace != worktreeDir {
|
|
t.Fatalf("resolved workspace = %q, want worktree %q", resolvedWorkspace, worktreeDir)
|
|
}
|
|
if !strings.Contains(stdout.String(), "hello") {
|
|
t.Fatalf("expected provider output, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsForkWithWorktree(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"exec", "--worktree", "--fork", "zero_parent", "hello"}, &stdout, &stderr, appDeps{
|
|
prepareWorktree: func(context.Context, worktrees.Options) (worktrees.Result, error) {
|
|
t.Fatal("prepareWorktree should not be called for invalid flags")
|
|
return worktrees.Result{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--fork cannot be used with --worktree") {
|
|
t.Fatalf("expected flag conflict error, got %q", stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"exec", "--worktree-dir", "/tmp/zero", "hello"}, &stdout, &stderr, appDeps{
|
|
prepareWorktree: func(context.Context, worktrees.Options) (worktrees.Result, error) {
|
|
t.Fatal("prepareWorktree should not be called for invalid flags")
|
|
return worktrees.Result{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--worktree-dir requires --worktree") {
|
|
t.Fatalf("expected worktree-dir dependency error, got %q", stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestParseChangesArgsAuto(t *testing.T) {
|
|
// 1. Correct parsing of auto
|
|
for _, args := range [][]string{
|
|
{"--auto"},
|
|
{"-a"},
|
|
} {
|
|
options, help, err := parseChangesArgs(args, "commit")
|
|
if err != nil {
|
|
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
|
|
}
|
|
if help {
|
|
t.Fatalf("parseChangesArgs(%v) returned help", args)
|
|
}
|
|
if !options.auto {
|
|
t.Fatalf("auto = false, want true (args %v)", args)
|
|
}
|
|
}
|
|
|
|
// 2. Reject --auto on inspect
|
|
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
|
|
if err == nil || !strings.Contains(err.Error(), "--auto") {
|
|
t.Fatalf("expected --auto rejection on inspect, got %v", err)
|
|
}
|
|
|
|
// 3. Reject both --message and --auto on commit
|
|
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
|
|
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
|
t.Fatalf("expected --message and --auto conflict error, got %v", err)
|
|
}
|
|
|
|
// 4. Reject both empty message and --auto on commit
|
|
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
|
|
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
|
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
|
|
}
|
|
}
|
|
|
|
type mockCommitMsgProvider struct {
|
|
response string
|
|
req zeroruntime.CompletionRequest
|
|
}
|
|
|
|
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
p.req = request
|
|
events := make(chan zeroruntime.StreamEvent, 2)
|
|
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
|
|
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(events)
|
|
return events, nil
|
|
}
|
|
|
|
func TestRunChangesCommitAuto(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
summary := zerogit.ChangeSummary{
|
|
Root: cwd,
|
|
Branch: "main",
|
|
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
|
|
Diff: "some diff content with ghp_SECRETKEYHERE",
|
|
}
|
|
|
|
mockProv := &mockCommitMsgProvider{
|
|
response: "```\nfeat: auto commit message\n```",
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
commitCalled := false
|
|
|
|
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
|
return summary, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
cfg := execResolvedConfig()
|
|
cfg.Provider.Name = "openai"
|
|
cfg.Provider.Model = "gpt-4o"
|
|
return cfg, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return mockProv, nil
|
|
},
|
|
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
|
|
commitCalled = true
|
|
if options.Message != "feat: auto commit message" {
|
|
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
|
|
}
|
|
return zerogit.CommitResult{
|
|
Root: cwd,
|
|
Message: options.Message,
|
|
Committed: true,
|
|
CommitHash: "abc1234",
|
|
Before: summary,
|
|
}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !commitCalled {
|
|
t.Fatal("expected commitChanges to be called")
|
|
}
|
|
// Verify that secret in the diff was redacted
|
|
promptContent := mockProv.req.Messages[0].Content
|
|
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
|
|
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
|
|
}
|
|
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
|
|
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
|
|
}
|
|
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
|
|
t.Fatalf("expected status message in stdout, got %q", stdout.String())
|
|
}
|
|
|
|
t.Run("EmptyLLMResponse", func(t *testing.T) {
|
|
mockProvEmpty := &mockCommitMsgProvider{
|
|
response: " \n\n ",
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
|
return summary, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return mockProvEmpty, nil
|
|
},
|
|
})
|
|
if code == exitSuccess {
|
|
t.Fatal("expected command to fail when LLM returns empty response, got success")
|
|
}
|
|
if !strings.Contains(stderr.String(), "empty commit message") {
|
|
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
|
|
}
|
|
})
|
|
}
|