Skip to main content

gwm/
sync.rs

1//! `gwm sync` (issue #24) — fetch + rebase / merge a worktree's branch
2//! onto its configured upstream.
3//!
4//! The read-side inspection (dirty check, upstream resolution,
5//! ahead/behind) goes through libgit2; the mutating steps (`fetch`,
6//! `rebase`, `merge`) shell out to the `git` binary. That split is
7//! deliberate: libgit2's fetch needs the caller to wire credential
8//! callbacks (SSH agents, tokens, helpers) to talk to a real remote,
9//! whereas the user's `git` already has all of that configured. The
10//! existing sidebar previews (`git_log_oneline`, `git_status_short`)
11//! shell out for the same reason, so this stays consistent.
12
13use crate::error::{GwmError, Result};
14use crate::worktree;
15use git2::{BranchType, Repository};
16use std::path::Path;
17
18/// How `gwm sync` reconciles the local branch when it is behind its
19/// upstream. Defaults to rebase (linear history, the repo convention);
20/// `--merge` opts into a merge commit instead.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SyncStrategy {
23  Rebase,
24  Merge,
25}
26
27impl SyncStrategy {
28  /// The `git` subcommand verb (`rebase` / `merge`).
29  fn verb(self) -> &'static str {
30    match self {
31      SyncStrategy::Rebase => "rebase",
32      SyncStrategy::Merge => "merge",
33    }
34  }
35}
36
37/// What `sync` actually did once preconditions passed.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum SyncAction {
40  /// The branch was already level with (or ahead of) upstream — no
41  /// integration was needed.
42  UpToDate,
43  /// `behind_before` upstream commits were integrated via the chosen
44  /// strategy.
45  Integrated,
46}
47
48/// Outcome of a successful `sync` run. Conflicts, dirty trees, and
49/// missing upstreams surface as `GwmError` instead — only the
50/// non-error paths produce a report.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SyncReport {
53  /// Local branch shorthand that was synced (e.g. `feat/#24-sync`).
54  pub branch: String,
55  /// Upstream tracking ref shorthand (e.g. `origin/main`).
56  pub upstream: String,
57  /// Strategy used (rebase / merge).
58  pub strategy: SyncStrategy,
59  /// Commits the local branch had that upstream did not, measured
60  /// before integration.
61  pub ahead_before: usize,
62  /// Commits upstream had that the local branch did not, measured
63  /// after the fetch but before integration.
64  pub behind_before: usize,
65  /// What happened.
66  pub action: SyncAction,
67}
68
69/// Fetch `start`'s upstream, then rebase (or merge) the local branch
70/// onto it. `start` may be any path inside the target worktree — the
71/// repository is discovered upwards from it.
72///
73/// Refuses up front when:
74/// - the working tree is dirty (uncommitted changes),
75/// - HEAD is detached / unborn (no branch shorthand),
76/// - the branch has no upstream configured.
77///
78/// On a conflicting rebase/merge the operation is aborted so the
79/// worktree is left usable, and a conflict error is returned telling
80/// the user to reconcile by hand.
81pub fn sync(start: &Path, strategy: SyncStrategy) -> Result<SyncReport> {
82  let repo = Repository::discover(start).map_err(|_| GwmError::NotInGitRepo)?;
83  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
84
85  // 1. Refuse to touch a dirty tree — a rebase/merge on top of
86  //    uncommitted work is how people lose changes.
87  if worktree::is_dirty(&repo)? {
88    return Err(GwmError::Other(
89      "worktree has uncommitted changes; commit or stash before syncing".into(),
90    ));
91  }
92
93  // 2. Resolve the current branch and its upstream.
94  let head = repo.head().map_err(|_| GwmError::UnbornHead {
95    reason: "sync: cannot read HEAD (unborn or unreadable)".into(),
96  })?;
97  if !head.is_branch() {
98    return Err(GwmError::UnbornHead {
99      reason: "sync: HEAD is detached — check out a branch first".into(),
100    });
101  }
102  let branch_short = head
103    .shorthand()
104    .ok()
105    .ok_or_else(|| GwmError::UnbornHead {
106      reason: "sync: HEAD has no branch name".into(),
107    })?
108    .to_string();
109  let head_refname = head.name().ok().map(|s| s.to_string());
110
111  let local = repo
112    .find_branch(&branch_short, BranchType::Local)
113    .map_err(|_| GwmError::Other(format!("sync: local branch '{branch_short}' not found")))?;
114  let upstream = local.upstream().map_err(|_| {
115    GwmError::Other(format!(
116      "branch '{branch_short}' has no upstream configured; set one with `git branch --set-upstream-to=<remote>/{branch_short}`"
117    ))
118  })?;
119  let upstream_short = upstream
120    .name()
121    .ok()
122    .flatten()
123    .ok_or_else(|| GwmError::Other("sync: upstream tracking ref has no name".into()))?
124    .to_string();
125
126  // The remote to fetch. `branch_upstream_remote` wants the full
127  // refname (`refs/heads/<branch>`). If the upstream is a local
128  // branch (no remote), fall back to a bare `git fetch`.
129  let remote = head_refname
130    .as_deref()
131    .and_then(|rn| repo.branch_upstream_remote(rn).ok())
132    .and_then(|buf| buf.as_str().ok().map(|s| s.to_string()));
133
134  // 3. Fetch. After this the in-memory `repo` ref cache is stale, so
135  //    everything past here re-resolves against a freshly opened repo.
136  match &remote {
137    Some(r) => worktree::run_git_logged(&workdir, &["fetch", r])?,
138    None => worktree::run_git_logged(&workdir, &["fetch"])?,
139  };
140
141  // 4. Recompute ahead/behind against the now-updated upstream.
142  let repo = Repository::discover(start).map_err(|_| GwmError::NotInGitRepo)?;
143  let (ahead_before, behind_before) = ahead_behind(&repo, &branch_short)?;
144
145  if behind_before == 0 {
146    return Ok(SyncReport {
147      branch: branch_short,
148      upstream: upstream_short,
149      strategy,
150      ahead_before,
151      behind_before,
152      action: SyncAction::UpToDate,
153    });
154  }
155
156  // 5. Integrate. On failure (conflicts), abort so the worktree is
157  //    not left mid-rebase/merge, then surface a conflict error.
158  let integrate = match strategy {
159    SyncStrategy::Rebase => worktree::run_git_logged(&workdir, &["rebase", &upstream_short]),
160    SyncStrategy::Merge => worktree::run_git_logged(&workdir, &["merge", "--no-edit", &upstream_short]),
161  };
162  if let Err(e) = integrate {
163    // Distinguish a genuine conflict from any other failure (a failing
164    // hook, a missing committer identity, a strategy/config error) by
165    // inspecting the index for conflict stages BEFORE aborting —
166    // language-independent, unlike grepping git's output. Either way we
167    // abort so the worktree is left usable.
168    let conflicted = Repository::discover(start)
169      .ok()
170      .and_then(|r| r.index().ok())
171      .map(|idx| idx.has_conflicts())
172      .unwrap_or(false);
173    let _ = worktree::run_git_logged(&workdir, &[strategy.verb(), "--abort"]);
174    if conflicted {
175      return Err(GwmError::Other(format!(
176        "{} onto {} hit conflicts and was aborted; reconcile manually with `git {} {}`",
177        strategy.verb(),
178        upstream_short,
179        strategy.verb(),
180        upstream_short
181      )));
182    }
183    // Not a conflict — surface the underlying git failure verbatim so
184    // the user isn't sent down the wrong recovery path.
185    return Err(GwmError::Other(format!(
186      "git {} onto {} failed and was aborted: {}",
187      strategy.verb(),
188      upstream_short,
189      e
190    )));
191  }
192
193  Ok(SyncReport {
194    branch: branch_short,
195    upstream: upstream_short,
196    strategy,
197    ahead_before,
198    behind_before,
199    action: SyncAction::Integrated,
200  })
201}
202
203/// Ahead / behind counts of `branch` versus its upstream, resolved
204/// fresh from disk. Returns `(ahead, behind)`.
205fn ahead_behind(repo: &Repository, branch: &str) -> Result<(usize, usize)> {
206  let local = repo
207    .find_branch(branch, BranchType::Local)
208    .map_err(|_| GwmError::Other(format!("sync: local branch '{branch}' not found")))?;
209  let upstream = local
210    .upstream()
211    .map_err(|_| GwmError::Other(format!("branch '{branch}' has no upstream configured")))?;
212  let local_oid = local
213    .get()
214    .target()
215    .ok_or_else(|| GwmError::Other(format!("sync: branch '{branch}' has no commit")))?;
216  let up_oid = upstream
217    .get()
218    .target()
219    .ok_or_else(|| GwmError::Other("sync: upstream has no commit".into()))?;
220  let (ahead, behind) = repo.graph_ahead_behind(local_oid, up_oid)?;
221  Ok((ahead, behind))
222}