Files
zero/internal/cli/workflow_test.go
d065467cd3 feat(zerogit): auto-create a conventional branch before push/pr on default branch (#671)
* feat(zerogit): auto-create a conventional branch before push/pr on default branch

zero changes push/pr refused to push straight to the default branch but
never offered an alternative, unlike other agent tooling that names and
checks out a feature branch automatically. Add CreateBranch, IsDefaultBranch,
CurrentGitUser, and branch-name slugify/build helpers to zerogit, and wire
push/pr to auto-create a "<user>/<slug>" branch (slug from an LLM when a
provider is configured, otherwise a deterministic fallback from the diff)
whenever the current branch is the default one and --yes/--dry-run weren't
passed.

* fix(zerogit): normalize LLM branch slugs, bound remote lookup, handle existing branches

Address CodeRabbit's review on the auto-branch-naming PR:

- generateAutoBranchSlug now takes the first non-empty, quote-trimmed
  line of the model's response before slugifying, instead of folding
  any preamble or wrapping quotes into the branch name.
- isDefaultBranch's ls-remote lookup is now bounded by a 5s timeout so
  a slow or unreachable remote can't stall push/pr; falls back to the
  local main/master heuristic as before.
- CreateBranch checks whether the target branch already exists locally
  and checks it out instead of failing checkout -b on the collision.

Also add regression coverage: CurrentGitUser's OS-username fallback
tier, CreateBranch's existing-branch and generic checkout-failure
paths, a CLI-level test for changes pr (previously untested) mirroring
the existing changes push coverage, and a messy-LLM-response case for
the slug normalization fix.

* fix(zerogit,cli): make auto-branching collision-safe, remote-aware, fail-closed, and opt-in for LLM naming

- CreateBranch never checks out an existing branch on a name collision: it
  picks a unique suffixed name off the current HEAD (name-2 .. name-9) and
  fails visibly when the namespace is exhausted, so a stale branch with
  unrelated history can no longer be published while the new commit stays
  behind on the default branch.
- ensureFeatureBranch resolves the remote once (explicit --remote, then the
  original branch's configured upstream, then origin) via IsDefaultBranch,
  which now reports the resolved remote, and push/pr thread that remote into
  Push, so a freshly created branch without tracking configuration no longer
  falls back to origin in fork setups.
- The default-branch check fails closed: main and master count as default
  with no network, an unreachable remote falls back to the local
  refs/remotes/<remote>/HEAD record, and when neither answers the push is
  refused with guidance instead of silently dropping the confirmation guard
  for repositories whose default is trunk or develop.
- LLM branch naming is now opt-in via --auto on push/pr, matching commit
  --auto: a configured provider alone no longer causes the change diff to be
  uploaded during ordinary git-only pushes.
- After the ordinary commit-then-push sequence the tree is clean, so the
  fallback name comes from the HEAD commit subject instead of the constant
  user/changes.

* style(zerogit): align fake-runner result comments to gofmt output

* test(zerogit): cover Push's fail-closed path when the default branch is unknown

* fix(zerogit): keep a stale cached remote HEAD from clearing the push guard

When the live ls-remote default-branch lookup fails, isDefaultBranch fell
back to the local refs/remotes/<remote>/HEAD cache and returned
branch == cachedName. That cache is only a hint: if the server renamed its
default (main -> trunk) the record still names main, so checking whether
pushing trunk is safe returned false and the newly protected branch was
pushed without --yes.

Trust the cache only to block a push (a positive match proves the branch is
the recorded default); on a mismatch fall through to the existing
fail-closed unknown-default error instead of treating the branch as
unprotected.

* fix(cli): honor --diff-bytes when inspecting the diff for auto-branch naming

changes push/pr accept and document --diff-bytes, but ensureFeatureBranch
inspected the working tree with only Cwd set. With --auto the resulting
unbounded diff was embedded in the branch-name request to the provider, so a
user who capped the diff to limit how much proprietary source is uploaded
for naming still sent the complete diff.

Thread options.maxDiffBytes through ensureFeatureBranch into InspectOptions,
matching how the commit path already bounds the diff.

* fix(cli): refuse an auto-branch push when HEAD has nothing to publish

The auto-branch path decided to branch solely from working-tree status and
never checked that HEAD was ahead of the default branch. On a clean,
up-to-date default branch it would create and push user/<subject> at the
exact default tip; with only uncommitted edits it named a branch from those
edits but pushed the unchanged HEAD, leaving the edits local, and changes pr
then abandoned the empty branch before the host rejected the comparison.

Add zerogit.CommitsAhead and consult it before branching: when HEAD is
provably not ahead of <remote>/<default>, report "no changes to publish"
instead of creating the branch. An indeterminate count (for example a
remote-tracking ref that was never fetched) is treated as "cannot tell" so a
legitimate first push is not blocked.

* fix(cli): recover the branch slug from preamble or fenced LLM replies

The slug helper returned the first non-empty line verbatim, so a reply like
"Here is a suggested branch name:\nadd-login-page" produced
user/here-is-a-suggested-branch-name, and a reply wrapped in a code fence
slugified the ``` line to nothing and silently fell back to the local name.

Drop Markdown code-fence lines and prefer a line that already reads as a
kebab-case slug, falling back to the first quoted/plain line only when no
slug-shaped line exists. Cover preamble and fenced responses with tests.

* fix(zerogit): harden auto-branch remote handling

Three review findings:

- isDefaultBranch passed the remote to ls-remote before the HEAD
  positional with no --, so a remote value shaped like an option
  (--upload-pack=/bin/echo) was parsed as one. Terminate options with --
  and cover a dash-prefixed remote.

- An unborn remote (freshly created, zero refs) answered ls-remote with
  empty output, fell through to the fail-closed unknown-default error,
  and made the very first feature-branch push a --yes dead end that
  git remote set-head --auto cannot repair. ls-remote succeeding with no
  refs now counts as proof there is no protected default; main/master
  stay guarded by the name heuristic and every failure path stays
  fail-closed.

- CreateBranch probed only local refs/heads for collisions, so a branch
  existing only on the target remote (an old merged-PR branch) was
  silently fast-forwarded by the later push -u. The target remote's
  heads are now probed once (bounded, with --) and count as taken;
  an unreachable remote fails visibly before anything is created.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(zerogit): close branch-naming and push safety gaps from review

Require a confirmed unborn HEAD, not just an empty ls-remote --symref,
before granting the first-push exception, since a dangling remote
HEAD looked the same. Resolve branch naming and ahead-count checks
against the remote branch instead of a working-tree snapshot, which
could describe uncommitted edits a commit-only push would never
publish. Guard concurrent branch creation with a force-with-lease
push. Scope the ls-remote timeout to the default-branch preflight
only, so it no longer caps Push's own unrelated check.

* fix(cli): require a clean tree before auto-branch push/pr

Refuse ensureFeatureBranch when the working tree is dirty or the
ahead count against the remote default cannot be determined, so a
default-branch push cannot leave uncommitted edits behind or publish
an empty comparison under a guessed name.

* fix(zerogit): drop fixed five-second remote lookup timeout

IsDefaultBranch and CreateBranch applied a hard 5s bound on ls-remote,
so ordinary feature-branch pushes and the collision probe failed on slow
but reachable remotes (SSH/VPN) before Push could run. Honor the
caller's context instead; callers that need a bound pass a deadline.

* fix(cli): bypass the ahead-count and diff-base checks on a confirmed-unborn remote

jatmn flagged that the confirmed-unborn-remote exception this PR added to
IsDefaultBranch never actually helps the main/master case it was written
for: when the local branch is named main, isDefaultBranch returns true from
the name heuristic before it ever consults the remote, so
ensureFeatureBranch still unconditionally calls commitsAhead against
<remote>/<branch>. On a genuinely empty remote that ref cannot exist, so
`zero changes push`/`pr` from a local default branch against a brand-new
empty remote still dead-ended with "cannot determine whether HEAD is
ahead of origin/main" - the exact bug this PR set out to fix.

Tracing the flow further, the same problem also hit the diff-base Inspect
call right after: it uses the same nonexistent <remote>/<branch> ref, so
bypassing only commitsAhead would have left the dead end one line later.

ensureFeatureBranch now probes the new zerogit.IsUnbornRemote (ls-remote
--heads on the remote) whenever commitsAhead fails, and only bypasses both
checks when that probe positively confirms the remote has no refs at all.
An unreachable remote, or one that answers with refs, still fails closed
exactly as before - the fail-closed "unknown default branch" behavior is
unchanged and covered by TestEnsureFeatureBranchFailsWhenAheadCountUnknown
and the new TestEnsureFeatureBranchFailsWhenUnbornCheckErrors.

TestEnsureFeatureBranchCreatesBranchOnConfirmedUnbornRemote is the
regression test: confirmed failing (with the exact dead-end error) against
the pre-fix logic, passing after.

* fix(zerogit): resolve main/master against the live remote default first

isDefaultBranch short-circuited on the literal branch names main/master
before ever consulting the remote. A repository whose remote default is
genuinely something else (e.g. trunk) could still have a local main branch
tracking that remote, and the shortcut wrongly treated it as protected
instead of trusting the live symref result. The conventional-name check
now only applies as a last-resort fallback, after both the live ls-remote
symref lookup and the local remotes/<remote>/HEAD cache fail to answer.

* fix(cli): preserve push lease on retry and check the live default ref

Two related gaps in ensureFeatureBranch's auto-branch preflight:

- When an auto-branch push loses a force-with-lease race against a
  concurrent creator, the generated branch is left checked out locally
  with no successful push behind it. A retry took the "already off the
  default branch" early return and dropped RequireNewRemoteBranch,
  letting the next push silently fast-forward whatever the concurrent
  creator published. The lease now stays required unless the branch
  already has a configured upstream (proof a push already landed).

- The ahead-of-default check compared HEAD against the local
  remote-tracking ref, which is only refreshed at clone or the last
  fetch. A stale ref can under-report how far ahead HEAD is, or say
  zero when the remote has actually moved past it, so ensureFeatureBranch
  now refreshes that tracking ref before trusting it, and fails closed
  if the refresh itself cannot be confirmed.

Adds regression coverage for both: a retry after a lease collision keeps
requiring the lease (and an already-published branch does not), and a
stale tracking ref is refreshed before the ahead check runs (with a
fail-closed case when that refresh cannot be confirmed).

* fix(cli): track generated branches, reject PR on unborn remote, classify main on unborn

* fix(zerogit,cli): preserve explicit nonexistence lease on force, propagate marker write errors, check unborn remote before branch switch

* fix(zerogit): preserve non-existence lease on target remote and roll back failed branch marking

Compare target remote against configured upstream remote to retain non-existence lease on target, roll back created branch if marking fails, and allow --yes to bypass PR default-branch preflight.

Refs #671

* fix(cli): address review findings for auto feature branch creation

* fix(cli): refine LLM preamble candidate slug extraction and enforce unborn-remote guard

* fix(cli): restore default branch after auto-branch and resolve --yes upstream

Do not leave auto-branch commits on the protected local default branch,
and resolve the real upstream for the unborn-remote guard under --yes
instead of always probing origin.

* test(cli): assert auto-branch passes capped diff into the provider

Thread --auto and a mock provider through DiffBytes coverage so the
cap is checked on the real LLM request path, not only Inspect.

* fix(cli): block auto-branch on unborn remotes and fix lease/restore

Refuse auto-creating a feature branch when the target remote has no refs yet.
Publishing only user/slug leaves remote HEAD dangling, so the next changes pr
fails closed and a later main push has no PR diff. Require push --yes first.

Keep the nonexistence lease unless branch@{upstream} is exactly the target
remote/generated-branch ref from a successful push -u. Inherited origin/main
from branch.autoSetupMerge=inherit must not drop the lease.

Restore the original default branch against its own upstream tip, not the
push destination, so --remote upstream does not rewrite a fork main that
tracks origin/main.

* fix(cli): recover push -u upstream and filter LLM slug preambles

Push -u can publish a remote branch then fail to write local upstream
config. Treat that as incomplete, recover with set-upstream-to, and probe
the remote before reasserting the nonexistence lease on retry. Also skip
one-word LLM acknowledgements before accepting a slug-shaped line.

Refs #671

* fix(cli): decide the branch-collision lease with one live remote check

Replace the local-state inference chain (upstream config, a
zeroAutoBranch marker, per-remote provenance tracking) that kept
producing new collision-safety edge cases across many review rounds
with a single live remoteHasBranch check against the destination
remote: missing there gets the nonexistence lease, already there gets
a plain push, and git's own fast-forward check is the safety net if
that existing branch isn't actually ours.

Removes MarkGeneratedBranch/IsGeneratedBranch, now dead, along with
the tests that pinned the specific local-state heuristics rather than
the collision-safety property itself.

* fix(cli,zerogit): address CodeRabbit findings on auto-branch naming

Recover trailing-punctuation LLM slugs, notice deterministic fallback,
report dual restore/rollback failures, and skip Push upstream repair on
dry-run. Align comments with fail-closed and DryRun behavior.

Refs #671

* fix(tools): set explicit stdin reader and use resolved binary for format-on-write

Use the binary path resolved by exec.LookPath and set formatter.Stdin
to an empty reader so format-on-write does not hang on inherited stdin
handles on Windows.

Refs #671

* fix(cli,zerogit,tools): address CodeRabbit review findings on PR 671

Remove redundant command validation, eliminate duplicate remote lookup in PR
workflow, harden ref options and branch switching, isolate test Git
environment, and add formatter lookup test.

Refs #671

* fix(cli,zerogit): address new CodeRabbit review findings on PR 671

Harden CreateBranch name validation, remove unreachable workflow error check,
complete doc comment, isolate test Git environment, and clean up unused
appDeps field.

Refs #671

* fix(cli,zerogit,tools): address third round of CodeRabbit review findings on PR 671

Use featureBranchOptions struct for ensureFeatureBranch, bound preflight
contexts, add upstream repair mismatch subtest, and centralize test Git
config isolation.

Refs #671

* fix(cli,zerogit): finish ensureFeatureBranch doc contract and close review gaps

The ensureFeatureBranch doc comment ended mid-sentence and sat on the featureBranchOptions type, so go doc attached function prose to the struct. Move it above the function and state which deps are required non-nil (inspectChanges, commitsAhead, headCommitSubject, currentGitUser, createBranch) versus optional and nil-guarded (refreshTrackingRef, isUnbornRemote, branchUpstreamRef, resetBranchRef, deleteBranch). Also extract the branch slug filename with path.Base because git paths are always slash-separated, gate two tests on the git version their fixtures need (autoSetupMerge=inherit requires 2.35, init --bare -b requires 2.28), and cover the literal "user" CurrentGitUser fallback via a test seam.

Refs #671

Co-Authored-By: cairn-code <[email protected]>

* Protect auto-branch publication and default ref restoration

Co-Authored-By: cairn-code <[email protected]>

* fix(cli): wire expected-old restore through the dependency seam

The P1 fix added expectedOld to resetBranchRef but left the appDeps field and the workflows.go import behind, so the CLI package did not compile. Update the field type to the 5-argument signature and restore the filepath import used by worktree release resolution.

Co-Authored-By: cairn-code <[email protected]>

* fix(cli,zerogit): wire CAS tip and close review test gaps

Addresses the CodeRabbit round on 93e3448e: fillAppDeps now defaults currentBranchTip so the compare-and-swap old value actually reaches ResetBranchRef in production; TestEnsureFeatureBranchRestoresDefaultBranchAfterCreate asserts the CAS value; the real-git auto-branch test asserts local main is restored to its upstream tip; docs explain the deliberate context.Background push, CurrentBranchTip semantics, the RemoteHasBranch tip-equality ownership check, and the Git 2.24 requirement for CommitsAhead; CreateBranch gains name-validation subtests.

Co-Authored-By: cairn-code <[email protected]>

* fix(zerogit): make RemoteHasBranch report existence only, not tip equality

RemoteHasBranch's tip-equality check made ensureFeatureBranch reassert the
nonexistence lease (--force-with-lease=<branch>:) on every push after the
first: a second local commit puts HEAD ahead of the just-published remote
tip, so the check reported false for a branch that plainly exists, and Git
rejected the follow-up push with a stale-info error instead of a normal
fast-forward.

The concurrent-creator race the tip check was meant to guard was already
handled one layer up: ensureFeatureBranch drops to a plain push when the
branch exists and relies on Git's own fast-forward check as the safety net
(see TestRunChangesPushDropsLeaseWhenBranchExistsOnRemote), so a race
survivor with unrelated history is rejected there regardless of tip
equality here.

Add TestRemoteHasBranchReportsTrueForDivergedTip, which fails under the old
tip-equality check and passes now.

Refs #671

* fix(cli,zerogit): preserve the generated branch on a compare-and-swap conflict

After auto-branching, ensureFeatureBranch restores the default branch to its
own upstream tip using the compare-and-swap form of update-ref. When that
refused because the default branch had moved concurrently, the failure was
indistinguishable from an ordinary update-ref write error, so the rollback
path ran and deleted the freshly created feature branch. That branch
exclusively owns the user's commits at that point, so a lost race destroyed
work in order to "recover" from it.

Add ErrCompareAndSwapConflict, returned by ResetBranchRef only when an
expected-old value was supplied (the sole way git refuses is a tip mismatch).
ensureFeatureBranch now recognizes it and returns without rolling back,
reporting that the branch was preserved so the user can reconcile the default
branch by hand.

Extend TestResetBranchRefRefusesConcurrentDefaultAdvance, which already drove
a real concurrent advance, to require the sentinel. Add
TestEnsureFeatureBranchPreservesBranchOnCompareAndSwapConflict, which fails
with "must not delete the generated branch" when the new branch is removed.

Refs #671

* Address review feedback for auto-branch workflow.

Refresh the exact configured upstream ref before branching and restoring, guard direct URL and path remote destinations from tracking-ref assertion, and remove undeclared --end-of-options from CommitsAhead.

Refs #671

* fix(zerogit,cli): resolve reviewer feedback on branch restoration and remote handling

Update CommitsAhead and ResetBranchRef to safely terminate option parsing with --, inspect all linked worktrees before ref updates, resolve advertised branches on direct path and URL remotes, and query git configuration directly for tracking remote and merge branch without splitting on slashes.

Refs #671

* fix(zerogit): invoke rev-list without positional delimiter to treat revs correctly

Remove premature option terminator from rev-list command invocation while validating base ref against option-like prefixes.

Refs #671

* fix(cli,zerogit): resolve restore tip commit and enforce push remote unborn policy

Resolve restore tip commits via ResolveRemoteBranchTip for named and direct URL/path upstreams to eliminate bare branch fallback, enforce unborn destination policy on push remotes before branch creation, and extract resolveBranchPushPlan to isolate decision logic from repository mutation.

Refs #671

* fix(cli,zerogit): address review feedback on destination remote and upstream matching

Forward resolved pushRemote to branchPushPlan, update HasUpstream to support multi-segment remote upstream references, clarify IsNamedRemote comment, and extend CLI argument parsing tests.

Refs #671

* docs(cli): complete ensureFeatureBranch documentation and wire branchUpstreamRemoteAndMerge

Refs #671

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-16 16:01:40 +08:00

3190 lines
130 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"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"
)
// featureBranchInspect returns an inspectChanges stub for ensureFeatureBranch:
// a clean working tree when BaseRef is empty, and the given committed range
// summary when BaseRef is set (the remote/branch naming path).
func featureBranchInspect(files []zerogit.FileChange, diff string) func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
if strings.TrimSpace(options.BaseRef) == "" {
return zerogit.ChangeSummary{Clean: true}, nil
}
return zerogit.ChangeSummary{Files: files, Diff: diff}, nil
}
}
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-abcdefghijklmnopqrstuvwxyz0"
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 TestRunWorktreesRelease(t *testing.T) {
worktreeDir := t.TempDir()
var releasedPath string
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", worktreeDir}, &stdout, &stderr, appDeps{
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
releasedPath = path
return nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if releasedPath != worktreeDir {
t.Fatalf("released path = %q, want %q", releasedPath, worktreeDir)
}
if !strings.Contains(stdout.String(), worktreeDir) {
t.Fatalf("expected confirmation output to mention path, got %q", stdout.String())
}
}
func TestRunWorktreesReleaseNormalizesRelativePath(t *testing.T) {
// git worktree unlock matches against the path git recorded when the
// worktree was created, so a relative argument (resolved against
// whatever directory the caller happens to be running `zero` from) can
// fail to match. Chdir into a known directory and pass a relative
// argument to confirm it reaches releaseWorktree as an absolute path.
origWd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
parent := t.TempDir()
worktreeDir := filepath.Join(parent, "task-a")
if err := os.Mkdir(worktreeDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Chdir(parent); err != nil {
t.Fatal(err)
}
defer func() {
if err := os.Chdir(origWd); err != nil {
t.Fatal(err)
}
}()
// filepath.Abs resolves against os.Getwd, whose spelling of the temp dir
// can differ from t.TempDir()'s (macOS reports /private/var for /var), so
// derive the expected value through the same resolution instead of
// joining onto the lexical parent path.
expectedPath, err := filepath.Abs("task-a")
if err != nil {
t.Fatal(err)
}
var releasedPath string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", "task-a"}, &stdout, &stderr, appDeps{
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
releasedPath = path
return nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if releasedPath != expectedPath {
t.Fatalf("released path = %q, want absolute %q", releasedPath, expectedPath)
}
}
func TestRunWorktreesReleaseRequiresPath(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release"}, &stdout, &stderr, appDeps{
releaseWorktree: func(context.Context, worktrees.Options, string) error {
t.Fatal("releaseWorktree should not be called without a path")
return nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if !strings.Contains(stderr.String(), "requires a worktree path") {
t.Fatalf("expected missing-path error, got %q", stderr.String())
}
}
func TestRunWorktreesReleaseWiresWorkspaceCwd(t *testing.T) {
// Release only consults Options.Cwd when the worktree directory itself is
// already gone (deleted by hand instead of released); git then has to run
// from the source repository to clear the orphaned lock. The CLI must
// wire the resolved workspace root through, or that advertised recovery
// path runs git from a possibly non-repository directory and the lock is
// never cleared (Clean skips locked entries).
root := t.TempDir()
var gotCwd string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", filepath.Join(root, "already-deleted")}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return root, nil },
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
gotCwd = options.Cwd
return nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if gotCwd != root {
t.Fatalf("release Options.Cwd = %q, want workspace root %q", gotCwd, root)
}
}
func TestRunWorktreesReleaseHonorsExplicitCwd(t *testing.T) {
// The deleted-path recovery cannot derive the source repository from the
// worktree path (the directory is gone and its name is a one-way hash),
// so -C names it explicitly; the resolved root must reach Release as
// Options.Cwd regardless of where the command was launched.
launchDir := t.TempDir()
repoDir := t.TempDir()
var gotCwd string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", "-C", repoDir, filepath.Join(repoDir, "already-deleted")}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return launchDir, nil },
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
gotCwd = options.Cwd
return nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if gotCwd != repoDir {
t.Fatalf("release Options.Cwd = %q, want explicit -C root %q", gotCwd, repoDir)
}
}
func TestRunWorktreesReleaseReportsErrors(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", "/no/such/worktree"}, &stdout, &stderr, appDeps{
releaseWorktree: func(context.Context, worktrees.Options, string) error {
return errors.New("unlock git worktree: not a valid worktree")
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if !strings.Contains(stderr.String(), "not a valid worktree") {
t.Fatalf("expected underlying error, got %q", stderr.String())
}
}
func TestRunWorktreesReleaseRedactsErrorText(t *testing.T) {
// Release errors interpolate the caller-supplied path (see
// verifyZeroOwnedWorktree's "refusing to release %s" messages); unlike the
// success path, which redacts before printing, the error was previously
// forwarded to stderr verbatim, so a rejected path containing a key-shaped
// segment would reach terminal/model-visible output unredacted.
secret := "sk-proj-abcDEF123_ghiJKL456-mnoPQR789stu"
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"worktrees", "release", "/tmp/" + secret + "/task"}, &stdout, &stderr, appDeps{
releaseWorktree: func(context.Context, worktrees.Options, string) error {
return fmt.Errorf("refusing to release /tmp/%s/task: not a registered worktree of this repository", secret)
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("release error leaked unredacted key-shaped path: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("expected redaction placeholder in error output, 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-abcdefghijklmnopqrstuvwxyz0"
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 TestRunExecWorktreeReleasesLockAfterRun(t *testing.T) {
root := t.TempDir()
worktreeDir := t.TempDir()
var releasedPath string
releaseCalls := 0
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) {
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil
},
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
releaseCalls++
releasedPath = path
return nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
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())
}
// A `zero exec --worktree` run is the only user of the worktree it prepares:
// its own process created it and is now exiting, so it must release the
// Prepare lock itself rather than leaving it locked forever (unlike `zero
// worktrees prepare`, which hands the path to a longer-lived external
// caller and has no such end-of-life signal to act on).
if releaseCalls != 1 {
t.Fatalf("releaseWorktree call count = %d, want 1", releaseCalls)
}
if releasedPath != worktreeDir {
t.Fatalf("released path = %q, want %q", releasedPath, worktreeDir)
}
}
func TestRunExecWorktreeKeepsLockItDidNotAcquire(t *testing.T) {
// Defense in depth: Prepare now rejects an in-use lease outright, so a
// successful result always reports LockAcquired=true. Should that ever
// change, exec must still only release the ownership its own invocation
// established; releasing a lock it did not acquire would clear another
// caller's lease and let a later Clean force-delete a live workspace.
root := t.TempDir()
worktreeDir := t.TempDir()
releaseCalls := 0
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) {
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", Reused: true, LockAcquired: false}, nil
},
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
releaseCalls++
return nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
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 releaseCalls != 0 {
t.Fatalf("releaseWorktree call count = %d, want 0 for a lock this run did not acquire", releaseCalls)
}
}
func TestRunExecWorktreeSurfacesReleaseFailure(t *testing.T) {
// A failed unlock leaves a lock Clean permanently skips, recreating the
// disk leak silently; the failure must reach the user with the affected
// path so the leaked lock can be cleared by hand.
root := t.TempDir()
worktreeDir := t.TempDir()
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) {
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil
},
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
return errors.New("unlock git worktree: boom")
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
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 !strings.Contains(stderr.String(), worktreeDir) || !strings.Contains(stderr.String(), "boom") {
t.Fatalf("expected release failure with path on stderr, got %q", stderr.String())
}
}
func TestRunExecWorktreeRedactsReleaseFailureText(t *testing.T) {
// The release path argument was already redacted before this diagnostic
// was added, but the error's own text was forwarded verbatim; Release's
// ownership errors interpolate the caller-supplied path (see
// verifyZeroOwnedWorktree), so a key-shaped path reaching this message
// leaked unredacted onto stderr.
root := t.TempDir()
worktreeDir := t.TempDir()
secret := "sk-proj-abcDEF123_ghiJKL456-mnoPQR789stu"
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) {
return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil
},
releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error {
return fmt.Errorf("refusing to release /tmp/%s/task: not a registered worktree of this repository", secret)
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
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 strings.Contains(stderr.String(), secret) {
t.Fatalf("deferred release diagnostic leaked unredacted key-shaped path: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("expected redaction placeholder in deferred release diagnostic, got %q", stderr.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 on commit, push, and pr
for _, cmd := range []string{"commit", "push", "pr"} {
for _, args := range [][]string{
{"--auto"},
{"-a"},
} {
options, help, err := parseChangesArgs(args, cmd)
if err != nil {
t.Fatalf("parseChangesArgs(%v, %q) error: %v", args, cmd, err)
}
if help {
t.Fatalf("parseChangesArgs(%v, %q) returned help", args, cmd)
}
if !options.auto {
t.Fatalf("auto = false, want true (args %v, cmd %q)", args, cmd)
}
}
}
// 2. Reject --auto on inspect
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
if err == nil || !strings.Contains(err.Error(), "--auto is only valid with commit, push, or pr") {
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)
}
// 5. Dry-run allowed on commit and push, rejected on pr and inspect
for _, cmd := range []string{"commit", "push"} {
opts, _, err := parseChangesArgs([]string{"--dry-run"}, cmd)
if err != nil || !opts.dryRun {
t.Fatalf("expected --dry-run accepted on %q, got opts=%+v err=%v", cmd, opts, err)
}
}
for _, cmd := range []string{"pr", "inspect"} {
_, _, err := parseChangesArgs([]string{"--dry-run"}, cmd)
if err == nil || !strings.Contains(err.Error(), "--dry-run is only valid with commit or push") {
t.Fatalf("expected --dry-run rejected on %q, got %v", cmd, err)
}
}
}
type mockCommitMsgProvider struct {
response string
req zeroruntime.CompletionRequest
err error
}
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
p.req = request
if p.err != nil {
return nil, p.err
}
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_1234567890abcdefghijklmnopqrstuvwxyz",
}
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_1234567890abcdefghijklmnopqrstuvwxyz") {
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())
}
})
}
func TestEnsureFeatureBranchCreatesBranchOffDefaultWithoutProvider(t *testing.T) {
cwd := t.TempDir()
var createdName string
branch, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error { return nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createdName = options.Name
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "someone/readme-md" || createdName != "someone/readme-md" {
t.Fatalf("unexpected branch: got %q (created %q)", branch, createdName)
}
}
func TestEnsureFeatureBranchUsesLLMSlugWhenProviderConfigured(t *testing.T) {
cwd := t.TempDir()
mockProv := &mockCommitMsgProvider{response: "add login page"}
var createdName string
branch, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{AutoNaming: true}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "login.go", Status: "added"}}, "+func Login() {}"),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createdName = options.Name
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "someone/add-login-page" || createdName != "someone/add-login-page" {
t.Fatalf("unexpected branch: got %q (created %q)", branch, createdName)
}
}
func TestEnsureFeatureBranchNormalizesMessyLLMSlugResponse(t *testing.T) {
cwd := t.TempDir()
// The prompt asks the model for "ONLY the raw slug text," but models don't
// always comply: this response wraps the actual slug in quotes with a
// blank line first. Slugifying the whole response verbatim would fold that
// noise into the branch name instead of just "add-login-page".
mockProv := &mockCommitMsgProvider{response: "\n\"add login page\"\n"}
var createdName string
branch, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{AutoNaming: true}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "login.go", Status: "added"}}, "+func Login() {}"),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createdName = options.Name
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "someone/add-login-page" || createdName != "someone/add-login-page" {
t.Fatalf("unexpected branch: got %q (created %q)", branch, createdName)
}
}
func TestExtractBranchSlug(t *testing.T) {
// The prompt asks for "ONLY the raw slug", but models add a preamble line,
// wrap the answer in a code fence, or quote a multi-word phrase. The real
// slug must be recovered rather than slugified whole (which would turn a
// preamble into the branch name) or dropped (a bare fence line slugifies to
// nothing).
for _, tc := range []struct {
name string
in string
want string
}{
{"RawSlug", "add-login-page", "add-login-page"},
{"Preamble", "Here is a suggested branch name:\nadd-login-page", "add-login-page"},
{"PreambleWithMultiWord", "Here is a suggested branch name:\nadd login page", "add login page"},
{"PreambleInlineWithColon", "Here is a suggested branch name: add login page", "add login page"},
{"InlineLabeledSlug", "Branch name: add-login-page", "add-login-page"},
// One-word acknowledgements match the slug regex; preamble filter must
// run before the early return so the real suggestion on the next line wins.
{"OneWordAckThenSpaced", "Sure\nadd login page", "add login page"},
{"OneWordAckThenKebab", "Certainly\nadd-login-page", "add-login-page"},
{"CodeFence", "```\nadd-login-page\n```", "add-login-page"},
{"FencedWithLanguage", "```text\nadd-login-page\n```", "add-login-page"},
{"QuotedPhrase", "\n\"add login page\"\n", "add login page"},
// Trailing sentence punctuation must not discard the suggestion as preamble.
{"TrailingPeriodSpaced", "add login page.", "add login page"},
{"TrailingPeriodKebab", "add-login-page.", "add-login-page"},
{"EmptyResponse", " \n\n ", ""},
} {
t.Run(tc.name, func(t *testing.T) {
if got := extractBranchSlug(tc.in); got != tc.want {
t.Fatalf("extractBranchSlug(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestFallbackBranchSlug(t *testing.T) {
// Git paths are slash-separated on every platform; the filename must be
// extracted with path.Base semantics so Windows cannot mangle a path.
for _, tc := range []struct {
name string
files []zerogit.FileChange
want string
}{
{"NoFiles", nil, "changes"},
{"RootFile", []zerogit.FileChange{{Path: "README.md", Status: "modified"}}, "readme-md"},
{"SlashSeparatedPath", []zerogit.FileChange{{Path: "docs/guide.md", Status: "modified"}}, "guide-md"},
{"DeepGitStylePath", []zerogit.FileChange{{Path: "internal/cli/workflows.go", Status: "modified"}}, "workflows-go"},
{"MultipleFiles", []zerogit.FileChange{{Path: "a.md", Status: "added"}, {Path: "b.md", Status: "added"}}, "update-2-files"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := fallbackBranchSlug(zerogit.ChangeSummary{Files: tc.files}); got != tc.want {
t.Fatalf("fallbackBranchSlug(%#v) = %q, want %q", tc.files, got, tc.want)
}
})
}
}
func TestEnsureFeatureBranchExtractsSlugFromMessyLLMReplies(t *testing.T) {
// End-to-end: a preamble line or a code fence around the slug must still
// yield the intended branch name, not one derived from the preamble or a
// silent fallback when the fence line slugifies to nothing.
for _, tc := range []struct {
name string
response string
}{
{"Preamble", "Here is a suggested branch name:\nadd-login-page"},
{"PreambleWithMultiWord", "Here is a suggested branch name:\nadd login page"},
{"CodeFence", "```\nadd-login-page\n```"},
{"FencedWithLanguage", "```text\nadd-login-page\n```"},
{"TrailingPeriod", "add login page."},
} {
t.Run(tc.name, func(t *testing.T) {
cwd := t.TempDir()
mockProv := &mockCommitMsgProvider{response: tc.response}
var createdName string
branch, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{AutoNaming: true}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "login.go", Status: "added"}}, "+func Login() {}"),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createdName = options.Name
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "someone/add-login-page" || createdName != "someone/add-login-page" {
t.Fatalf("unexpected branch: got %q (created %q)", branch, createdName)
}
})
}
}
// TestEnsureFeatureBranchFallsBackWhenLLMNamingFails pins the silent-to-notice
// path: provider errors and empty generated slugs keep the deterministic name
// and print a short notice (unless JSON mode).
func TestEnsureFeatureBranchFallsBackWhenLLMNamingFails(t *testing.T) {
for _, tc := range []struct {
name string
provider zeroruntime.Provider
provErr error
}{
{"ProviderError", &mockCommitMsgProvider{err: errors.New("provider down")}, nil},
{"EmptySlug", &mockCommitMsgProvider{response: " \n\n "}, nil},
{"NewProviderFails", nil, errors.New("cannot construct provider")},
} {
t.Run(tc.name, func(t *testing.T) {
cwd := t.TempDir()
var createdName string
var stdout bytes.Buffer
branch, _, _, err := ensureFeatureBranch(context.Background(), &stdout, cwd, "", featureBranchOptions{AutoNaming: true}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "login.go", Status: "added"}}, "+func Login() {}"),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
if tc.provErr != nil {
return nil, tc.provErr
}
return tc.provider, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createdName = options.Name
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "someone/login-go" || createdName != "someone/login-go" {
t.Fatalf("expected deterministic fallback someone/login-go, got %q (created %q)", branch, createdName)
}
if !strings.Contains(stdout.String(), "LLM branch naming unavailable; using deterministic name.") {
t.Fatalf("expected fallback notice in stdout, got %q", stdout.String())
}
})
}
t.Run("JSONModeSuppressesNotice", func(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
branch, _, _, err := ensureFeatureBranch(context.Background(), &stdout, cwd, "", featureBranchOptions{JSONMode: true, AutoNaming: true}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "login.go", Status: "added"}}, "+func Login() {}"),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return &mockCommitMsgProvider{err: errors.New("provider down")}, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "someone/login-go" {
t.Fatalf("expected deterministic fallback, got %q", branch)
}
if strings.Contains(stdout.String(), "LLM branch naming unavailable") || strings.Contains(stdout.String(), "Generating branch name") {
t.Fatalf("json mode must suppress naming notices, got %q", stdout.String())
}
})
}
func TestEnsureFeatureBranchSkipsWhenNotOnDefault(t *testing.T) {
cwd := t.TempDir()
createBranchCalled := false
branch, _, created, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return false, "feat/existing", "origin", nil
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
// An ordinary, already-published feature branch: no lease needed.
return true, nil
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createBranchCalled = true
return zerogit.BranchResult{}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "feat/existing" {
t.Fatalf("expected existing branch to be returned unchanged, got %q", branch)
}
if created {
t.Fatal("expected an already-published branch not to require the nonexistence lease")
}
if createBranchCalled {
t.Fatal("expected createBranch not to be called when already off the default branch")
}
}
func TestEnsureFeatureBranchSkipsWhenAllowDefaultOrDryRun(t *testing.T) {
for _, tc := range []struct {
name string
allowDefaultBranch bool
dryRun bool
}{
{"AllowDefaultBranch", true, false},
{"DryRun", false, true},
} {
t.Run(tc.name, func(t *testing.T) {
cwd := t.TempDir()
branch, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{AllowDefaultBranch: tc.allowDefaultBranch, DryRun: tc.dryRun}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
t.Fatal("isDefaultBranch should not be called")
return false, "", "", nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "" {
t.Fatalf("expected empty branch (defer to current HEAD), got %q", branch)
}
})
}
}
func TestEnsureFeatureBranchNamesFromHeadCommitAfterCommit(t *testing.T) {
// The ordinary sequence is `changes commit` then `changes push`: the
// working tree is clean by the time the branch is named, so the
// diff-derived fallback would always be the meaningless "changes". The
// name must come from the commit being pushed instead.
cwd := t.TempDir()
var createdName string
branch, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect(nil, ""), // clean tree + empty committed range: name from HEAD
headCommitSubject: func(ctx context.Context, cwd string) string {
return "fix(parser): handle empty input"
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createdName = options.Name
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != createdName || !strings.HasPrefix(branch, "someone/fix-parser") {
t.Fatalf("expected a name derived from the HEAD commit subject, got %q", branch)
}
}
func TestEnsureFeatureBranchDoesNotCallProviderWithoutAuto(t *testing.T) {
// changes push/pr were git-only commands: a configured provider must not
// cause the change diff to be uploaded for naming unless --auto opts in.
cwd := t.TempDir()
branch, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "login.go", Status: "added"}}, "+func Login() {}"),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil // provider IS configured
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("provider must not be constructed without --auto")
return nil, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if branch != "someone/login-go" {
t.Fatalf("expected the deterministic local name, got %q", branch)
}
}
func TestEnsureFeatureBranchThreadsDiffBytesToInspect(t *testing.T) {
// --diff-bytes caps how much of the diff Inspect returns; with --auto that
// diff is embedded in the provider request, so the cap must reach Inspect
// or a user bounding the proprietary source sent for LLM naming would still
// upload the complete diff.
cwd := t.TempDir()
var gotMaxDiffBytes int
mockProv := &mockCommitMsgProvider{
response: "feat/capped-diff-branch",
}
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{AutoNaming: true, MaxDiffBytes: 4096}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
if strings.TrimSpace(options.BaseRef) == "" {
return zerogit.ChangeSummary{Clean: true}, nil
}
gotMaxDiffBytes = options.MaxDiffBytes
return zerogit.ChangeSummary{
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
Diff: "diff --git a/README.md b/README.md\n+capped diff content",
}, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if gotMaxDiffBytes != 4096 {
t.Fatalf("expected MaxDiffBytes 4096 threaded into Inspect, got %d", gotMaxDiffBytes)
}
if len(mockProv.req.Messages) == 0 || !strings.Contains(mockProv.req.Messages[len(mockProv.req.Messages)-1].Content, "capped diff content") {
t.Fatalf("expected provider request to contain capped diff content, got %#v", mockProv.req)
}
}
func TestEnsureFeatureBranchRefusesWhenNothingToPublish(t *testing.T) {
// On a clean, up-to-date default branch HEAD is not ahead of the remote
// default, so a push would publish nothing. ensureFeatureBranch must refuse
// instead of creating and pushing an empty feature branch.
cwd := t.TempDir()
createBranchCalled := false
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return 0, nil
},
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
if strings.TrimSpace(options.BaseRef) != "" {
t.Fatal("base-ref inspect should not run when there is nothing to publish")
}
return zerogit.ChangeSummary{Clean: true}, nil
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createBranchCalled = true
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err == nil || !strings.Contains(err.Error(), "no changes to publish") {
t.Fatalf("expected a no-changes-to-publish error, got %v", err)
}
if createBranchCalled {
t.Fatal("expected createBranch not to be called when nothing is publishable")
}
}
func TestEnsureFeatureBranchRefusesDirtyWorkingTree(t *testing.T) {
// CreateBranch/Push publish commits only. With an ahead commit plus
// uncommitted edits, naming and pushing would leave those edits behind
// under a branch/PR that does not include them.
cwd := t.TempDir()
createBranchCalled := false
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
t.Fatal("commitsAhead should not run when the working tree is dirty")
return 0, nil
},
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
if strings.TrimSpace(options.BaseRef) != "" {
t.Fatal("base-ref inspect should not run when the working tree is dirty")
}
return zerogit.ChangeSummary{
Clean: false,
Files: []zerogit.FileChange{{Path: "wip.go", Status: "modified"}},
}, nil
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createBranchCalled = true
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
t.Fatalf("expected an uncommitted-changes error, got %v", err)
}
if createBranchCalled {
t.Fatal("expected createBranch not to be called when the working tree is dirty")
}
}
func TestEnsureFeatureBranchFailsWhenAheadCountUnknown(t *testing.T) {
// A missing remote-tracking ref (never fetched) means the ahead count
// cannot be determined. Fail closed rather than guessing that there is
// something to publish (or naming a branch from a working-tree snapshot).
cwd := t.TempDir()
createBranchCalled := false
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return 0, errors.New("unknown revision origin/main")
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
// Not unborn: the remote exists and has branches, it just was
// never fetched locally. This must still fail closed.
return false, nil
},
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
if strings.TrimSpace(options.BaseRef) != "" {
t.Fatal("base-ref inspect should not run when the ahead count is unknown")
}
return zerogit.ChangeSummary{Clean: true}, nil
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createBranchCalled = true
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err == nil || !strings.Contains(err.Error(), "cannot determine whether HEAD is ahead") {
t.Fatalf("expected an ahead-count-unknown error, got %v", err)
}
if createBranchCalled {
t.Fatal("expected createBranch not to be called when the publishable range is unknown")
}
}
func TestEnsureFeatureBranchFailsWhenUnbornCheckErrors(t *testing.T) {
// The ahead count is unknown AND the unborn probe itself fails (remote
// unreachable): this must still fail closed exactly like the plain
// unknown-ahead-count case, not be treated as a confirmed-unborn remote.
cwd := t.TempDir()
createBranchCalled := false
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return 0, errors.New("unknown revision origin/main")
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
return false, errors.New("remote unreachable")
},
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
if strings.TrimSpace(options.BaseRef) != "" {
t.Fatal("base-ref inspect should not run when the ahead count is unknown")
}
return zerogit.ChangeSummary{Clean: true}, nil
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createBranchCalled = true
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err == nil || !strings.Contains(err.Error(), "cannot determine whether HEAD is ahead") {
t.Fatalf("expected an ahead-count-unknown error, got %v", err)
}
if createBranchCalled {
t.Fatal("expected createBranch not to be called when the unborn state is unconfirmed")
}
}
// TestEnsureFeatureBranchRefusesUnbornRemote covers jatmn's P1: auto-creating
// only a feature branch on a brand-new empty remote leaves remote HEAD
// dangling (no default branch), so the next `changes pr` fails closed and a
// later `push --yes` of main publishes the same tip with no PR diff. Refuse
// auto-branching until the initial default branch is established with --yes.
func TestEnsureFeatureBranchRefusesUnbornRemote(t *testing.T) {
cwd := t.TempDir()
createBranchCalled := false
var isUnbornRemoteCalled bool
_, _, created, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
inspectChanges: featureBranchInspect(nil, ""),
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return 0, errors.New("unknown revision origin/main..HEAD")
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
isUnbornRemoteCalled = true
if remote != "origin" {
t.Fatalf("expected the resolved remote %q, got %q", "origin", remote)
}
return true, nil
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createBranchCalled = true
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err == nil || !strings.Contains(err.Error(), "no branches yet") || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("expected unborn-remote refuse error mentioning --yes, got %v", err)
}
if !isUnbornRemoteCalled {
t.Fatal("expected isUnbornRemote to be consulted after commitsAhead failed")
}
if created || createBranchCalled {
t.Fatal("expected no feature branch on an unborn remote")
}
}
func TestEnsureFeatureBranchInspectsAgainstResolvedRemoteBranch(t *testing.T) {
// Push and CreateBranch only publish commits, so the branch (and, with
// --auto, the diff sent to a provider) must be named from what HEAD is
// actually ahead of the resolved remote branch by, not from the working
// tree: Inspect must be asked to diff against "<remote>/<branch>", the
// same ref commitsAhead just checked.
cwd := t.TempDir()
var gotBaseRef string
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
if strings.TrimSpace(options.BaseRef) == "" {
return zerogit.ChangeSummary{Clean: true}, nil
}
gotBaseRef = options.BaseRef
return zerogit.ChangeSummary{Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}}}, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if gotBaseRef != "origin/main" {
t.Fatalf("expected Inspect to diff against %q, got %q", "origin/main", gotBaseRef)
}
}
func TestRunChangesPushUsesResolvedRemoteForNewBranch(t *testing.T) {
// In a fork setup the original branch tracks a non-origin remote. The
// freshly created feature branch has no tracking configuration, so the
// resolved remote must be threaded into Push explicitly or it would
// silently fall back to origin.
cwd := t.TempDir()
var pushedRemote string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "push"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "upstream", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error { return nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
pushedRemote = options.Remote
return zerogit.PushResult{Remote: options.Remote, Branch: options.Branch}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if pushedRemote != "upstream" {
t.Fatalf("expected push to target the resolved remote %q, got %q", "upstream", pushedRemote)
}
}
func TestRunChangesPushCreatesFeatureBranchWhenOnDefault(t *testing.T) {
cwd := t.TempDir()
var pushedBranch string
var requiredNewRemoteBranch bool
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "push"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error { return nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
pushedBranch = options.Branch
requiredNewRemoteBranch = options.RequireNewRemoteBranch
return zerogit.PushResult{Remote: "origin", Branch: options.Branch}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if pushedBranch != "someone/readme-md" {
t.Fatalf("expected push to target the newly created branch, got %q", pushedBranch)
}
if !strings.Contains(stdout.String(), "Created branch someone/readme-md") {
t.Fatalf("expected branch-creation message in stdout, got %q", stdout.String())
}
// CreateBranch's own remote-collision probe runs before this push, so the
// push itself must assert the destination is still new, closing the
// window for a concurrent creator of the same name.
if !requiredNewRemoteBranch {
t.Fatal("expected Push to require the destination not already exist on the remote")
}
}
func TestRunChangesPRCreatesFeatureBranchWhenOnDefault(t *testing.T) {
cwd := t.TempDir()
var pushedBranch string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "pr", "--fill"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error { return nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
pushedBranch = options.Branch
return zerogit.PushResult{Remote: "origin", Branch: options.Branch}, nil
},
createPR: func(ctx context.Context, options zerogit.PROptions) (zerogit.PRResult, error) {
return zerogit.PRResult{Output: "https://example.invalid/pr/1"}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
// runChangesPR hardcodes dryRun=false into ensureFeatureBranch (unlike push,
// which forwards options.dryRun), so it always creates and forwards the
// branch on the default branch, with no --dry-run bypass to verify here.
if pushedBranch != "someone/readme-md" {
t.Fatalf("expected pushChanges to target the newly created branch, got %q", pushedBranch)
}
if !strings.Contains(stdout.String(), "Created branch someone/readme-md") {
t.Fatalf("expected branch-creation message in stdout, got %q", stdout.String())
}
}
func TestRunChangesPushSkipsBranchCreationWithYes(t *testing.T) {
cwd := t.TempDir()
isDefaultBranchCalled := false
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "push", "--yes"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
isDefaultBranchCalled = true
return true, "main", "origin", nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
if options.Branch != "" {
t.Fatalf("expected empty Branch (defer to current HEAD) with --yes, got %q", options.Branch)
}
if options.RequireNewRemoteBranch {
t.Fatal("expected RequireNewRemoteBranch to be false: no branch was created")
}
return zerogit.PushResult{Remote: "origin", Branch: "main"}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if isDefaultBranchCalled {
t.Fatal("expected isDefaultBranch not to be consulted when --yes is passed")
}
}
func TestRunChangesPRRejectsUnbornRemoteEvenWithYes(t *testing.T) {
cwd := t.TempDir()
isUnbornRemoteCalled := false
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "pr", "--yes", "--fill"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
isUnbornRemoteCalled = true
if remote != "origin" {
t.Fatalf("expected remote %q, got %q", "origin", remote)
}
return true, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
t.Fatal("pushChanges should not be called on unborn remote")
return zerogit.PushResult{}, nil
},
createPR: func(ctx context.Context, options zerogit.PROptions) (zerogit.PRResult, error) {
t.Fatal("createPR should not be called on unborn remote")
return zerogit.PRResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
}
if !isUnbornRemoteCalled {
t.Fatal("expected isUnbornRemote to be consulted even when --yes is passed")
}
if !strings.Contains(stderr.String(), "cannot create pull request on unborn remote origin") {
t.Fatalf("unexpected stderr: %q", stderr.String())
}
}
// TestRunChangesPushRequiresLeaseWhenBranchMissingOnRemote covers the retry
// case: HEAD is already on a previously-generated branch (whether from a
// lost force-with-lease race or any other reason it never landed), and the
// live remote check finds no branch of that name yet. The nonexistence lease
// must stay so a concurrent creator of the same name cannot be silently
// fast-forwarded over.
func TestRunChangesPushRequiresLeaseWhenBranchMissingOnRemote(t *testing.T) {
cwd := t.TempDir()
var requireNewRemoteBranch bool
var pushedBranch string
var remoteProbed bool
createBranchCalled := false
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "push"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return false, "someone/readme-md", "origin", nil
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
remoteProbed = true
if remote != "origin" || branch != "someone/readme-md" {
t.Fatalf("unexpected remote probe: %s/%s", remote, branch)
}
return false, nil
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createBranchCalled = true
return zerogit.BranchResult{}, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
pushedBranch = options.Branch
requireNewRemoteBranch = options.RequireNewRemoteBranch
return zerogit.PushResult{Remote: options.Remote, Branch: options.Branch}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if createBranchCalled {
t.Fatal("expected createBranch not to be called on a retry already off the default branch")
}
if !remoteProbed {
t.Fatal("expected a live remote check before deciding the lease")
}
if pushedBranch != "someone/readme-md" {
t.Fatalf("expected the retry to target the same branch, got %q", pushedBranch)
}
if !requireNewRemoteBranch {
t.Fatal("expected the nonexistence lease when the remote check finds no branch")
}
}
// TestRunChangesPushDropsLeaseWhenBranchExistsOnRemote is the companion case:
// the live remote check finds the branch already exists, so the push must go
// through as a plain (non-leased) push. Git's own fast-forward check is the
// safety net if that existing branch is not actually ours.
func TestRunChangesPushDropsLeaseWhenBranchExistsOnRemote(t *testing.T) {
cwd := t.TempDir()
var requireNewRemoteBranch bool
var remoteProbed bool
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "push"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return false, "someone/readme-md", "origin", nil
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
remoteProbed = true
if remote != "origin" || branch != "someone/readme-md" {
t.Fatalf("unexpected remote probe: %s/%s", remote, branch)
}
return true, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
requireNewRemoteBranch = options.RequireNewRemoteBranch
return zerogit.PushResult{Remote: options.Remote, Branch: options.Branch}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !remoteProbed {
t.Fatal("expected a live remote check before deciding the lease")
}
if requireNewRemoteBranch {
t.Fatal("expected no nonexistence lease when the remote check finds the branch")
}
}
// TestRunChangesPushChecksSelectedRemoteNotConfiguredUpstream covers a
// generated branch published to origin while this invocation targets a
// different remote (--remote upstream): the live check must probe the
// selected remote, not whatever the branch happens to track locally.
func TestRunChangesPushChecksSelectedRemoteNotConfiguredUpstream(t *testing.T) {
cwd := t.TempDir()
var probedRemote string
var requireNewRemoteBranch bool
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "push", "--remote", "upstream"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
if options.Remote != "upstream" {
t.Fatalf("expected requested remote upstream, got %q", options.Remote)
}
return false, "user/feature", "origin", nil
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
probedRemote = remote
// Exists on origin (this invocation's default), but this push
// targets upstream, where it has never been published.
return false, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
requireNewRemoteBranch = options.RequireNewRemoteBranch
return zerogit.PushResult{Remote: options.Remote, Branch: options.Branch}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if probedRemote != "upstream" {
t.Fatalf("expected the live check against the requested remote upstream, got %q", probedRemote)
}
if !requireNewRemoteBranch {
t.Fatal("expected the nonexistence lease for the not-yet-published upstream remote")
}
}
// TestEnsureFeatureBranchRefreshesTrackingRefBeforeCheckingAhead covers
// jatmn's P2 finding: validate publishability against the live default, not a
// stale local tracking ref. IsDefaultBranch already contacted the remote for
// its symref check, but a merely-cached origin/main (last fetched earlier)
// can sit behind the remote's live tip. Before the fix, commitsAhead ran
// straight against that stale ref: this simulates the stale ref reporting
// zero commits ahead until the tracking ref is refreshed, matching the
// scenario where the remote has already advanced past what was last fetched.
func TestEnsureFeatureBranchRefreshesTrackingRefBeforeCheckingAhead(t *testing.T) {
cwd := t.TempDir()
refreshed := false
var createdName string
branch, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
if remote != "origin" || branch != "main" {
t.Fatalf("unexpected refresh target: %s/%s", remote, branch)
}
refreshed = true
return nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
if !refreshed {
// The stale, unrefreshed local origin/main is behind and
// would report zero commits ahead even though HEAD carries
// real, unpublished work relative to the remote's live tip.
return 0, nil
}
return 2, nil
},
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createdName = options.Name
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if !refreshed {
t.Fatal("expected the tracking ref to be refreshed before the ahead check")
}
if branch != "someone/readme-md" || createdName != "someone/readme-md" {
t.Fatalf("unexpected branch: got %q (created %q)", branch, createdName)
}
}
// TestEnsureFeatureBranchFailsClosedWhenTrackingRefCannotBeRefreshed asserts
// that a failed refresh is never silently ignored in favor of trusting
// whatever commitsAhead reports against the (potentially stale, unrefreshed)
// local ref: the command must fail closed instead of risking a false "no
// changes to publish" from comparing against a tracking ref that could not be
// confirmed fresh.
func TestEnsureFeatureBranchFailsClosedWhenTrackingRefCannotBeRefreshed(t *testing.T) {
cwd := t.TempDir()
createBranchCalled := false
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
return errors.New("remote unreachable")
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return 0, nil
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
return false, nil
},
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return zerogit.ChangeSummary{Clean: true}, nil
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createBranchCalled = true
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if err == nil || !strings.Contains(err.Error(), "cannot determine whether HEAD is ahead") {
t.Fatalf("expected a fail-closed error when the tracking ref could not be refreshed, got %v", err)
}
if createBranchCalled {
t.Fatal("expected createBranch not to be called when the tracking ref could not be refreshed")
}
}
// TestEnsureFeatureBranchErrorsWhenRemoteCheckFails covers the surfaced-error
// path: when the live remote check itself fails, ensureFeatureBranch must
// fail closed with a clear error rather than guessing a lease value.
func TestEnsureFeatureBranchErrorsWhenRemoteCheckFails(t *testing.T) {
cwd := t.TempDir()
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return false, "user/slug", "origin", nil
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
return false, errors.New("remote unreachable")
},
})
if err == nil || !strings.Contains(err.Error(), "cannot check whether") {
t.Fatalf("expected a clear remote-check error, got %v", err)
}
}
func TestRunChangesPRYesBypassesDefaultBranchCheck(t *testing.T) {
cwd := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "pr", "--yes", "--fill"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return false, "", "", errors.New("remote unreachable")
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
return zerogit.PushResult{Remote: "origin", Branch: "main"}, nil
},
createPR: func(ctx context.Context, options zerogit.PROptions) (zerogit.PRResult, error) {
return zerogit.PRResult{Output: "https://example.invalid/pr/1"}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d with --yes even when isDefaultBranch errors, got %d: %s", exitSuccess, exitCode, stderr.String())
}
}
// TestEnsureFeatureBranchRestoresDefaultBranchAfterCreate covers jatmn's P2
// finding: after committing on main and auto-creating user/slug at the same
// HEAD, the local default branch must be moved back to its remote-tracking
// tip so it does not keep the new commit (which would diverge after a
// squash-merge).
func TestEnsureFeatureBranchRestoresDefaultBranchAfterCreate(t *testing.T) {
cwd := t.TempDir()
var resetBranch, resetTip, resetExpectedOld string
var createdName string
branch, _, created, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error { return nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
currentBranchTip: func(ctx context.Context, cwd string) string { return "abc123" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
createdName = options.Name
return zerogit.BranchResult{Branch: options.Name}, nil
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
resetBranch = branch
resetTip = newTip
resetExpectedOld = expectedOld
return nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if !created || branch != createdName {
t.Fatalf("expected a created branch, got branch=%q created=%v", branch, created)
}
if resetBranch != "main" || resetTip != "origin/main" {
t.Fatalf("expected reset of main to origin/main, got branch=%q tip=%q", resetBranch, resetTip)
}
// The pre-create HEAD tip must reach ResetBranchRef as the compare-and-swap
// old value, or a concurrent advance of the default branch is silently
// overwritten instead of refused.
if resetExpectedOld != "abc123" {
t.Fatalf("expected compare-and-swap old value abc123, got %q", resetExpectedOld)
}
}
// TestEnsureFeatureBranchRollsBackWhenDefaultRestoreFails covers the
// failure-safe path: if restoring the default branch ref fails after the
// feature branch was created, delete the feature branch and surface the error
// rather than leaving a half-moved tree.
func TestEnsureFeatureBranchRollsBackWhenDefaultRestoreFails(t *testing.T) {
cwd := t.TempDir()
deletedBranch := ""
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "user" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
return errors.New("update-ref failed")
},
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
if fallbackBranch != "main" {
t.Fatalf("expected fallback main, got %q", fallbackBranch)
}
deletedBranch = branchToDelete
return nil
},
})
if err == nil || !strings.Contains(err.Error(), "restore default branch") {
t.Fatalf("expected restore error, got %v", err)
}
if deletedBranch != "user/readme-md" {
t.Fatalf("expected rollback deletion of user/readme-md, got %q", deletedBranch)
}
}
// TestEnsureFeatureBranchReportsRollbackFailureWhenDeleteAlsoFails covers the
// dual-failure path: restore fails and feature-branch deletion also fails, so
// the error must mention both so the user knows manual repair is required.
func TestEnsureFeatureBranchReportsRollbackFailureWhenDeleteAlsoFails(t *testing.T) {
cwd := t.TempDir()
_, _, _, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "user" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
return errors.New("update-ref failed")
},
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
return errors.New("cannot delete branch: checked out")
},
})
if err == nil {
t.Fatal("expected dual-failure error, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "restore default branch") || !strings.Contains(msg, "rollback of user/readme-md also failed") {
t.Fatalf("expected restore+rollback error, got %v", err)
}
if !strings.Contains(msg, "cannot delete branch") {
t.Fatalf("expected delete cause in error, got %v", err)
}
}
// TestEnsureFeatureBranchPreservesBranchOnCompareAndSwapConflict covers the
// concurrent-modification case: the default branch moved while we were
// branching, so the compare-and-swap restore refuses. The generic restore
// failure above rolls back by deleting the feature branch, but that branch now
// exclusively owns the user's commits, so deleting it here would destroy work
// to "recover" from a race. The branch must survive and the error must say so.
func TestEnsureFeatureBranchPreservesBranchOnCompareAndSwapConflict(t *testing.T) {
cwd := t.TempDir()
deleteCalled := false
branch, _, created, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "user" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
return fmt.Errorf("%w: update-ref refs/heads/main: stale", zerogit.ErrCompareAndSwapConflict)
},
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
deleteCalled = true
return nil
},
})
if err == nil {
t.Fatal("expected a restore error on a compare-and-swap conflict")
}
if deleteCalled {
t.Fatal("compare-and-swap conflict must not delete the generated branch: it owns the user's commits")
}
if !strings.Contains(err.Error(), "preserved") {
t.Fatalf("expected the error to report the branch was preserved, got %v", err)
}
if !strings.Contains(err.Error(), "user/readme-md") {
t.Fatalf("expected the error to name the preserved branch, got %v", err)
}
// The call still fails, so the caller must not proceed to push.
if created || branch != "" {
t.Fatalf("expected no usable branch result, got branch=%q created=%v", branch, created)
}
}
// TestEnsureFeatureBranchRestoresSourceUpstreamOnForkRemote covers jatmn's P2:
// --remote selects the push destination only. When local main tracks
// origin/main and the user passes --remote upstream, restore must leave main
// at origin/main, not rewrite it to upstream/main.
func TestEnsureFeatureBranchRestoresSourceUpstreamOnForkRemote(t *testing.T) {
cwd := t.TempDir()
var resetBranch, resetTip string
var refreshedRemote, refreshedBranch string
_, _, created, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "upstream", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
if options.Remote != "upstream" {
t.Fatalf("expected requested remote upstream, got %q", options.Remote)
}
return true, "main", "upstream", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
if remote != "origin" || branch != "main" {
t.Fatalf("expected commitsAhead check against origin/main, got %s/%s", remote, branch)
}
return 1, nil
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
refreshedRemote = remote
refreshedBranch = branch
return nil
},
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
if branch != "main" {
t.Fatalf("expected upstream query for main, got %q", branch)
}
// Fork: main tracks origin, not the push destination.
return "origin", "main"
},
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
if options.Remote != "upstream" {
t.Fatalf("expected create against upstream, got %q", options.Remote)
}
return zerogit.BranchResult{Branch: options.Name}, nil
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
resetBranch = branch
resetTip = newTip
return nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if !created {
t.Fatal("expected a feature branch to be created")
}
if refreshedRemote != "origin" || refreshedBranch != "main" {
t.Fatalf("expected refreshTrackingRef for origin/main, got %s/%s", refreshedRemote, refreshedBranch)
}
if resetBranch != "main" || resetTip != "origin/main" {
t.Fatalf("expected restore main -> origin/main (source upstream), got %q -> %q", resetBranch, resetTip)
}
}
// TestEnsureFeatureBranchHandlesSlashContainingRemoteName covers configured
// remote names with slashes (e.g. "team/upstream"). It must not split the
// remote name on '/' or fail tracking ref refresh.
func TestEnsureFeatureBranchHandlesSlashContainingRemoteName(t *testing.T) {
cwd := t.TempDir()
var resetBranch, resetTip string
var refreshedRemote, refreshedBranch string
_, _, created, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "team/upstream", nil
},
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
return "team/upstream", "main"
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
if remote != "team/upstream" || branch != "main" {
t.Fatalf("expected commitsAhead check against team/upstream/main, got %s/%s", remote, branch)
}
return 1, nil
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
refreshedRemote = remote
refreshedBranch = branch
return nil
},
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
resetBranch = branch
resetTip = newTip
return nil
},
})
if err != nil {
t.Fatalf("ensureFeatureBranch returned error: %v", err)
}
if !created {
t.Fatal("expected a feature branch to be created")
}
if refreshedRemote != "team/upstream" || refreshedBranch != "main" {
t.Fatalf("expected refreshTrackingRef for team/upstream/main, got %s/%s", refreshedRemote, refreshedBranch)
}
if resetBranch != "main" || resetTip != "team/upstream/main" {
t.Fatalf("expected restore main -> team/upstream/main, got %q -> %q", resetBranch, resetTip)
}
}
// TestEnsureFeatureBranchResolvesRestoreTipToCommit covers jatmn's P2: when the
// source upstream is a direct URL or path (branch.main.remote is a transport
// locator, not a named remote), restore must resolve a concrete commit, never
// fall back to the bare branch name. Resolving "main" yields the very commit
// the feature branch was just created from, making the restore a silent no-op
// and leaving the publishable commits on the default branch.
func TestEnsureFeatureBranchResolvesRestoreTipToCommit(t *testing.T) {
tests := []struct {
name string
upstreamRemote string
upstreamBranch string
tipSHA string
tipErr error
wantRestoreTip string
wantErr bool
errContains string
}{
{
name: "NamedRemoteOrigin",
upstreamRemote: "origin",
upstreamBranch: "main",
tipSHA: "1111111111111111111111111111111111111111",
wantRestoreTip: "1111111111111111111111111111111111111111",
},
{
name: "DirectHTTPSURL",
upstreamRemote: "https://github.com/example/repo.git",
upstreamBranch: "main",
tipSHA: "2222222222222222222222222222222222222222",
wantRestoreTip: "2222222222222222222222222222222222222222",
},
{
name: "DirectFilesystemPath",
upstreamRemote: "/path/to/bare.git",
upstreamBranch: "main",
tipSHA: "3333333333333333333333333333333333333333",
wantRestoreTip: "3333333333333333333333333333333333333333",
},
{
name: "ResolutionErrorFailsClosedBeforeBranchCreation",
upstreamRemote: "https://github.com/example/missing.git",
upstreamBranch: "main",
tipErr: errors.New("remote has no branch main to restore to"),
wantErr: true,
errContains: "cannot resolve restore tip",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cwd := t.TempDir()
var resetBranch, resetTip string
branchCreated := false
_, _, created, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
return tc.upstreamRemote, tc.upstreamBranch
},
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
if remote != tc.upstreamRemote || branch != tc.upstreamBranch {
t.Fatalf("expected restore-tip resolution for %s/%s, got %s/%s", tc.upstreamRemote, tc.upstreamBranch, remote, branch)
}
return tc.tipSHA, tc.tipErr
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return 1, nil
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
return nil
},
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
branchCreated = true
return zerogit.BranchResult{Branch: options.Name}, nil
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
resetBranch = branch
resetTip = newTip
return nil
},
})
if tc.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
t.Fatalf("expected error containing %q, got %v", tc.errContains, err)
}
if branchCreated || created {
t.Fatal("expected no branch creation when restore tip resolution fails")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !created {
t.Fatal("expected a feature branch to be created")
}
if resetBranch != "main" || resetTip != tc.wantRestoreTip {
t.Fatalf("expected restore main -> %s, got %q -> %q", tc.wantRestoreTip, resetBranch, resetTip)
}
})
}
}
// TestEnsureFeatureBranchUnbornDestinationMatrix covers jatmn's P2: unborn
// remote policy must gate the push destination as well as the source upstream.
func TestEnsureFeatureBranchUnbornDestinationMatrix(t *testing.T) {
matrix := []struct {
name string
requestedRemote string
defaultRemote string
unbornRemotes map[string]bool
allowDefaultBranch bool
wantBranch bool
wantErr bool
errContains string
}{
{
name: "PushDestinationUnbornViaFlagRefuses",
requestedRemote: "backup",
defaultRemote: "origin",
unbornRemotes: map[string]bool{"backup": true, "origin": false},
wantErr: true,
errContains: "remote backup has no branches yet",
},
{
name: "SourceUpstreamUnbornRefuses",
requestedRemote: "",
defaultRemote: "origin",
unbornRemotes: map[string]bool{"origin": true},
wantErr: true,
errContains: "remote origin has no branches yet",
},
{
name: "PopulatedDestinationAndSourceAllows",
requestedRemote: "upstream",
defaultRemote: "origin",
unbornRemotes: map[string]bool{"upstream": false, "origin": false},
wantBranch: true,
},
{
name: "AllowDefaultBranchBypassesAutoBranching",
requestedRemote: "backup",
defaultRemote: "origin",
unbornRemotes: map[string]bool{"backup": true},
allowDefaultBranch: true,
wantBranch: false,
},
}
for _, tc := range matrix {
t.Run(tc.name, func(t *testing.T) {
cwd := t.TempDir()
branchCreated := false
var createdRemote string
_, _, created, err := ensureFeatureBranch(context.Background(), &bytes.Buffer{}, cwd, tc.requestedRemote, featureBranchOptions{
AllowDefaultBranch: tc.allowDefaultBranch,
}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", tc.defaultRemote, nil
},
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
return tc.defaultRemote, "main"
},
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
return "abc123sha", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
if tc.unbornRemotes[remote] {
return 0, errors.New("cannot determine ahead")
}
return 1, nil
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
return nil
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
return tc.unbornRemotes[remote], nil
},
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
branchCreated = true
createdRemote = options.Remote
return zerogit.BranchResult{Branch: options.Name}, nil
},
})
if tc.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
t.Fatalf("expected error containing %q, got %v", tc.errContains, err)
}
if branchCreated || created {
t.Fatal("expected no branch creation on error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if created != tc.wantBranch {
t.Fatalf("created = %v, want %v", created, tc.wantBranch)
}
if tc.wantBranch {
wantRemote := tc.defaultRemote
if tc.requestedRemote != "" {
wantRemote = tc.requestedRemote
}
if createdRemote != wantRemote {
t.Fatalf("createdRemote = %q, want %q", createdRemote, wantRemote)
}
}
})
}
}
// TestResolveBranchPushPlan covers the pure plan resolution function directly
// without mutating repository state.
func TestResolveBranchPushPlan(t *testing.T) {
cwd := t.TempDir()
t.Run("NonDefaultBranchPlan", func(t *testing.T) {
plan, err := resolveBranchPushPlan(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return false, "feat/my-feature", "origin", nil
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
return false, nil
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if plan.ShouldBranch {
t.Fatal("expected ShouldBranch to be false for non-default branch")
}
if plan.CurrentBranch != "feat/my-feature" || plan.PushRemote != "origin" || !plan.RequireNewRemoteBranch {
t.Fatalf("unexpected plan values: %+v", plan)
}
})
t.Run("DefaultBranchPlanWithResolvedCommit", func(t *testing.T) {
plan, err := resolveBranchPushPlan(context.Background(), &bytes.Buffer{}, cwd, "upstream", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "upstream", nil
},
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
return "origin", "main"
},
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
return "fedcba9876", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return 2, nil
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
return nil
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
return false, nil
},
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "main.go", Status: "modified"}}, ""),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !plan.ShouldBranch {
t.Fatal("expected ShouldBranch to be true for ahead default branch")
}
if plan.CurrentBranch != "main" || plan.PushRemote != "upstream" {
t.Fatalf("unexpected branch/remote: %s/%s", plan.CurrentBranch, plan.PushRemote)
}
if plan.TrackingRemote != "origin" || plan.TrackingBranch != "main" {
t.Fatalf("unexpected tracking: %s/%s", plan.TrackingRemote, plan.TrackingBranch)
}
if plan.RestoreTip != "fedcba9876" || plan.PublishabilityBase != "fedcba9876" {
t.Fatalf("unexpected restore tip / base: %s / %s", plan.RestoreTip, plan.PublishabilityBase)
}
if plan.Slug != "main-go" {
t.Fatalf("unexpected slug: %s", plan.Slug)
}
})
t.Run("RefusesDirtyWorkingTree", func(t *testing.T) {
_, err := resolveBranchPushPlan(context.Background(), &bytes.Buffer{}, cwd, "", featureBranchOptions{}, appDeps{
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return zerogit.ChangeSummary{Clean: false}, nil
},
})
if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
t.Fatalf("expected dirty working tree error, got %v", err)
}
})
}
// TestRunChangesPushRestoresDefaultBranchOnAutoBranchPath exercises the
// commit-then-push auto-branch path end-to-end through runChangesPush and
// asserts the default branch is restored to its remote tip after the feature
// branch is created.
func TestRunChangesPushRestoresDefaultBranchOnAutoBranchPath(t *testing.T) {
cwd := t.TempDir()
var resetBranch, resetTip string
var pushedBranch string
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "push"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
return true, "main", "origin", nil
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) { return 1, nil },
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error { return nil },
inspectChanges: featureBranchInspect([]zerogit.FileChange{{Path: "README.md", Status: "modified"}}, ""),
currentGitUser: func(ctx context.Context, cwd string) string { return "Someone" },
createBranch: func(ctx context.Context, options zerogit.BranchOptions) (zerogit.BranchResult, error) {
return zerogit.BranchResult{Branch: options.Name}, nil
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
resetBranch = branch
resetTip = newTip
return nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
pushedBranch = options.Branch
return zerogit.PushResult{Remote: "origin", Branch: options.Branch}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if resetBranch != "main" || resetTip != "origin/main" {
t.Fatalf("expected default branch restore main -> origin/main, got %q -> %q", resetBranch, resetTip)
}
if pushedBranch != "someone/readme-md" {
t.Fatalf("expected push of auto-created branch, got %q", pushedBranch)
}
}
// TestRunChangesPRYesUsesActualUpstreamForUnbornCheck covers jatmn's P2
// finding: with changes pr --yes and no --remote, the unborn-remote preflight
// must resolve the current branch's configured upstream (e.g. a fork's
// "upstream") rather than calling branchUpstreamRemote with an empty branch
// name and falling back to origin.
func TestRunChangesPRYesUsesActualUpstreamForUnbornCheck(t *testing.T) {
cwd := t.TempDir()
var unbornRemoteChecked string
var upstreamBranchArg string
isDefaultBranchCalled := false
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "pr", "--yes", "--fill"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
isDefaultBranch: func(ctx context.Context, options zerogit.DefaultBranchOptions) (bool, string, string, error) {
isDefaultBranchCalled = true
return false, "", "", errors.New("remote HEAD unreachable")
},
currentGitBranch: func(ctx context.Context, cwd string) string {
return "feature/fork-work"
},
branchUpstreamRemote: func(ctx context.Context, cwd, branch string) string {
upstreamBranchArg = branch
if branch == "feature/fork-work" {
return "upstream"
}
return ""
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
unbornRemoteChecked = remote
return false, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
return zerogit.PushResult{Remote: "upstream", Branch: "feature/fork-work"}, nil
},
createPR: func(ctx context.Context, options zerogit.PROptions) (zerogit.PRResult, error) {
return zerogit.PRResult{Output: "https://example.invalid/pr/2"}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if isDefaultBranchCalled {
t.Fatal("expected isDefaultBranch not to be consulted under --yes")
}
if upstreamBranchArg != "feature/fork-work" {
t.Fatalf("expected branchUpstreamRemote to receive current branch, got %q", upstreamBranchArg)
}
if unbornRemoteChecked != "upstream" {
t.Fatalf("expected unborn check against upstream, got %q", unbornRemoteChecked)
}
}
// TestRunChangesPRYesRejectsUnbornNonOriginUpstream: when the resolved
// upstream is unborn, the preflight must still block even under --yes.
func TestRunChangesPRYesRejectsUnbornNonOriginUpstream(t *testing.T) {
cwd := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "pr", "--yes", "--fill"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
currentGitBranch: func(ctx context.Context, cwd string) string {
return "feature/fork-work"
},
branchUpstreamRemote: func(ctx context.Context, cwd, branch string) string {
return "upstream"
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
if remote != "upstream" {
t.Fatalf("expected remote upstream, got %q", remote)
}
return true, nil
},
pushChanges: func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
t.Fatal("pushChanges should not run on unborn upstream")
return zerogit.PushResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d: %s", exitUsage, exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "cannot create pull request on unborn remote upstream") {
t.Fatalf("unexpected stderr: %q", stderr.String())
}
}
// TestRunChangesBareRemotePushThenPRUsable is the end-to-end bare-remote
// regression for jatmn's unborn P1: auto-branch must not be the first remote
// ref. Establish main with --yes, then auto-branch a second commit and show
// that changes pr remains usable (push + createPR succeed against a remote
// that has a real default branch).
func TestRunChangesBareRemotePushThenPRUsable(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("git unavailable: %v", err)
}
// The fixture relies on `git init --bare -b main` to point the bare
// remote's HEAD at main; older git (before 2.28) lacks -b on init and
// would leave the remote on master, failing the test for the wrong reason.
requireGitAtLeast(t, 2, 28)
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
tmp := t.TempDir()
bare := filepath.Join(tmp, "remote.git")
repo := filepath.Join(tmp, "repo")
// -b main so remote HEAD points at the default branch GitHub/new repos use;
// otherwise a bare repo's HEAD stays on master and IsDefaultBranch fails
// closed after the first main push (dangling HEAD + existing heads).
runWorkflowGit(t, tmp, "init", "--bare", "-b", "main", bare)
runWorkflowGit(t, tmp, "init", repo)
runWorkflowGit(t, repo, "config", "user.name", "Zero")
runWorkflowGit(t, repo, "config", "user.email", "[email protected]")
runWorkflowGit(t, repo, "checkout", "-b", "main")
writeWorkflowFile(t, filepath.Join(repo, "README.md"), "initial\n")
runWorkflowGit(t, repo, "add", "README.md")
runWorkflowGit(t, repo, "commit", "-m", "Initial commit")
runWorkflowGit(t, repo, "remote", "add", "origin", bare)
// Auto-branch on an unborn remote must refuse (not publish only a feature branch).
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "push"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return repo, nil },
})
if exitCode == exitSuccess {
t.Fatalf("expected unborn remote to refuse auto-branch, stdout=%q stderr=%q", stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "no branches yet") {
t.Fatalf("expected unborn refuse message, got stderr=%q", stderr.String())
}
// Establish the default branch.
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"changes", "push", "--yes"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return repo, nil },
})
if exitCode != exitSuccess {
t.Fatalf("push --yes failed: exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
}
heads := runWorkflowGit(t, bare, "show-ref", "--heads")
if !strings.Contains(heads, "refs/heads/main") {
t.Fatalf("expected bare remote to have main after --yes, got %q", heads)
}
// Second commit: auto-branch + push, then pr against an established remote.
writeWorkflowFile(t, filepath.Join(repo, "feature.txt"), "work\n")
runWorkflowGit(t, repo, "add", "feature.txt")
runWorkflowGit(t, repo, "commit", "-m", "add feature")
var pushedBranch string
var prCalled bool
stdout.Reset()
stderr.Reset()
deps := defaultAppDeps()
deps.getwd = func() (string, error) { return repo, nil }
deps.currentGitUser = func(ctx context.Context, cwd string) string { return "Someone" }
basePush := deps.pushChanges
deps.pushChanges = func(ctx context.Context, options zerogit.PushOptions) (zerogit.PushResult, error) {
pushedBranch = options.Branch
return basePush(ctx, options)
}
deps.createPR = func(ctx context.Context, options zerogit.PROptions) (zerogit.PRResult, error) {
prCalled = true
return zerogit.PRResult{Output: "https://example.invalid/pr/1"}, nil
}
exitCode = runWithDeps([]string{"changes", "push"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("auto-branch push failed: exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
}
if pushedBranch == "" || pushedBranch == "main" {
t.Fatalf("expected auto-created feature branch push, got %q", pushedBranch)
}
heads = runWorkflowGit(t, bare, "show-ref", "--heads")
if !strings.Contains(heads, "refs/heads/main") {
t.Fatalf("expected main to remain on bare remote, got %q", heads)
}
if !strings.Contains(heads, "refs/heads/"+pushedBranch) {
t.Fatalf("expected feature branch %q on bare remote, got %q", pushedBranch, heads)
}
// The feature branch must exclusively own the new commit: local main goes
// back to its upstream tip so it does not diverge after a squash-merge.
localMain := strings.TrimSpace(runWorkflowGit(t, repo, "rev-parse", "refs/heads/main"))
remoteMain := strings.TrimSpace(runWorkflowGit(t, repo, "rev-parse", "refs/remotes/origin/main"))
if localMain != remoteMain {
t.Fatalf("expected local main restored to origin/main, got %s vs %s", localMain, remoteMain)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"changes", "pr", "--fill"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("changes pr failed after established remote: exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
}
if !prCalled {
t.Fatal("expected createPR to run once the default branch exists")
}
}
func runWorkflowGit(t *testing.T, dir string, args ...string) string {
t.Helper()
ctx := context.Background()
if dl, ok := t.Deadline(); ok {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, dl)
defer cancel()
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(out))
}
return string(out)
}
// requireGitAtLeast skips the test when the installed Git predates the given
// version, for tests that rely on newer git features (for example
// `git init --bare -b main`, added in 2.28).
func requireGitAtLeast(t *testing.T, wantMajor, wantMinor int) {
t.Helper()
out, err := exec.Command("git", "--version").Output()
if err != nil {
t.Skipf("cannot determine git version: %v", err)
}
version := strings.TrimSpace(string(out))
rest, ok := strings.CutPrefix(version, "git version ")
if !ok {
t.Skipf("cannot parse git version %q", version)
}
var major, minor int
if n, err := fmt.Sscanf(rest, "%d.%d", &major, &minor); err != nil || n != 2 {
t.Skipf("cannot parse git version %q", version)
}
if major < wantMajor || (major == wantMajor && minor < wantMinor) {
t.Skipf("git %d.%d+ required, have %s", wantMajor, wantMinor, version)
}
}
func writeWorkflowFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}