Files
github__gh-stack/cmd/sync.go
Sameen Karim f7b560076e Force-push rewritten branches from sync, and stop signing off when it fails
Running `gh stack rebase` and then `gh stack sync` left the stack unpushed.
sync derived the force flag purely from whether it had rebased in that same
run, so after an earlier rebase it attempted a plain push; the branches had
rewritten history, the atomic push was rejected as a whole, and sync still
finished with "✓ Stack synced".

The flag now also accounts for branches that already diverged from their
remote-tracking refs, which is the state any earlier rebase leaves behind.
--force-with-lease still protects against overwriting someone else's work.

A push that does not land is also no longer reported as a synced stack: the
remote does not have the commits, so nothing downstream of the push reflects
the local stack. sync now says so and points at `gh stack push`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f5959297-80fd-4732-aeae-aa9a4b6a7755
2026-07-27 12:57:09 -04:00

525 lines
17 KiB
Go

package cmd
import (
"errors"
"fmt"
"strings"
"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/modify"
"github.com/github/gh-stack/internal/stack"
"github.com/spf13/cobra"
)
type syncOptions struct {
remote string
prune bool
autostash bool
}
func SyncCmd(cfg *config.Config) *cobra.Command {
opts := &syncOptions{}
cmd := &cobra.Command{
Use: "sync",
Short: "Sync the current stack with the remote",
Long: `Fetch, rebase, push, and sync PR state for the current stack.
This command performs a safe synchronization:
1. Fetches the latest changes from the remote
2. Reconciles the stack on GitHub with your local stack: pulls down
branches for any PRs added to the stack on GitHub, or prompts you to
resolve a divergence in an interactive terminal
3. Fast-forwards the trunk branch to match the remote
4. Cascade-rebases stack branches onto their updated parents
5. Pushes all branches atomically (using --force-with-lease --atomic)
6. Syncs PR state from GitHub
7. Links the stack's open PRs into a stack on GitHub (creating or updating
the remote stack object) when two or more PRs exist
Requires no rebase in progress and no uncommitted changes to tracked files,
since git refuses to rebase otherwise. Untracked files are fine. Use
--autostash to stash your changes for the duration of the sync and restore
them afterwards.
If the local trunk branch cannot be brought up to date — because it is
checked out in another worktree, or has diverged from the remote — the
stack is rebased onto the remote-tracking branch instead. Branches are only
pushed once sync has verified that each one really does sit on top of its
parent.
If PRs have been added to the stack on GitHub, their branches are pulled
down and appended to your local stack so it mirrors the remote. A clean
"remote is ahead" update happens automatically without prompting. If the
local and remote stacks have diverged, sync prompts (in an interactive
terminal) to use the remote as the source of truth, delete the stack on
GitHub and recreate it later with sync/submit, or cancel. Cancelling — or a
divergence in a non-interactive terminal — aborts the sync without pushing
branches or updating PRs.
If a rebase conflict is detected, all branches are restored to their
original state and you are advised to run "gh stack rebase" to resolve
conflicts interactively.
Sync never opens pull requests — use "gh stack submit" for that. It only
links PRs that already exist. The final message reflects what happened:
"Stack synced" means the stack object on GitHub now matches your local
stack, while "Branches synced" means the branches were rebased and pushed
but no remote stack object was created or updated (for example, when fewer
than two PRs exist yet).
Use --prune to delete local branches for merged PRs. Stack metadata is
preserved so that rebase and display logic continue to work correctly.
If you are on a branch that would be pruned, your checkout is moved to
the first active branch in the stack, or the trunk if all are merged.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSync(cfg, opts)
},
}
cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)")
cmd.Flags().BoolVar(&opts.prune, "prune", false, "Delete local branches for merged PRs")
cmd.Flags().BoolVar(&opts.autostash, "autostash", false, "Stash uncommitted changes before rebasing and restore them afterwards")
return cmd
}
func runSync(cfg *config.Config, opts *syncOptions) error {
result, err := loadStack(cfg, "")
if err != nil {
return ErrNotInStack
}
gitDir := result.GitDir
if err := modify.CheckStateGuard(gitDir); err != nil {
cfg.Errorf("%s", err)
return ErrModifyRecovery
}
// git refuses to rebase when another rebase is in progress or tracked files
// are dirty, and sync has no interactive conflict recovery, so check before
// touching any refs.
if err := preflightRebase(cfg, "sync", opts.autostash); err != nil {
return err
}
// Stash once around the whole run. sync has no interactive conflict
// recovery — it restores the branches itself — so the stash is always
// popped before returning.
if opts.autostash {
stashed, stashErr := stashForRebase(cfg)
if stashErr != nil {
cfg.Errorf("%s", stashErr)
return ErrSilent
}
if stashed {
defer restoreStash(cfg)
}
}
sf := result.StackFile
s := result.Stack
currentBranch := result.CurrentBranch
// Resolve remote once for fetch and push
remote, err := pickRemote(cfg, currentBranch, opts.remote)
if err != nil {
if !errors.Is(err, errInterrupt) {
cfg.Errorf("%s", err)
}
return ErrSilent
}
// --- Step 1: Fetch ---
// Enable git rerere so conflict resolutions are remembered.
if err := ensureRerere(cfg); errors.Is(err, errInterrupt) {
return ErrSilent
}
// Fetch trunk + active branches so tracking refs are current for
// fast-forward detection (Step 2) and --force-with-lease (Step 4). The
// trunk name is normalized first so a remote-qualified trunk does not
// produce a "origin/origin/main" refspec.
normalizeStackTrunk(cfg, s, remote)
fetchTargets := append([]string{s.Trunk.Branch}, activeBranchNames(s)...)
if err := git.FetchBranches(remote, fetchTargets); err != nil {
cfg.Warningf("Failed to fetch from %s: %v", remote, err)
cfg.Printf(" Continuing with the refs already available locally, which may be out of date.")
} else {
cfg.Successf("Fetched latest changes from %s", remote)
}
// --- Step 1b: Reconcile remote-ahead stack changes ---
// Pull in branches for PRs that were added to the stack on GitHub, or
// resolve a divergence, before rebasing and pushing so pulled branches
// participate in the normal flow. Best-effort for stacks tracked on the
// remote; a no-op otherwise.
reconcileRes, err := reconcileRemoteStack(cfg, sf, s, currentBranch, gitDir, remote)
if err != nil {
if errors.Is(err, errInterrupt) {
return ErrSilent
}
return err
}
if reconcileRes.stack != nil {
s = reconcileRes.stack
}
if reconcileRes.stop {
// The reconcile step resolved the situation and there is nothing more to
// do (the user cancelled or deleted the remote stack, or a divergence was
// detected non-interactively). The resolving path already reported the
// outcome, so just exit successfully.
return nil
}
// Reconciling "use remote as source of truth" may have moved us off a
// branch that is no longer in the stack, so re-read the current branch.
if cb, cbErr := git.CurrentBranch(); cbErr == nil {
currentBranch = cb
}
// --- Step 2: Resolve and fast-forward trunk ---
// Resolves the ref the cascade rebases the bottom branch onto: the local
// trunk when it can be brought up to date, otherwise the remote-tracking
// ref, so a trunk that cannot be moved never leaves the stack silently
// based on a stale commit.
trunk, err := resolveTrunkTarget(cfg, s, remote, currentBranch)
if err != nil {
return err
}
trunkUpdated := trunk.Moved
// --- Step 2b: Fast-forward stack branches behind their remote tracking branch ---
updatedBranches := fastForwardBranches(cfg, s, remote, currentBranch)
branchesUpdated := len(updatedBranches) > 0
// --- Step 3: Cascade rebase ---
// Rebase if trunk or any branch moved, or if the stack is stale
// (branches not yet rebased onto their parent's current tip).
needsRebase := trunkUpdated || branchesUpdated || stackNeedsRebase(s, trunk.Ref)
rebased := false
if needsRebase {
cfg.Printf("")
cfg.Printf("Rebasing stack ...")
// Sync PR state to detect merged PRs before rebasing.
_ = syncStackPRs(cfg, s)
originalRefs, err := resolveOriginalRefs(s)
if err != nil {
cfg.Warningf("Could not resolve branch SHAs — skipping rebase: %v", err)
} else {
result := cascadeRebase(cascadeRebaseOpts{
Cfg: cfg,
Stack: s,
Branches: s.Branches,
StartAbsIdx: 0,
OriginalRefs: originalRefs,
TrunkRef: trunk.Ref,
})
if result.Err != nil {
cfg.Errorf("%v", result.Err)
_ = git.CheckoutBranch(currentBranch)
stack.SaveNonBlocking(gitDir, sf)
return ErrSilent
}
if result.Conflicted {
// Abort and restore everything — sync is non-interactive.
if git.IsRebaseInProgress() {
_ = git.RebaseAbort()
}
restoreErrors := restoreBranches(originalRefs)
_ = git.CheckoutBranch(currentBranch)
cfg.Errorf("Conflict detected rebasing %s onto %s", result.ConflictBranch, result.ConflictBase)
reportRestoreStatus(cfg, restoreErrors)
cfg.Printf(" Run `%s` to resolve conflicts interactively.",
cfg.ColorCyan("gh stack rebase"))
// Persist refreshed PR state even on conflict, then bail out
// before pushing or reporting success.
stack.SaveNonBlocking(gitDir, sf)
return ErrConflict
}
if result.Rebased {
rebased = true
}
}
_ = git.CheckoutBranch(currentBranch)
}
// Never push branches that are not actually stacked on the trunk target. A
// rebase that reported success but left the stack where it was must not be
// force-pushed over the remote.
if unstacked := verifyStacked(s, trunk.Ref, 0, len(s.Branches)); len(unstacked) > 0 {
_ = git.CheckoutBranch(currentBranch)
reportUnstacked(cfg, trunk.Ref, unstacked)
stack.SaveNonBlocking(gitDir, sf)
return ErrSilent
}
// --- Step 4: Push ---
cfg.Printf("")
branches := activeBranchNames(s)
pushed := true
if mergedCount := len(s.MergedBranches()); mergedCount > 0 {
cfg.Printf("Skipping %d merged %s", mergedCount, plural(mergedCount, "branch", "branches"))
}
if queuedCount := len(s.QueuedBranches()); queuedCount > 0 {
cfg.Printf("Skipping %d queued %s", queuedCount, plural(queuedCount, "branch", "branches"))
}
if len(branches) == 0 {
cfg.Printf("No active branches to push (all merged)")
} else {
// Rewritten history needs --force-with-lease. That is not only true of
// a rebase this run performed: a `gh stack rebase` beforehand leaves
// exactly the same divergence, and a plain push then fails for the
// whole atomic set.
force := rebased || branchesNeedForcePush(remote, branches)
cfg.Printf("Pushing %d %s to %s...", len(branches), plural(len(branches), "branch", "branches"), remote)
if err := git.Push(remote, branches, force, true); err != nil {
pushed = false
if !force {
cfg.Warningf("Push failed — branches may need force push after rebase")
cfg.Printf(" Run `%s` to push with --force-with-lease.",
cfg.ColorCyan("gh stack push"))
} else {
cfg.Warningf("Push failed: %v", err)
cfg.Printf(" Run `%s` to retry.", cfg.ColorCyan("gh stack push"))
}
} else {
cfg.Successf("Pushed %d branches", len(branches))
}
}
// --- Step 5: Sync PR state ---
cfg.Printf("")
cfg.Printf("Syncing PRs ...")
_ = syncStackPRs(cfg, s)
// Report PR status for each branch
for _, b := range s.Branches {
if b.IsMerged() {
continue
}
if b.IsQueued() {
cfg.Successf("PR %s (%s) — Queued", cfg.PRLink(b.PullRequest.Number, b.PullRequest.URL), b.Branch)
continue
}
if b.PullRequest != nil {
cfg.Successf("PR %s (%s) — Open", cfg.PRLink(b.PullRequest.Number, b.PullRequest.URL), b.Branch)
} else {
cfg.Warningf("%s has no PR", b.Branch)
}
}
merged := s.MergedBranches()
if len(merged) > 0 {
names := make([]string, len(merged))
for i, m := range merged {
if m.PullRequest != nil {
names[i] = fmt.Sprintf("#%d", m.PullRequest.Number)
} else {
names[i] = m.Branch
}
}
cfg.Printf("Merged: %s", strings.Join(names, ", "))
}
// --- Step 5b: Reconcile the remote stack object ---
// syncStackPRs above only refreshes local PR associations; it does not touch
// the stack object on GitHub. When the branches have open PRs, link them into
// a stack so the remote reflects the local stack. This never opens PRs — that
// is still `gh stack submit`'s job. stackSynced records whether the remote
// stack object actually reflects the local stack, which determines the final
// summary message below.
stackSynced := false
if client, err := cfg.GitHubClient(); err == nil {
stackSynced = syncStack(cfg, client, s)
}
// --- Step 6: Prune merged branches (optional) ---
doPrune := opts.prune
if !doPrune {
// --prune was not provided. If interactive, prompt.
merged := s.MergedBranches()
var prunableCount int
for _, b := range merged {
if git.BranchExists(b.Branch) {
prunableCount++
}
}
if prunableCount > 0 && cfg.IsInteractive() {
prompt := fmt.Sprintf("Prune %d merged %s?",
prunableCount, plural(prunableCount, "branch", "branches"))
confirmed, err := confirmPrune(cfg, prompt, true)
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
// Save state before exiting so PR sync isn't lost.
_ = stack.Save(gitDir, sf)
return ErrSilent
}
// On any other prompt error, skip pruning silently.
} else {
doPrune = confirmed
}
}
}
if doPrune {
merged := s.MergedBranches()
var prunable []string
for _, b := range merged {
if git.BranchExists(b.Branch) {
prunable = append(prunable, b.Branch)
}
}
if len(prunable) > 0 {
// If the current branch is being pruned, switch away first.
needsSwitch := false
for _, name := range prunable {
if name == currentBranch {
needsSwitch = true
break
}
}
if needsSwitch {
switchTarget := trunk.Branch
for _, b := range s.Branches {
if !b.IsSkipped() {
switchTarget = b.Branch
break
}
}
if err := git.CheckoutBranch(switchTarget); err != nil {
cfg.Warningf("Failed to switch from %s to %s: %v", currentBranch, switchTarget, err)
} else {
currentBranch = switchTarget
}
}
cfg.Printf("")
pruned := 0
for _, name := range prunable {
if err := git.DeleteBranch(name, true); err != nil {
cfg.Warningf("Failed to delete %s: %v", name, err)
} else {
cfg.Successf("Pruned %s (merged)", name)
pruned++
}
}
if pruned > 0 {
cfg.Successf("Pruned %d merged %s", pruned, plural(pruned, "branch", "branches"))
}
} else if opts.prune {
cfg.Printf("")
cfg.Printf("No merged branches to prune")
}
// Clean up remote-tracking refs for all merged branches, even if
// the local branch was already deleted. This prevents
// `git checkout <name>` from resurrecting the branch.
for _, b := range merged {
_ = git.DeleteTrackingRef(remote, b.Branch)
}
}
// --- Step 7: Update base SHAs and save ---
updateBaseSHAs(s)
if err := stack.Save(gitDir, sf); err != nil {
return handleSaveError(cfg, err)
}
cfg.Printf("")
switch {
case !pushed:
// The branches were rebased and the PR state refreshed, but the remote
// does not have the new commits, so nothing downstream of the push
// actually reflects the local stack yet.
cfg.Warningf("Synced locally, but the branches were not pushed")
cfg.Printf(" Run `%s` to push them.", cfg.ColorCyan("gh stack push"))
case stackSynced:
cfg.Successf("Stack synced")
default:
// The branches were fetched, rebased, and pushed, but no stack object on
// GitHub was created or updated (no PRs, fewer than two PRs, stacked PRs
// unavailable, or a divergence). Report only what actually happened.
cfg.Successf("Branches synced")
}
cfg.Printf(" Stacked on %s", trunk.Describe())
return nil
}
// branchesNeedForcePush reports whether pushing any of the branches would
// rewrite history on the remote, which requires --force-with-lease.
//
// A branch qualifies when its remote-tracking ref is not contained in the local
// branch — the state a rebase leaves behind. Branches with no remote ref yet,
// or whose ancestry cannot be determined, do not qualify: a plain push is the
// safer default and its failure is reported.
func branchesNeedForcePush(remote string, branches []string) bool {
for _, b := range branches {
remoteSHA, err := git.RevParse(remote + "/" + b)
if err != nil {
continue
}
if isAnc, err := git.IsAncestor(remoteSHA, b); err == nil && !isAnc {
return true
}
}
return false
}
// restoreBranches resets each branch to its original SHA, collecting any errors.
func restoreBranches(originalRefs map[string]string) []string {
var errors []string
for branch, sha := range originalRefs {
if err := git.CheckoutBranch(branch); err != nil {
errors = append(errors, fmt.Sprintf("checkout %s: %s", branch, err))
continue
}
if err := git.ResetHard(sha); err != nil {
errors = append(errors, fmt.Sprintf("reset %s: %s", branch, err))
}
}
return errors
}
// reportRestoreStatus prints whether branch restoration succeeded or partially failed.
func reportRestoreStatus(cfg *config.Config, restoreErrors []string) {
if len(restoreErrors) > 0 {
cfg.Warningf("Some branches could not be fully restored:")
for _, e := range restoreErrors {
cfg.Printf(" %s", e)
}
} else {
cfg.Printf(" All branches restored to their original state.")
}
}
// short returns the first 7 characters of a SHA.
func short(sha string) string {
if len(sha) > 7 {
return sha[:7]
}
return sha
}
// confirmPrune asks the user to confirm pruning via ConfirmFn or a terminal prompt.
func confirmPrune(cfg *config.Config, prompt string, defaultValue bool) (bool, error) {
if cfg.ConfirmFn != nil {
return cfg.ConfirmFn(prompt, defaultValue)
}
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
return p.Confirm(prompt, defaultValue)
}