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}