diff --git a/.changes/unreleased/Added-20260610-211549.yaml b/.changes/unreleased/Added-20260610-211549.yaml new file mode 100644 index 000000000..c92604e81 --- /dev/null +++ b/.changes/unreleased/Added-20260610-211549.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'sync: Add `gs branch sync` (and `gs stack sync` / `gs upstack sync` / `gs downstack sync`) to pull remote-side commits added to a tracked branch since the last push. Default behavior is fast-forward only; use `--rebase` (or `spice.repoSync.pullBranches=rebase`) to integrate remote-side commits even when the branch has been restacked since its last push. A branch that has only moved locally, with no new remote commits, is reported as owing a push rather than as diverged.' +time: 2026-06-10T21:15:49.107419-04:00 diff --git a/.changes/unreleased/Added-20260610-211604.yaml b/.changes/unreleased/Added-20260610-211604.yaml new file mode 100644 index 000000000..78630afb5 --- /dev/null +++ b/.changes/unreleased/Added-20260610-211604.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'submit: Record the pushed commit hash for each tracked branch as `LastPushedHash` so subsequent syncs can distinguish bot-added commits from divergence.' +time: 2026-06-10T21:16:04.581853-04:00 diff --git a/.changes/unreleased/Changed-20260610-211549.yaml b/.changes/unreleased/Changed-20260610-211549.yaml new file mode 100644 index 000000000..7e628bdb3 --- /dev/null +++ b/.changes/unreleased/Changed-20260610-211549.yaml @@ -0,0 +1,3 @@ +kind: Changed +body: 'repo sync: After the trunk pull, also pull remote-side commits on tracked stack branches via the new `spice.repoSync.pullBranches` config (default `fastForward`). Closes the common loop where CI bots add a commit and the next `gs submit` is rejected with "stale info".' +time: 2026-06-10T21:15:49.138568-04:00 diff --git a/.changes/unreleased/Fixed-20260610-211549.yaml b/.changes/unreleased/Fixed-20260610-211549.yaml new file mode 100644 index 000000000..00a28275d --- /dev/null +++ b/.changes/unreleased/Fixed-20260610-211549.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: 'test/shamhub: Pass `--git-dir` to git invocations against bare repos so shamhub-backed tests work on git 2.50+ (which no longer auto-discovers a bare repo from cwd alone).' +time: 2026-06-10T21:15:49.166884-04:00 diff --git a/branch.go b/branch.go index 160587832..cf69de503 100644 --- a/branch.go +++ b/branch.go @@ -38,6 +38,7 @@ type branchCmd struct { // Pull request management Merge branchMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a branch into trunk"` Submit branchSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch"` + Sync branchSyncCmd `cmd:"" aliases:"sy" help:"Pull remote-side commits into a tracked branch" released:"unreleased"` } // BranchPromptConfig defines configuration for the branch tree prompt diff --git a/branch_sync.go b/branch_sync.go new file mode 100644 index 000000000..c8f20169e --- /dev/null +++ b/branch_sync.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "fmt" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/handler/branchsync" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/text" +) + +type branchSyncCmd struct { + Branch string `placeholder:"NAME" help:"Branch to sync" predictor:"trackedBranches"` + Rebase bool `help:"On divergence (both local and remote have new commits since the last push), replay remote-side commits onto local. Conflicts surface as a normal interrupted rebase; resume with 'gs rebase --continue'."` +} + +func (*branchSyncCmd) Help() string { + return text.Dedent(` + Pull remote-side commits added to a tracked branch since the + last push (typically by a CI bot like autofix-ci). + + The branch is fast-forwarded only if local has no commits past + the last push and the remote has moved forward. Diverged + branches (both sides have new commits) are reported and left + unchanged in this release; use 'gs upstack restack' after + moving the parent if you need to integrate them manually. + + Children of a fast-forwarded branch will need a restack; + 'gs upstack restack' from this branch will pick that up. + `) +} + +func (cmd *branchSyncCmd) AfterApply(ctx context.Context, wt *git.Worktree) error { + if cmd.Branch == "" { + currentBranch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + cmd.Branch = currentBranch + } + return nil +} + +func (cmd *branchSyncCmd) Run( + ctx context.Context, + log *silog.Logger, + handler *branchsync.Handler, +) error { + req := branchsync.SyncRequest{Branch: cmd.Branch} + if cmd.Rebase { + req.Mode = branchsync.ModeRebase + } + res, err := handler.Sync(ctx, req) + if err != nil { + return err + } + + switch res.Action { + case branchsync.ActionClean: + log.Infof("%v: already in sync", res.Branch) + case branchsync.ActionFastForward: + log.Infof("%v: fast-forwarded %s..%s", res.Branch, res.FromHash.Short(), res.ToHash.Short()) + case branchsync.ActionRebased: + log.Infof("%v: rebased onto remote %s", res.Branch, res.ToHash.Short()) + case branchsync.ActionBehind: + log.Infof("%v: ahead of remote; nothing to pull", res.Branch) + case branchsync.ActionDiverged: + log.Warnf("%v: diverged from remote; pass --rebase to integrate remote-side commits", res.Branch) + case branchsync.ActionSkipped: + log.Warnf("%v: skipped (%v)", res.Branch, res.SkipReason) + } + + return nil +} diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 507c9d1c6..d8a733758 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -162,11 +162,19 @@ Run with --restack to also restack them and their upstacks. Run with --restack=aboves to only restack direct upstacks of deleted branches, leaving higher branches in place. +After the trunk sync, every other tracked branch is also +checked against its remote: if the remote has new commits +(typically from a CI bot) and local has not moved past the +last-pushed hash, the local branch is fast-forwarded so the +next 'gs submit' will not be rejected with "stale info". +Pass --no-pull-branches to skip this step. + **Flags** * `--restack` ([:material-wrench:{ .middle title="spice.repoSync.restack" }](/cli/config.md#spicereposyncrestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. +* `--pull-branches="fastForward"` ([:material-wrench:{ .middle title="spice.repoSync.pullBranches" }](/cli/config.md#spicereposyncpullbranches)): How to integrate remote-side commits on tracked stack branches. 'off' skips the per-branch pull; 'fastForward' (default) advances safe branches and skips diverged ones; 'rebase' replays diverged branches' local commits on top of the remote. -**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack) +**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.pullBranches](/cli/config.md#spicereposyncpullbranches), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack) ### git-spice repo restack {#gs-repo-restack} @@ -330,6 +338,25 @@ Use --fail-fast to stop the queue after the first branch failure. **Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +### git-spice stack sync {#gs-stack-sync} + +``` +gs stack (s) sync (sy) [flags] +``` + +:material-tag-hidden:{ title="Released in version" }Unreleased + +Pull remote-side commits for every branch in the stack + +Pull remote-side commits for every branch in the current +stack (downstack and upstack of the named branch), bottom-up. +Use --branch to identify a different stack. + +**Flags** + +* `--branch=NAME`: Branch to identify the stack +* `--rebase`: On divergence, replay remote-side commits onto local. + ### git-spice stack restack {#gs-stack-restack} ``` @@ -453,6 +480,27 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice upstack sync {#gs-upstack-sync} + +``` +gs upstack (us) sync (sy) [flags] +``` + +:material-tag-hidden:{ title="Released in version" }Unreleased + +Pull remote-side commits for a branch and those above it + +Pull remote-side commits for the current branch and all +branches upstack from it. Branches that fast-forward +(or rebase, with --rebase) trigger an upstack restack of +their children so the stack stays coherent. +Use --branch to start at a different branch. + +**Flags** + +* `--branch=NAME`: Branch to start at +* `--rebase`: On divergence, replay remote-side commits onto local. + ### git-spice upstack restack {#gs-upstack-restack} ``` @@ -608,6 +656,25 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice downstack sync {#gs-downstack-sync} + +``` +gs downstack (ds) sync (sy) [flags] +``` + +:material-tag-hidden:{ title="Released in version" }Unreleased + +Pull remote-side commits for a branch and those below it + +Pull remote-side commits for the current branch and all +branches downstack from it (excluding trunk). +Use --branch to start at a different branch. + +**Flags** + +* `--branch=NAME`: Branch to start at +* `--rebase`: On divergence, replay remote-side commits onto local. + ### git-spice downstack merge {#gs-downstack-merge} ``` @@ -1205,6 +1272,33 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice branch sync {#gs-branch-sync} + +``` +gs branch (b) sync (sy) [flags] +``` + +:material-tag-hidden:{ title="Released in version" }Unreleased + +Pull remote-side commits into a tracked branch + +Pull remote-side commits added to a tracked branch since the +last push (typically by a CI bot like autofix-ci). + +The branch is fast-forwarded only if local has no commits past +the last push and the remote has moved forward. Diverged +branches (both sides have new commits) are reported and left +unchanged in this release; use 'gs upstack restack' after +moving the parent if you need to integrate them manually. + +Children of a fast-forwarded branch will need a restack; +'gs upstack restack' from this branch will pick that up. + +**Flags** + +* `--branch=NAME`: Branch to sync +* `--rebase`: On divergence (both local and remote have new commits since the last push), replay remote-side commits onto local. Conflicts surface as a normal interrupted rebase; resume with 'gs rebase --continue'. + ## Commit ### git-spice commit create {#gs-commit-create} diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 064cad722..c51065d1b 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -13,6 +13,7 @@ | gs bs | [gs branch submit](/cli/reference.md#gs-branch-submit) | | gs bsp | [gs branch split](/cli/reference.md#gs-branch-split) | | gs bsq | [gs branch squash](/cli/reference.md#gs-branch-squash) | +| gs bsy | [gs branch sync](/cli/reference.md#gs-branch-sync) | | gs btr | [gs branch track](/cli/reference.md#gs-branch-track) | | gs buntr | [gs branch untrack](/cli/reference.md#gs-branch-untrack) | | gs ca | [gs commit amend](/cli/reference.md#gs-commit-amend) | @@ -24,6 +25,7 @@ | gs dsm | [gs downstack merge](/cli/reference.md#gs-downstack-merge) | | gs dsr | [gs downstack restack](/cli/reference.md#gs-downstack-restack) | | gs dss | [gs downstack submit](/cli/reference.md#gs-downstack-submit) | +| gs dssy | [gs downstack sync](/cli/reference.md#gs-downstack-sync) | | gs dstr | [gs downstack track](/cli/reference.md#gs-downstack-track) | | gs ll | [gs log long](/cli/reference.md#gs-log-long) | | gs ls | [gs log short](/cli/reference.md#gs-log-short) | @@ -37,7 +39,9 @@ | gs sm | [gs stack merge](/cli/reference.md#gs-stack-merge) | | gs sr | [gs stack restack](/cli/reference.md#gs-stack-restack) | | gs ss | [gs stack submit](/cli/reference.md#gs-stack-submit) | +| gs ssy | [gs stack sync](/cli/reference.md#gs-stack-sync) | | gs usd | [gs upstack delete](/cli/reference.md#gs-upstack-delete) | | gs uso | [gs upstack onto](/cli/reference.md#gs-upstack-onto) | | gs usr | [gs upstack restack](/cli/reference.md#gs-upstack-restack) | | gs uss | [gs upstack submit](/cli/reference.md#gs-upstack-submit) | +| gs ussy | [gs upstack sync](/cli/reference.md#gs-upstack-sync) | diff --git a/doc/src/guide/cr.md b/doc/src/guide/cr.md index 2e0932fe0..500947076 100644 --- a/doc/src/guide/cr.md +++ b/doc/src/guide/cr.md @@ -269,6 +269,46 @@ in one step, use the `--restack` flag: {green}${reset} gs repo sync --restack ``` +### Pulling remote-side commits on stack branches + + + +After the trunk pull and merged-branch handling, +$$gs repo sync$$ also pulls remote-side commits added to each tracked +stack branch since its last push -- +typically commits added by CI bots such as autofix-ci, license-headers, +or codeowners. + +Branches that fast-forward bring their children along by triggering an +upstack restack. +When the remote has genuinely gained commits but the local branch has +also moved -- including branches restacked onto a newer trunk since +their last push -- the branch is reported as diverged and skipped by +default. +A branch that has only moved locally, with no new remote commits, +is recognized as simply owing a push and is not reported as diverged. +To replay the remote-side commits on top of a diverged branch +(even one that has been restacked), set: + +```freeze language="terminal" +{green}${reset} git config {red}spice.repoSync.pullBranches{reset} {mag}rebase{reset} +``` + +To skip the per-branch pull entirely, +set the value to `off`. +See the [configuration reference](../cli/config.md#spicereposyncpullbranches). + +For finer-grained control, +the same machinery is also available per branch and per scope: + +```freeze language="terminal" +{green}${reset} gs branch sync {gray}# current branch only{reset} +{green}${reset} gs upstack sync {gray}# current branch and above{reset} +{green}${reset} gs downstack sync {gray}# current branch and below{reset} +{green}${reset} gs stack sync {gray}# the whole stack{reset} +{green}${reset} gs branch sync --rebase {gray}# integrate a diverged branch{reset} +``` + ### Handling closed Change Requests When running $$gs repo sync$$, if a Change Request was closed diff --git a/downstack.go b/downstack.go index ed135fcbf..ad6c621ed 100644 --- a/downstack.go +++ b/downstack.go @@ -3,6 +3,7 @@ package main type downstackCmd struct { Track downstackTrackCmd `cmd:"" aliases:"tr" help:"Track all untracked branches below a branch"` Submit downstackSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch and those below it"` + Sync downstackSyncCmd `cmd:"" aliases:"sy" help:"Pull remote-side commits for a branch and those below it" released:"unreleased"` Merge downstackMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a branch and those below it"` Edit downstackEditCmd `cmd:"" aliases:"e" help:"Edit the order of branches below a branch"` Restack downstackRestackCmd `cmd:"" aliases:"r" help:"Restack a branch and its downstack"` diff --git a/downstack_sync.go b/downstack_sync.go new file mode 100644 index 000000000..ce6cba386 --- /dev/null +++ b/downstack_sync.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "fmt" + "slices" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/handler/branchsync" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type downstackSyncCmd struct { + Branch string `placeholder:"NAME" help:"Branch to start at" predictor:"trackedBranches"` + Rebase bool `help:"On divergence, replay remote-side commits onto local."` +} + +func (*downstackSyncCmd) Help() string { + return text.Dedent(` + Pull remote-side commits for the current branch and all + branches downstack from it (excluding trunk). + Use --branch to start at a different branch. + `) +} + +func (cmd *downstackSyncCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + store *state.Store, + svc *spice.Service, + branchSyncHandler *branchsync.Handler, + restackHandler RestackHandler, +) error { + if cmd.Branch == "" { + currentBranch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + cmd.Branch = currentBranch + } + + graph, err := svc.BranchGraph(ctx, nil) + if err != nil { + return fmt.Errorf("build branch graph: %w", err) + } + + branches := slices.Collect(graph.Downstack(cmd.Branch)) + // Downstack is [branch, child1, child2, ..., trunk-adjacent]; we want + // to sync bottom-up so children pick up updated bases. + slices.Reverse(branches) + // Drop trunk if present. + trunk := store.Trunk() + branches = slices.DeleteFunc(branches, func(b string) bool { return b == trunk }) + + return syncBranches(ctx, log, branchSyncHandler, restackHandler, branches, cmd.Rebase) +} diff --git a/internal/handler/branchsync/handler.go b/internal/handler/branchsync/handler.go new file mode 100644 index 000000000..bf60ffed9 --- /dev/null +++ b/internal/handler/branchsync/handler.go @@ -0,0 +1,332 @@ +// Package branchsync implements the per-branch sync operation used by +// 'gs branch sync' and friends. It pulls remote-side commits added to a +// tracked branch since the last push -- typically by a CI bot like +// autofix-ci, license-headers, or codeowners. +// +// The v1 surface is fast-forward only: a branch is updated if its remote +// is strictly ahead of the last-pushed hash AND the local branch hasn't +// moved past that hash. Diverged branches are reported and skipped; the +// rebase strategy is reserved for a follow-up commit. +package branchsync + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" +) + +// GitRepository is the subset of git.Repository this handler uses. +type GitRepository interface { + PeelToCommit(ctx context.Context, ref string) (git.Hash, error) + IsAncestor(ctx context.Context, a, b git.Hash) bool + Fetch(ctx context.Context, opts git.FetchOptions) error + SetRef(ctx context.Context, req git.SetRefRequest) error +} + +var _ GitRepository = (*git.Repository)(nil) + +// GitWorktree is the subset of git.Worktree this handler uses. +type GitWorktree interface { + CurrentBranch(ctx context.Context) (string, error) + CheckoutBranch(ctx context.Context, branch string) error + Pull(ctx context.Context, opts git.PullOptions) error + Reset(ctx context.Context, commit string, opts git.ResetOptions) error + Rebase(ctx context.Context, req git.RebaseRequest) error +} + +var _ GitWorktree = (*git.Worktree)(nil) + +// Store is the subset of state.Store this handler uses. +type Store interface { + LookupBranch(ctx context.Context, name string) (*state.LookupResponse, error) + BeginBranchTx() *state.BranchTx + Trunk() string +} + +// Handler implements per-branch sync. +type Handler struct { + Log *silog.Logger // required + Repository GitRepository // required + Worktree GitWorktree // required + Store Store // required + + // Remote is the name of the remote to fetch from (e.g., "origin"). + Remote string // required +} + +// Action reports what 'gs branch sync' did to a branch. +type Action int + +const ( + // ActionClean means the branch was already in sync. + ActionClean Action = iota + + // ActionFastForward means the branch was fast-forwarded to its remote. + ActionFastForward + + // ActionRebased means local commits were replayed on top of the + // remote-side commits brought in from the upstream. + ActionRebased + + // ActionBehind means the remote has no commits to integrate: it sits + // at (or behind) our last push. The local branch is ahead of, or has + // only moved away from, a stale remote and simply needs to be pushed. + ActionBehind + + // ActionDiverged means both local and remote have new commits since + // the last push. Skipped under fast-forward policy. + ActionDiverged + + // ActionSkipped means the branch was skipped for a reason that + // merits a warning (no upstream, no LastPushedHash recorded, etc.). + ActionSkipped +) + +// SyncResult reports the outcome of syncing a single branch. +type SyncResult struct { + Branch string + Action Action + + // FromHash is the local tip before the sync. + FromHash git.Hash + + // ToHash is the local tip after the sync. Equal to FromHash when no + // update was applied. + ToHash git.Hash + + // SkipReason is set when Action is ActionSkipped. + SkipReason string +} + +// ErrNoUpstream is returned when the branch has no upstream configured +// and therefore cannot be synced. +var ErrNoUpstream = errors.New("branch has no upstream configured") + +// Mode controls how diverged branches are handled. +type Mode int + +const ( + // ModeFastForward is the default: fast-forward when safe; skip when + // the branch has diverged from its remote. + ModeFastForward Mode = iota + + // ModeRebase replays remote-side commits on top of the local branch + // when both sides have new commits since the last push. Conflicts + // surface as an interrupted rebase, recoverable with + // 'gs rebase --continue'. + ModeRebase +) + +// SyncRequest configures a single-branch sync. +type SyncRequest struct { + Branch string + Mode Mode +} + +// Sync syncs a single tracked branch according to the request. Returns +// the result of the sync. The caller is responsible for restacking any +// children of the branch when ToHash != FromHash. +func (h *Handler) Sync(ctx context.Context, req SyncRequest) (*SyncResult, error) { + branch := req.Branch + log := h.Log.With("branch", branch) + + if branch == h.Store.Trunk() { + return &SyncResult{Branch: branch, Action: ActionSkipped, SkipReason: "trunk is synced by 'gs repo sync'"}, nil + } + + lookup, err := h.Store.LookupBranch(ctx, branch) + if err != nil { + return nil, fmt.Errorf("lookup branch: %w", err) + } + + if lookup.UpstreamBranch == "" { + return nil, ErrNoUpstream + } + + // Fetch the remote ref so origin/ is up to date. + refspec := git.Refspec(h.Remote + "/" + lookup.UpstreamBranch) + if err := h.Repository.Fetch(ctx, git.FetchOptions{ + Remote: h.Remote, + Refspecs: []git.Refspec{git.Refspec(lookup.UpstreamBranch)}, + }); err != nil { + return nil, fmt.Errorf("fetch %v: %w", refspec, err) + } + + localHash, err := h.Repository.PeelToCommit(ctx, branch) + if err != nil { + return nil, fmt.Errorf("resolve local %v: %w", branch, err) + } + + remoteRef := h.Remote + "/" + lookup.UpstreamBranch + remoteHash, err := h.Repository.PeelToCommit(ctx, remoteRef) + if err != nil { + return nil, fmt.Errorf("resolve %v: %w", remoteRef, err) + } + + res := &SyncResult{Branch: branch, FromHash: localHash, ToHash: localHash} + + if localHash == remoteHash { + res.Action = ActionClean + return res, nil + } + + // Local is an ancestor of remote -> can fast-forward. + if h.Repository.IsAncestor(ctx, localHash, remoteHash) { + if err := h.fastForward(ctx, branch, lookup.UpstreamBranch, localHash, remoteHash); err != nil { + return nil, err + } + if err := h.recordPushedHash(ctx, branch, lookup.UpstreamBranch, remoteHash); err != nil { + log.Warn("Could not record pushed hash", "error", err) + } + res.Action = ActionFastForward + res.ToHash = remoteHash + return res, nil + } + + // Remote is an ancestor of local -> nothing to pull; we owe a push. + if h.Repository.IsAncestor(ctx, remoteHash, localHash) { + res.Action = ActionBehind + return res, nil + } + + // Local and remote have diverged in raw Git terms: neither tip is an + // ancestor of the other. Use the last-pushed hash as a baseline to + // tell apart two very different situations that look identical here: + // + // - The remote is stale and only our local branch moved (e.g. it + // was restacked onto a newer trunk). There is nothing to pull; + // the branch owes a push. + // - The remote genuinely gained commits since our last push (e.g. a + // CI bot). Those are the commits we want to integrate. + // + // The remote-side commits are the range pHash..remote. If that range + // is empty, the remote has gained nothing and the divergence is purely + // local. + pHash := lookup.UpstreamLastPushedHash + remoteAhead := pHash != "" && pHash != remoteHash && + h.Repository.IsAncestor(ctx, pHash, remoteHash) + + if !remoteAhead { + switch { + case pHash != "" && pHash == remoteHash: + // Remote sits exactly at our last push: it gained nothing. + // The local branch moved on its own and just owes a push. + res.Action = ActionBehind + case pHash == "": + // No baseline recorded, so we can't identify what to pull. + res.Action = ActionSkipped + res.SkipReason = "no last-pushed hash recorded; push the branch once to establish a baseline" + default: + // Remote was rewritten away from our baseline. + res.Action = ActionDiverged + } + return res, nil + } + + // pHash..remote is a non-empty, well-formed range: the remote has real + // commits to integrate. Bringing them in means replaying them onto the + // local tip, which is a rebase, not a fast-forward. + if req.Mode != ModeRebase { + res.Action = ActionDiverged + return res, nil + } + + // Replay pHash..remote onto the local tip via 'git rebase --onto'. + // + // We deliberately do NOT require pHash to be an ancestor of local: a + // restacked branch drops pHash from its history, yet rebase --onto + // only needs pHash..remote to be a valid range, which remoteAhead + // already guarantees. This is what lets remote-side commits survive + // even after the branch has been restacked locally. + if err := h.rebase(ctx, branch, lookup.UpstreamBranch, localHash, remoteHash, pHash); err != nil { + return nil, err + } + if err := h.recordPushedHash(ctx, branch, lookup.UpstreamBranch, remoteHash); err != nil { + log.Warn("Could not record pushed hash", "error", err) + } + // Recompute the post-rebase tip. + newHash, err := h.Repository.PeelToCommit(ctx, branch) + if err != nil { + return nil, fmt.Errorf("resolve post-rebase %v: %w", branch, err) + } + res.Action = ActionRebased + res.ToHash = newHash + return res, nil +} + +// rebase replays the remote-only commits (p..R) onto the local tip (L) +// by checking out the branch, resetting it to R, then running +// git rebase --onto L p. Conflicts surface as a normal interrupted +// rebase; the caller can resume with 'gs rebase --continue'. +func (h *Handler) rebase(ctx context.Context, branch, upstream string, localHash, remoteHash, lastPushed git.Hash) error { + _ = upstream // currently unused; reserved for future logging + + currentBranch, err := h.Worktree.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("current branch: %w", err) + } + if currentBranch != branch { + if err := h.Worktree.CheckoutBranch(ctx, branch); err != nil { + return fmt.Errorf("checkout %v: %w", branch, err) + } + } + + // Move HEAD to R so the rebase sees p..R as the commit range. + if err := h.Worktree.Reset(ctx, remoteHash.String(), git.ResetOptions{Mode: git.ResetHard}); err != nil { + return fmt.Errorf("reset %v to remote: %w", branch, err) + } + + // Replay p..R (which is now HEAD since HEAD = R) onto L. + if err := h.Worktree.Rebase(ctx, git.RebaseRequest{ + Branch: branch, + Upstream: lastPushed.String(), + Onto: localHash.String(), + Autostash: true, + }); err != nil { + return fmt.Errorf("rebase remote commits onto local: %w", err) + } + + return nil +} + +// fastForward updates a branch ref to a new (descendant) hash. For a +// non-current branch this is a plain ref update; for the currently +// checked-out branch we use 'git pull --rebase --autostash' to bring +// HEAD and the working tree along (a no-commits-to-replay rebase +// degenerates into a fast-forward). +func (h *Handler) fastForward(ctx context.Context, branch, upstream string, oldHash, newHash git.Hash) error { + currentBranch, err := h.Worktree.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("current branch: %w", err) + } + if currentBranch == branch { + return h.Worktree.Pull(ctx, git.PullOptions{ + Remote: h.Remote, + Rebase: true, + Autostash: true, + Refspec: git.Refspec(upstream), + }) + } + + return h.Repository.SetRef(ctx, git.SetRefRequest{ + Ref: "refs/heads/" + branch, + Hash: newHash, + OldHash: oldHash, + }) +} + +func (h *Handler) recordPushedHash(ctx context.Context, branch, upstream string, hash git.Hash) error { + tx := h.Store.BeginBranchTx() + if err := tx.Upsert(ctx, state.UpsertRequest{ + Name: branch, + UpstreamBranch: &upstream, + UpstreamLastPushedHash: &hash, + }); err != nil { + return fmt.Errorf("upsert: %w", err) + } + return tx.Commit(ctx, "branch sync "+branch+": record fast-forward") +} diff --git a/internal/handler/submit/handler.go b/internal/handler/submit/handler.go index f1587929e..8b4568dcb 100644 --- a/internal/handler/submit/handler.go +++ b/internal/handler/submit/handler.go @@ -1005,6 +1005,15 @@ func (h *Handler) submitBranch( Name: branchToSubmit, UpstreamBranch: &upstreamBranch, } + // Capture the hash we just pushed so 'gs branch sync' can + // detect remote-side commits added later (e.g. by CI bots). + if pushedHash, err := h.Repository.PeelToCommit(ctx, branchToSubmit); err == nil { + upsert.UpstreamLastPushedHash = &pushedHash + } else { + log.Warn("Could not record pushed hash; sync will not detect remote-side commits until next push", + "branch", branchToSubmit, + "error", err) + } defer func() { msg := "branch submit " + branchToSubmit tx := h.Store.BeginBranchTx() @@ -1182,6 +1191,21 @@ func (h *Handler) submitBranch( log.Error("Push failed. Branch may have been updated by someone else. Try with --force.") return status, fmt.Errorf("push branch: %w", err) } + + // Record the pushed hash so 'gs branch sync' can detect + // remote-side commits added later. + tx := h.Store.BeginBranchTx() + if err := errors.Join( + tx.Upsert(ctx, state.UpsertRequest{ + Name: branchToSubmit, + UpstreamLastPushedHash: &commitHash, + }), + tx.Commit(ctx, "branch submit "+branchToSubmit+": record pushed hash"), + ); err != nil { + log.Warn("Could not record pushed hash; sync will not detect remote-side commits until next push", + "branch", branchToSubmit, + "error", err) + } } if len(updates) > 0 { diff --git a/internal/spice/state/branch.go b/internal/spice/state/branch.go index 44e02e1f2..0c2b7ab3f 100644 --- a/internal/spice/state/branch.go +++ b/internal/spice/state/branch.go @@ -32,6 +32,12 @@ type branchStateBase struct { type branchUpstreamState struct { Branch string `json:"branch,omitempty"` + + // LastPushedHash is the commit hash that was last successfully pushed + // to the upstream branch. Used by `gs branch sync` to determine + // whether the remote has new commits since the last push. + // Empty if the branch has never been pushed. + LastPushedHash string `json:"lastPushedHash,omitempty"` } type branchChangeState struct { @@ -99,6 +105,11 @@ type LookupResponse struct { // or an empty string if the branch is not tracking an upstream branch. UpstreamBranch string + // UpstreamLastPushedHash is the commit hash that was last successfully + // pushed to the upstream branch. Empty if the branch has never been + // pushed or has no upstream configured. + UpstreamLastPushedHash git.Hash + // MergedDownstack holds information about branches // that were previously downstack from this branch // that have since been merged into trunk. @@ -130,6 +141,7 @@ func (s *Store) LookupBranch(ctx context.Context, name string) (*LookupResponse, if upstream := state.Upstream; upstream != nil { res.UpstreamBranch = upstream.Branch + res.UpstreamLastPushedHash = git.Hash(upstream.LastPushedHash) } return res, nil @@ -229,6 +241,12 @@ type UpsertRequest struct { // Leave nil to leave it unchanged, or set to an empty string to clear it. UpstreamBranch *string + // UpstreamLastPushedHash is the commit hash to record as the last + // successful push to the upstream branch. Leave nil to leave it + // unchanged, or set to the empty hash to clear it. Ignored if the + // branch has no upstream after this update. + UpstreamLastPushedHash *git.Hash + // MergedDownstack is a list of branches that were previously // downstack from this branch that have since been merged into trunk. MergedDownstack *[]json.RawMessage @@ -304,12 +322,22 @@ func (tx *BranchTx) Upsert(ctx context.Context, req UpsertRequest) error { if *req.UpstreamBranch == "" { state.Upstream = nil } else { + // Preserve the existing LastPushedHash if the upstream name + // is unchanged; reset to empty if the upstream is new. + prev := state.Upstream state.Upstream = &branchUpstreamState{ Branch: *req.UpstreamBranch, } + if prev != nil && prev.Branch == *req.UpstreamBranch { + state.Upstream.LastPushedHash = prev.LastPushedHash + } } } + if req.UpstreamLastPushedHash != nil && state.Upstream != nil { + state.Upstream.LastPushedHash = req.UpstreamLastPushedHash.String() + } + if req.MergedDownstack != nil { state.MergedDownstack = *req.MergedDownstack } diff --git a/internal/spice/state/branch_int_test.go b/internal/spice/state/branch_int_test.go index a821d2406..9834cf6e5 100644 --- a/internal/spice/state/branch_int_test.go +++ b/internal/spice/state/branch_int_test.go @@ -97,6 +97,41 @@ func TestBranchStateUnmarshal(t *testing.T) { }, }, }, + + { + name: "UpstreamWithLastPushedHash", + give: `{ + "base": {"name": "main", "hash": "abc123"}, + "upstream": {"branch": "feat1", "lastPushedHash": "deadbeefcafe"} + }`, + want: &branchState{ + Base: branchStateBase{ + Name: "main", + Hash: "abc123", + }, + Upstream: &branchUpstreamState{ + Branch: "feat1", + LastPushedHash: "deadbeefcafe", + }, + }, + }, + + { + name: "UpstreamWithoutLastPushedHash_RoundTrips", + give: `{ + "base": {"name": "main", "hash": "abc123"}, + "upstream": {"branch": "feat1"} + }`, + want: &branchState{ + Base: branchStateBase{ + Name: "main", + Hash: "abc123", + }, + Upstream: &branchUpstreamState{ + Branch: "feat1", + }, + }, + }, } for _, tt := range tests { @@ -115,3 +150,15 @@ func TestBranchStateUnmarshal(t *testing.T) { }) } } + +func TestBranchUpstreamState_LastPushedHashOmittedWhenEmpty(t *testing.T) { + state := &branchState{ + Base: branchStateBase{Name: "main", Hash: "abc123"}, + Upstream: &branchUpstreamState{Branch: "feat1"}, + } + + out, err := json.Marshal(state) + require.NoError(t, err) + assert.NotContains(t, string(out), "lastPushedHash", + "empty LastPushedHash must be omitted to keep existing state files compact") +} diff --git a/internal/spice/state/branch_test.go b/internal/spice/state/branch_test.go index a279bc5a0..e037ca6c2 100644 --- a/internal/spice/state/branch_test.go +++ b/internal/spice/state/branch_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/git" "go.abhg.dev/gs/internal/sliceutil" "go.abhg.dev/gs/internal/spice/state" "go.abhg.dev/gs/internal/spice/state/statetest" @@ -287,6 +288,72 @@ func TestBranchTxUpsert_canClearUpstream(t *testing.T) { assert.Equal(t, "", foo.UpstreamBranch) } +func TestBranchTxUpsert_lastPushedHash(t *testing.T) { + ctx := t.Context() + db := storage.NewDB(make(storage.MapBackend)) + store, err := state.InitStore(ctx, state.InitStoreRequest{ + DB: db, + Trunk: "main", + }) + require.NoError(t, err) + + upstream := "remote-foo" + hashA := git.Hash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + hashB := git.Hash("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + + // Initial upsert with upstream and pushed hash. + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{ + { + Name: "foo", + Base: "main", + UpstreamBranch: &upstream, + UpstreamLastPushedHash: &hashA, + }, + }, + Message: "add foo", + })) + + foo, err := store.LookupBranch(ctx, "foo") + require.NoError(t, err) + assert.Equal(t, upstream, foo.UpstreamBranch) + assert.Equal(t, hashA, foo.UpstreamLastPushedHash) + + // Update only the pushed hash; upstream unchanged. + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{ + { + Name: "foo", + Base: "main", + UpstreamLastPushedHash: &hashB, + }, + }, + Message: "advance pushed hash", + })) + + foo, err = store.LookupBranch(ctx, "foo") + require.NoError(t, err) + assert.Equal(t, upstream, foo.UpstreamBranch) + assert.Equal(t, hashB, foo.UpstreamLastPushedHash) + + // Clearing the upstream also clears the pushed hash. + var empty string + require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{ + Upserts: []state.UpsertRequest{ + { + Name: "foo", + UpstreamBranch: &empty, + }, + }, + Message: "drop upstream", + })) + + foo, err = store.LookupBranch(ctx, "foo") + require.NoError(t, err) + assert.Empty(t, foo.UpstreamBranch) + assert.Empty(t, foo.UpstreamLastPushedHash) +} + // Uses rapid to run randomized scenarios on the branch state // to ensure we never leave it in a corrupted state. func TestBranchStateUncorruptible(t *testing.T) { diff --git a/main.go b/main.go index d020ea4cd..2d9860abe 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "go.abhg.dev/gs/internal/forge/gitlab" "go.abhg.dev/gs/internal/git" "go.abhg.dev/gs/internal/handler/autostash" + "go.abhg.dev/gs/internal/handler/branchsync" "go.abhg.dev/gs/internal/handler/checkout" "go.abhg.dev/gs/internal/handler/cherrypick" "go.abhg.dev/gs/internal/handler/delete" @@ -505,6 +506,21 @@ func (cmd *mainCmd) AfterApply( Service: svc, }, nil }), + kctx.BindSingletonProvider(func( + log *silog.Logger, + repo *git.Repository, + worktree *git.Worktree, + store *state.Store, + remote state.Remote, + ) (*branchsync.Handler, error) { + return &branchsync.Handler{ + Log: log, + Repository: repo, + Worktree: worktree, + Store: store, + Remote: remote.Upstream, + }, nil + }), kctx.BindSingletonProvider(func( log *silog.Logger, wt *git.Worktree, diff --git a/repo_sync.go b/repo_sync.go index 3cac2100e..1fbe81b75 100644 --- a/repo_sync.go +++ b/repo_sync.go @@ -2,13 +2,29 @@ package main import ( "context" + "errors" + "fmt" + "slices" + "go.abhg.dev/gs/internal/handler/branchsync" "go.abhg.dev/gs/internal/handler/sync" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" "go.abhg.dev/gs/internal/text" ) +type pullBranchesMode string + +const ( + pullBranchesOff pullBranchesMode = "off" + pullBranchesFastForward pullBranchesMode = "fastForward" + pullBranchesRebase pullBranchesMode = "rebase" +) + type repoSyncCmd struct { sync.TrunkOptions + + PullBranches pullBranchesMode `default:"fastForward" config:"repoSync.pullBranches" enum:"off,fastForward,rebase" help:"How to integrate remote-side commits on tracked stack branches. 'off' skips the per-branch pull; 'fastForward' (default) advances safe branches and skips diverged ones; 'rebase' replays diverged branches' local commits on top of the remote."` } func (*repoSyncCmd) Help() string { @@ -25,6 +41,13 @@ func (*repoSyncCmd) Help() string { Run with --restack to also restack them and their upstacks. Run with --restack=aboves to only restack direct upstacks of deleted branches, leaving higher branches in place. + + After the trunk sync, every other tracked branch is also + checked against its remote: if the remote has new commits + (typically from a CI bot) and local has not moved past the + last-pushed hash, the local branch is fast-forwarded so the + next 'gs submit' will not be rejected with "stale info". + Pass --no-pull-branches to skip this step. `) } @@ -33,6 +56,60 @@ type SyncHandler interface { SyncTrunk(ctx context.Context, opts *sync.TrunkOptions) error } -func (cmd *repoSyncCmd) Run(ctx context.Context, syncHandler SyncHandler) error { - return syncHandler.SyncTrunk(ctx, &cmd.TrunkOptions) +func (cmd *repoSyncCmd) Run( + ctx context.Context, + log *silog.Logger, + store *state.Store, + syncHandler SyncHandler, + branchSyncHandler *branchsync.Handler, + restackHandler RestackHandler, +) error { + if err := syncHandler.SyncTrunk(ctx, &cmd.TrunkOptions); err != nil { + return err + } + + if cmd.PullBranches == pullBranchesOff { + return nil + } + rebase := cmd.PullBranches == pullBranchesRebase + + var branches []string + for branch, err := range store.ListBranches(ctx) { + if err != nil { + return fmt.Errorf("list tracked branches: %w", err) + } + branches = append(branches, branch) + } + slices.Sort(branches) + + mode := branchsync.ModeFastForward + if rebase { + mode = branchsync.ModeRebase + } + + for _, branch := range branches { + res, err := branchSyncHandler.Sync(ctx, branchsync.SyncRequest{Branch: branch, Mode: mode}) + if err != nil { + if errors.Is(err, branchsync.ErrNoUpstream) { + continue + } + log.Warnf("%v: sync failed: %v", branch, err) + continue + } + switch res.Action { + case branchsync.ActionFastForward: + log.Infof("%v: fast-forwarded %s..%s", res.Branch, res.FromHash.Short(), res.ToHash.Short()) + // The branch moved; children built on the old hash need + // to be rebased onto the new tip. + if err := restackHandler.RestackUpstack(ctx, res.Branch, nil); err != nil { + log.Warnf("%v: upstack restack after sync failed: %v", res.Branch, err) + } + case branchsync.ActionDiverged: + log.Warnf("%v: diverged from remote; run 'gs branch sync --rebase' to integrate", res.Branch) + case branchsync.ActionSkipped: + log.Warnf("%v: skipped (%v)", res.Branch, res.SkipReason) + } + } + + return nil } diff --git a/stack.go b/stack.go index 822c5f603..27b2f696a 100644 --- a/stack.go +++ b/stack.go @@ -3,6 +3,7 @@ package main type stackCmd struct { Submit stackSubmitCmd `cmd:"" aliases:"s" help:"Submit a stack"` Merge stackMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a stack"` + Sync stackSyncCmd `cmd:"" aliases:"sy" help:"Pull remote-side commits for every branch in the stack" released:"unreleased"` Restack stackRestackCmd `cmd:"" aliases:"r" help:"Restack a stack"` Edit stackEditCmd `cmd:"" aliases:"e" help:"Edit the order of branches in a stack"` Delete stackDeleteCmd `cmd:"" aliases:"d" released:"v0.16.0" help:"Delete all branches in a stack"` diff --git a/stack_sync.go b/stack_sync.go new file mode 100644 index 000000000..7f27373a4 --- /dev/null +++ b/stack_sync.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + "slices" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/handler/branchsync" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type stackSyncCmd struct { + Branch string `placeholder:"NAME" help:"Branch to identify the stack" predictor:"trackedBranches"` + Rebase bool `help:"On divergence, replay remote-side commits onto local."` +} + +func (*stackSyncCmd) Help() string { + return text.Dedent(` + Pull remote-side commits for every branch in the current + stack (downstack and upstack of the named branch), bottom-up. + Use --branch to identify a different stack. + `) +} + +func (cmd *stackSyncCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + store *state.Store, + svc *spice.Service, + branchSyncHandler *branchsync.Handler, + restackHandler RestackHandler, +) error { + if cmd.Branch == "" { + currentBranch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + cmd.Branch = currentBranch + } + + graph, err := svc.BranchGraph(ctx, nil) + if err != nil { + return fmt.Errorf("build branch graph: %w", err) + } + + // Downstack bottom-up, then upstack top-down (excluding the + // duplicated start branch). + downs := slices.Collect(graph.Downstack(cmd.Branch)) + slices.Reverse(downs) + ups := slices.Collect(graph.Upstack(cmd.Branch)) + if len(ups) > 0 && ups[0] == cmd.Branch { + ups = ups[1:] + } + + trunk := store.Trunk() + branches := make([]string, 0, len(downs)+len(ups)) + branches = append(branches, downs...) + branches = append(branches, ups...) + branches = slices.DeleteFunc(branches, func(b string) bool { return b == trunk }) + + return syncBranches(ctx, log, branchSyncHandler, restackHandler, branches, cmd.Rebase) +} diff --git a/testdata/help/branch_sync.txt b/testdata/help/branch_sync.txt new file mode 100644 index 000000000..1928a38ab --- /dev/null +++ b/testdata/help/branch_sync.txt @@ -0,0 +1,28 @@ +Usage: gs branch (b) sync (sy) [flags] + +Pull remote-side commits into a tracked branch + +Pull remote-side commits added to a tracked branch since the last push +(typically by a CI bot like autofix-ci). + +The branch is fast-forwarded only if local has no commits past the last push and +the remote has moved forward. Diverged branches (both sides have new commits) +are reported and left unchanged in this release; use 'gs upstack restack' after +moving the parent if you need to integrate them manually. + +Children of a fast-forwarded branch will need a restack; 'gs upstack restack' +from this branch will pick that up. + +Flags: + --branch=NAME Branch to sync + --rebase On divergence (both local and remote have new commits since + the last push), replay remote-side commits onto local. + Conflicts surface as a normal interrupted rebase; resume with + 'gs rebase --continue'. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/downstack_sync.txt b/testdata/help/downstack_sync.txt new file mode 100644 index 000000000..4dc365c36 --- /dev/null +++ b/testdata/help/downstack_sync.txt @@ -0,0 +1,17 @@ +Usage: gs downstack (ds) sync (sy) [flags] + +Pull remote-side commits for a branch and those below it + +Pull remote-side commits for the current branch and all branches downstack from +it (excluding trunk). Use --branch to start at a different branch. + +Flags: + --branch=NAME Branch to start at + --rebase On divergence, replay remote-side commits onto local. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 43406fde2..38d6e7504 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -32,15 +32,21 @@ Log Stack stack (s) submit (s) Submit a stack stack (s) merge (m) Merge a stack + stack (s) sync (sy) Pull remote-side commits for every branch in the + stack stack (s) restack (r) Restack a stack stack (s) edit (e) Edit the order of branches in a stack stack (s) delete (d) Delete all branches in a stack upstack (us) submit (s) Submit a branch and those above it + upstack (us) sync (sy) Pull remote-side commits for a branch and those + above it upstack (us) restack (r) Restack a branch and its upstack upstack (us) onto (o) Move a branch onto another branch upstack (us) delete (d) Delete all branches above the current branch downstack (ds) track (tr) Track all untracked branches below a branch downstack (ds) submit (s) Submit a branch and those below it + downstack (ds) sync (sy) Pull remote-side commits for a branch and those + below it downstack (ds) merge (m) Merge a branch and those below it downstack (ds) edit (e) Edit the order of branches below a branch downstack (ds) restack (r) Restack a branch and its downstack @@ -61,6 +67,7 @@ Branch branch (b) diff (di) Show diff between a branch and its base branch (b) merge (m) Merge a branch into trunk branch (b) submit (s) Submit a branch + branch (b) sync (sy) Pull remote-side commits into a tracked branch Commit commit (c) create (c) Create a new commit diff --git a/testdata/help/repo_sync.txt b/testdata/help/repo_sync.txt index 4bc5a556b..a0a6afbb8 100644 --- a/testdata/help/repo_sync.txt +++ b/testdata/help/repo_sync.txt @@ -12,9 +12,23 @@ Run with --restack to also restack them and their upstacks. Run with --restack=aboves to only restack direct upstacks of deleted branches, leaving higher branches in place. +After the trunk sync, every other tracked branch is also checked against its +remote: if the remote has new commits (typically from a CI bot) and local has +not moved past the last-pushed hash, the local branch is fast-forwarded so the +next 'gs submit' will not be rejected with "stale info". Pass --no-pull-branches +to skip this step. + Flags: - --restack How to restack branches above deleted branches. One of 'none', - 'aboves', and 'upstack'. (🔧 spice.repoSync.restack) + --restack How to restack branches above deleted + branches. One of 'none', 'aboves', and + 'upstack'. (🔧 spice.repoSync.restack) + --pull-branches="fastForward" How to integrate remote-side commits on + tracked stack branches. 'off' skips the + per-branch pull; 'fastForward' (default) + advances safe branches and skips diverged + ones; 'rebase' replays diverged branches' + local commits on top of the remote. + (🔧 spice.repoSync.pullBranches) Global Flags: -h, --help Show help for the command diff --git a/testdata/help/stack_sync.txt b/testdata/help/stack_sync.txt new file mode 100644 index 000000000..2deb24797 --- /dev/null +++ b/testdata/help/stack_sync.txt @@ -0,0 +1,18 @@ +Usage: gs stack (s) sync (sy) [flags] + +Pull remote-side commits for every branch in the stack + +Pull remote-side commits for every branch in the current stack (downstack and +upstack of the named branch), bottom-up. Use --branch to identify a different +stack. + +Flags: + --branch=NAME Branch to identify the stack + --rebase On divergence, replay remote-side commits onto local. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/upstack_sync.txt b/testdata/help/upstack_sync.txt new file mode 100644 index 000000000..c74ec9734 --- /dev/null +++ b/testdata/help/upstack_sync.txt @@ -0,0 +1,19 @@ +Usage: gs upstack (us) sync (sy) [flags] + +Pull remote-side commits for a branch and those above it + +Pull remote-side commits for the current branch and all branches upstack from +it. Branches that fast-forward (or rebase, with --rebase) trigger an upstack +restack of their children so the stack stays coherent. Use --branch to start at +a different branch. + +Flags: + --branch=NAME Branch to start at + --rebase On divergence, replay remote-side commits onto local. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/script/branch_sync_conflict.txt b/testdata/script/branch_sync_conflict.txt new file mode 100644 index 000000000..f3f19c0e7 --- /dev/null +++ b/testdata/script/branch_sync_conflict.txt @@ -0,0 +1,74 @@ +# 'gs branch sync --rebase' surfaces conflicts as a normal interrupted rebase. +# 'gs rebase continue' after manual resolution finishes the sync. + +as 'Test ' +at '2026-06-10T10:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create feat1 and submit +git add feat1.txt +gs bc -m 'Add feat1' feat1 +gs branch submit --fill +stderr 'Created #' + +# simulate a contributor pushing a conflicting change to feat1. +cd .. +git clone $SHAMHUB_URL/alice/example.git fork +cd fork +git checkout feat1 +cp $WORK/extra/feat1-remote.txt feat1.txt +git add feat1.txt +git commit -m 'remote: tweak feat1' +git push origin feat1 + +# locally, make a different change to the same file. +cd ../repo +cp $WORK/extra/feat1-local.txt feat1.txt +git add feat1.txt +git commit -m 'local: tweak feat1' + +# 'gs branch sync --rebase' begins a rebase that conflicts. +! gs branch sync --rebase +stderr 'interrupted by a conflict' + +# resolve the conflict and continue. +cp $WORK/extra/feat1-resolved.txt feat1.txt +git add feat1.txt +env EDITOR=true +gs rebase continue + +# verify final state: the resolution is the committed content. +cmp feat1.txt $WORK/extra/feat1-resolved.txt + +# follow-up submit succeeds. +gs branch submit +stderr 'Updated #' + +-- repo/feat1.txt -- +line one +line two +line three +-- extra/feat1-remote.txt -- +line one +line two REMOTE +line three +-- extra/feat1-local.txt -- +line one +line two LOCAL +line three +-- extra/feat1-resolved.txt -- +line one +line two RESOLVED +line three diff --git a/testdata/script/branch_sync_fast_forward.txt b/testdata/script/branch_sync_fast_forward.txt new file mode 100644 index 000000000..504da0f0d --- /dev/null +++ b/testdata/script/branch_sync_fast_forward.txt @@ -0,0 +1,75 @@ +# 'gs branch sync' fast-forwards a tracked branch +# when the remote has new commits and local has none past the last push. +# After sync, 'gs branch submit' succeeds without --force. + +as 'Test ' +at '2026-06-10T10:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +# set up a fake GitHub remote +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create a branch and submit it +git add feat1.txt +gs bc -m 'Add feat1' feat1 +gs branch submit --fill +stderr 'Created #' + +# verify branch is in sync +gs ls +cmp stderr $WORK/golden/ls-before.txt + +# simulate a bot pushing a commit to feat1's remote. +# clone the repo separately and push a regen-style commit. +cd .. +git clone $SHAMHUB_URL/alice/example.git fork +cd fork +git checkout feat1 +cp $WORK/extra/feat1-regen.txt feat1.txt +git add feat1.txt +git commit -m 'bot: regenerate derived files' +git push origin feat1 + +# back in the main repo +cd ../repo + +# 'gs branch sync' fast-forwards local feat1 to match the remote. +gs branch sync +stderr 'feat1: fast-forwarded' + +# local feat1 now has the bot commit +cmp feat1.txt $WORK/extra/feat1-regen.txt +git log --oneline feat1 +stdout 'bot: regenerate derived files' +stdout 'Add feat1' + +# a follow-up submit succeeds without --force +# because the local LastPushedHash is updated. +cp $WORK/extra/feat1-followup.txt feat1.txt +git add feat1.txt +git commit -m 'Continue feat1' +gs branch submit +stderr 'Updated #' + +-- repo/feat1.txt -- +feat 1 +-- extra/feat1-regen.txt -- +feat 1 +regenerated by bot +-- extra/feat1-followup.txt -- +feat 1 +regenerated by bot +follow-up +-- golden/ls-before.txt -- +┏━■ feat1 (#1) ◀ +main diff --git a/testdata/script/branch_sync_local_rewrite.txt b/testdata/script/branch_sync_local_rewrite.txt new file mode 100644 index 000000000..c7da07822 --- /dev/null +++ b/testdata/script/branch_sync_local_rewrite.txt @@ -0,0 +1,46 @@ +# When a branch has been rewritten locally (e.g. restacked onto a newer +# trunk) but the remote has gained nothing since the last push, the +# branch has not really "diverged": there is nothing to pull and it +# simply owes a push. 'gs branch sync' reports this benignly and never +# warns about divergence. + +as 'Test ' +at '2026-06-18T10:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create feat1 and submit; this records the last-pushed hash. +git add feat1.txt +gs bc -m 'Add feat1' feat1 +gs branch submit --fill +stderr 'Created #' + +# rewrite the branch locally below the last-pushed hash (an amend stands +# in for a restack onto a moved trunk: the pushed commit leaves local +# history). The remote still sits at the last-pushed hash. +cp $WORK/extra/feat1-v2.txt feat1.txt +git add feat1.txt +gs commit amend --no-edit + +# 'gs branch sync' sees raw divergence but recognizes the remote gained +# nothing: it reports the branch as ahead and does NOT warn about +# divergence. +gs branch sync +stderr 'feat1: ahead of remote; nothing to pull' +! stderr 'diverged' + +-- repo/feat1.txt -- +feat 1 +-- extra/feat1-v2.txt -- +feat 1 rewritten diff --git a/testdata/script/branch_sync_rebase.txt b/testdata/script/branch_sync_rebase.txt new file mode 100644 index 000000000..98c23f85d --- /dev/null +++ b/testdata/script/branch_sync_rebase.txt @@ -0,0 +1,72 @@ +# 'gs branch sync --rebase' replays remote-side commits onto a local branch +# that has new local commits since the last push. +# This is the diverged case: local has work, remote has bot commits. + +as 'Test ' +at '2026-06-10T10:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create feat1 and submit +git add feat1.txt +gs bc -m 'Add feat1' feat1 +gs branch submit --fill +stderr 'Created #' + +# simulate a bot pushing a regen commit to feat1 on the remote. +cd .. +git clone $SHAMHUB_URL/alice/example.git fork +cd fork +git checkout feat1 +cp $WORK/extra/feat1-regen.txt feat1.txt +git add feat1.txt +git commit -m 'bot: regenerate derived files' +git push origin feat1 + +# meanwhile, locally, the user adds a commit to a different file. +cd ../repo +git add other.txt +git commit -m 'Add other' + +# 'gs branch sync --rebase' takes the bot commit from origin/feat1 +# and replays it onto the local feat1 (which has 'Add other' on top). +gs branch sync --rebase +stderr 'feat1: rebased' + +# verify the final history: bot commit replayed on top of 'Add other' +git log --pretty=%s feat1 +cmp stdout $WORK/golden/log-after.txt + +# the local working tree has both: +# - other.txt (local commit) +# - feat1.txt with regen content (bot commit) +cmp feat1.txt $WORK/extra/feat1-regen.txt +exists other.txt + +# follow-up submit succeeds; bot commit is part of the PR now. +gs branch submit +stderr 'Updated #' + +-- repo/feat1.txt -- +feat 1 +-- repo/other.txt -- +other content +-- extra/feat1-regen.txt -- +feat 1 +regenerated by bot +-- golden/log-after.txt -- +bot: regenerate derived files +Add other +Add feat1 +Initial commit diff --git a/testdata/script/branch_sync_rebase_restacked.txt b/testdata/script/branch_sync_rebase_restacked.txt new file mode 100644 index 000000000..f21b7815c --- /dev/null +++ b/testdata/script/branch_sync_rebase_restacked.txt @@ -0,0 +1,67 @@ +# 'gs branch sync --rebase' integrates remote-side commits even when the +# local branch was rewritten below the last-pushed hash (e.g. restacked +# onto a newer trunk). The remote-side commits (pHash..remote) are +# replayed onto the current local tip; they are not stranded just +# because the pushed commit is no longer in local history. + +as 'Test ' +at '2026-06-18T10:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create feat1 and submit; records the last-pushed hash. +git add feat1.txt +gs bc -m 'Add feat1' feat1 +gs branch submit --fill +stderr 'Created #' + +# a bot pushes a derived-file commit to feat1 on the remote. +cd .. +git clone $SHAMHUB_URL/alice/example.git fork +cd fork +git checkout feat1 +cp $WORK/extra/derived.txt derived.txt +git add derived.txt +git commit -m 'bot: regenerate derived files' +git push origin feat1 + +# meanwhile, the user rewrites feat1 locally below the last-pushed hash +# (an amend stands in for a restack: the pushed commit leaves local +# history). The bot commit is NOT in local history. +cd ../repo +cp $WORK/extra/feat1-v2.txt feat1.txt +git add feat1.txt +gs commit amend --no-edit + +# 'gs branch sync --rebase' replays the bot commit onto the rewritten +# local tip instead of skipping it as unrecoverable. +gs branch sync --rebase +stderr 'feat1: rebased' + +# the bot's derived file is now present locally on top of the rewrite. +git log --pretty=%s feat1 +cmp stdout $WORK/golden/log-after.txt +exists derived.txt +cmp feat1.txt $WORK/extra/feat1-v2.txt + +-- repo/feat1.txt -- +feat 1 +-- extra/feat1-v2.txt -- +feat 1 rewritten +-- extra/derived.txt -- +generated by bot +-- golden/log-after.txt -- +bot: regenerate derived files +Add feat1 +Initial commit diff --git a/testdata/script/branch_sync_regenerate_driver.txt b/testdata/script/branch_sync_regenerate_driver.txt new file mode 100644 index 000000000..b22750a78 --- /dev/null +++ b/testdata/script/branch_sync_regenerate_driver.txt @@ -0,0 +1,87 @@ +# 'gs branch sync --rebase' respects .gitattributes merge drivers. +# When a file matches a custom 'regenerate' driver, conflicts in that file +# are auto-resolved by re-running the configured generator, not by leaving +# conflict markers for the user. + +as 'Test ' +at '2026-06-10T10:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# install a custom merge driver named 'regenerate'. +# the driver script writes a sentinel value to the merged path. +chmod 755 $WORK/extra/regen-driver.sh +env REGEN_DRIVER=$WORK/extra/regen-driver.sh +git config merge.regenerate.name 'Regenerate derived files' +# Driver string includes %A %O %B placeholders for the file paths. +git config merge.regenerate.driver $REGEN_DRIVER' %A %O %B' + +# create a branch with two files: +# - input.txt: the source file (no merge driver) +# - doc.txt: a derived file (uses the regenerate driver) +git add .gitattributes input.txt doc.txt +gs bc -m 'Add feature' feat1 +gs branch submit --fill +stderr 'Created #' + +# simulate the bot regenerating doc.txt on the remote. +cd .. +git clone $SHAMHUB_URL/alice/example.git fork +cd fork +git checkout feat1 +cp $WORK/extra/doc-bot.txt doc.txt +git add doc.txt +git commit -m 'bot: regenerate doc.txt' +git push origin feat1 + +# locally, change both input.txt and doc.txt. +cd ../repo +cp $WORK/extra/input-local.txt input.txt +cp $WORK/extra/doc-local.txt doc.txt +git add input.txt doc.txt +git commit -m 'local: change input and doc' + +# 'gs branch sync --rebase' should succeed because doc.txt is +# auto-resolved by the regenerate driver. +gs branch sync --rebase +stderr 'feat1: rebased' + +# verify the resolved doc.txt has the sentinel from the driver, +# and input.txt has the local change. +cmp doc.txt $WORK/extra/doc-sentinel.txt +cmp input.txt $WORK/extra/input-local.txt + +# follow-up submit succeeds. +gs branch submit +stderr 'Updated #' + +-- repo/.gitattributes -- +doc.txt merge=regenerate +-- repo/input.txt -- +input v1 +-- repo/doc.txt -- +DOC: input v1 +-- extra/input-local.txt -- +input local +-- extra/doc-local.txt -- +DOC: input local +-- extra/doc-bot.txt -- +DOC: input v1 (generated by bot) +-- extra/doc-sentinel.txt -- +REGENERATED-BY-DRIVER +-- extra/regen-driver.sh -- +#!/bin/sh +# Custom merge driver: writes a sentinel to %A (the merged path). +echo "REGENERATED-BY-DRIVER" > "$1" +exit 0 diff --git a/testdata/script/repo_sync_pull_branches.txt b/testdata/script/repo_sync_pull_branches.txt new file mode 100644 index 000000000..50d88f9a9 --- /dev/null +++ b/testdata/script/repo_sync_pull_branches.txt @@ -0,0 +1,81 @@ +# 'gs repo sync' pulls remote-side commits on tracked stack branches +# in addition to trunk. Branches that fast-forward bring their children +# along by triggering a restack; clean branches with moved parents +# also restack so the stack stays coherent. + +as 'Test ' +at '2026-06-10T10:00:00Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# build a stack: main -> feat1 -> feat2 -> feat3 +git add feat1.txt +gs bc -m 'Add feat1' feat1 +git add feat2.txt +gs bc -m 'Add feat2' feat2 +git add feat3.txt +gs bc -m 'Add feat3' feat3 +gs stack submit --fill +stderr 'Created #1' +stderr 'Created #2' +stderr 'Created #3' + +# simulate a bot pushing a regen commit to feat1 on the remote. +# feat2 and feat3 are untouched on the remote. +cd .. +git clone $SHAMHUB_URL/alice/example.git fork +cd fork + +git checkout feat1 +cp $WORK/extra/feat1-regen.txt feat1-regen.txt +git add feat1-regen.txt +git commit -m 'bot: regenerate feat1' +git push origin feat1 + +# back in the main repo +cd ../repo +gs bco feat3 + +# 'gs repo sync' pulls trunk, then each tracked branch in bottom-up order. +# feat1 fast-forwards, then feat2 and feat3 restack onto the new bases. +gs repo sync +stderr 'feat1: fast-forwarded' + +# verify all branches now have the bot commit in their history. +gs bco feat1 +exists feat1-regen.txt + +gs bco feat2 +exists feat1-regen.txt +exists feat2.txt + +gs bco feat3 +exists feat1-regen.txt +exists feat2.txt +exists feat3.txt + +# stack submit succeeds. feat2 and feat3 will show 'Updated #' because +# their SHAs changed during the restack. +gs stack submit +! stderr '\(stale info\)' +stderr 'Updated #' + +-- repo/feat1.txt -- +feat 1 +-- repo/feat2.txt -- +feat 2 +-- repo/feat3.txt -- +feat 3 +-- extra/feat1-regen.txt -- +generated content for feat1 diff --git a/upstack.go b/upstack.go index c569105fe..78a7b542d 100644 --- a/upstack.go +++ b/upstack.go @@ -2,6 +2,7 @@ package main type upstackCmd struct { Submit upstackSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch and those above it"` + Sync upstackSyncCmd `cmd:"" aliases:"sy" help:"Pull remote-side commits for a branch and those above it" released:"unreleased"` Restack upstackRestackCmd `cmd:"" aliases:"r" help:"Restack a branch and its upstack"` Onto upstackOntoCmd `cmd:"" aliases:"o" help:"Move a branch onto another branch"` Delete upstackDeleteCmd `cmd:"" aliases:"d" released:"v0.16.0" help:"Delete all branches above the current branch"` diff --git a/upstack_sync.go b/upstack_sync.go new file mode 100644 index 000000000..445c1463b --- /dev/null +++ b/upstack_sync.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "errors" + "fmt" + "slices" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/handler/branchsync" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type upstackSyncCmd struct { + Branch string `placeholder:"NAME" help:"Branch to start at" predictor:"trackedBranches"` + Rebase bool `help:"On divergence, replay remote-side commits onto local."` +} + +func (*upstackSyncCmd) Help() string { + return text.Dedent(` + Pull remote-side commits for the current branch and all + branches upstack from it. Branches that fast-forward + (or rebase, with --rebase) trigger an upstack restack of + their children so the stack stays coherent. + Use --branch to start at a different branch. + `) +} + +func (cmd *upstackSyncCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + store *state.Store, + svc *spice.Service, + branchSyncHandler *branchsync.Handler, + restackHandler RestackHandler, +) error { + if cmd.Branch == "" { + currentBranch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + cmd.Branch = currentBranch + } + + graph, err := svc.BranchGraph(ctx, nil) + if err != nil { + return fmt.Errorf("build branch graph: %w", err) + } + + branches := slices.Collect(graph.Upstack(cmd.Branch)) + if cmd.Branch == store.Trunk() && len(branches) > 0 && branches[0] == cmd.Branch { + branches = branches[1:] + } + + return syncBranches(ctx, log, branchSyncHandler, restackHandler, branches, cmd.Rebase) +} + +// syncBranches runs Sync on each branch in order and restacks the +// upstack of any branch whose tip moved. +func syncBranches( + ctx context.Context, + log *silog.Logger, + branchSyncHandler *branchsync.Handler, + restackHandler RestackHandler, + branches []string, + rebase bool, +) error { + mode := branchsync.ModeFastForward + if rebase { + mode = branchsync.ModeRebase + } + + for _, branch := range branches { + res, err := branchSyncHandler.Sync(ctx, branchsync.SyncRequest{Branch: branch, Mode: mode}) + if err != nil { + if errors.Is(err, branchsync.ErrNoUpstream) { + continue + } + log.Warnf("%v: sync failed: %v", branch, err) + continue + } + switch res.Action { + case branchsync.ActionClean: + // quiet + case branchsync.ActionFastForward: + log.Infof("%v: fast-forwarded %s..%s", res.Branch, res.FromHash.Short(), res.ToHash.Short()) + case branchsync.ActionRebased: + log.Infof("%v: rebased onto remote %s", res.Branch, res.ToHash.Short()) + case branchsync.ActionBehind: + // quiet + case branchsync.ActionDiverged: + log.Warnf("%v: diverged from remote; pass --rebase to integrate", res.Branch) + case branchsync.ActionSkipped: + log.Warnf("%v: skipped (%v)", res.Branch, res.SkipReason) + } + + if res.FromHash != res.ToHash { + if err := restackHandler.RestackUpstack(ctx, res.Branch, nil); err != nil { + log.Warnf("%v: upstack restack after sync failed: %v", res.Branch, err) + } + } + } + + return nil +}