Skip to content

Commit 2661ee7

Browse files
committed
sync command to fetch, rebase, and push
1 parent cf62538 commit 2661ee7

3 files changed

Lines changed: 285 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func RootCmd() *cobra.Command {
2828
// Remote operations
2929
root.AddCommand(CheckoutCmd(cfg))
3030
root.AddCommand(PushCmd(cfg))
31+
root.AddCommand(SyncCmd(cfg))
3132
root.AddCommand(UnstackCmd(cfg))
3233
root.AddCommand(MergeCmd(cfg))
3334

cmd/sync.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package cmd
2+
3+
import (
4+
"github.com/github/gh-stack/internal/config"
5+
"github.com/github/gh-stack/internal/git"
6+
"github.com/github/gh-stack/internal/stack"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
type syncOptions struct{}
11+
12+
func SyncCmd(cfg *config.Config) *cobra.Command {
13+
opts := &syncOptions{}
14+
15+
cmd := &cobra.Command{
16+
Use: "sync",
17+
Short: "Sync the current stack with the remote",
18+
Long: `Fetch, rebase, push, and sync PR state for the current stack.
19+
20+
This command performs a safe, non-interactive synchronization:
21+
22+
1. Fetches the latest changes from origin
23+
2. Fast-forwards the trunk branch to match the remote
24+
3. Cascade-rebases stack branches onto their updated parents
25+
4. Pushes all branches (using --force-with-lease)
26+
5. Syncs PR state from GitHub
27+
28+
If a rebase conflict is detected, all branches are restored to their
29+
original state and you are advised to run "gh stack rebase" to resolve
30+
conflicts interactively.`,
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
return runSync(cfg, opts)
33+
},
34+
}
35+
36+
return cmd
37+
}
38+
39+
func runSync(cfg *config.Config, _ *syncOptions) error {
40+
gitDir, err := git.GitDir()
41+
if err != nil {
42+
cfg.Errorf("not a git repository")
43+
return nil
44+
}
45+
46+
sf, err := stack.Load(gitDir)
47+
if err != nil {
48+
cfg.Errorf("failed to load stack state: %s", err)
49+
return nil
50+
}
51+
52+
currentBranch, err := git.CurrentBranch()
53+
if err != nil {
54+
cfg.Errorf("failed to get current branch: %s", err)
55+
return nil
56+
}
57+
58+
s, err := resolveStack(sf, currentBranch, cfg)
59+
if err != nil {
60+
cfg.Errorf("%s", err)
61+
return nil
62+
}
63+
if s == nil {
64+
cfg.Errorf("current branch %q is not part of a stack", currentBranch)
65+
return nil
66+
}
67+
68+
// Re-read current branch in case disambiguation caused a checkout
69+
currentBranch, err = git.CurrentBranch()
70+
if err != nil {
71+
cfg.Errorf("failed to get current branch: %s", err)
72+
return nil
73+
}
74+
75+
// --- Step 1: Fetch ---
76+
cfg.Printf("Fetching origin ...")
77+
if err := git.Fetch("origin"); err != nil {
78+
cfg.Warningf("Failed to fetch origin: %v", err)
79+
} else {
80+
cfg.Successf("Fetched latest changes")
81+
}
82+
83+
// --- Step 2: Fast-forward trunk ---
84+
trunk := s.Trunk.Branch
85+
trunkUpdated := false
86+
87+
localSHA, localErr := git.HeadSHA(trunk)
88+
remoteSHA, remoteErr := git.HeadSHA("origin/" + trunk)
89+
90+
if localErr != nil || remoteErr != nil {
91+
cfg.Warningf("Could not compare trunk %s with remote — skipping trunk update", trunk)
92+
} else if localSHA == remoteSHA {
93+
cfg.Successf("Trunk %s is already up to date", trunk)
94+
} else {
95+
isAncestor, err := git.IsAncestor(localSHA, remoteSHA)
96+
if err != nil {
97+
cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err)
98+
} else if !isAncestor {
99+
cfg.Warningf("Trunk %s has diverged from origin — skipping trunk update", trunk)
100+
cfg.Printf(" Local and remote %s have diverged. Resolve manually.", trunk)
101+
} else {
102+
// Fast-forward the trunk branch
103+
if currentBranch == trunk {
104+
// Can't update ref of checked-out branch; merge instead
105+
if err := ffMerge(trunk); err != nil {
106+
cfg.Warningf("Failed to fast-forward %s: %v", trunk, err)
107+
} else {
108+
cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA))
109+
trunkUpdated = true
110+
}
111+
} else {
112+
if err := updateBranchRef(trunk, remoteSHA); err != nil {
113+
cfg.Warningf("Failed to fast-forward %s: %v", trunk, err)
114+
} else {
115+
cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA))
116+
trunkUpdated = true
117+
}
118+
}
119+
}
120+
}
121+
122+
// --- Step 3: Cascade rebase (only if trunk moved) ---
123+
rebased := false
124+
if trunkUpdated {
125+
cfg.Printf("")
126+
cfg.Printf("Rebasing stack ...")
127+
128+
// Save original refs so we can restore on conflict
129+
originalRefs := make(map[string]string)
130+
for _, b := range s.Branches {
131+
sha, _ := git.HeadSHA(b.Branch)
132+
originalRefs[b.Branch] = sha
133+
}
134+
135+
conflicted := false
136+
for i, br := range s.Branches {
137+
var base string
138+
if i == 0 {
139+
base = trunk
140+
} else {
141+
base = s.Branches[i-1].Branch
142+
}
143+
144+
if err := git.CheckoutBranch(br.Branch); err != nil {
145+
cfg.Errorf("Failed to checkout %s: %v", br.Branch, err)
146+
conflicted = true
147+
break
148+
}
149+
150+
if err := git.Rebase(base); err != nil {
151+
// Conflict detected — abort and restore everything
152+
if git.IsRebaseInProgress() {
153+
_ = git.RebaseAbort()
154+
}
155+
156+
// Restore all branches to original state
157+
for branch, sha := range originalRefs {
158+
_ = git.CheckoutBranch(branch)
159+
_ = git.ResetHard(sha)
160+
}
161+
162+
_ = git.CheckoutBranch(currentBranch)
163+
164+
cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, base)
165+
cfg.Printf(" All branches restored to their original state.")
166+
cfg.Printf(" Run %s to resolve conflicts interactively.",
167+
cfg.ColorCyan("gh stack rebase"))
168+
conflicted = true
169+
break
170+
}
171+
172+
cfg.Successf("Rebased %s onto %s", br.Branch, base)
173+
}
174+
175+
if !conflicted {
176+
rebased = true
177+
_ = git.CheckoutBranch(currentBranch)
178+
}
179+
}
180+
181+
// --- Step 4: Push ---
182+
cfg.Printf("")
183+
branches := make([]string, len(s.Branches))
184+
for i, b := range s.Branches {
185+
branches[i] = b.Branch
186+
}
187+
188+
// After rebase, force-with-lease is required (history rewritten).
189+
// Without rebase, try a normal push first.
190+
force := rebased
191+
cfg.Printf("Pushing branches ...")
192+
if err := git.Push("origin", branches, force, false); err != nil {
193+
if !force {
194+
cfg.Warningf("Push failed — branches may need force push after rebase")
195+
cfg.Printf(" Run %s to push with --force-with-lease.",
196+
cfg.ColorCyan("gh stack push -f"))
197+
} else {
198+
cfg.Warningf("Push failed: %v", err)
199+
cfg.Printf(" Run %s to retry.", cfg.ColorCyan("gh stack push -f"))
200+
}
201+
} else {
202+
cfg.Successf("Pushed %d branches", len(branches))
203+
}
204+
205+
// --- Step 5: Sync PR state ---
206+
cfg.Printf("")
207+
cfg.Printf("Syncing PRs ...")
208+
syncStackPRs(cfg, s)
209+
210+
// Report PR status for each branch
211+
for _, b := range s.Branches {
212+
if b.PullRequest != nil {
213+
state := "Open"
214+
if b.PullRequest.Merged {
215+
state = "Merged"
216+
}
217+
cfg.Successf("PR #%d (%s) — %s", b.PullRequest.Number, b.Branch, state)
218+
} else {
219+
cfg.Warningf("%s has no PR", b.Branch)
220+
}
221+
}
222+
223+
// --- Step 6: Update base SHAs and save ---
224+
for i := range s.Branches {
225+
parent := trunk
226+
if i > 0 {
227+
parent = s.Branches[i-1].Branch
228+
}
229+
if base, err := git.HeadSHA(parent); err == nil {
230+
s.Branches[i].Base = base
231+
}
232+
}
233+
234+
if err := stack.Save(gitDir, sf); err != nil {
235+
cfg.Errorf("failed to save stack state: %s", err)
236+
return nil
237+
}
238+
239+
cfg.Printf("")
240+
cfg.Successf("Stack synced")
241+
return nil
242+
}
243+
244+
// ffMerge fast-forwards the currently checked-out branch to match origin.
245+
func ffMerge(branch string) error {
246+
return git.MergeFF("origin/" + branch)
247+
}
248+
249+
// updateBranchRef updates a branch ref to point to a new SHA (for branches not checked out).
250+
func updateBranchRef(branch, sha string) error {
251+
return git.UpdateBranchRef(branch, sha)
252+
}
253+
254+
// short returns the first 7 characters of a SHA.
255+
func short(sha string) string {
256+
if len(sha) > 7 {
257+
return sha[:7]
258+
}
259+
return sha
260+
}

