Skip to main content

git_workflow/commands/
sync.rs

1//! `gw sync` command - Bring the current branch up to date with its base.
2//!
3//! "Base" is whatever this branch is meant to sit on, and `sync` always moves
4//! the branch onto the latest version of it:
5//!
6//! | Situation                                  | What `gw sync` does                                   |
7//! |--------------------------------------------|-------------------------------------------------------|
8//! | on the home branch                         | fast-forward pull from `origin/main`                  |
9//! | plain branch (base is `main`)              | `git rebase origin/main`, force-push if published     |
10//! | stacked, base PR still open                | rebase onto `origin/<base>`, force-push               |
11//! | stacked, base PR merged                    | `rebase --onto origin/main <old base>`, move the PR   |
12//! |                                            | base to `main`, force-push (restack)                  |
13//! | stacked before a PR exists, base merged    | `rebase --onto origin/main <recorded base tip>`       |
14//!
15//! Rebasing a stacked branch always uses `--onto` with the recorded base tip
16//! (`gw new --stack` stores it) as the boundary, so only *this* branch's own
17//! commits are replayed — never the base's, which after a squash merge would be
18//! doubled and conflict-prone. A plain `git rebase` is used only when nothing
19//! better is known (base is `main`, or a stack created without `gw new --stack`).
20//!
21//! # Example
22//!
23//! ```text
24//! $ gw status
25//!   Branch: feature/child
26//!   PR: #42 (open)
27//!   Base: feature/base (merged ✓)
28//!
29//!   Next: gw sync
30//!
31//! $ gw sync
32//!   Rebasing commits after <base tip> onto origin/main...
33//!   Updating PR base to main...
34//!   Force pushing...
35//!   ✓ Synced
36//! ```
37
38use super::helpers;
39use crate::error::{GwError, Result};
40use crate::git;
41use crate::github::{self, PrState};
42use crate::output;
43use crate::state::{RepoType, SyncState, WorkingDirState};
44
45/// How the branch should be moved onto its base.
46struct Plan {
47    /// Ref to rebase onto (`origin/main`, `origin/<parent>`, ...).
48    new_base: String,
49    /// `rebase --onto` boundary: replay only `boundary..HEAD`. `None` means a
50    /// plain `git rebase <new_base>`.
51    boundary: Option<String>,
52    /// The PR whose base must move to the default branch (restack after the
53    /// base PR merged).
54    retarget_pr: Option<u64>,
55    /// After rebasing, the branch is no longer stacked: drop the recorded base.
56    unstack: bool,
57    /// After rebasing, the branch is still stacked on `new_base`: re-record its
58    /// tip as the next `--onto` boundary.
59    rerecord_base: bool,
60}
61
62/// Execute the `sync` command
63pub fn run(verbose: bool) -> Result<()> {
64    // 1. Check prerequisites
65    if !git::is_git_repo() {
66        return Err(GwError::NotAGitRepository);
67    }
68
69    let working_dir = WorkingDirState::detect();
70    if !working_dir.is_clean() {
71        output::error(&format!(
72            "You have uncommitted changes ({}).",
73            working_dir.description()
74        ));
75        output::action("git add <files> && git commit -m \"...\"  # commit first");
76        output::action("gw pause                                  # or park the work as WIP");
77        return Err(GwError::UncommittedChanges);
78    }
79
80    // 2. Get current branch info
81    let repo_type = RepoType::detect()?;
82    let home_branch = repo_type.home_branch();
83    let current = git::current_branch()?;
84
85    // On home branch - just sync with origin/main
86    if current == home_branch {
87        println!();
88        output::info(&format!("Branch: {}", output::bold(&current)));
89
90        // Fetch latest
91        output::info("Fetching from origin...");
92        git::fetch_prune(verbose)?;
93        output::success("Fetched (stale remote branches pruned)");
94
95        // Detect default remote branch and sync
96        let default_remote = git::get_default_remote_branch()?;
97        let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
98        helpers::pull_with_output(&default_remote, default_branch, verbose)?;
99
100        output::ready("Ready", home_branch);
101        return Ok(());
102    }
103
104    println!();
105    output::info(&format!("Branch: {}", output::bold(&current)));
106
107    // 3. Fetch latest first to get accurate PR/branch state
108    output::info("Fetching from origin...");
109    git::fetch_prune(verbose)?;
110
111    // 4. Look up this branch's PR. GitHub's base is authoritative once a PR
112    // exists; if we can't ask (no gh, not a GitHub remote, network), fall back
113    // to what we know locally — the recorded stacked base, else main — and say
114    // so.
115    let pr = if github::is_gh_available() {
116        match github::get_pr_for_branch(&current) {
117            Ok(pr) => pr,
118            Err(e) => {
119                output::warn(&format!("Could not fetch PR info: {e}"));
120                output::warn("Assuming no PR; syncing with the locally known base.");
121                None
122            }
123        }
124    } else {
125        output::warn("GitHub CLI (gh) not available; syncing with the locally known base.");
126        None
127    };
128
129    // Detect default remote branch
130    let default_remote = git::get_default_remote_branch()?;
131    let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
132
133    // Locally recorded stacked base (`gw new --stack`), if any.
134    let recorded_base = git::branch_base(&current).filter(|b| b != default_branch && b != &current);
135    let recorded_base_sha = recorded_base
136        .as_ref()
137        .and_then(|_| git::branch_base_sha(&current))
138        // Only usable as a boundary if it is still in this branch's history.
139        .filter(|sha| git::is_ancestor(sha, "HEAD"));
140
141    // 5. Decide the plan from the PR (GitHub's base is authoritative once a PR
142    // exists) or, before a PR, from the recorded base.
143    let plan = match pr {
144        Some(pr) => {
145            output::info(&format!("PR: #{} ({})", pr.number, pr.title));
146            output::info(&format!("Base: {}", pr.base_branch));
147            match &pr.state {
148                PrState::Merged { .. } => {
149                    output::success(&format!("PR #{} is merged. Nothing to sync.", pr.number));
150                    output::hints(&["gw cleanup  # Delete the merged branch"]);
151                    return Ok(());
152                }
153                PrState::Closed => {
154                    output::warn(&format!(
155                        "PR #{} was closed without merging. Nothing to sync.",
156                        pr.number
157                    ));
158                    output::hints(&[&format!("gh pr reopen {}  # Reopen it first", pr.number)]);
159                    return Ok(());
160                }
161                PrState::Open => {}
162            }
163
164            if pr.base_branch == default_branch {
165                // The PR targets main. If it was stacked and GitHub already
166                // retargeted it (the merged base branch was deleted), the
167                // branch still carries the old base's commits — restack with
168                // the recorded boundary instead of a plain rebase.
169                match retargeted_boundary(recorded_base.as_deref(), recorded_base_sha.as_deref()) {
170                    Some(boundary) => Plan {
171                        new_base: default_remote.clone(),
172                        boundary: Some(boundary),
173                        retarget_pr: None,
174                        unstack: true,
175                        rerecord_base: false,
176                    },
177                    None => Plan {
178                        new_base: default_remote.clone(),
179                        boundary: None,
180                        retarget_pr: None,
181                        unstack: false,
182                        rerecord_base: false,
183                    },
184                }
185            } else {
186                match plan_for_stacked_pr(
187                    &pr.base_branch,
188                    pr.number,
189                    &default_remote,
190                    recorded_base_sha.as_deref(),
191                )? {
192                    Some(plan) => plan,
193                    None => return Ok(()),
194                }
195            }
196        }
197        None => match &recorded_base {
198            Some(base) => {
199                match plan_for_recorded_base(base, &default_remote, recorded_base_sha.as_deref())? {
200                    Some(plan) => plan,
201                    None => return Ok(()),
202                }
203            }
204            None => Plan {
205                new_base: default_remote.clone(),
206                boundary: None,
207                retarget_pr: None,
208                unstack: false,
209                rerecord_base: false,
210            },
211        },
212    };
213
214    // 6. Carry it out.
215    execute(&plan, &current, default_branch, verbose)
216}
217
218/// For a PR that GitHub shows targeting the default branch: if `gw new --stack`
219/// recorded a base whose PR has since merged, the branch was retargeted by
220/// GitHub and still contains the base's commits. Return the `--onto` boundary
221/// to replay only this branch's own commits; `None` for an ordinary branch.
222fn retargeted_boundary(
223    recorded_base: Option<&str>,
224    recorded_base_sha: Option<&str>,
225) -> Option<String> {
226    let base = recorded_base?;
227    if !recorded_base_pr_merged(base) {
228        return None;
229    }
230    output::info(&format!(
231        "Recorded base '{}' merged and the PR now targets the default branch — restacking",
232        base
233    ));
234    boundary_for_merged_base(base, recorded_base_sha)
235}
236
237/// Whether the recorded stacked base's PR has merged. "Can't tell" (no gh, not
238/// a GitHub remote, network) counts as not merged — we then keep following the
239/// base rather than guess it's gone.
240fn recorded_base_pr_merged(base: &str) -> bool {
241    if !github::is_gh_available() {
242        return false;
243    }
244    match github::get_pr_for_branch(base) {
245        Ok(Some(base_pr)) => base_pr.state.is_merged(),
246        Ok(None) => false,
247        Err(e) => {
248            output::warn(&format!("Could not check PR for base '{}': {}", base, e));
249            false
250        }
251    }
252}
253
254/// The `--onto` boundary after a base merged: the recorded base tip if we have
255/// it (survives the base branch's deletion), else the remote-tracking ref of
256/// the base (cleanup keeps it alive while a child PR still targets it).
257fn boundary_for_merged_base(base: &str, recorded_base_sha: Option<&str>) -> Option<String> {
258    if let Some(sha) = recorded_base_sha {
259        return Some(sha.to_string());
260    }
261    let remote_ref = format!("origin/{base}");
262    if git::ref_exists(&remote_ref) {
263        return Some(remote_ref);
264    }
265    output::warn(&format!(
266        "Cannot find where '{}' was forked from (no recorded base tip, origin/{} is gone).",
267        base, base
268    ));
269    output::hints(&[&format!(
270        "git rebase --onto origin/main <last commit of {base}>  # replay only your commits"
271    )]);
272    None
273}
274
275/// Plan for a PR stacked on `base` (GitHub base != default branch).
276fn plan_for_stacked_pr(
277    base: &str,
278    pr_number: u64,
279    default_remote: &str,
280    recorded_base_sha: Option<&str>,
281) -> Result<Option<Plan>> {
282    let base_pr = github::get_pr_for_branch(base)?;
283    match base_pr.as_ref().map(|p| &p.state) {
284        Some(PrState::Merged { .. }) => {
285            let base_pr = base_pr.as_ref().expect("matched Some");
286            output::success(&format!(
287                "Base PR #{} ({}) is merged ✓",
288                base_pr.number, base
289            ));
290            Ok(
291                boundary_for_merged_base(base, recorded_base_sha).map(|boundary| Plan {
292                    new_base: default_remote.to_string(),
293                    boundary: Some(boundary),
294                    retarget_pr: Some(pr_number),
295                    unstack: true,
296                    rerecord_base: false,
297                }),
298            )
299        }
300        Some(PrState::Closed) => {
301            let base_pr = base_pr.as_ref().expect("matched Some");
302            output::warn(&format!(
303                "Base PR #{} ({}) was closed without merging.",
304                base_pr.number, base
305            ));
306            output::hints(&[&format!(
307                "gh pr reopen {}  # or retarget this PR with gh pr edit --base",
308                base_pr.number
309            )]);
310            Ok(None)
311        }
312        Some(PrState::Open) | None => {
313            // Base still in flight: follow it (pick up the parent's new commits).
314            if base_pr.is_none() {
315                output::warn(&format!(
316                    "No PR found for base branch '{}'; following origin/{}.",
317                    base, base
318                ));
319            }
320            let base_ref = format!("origin/{base}");
321            if !git::ref_exists(&base_ref) {
322                output::warn(&format!("origin/{} does not exist. Nothing to sync.", base));
323                return Ok(None);
324            }
325            Ok(Some(Plan {
326                new_base: base_ref,
327                boundary: recorded_base_sha.map(String::from),
328                retarget_pr: None,
329                unstack: false,
330                rerecord_base: recorded_base_sha.is_some(),
331            }))
332        }
333    }
334}
335
336/// Plan for a branch stacked via `gw new --stack` that has no PR yet.
337fn plan_for_recorded_base(
338    base: &str,
339    default_remote: &str,
340    recorded_base_sha: Option<&str>,
341) -> Result<Option<Plan>> {
342    output::info(&format!("Base: {} (stacked, PR not created yet)", base));
343    if recorded_base_pr_merged(base) {
344        output::success(&format!("Base '{}' merged ✓ — restacking onto main", base));
345        return Ok(
346            boundary_for_merged_base(base, recorded_base_sha).map(|boundary| Plan {
347                new_base: default_remote.to_string(),
348                boundary: Some(boundary),
349                retarget_pr: None,
350                unstack: true,
351                rerecord_base: false,
352            }),
353        );
354    }
355    // Follow the parent: its remote ref if pushed, else the local branch.
356    let remote_ref = format!("origin/{base}");
357    let base_ref = if git::ref_exists(&remote_ref) {
358        remote_ref
359    } else if git::branch_exists(base) {
360        base.to_string()
361    } else {
362        output::warn(&format!(
363            "Base branch '{}' no longer exists locally or on origin. Nothing to sync.",
364            base
365        ));
366        return Ok(None);
367    };
368    Ok(Some(Plan {
369        new_base: base_ref,
370        boundary: recorded_base_sha.map(String::from),
371        retarget_pr: None,
372        unstack: false,
373        rerecord_base: recorded_base_sha.is_some(),
374    }))
375}
376
377/// Rebase per `plan`, then publish (force-with-lease) if the branch is pushed.
378fn execute(plan: &Plan, current: &str, default_branch: &str, verbose: bool) -> Result<()> {
379    let upstream_exists = git::has_remote_tracking(current);
380
381    println!();
382    if git::is_ancestor(&plan.new_base, "HEAD") && plan.retarget_pr.is_none() {
383        output::success(&format!("Already up to date with {}", plan.new_base));
384        // A previous local rebase/amend may still be unpublished.
385        if upstream_exists && matches!(SyncState::detect(current), Ok(SyncState::Diverged { .. })) {
386            output::info("Local history was rewritten but not pushed — publishing...");
387            git::force_push_with_lease(current, verbose)?;
388            output::success("Force pushed");
389        }
390        if plan.unstack {
391            git::unset_branch_base(current, verbose)?;
392        }
393        output::ready("Synced", current);
394        return Ok(());
395    }
396
397    let behind = git::behind_base_count("HEAD", &plan.new_base);
398    output::info("Syncing...");
399
400    // Rebase first, then move the PR base, then push. Moving the base first
401    // would leave GitHub showing the new base while the branch still carried
402    // the old commits if the rebase then failed.
403    let result = match &plan.boundary {
404        Some(boundary) => {
405            output::info(&format!(
406                "  Rebasing commits after {} onto {} ({} new commit(s))...",
407                short(boundary),
408                plan.new_base,
409                behind
410            ));
411            git::rebase_onto(&plan.new_base, boundary, verbose)
412        }
413        None => {
414            output::info(&format!(
415                "  Rebasing onto {} ({} new commit(s))...",
416                plan.new_base, behind
417            ));
418            git::rebase(&plan.new_base, verbose)
419        }
420    };
421    if let Err(e) = result {
422        output::error("Rebase failed. You may need to resolve conflicts manually.");
423        output::action("git rebase --continue  # After resolving conflicts, then: gw sync");
424        output::action("git rebase --abort     # To cancel");
425        return Err(e);
426    }
427
428    if let Some(pr_number) = plan.retarget_pr {
429        output::info(&format!("  Updating PR base to {}...", default_branch));
430        github::update_pr_base(pr_number, default_branch)?;
431    }
432
433    if upstream_exists {
434        output::info("  Force pushing...");
435        git::force_push_with_lease(current, verbose)?;
436    }
437
438    if plan.unstack {
439        // The branch now targets the default branch, so it is no longer
440        // stacked -- drop the recorded base so `gw status` stops treating it
441        // as such.
442        git::unset_branch_base(current, verbose)?;
443    } else if plan.rerecord_base {
444        // Still stacked: the parent's current tip is the next `--onto`
445        // boundary.
446        if let Ok(sha) = git::rev_parse(&plan.new_base) {
447            git::set_branch_base_sha(current, &sha, verbose)?;
448        }
449    }
450
451    println!();
452    output::ready("Synced", current);
453    let mut hints: Vec<String> = Vec::new();
454    if let Some(pr_number) = plan.retarget_pr {
455        hints.push(format!(
456            "PR #{} base is now '{}'",
457            pr_number, default_branch
458        ));
459    }
460    if !upstream_exists {
461        hints.push(format!(
462            "git push -u origin {current}  # Publish when ready"
463        ));
464    }
465    hints.push("gw status  # Check status".to_string());
466    let hint_refs: Vec<&str> = hints.iter().map(String::as_str).collect();
467    output::hints(&hint_refs);
468
469    Ok(())
470}
471
472/// Abbreviate a full SHA for display; leave named refs alone.
473fn short(reference: &str) -> &str {
474    if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
475        &reference[..7]
476    } else {
477        reference
478    }
479}