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