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