mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* 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]>
1633 lines
60 KiB
Go
1633 lines
60 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
"github.com/Gitlawb/zero/internal/selfverify"
|
|
"github.com/Gitlawb/zero/internal/testrunner"
|
|
"github.com/Gitlawb/zero/internal/verify"
|
|
"github.com/Gitlawb/zero/internal/worktrees"
|
|
"github.com/Gitlawb/zero/internal/zerogit"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
type worktreeCommandOptions struct {
|
|
json bool
|
|
name string
|
|
baseDir string
|
|
cwd string
|
|
}
|
|
|
|
type verifyCommandOptions struct {
|
|
json bool
|
|
cwd string
|
|
only []string
|
|
timeoutMS int
|
|
attempts int
|
|
}
|
|
|
|
type changesCommandOptions struct {
|
|
json bool
|
|
cwd string
|
|
baseRef string
|
|
message string
|
|
hasMessage bool
|
|
dryRun bool
|
|
maxDiffBytes int
|
|
remote string
|
|
force bool
|
|
title string
|
|
body string
|
|
fill bool
|
|
draft bool
|
|
yes bool
|
|
auto bool
|
|
}
|
|
|
|
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
command := "prepare"
|
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
|
command = strings.ToLower(strings.TrimSpace(args[0]))
|
|
args = args[1:]
|
|
}
|
|
if command == "help" {
|
|
if err := writeWorktreesHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if command == "release" {
|
|
return runWorktreesRelease(args, stdout, stderr, deps)
|
|
}
|
|
if command != "prepare" {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees command %q", command))
|
|
}
|
|
options, help, err := parseWorktreeCommandArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeWorktreesHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
result, err := deps.prepareWorktree(context.Background(), worktrees.Options{
|
|
Cwd: workspaceRoot,
|
|
Name: options.name,
|
|
BaseDir: options.baseDir,
|
|
Now: deps.now,
|
|
})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
safeResult := redactWorktreeResult(result)
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, safeResult); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatWorktreeResult(safeResult)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
// runWorktreesRelease unlocks a worktree `zero worktrees prepare` created, so
|
|
// Zero's automatic Clean pass can reclaim it once it goes stale. Prepare locks
|
|
// every worktree it creates and nothing else in that command's own lifetime
|
|
// unlocks it (its process exits right after printing the path, long before
|
|
// whatever external caller actually finishes using the worktree), so that
|
|
// caller is responsible for running this once it is done.
|
|
func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
var path string
|
|
var cwdFlag string
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
if err := writeWorktreesHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case arg == "-C" || arg == "--cwd":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
cwdFlag = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--cwd="):
|
|
cwdFlag = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
|
|
case strings.HasPrefix(arg, "-"):
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees release flag %q", arg))
|
|
default:
|
|
if path != "" {
|
|
return writeExecUsageError(stderr, "worktree path was provided more than once")
|
|
}
|
|
path = arg
|
|
}
|
|
}
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return writeExecUsageError(stderr, "worktrees release requires a worktree path")
|
|
}
|
|
// git worktree unlock matches against the path git recorded when the
|
|
// worktree was created, so a relative argument here (resolved against
|
|
// whatever directory the caller happens to be running in, not
|
|
// necessarily the worktree's own) can fail to match. Resolve to absolute
|
|
// up front so the unlock target is unambiguous regardless of cwd.
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, redactCLIString(err.Error()))
|
|
}
|
|
// Release falls back to Options.Cwd as git's working directory when the
|
|
// worktree directory itself was deleted by hand instead of released; with
|
|
// no Cwd wired in, that recovery path runs `git worktree unlock` from
|
|
// wherever the caller happens to be, which outside the source repository
|
|
// leaves the orphaned lock behind forever (Clean skips locked entries).
|
|
// -C/--cwd names the source repository explicitly for exactly that case,
|
|
// since the deleted worktree path itself carries no way back to the repo;
|
|
// without the flag, resolve the launch directory best-effort (the
|
|
// ordinary existing-path case does not need it, so a resolution failure
|
|
// is not an error here).
|
|
releaseOptions := worktrees.Options{}
|
|
if cwdFlag != "" {
|
|
workspaceRoot, err := resolveWorkspaceRoot(cwdFlag, deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, redactCLIString(err.Error()))
|
|
}
|
|
releaseOptions.Cwd = workspaceRoot
|
|
} else if workspaceRoot, rootErr := resolveWorkspaceRoot("", deps); rootErr == nil {
|
|
releaseOptions.Cwd = workspaceRoot
|
|
}
|
|
if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil {
|
|
return writeExecUsageError(stderr, redactCLIString(err.Error()))
|
|
}
|
|
// Print the absolute path that was released so relative arguments still
|
|
// show an unambiguous target in the confirmation line.
|
|
if _, err := fmt.Fprintf(stdout, "released %s\n", redactCLIString(absPath)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runVerifyCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseVerifyCommandArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeVerifyHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
plan, err := deps.detectVerifyPlan(workspaceRoot)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if options.attempts > 1 {
|
|
loopReport := deps.runSelfVerify(context.Background(), plan, selfverify.Options{
|
|
RunOptions: verify.RunOptions{
|
|
Only: options.only,
|
|
TimeoutMS: options.timeoutMS,
|
|
Now: deps.now,
|
|
},
|
|
MaxAttempts: options.attempts,
|
|
})
|
|
safeLoopReport := redactVerifyLoopReport(loopReport)
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, selfverify.SnapshotFromReport(safeLoopReport)); err != nil {
|
|
return exitCrash
|
|
}
|
|
} else if _, err := fmt.Fprintln(stdout, formatVerifyLoopReport(safeLoopReport)); err != nil {
|
|
return exitCrash
|
|
}
|
|
if !loopReport.OK {
|
|
return exitProvider
|
|
}
|
|
return exitSuccess
|
|
}
|
|
report := deps.runVerify(context.Background(), plan, verify.RunOptions{
|
|
Only: options.only,
|
|
TimeoutMS: options.timeoutMS,
|
|
Now: deps.now,
|
|
})
|
|
safeReport := redactVerifyReport(report)
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, verify.SnapshotFromReport(safeReport)); err != nil {
|
|
return exitCrash
|
|
}
|
|
} else if _, err := fmt.Fprintln(stdout, formatVerifyReport(safeReport)); err != nil {
|
|
return exitCrash
|
|
}
|
|
if !report.OK {
|
|
return exitProvider
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
command := "inspect"
|
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
|
command = strings.ToLower(strings.TrimSpace(args[0]))
|
|
args = args[1:]
|
|
}
|
|
if command == "help" {
|
|
if err := writeChangesHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
options, help, err := parseChangesArgs(args, command)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeChangesHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
switch command {
|
|
case "inspect", "status":
|
|
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
|
|
Cwd: workspaceRoot,
|
|
BaseRef: options.baseRef,
|
|
MaxDiffBytes: options.maxDiffBytes,
|
|
})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
safeSummary := redactChangeSummary(summary)
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, zerogit.SnapshotFromSummary(safeSummary)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatChangeSummary(safeSummary)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "commit":
|
|
var message string
|
|
if options.auto {
|
|
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
|
|
Cwd: workspaceRoot,
|
|
MaxDiffBytes: options.maxDiffBytes,
|
|
})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
|
|
}
|
|
if summary.Clean {
|
|
return writeExecUsageError(stderr, "no changes to commit")
|
|
}
|
|
|
|
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
|
|
}
|
|
if !config.HasProviderProfile(resolved.Provider) {
|
|
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
|
|
}
|
|
provider, err := deps.newProvider(resolved.Provider)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
|
|
}
|
|
|
|
if !options.json {
|
|
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
|
|
safeSummary := redactChangeSummary(summary)
|
|
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
|
|
}
|
|
message = msg
|
|
} else {
|
|
message = options.message
|
|
}
|
|
|
|
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
|
|
Cwd: workspaceRoot,
|
|
Message: message,
|
|
DryRun: options.dryRun,
|
|
MaxDiffBytes: options.maxDiffBytes,
|
|
})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
safeResult := redactCommitResult(result)
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, safeResult); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatCommitResult(safeResult)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "push":
|
|
return runChangesPush(args, stdout, stderr, deps)
|
|
case "pr", "pull-request":
|
|
return runChangesPR(args, stdout, stderr, deps)
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command))
|
|
}
|
|
}
|
|
|
|
func parseWorktreeCommandArgs(args []string) (worktreeCommandOptions, bool, error) {
|
|
options := worktreeCommandOptions{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "--name":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
if err := setWorktreeName(&options, value); err != nil {
|
|
return options, false, err
|
|
}
|
|
index = next
|
|
case strings.HasPrefix(arg, "--name="):
|
|
if err := setWorktreeName(&options, strings.TrimPrefix(arg, "--name=")); err != nil {
|
|
return options, false, err
|
|
}
|
|
case arg == "--dir":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.baseDir = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--dir="):
|
|
options.baseDir = strings.TrimSpace(strings.TrimPrefix(arg, "--dir="))
|
|
case arg == "-C" || arg == "--cwd":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.cwd = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--cwd="):
|
|
options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown worktrees flag %q", arg)}
|
|
default:
|
|
if err := setWorktreeName(&options, arg); err != nil {
|
|
return options, false, err
|
|
}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func setWorktreeName(options *worktreeCommandOptions, value string) error {
|
|
name := strings.TrimSpace(value)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
if options.name != "" {
|
|
return execUsageError{"worktree name was provided more than once"}
|
|
}
|
|
options.name = name
|
|
return nil
|
|
}
|
|
|
|
func parseVerifyCommandArgs(args []string) (verifyCommandOptions, bool, error) {
|
|
options := verifyCommandOptions{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "-C" || arg == "--cwd":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.cwd = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--cwd="):
|
|
options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
|
|
case arg == "--only":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.only = append(options.only, parseToolList(value)...)
|
|
index = next
|
|
case strings.HasPrefix(arg, "--only="):
|
|
options.only = append(options.only, parseToolList(strings.TrimSpace(strings.TrimPrefix(arg, "--only=")))...)
|
|
case arg == "--timeout-ms":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
timeoutMS, err := parsePositiveIntFlag("--timeout-ms", value)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.timeoutMS = timeoutMS
|
|
index = next
|
|
case strings.HasPrefix(arg, "--timeout-ms="):
|
|
timeoutMS, err := parsePositiveIntFlag("--timeout-ms", strings.TrimSpace(strings.TrimPrefix(arg, "--timeout-ms=")))
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.timeoutMS = timeoutMS
|
|
case arg == "--attempts":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
attempts, err := parsePositiveIntFlag("--attempts", value)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.attempts = attempts
|
|
index = next
|
|
case strings.HasPrefix(arg, "--attempts="):
|
|
attempts, err := parsePositiveIntFlag("--attempts", strings.TrimSpace(strings.TrimPrefix(arg, "--attempts=")))
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.attempts = attempts
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown verify flag %q", arg)}
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected verify argument %q", arg)}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func parseChangesArgs(args []string, command string) (changesCommandOptions, bool, error) {
|
|
options := changesCommandOptions{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "--dry-run":
|
|
options.dryRun = true
|
|
case arg == "-C" || arg == "--cwd":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.cwd = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--cwd="):
|
|
options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
|
|
case arg == "--base":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.baseRef = strings.TrimSpace(value)
|
|
index = next
|
|
case strings.HasPrefix(arg, "--base="):
|
|
v := strings.TrimSpace(strings.TrimPrefix(arg, "--base="))
|
|
if v == "" || flagValueLooksLikeOption(v) {
|
|
return options, false, execUsageError{"--base requires a value"}
|
|
}
|
|
options.baseRef = v
|
|
case arg == "-m" || arg == "--message":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.message = value
|
|
options.hasMessage = true
|
|
index = next
|
|
case strings.HasPrefix(arg, "--message="):
|
|
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
|
|
options.hasMessage = true
|
|
case arg == "--diff-bytes":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
maxDiffBytes, err := parsePositiveIntFlag("--diff-bytes", value)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.maxDiffBytes = maxDiffBytes
|
|
index = next
|
|
case strings.HasPrefix(arg, "--diff-bytes="):
|
|
maxDiffBytes, err := parsePositiveIntFlag("--diff-bytes", strings.TrimSpace(strings.TrimPrefix(arg, "--diff-bytes=")))
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.maxDiffBytes = maxDiffBytes
|
|
case arg == "--remote":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.remote = strings.TrimSpace(value)
|
|
index = next
|
|
case strings.HasPrefix(arg, "--remote="):
|
|
options.remote = strings.TrimSpace(strings.TrimPrefix(arg, "--remote="))
|
|
case arg == "--force":
|
|
options.force = true
|
|
case arg == "--title":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.title = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--title="):
|
|
options.title = strings.TrimPrefix(arg, "--title=")
|
|
case arg == "--body":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.body = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--body="):
|
|
options.body = strings.TrimPrefix(arg, "--body=")
|
|
case arg == "--fill":
|
|
options.fill = true
|
|
case arg == "--draft":
|
|
options.draft = true
|
|
case arg == "--yes":
|
|
options.yes = true
|
|
case arg == "-a" || arg == "--auto":
|
|
options.auto = true
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
|
}
|
|
}
|
|
if command != "commit" && options.hasMessage {
|
|
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
|
|
}
|
|
if command == "commit" && options.hasMessage && options.auto {
|
|
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
|
}
|
|
if command != "commit" && command != "push" && options.dryRun {
|
|
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
|
|
}
|
|
// --auto on push/pr is the explicit opt-in for LLM branch naming (see
|
|
// ensureFeatureBranch); on commit it opts into the LLM commit message.
|
|
if command != "commit" && command != "push" && command != "pr" && options.auto {
|
|
return options, false, execUsageError{"--auto is only valid with commit, push, or pr"}
|
|
}
|
|
if command != "inspect" && options.baseRef != "" {
|
|
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
|
}
|
|
if command != "push" && command != "pr" && (options.remote != "" || options.force) {
|
|
return options, false, execUsageError{"--remote and --force are only valid with push or pr"}
|
|
}
|
|
if command != "pr" && (options.title != "" || options.body != "" || options.fill || options.draft) {
|
|
return options, false, execUsageError{"--title, --body, --fill, and --draft are only valid with `zero changes pr`"}
|
|
}
|
|
if command != "push" && command != "pr" && options.yes {
|
|
return options, false, execUsageError{"--yes is only valid with push or pr"}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func parsePositiveIntFlag(flag string, value string) (int, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return 0, execUsageError{flag + " requires a value"}
|
|
}
|
|
number, err := strconv.Atoi(trimmed)
|
|
if err != nil || number <= 0 {
|
|
return 0, execUsageError{fmt.Sprintf("invalid %s %q. Expected a positive integer.", flag, value)}
|
|
}
|
|
return number, nil
|
|
}
|
|
|
|
func redactWorktreeResult(result worktrees.Result) worktrees.Result {
|
|
result.Name = redactCLIString(result.Name)
|
|
result.Path = redactCLIString(result.Path)
|
|
result.RepoRoot = redactCLIString(result.RepoRoot)
|
|
result.SourceBranch = redactCLIString(result.SourceBranch)
|
|
result.SourceCommit = redactCLIString(result.SourceCommit)
|
|
return result
|
|
}
|
|
|
|
func redactVerifyReport(report verify.Report) verify.Report {
|
|
report.Root = redactCLIString(report.Root)
|
|
report.Results = append([]verify.Result{}, report.Results...)
|
|
for index := range report.Results {
|
|
report.Results[index].Stdout = redactCLIString(report.Results[index].Stdout)
|
|
report.Results[index].Stderr = redactCLIString(report.Results[index].Stderr)
|
|
report.Results[index].Error = redactCLIString(report.Results[index].Error)
|
|
if report.Results[index].OutputSummary != nil {
|
|
summary := *report.Results[index].OutputSummary
|
|
summary.Lines = append([]string{}, summary.Lines...)
|
|
for lineIndex := range summary.Lines {
|
|
summary.Lines[lineIndex] = redactCLIString(summary.Lines[lineIndex])
|
|
}
|
|
report.Results[index].OutputSummary = &summary
|
|
}
|
|
if report.Results[index].TestSummary != nil {
|
|
summary := *report.Results[index].TestSummary
|
|
summary.Failures = append([]testrunner.Failure{}, summary.Failures...)
|
|
for failureIndex := range summary.Failures {
|
|
summary.Failures[failureIndex].Name = redactCLIString(summary.Failures[failureIndex].Name)
|
|
summary.Failures[failureIndex].File = redactCLIString(summary.Failures[failureIndex].File)
|
|
summary.Failures[failureIndex].Message = redactCLIString(summary.Failures[failureIndex].Message)
|
|
}
|
|
report.Results[index].TestSummary = &summary
|
|
}
|
|
}
|
|
return report
|
|
}
|
|
|
|
func redactVerifyLoopReport(report selfverify.Report) selfverify.Report {
|
|
report.Root = redactCLIString(report.Root)
|
|
report.Error = redactCLIString(report.Error)
|
|
for index := range report.Attempts {
|
|
report.Attempts[index].Report = redactVerifyReport(report.Attempts[index].Report)
|
|
if report.Attempts[index].Remediation != nil {
|
|
remediation := *report.Attempts[index].Remediation
|
|
remediation.StartedAt = redactCLIString(remediation.StartedAt)
|
|
remediation.EndedAt = redactCLIString(remediation.EndedAt)
|
|
remediation.Message = redactCLIString(remediation.Message)
|
|
remediation.Error = redactCLIString(remediation.Error)
|
|
report.Attempts[index].Remediation = &remediation
|
|
}
|
|
}
|
|
return report
|
|
}
|
|
|
|
func redactChangeSummary(summary zerogit.ChangeSummary) zerogit.ChangeSummary {
|
|
summary.Root = redactCLIString(summary.Root)
|
|
summary.Base = redactCLIString(summary.Base)
|
|
summary.Branch = redactCLIString(summary.Branch)
|
|
summary.Commit = redactCLIString(summary.Commit)
|
|
summary.DiffStat = redactCLIString(summary.DiffStat)
|
|
summary.Diff = redactCLIString(summary.Diff)
|
|
for index := range summary.Files {
|
|
summary.Files[index].Path = redactCLIString(summary.Files[index].Path)
|
|
summary.Files[index].Status = redactCLIString(summary.Files[index].Status)
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func redactCommitResult(result zerogit.CommitResult) zerogit.CommitResult {
|
|
result.Root = redactCLIString(result.Root)
|
|
result.Message = redactCLIString(result.Message)
|
|
result.CommitHash = redactCLIString(result.CommitHash)
|
|
result.Before = redactChangeSummary(result.Before)
|
|
return result
|
|
}
|
|
|
|
func redactCLIString(value string) string {
|
|
// Keep ordinary paths visible; these commands report useful locations.
|
|
// Central redaction still removes secret-looking tokens embedded in paths.
|
|
return redaction.RedactString(value, redaction.Options{})
|
|
}
|
|
|
|
func formatWorktreeResult(result worktrees.Result) string {
|
|
lines := []string{
|
|
"Zero worktree ready",
|
|
"name: " + result.Name,
|
|
"path: " + result.Path,
|
|
"repo: " + result.RepoRoot,
|
|
}
|
|
if result.SourceBranch != "" {
|
|
lines = append(lines, "branch: "+result.SourceBranch)
|
|
}
|
|
if result.SourceCommit != "" {
|
|
lines = append(lines, "commit: "+result.SourceCommit)
|
|
}
|
|
if result.Reused {
|
|
lines = append(lines, "reused: true")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatVerifyReport(report verify.Report) string {
|
|
lines := []string{
|
|
"Zero verification",
|
|
"root: " + report.Root,
|
|
fmt.Sprintf("summary: %d total, %d passed, %d failed, %d errors", report.Summary.Total, report.Summary.Passed, report.Summary.Failed, report.Summary.Errors),
|
|
}
|
|
if len(report.Results) == 0 {
|
|
lines = append(lines, " (no checks detected)")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
for _, result := range report.Results {
|
|
lines = append(lines, fmt.Sprintf(" [%s] %s - %s", result.Status, result.ID, strings.Join(result.Command, " ")))
|
|
if result.TestSummary != nil {
|
|
lines = append(lines, formatVerifyTestSummary(result.TestSummary))
|
|
for _, failure := range result.TestSummary.Failures {
|
|
if failure.Name == "" {
|
|
continue
|
|
}
|
|
detail := failure.Name
|
|
if failure.File != "" {
|
|
detail += " at " + failure.File
|
|
}
|
|
lines = append(lines, " failure: "+detail)
|
|
}
|
|
}
|
|
if result.Error != "" {
|
|
lines = append(lines, " error: "+result.Error)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatVerifyTestSummary(summary *testrunner.Summary) string {
|
|
line := fmt.Sprintf(" tests: %d total, %d passed, %d failed", summary.Total, summary.Passed, summary.Failed)
|
|
if summary.Skipped > 0 {
|
|
line += fmt.Sprintf(", %d skipped", summary.Skipped)
|
|
}
|
|
return line
|
|
}
|
|
|
|
func formatVerifyLoopReport(report selfverify.Report) string {
|
|
lines := []string{
|
|
"Zero self-verification",
|
|
}
|
|
if report.Root != "" {
|
|
lines = append(lines, "root: "+report.Root)
|
|
}
|
|
lines = append(lines,
|
|
fmt.Sprintf("attempts: %d", len(report.Attempts)),
|
|
"stop: "+string(report.StopReason),
|
|
fmt.Sprintf("summary: %d total, %d passed, %d failed, %d errors", report.Summary.Total, report.Summary.Passed, report.Summary.Failed, report.Summary.Errors),
|
|
)
|
|
for _, attempt := range report.Attempts {
|
|
status := "failed"
|
|
if attempt.Report.OK {
|
|
status = "passed"
|
|
}
|
|
lines = append(lines, fmt.Sprintf(" attempt %d: %s", attempt.Number, status))
|
|
if attempt.Remediation != nil {
|
|
lines = append(lines, " remediation: "+formatRemediation(*attempt.Remediation))
|
|
}
|
|
}
|
|
if report.Error != "" {
|
|
lines = append(lines, "error: "+report.Error)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatRemediation(remediation selfverify.Remediation) string {
|
|
status := "not applied"
|
|
if remediation.Applied {
|
|
status = "applied"
|
|
}
|
|
details := []string{status}
|
|
if remediation.Message != "" {
|
|
details = append(details, remediation.Message)
|
|
}
|
|
if remediation.Error != "" {
|
|
details = append(details, "error: "+remediation.Error)
|
|
}
|
|
return strings.Join(details, " - ")
|
|
}
|
|
|
|
func formatChangeSummary(summary zerogit.ChangeSummary) string {
|
|
lines := []string{
|
|
"Zero changes",
|
|
"root: " + summary.Root,
|
|
fmt.Sprintf("files: %d changed", len(summary.Files)),
|
|
}
|
|
if summary.Branch != "" {
|
|
lines = append(lines, "branch: "+summary.Branch)
|
|
}
|
|
if summary.Base != "" {
|
|
lines = append(lines, "base: "+summary.Base)
|
|
}
|
|
if summary.Commit != "" {
|
|
lines = append(lines, "commit: "+summary.Commit)
|
|
}
|
|
if summary.Clean {
|
|
lines = append(lines, "clean: true")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
for _, file := range summary.Files {
|
|
lines = append(lines, fmt.Sprintf(" [%s] %s", file.Status, file.Path))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatCommitResult(result zerogit.CommitResult) string {
|
|
lines := []string{
|
|
"Zero changes commit",
|
|
"root: " + result.Root,
|
|
"message: " + result.Message,
|
|
fmt.Sprintf("dry-run: %t", result.DryRun),
|
|
fmt.Sprintf("committed: %t", result.Committed),
|
|
fmt.Sprintf("files: %d changed", len(result.Before.Files)),
|
|
}
|
|
if result.CommitHash != "" {
|
|
lines = append(lines, "commit: "+result.CommitHash)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func writeWorktreesHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero worktrees prepare [flags] [name]
|
|
zero worktrees release [flags] <path>
|
|
|
|
Prepares an isolated git worktree for a Zero task.
|
|
|
|
prepare locks the worktree it creates so Zero's automatic cleanup never
|
|
removes it out from under you. release unlocks a worktree prepare created,
|
|
once you are done with it, so cleanup can reclaim it later if it goes stale.
|
|
If the worktree directory was deleted by hand, run release with -C pointing
|
|
at the source repository so the orphaned lock can still be cleared.
|
|
|
|
prepare flags:
|
|
--name <name> Worktree name; defaults to a timestamped task name
|
|
--dir <path> Base directory for Zero worktrees
|
|
-C, --cwd <path> Source repository directory
|
|
--json Print JSON output
|
|
-h, --help Show this help
|
|
|
|
release flags:
|
|
-C, --cwd <path> Source repository directory; needed when the
|
|
worktree directory was already deleted and you
|
|
are not running inside the source repository
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeVerifyHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero verify [flags]
|
|
|
|
Detects and runs local verification checks for the workspace.
|
|
|
|
Flags:
|
|
-C, --cwd <path> Workspace directory
|
|
--only <ids> Run only matching check ids
|
|
--timeout-ms <n> Per-check timeout in milliseconds
|
|
--attempts <n> Run a bounded self-verification loop
|
|
--json Print JSON output
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeChangesHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero changes inspect [flags]
|
|
zero changes commit [flags]
|
|
zero changes push [flags]
|
|
zero changes pr [flags]
|
|
|
|
Inspects, commits, pushes, and creates pull requests for local git changes.
|
|
|
|
Flags:
|
|
-C, --cwd <path> Workspace directory
|
|
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
|
--diff-bytes <n> Maximum diff bytes to include
|
|
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
|
--dry-run Preview commit metadata / push without mutating git state
|
|
--remote <name> Remote to push to (defaults to upstream tracked branch or origin)
|
|
--force Use force-with-lease when pushing
|
|
--title <text> PR title
|
|
--body <text> PR body
|
|
--fill Automatically populate PR title and body from commits
|
|
--draft Create PR as a draft
|
|
--yes Confirm pushing to a default/protected branch
|
|
-a, --auto Use the LLM: commit generates the message, push/pr name the auto-created branch (sends the diff to the provider)
|
|
--json Print JSON output
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseChangesArgs(args, "push")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeChangesHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
|
|
JSONMode: options.json,
|
|
AllowDefaultBranch: options.yes,
|
|
DryRun: options.dryRun,
|
|
AutoNaming: options.auto,
|
|
MaxDiffBytes: options.maxDiffBytes,
|
|
}, deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
// context.Background() is deliberate: the 2-minute ctx above is the
|
|
// feature-branch preflight (inspection, remote probes, branch creation).
|
|
// The push itself must not be cancelled mid-transfer just because the
|
|
// preflight budget elapsed; a large push can legitimately take longer.
|
|
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
|
Cwd: workspaceRoot,
|
|
Remote: firstNonEmptyString(options.remote, remote),
|
|
Branch: branch,
|
|
Force: options.force,
|
|
DryRun: options.dryRun,
|
|
RequireNewRemoteBranch: created,
|
|
AllowPushDefaultBranch: options.yes,
|
|
})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, result); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
dryRunStr := ""
|
|
if options.dryRun {
|
|
dryRunStr = " (dry run)"
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s%s\n", result.Branch, result.Remote, dryRunStr); err != nil {
|
|
return exitCrash
|
|
}
|
|
if result.Output != "" {
|
|
if _, err := fmt.Fprintln(stdout, result.Output); err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseChangesArgs(args, "pr")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeChangesHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if !options.fill && options.title == "" {
|
|
return writeExecUsageError(stderr, "must provide either --fill or --title to run non-interactively")
|
|
}
|
|
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
|
|
JSONMode: options.json,
|
|
AllowDefaultBranch: options.yes,
|
|
DryRun: false,
|
|
AutoNaming: options.auto,
|
|
MaxDiffBytes: options.maxDiffBytes,
|
|
}, deps)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
// Resolve the target remote for the unborn-remote check. Explicit --remote wins,
|
|
// followed by the remote resolved by ensureFeatureBranch. When --yes is set,
|
|
// fall back to the branch upstream or "origin".
|
|
targetRemote := strings.TrimSpace(options.remote)
|
|
if targetRemote == "" {
|
|
targetRemote = remote
|
|
}
|
|
if targetRemote == "" {
|
|
currentBranch := ""
|
|
if deps.currentGitBranch != nil {
|
|
currentBranch = deps.currentGitBranch(context.Background(), workspaceRoot)
|
|
}
|
|
if deps.branchUpstreamRemote != nil && currentBranch != "" {
|
|
targetRemote = deps.branchUpstreamRemote(context.Background(), workspaceRoot, currentBranch)
|
|
}
|
|
if targetRemote == "" {
|
|
targetRemote = "origin"
|
|
}
|
|
}
|
|
|
|
// Probe errors are intentionally ignored so the flow can continue; if the
|
|
// probe fails, the subsequent push or PR operation provides fail-closed behavior.
|
|
if deps.isUnbornRemote != nil {
|
|
if unborn, unbornErr := deps.isUnbornRemote(context.Background(), workspaceRoot, targetRemote); unbornErr == nil && unborn {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("cannot create pull request on unborn remote %s: push the initial default branch first", targetRemote))
|
|
}
|
|
}
|
|
|
|
if !options.json {
|
|
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
// context.Background() is deliberate here too: runChangesPR reuses the
|
|
// 2-minute ctx for preflight (branch creation and remote probes), but the
|
|
// push must not be cancelled mid-transfer if that budget elapsed.
|
|
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
|
Cwd: workspaceRoot,
|
|
Remote: firstNonEmptyString(options.remote, remote),
|
|
Branch: branch,
|
|
Force: options.force,
|
|
RequireNewRemoteBranch: created,
|
|
AllowPushDefaultBranch: options.yes,
|
|
})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("auto-push failed: %v", err))
|
|
}
|
|
if !options.json {
|
|
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s\n", pushResult.Branch, pushResult.Remote); err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
|
|
prResult, err := deps.createPR(context.Background(), zerogit.PROptions{
|
|
Cwd: workspaceRoot,
|
|
Fill: options.fill,
|
|
Draft: options.draft,
|
|
Title: options.title,
|
|
Body: options.body,
|
|
})
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
if options.json {
|
|
type prJSONResult struct {
|
|
Branch string `json:"branch"`
|
|
Remote string `json:"remote"`
|
|
Output string `json:"output"`
|
|
}
|
|
res := prJSONResult{
|
|
Branch: pushResult.Branch,
|
|
Remote: pushResult.Remote,
|
|
Output: strings.TrimSpace(prResult.Output),
|
|
}
|
|
if err := writePrettyJSON(stdout, res); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
if _, err := fmt.Fprintln(stdout, strings.TrimSpace(prResult.Output)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
|
var promptBuilder strings.Builder
|
|
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
|
|
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
|
|
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
|
|
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
|
|
promptBuilder.WriteString("Git Diff:\n")
|
|
promptBuilder.WriteString(summary.Diff)
|
|
|
|
request := zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{
|
|
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
|
|
},
|
|
}
|
|
stream, err := provider.StreamCompletion(ctx, request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
collected := zeroruntime.CollectStream(ctx, stream)
|
|
if collected.Error != "" {
|
|
return "", fmt.Errorf("%s", collected.Error)
|
|
}
|
|
|
|
msg := strings.TrimSpace(collected.Text)
|
|
if strings.HasPrefix(msg, "```") {
|
|
if idx := strings.Index(msg, "\n"); idx != -1 {
|
|
msg = msg[idx+1:]
|
|
}
|
|
}
|
|
msg = strings.TrimSuffix(msg, "```")
|
|
msg = strings.TrimSpace(msg)
|
|
if msg == "" {
|
|
return "", fmt.Errorf("provider returned empty commit message")
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
type featureBranchOptions struct {
|
|
JSONMode bool
|
|
AllowDefaultBranch bool
|
|
DryRun bool
|
|
AutoNaming bool
|
|
MaxDiffBytes int
|
|
}
|
|
|
|
// branchPushPlan holds the resolved parameters for a push or PR workflow
|
|
// before any branches are created or refs mutated. Separating plan resolution
|
|
// from mutation allows tests to assert the resolution matrix directly without
|
|
// touching a git repository.
|
|
type branchPushPlan struct {
|
|
ShouldBranch bool
|
|
CurrentBranch string
|
|
PushRemote string
|
|
RequireNewRemoteBranch bool
|
|
TrackingRemote string
|
|
TrackingBranch string
|
|
RestoreTip string
|
|
PublishabilityBase string
|
|
Summary zerogit.ChangeSummary
|
|
Slug string
|
|
}
|
|
|
|
// resolveBranchPushPlan resolves the branch, remotes, restore tip, and naming
|
|
// slug for changes push / pr workflows. It performs all checks (clean working
|
|
// tree, unborn remote detection, ahead count, restore tip resolution) but
|
|
// executes no mutation (no branch creation, ref resets, or deletions).
|
|
func resolveBranchPushPlan(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (branchPushPlan, error) {
|
|
if opts.AllowDefaultBranch || opts.DryRun {
|
|
return branchPushPlan{
|
|
ShouldBranch: false,
|
|
PushRemote: strings.TrimSpace(requestedRemote),
|
|
RequireNewRemoteBranch: false,
|
|
}, nil
|
|
}
|
|
|
|
if deps.isDefaultBranch == nil {
|
|
return branchPushPlan{}, fmt.Errorf("isDefaultBranch dependency missing")
|
|
}
|
|
isDefault, currentBranch, remote, err := deps.isDefaultBranch(ctx, zerogit.DefaultBranchOptions{Cwd: workspaceRoot, Remote: requestedRemote})
|
|
if err != nil {
|
|
return branchPushPlan{}, err
|
|
}
|
|
if !isDefault {
|
|
// Whether this push needs the nonexistence lease is decided by one
|
|
// live check against the destination remote, not by inference from
|
|
// local git state (upstream config, a generated-branch marker):
|
|
// every local signal that stood in for that check turned out to be
|
|
// wrong in some ordering (inherited config from checkout -b, a
|
|
// config write that failed after a successful push, a push aimed at
|
|
// a different remote than the one local config describes). A branch
|
|
// missing on the remote gets the nonexistence lease, protecting a
|
|
// concurrent creator of the same name; a branch already there gets a
|
|
// plain push, and git's own fast-forward check is the safety net —
|
|
// if the remote history isn't ours, the push fails with a clear
|
|
// non-fast-forward error instead of an attempted silent recovery.
|
|
if deps.remoteHasBranch == nil {
|
|
return branchPushPlan{
|
|
ShouldBranch: false,
|
|
CurrentBranch: currentBranch,
|
|
PushRemote: remote,
|
|
RequireNewRemoteBranch: true,
|
|
}, nil
|
|
}
|
|
targetRemote := firstNonEmptyString(requestedRemote, remote)
|
|
exists, existsErr := deps.remoteHasBranch(ctx, workspaceRoot, targetRemote, currentBranch)
|
|
if existsErr != nil {
|
|
return branchPushPlan{}, fmt.Errorf("cannot check whether %s already exists on remote %s: %w", currentBranch, targetRemote, existsErr)
|
|
}
|
|
return branchPushPlan{
|
|
ShouldBranch: false,
|
|
CurrentBranch: currentBranch,
|
|
PushRemote: remote,
|
|
RequireNewRemoteBranch: !exists,
|
|
}, nil
|
|
}
|
|
|
|
// CreateBranch and Push publish commits only. A dirty working tree would
|
|
// leave uncommitted edits behind under a branch/PR that does not include
|
|
// them, so refuse until the tree is clean (commit or stash first).
|
|
workingTree, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot})
|
|
if err != nil {
|
|
return branchPushPlan{}, fmt.Errorf("failed to inspect working tree: %w", err)
|
|
}
|
|
if !workingTree.Clean {
|
|
return branchPushPlan{}, fmt.Errorf("working tree has uncommitted changes; commit or stash them before pushing from the default branch")
|
|
}
|
|
|
|
// Capture the source default branch's own upstream before refreshing or
|
|
// leaving it. --remote selects the push destination; restore must not
|
|
// rewrite local main to upstream/main when main still tracks origin/main
|
|
// (fork setups), and refreshing must update the exact tracking ref that
|
|
// will supply both the ahead count and the restore target.
|
|
trackingRemote := remote
|
|
trackingBranch := currentBranch
|
|
if deps.branchUpstreamRemoteAndMerge != nil {
|
|
if r, m := deps.branchUpstreamRemoteAndMerge(ctx, workspaceRoot, currentBranch); r != "" && m != "" {
|
|
trackingRemote = r
|
|
trackingBranch = m
|
|
}
|
|
}
|
|
|
|
// Refresh the local remote-tracking ref before trusting it: IsDefaultBranch
|
|
// already contacted the remote for its symref check, but the tracking ref
|
|
// commitsAhead reads from is only a local cache (written at clone or the
|
|
// last fetch) and can sit behind the remote's live tip. Left stale, a real
|
|
// publishable range could look like zero commits ahead, and the diff
|
|
// derived below (which reuses this same ref as its base) would be stale
|
|
// too. A failure here (offline, or a genuinely unborn remote with no such
|
|
// ref to fetch yet) is folded into the same fail-closed path as an
|
|
// unresolvable ahead count below.
|
|
var fetchErr error
|
|
if deps.refreshTrackingRef != nil {
|
|
fetchErr = deps.refreshTrackingRef(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
|
}
|
|
|
|
// The ahead check gates on the source upstream (trackingRemote), but the
|
|
// feature branch is pushed to `pushRemote`, which can be a different
|
|
// destination (--remote on a fork). Refuse to auto-branch onto an unborn
|
|
// destination: without an initial default branch the new feature branch
|
|
// becomes the destination's first ref and its HEAD is left pointing at it.
|
|
// This is checked once, here, against the push destination, before resolving
|
|
// commit tips or creating any branches.
|
|
pushRemote := firstNonEmptyString(requestedRemote, remote)
|
|
if deps.isUnbornRemote != nil {
|
|
if unbornDest, unbornDestErr := deps.isUnbornRemote(ctx, workspaceRoot, pushRemote); unbornDestErr == nil && unbornDest {
|
|
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", pushRemote)
|
|
}
|
|
}
|
|
|
|
// Branching off the default branch only makes sense when HEAD carries a
|
|
// commit that is not already on the remote default branch. A clean,
|
|
// up-to-date default branch would otherwise publish a feature branch at the
|
|
// exact default tip. If the ahead count cannot be determined (for example
|
|
// the remote-tracking ref was never fetched), fail rather than guess. When
|
|
// the remote is confirmed unborn, refuse auto-branching entirely: the
|
|
// initial default branch must be established first (changes push --yes)
|
|
// so the remote has a real HEAD before any feature branch is published.
|
|
ahead, aheadErr := deps.commitsAhead(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
|
if fetchErr != nil || aheadErr != nil {
|
|
var unbornRemote bool
|
|
var unbornErr error
|
|
if deps.isUnbornRemote != nil {
|
|
unbornRemote, unbornErr = deps.isUnbornRemote(ctx, workspaceRoot, trackingRemote)
|
|
}
|
|
if unbornErr == nil && unbornRemote {
|
|
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", trackingRemote)
|
|
}
|
|
cause := aheadErr
|
|
if cause == nil {
|
|
cause = fetchErr
|
|
}
|
|
return branchPushPlan{}, fmt.Errorf("cannot determine whether HEAD is ahead of %s/%s: %w; fetch the remote tracking branch first", trackingRemote, trackingBranch, cause)
|
|
}
|
|
if ahead == 0 {
|
|
return branchPushPlan{}, fmt.Errorf("no changes to publish: HEAD is not ahead of %s/%s; commit your work before pushing", trackingRemote, trackingBranch)
|
|
}
|
|
|
|
// Resolve the restore tip to a concrete commit before creating any branch.
|
|
// It must never be a bare local branch name: resolving "main" yields the
|
|
// very commit the feature branch is about to own, so restore becomes a
|
|
// silent no-op and the publishable commits stay on the default branch. A
|
|
// named remote resolves through its just-refreshed remote-tracking ref; a
|
|
// direct URL or path asks the remote itself. Fail closed rather than
|
|
// substitute a local branch name.
|
|
restoreTip := ""
|
|
if deps.resolveRemoteBranchTip != nil {
|
|
tip, tipErr := deps.resolveRemoteBranchTip(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
|
if tipErr != nil {
|
|
return branchPushPlan{}, fmt.Errorf("cannot resolve restore tip for %s/%s: %w", trackingRemote, trackingBranch, tipErr)
|
|
}
|
|
restoreTip = tip
|
|
}
|
|
if restoreTip == "" {
|
|
restoreTip = trackingRemote + "/" + trackingBranch
|
|
}
|
|
|
|
// Name the branch (and, with --auto, send the provider) from what HEAD is
|
|
// actually ahead of the resolved remote branch by, using the same ref
|
|
// commitsAhead just checked. A working-tree snapshot can describe edits a
|
|
// commit-only push will never include.
|
|
baseRef := restoreTip
|
|
summary, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot, BaseRef: baseRef, MaxDiffBytes: opts.MaxDiffBytes})
|
|
if err != nil {
|
|
return branchPushPlan{}, fmt.Errorf("failed to inspect changes: %w", err)
|
|
}
|
|
|
|
slug := fallbackBranchSlug(summary)
|
|
if len(summary.Files) == 0 {
|
|
// An empty commit (or one whose only content the diff omits) leaves
|
|
// nothing to derive a slug from; name the branch from the commit
|
|
// subject instead.
|
|
if subject := deps.headCommitSubject(ctx, workspaceRoot); subject != "" {
|
|
slug = zerogit.SlugifyBranchComponent(subject)
|
|
}
|
|
}
|
|
if opts.AutoNaming && strings.TrimSpace(summary.Diff) != "" {
|
|
llmSlug := ""
|
|
if resolved, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil && config.HasProviderProfile(resolved.Provider) {
|
|
if provider, provErr := deps.newProvider(resolved.Provider); provErr == nil {
|
|
if !opts.JSONMode {
|
|
fmt.Fprintln(stdout, "Generating branch name using LLM...")
|
|
}
|
|
genCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
generated, genErr := generateAutoBranchSlug(genCtx, provider, resolved.Provider.Model, redactChangeSummary(summary))
|
|
cancel()
|
|
if genErr == nil && generated != "" {
|
|
llmSlug = generated
|
|
}
|
|
}
|
|
}
|
|
if llmSlug != "" {
|
|
slug = llmSlug
|
|
} else if !opts.JSONMode {
|
|
// Cover resolveConfig failures, missing provider profiles,
|
|
// newProvider errors, generation errors, and empty results so the
|
|
// user is not left guessing after "Generating..." or silence.
|
|
fmt.Fprintln(stdout, "LLM branch naming unavailable; using deterministic name.")
|
|
}
|
|
}
|
|
|
|
return branchPushPlan{
|
|
ShouldBranch: true,
|
|
CurrentBranch: currentBranch,
|
|
PushRemote: pushRemote,
|
|
RequireNewRemoteBranch: true,
|
|
TrackingRemote: trackingRemote,
|
|
TrackingBranch: trackingBranch,
|
|
RestoreTip: restoreTip,
|
|
PublishabilityBase: baseRef,
|
|
Summary: summary,
|
|
Slug: slug,
|
|
}, nil
|
|
}
|
|
|
|
// ensureFeatureBranch is the branch-naming step `zero changes push`/`pr` run
|
|
// before pushing: pushing straight to the default branch is refused deeper in
|
|
// zerogit.Push, so rather than surface that as a dead end, create and switch
|
|
// to a conventionally named "<user>/<slug>" branch first. It returns the
|
|
// branch push/pr should target, or "" to mean "current HEAD branch, unchanged"
|
|
// (zerogit.Push already treats an empty Branch that way), plus the remote the
|
|
// preflight resolved (requestedRemote, then the original branch's configured
|
|
// upstream, then "origin"), plus whether Push should require that branch not
|
|
// already exist on that remote. Callers must pass the remote to Push: a
|
|
// freshly created branch has no tracking configuration, so Push's own
|
|
// fallback would silently retarget "origin" even when the work came from a
|
|
// branch tracking a different remote. Callers must also pass that third value
|
|
// through as Push's RequireNewRemoteBranch: CreateBranch's own
|
|
// remote-collision probe runs before this returns, and closing that race
|
|
// requires Push's push itself to assert the destination is still new. That
|
|
// requirement also carries across a retry: if the current branch is already
|
|
// non-default (this call didn't create it) but has not been successfully
|
|
// published to the exact target remote under the same branch name, keep the
|
|
// nonexistence lease. branch.<name>.remote alone is not enough: with
|
|
// branch.autoSetupMerge=inherit, checkout -b copies remote=origin from the
|
|
// source branch before any push. After a successful create, the original
|
|
// default branch ref is moved back to its own upstream tip (not necessarily
|
|
// the push destination) so the feature branch exclusively owns the
|
|
// publishable commits; a failed restore rolls the feature branch back.
|
|
// allowDefaultBranch (the --yes flag) and dryRun both opt out via the ""
|
|
// branch / false return, leaving Push's own guard/preview behavior on the
|
|
// default branch unaffected.
|
|
//
|
|
// autoNaming gates the LLM naming path (--auto): these commands were
|
|
// git-only, and sending the change diff to a configured provider on every
|
|
// default-branch push would silently export source code nobody asked to
|
|
// share. Without the opt-in the name comes from deterministic local
|
|
// information only. maxDiffBytes caps the committed-range diff Inspect
|
|
// returns, so a user who passed --diff-bytes to bound the proprietary source
|
|
// sent for LLM naming has that cap honored here just as the commit path does.
|
|
// The working tree must be clean and HEAD must be ahead of the resolved
|
|
// remote branch; both are verified before any branch is created. The
|
|
// inspectChanges, commitsAhead, headCommitSubject, currentGitUser, and
|
|
// createBranch dependencies are required on the default-branch path and are
|
|
// called without nil guards; fillAppDeps populates all of them. The
|
|
// refreshTrackingRef, resolveRemoteBranchTip, isUnbornRemote, resetBranchRef,
|
|
// and deleteBranch dependencies are optional and are skipped when nil.
|
|
func ensureFeatureBranch(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (string, string, bool, error) {
|
|
plan, err := resolveBranchPushPlan(ctx, stdout, workspaceRoot, requestedRemote, opts, deps)
|
|
if err != nil {
|
|
return "", "", false, err
|
|
}
|
|
if !plan.ShouldBranch {
|
|
return plan.CurrentBranch, plan.PushRemote, plan.RequireNewRemoteBranch, nil
|
|
}
|
|
|
|
expectedRestoreTip := ""
|
|
if deps.currentBranchTip != nil {
|
|
expectedRestoreTip = deps.currentBranchTip(ctx, workspaceRoot)
|
|
}
|
|
|
|
name := zerogit.BuildBranchName(deps.currentGitUser(ctx, workspaceRoot), plan.Slug)
|
|
result, err := deps.createBranch(ctx, zerogit.BranchOptions{Cwd: workspaceRoot, Name: name, Remote: plan.PushRemote})
|
|
if err != nil {
|
|
return "", "", false, fmt.Errorf("failed to create branch: %w", err)
|
|
}
|
|
// The normal flow commits on the default branch first, then creates the
|
|
// feature branch at the same HEAD. Without moving the original default
|
|
// ref back, local main keeps the pre-squash commits and diverges from
|
|
// its upstream after a squash-merge (and a later pull/push can re-publish
|
|
// them). Once the feature branch owns those commits, restore the default
|
|
// branch to the source branch's own upstream tip captured above.
|
|
// Failure-safe: if the restore fails, delete the feature branch and
|
|
// check the default branch back out so the tree is not left half-moved.
|
|
if deps.resetBranchRef != nil {
|
|
if err := deps.resetBranchRef(ctx, workspaceRoot, plan.CurrentBranch, plan.RestoreTip, expectedRestoreTip); err != nil {
|
|
// A compare-and-swap conflict means the default branch moved
|
|
// while we were branching, so restoreTip is no longer the tip we
|
|
// meant to restore. The rollback below deletes the branch that
|
|
// now exclusively owns the user's commits, which would destroy
|
|
// work to recover from a race we can simply report. Keep the
|
|
// branch and let the user reconcile the default branch.
|
|
if errors.Is(err, zerogit.ErrCompareAndSwapConflict) {
|
|
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; generated branch %s was preserved because the default branch changed concurrently", plan.CurrentBranch, plan.RestoreTip, err, result.Branch)
|
|
}
|
|
var rollbackErr error
|
|
if deps.deleteBranch != nil {
|
|
rollbackErr = deps.deleteBranch(ctx, workspaceRoot, plan.CurrentBranch, result.Branch)
|
|
}
|
|
if rollbackErr != nil {
|
|
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; rollback of %s also failed: %v", plan.CurrentBranch, plan.RestoreTip, err, result.Branch, rollbackErr)
|
|
}
|
|
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w", plan.CurrentBranch, plan.RestoreTip, err)
|
|
}
|
|
}
|
|
if !opts.JSONMode {
|
|
fmt.Fprintf(stdout, "Created branch %s (was on %s)\n", result.Branch, plan.CurrentBranch)
|
|
}
|
|
return result.Branch, plan.PushRemote, true, nil
|
|
}
|
|
|
|
// fallbackBranchSlug derives a deterministic branch-name slug from a change
|
|
// summary without calling an LLM, so ensureFeatureBranch still works when no
|
|
// provider is configured.
|
|
func fallbackBranchSlug(summary zerogit.ChangeSummary) string {
|
|
switch len(summary.Files) {
|
|
case 0:
|
|
return "changes"
|
|
case 1:
|
|
// Git paths are always slash-separated regardless of platform, so
|
|
// path.Base (not filepath.Base) extracts the filename portably.
|
|
return zerogit.SlugifyBranchComponent(path.Base(summary.Files[0].Path))
|
|
default:
|
|
return fmt.Sprintf("update-%d-files", len(summary.Files))
|
|
}
|
|
}
|
|
|
|
// generateAutoBranchSlug asks the model for a short kebab-case slug
|
|
// describing the diff, mirroring generateAutoCommitMessage's prompt shape.
|
|
func generateAutoBranchSlug(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
|
var promptBuilder strings.Builder
|
|
promptBuilder.WriteString("Analyze the following git diff and generate a short git branch name slug for it.\n")
|
|
promptBuilder.WriteString("The slug must be 2 to 5 lowercase words separated by hyphens (kebab-case), using only letters, digits, and hyphens, with no prefix like \"feature/\" or \"fix/\" and no surrounding quotes.\n")
|
|
promptBuilder.WriteString("Output ONLY the raw slug text, nothing else.\n\n")
|
|
promptBuilder.WriteString("Git Diff:\n")
|
|
promptBuilder.WriteString(summary.Diff)
|
|
|
|
request := zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{
|
|
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
|
|
},
|
|
}
|
|
stream, err := provider.StreamCompletion(ctx, request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
collected := zeroruntime.CollectStream(ctx, stream)
|
|
if collected.Error != "" {
|
|
return "", fmt.Errorf("%s", collected.Error)
|
|
}
|
|
|
|
slug := zerogit.SlugifyBranchComponent(extractBranchSlug(collected.Text))
|
|
if slug == "" {
|
|
return "", fmt.Errorf("provider returned empty branch slug")
|
|
}
|
|
return slug, nil
|
|
}
|
|
|
|
// slugLineRe matches a line that already reads as a kebab-case slug (letters,
|
|
// digits, and internal single hyphens only). extractBranchSlug prefers such a
|
|
// line so a preamble sentence is never mistaken for the slug.
|
|
var slugLineRe = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
|
|
|
func isPreambleText(s string) bool {
|
|
lower := strings.ToLower(strings.TrimSpace(s))
|
|
if strings.HasSuffix(lower, ":") || strings.HasSuffix(lower, ".") || strings.HasSuffix(lower, "!") || strings.HasSuffix(lower, "?") {
|
|
return true
|
|
}
|
|
preambles := []string{
|
|
"here is", "here's", "below is", "below are", "sure", "certainly",
|
|
"i suggest", "suggested branch", "branch name", "recommended branch",
|
|
"how about", "you could use",
|
|
}
|
|
for _, p := range preambles {
|
|
if strings.HasPrefix(lower, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractBranchSlug pulls the intended slug out of a model response that didn't
|
|
// follow the "output only the raw slug" instruction exactly. It drops Markdown
|
|
// code-fence lines, then prefers a line that already looks like a kebab-case
|
|
// slug: that skips a leading preamble such as "Here is a suggested branch
|
|
// name:" in favor of the "add-login-page" line that follows it. One-word
|
|
// acknowledgements that happen to match the slug regex ("Sure", "Certainly")
|
|
// are filtered as preamble before the early return, so "Sure\nadd login page"
|
|
// yields the real suggestion. Inline labeled forms ("Branch name: add-login-page")
|
|
// still work: the preamble check runs on the extracted value after the label
|
|
// is stripped. If no line is strictly kebab-cased, it prefers a plausible
|
|
// non-preamble line so plain multi-word replies still slugify correctly.
|
|
func extractBranchSlug(text string) string {
|
|
var firstLine string
|
|
|
|
for _, line := range strings.Split(text, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "```") {
|
|
continue
|
|
}
|
|
line = strings.TrimSpace(strings.Trim(line, `"'`))
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
candidate := line
|
|
if idx := strings.Index(line, ":"); idx != -1 {
|
|
after := strings.TrimSpace(strings.Trim(line[idx+1:], `"'`))
|
|
if after != "" {
|
|
candidate = after
|
|
}
|
|
}
|
|
// Models often terminate a suggestion with sentence punctuation;
|
|
// strip it so "add login page." is recovered as the slug rather than
|
|
// rejected as preamble (isPreambleText treats a trailing '.' as a
|
|
// full sentence). True preamble lines still match the prefix list.
|
|
candidate = strings.TrimSpace(strings.TrimRight(candidate, ".!?"))
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
|
|
// Preamble classification runs before accepting a slug-shaped line so
|
|
// "Sure" does not win over a later "add-login-page". The check is on
|
|
// candidate (post label-strip) so "Branch name: add-login-page" still
|
|
// returns the extracted slug.
|
|
if slugLineRe.MatchString(candidate) && !isPreambleText(candidate) {
|
|
return candidate
|
|
}
|
|
|
|
if firstLine == "" && !isPreambleText(candidate) {
|
|
firstLine = candidate
|
|
}
|
|
}
|
|
|
|
return firstLine
|
|
}
|