internal/git/git.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,20 @@ func FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) {
202202
return info, nil
203203
}
204204

205+
// IsAncestor returns whether ancestor is an ancestor of descendant.
206+
// This is useful to check if a fast-forward merge is possible.
207+
func IsAncestor(ancestor, descendant string) (bool, error) {
208+
err := runSilent("merge-base", "--is-ancestor", ancestor, descendant)
209+
if err == nil {
210+
return true, nil
211+
}
212+
// Exit code 1 means "not an ancestor", which is not an error condition.
213+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
214+
return false, nil
215+
}
216+
return false, err
217+
}
218+
205219
// HeadSHA returns the full SHA of the given ref.
206220
func HeadSHA(ref string) (string, error) {
207221
return run("rev-parse", ref)
@@ -262,3 +276,13 @@ func ResetHard(ref string) error {
262276
func SetUpstreamTracking(branch, remote string) error {
263277
return runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch)
264278
}
279+
280+
// MergeFF fast-forwards the currently checked-out branch using a merge.
281+
func MergeFF(target string) error {
282+
return runSilent("merge", "--ff-only", target)
283+
}
284+
285+
// UpdateBranchRef moves a branch pointer to a new commit (for branches not currently checked out).
286+
func UpdateBranchRef(branch, sha string) error {
287+
return runSilent("branch", "-f", branch, sha)
288+
}

0 commit comments

Comments
 (0)