Skip to main content

gwm/
worktree.rs

1use crate::error::{GwmError, Result};
2use crate::github::{self, BranchLink, IssueState, PrState};
3use git2::{BranchType, Repository, StatusOptions, WorktreeAddOptions, WorktreePruneOptions};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::sync::{LazyLock, Mutex, MutexGuard};
8use std::time::Duration;
9
10/// Trunk branches treated as "merge destinations" when measuring how
11/// long a branch has been alive. Order matters: the first match wins,
12/// so `main` (modern default) beats `master` (legacy) beats `dev` (gwm
13/// convention). Hardcoded here because `branch_age` is also reachable
14/// from contexts that don't carry a `Config` (CLI smoke paths).
15const TRUNK_CANDIDATES: &[&str] = &["main", "master", "dev"];
16/// Common trunk branch names tried (after any configured trunks) when
17/// resolving a PR / diff base, and treated as "this branch is itself a
18/// trunk" by [`is_trunk_branch`]. Superset of [`TRUNK_CANDIDATES`].
19const COMMON_TRUNKS: &[&str] = &["main", "master", "dev", "develop", "trunk"];
20const BRANCH_CREATED_AT_CONFIG_KEY: &str = "gwm-created-at";
21const RECENT_COMMITS_CACHE_MAX_ENTRIES: usize = 64;
22type RecentCommitCacheKey = (PathBuf, git2::Oid, usize);
23
24static RECENT_COMMITS_CACHE: LazyLock<Mutex<HashMap<RecentCommitCacheKey, Vec<CommitRow>>>> =
25  LazyLock::new(|| Mutex::new(HashMap::new()));
26
27#[derive(Debug, Clone)]
28pub struct WorktreeInfo {
29  /// Display name — the basename of the worktree directory on disk. This is
30  /// what the user sees, yanks, and filters on, so after a `git worktree move`
31  /// (the `c` rename, #290) it reflects the new slug rather than the stale
32  /// internal id (Codex review on PR #292).
33  pub name: String,
34  /// Internal git worktree id — the `.git/worktrees/<id>` entry from
35  /// `repo.worktrees()`. `git worktree move` does NOT rename it, so it can
36  /// diverge from [`Self::name`] after a rename. Use this (not `name`) for
37  /// `worktree::remove` / `find_worktree`, which resolve by id. Equal to
38  /// `name` for a freshly created worktree and for the main worktree.
39  pub id: String,
40  pub path: PathBuf,
41  pub branch: Option<String>,
42  pub head: Option<String>,
43  pub is_main: bool,
44  pub is_locked: bool,
45  pub is_prunable: bool,
46  pub status: BranchStatus,
47  /// Issue/PR link resolved at list time, so the table marker column
48  /// can show `●` on rows that carry GitHub context without each frame
49  /// re-shelling `git config`. Empty link = no marker dot. See
50  /// `tui/ui.rs::table_marker`.
51  pub link: BranchLink,
52  /// Loaded GitHub issue state for the row, if the TUI has fetched it this
53  /// session. `None` keeps the table on its no-fetch linked/unlinked colour.
54  pub issue_state: Option<IssueState>,
55  /// Loaded GitHub PR state for the row, if the TUI has fetched it this
56  /// session. `None` keeps the table on its no-fetch linked/unlinked colour.
57  pub pr_state: Option<PrState>,
58  /// Branch age relative to the trunk baseline, pre-computed at list
59  /// time so the TUI render path never opens a fresh `git2::Repository`
60  /// per row per frame (issue #103). `None` for trunk branches and for
61  /// worktrees whose repo can't be opened — the UI renders `-`.
62  pub age: Option<Duration>,
63}
64
65#[cfg(test)]
66mod tests {
67  use super::parse_git_log_with_author_output;
68
69  #[test]
70  fn parse_git_log_error_includes_invalid_commit_oid_text() {
71    let err = parse_git_log_with_author_output("not-an-oid\u{0}Ada\u{0}\u{0}subject\n").unwrap_err();
72    let rendered = err.to_string();
73
74    assert!(
75      rendered.contains("not-an-oid"),
76      "invalid commit oid should be included in the error, got: {}",
77      rendered
78    );
79  }
80
81  #[test]
82  fn parse_git_log_error_includes_invalid_parent_oid_text() {
83    let raw = "0123456789abcdef0123456789abcdef01234567\u{0}Ada\u{0}bad-parent\u{0}subject\n";
84    let err = parse_git_log_with_author_output(raw).unwrap_err();
85    let rendered = err.to_string();
86
87    assert!(
88      rendered.contains("bad-parent"),
89      "invalid parent oid should be included in the error, got: {}",
90      rendered
91    );
92  }
93}
94
95/// Cheap snapshot of "where are we vs. clean / upstream".
96#[derive(Debug, Clone, Default)]
97pub struct BranchStatus {
98  /// At least one tracked / untracked change in the work tree or index.
99  pub is_dirty: bool,
100  /// Upstream is configured for the current branch.
101  pub has_upstream: bool,
102  /// Commits on local not on upstream.
103  pub ahead: usize,
104  /// Commits on upstream not on local.
105  pub behind: usize,
106  /// Status couldn't be computed (e.g. detached HEAD, unborn branch).
107  pub unknown: bool,
108}
109
110impl BranchStatus {
111  pub fn synced(&self) -> bool {
112    self.has_upstream && self.ahead == 0 && self.behind == 0
113  }
114}
115
116/// True when the worktree at `repo` carries staged, unstaged, or
117/// untracked changes (ignored files excluded). Shares its
118/// `StatusOptions` shape with [`compute_status`] so the status column
119/// and `gwm sync`'s dirty-tree refusal (issue #24) agree on what
120/// "dirty" means.
121pub fn is_dirty(repo: &Repository) -> Result<bool> {
122  let mut opts = StatusOptions::new();
123  opts
124    .include_untracked(true)
125    .include_ignored(false)
126    .recurse_untracked_dirs(true);
127  let statuses = repo.statuses(Some(&mut opts))?;
128  Ok(!statuses.is_empty())
129}
130
131/// Compute the working-tree + upstream status of a single repo / linked worktree.
132fn compute_status(repo: &Repository) -> BranchStatus {
133  let mut out = BranchStatus::default();
134
135  // Dirty check — reuse the shared `is_dirty` scanner so the column
136  // and `gwm sync` can never disagree on dirtiness.
137  match is_dirty(repo) {
138    Ok(dirty) => out.is_dirty = dirty,
139    Err(_) => out.unknown = true,
140  }
141
142  // Ahead / behind vs upstream
143  if let Ok(head_ref) = repo.head() {
144    if let Ok(shorthand) = head_ref.shorthand() {
145      if let Ok(local_branch) = repo.find_branch(shorthand, BranchType::Local) {
146        if let Ok(upstream) = local_branch.upstream() {
147          if let (Some(local_oid), Some(up_oid)) = (head_ref.target(), upstream.into_reference().target()) {
148            out.has_upstream = true;
149            if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, up_oid) {
150              out.ahead = ahead;
151              out.behind = behind;
152            }
153          }
154        }
155      }
156    }
157  }
158
159  out
160}
161
162/// Find the main repository starting from CWD, walking upwards.
163pub fn discover_repo(start: Option<&Path>) -> Result<Repository> {
164  let from = match start {
165    Some(p) => p.to_path_buf(),
166    None => std::env::current_dir()?,
167  };
168  let repo = Repository::discover(&from).map_err(|_| GwmError::NotInGitRepo)?;
169  // If we're inside a linked worktree, walk back to the main repo working dir.
170  // `repo.path()` for a linked worktree returns `<main>/.git/worktrees/<name>/`.
171  // Two parents up = `<main>/.git`, three up = `<main>` (the main workdir).
172  if repo.is_worktree() {
173    let wt_admin = repo.path().to_path_buf();
174    if let Some(git_dir) = wt_admin.parent().and_then(|p| p.parent()) {
175      if let Some(main_workdir) = git_dir.parent() {
176        if let Ok(main) = Repository::open(main_workdir) {
177          return Ok(main);
178        }
179      }
180    }
181  }
182  Ok(repo)
183}
184
185/// Name of the repo derived from the working dir path.
186pub fn repo_name(repo: &Repository) -> String {
187  repo
188    .workdir()
189    .and_then(|p| p.file_name())
190    .map(|n| n.to_string_lossy().to_string())
191    .unwrap_or_else(|| "repo".into())
192}
193
194pub fn list(repo: &Repository) -> Result<Vec<WorktreeInfo>> {
195  let mut out = Vec::new();
196
197  // Issue #417: every row's issue/PR pastille comes from re-reading the
198  // branch name, so the parser has to be this repo's own — compiled from its
199  // `worktree.branch_pattern`, not from the built-in shape. Compiled once
200  // here rather than inside `read_link`, which would re-read `.gwm.toml` and
201  // recompile the regex for every worktree in the listing.
202  let parser = crate::naming::BranchParser::for_repo(repo);
203
204  // The main worktree is not listed by git2::Repository::worktrees(); add it manually.
205  if let Some(workdir) = repo.workdir() {
206    let head_ref = repo.head().ok();
207    let branch = head_ref
208      .as_ref()
209      .and_then(|r| r.shorthand().ok().map(|s| s.to_string()));
210    let head = head_ref.as_ref().and_then(|r| r.target().map(|o| o.to_string()));
211    let link = branch
212      .as_deref()
213      .and_then(|b| github::read_link_with(repo, b, &parser).ok())
214      .unwrap_or_else(BranchLink::empty);
215    let age = branch.as_deref().and_then(|b| branch_age(repo, b));
216    let main_name = workdir
217      .file_name()
218      .map(|n| n.to_string_lossy().to_string())
219      .unwrap_or_else(|| "main".into());
220    out.push(WorktreeInfo {
221      // The main worktree has no `.git/worktrees/<id>` entry; id == display.
222      id: main_name.clone(),
223      name: main_name,
224      path: workdir.to_path_buf(),
225      branch,
226      head,
227      is_main: true,
228      is_locked: false,
229      is_prunable: false,
230      status: compute_status(repo),
231      issue_state: link.issue_state,
232      pr_state: link.pr_state,
233      link,
234      age,
235    });
236  }
237
238  let names = repo.worktrees()?;
239  // `StringArray::iter` yields `Result<Option<&str>, _>`; skip both the
240  // `Err` (non-UTF-8 entry) and `None` arms so `name` is a plain `&str`.
241  for name in names.iter().filter_map(|r| r.ok().flatten()) {
242    let wt = match repo.find_worktree(name) {
243      Ok(w) => w,
244      Err(_) => continue,
245    };
246    let path = wt.path().to_path_buf();
247    let is_locked = matches!(wt.is_locked(), Ok(git2::WorktreeLockStatus::Locked(_)));
248    let is_prunable = matches!(wt.is_prunable(None), Ok(p) if p);
249
250    // Open the worktree as a repo to read its HEAD + status + branch age.
251    // Issue #103: piggyback the age computation onto this existing open so
252    // the TUI render path no longer needs to call `Repository::open` per
253    // row per frame. Cost is the same revwalk we'd otherwise do per frame.
254    let (branch, head, status, age) = match Repository::open(&path) {
255      Ok(sub) => {
256        let head_ref = sub.head().ok();
257        let b = head_ref
258          .as_ref()
259          .and_then(|r| r.shorthand().ok().map(|s| s.to_string()));
260        let h = head_ref.as_ref().and_then(|r| r.target().map(|o| o.to_string()));
261        let s = compute_status(&sub);
262        // The trunk-baseline lookup must run against the main repo's
263        // branch table; the linked worktree's `sub` has the same refs DB
264        // either way (git2 shares the gitdir), so either handle works.
265        let a = b.as_deref().and_then(|name| branch_age(&sub, name));
266        (b, h, s, a)
267      }
268      Err(_) => (
269        None,
270        None,
271        BranchStatus {
272          unknown: true,
273          ..Default::default()
274        },
275        None,
276      ),
277    };
278
279    let link = branch
280      .as_deref()
281      .and_then(|b| github::read_link_with(repo, b, &parser).ok())
282      .unwrap_or_else(BranchLink::empty);
283    // Display name = basename of the on-disk path (tracks `git worktree move`);
284    // id = the `repo.worktrees()` entry (stable, used for remove/find).
285    let display_name = path
286      .file_name()
287      .map(|n| n.to_string_lossy().to_string())
288      .unwrap_or_else(|| name.to_string());
289    out.push(WorktreeInfo {
290      name: display_name,
291      id: name.to_string(),
292      path,
293      branch,
294      head,
295      is_main: false,
296      is_locked,
297      is_prunable,
298      status,
299      issue_state: link.issue_state,
300      pr_state: link.pr_state,
301      link,
302      age,
303    });
304  }
305
306  Ok(out)
307}
308
309/// Create a new worktree off of HEAD, attaching it either to a freshly
310/// created branch (the default) or — when `reuse_branch` is true — to a
311/// pre-existing local branch of the same name.
312///
313/// Records the HEAD ref's short name into `branch.<branch_name>.gwm-base`
314/// so the review launcher (issue #75) can recover the original parent
315/// ref later — even on branches without an upstream. The write is
316/// best-effort: a config-write error does not roll the worktree back.
317///
318/// `reuse_branch` gates the "branch already exists" path (issue #99). The
319/// historical default silently reused a stale branch at whatever commit
320/// it referenced, resurrecting `git log` state the user never asked for.
321/// The new default refuses with `GwmError::BranchExists`; pass `true`
322/// (`--reuse-branch` on the CLI) to opt back into the legacy behaviour
323/// when attaching to an existing branch is the intent.
324pub fn add(
325  repo: &Repository,
326  name: &str,
327  target_path: &Path,
328  branch_name: &str,
329  reuse_branch: bool,
330) -> Result<PathBuf> {
331  // Refuse to clobber an existing directory.
332  if target_path.exists() {
333    return Err(GwmError::WorktreeExists(name.into(), target_path.display().to_string()));
334  }
335
336  // Ensure parent dir exists.
337  if let Some(parent) = target_path.parent() {
338    std::fs::create_dir_all(parent)?;
339  }
340
341  // Capture HEAD's short name BEFORE creating the new branch so the
342  // record points at the actual parent (`main` / `dev` / a release
343  // train), not the freshly-created `branch_name` itself.
344  let head_ref = repo.head()?;
345  let head_short = head_ref.shorthand().ok().map(|s| s.to_string());
346  let head_commit = head_ref.peel_to_commit()?;
347  let (branch, created_branch) = match repo.find_branch(branch_name, git2::BranchType::Local) {
348    Ok(b) => {
349      if !reuse_branch {
350        // Resolve the existing tip for the error message so the user
351        // sees *where* the stale ref is pointing and can decide between
352        // `--reuse-branch`, `git branch -D <name>`, or a different slug.
353        let oid = b
354          .get()
355          .target()
356          .map(|o| o.to_string())
357          .unwrap_or_else(|| "<unresolved>".into());
358        return Err(GwmError::BranchExists {
359          name: branch_name.into(),
360          oid,
361        });
362      }
363      (b, false)
364    }
365    Err(_) => (repo.branch(branch_name, &head_commit, false)?, true),
366  };
367  if created_branch {
368    let _ = write_branch_created_at(repo, branch_name, chrono::Utc::now().timestamp());
369  }
370  let reference = branch.into_reference();
371
372  let mut opts = WorktreeAddOptions::new();
373  opts.reference(Some(&reference));
374
375  if let Err(e) = repo.worktree(name, target_path, Some(&opts)) {
376    // The branch has to exist before this call, because
377    // `WorktreeAddOptions::reference` takes a live reference, so every
378    // failure here lands with a branch already on disk that the user never
379    // asked for (#487). Roll back only what *this* call created: a reused
380    // branch predates the command, and deleting it would destroy work.
381    //
382    // Best effort, and deliberately so. Once some checkout has the branch
383    // as its HEAD, the branch stays: deleting it leaves a worktree
384    // pointing at nothing, and that residue is reported by nothing at all,
385    // unlike an orphan branch. That covers both the worktree this call was
386    // binding, since libgit2 writes its HEAD just before the checkout, and
387    // any other one already standing on the name. The caller gets the
388    // underlying error in every case, since the rollback is not the story.
389    //
390    // The tip is re-checked because "what this call created" is a claim
391    // about an OID, not about a name: another process is free to move the
392    // ref while `repo.worktree` runs, and a branch that no longer points
393    // where this call put it is somebody else's now. Leaving an orphan is
394    // the smaller harm of the two.
395    //
396    // The ref goes rather than the branch, because `git_branch_delete`
397    // drops the whole `branch.<name>` config section and that section
398    // outlives its ref easily: `git update-ref -d` leaves it, and an
399    // upstream can be configured before the branch exists. Dropping the
400    // stamp `add` wrote is enough, and it is all this call put there.
401    if created_branch && !branch_is_checked_out_anywhere(repo, branch_name) {
402      if let Ok(b) = repo.find_branch(branch_name, git2::BranchType::Local) {
403        if b.get().target() == Some(head_commit.id()) {
404          let mut r = b.into_reference();
405          if r.delete().is_ok() {
406            let _ = remove_branch_created_at(repo, branch_name);
407          }
408        }
409      }
410    }
411    return Err(e.into());
412  }
413
414  // Record the parent ref for the launcher's base resolution chain.
415  if let Some(parent_ref) = head_short {
416    let _ = crate::launcher::write_gwm_base(repo, branch_name, &parent_ref);
417  }
418
419  Ok(target_path.to_path_buf())
420}
421
422fn branch_config_key(branch: &str, leaf: &str) -> String {
423  format!("branch.{}.{}", branch, leaf)
424}
425
426fn write_branch_created_at(repo: &Repository, branch: &str, unix_secs: i64) -> Result<()> {
427  let mut cfg = repo.config()?;
428  cfg.set_str(
429    &branch_config_key(branch, BRANCH_CREATED_AT_CONFIG_KEY),
430    &unix_secs.to_string(),
431  )?;
432  Ok(())
433}
434
435/// True when any checkout, main or linked, already has `branch` as its HEAD.
436/// That is `git_branch_is_checked_out`: what `git_worktree_add` refuses on and
437/// what `git_branch_delete` guards against. git2 binds neither, and the
438/// rollback deletes the reference rather than the branch, so it is spelled out
439/// here.
440///
441/// The admin directory is read from disk rather than through
442/// `repo.worktrees()`, because an entry a failed run left half-written has no
443/// `gitdir` file and libgit2 will not list it, while its `HEAD` still names a
444/// branch. `commondir` rather than `path`, since the latter is the
445/// per-worktree gitdir when gwm runs from inside a linked worktree.
446///
447/// A read that fails answers nothing, and folding that into "no" would make
448/// the rollback delete a ref on the strength of a failed read, which is the
449/// one outcome this check exists to prevent. So: absent is "no", unreadable is
450/// "assume yes".
451fn branch_is_checked_out_anywhere(repo: &Repository, branch: &str) -> bool {
452  let want = format!("ref: refs/heads/{}", branch);
453  let names_branch = |p: PathBuf| match std::fs::read_to_string(p) {
454    Ok(s) => s.trim() == want,
455    Err(e) => e.kind() != std::io::ErrorKind::NotFound,
456  };
457  let common = repo.commondir().to_path_buf();
458  if names_branch(common.join("HEAD")) {
459    return true;
460  }
461  match std::fs::read_dir(common.join("worktrees")) {
462    Ok(mut entries) => entries.any(|e| match e {
463      Ok(entry) => names_branch(entry.path().join("HEAD")),
464      Err(_) => true,
465    }),
466    // No linked worktrees at all is the ordinary case, and it is a "no".
467    Err(e) => e.kind() != std::io::ErrorKind::NotFound,
468  }
469}
470
471fn remove_branch_created_at(repo: &Repository, branch: &str) -> Result<()> {
472  let mut cfg = repo.config()?;
473  cfg.remove(&branch_config_key(branch, BRANCH_CREATED_AT_CONFIG_KEY))?;
474  Ok(())
475}
476
477fn branch_created_age(repo: &Repository, branch: &str) -> Option<Duration> {
478  let cfg = repo.config().ok()?;
479  let key = branch_config_key(branch, BRANCH_CREATED_AT_CONFIG_KEY);
480  let raw = cfg.get_string(&key).ok()?;
481  let created = raw.trim().parse::<i64>().ok()?;
482  let now = chrono::Utc::now().timestamp();
483  Some(Duration::from_secs((now - created).max(0) as u64))
484}
485
486/// Remove a worktree directory and prune its admin files. Optionally delete the branch.
487pub fn remove(repo: &Repository, name: &str, delete_branch: bool) -> Result<()> {
488  let wt = repo
489    .find_worktree(name)
490    .map_err(|_| GwmError::WorktreeNotFound(name.into()))?;
491  let path = wt.path().to_path_buf();
492
493  // Capture the branch (if any) so we can drop it after pruning.
494  let branch_name = match Repository::open(&path) {
495    Ok(sub) => sub.head().ok().and_then(|r| r.shorthand().ok().map(|s| s.to_string())),
496    Err(_) => None,
497  };
498
499  // Prune admin files (.git/worktrees/<name>) FIRST so a subsequent
500  // filesystem failure cannot leave a "phantom worktree" (issue #98):
501  // directory gone but `repo.worktrees()` still listing the name. The
502  // reverse ordering forced users into a manual `gwm prune` recovery
503  // after any partial failure.
504  let mut opts = WorktreePruneOptions::new();
505  opts.valid(true).locked(true).working_tree(true);
506  wt.prune(Some(&mut opts))?;
507
508  // Physical removal — git2's prune does NOT delete the work tree directory itself.
509  if path.exists() {
510    std::fs::remove_dir_all(&path)?;
511  }
512
513  if delete_branch {
514    if let Some(b) = branch_name {
515      if let Ok(mut branch) = repo.find_branch(&b, git2::BranchType::Local) {
516        let _ = branch.delete();
517      }
518    }
519  }
520
521  Ok(())
522}
523
524/// Run a `git` subcommand in `dir`, returning trimmed stdout on success or a
525/// [`GwmError::CommandFailed`] carrying stderr on a non-zero exit. Shared by
526/// the worktree-rename steps (#290) so each step reports a precise error.
527fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
528  let mut cmd = Command::new("git");
529  cmd.args(args).current_dir(dir);
530  // Route through the command-log chokepoint so the rename's mutating steps
531  // (`worktree move`, `branch -m`, the lease `fetch`, `push --atomic`) surface
532  // in the Command Logs modal (#290). `git_in` is rename-only, so this never
533  // spams the log with read-only sidebar previews.
534  let out = crate::command_log::run_logged(&mut cmd, format!("git {}", args.join(" ")))?;
535  if out.status.success() {
536    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
537  } else {
538    Err(GwmError::CommandFailed(
539      String::from_utf8_lossy(&out.stderr).trim().to_string(),
540    ))
541  }
542}
543
544/// Rename a worktree's branch (local + remote) and move its directory on
545/// disk (`c` in the TUI, #290).
546///
547/// The directory move is the step most likely to fail (the row is the main
548/// or a locked worktree, or the target path already exists), so it runs
549/// **first** — a failure there leaves every ref untouched (Codex review on
550/// PR #292). Only once the directory is in place are the refs renamed, and a
551/// branch-rename failure rolls the move back so the worktree is never left
552/// in a half-renamed state. Order of operations:
553///
554/// 1. Preflight: refuse if `<new_path>` already exists (the move would fail).
555/// 2. `git worktree move <old_path> <new_path>` (run from `workdir`, the main
556///    repo, so the CWD is never inside the moved dir). Skipped when the path
557///    is unchanged.
558/// 3. When the branch name changes, `git branch -m <old> <new>` from the
559///    moved directory. On failure, roll the move back and return the error. A
560///    path-only edit (same branch) skips this and every remote step.
561/// 4. If `<old_branch>` exists on `origin`, `git push --atomic origin :<old>
562///    <new>:<new>` renames the remote branch (the `--atomic` flag makes the
563///    delete-old + create-new pair all-or-nothing, so a rejected push can't
564///    leave the remote with neither branch), then `git branch
565///    --set-upstream-to` re-points tracking (non-fatal). A rejected push rolls
566///    back both the local rename and the move so the repo is never left
567///    half-renamed.
568///
569/// Returns `true` when the remote branch was also renamed (it existed on
570/// `origin`), `false` when only the local branch + directory changed (or a
571/// path-only move with no branch change).
572pub fn rename_worktree(
573  workdir: &Path,
574  old_path: &Path,
575  old_branch: &str,
576  new_path: &Path,
577  new_branch: &str,
578) -> Result<bool> {
579  let moves = new_path != old_path;
580
581  // 1. Preflight — a pre-existing target would make `git worktree move`
582  //    fail anyway, so reject it up front before touching any ref.
583  if moves && new_path.exists() {
584    return Err(GwmError::CommandFailed(format!(
585      "target path already exists: {}",
586      new_path.display()
587    )));
588  }
589
590  // 2. Move the worktree directory first: it is the most failure-prone step
591  //    (main/locked worktree, busy dir), and failing here leaves all refs
592  //    untouched.
593  if moves {
594    git_in(
595      workdir,
596      &[
597        "worktree",
598        "move",
599        &old_path.to_string_lossy(),
600        &new_path.to_string_lossy(),
601      ],
602    )
603    .map_err(|e| GwmError::CommandFailed(format!("worktree move failed: {e}")))?;
604  }
605  // From here on the branch lives in `branch_dir`.
606  let branch_dir = if moves { new_path } else { old_path };
607
608  // Roll the directory move back to its original location. Used when a later
609  // step fails so the worktree is never left moved-but-not-renamed.
610  let rollback_move = || {
611    if moves {
612      let _ = git_in(
613        workdir,
614        &[
615          "worktree",
616          "move",
617          &new_path.to_string_lossy(),
618          &old_path.to_string_lossy(),
619        ],
620      );
621    }
622  };
623
624  // A path-only edit (same branch, different dir — e.g. a changed
625  // `[worktree].base`) must skip every ref mutation: `git branch -m old old`
626  // is an error, which would roll a valid move back (Codex review on PR #292).
627  let renames_branch = new_branch != old_branch;
628  if !renames_branch {
629    return Ok(false);
630  }
631
632  // 3. Local branch rename. Roll the directory move back on failure so the
633  //    worktree is not left moved-but-not-renamed.
634  if let Err(e) = git_in(branch_dir, &["branch", "-m", old_branch, new_branch]) {
635    rollback_move();
636    return Err(GwmError::CommandFailed(format!("local rename failed: {e}")));
637  }
638
639  // 4. Remote branch rename, only when the old branch is on origin.
640  //    First decide whether an `origin` remote is even configured: with no
641  //    remote a local-only rename is perfectly valid (don't abort). Only when
642  //    `origin` exists do we look the branch up — and there, with
643  //    `--exit-code`, `git ls-remote` exits 0 when the branch is found and 2
644  //    when it is genuinely absent. Any other status (auth, network, server)
645  //    is a lookup *failure*, not "absent": treating it as absent would skip
646  //    the remote rename and report local-only success while `origin/<old>`
647  //    lives on, so abort + roll back instead (Codex review on PR #292).
648  let has_origin = Command::new("git")
649    .args(["remote", "get-url", "origin"])
650    .current_dir(branch_dir)
651    .output()
652    .map(|o| o.status.success())
653    .unwrap_or(false);
654  let remote_exists = if has_origin {
655    let ls = Command::new("git")
656      .args(["ls-remote", "--exit-code", "--heads", "origin", old_branch])
657      .current_dir(branch_dir)
658      .output();
659    match ls {
660      Ok(o) if o.status.success() => true,
661      Ok(o) if o.status.code() == Some(2) => false,
662      other => {
663        let detail = match other {
664          Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
665          Err(e) => e.to_string(),
666        };
667        let _ = git_in(branch_dir, &["branch", "-m", new_branch, old_branch]);
668        rollback_move();
669        return Err(GwmError::CommandFailed(format!("remote lookup failed: {detail}")));
670      }
671    }
672  } else {
673    false
674  };
675  let mut remote_renamed = false;
676  if remote_exists {
677    // Lease check (Codex review on PR #292): the rename deletes `origin/<old>`
678    // and recreates it from the LOCAL tip. If `origin/<old>` has commits this
679    // worktree never fetched, that would silently drop them. Fetch the current
680    // remote tip and refuse unless it is already contained in the local branch.
681    let _ = git_in(branch_dir, &["fetch", "origin", old_branch]);
682    let remote_tip = Command::new("git")
683      .args(["rev-parse", "FETCH_HEAD"])
684      .current_dir(branch_dir)
685      .output();
686    // Keep the fetched old tip so the push can lease against it (Codex review
687    // on PR #292, P1): the ancestor check below only proves the tip we *saw*
688    // is contained locally — it cannot stop `origin/<old>` from advancing in
689    // the window between this fetch and the push. The `--force-with-lease`
690    // makes the delete refspec conditional on this exact tip, so a concurrent
691    // push lands the rename in the rejected/rollback path instead of dropping
692    // the other writer's commits.
693    let fetched_old_tip = match &remote_tip {
694      Ok(o) if o.status.success() => Some(String::from_utf8_lossy(&o.stdout).trim().to_string()),
695      _ => None,
696    };
697    let up_to_date = match &fetched_old_tip {
698      Some(tip) => {
699        // The remote tip must be an ancestor of (already contained in) the
700        // local branch — otherwise origin carries commits we don't have.
701        Command::new("git")
702          .args(["merge-base", "--is-ancestor", tip, new_branch])
703          .current_dir(branch_dir)
704          .output()
705          .map(|o| o.status.success())
706          .unwrap_or(false)
707      }
708      None => false,
709    };
710    if !up_to_date {
711      let _ = git_in(branch_dir, &["branch", "-m", new_branch, old_branch]);
712      rollback_move();
713      return Err(GwmError::CommandFailed(format!(
714        "origin/{old_branch} has commits not in your local branch; fetch/merge before renaming"
715      )));
716    }
717    // Prove `origin/<new_branch>` is absent before pushing (Codex review on
718    // PR #292, P1). If it already exists and is an ancestor of our local tip,
719    // the `<new>:<new>` refspec is a fast-forward, not a create — the atomic
720    // push would move that pre-existing remote branch AND delete origin/<old>,
721    // silently overwriting another worktree's branch. Refuse up front and roll
722    // the local rename + move back. (`ls-remote --exit-code` exits 0 when the
723    // ref is found.)
724    let target_exists = Command::new("git")
725      .args([
726        "ls-remote",
727        "--exit-code",
728        "origin",
729        &format!("refs/heads/{new_branch}"),
730      ])
731      .current_dir(branch_dir)
732      .output()
733      .map(|o| o.status.success())
734      .unwrap_or(false);
735    if target_exists {
736      let _ = git_in(branch_dir, &["branch", "-m", new_branch, old_branch]);
737      rollback_move();
738      return Err(GwmError::CommandFailed(format!(
739        "origin/{new_branch} already exists; choose another name or delete it on the remote first"
740      )));
741    }
742    // `--atomic` makes the two-refspec push all-or-nothing: without it git can
743    // delete `origin/<old>` and then fail on `<new>`, leaving the remote with
744    // neither branch — and the local rollback below can't restore a deleted
745    // remote ref (Codex review on PR #292). With `--atomic`, a rejected push
746    // leaves `origin/<old>` intact, so the local rollback fully restores state.
747    //
748    // `--force-with-lease=<old>:<fetched tip>` guards the delete refspec: the
749    // server only honours `:{old_branch}` while `origin/<old>` still points at
750    // the tip we fetched and proved contained locally. A commit pushed by
751    // someone else in the fetch→push window flips the lease, the atomic push
752    // is rejected as a whole, and we roll back instead of dropping their work
753    // (Codex review on PR #292, P1).
754    let lease = fetched_old_tip
755      .as_deref()
756      .map(|tip| format!("--force-with-lease={old_branch}:{tip}"));
757    // Absence lease on the new ref (Codex review on PR #292, P1, iter 4): a
758    // zero-OID expected value makes `<new>:<new>` a *create-only* push. The
759    // preflight `ls-remote` above has a window — another client can create
760    // `origin/<new>` before our push — and without this lease an atomic push
761    // would fast-forward that ref while deleting `origin/<old>`. The zero-OID
762    // lease makes the server reject the whole push if `origin/<new>` exists.
763    let new_absence_lease = format!("--force-with-lease={new_branch}:{}", "0".repeat(40));
764    let mut push_args: Vec<&str> = vec!["push", "--atomic"];
765    if let Some(lease) = lease.as_deref() {
766      push_args.push(lease);
767    }
768    push_args.push(&new_absence_lease);
769    let old_refspec = format!(":{old_branch}");
770    let new_refspec = format!("{new_branch}:{new_branch}");
771    push_args.extend(["origin", &old_refspec, &new_refspec]);
772    if let Err(e) = git_in(branch_dir, &push_args) {
773      // The remote push was rejected (protected branch, auth/network, or an
774      // existing remote target). Undo the local branch rename and the move so
775      // the repo is not left half-renamed (Codex review on PR #292).
776      let _ = git_in(branch_dir, &["branch", "-m", new_branch, old_branch]);
777      rollback_move();
778      return Err(GwmError::CommandFailed(format!("remote rename failed: {e}")));
779    }
780    // Re-track the new upstream. Non-fatal: the rename is already done.
781    let _ = git_in(
782      branch_dir,
783      &[
784        "branch",
785        "--set-upstream-to",
786        &format!("origin/{new_branch}"),
787        new_branch,
788      ],
789    );
790    remote_renamed = true;
791  }
792
793  Ok(remote_renamed)
794}
795
796/// One prunable worktree entry as surfaced by `gwm prune --dry-run`
797/// (issue #31). The `reason` field is a human-readable rationale that
798/// is currently hard-coded to "working dir missing" — that is the only
799/// case `is_prunable(None)` flags today (working tree removed out from
800/// under the admin entry). Kept as a `String` rather than a literal
801/// in the CLI so future libgit2 versions can surface richer reasons
802/// (locked worktrees, broken HEAD, …) without breaking the CLI
803/// rendering contract.
804#[derive(Debug, Clone, PartialEq, Eq)]
805pub struct PrunableEntry {
806  pub name: String,
807  pub path: PathBuf,
808  pub reason: String,
809}
810
811/// Compute (without mutating) the list of worktree admin entries that
812/// `gwm prune` would drop. Used by `gwm prune --dry-run` (issue #31)
813/// and consumed by [`prune`] so the dry-run preview and the destructive
814/// pass can never drift on what "prunable" means. Output is sorted by
815/// name for deterministic stdout — scripted callers diff across runs.
816pub fn prunable_worktrees(repo: &Repository) -> Result<Vec<PrunableEntry>> {
817  let names = repo.worktrees()?;
818  let mut out = Vec::new();
819  // `StringArray::iter` yields `Result<Option<&str>, _>`; skip both the
820  // `Err` (non-UTF-8 entry) and `None` arms so `name` is a plain `&str`.
821  for name in names.iter().filter_map(|r| r.ok().flatten()) {
822    let wt = match repo.find_worktree(name) {
823      Ok(w) => w,
824      Err(_) => continue,
825    };
826    if !matches!(wt.is_prunable(None), Ok(p) if p) {
827      continue;
828    }
829    out.push(PrunableEntry {
830      name: name.to_string(),
831      path: wt.path().to_path_buf(),
832      reason: "working dir missing".to_string(),
833    });
834  }
835  out.sort_by(|a, b| a.name.cmp(&b.name));
836  Ok(out)
837}
838
839/// Prune stale worktree admin entries (gwq cleanup equivalent).
840/// Consumes [`prunable_worktrees`] so what `--dry-run` shows is exactly
841/// what this destructive pass acts on — the two surfaces share the
842/// scanner, by construction.
843pub fn prune(repo: &Repository) -> Result<usize> {
844  let plan = prunable_worktrees(repo)?;
845  let mut pruned = 0usize;
846  for entry in plan {
847    let wt = match repo.find_worktree(&entry.name) {
848      Ok(w) => w,
849      Err(_) => continue,
850    };
851    let mut opts = WorktreePruneOptions::new();
852    opts.valid(true).locked(true).working_tree(true);
853    if wt.prune(Some(&mut opts)).is_ok() {
854      pruned += 1;
855    }
856  }
857  Ok(pruned)
858}
859
860/// Read-only check that `name` resolves to a removable worktree —
861/// the libgit2 half of `gwm remove --dry-run` (issue #31). Errors on
862/// the same "worktree not found" path as `remove` so the dry-run
863/// surface and the destructive surface share an error contract;
864/// returns `Ok(())` when the worktree exists. The caller (the CLI)
865/// is responsible for rendering the plan; this function intentionally
866/// touches no filesystem state and emits no output.
867pub fn remove_dry_run(repo: &Repository, name: &str) -> Result<()> {
868  repo
869    .find_worktree(name)
870    .map_err(|_| GwmError::WorktreeNotFound(name.into()))?;
871  Ok(())
872}
873
874/// A commit row pulled from `git log` for the Recent Commits sidebar block.
875/// Mirrors lazygit's columnar layout (hash + author + subject) so the
876/// renderer can lay out one commit per visual line. Hashes are parsed
877/// into binary OIDs once, then formatted on display to a fixed length (the
878/// `COMMIT_HASH_DISPLAY_LEN` constant in `src/tui/ui.rs`, currently 8
879/// chars, matching lazygit's `Gui.CommitHashLength` default). Not
880/// user-configurable today — change the constant to retune.
881/// `parents.len() >= 2` flags a merge commit, which the renderer marks
882/// with `◎` instead of `○`.
883#[derive(Debug, Clone, PartialEq, Eq)]
884pub struct CommitRow {
885  pub hash: git2::Oid,
886  pub author: String,
887  pub parents: Vec<git2::Oid>,
888  pub subject: String,
889}
890
891/// Return recent commits for the sidebar using libgit2. This is the uncached
892/// compatibility entry point; the TUI should call [`recent_commits_cached`]
893/// so repeated sidebar rebuilds for the same branch tip are a hash lookup.
894pub fn git_log_with_author(path: &Path, n: usize) -> Result<Vec<CommitRow>> {
895  let repo = Repository::open(path)?;
896  let tip = repo.head()?.target().ok_or_else(|| GwmError::UnbornHead {
897    reason: "HEAD does not point at a commit".into(),
898  })?;
899  recent_commits_revwalk(&repo, tip, n)
900}
901
902/// Return recent commits for one worktree, memoised by branch-tip OID and
903/// limit. `WorktreeInfo.head` is populated by [`list`], so normal TUI sidebar
904/// refreshes can hit the cache without reopening the repo. Fixtures and older
905/// callers with `head = None` fall back to opening the worktree once.
906pub fn recent_commits_cached(w: &WorktreeInfo, limit: usize) -> Result<Vec<CommitRow>> {
907  let tip = worktree_head_oid(w)?;
908  let key = (recent_commits_cache_repo_key(&w.path), tip, limit);
909  if let Some(rows) = recent_commits_cache().get(&key).cloned() {
910    return Ok(rows);
911  }
912
913  let repo = Repository::open(&w.path)?;
914  let rows = recent_commits_revwalk(&repo, tip, limit)?;
915  let mut cache = recent_commits_cache();
916  if cache.len() >= RECENT_COMMITS_CACHE_MAX_ENTRIES {
917    if let Some(oldest_key) = cache.keys().next().cloned() {
918      cache.remove(&oldest_key);
919    }
920  }
921  cache.insert(key, rows.clone());
922  Ok(rows)
923}
924
925fn recent_commits_cache() -> MutexGuard<'static, HashMap<RecentCommitCacheKey, Vec<CommitRow>>> {
926  match RECENT_COMMITS_CACHE.lock() {
927    Ok(cache) => cache,
928    Err(poisoned) => poisoned.into_inner(),
929  }
930}
931
932fn recent_commits_cache_repo_key(path: &Path) -> PathBuf {
933  std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
934}
935
936fn worktree_head_oid(w: &WorktreeInfo) -> Result<git2::Oid> {
937  if let Some(head) = &w.head {
938    return git2::Oid::from_str(head)
939      .map_err(|e| GwmError::Other(format!("cached worktree head '{}' is not an oid: {}", head, e)));
940  }
941
942  let repo = Repository::open(&w.path)?;
943  let head_ref = repo.head()?;
944  head_ref.target().ok_or_else(|| GwmError::UnbornHead {
945    reason: "HEAD does not point at a commit".into(),
946  })
947}
948
949fn recent_commits_revwalk(repo: &Repository, tip: git2::Oid, limit: usize) -> Result<Vec<CommitRow>> {
950  let mut walker = repo.revwalk()?;
951  walker.set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)?;
952  walker.push(tip)?;
953
954  let mut rows = Vec::new();
955  for oid in walker.take(limit) {
956    let oid = oid?;
957    let commit = repo.find_commit(oid)?;
958    rows.push(CommitRow {
959      hash: oid,
960      author: commit.author().name().unwrap_or("").to_string(),
961      parents: commit.parent_ids().collect(),
962      subject: commit.summary().ok().flatten().unwrap_or("").to_string(),
963    });
964  }
965  Ok(rows)
966}
967
968#[cfg(test)]
969fn parse_git_log_with_author_output(raw: &str) -> Result<Vec<CommitRow>> {
970  let mut rows = Vec::new();
971  for line in raw.lines() {
972    let mut parts = line.splitn(4, '\u{0}');
973    let hash = parts.next().unwrap_or("");
974    let author = parts.next().unwrap_or("").to_string();
975    let parents_field = parts.next().unwrap_or("");
976    let subject = parts.next().unwrap_or("").to_string();
977    if hash.is_empty() {
978      continue;
979    }
980    let hash = git2::Oid::from_str(hash)
981      .map_err(|e| GwmError::CommandFailed(format!("git log returned invalid commit oid '{}': {}", hash, e)))?;
982    let parents: Vec<git2::Oid> = parents_field
983      .split_whitespace()
984      .map(|s| {
985        git2::Oid::from_str(s)
986          .map_err(|e| GwmError::CommandFailed(format!("git log returned invalid parent oid '{}': {}", s, e)))
987      })
988      .collect::<Result<Vec<_>>>()?;
989    rows.push(CommitRow {
990      hash,
991      author,
992      parents,
993      subject,
994    });
995  }
996  Ok(rows)
997}
998
999/// Run `git -C <dir> <args>`, returning stdout verbatim on success or a
1000/// [`GwmError::CommandFailed`] carrying the verb and git's stderr on a
1001/// non-zero exit (or the spawn error if `git` could not be launched).
1002///
1003/// This is the single shell-out helper for the read-side git invocations
1004/// (sidebar previews, PR-body fillers). Read-only previews fire on every
1005/// selection change, so this variant is deliberately **not** logged — see
1006/// [`run_git_logged`] for the mutating-step counterpart used by `gwm sync`.
1007/// Callers that need trimming, truncation, or field parsing post-process the
1008/// returned `String` themselves.
1009pub fn run_git(dir: &Path, args: &[&str]) -> Result<String> {
1010  run_git_inner(dir, args, false)
1011}
1012
1013/// Like [`run_git`] but records the call on the process-global command log so
1014/// it surfaces in the Command Logs modal (#290). Used for `gwm sync`'s
1015/// mutating steps (`fetch` / `rebase` / `merge` / `--abort`), which are
1016/// user-triggered operations the user expects to find in the transcript —
1017/// unlike the read-only previews that go through [`run_git`].
1018pub fn run_git_logged(dir: &Path, args: &[&str]) -> Result<String> {
1019  run_git_inner(dir, args, true)
1020}
1021
1022fn run_git_inner(dir: &Path, args: &[&str], log: bool) -> Result<String> {
1023  let mut cmd = Command::new("git");
1024  cmd.arg("-C").arg(dir).args(args);
1025  let out = if log {
1026    crate::command_log::run_logged(&mut cmd, format!("git {}", args.join(" ")))
1027  } else {
1028    cmd.output()
1029  }
1030  .map_err(|e| GwmError::CommandFailed(format!("git {} failed to spawn: {}", args.join(" "), e)))?;
1031  if !out.status.success() {
1032    return Err(GwmError::CommandFailed(format!(
1033      "git {} exited {}: {}",
1034      args.join(" "),
1035      out.status,
1036      String::from_utf8_lossy(&out.stderr).trim()
1037    )));
1038  }
1039  Ok(String::from_utf8_lossy(&out.stdout).into_owned())
1040}
1041
1042/// Shell out to `git log --oneline -n <n>` inside `path` and return raw stdout.
1043/// Used by the TUI sidebar to preview recent commits of the selected worktree.
1044pub fn git_log_oneline(path: &Path, n: usize) -> Result<String> {
1045  let n = n.to_string();
1046  run_git(path, &["log", "--oneline", "-n", &n])
1047}
1048
1049/// Shell out to `git log --pretty=- %s <base>..<head>` inside `path`
1050/// and return raw stdout. Used by `gwm pr` to fill the `{commits}`
1051/// placeholder in PR templates (issue #84) — each commit becomes a
1052/// Markdown bullet so a list of commit subjects drops straight into a
1053/// PR body without extra formatting.
1054pub fn git_log_subject_between(path: &Path, base: &str, head: &str) -> Result<String> {
1055  let range = format!("{}..{}", base, head);
1056  let out = run_git(path, &["log", "--pretty=format:- %s", &range])?;
1057  Ok(out.trim_end().to_string())
1058}
1059
1060/// Shell out to `git diff --stat <base>..<head>` inside `path`. The
1061/// output is truncated to `max_lines` lines so a sprawling diff stat
1062/// doesn't blow up the PR body (issue #84: 30-line cap by convention).
1063pub fn git_diff_stat_between(path: &Path, base: &str, head: &str, max_lines: usize) -> Result<String> {
1064  let range = format!("{}..{}", base, head);
1065  let raw = run_git(path, &["diff", "--stat", &range])?;
1066  let mut lines: Vec<&str> = raw.lines().collect();
1067  let truncated = lines.len() > max_lines;
1068  if truncated {
1069    lines.truncate(max_lines);
1070  }
1071  let mut out = lines.join("\n");
1072  if truncated {
1073    out.push_str(&format!(
1074      "\n… ({} more line{} trimmed)",
1075      raw.lines().count() - max_lines,
1076      if raw.lines().count() - max_lines == 1 { "" } else { "s" }
1077    ));
1078  }
1079  Ok(out)
1080}
1081
1082/// Insertion / deletion line counts of a branch versus its base trunk
1083/// (issue #287). Populated from `git diff --shortstat <base>...HEAD` — the
1084/// three-dot merge-base form, so the figures reflect only what the branch
1085/// itself contributed (the GitHub-PR view), not divergence that landed on
1086/// the trunk after the fork.
1087#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1088pub struct DiffLineStat {
1089  /// Lines added by the branch relative to the merge-base with its trunk.
1090  pub insertions: usize,
1091  /// Lines removed by the branch relative to the merge-base with its trunk.
1092  pub deletions: usize,
1093}
1094
1095impl DiffLineStat {
1096  /// True when the branch carries no committed diff against its base — a
1097  /// fresh branch with no commits past the fork point, or one whose net
1098  /// change is empty. The sidebar hides the `Diff` line in that case.
1099  pub fn is_empty(&self) -> bool {
1100    self.insertions == 0 && self.deletions == 0
1101  }
1102}
1103
1104/// Parse a `git diff --shortstat` summary line into a [`DiffLineStat`]
1105/// (issue #287). The line looks like
1106/// ` 3 files changed, 12 insertions(+), 4 deletions(-)`, but either the
1107/// insertions or the deletions clause can be absent — an all-additions or
1108/// all-deletions diff omits the empty side, and an empty diff yields an
1109/// empty string. Any clause that's missing counts as zero; the singular
1110/// `1 insertion(+)` / `1 deletion(-)` forms are handled too.
1111pub fn parse_diff_shortstat(raw: &str) -> DiffLineStat {
1112  let mut out = DiffLineStat::default();
1113  for part in raw.split(',') {
1114    let part = part.trim();
1115    if let Some(n) = part
1116      .strip_suffix("insertions(+)")
1117      .or_else(|| part.strip_suffix("insertion(+)"))
1118    {
1119      out.insertions = n.trim().parse().unwrap_or(0);
1120    } else if let Some(n) = part
1121      .strip_suffix("deletions(-)")
1122      .or_else(|| part.strip_suffix("deletion(-)"))
1123    {
1124      out.deletions = n.trim().parse().unwrap_or(0);
1125    }
1126  }
1127  out
1128}
1129
1130/// True when `branch` is itself a trunk — present in the configured trunk
1131/// list or in the [`COMMON_TRUNKS`] defaults. Used to suppress the Status
1132/// pane's diff row on trunk worktrees regardless of which trunk
1133/// `resolve_trunk` would pick as the base (issue #287).
1134pub fn is_trunk_branch(branch: &str, configured: &[String]) -> bool {
1135  configured.iter().any(|t| t == branch) || COMMON_TRUNKS.contains(&branch)
1136}
1137
1138/// Committed diff size of the worktree's current branch versus its base
1139/// trunk (issue #287), via `git diff --shortstat <base>...HEAD`. Returns
1140/// `Ok(None)` when the path is not a readable repo, when HEAD is itself a
1141/// trunk (no meaningful base to diff against — see [`is_trunk_branch`]), or
1142/// when no base trunk resolves locally. `trunks` is the configured
1143/// trunk-priority list (`config.doctor.trunks`) so the figure matches the
1144/// base `gwm pr` would target — `resolve_trunk` walks it before falling
1145/// back to the common defaults.
1146pub fn git_diff_stat_vs_base(path: &Path, trunks: &[String]) -> Result<Option<DiffLineStat>> {
1147  let repo = match Repository::open(path) {
1148    Ok(r) => r,
1149    Err(_) => return Ok(None),
1150  };
1151  // HEAD sitting on *any* trunk has no meaningful base to diff against —
1152  // suppress the row so a trunk worktree never shows a `Diff`. This must
1153  // check the whole trunk universe, not just the resolved base: with the
1154  // default `["dev", "main"]`, a worktree on `main` resolves its base to
1155  // `dev` (the earlier candidate), and a `head == base` check alone would
1156  // leak a `main...dev` diff onto a trunk worktree (issue #287 review).
1157  if let Ok(head) = repo.head() {
1158    if let Ok(branch) = head.shorthand() {
1159      if is_trunk_branch(branch, trunks) {
1160        return Ok(None);
1161      }
1162    }
1163  }
1164  let base = match resolve_trunk(&repo, trunks) {
1165    Some(b) => b,
1166    None => return Ok(None),
1167  };
1168  let range = format!("{}...HEAD", base);
1169  let raw = run_git(path, &["diff", "--shortstat", &range])?;
1170  Ok(Some(parse_diff_shortstat(&raw)))
1171}
1172
1173/// One row of `git stash list` (issue #34). Surfaced by the sidebar
1174/// in stashes mode. Kept deliberately minimal — `ref_name` so the user
1175/// can copy `stash@{N}` to the status bar, `subject` so they can tell
1176/// which stash is which. Per-file diff numbers (`+/-`) live in a
1177/// follow-up — the v1 contract is just "name + subject".
1178#[derive(Debug, Clone, PartialEq, Eq)]
1179pub struct StashEntry {
1180  /// Canonical git stash reference (e.g. `stash@{0}`). Stable for the
1181  /// lifetime of the panel — the user can paste it into `git stash
1182  /// apply <ref>` from the surrounding shell.
1183  pub ref_name: String,
1184  /// Human-readable subject as written by `git stash push -m <msg>`
1185  /// (or the auto-generated `WIP on <branch>: …` when no `-m` was
1186  /// supplied).
1187  pub subject: String,
1188}
1189
1190/// Parse the worktree's stash list (issue #34). Returns up to `limit`
1191/// entries in `git stash list` order (LIFO — `stash@{0}` is the most
1192/// recent push).
1193///
1194/// Uses `--pretty=format:%gd<US>%s` (with `\x1f` as the unit
1195/// separator) so subjects containing spaces, colons, or `:` round-trip
1196/// safely. An empty stash list returns `Ok(Vec::new())`; only spawn /
1197/// non-zero-exit failures surface as `GwmError::CommandFailed`.
1198pub fn git_stash_list(path: &Path, limit: usize) -> Result<Vec<StashEntry>> {
1199  // ASCII Unit Separator (0x1F) cannot occur in a normal shell argv
1200  // or git ref name, so it's a safe per-field delimiter — same
1201  // technique `git_log_with_author` uses with `\x1c` for record
1202  // separation.
1203  //
1204  // Pass `-n <limit>` (a `git log` option `stash list` forwards
1205  // through) so a repo with hundreds of stashes doesn't materialise
1206  // the full list in stdout just for the panel to drop everything
1207  // past the cap. Pre-review the limit was applied client-side after
1208  // the full stdout was read.
1209  let limit_arg = format!("-n{}", limit);
1210  let raw = run_git(path, &["stash", "list", "--pretty=format:%gd\x1f%s", &limit_arg])?;
1211  let entries = raw
1212    .lines()
1213    .filter(|line| !line.is_empty())
1214    .take(limit)
1215    .filter_map(|line| {
1216      let mut parts = line.splitn(2, '\x1f');
1217      let ref_name = parts.next()?.to_string();
1218      let subject = parts.next().unwrap_or("").to_string();
1219      Some(StashEntry { ref_name, subject })
1220    })
1221    .collect();
1222  Ok(entries)
1223}
1224
1225/// Hard cap on the number of NUL-terminated `git status -z` records read
1226/// before the scan is abandoned (issue #300). `--untracked-files=all` makes
1227/// git recurse into unignored generated/vendor directories; streaming the
1228/// output and stopping here bounds **both** git's directory walk (the child
1229/// is killed once the cap is hit) and our own parse / allocation, so a
1230/// pathological worktree can't stall the TUI. Set well above any realistic
1231/// change set; the file tree itself renders at most
1232/// [`crate::tui::wt_tree::WT_TREE_MAX_FILES`].
1233pub const STATUS_SCAN_CAP: usize = 5000;
1234
1235/// Stream `git status --porcelain -z --untracked-files=all` inside `path`
1236/// and return raw stdout, capped at [`STATUS_SCAN_CAP`] records. Used by the
1237/// TUI sidebar to preview the working-tree state.
1238///
1239/// Two flags matter for the Working Tree file-explorer (issue #300):
1240///
1241/// - `--untracked-files=all` expands an entirely-untracked directory into
1242///   its individual files (`src/app/mod.rs`) instead of git's default
1243///   collapsed `src/` row, so the tree can nest them. Git-ignored paths
1244///   (e.g. `target/`) stay excluded, so the pane never floods with build
1245///   artefacts.
1246/// - `--porcelain -z` emits paths **verbatim**, NUL-terminated — no double-
1247///   quoting of non-ASCII / special-character names, and renames carry the
1248///   source path as a separate NUL field instead of an ambiguous
1249///   `old -> new` text join. This lets [`crate::tui::wt_tree::parse_status_z`]
1250///   parse filenames containing spaces, arrows, quotes, or UTF-8 bytes
1251///   without guesswork. The footer counts (issue #287) parse the same
1252///   stream.
1253pub fn git_status_short(path: &Path) -> Result<(String, bool)> {
1254  git_status_short_capped(path, STATUS_SCAN_CAP)
1255}
1256
1257/// Cap-injectable core of [`git_status_short`]. Spawns git with a piped
1258/// stdout, reads NUL-terminated records until `cap` is reached (then kills
1259/// the child so git stops walking the tree), and returns `(bytes, truncated)`
1260/// — the raw stdout gathered so far plus whether the cap was hit (so the
1261/// caller reports a lower bound rather than an exact total). Exposed so
1262/// integration tests can exercise truncation with a small `cap` instead of
1263/// creating thousands of files.
1264pub fn git_status_short_capped(path: &Path, cap: usize) -> Result<(String, bool)> {
1265  use std::io::{BufRead, BufReader};
1266  use std::process::{Command, Stdio};
1267
1268  // `--no-optional-locks` keeps git from taking the index lock for its
1269  // opportunistic stat-cache refresh (the flag git ships for status pollers
1270  // like IDEs / watchman). Without it, killing the child at the cap could
1271  // leave a stale `.git/index.lock` behind and break the next git command in
1272  // that worktree.
1273  let mut child = Command::new("git")
1274    .arg("--no-optional-locks")
1275    .arg("-C")
1276    .arg(path)
1277    .args(["status", "--porcelain", "-z", "--untracked-files=all"])
1278    .stdout(Stdio::piped())
1279    .stderr(Stdio::piped())
1280    .spawn()
1281    .map_err(|e| GwmError::CommandFailed(format!("git status failed to spawn: {}", e)))?;
1282
1283  let stdout = child
1284    .stdout
1285    .take()
1286    .ok_or_else(|| GwmError::CommandFailed("git status: stdout pipe missing".to_string()))?;
1287  // Drain stderr on its own thread so a chatty git (advisory warnings under
1288  // `-uall`) can't fill the stderr pipe and deadlock against our stdout read.
1289  let stderr_reader = child.stderr.take().map(|mut stderr| {
1290    std::thread::spawn(move || {
1291      use std::io::Read;
1292      let mut buf = String::new();
1293      let _ = stderr.read_to_string(&mut buf);
1294      buf
1295    })
1296  });
1297
1298  let mut reader = BufReader::new(stdout);
1299  let mut collected: Vec<u8> = Vec::new();
1300  let mut segment: Vec<u8> = Vec::new();
1301  let mut records = 0usize;
1302  let mut truncated = false;
1303  loop {
1304    if records >= cap {
1305      truncated = true;
1306      break;
1307    }
1308    segment.clear();
1309    let n = reader
1310      .read_until(0u8, &mut segment)
1311      .map_err(|e| GwmError::CommandFailed(format!("git status: read failed: {}", e)))?;
1312    if n == 0 {
1313      break; // EOF — git produced fewer than `cap` records.
1314    }
1315    collected.extend_from_slice(&segment);
1316    // A trailing NUL marks a complete record; a final unterminated chunk
1317    // (only at true EOF) is kept verbatim and just isn't counted.
1318    if segment.last() == Some(&0) {
1319      records += 1;
1320    }
1321  }
1322
1323  if truncated {
1324    // We have enough to render — stop git's directory walk. The kill is
1325    // best-effort: the child may have already exited on a small tree.
1326    let _ = child.kill();
1327  }
1328  let status = child
1329    .wait()
1330    .map_err(|e| GwmError::CommandFailed(format!("git status: wait failed: {}", e)))?;
1331  // Joining the drainer also closes our end of the stderr pipe.
1332  let stderr = stderr_reader.and_then(|h| h.join().ok()).unwrap_or_default();
1333
1334  // A non-truncated, unsuccessful run is a real failure (e.g. not a repo) —
1335  // surface git's stderr. A truncated run was killed on purpose, so its
1336  // non-zero status is expected and ignored.
1337  if !truncated && !status.success() {
1338    return Err(GwmError::CommandFailed(format!(
1339      "git status exited {}: {}",
1340      status,
1341      stderr.trim()
1342    )));
1343  }
1344
1345  Ok((String::from_utf8_lossy(&collected).into_owned(), truncated))
1346}
1347
1348/// Time elapsed since the *oldest* commit on `branch` that's not also on a
1349/// known trunk (main / master / dev). Returns `None` when no such commit
1350/// exists — i.e. the branch is the trunk itself, has no divergence yet,
1351/// or `branch` cannot be resolved. The "oldest commit" rule mirrors the
1352/// lazygit branch-age semantics (pkg/utils/date.go::UnixToTimeAgo on the
1353/// branch's founding commit) and is more meaningful for a worktree-manager
1354/// than `git log -1`: it answers "how long has this branch been alive?"
1355/// rather than "when did someone last touch it?".
1356pub fn branch_age(repo: &Repository, branch: &str) -> Option<Duration> {
1357  // The trunk itself has no "branch age" — there's no founding-commit
1358  // distinct from the repository's initial commit, and the natural
1359  // answer ("since forever") is more usefully encoded as `None` so the
1360  // UI can render a dash instead of a misleadingly precise duration.
1361  if TRUNK_CANDIDATES.contains(&branch) {
1362    return None;
1363  }
1364
1365  if let Some(age) = branch_created_age(repo, branch) {
1366    return Some(age);
1367  }
1368
1369  let local = repo.find_branch(branch, BranchType::Local).ok()?;
1370  let head_oid = local.into_reference().target()?;
1371
1372  let mut walker = repo.revwalk().ok()?;
1373  walker.push(head_oid).ok()?;
1374  // Track whether any trunk baseline was actually hidden. Without one,
1375  // the revwalk degenerates into "all commits reachable from HEAD" and
1376  // the oldest one is the repo's initial commit — i.e. the branch's
1377  // age becomes the repo's lifetime. PR #74 review caught this: when
1378  // no trunk candidate resolves locally, return `None` so the UI
1379  // renders `-` instead of a misleadingly large duration.
1380  let mut hidden_any = false;
1381  for trunk in TRUNK_CANDIDATES {
1382    if let Ok(t) = repo.find_branch(trunk, BranchType::Local) {
1383      if let Some(oid) = t.into_reference().target() {
1384        if walker.hide(oid).is_ok() {
1385          hidden_any = true;
1386        }
1387      }
1388    }
1389  }
1390  if !hidden_any {
1391    return None;
1392  }
1393
1394  let mut oldest_secs: Option<i64> = None;
1395  for oid in walker.flatten() {
1396    if let Ok(commit) = repo.find_commit(oid) {
1397      let t = commit.time().seconds();
1398      oldest_secs = Some(oldest_secs.map_or(t, |x| x.min(t)));
1399    }
1400  }
1401  let oldest = oldest_secs?;
1402  let now = chrono::Utc::now().timestamp();
1403  let elapsed = (now - oldest).max(0) as u64;
1404  Some(Duration::from_secs(elapsed))
1405}
1406
1407/// Render a `Duration` as a lazygit-style compact relative label
1408/// (`2d`, `3w`, `1M`, `5y`). Mirrors `pkg/utils/date.go::formatSecondsAgo`
1409/// from lazygit: single-character suffix, no plural, capital `M` to
1410/// disambiguate from minutes. Bounded at 4 chars for two-digit values in
1411/// each unit, which is enough for any realistic branch age.
1412pub fn format_relative_duration(d: Duration) -> String {
1413  const MINUTE: u64 = 60;
1414  const HOUR: u64 = 60 * MINUTE;
1415  const DAY: u64 = 24 * HOUR;
1416  const WEEK: u64 = 7 * DAY;
1417  // Month = 30.25 days, year = 365.25 days (matches lazygit `pkg/utils/date.go`).
1418  const MONTH: u64 = 30 * DAY + 6 * HOUR;
1419  const YEAR: u64 = 365 * DAY + 6 * HOUR;
1420
1421  let s = d.as_secs();
1422  if s < MINUTE {
1423    format!("{}s", s)
1424  } else if s < HOUR {
1425    format!("{}m", s / MINUTE)
1426  } else if s < DAY {
1427    format!("{}h", s / HOUR)
1428  } else if s < WEEK {
1429    format!("{}d", s / DAY)
1430  } else if s < MONTH {
1431    format!("{}w", s / WEEK)
1432  } else if s < YEAR {
1433    format!("{}M", s / MONTH)
1434  } else {
1435    format!("{}y", s / YEAR)
1436  }
1437}
1438
1439/// Resolve a worktree by exact name first, then by substring (case-insensitive) within the dir name.
1440pub fn find_fuzzy(repo: &Repository, pattern: &str) -> Result<WorktreeInfo> {
1441  let all = list(repo)?;
1442  // Exact display-name match. Since #290 derives `name` from the path basename,
1443  // it is no longer guaranteed unique (two worktrees in different parent dirs
1444  // can share a basename), so an exact match that hits more than one row is
1445  // ambiguous rather than "take the first" (Codex review on PR #292).
1446  let exact: Vec<&WorktreeInfo> = all.iter().filter(|w| w.name == pattern && !w.is_main).collect();
1447  match exact.len() {
1448    1 => {
1449      // A *different* worktree's stable id can equal this display name (an old
1450      // slug left behind by `git worktree move`). Returning the name-match
1451      // would silently shadow it, so a token that is one worktree's id and
1452      // another's name is ambiguous (Codex review on PR #292).
1453      if let Some(by_id) = all
1454        .iter()
1455        .find(|w| w.id == pattern && w.id != exact[0].id && !w.is_main)
1456      {
1457        return Err(GwmError::Other(format!(
1458          "'{}' is ambiguous: the display name of '{}' and the id of '{}'; target one by its unique id",
1459          pattern, exact[0].id, by_id.id
1460        )));
1461      }
1462      return Ok(exact[0].clone());
1463    }
1464    n if n > 1 => {
1465      // Duplicate display names are always ambiguous — even if one worktree's
1466      // internal id equals the typed token. Resolving to that id-match would
1467      // silently pick one row while the same visible name labels another, so a
1468      // user typing the duplicated name (e.g. `gwm remove dup`) is forced to
1469      // disambiguate by a unique id instead (Codex review on PR #292, iter 4).
1470      // Ids that differ from any duplicated name stay reachable through the
1471      // unique-name-not-found branch below.
1472      let ids = exact.iter().map(|w| w.id.as_str()).collect::<Vec<_>>().join(", ");
1473      return Err(GwmError::Other(format!(
1474        "name '{}' is ambiguous ({} worktrees share it); target one by id: {}",
1475        pattern, n, ids
1476      )));
1477    }
1478    // Unique display name not found: allow an exact id match before falling
1479    // back to substring search, so a renamed worktree stays reachable by id.
1480    _ => {
1481      if let Some(by_id) = all.iter().find(|w| w.id == pattern && !w.is_main) {
1482        return Ok(by_id.clone());
1483      }
1484    }
1485  }
1486  let pat = pattern.to_lowercase();
1487  let mut matches: Vec<&WorktreeInfo> = all
1488    .iter()
1489    .filter(|w| !w.is_main && w.name.to_lowercase().contains(&pat))
1490    .collect();
1491  match matches.len() {
1492    0 => Err(GwmError::WorktreeNotFound(pattern.into())),
1493    1 => Ok(matches.remove(0).clone()),
1494    _ => Err(GwmError::Other(format!(
1495      "pattern '{}' is ambiguous, candidates: {}",
1496      pattern,
1497      matches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>().join(", ")
1498    ))),
1499  }
1500}
1501
1502/// Pick a base ref for `gwm pr` by walking the `configured` trunks list
1503/// first, then the common defaults (`main`, `master`, `dev`, `develop`,
1504/// `trunk`) so a repo whose local trunk is `master` and which hasn't
1505/// customised `[doctor]` doesn't fall back to a non-existent `"main"`.
1506/// Returns `None` only if none of the candidates resolve to a local
1507/// branch — the caller then uses `"main"` as a last resort so the
1508/// downstream `gh pr create --base main` produces a clean error message
1509/// instead of a panic.
1510pub fn resolve_trunk(repo: &Repository, configured: &[String]) -> Option<String> {
1511  for trunk in configured {
1512    if repo.find_branch(trunk, BranchType::Local).is_ok() {
1513      return Some(trunk.clone());
1514    }
1515  }
1516  for trunk in COMMON_TRUNKS {
1517    if configured.iter().any(|t| t == trunk) {
1518      continue; // already tried as a configured trunk above
1519    }
1520    if repo.find_branch(trunk, BranchType::Local).is_ok() {
1521      return Some((*trunk).to_string());
1522    }
1523  }
1524  None
1525}