worktrunk/git/mod.rs
1//! Git operations and repository management
2
3use std::path::PathBuf;
4
5// Submodules
6mod ci_platform;
7mod diff;
8mod error;
9pub mod fsmonitor;
10mod parse;
11pub mod recover;
12pub mod remote_ref;
13pub mod remove;
14mod repository;
15mod url;
16
17#[cfg(test)]
18mod test;
19
20// Global semaphore for limiting concurrent heavy git operations
21// to reduce mmap thrash on shared commit-graph and pack files.
22//
23// Permit count of 4 was chosen based on:
24// - Typical CPU core counts (4-8 cores common on developer machines)
25// - Empirical testing showing 25.6% improvement on 4-worktree repos
26// - Balance between parallelism and mmap contention
27// - With 4 permits: operations remain fast, overall throughput improves
28//
29// Heavy operations protected:
30// - git rev-list --count (accesses commit-graph via mmap)
31// - git diff --shortstat (accesses pack files and indexes via mmap)
32use crate::sync::Semaphore;
33use std::sync::LazyLock;
34static HEAVY_OPS_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(4));
35
36/// The null OID returned by git when no commits exist (e.g., `git rev-parse HEAD` on an unborn branch).
37pub const NULL_OID: &str = "0000000000000000000000000000000000000000";
38
39// Re-exports from submodules
40pub use ci_platform::CiPlatform;
41pub(crate) use diff::DiffStats;
42pub use diff::{LineDiff, parse_numstat_line};
43pub use error::{
44 // Typed leaf error for buffered command-runner failures (downcast target)
45 CommandError,
46 // Trait for typed errors that produce a rich, styled diagnostic block
47 // distinct from their short single-line `Display`
48 Diagnostic,
49 // Extension methods on `anyhow::Error` (render_diagnostic,
50 // display_message, exit_code, interrupt_exit_code). Bring into scope
51 // to call them via method syntax.
52 ErrorExt,
53 // Structured command failure info
54 FailedCommand,
55 // Typed error enum
56 GitError,
57 // Special-handling error enum
58 HookErrorWithHint,
59 // Platform-specific reference type (PR vs MR)
60 RefContext,
61 RefType,
62 // CLI context for enriching switch suggestions in error hints
63 SwitchSuggestionCtx,
64 WorktrunkError,
65 // Wrap a HookCommandFailed-bearing error with a --no-hooks hint
66 add_hook_skip_hint,
67 // Render a single error via Diagnostic if it implements one
68 try_render_diagnostic,
69};
70pub use parse::{parse_porcelain_z, parse_untracked_files};
71pub use recover::{current_or_recover, cwd_removed_hint};
72pub use remove::{
73 BranchDeletionMode, BranchDeletionOutcome, BranchDeletionResult, RemovalOutput, RemoveOptions,
74 delete_branch_if_safe, remove_worktree_with_cleanup, stage_worktree_removal,
75 stop_fsmonitor_daemon,
76};
77pub use repository::sha_cache;
78pub use repository::{
79 Branch, CommitMessageDetail, IntegrationTargets, RefSnapshot, Repository, ResolvedWorktree,
80 TempIndex, WorkingTree, set_base_path,
81};
82pub use url::parse_owner_repo;
83pub use url::{GitRemoteUrl, GitRepoInfo, GitRepoProvider};
84/// Why branch content is considered integrated into the target branch.
85///
86/// Used by both `wt list` (for status symbols) and `wt remove` (for messages).
87/// Each variant corresponds to a specific integration check. In `wt list`,
88/// three symbols represent these checks:
89/// - `_` for [`SameCommit`](Self::SameCommit) with clean working tree (empty)
90/// - `–` for [`SameCommit`](Self::SameCommit) with dirty working tree
91/// - `⊂` for all others (content integrated via different history)
92///
93/// The checks are ordered by cost (cheapest first):
94/// 1. [`SameCommit`](Self::SameCommit) - commit SHA comparison (~1ms)
95/// 2. [`Ancestor`](Self::Ancestor) - ancestor check (~1ms)
96/// 3. [`NoAddedChanges`](Self::NoAddedChanges) - three-dot diff (~50-100ms)
97/// 4. [`TreesMatch`](Self::TreesMatch) - tree SHA comparison (~100-300ms)
98/// 5. [`MergeAddsNothing`](Self::MergeAddsNothing) - merge simulation (~500ms-2s)
99/// 6. [`PatchIdMatch`](Self::PatchIdMatch) - patch-id matching when merge-tree conflicts (~1-3s)
100#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, strum::IntoStaticStr)]
101#[serde(rename_all = "kebab-case")]
102#[strum(serialize_all = "kebab-case")]
103pub enum IntegrationReason {
104 /// Branch HEAD is literally the same commit as target.
105 ///
106 /// Used by `wt remove` to determine if branch is safely deletable.
107 /// In `wt list`, same-commit state is shown via `MainState::Empty` (`_`) or
108 /// `MainState::SameCommit` (`–`) depending on working tree cleanliness.
109 SameCommit,
110
111 /// Branch HEAD is an ancestor of target (target has moved past this branch).
112 ///
113 /// Symbol in `wt list`: `⊂`
114 Ancestor,
115
116 /// Three-dot diff (`main...branch`) shows no files.
117 /// The branch has no file changes beyond the merge-base.
118 ///
119 /// Symbol in `wt list`: `⊂`
120 NoAddedChanges,
121
122 /// Branch tree SHA equals target tree SHA.
123 /// Commit history differs but file contents are identical.
124 ///
125 /// Symbol in `wt list`: `⊂`
126 TreesMatch,
127
128 /// The branch has changes, but merging would produce the same tree as target.
129 ///
130 /// Detected via `git merge-tree` simulation. Handles squash-merged branches
131 /// where target has advanced with changes to different files.
132 ///
133 /// Symbol in `wt list`: `⊂`
134 MergeAddsNothing,
135
136 /// The branch's entire squashed diff matches a single commit on target.
137 ///
138 /// Fallback for when `merge-tree` conflicts (both sides modified the same
139 /// files). Computes `git diff-tree -p merge-base branch` as one combined
140 /// diff and checks if any individual commit on target has the same
141 /// patch-id. This specifically detects GitHub/GitLab squash merges — the
142 /// squash commit contains the whole branch in one commit, so the patch-ids
143 /// match. Does NOT detect cherry-picks of individual commits.
144 ///
145 /// Symbol in `wt list`: `⊂`
146 PatchIdMatch,
147}
148
149impl IntegrationReason {
150 /// Human-readable description for use in messages (e.g., `wt remove` output).
151 ///
152 /// Returns a phrase that expects the target branch name to follow
153 /// (e.g., "same commit as" + "main" → "same commit as main").
154 pub fn description(&self) -> &'static str {
155 match self {
156 Self::SameCommit => "same commit as",
157 Self::Ancestor => "ancestor of",
158 Self::NoAddedChanges => "no added changes on",
159 Self::TreesMatch => "tree matches",
160 Self::MergeAddsNothing => "all changes in",
161 Self::PatchIdMatch => "all changes in",
162 }
163 }
164
165 /// Status symbol used in `wt list` for this integration reason.
166 ///
167 /// - `SameCommit` → `_` (matches `MainState::Empty`)
168 /// - Others → `⊂` (matches `MainState::Integrated`)
169 pub fn symbol(&self) -> &'static str {
170 match self {
171 Self::SameCommit => "_",
172 _ => "⊂",
173 }
174 }
175}
176
177/// Integration signals for checking if a branch is integrated into target.
178///
179/// `None` means "unknown/failed to check". The check functions treat `None`
180/// conservatively (as if not integrated).
181///
182/// Used by:
183/// - `wt list`: Built from parallel task results
184/// - `wt remove`/`wt merge`: Built via [`compute_integration_lazy`]
185#[derive(Debug, Default)]
186pub struct IntegrationSignals {
187 pub is_same_commit: Option<bool>,
188 pub is_ancestor: Option<bool>,
189 pub has_added_changes: Option<bool>,
190 pub trees_match: Option<bool>,
191 pub would_merge_add: Option<bool>,
192 pub is_patch_id_match: Option<bool>,
193}
194
195/// Canonical integration check using pre-computed signals.
196///
197/// Checks signals in priority order (cheapest first). Returns as soon as any
198/// integration reason is found.
199///
200/// `None` values are treated conservatively: unknown signals don't match.
201/// This is the single source of truth for integration priority logic.
202pub fn check_integration(signals: &IntegrationSignals) -> Option<IntegrationReason> {
203 // Priority 1 (cheapest): Same commit as target
204 if signals.is_same_commit == Some(true) {
205 return Some(IntegrationReason::SameCommit);
206 }
207
208 // Priority 2 (cheap): Branch is ancestor of target (target has moved past)
209 if signals.is_ancestor == Some(true) {
210 return Some(IntegrationReason::Ancestor);
211 }
212
213 // Priority 3: No file changes beyond merge-base (empty three-dot diff)
214 if signals.has_added_changes == Some(false) {
215 return Some(IntegrationReason::NoAddedChanges);
216 }
217
218 // Priority 4: Tree SHA matches target (handles squash merge/rebase)
219 if signals.trees_match == Some(true) {
220 return Some(IntegrationReason::TreesMatch);
221 }
222
223 // Priority 5 (expensive ~500ms-2s): Merge would not add anything
224 if signals.would_merge_add == Some(false) {
225 return Some(IntegrationReason::MergeAddsNothing);
226 }
227
228 // Priority 6 (most expensive): Patch-id match detects squash merge when merge-tree conflicts
229 if signals.is_patch_id_match == Some(true) {
230 return Some(IntegrationReason::PatchIdMatch);
231 }
232
233 None
234}
235
236/// Compute integration signals lazily with short-circuit evaluation.
237///
238/// Runs git commands in priority order, stopping as soon as integration is
239/// confirmed. This avoids expensive checks (like `would_merge_add` which
240/// takes ~500ms-2s) when cheaper checks succeed.
241///
242/// Used by `wt remove` and `wt merge` for single-branch checks.
243/// For batch operations, use parallel tasks to build [`IntegrationSignals`] directly.
244///
245/// Resolves both `branch` and `target` to commit SHAs via `snapshot` so the
246/// integration probes are immune to ambient ref→SHA cache staleness — this
247/// is the safety contract that lets `wt merge`'s post-update-ref check
248/// observe the new local target SHA instead of the pre-merge value.
249/// Refs not in the snapshot (typically transient HEAD commits during a
250/// rebase, or raw SHAs) fall back to uncached `git rev-parse`.
251#[allow(clippy::field_reassign_with_default)] // Intentional: short-circuit populates fields incrementally
252pub fn compute_integration_lazy(
253 repo: &Repository,
254 snapshot: &RefSnapshot,
255 branch: &str,
256 target: &str,
257) -> anyhow::Result<IntegrationSignals> {
258 let mut signals = IntegrationSignals::default();
259
260 let branch_sha = resolve_via_snapshot(repo, snapshot, branch)?;
261 let target_sha = resolve_via_snapshot(repo, snapshot, target)?;
262
263 // Priority 1: Same commit
264 signals.is_same_commit = Some(branch_sha == target_sha);
265 if signals.is_same_commit == Some(true) {
266 return Ok(signals);
267 }
268
269 // Priority 2: Ancestor
270 signals.is_ancestor = Some(repo.is_ancestor_by_sha(&branch_sha, &target_sha)?);
271 if signals.is_ancestor == Some(true) {
272 return Ok(signals);
273 }
274
275 // Priority 3: No added changes
276 signals.has_added_changes = Some(repo.has_added_changes_by_sha(&branch_sha, &target_sha)?);
277 if signals.has_added_changes == Some(false) {
278 return Ok(signals);
279 }
280
281 // Priority 4: Trees match
282 signals.trees_match = Some(repo.trees_match_by_sha(&branch_sha, &target_sha)?);
283 if signals.trees_match == Some(true) {
284 return Ok(signals);
285 }
286
287 // Priority 5+6: Merge-tree simulation + patch-id fallback
288 let probe = repo.merge_integration_probe_by_sha(&branch_sha, &target_sha)?;
289 signals.would_merge_add = Some(probe.would_merge_add);
290 if !probe.would_merge_add {
291 return Ok(signals);
292 }
293 signals.is_patch_id_match = Some(probe.is_patch_id_match);
294
295 Ok(signals)
296}
297
298/// Resolve a ref name through the snapshot, falling back to an uncached
299/// `git rev-parse` for refs the snapshot doesn't carry (HEAD, raw SHAs,
300/// tags, transient rebase commits). Used by [`compute_integration_lazy`]
301/// at the boundary into SHA-keyed integration probes.
302fn resolve_via_snapshot(
303 repo: &Repository,
304 snapshot: &RefSnapshot,
305 name: &str,
306) -> anyhow::Result<String> {
307 if let Some(sha) = snapshot.resolve(name) {
308 return Ok(sha.to_string());
309 }
310 Ok(repo
311 .run_command(&["rev-parse", "--verify", "--end-of-options", name])?
312 .trim()
313 .to_string())
314}
315
316/// Category of branch for completion display
317#[derive(Debug, Clone, PartialEq)]
318pub enum BranchCategory {
319 /// Branch has an active worktree
320 Worktree,
321 /// Local branch without worktree
322 Local,
323 /// Remote-only branch (includes remote names — multiple if same branch on multiple remotes)
324 Remote(Vec<String>),
325}
326
327/// Branch information for shell completions
328#[derive(Debug, Clone)]
329pub struct CompletionBranch {
330 /// Branch name (local name for remotes, e.g., "fix" not "origin/fix")
331 pub name: String,
332 /// Unix timestamp of last commit
333 pub timestamp: i64,
334 /// Category for sorting and display
335 pub category: BranchCategory,
336}
337
338/// A single local branch entry from the branch inventory.
339///
340/// Populated by one `git for-each-ref refs/heads/` scan per Repository lifetime
341/// (see [`Repository::local_branches`]). Sorted by most recent commit first.
342#[derive(Debug, Clone)]
343pub struct LocalBranch {
344 /// Short branch name, e.g., "feature" (not "refs/heads/feature").
345 pub name: String,
346 /// Commit SHA this branch points at.
347 pub commit_sha: String,
348 /// Unix timestamp of the commit.
349 pub committer_ts: i64,
350 /// Upstream tracking ref (e.g., "origin/main"), if configured and present.
351 /// `None` when no upstream is set, or when the configured upstream is gone
352 /// (git reports `[gone]` via `%(upstream:track)`).
353 pub upstream_short: Option<String>,
354}
355
356/// A single remote-tracking branch entry from the branch inventory.
357///
358/// Populated by one `git for-each-ref refs/remotes/` scan per Repository
359/// lifetime (see [`Repository::remote_branches`]). `<remote>/HEAD` symrefs are
360/// excluded. Sorted by most recent commit first.
361#[derive(Debug, Clone)]
362pub struct RemoteBranch {
363 /// Remote-qualified name, e.g., "origin/feature".
364 pub short_name: String,
365 /// Commit SHA this remote ref points at.
366 pub commit_sha: String,
367 /// Unix timestamp of the commit.
368 pub committer_ts: i64,
369 /// Remote name, e.g., "origin".
370 pub remote_name: String,
371 /// Branch name without the remote prefix, e.g., "feature".
372 pub local_name: String,
373}
374
375// Re-export parsing helpers for internal use
376pub(crate) use parse::DefaultBranchName;
377
378use crate::shell_exec::Cmd;
379
380/// Check if a local branch is tracking a specific remote ref.
381///
382/// Returns `Some(true)` if the branch is configured to track the given ref.
383/// Returns `Some(false)` if the branch exists but tracks something else (or nothing).
384/// Returns `None` if the branch doesn't exist.
385///
386/// Used by PR/MR checkout to detect when a branch name collision exists.
387///
388/// # Arguments
389/// * `repo_root` - Path to run git commands from
390/// * `branch` - Local branch name to check
391/// * `expected_ref` - Full ref path (e.g., `refs/pull/101/head` or `refs/merge-requests/42/head`)
392/// * `expected_remote` - Optional remote name that must also match `branch.<name>.remote`
393pub fn branch_tracks_ref(
394 repo_root: &std::path::Path,
395 branch: &str,
396 expected_ref: &str,
397 expected_remote: Option<&str>,
398) -> Option<bool> {
399 let config_key = format!("branch.{}.merge", branch);
400 let output = Cmd::new("git")
401 .args(["config", "--get", &config_key])
402 .current_dir(repo_root)
403 .run()
404 .ok()?;
405
406 if !output.status.success() {
407 // Config key doesn't exist - branch might not track anything
408 // Check if branch exists at all
409 let branch_exists = Cmd::new("git")
410 .args([
411 "show-ref",
412 "--verify",
413 "--quiet",
414 &format!("refs/heads/{}", branch),
415 ])
416 .current_dir(repo_root)
417 .run()
418 .map(|o| o.status.success())
419 .unwrap_or(false);
420
421 return if branch_exists { Some(false) } else { None };
422 }
423
424 let merge_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
425 if merge_ref != expected_ref {
426 return Some(false);
427 }
428
429 let Some(expected_remote) = expected_remote else {
430 return Some(true);
431 };
432
433 let remote_key = format!("branch.{}.remote", branch);
434 let remote_output = Cmd::new("git")
435 .args(["config", "--get", &remote_key])
436 .current_dir(repo_root)
437 .run()
438 .ok()?;
439
440 if !remote_output.status.success() {
441 return Some(false);
442 }
443
444 let remote = String::from_utf8_lossy(&remote_output.stdout)
445 .trim()
446 .to_string();
447 Some(remote == expected_remote)
448}
449
450// Note: HookType and WorktreeInfo are defined in this module and are already public.
451// They're accessible as git::HookType and git::WorktreeInfo without needing re-export.
452
453/// Hook types for git operations
454#[derive(
455 Debug,
456 Clone,
457 Copy,
458 PartialEq,
459 Eq,
460 serde::Serialize,
461 serde::Deserialize,
462 strum::Display,
463 strum::EnumString,
464 strum::EnumIter,
465)]
466#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
467#[serde(rename_all = "kebab-case")]
468#[strum(serialize_all = "kebab-case")]
469pub enum HookType {
470 PreSwitch,
471 PostSwitch,
472 // Canonical display is `pre-start`/`post-start`; `pre-create`/`post-create`
473 // are accepted as silent aliases via `FromStr`. The Rust variant name uses
474 // the future canonical (`-create`) so the eventual flip is a Display-only
475 // change.
476 #[strum(to_string = "pre-start", serialize = "pre-create")]
477 #[serde(rename = "pre-start", alias = "pre-create")]
478 #[cfg_attr(feature = "cli", value(name = "pre-start", alias = "pre-create"))]
479 PreCreate,
480 #[strum(to_string = "post-start", serialize = "post-create")]
481 #[serde(rename = "post-start", alias = "post-create")]
482 #[cfg_attr(feature = "cli", value(name = "post-start", alias = "post-create"))]
483 PostCreate,
484 PreCommit,
485 PostCommit,
486 PreMerge,
487 PostMerge,
488 PreRemove,
489 PostRemove,
490}
491
492impl HookType {
493 /// True for `pre-*` hooks. `pre-*` hooks block the operation and propagate
494 /// failures fail-fast; `post-*` hooks run after success and default to
495 /// background-with-warn.
496 ///
497 /// Uses an exhaustive match (not `matches!`) so a new variant trips a
498 /// compile-time nudge instead of silently classifying as post-*.
499 pub fn is_pre(self) -> bool {
500 match self {
501 HookType::PreSwitch
502 | HookType::PreCreate
503 | HookType::PreCommit
504 | HookType::PreMerge
505 | HookType::PreRemove => true,
506 HookType::PostSwitch
507 | HookType::PostCreate
508 | HookType::PostCommit
509 | HookType::PostMerge
510 | HookType::PostRemove => false,
511 }
512 }
513}
514
515/// Reference to a branch for parallel task execution.
516///
517/// Works for both worktree items (has path) and branch-only items (no worktree).
518/// The `Option<PathBuf>` makes the worktree distinction explicit instead of using
519/// empty paths as a sentinel value.
520///
521/// # Construction
522///
523/// - From a worktree: `BranchRef::from(&worktree_info)`
524/// - For a local branch: `BranchRef::local_branch("feature", "abc123")`
525/// - For a remote branch: `BranchRef::remote_branch("origin/feature", "abc123")`
526///
527/// # Working Tree Access
528///
529/// For worktree-specific operations, use [`working_tree()`](Self::working_tree)
530/// which returns `Some(WorkingTree)` only when this ref has a worktree path.
531#[derive(Debug, Clone)]
532pub struct BranchRef {
533 /// Full git ref (e.g., `refs/heads/feature`, `refs/remotes/origin/feature`).
534 /// `None` for detached HEAD.
535 ///
536 /// Storing the full ref — rather than a short name plus a remote/local
537 /// flag — makes the ref unambiguous: git lets users create a local branch
538 /// literally named `origin/foo`, and `git rev-parse origin/foo` then picks
539 /// `refs/heads/origin/foo` over `refs/remotes/origin/foo`. With the full
540 /// ref there is nothing to disambiguate.
541 pub full_ref: Option<String>,
542 /// Commit SHA this branch/worktree points to.
543 pub commit_sha: String,
544 /// Path to worktree, if this branch has one.
545 /// None for branch-only items (remote branches, local branches without worktrees).
546 pub worktree_path: Option<PathBuf>,
547}
548
549impl BranchRef {
550 /// Create a BranchRef for a local branch without a worktree.
551 pub fn local_branch(branch: &str, commit_sha: &str) -> Self {
552 Self {
553 full_ref: Some(format!("refs/heads/{branch}")),
554 commit_sha: commit_sha.to_string(),
555 worktree_path: None,
556 }
557 }
558
559 /// Create a BranchRef for a remote-tracking branch.
560 ///
561 /// `branch` is the short remote-qualified name (e.g., `"origin/feature"`),
562 /// as produced by `%(refname:lstrip=2)` in `list_remote_branches`. It is
563 /// stored as `refs/remotes/<branch>`.
564 pub fn remote_branch(branch: &str, commit_sha: &str) -> Self {
565 Self {
566 full_ref: Some(format!("refs/remotes/{branch}")),
567 commit_sha: commit_sha.to_string(),
568 worktree_path: None,
569 }
570 }
571
572 /// Get a working tree handle for this branch's worktree.
573 ///
574 /// Returns `Some(WorkingTree)` if this branch has a worktree path,
575 /// `None` for branch-only items.
576 pub fn working_tree<'a>(&self, repo: &'a Repository) -> Option<WorkingTree<'a>> {
577 self.worktree_path
578 .as_ref()
579 .map(|p| repo.worktree_at(p.clone()))
580 }
581
582 /// Returns true if this branch has a worktree.
583 pub fn has_worktree(&self) -> bool {
584 self.worktree_path.is_some()
585 }
586
587 /// Full git ref (e.g., `refs/heads/feature`, `refs/remotes/origin/feature`),
588 /// suitable for passing to integration helpers that go through `git rev-parse`.
589 ///
590 /// Returns `None` for detached HEAD.
591 pub fn full_ref(&self) -> Option<&str> {
592 self.full_ref.as_deref()
593 }
594
595 /// Short display name (e.g., `feature`, `origin/feature`) with the
596 /// `refs/heads/` or `refs/remotes/` prefix stripped. Use for user-facing
597 /// output and APIs that expect short names (`git config branch.<name>.*`,
598 /// `gh pr view <branch>`).
599 ///
600 /// Returns `None` for detached HEAD.
601 ///
602 /// # Panics
603 ///
604 /// Every constructor on this type produces a `full_ref` starting with
605 /// `refs/heads/` or `refs/remotes/`; this panics if that invariant is
606 /// ever violated (e.g., by a future struct-literal caller). The panic
607 /// is the intended behavior — silently returning an unqualified ref
608 /// would re-open the shadowing class of bug this type exists to rule out.
609 pub fn short_name(&self) -> Option<&str> {
610 let r = self.full_ref.as_deref()?;
611 Some(
612 r.strip_prefix("refs/heads/")
613 .or_else(|| r.strip_prefix("refs/remotes/"))
614 .expect("BranchRef.full_ref must start with refs/heads/ or refs/remotes/"),
615 )
616 }
617
618 /// True if this is a remote-tracking ref (under `refs/remotes/`).
619 pub fn is_remote(&self) -> bool {
620 self.full_ref
621 .as_deref()
622 .is_some_and(|r| r.starts_with("refs/remotes/"))
623 }
624}
625
626impl From<&WorktreeInfo> for BranchRef {
627 fn from(wt: &WorktreeInfo) -> Self {
628 // `WorktreeInfo.branch` is the short form produced by
629 // `parse_porcelain_list` (one `refs/heads/` prefix stripped). Worktrees
630 // always point at local branches, so re-qualifying with `refs/heads/`
631 // gives the full ref. Note: git permits a branch literally named
632 // `refs/heads/foo`; in that case `wt.branch == "refs/heads/foo"` and
633 // this produces `refs/heads/refs/heads/foo` — which is the correct full
634 // ref for that pathological branch, so we don't special-case it.
635 Self {
636 full_ref: wt.branch.as_deref().map(|b| format!("refs/heads/{b}")),
637 commit_sha: wt.head.clone(),
638 worktree_path: Some(wt.path.clone()),
639 }
640 }
641}
642
643/// Parsed worktree data from `git worktree list --porcelain`.
644///
645/// This is a data record containing metadata about a worktree.
646/// For running commands in a worktree, use [`WorkingTree`] via
647/// [`Repository::worktree_at()`] or [`BranchRef::working_tree()`].
648#[derive(Debug, Clone, PartialEq, serde::Serialize)]
649pub struct WorktreeInfo {
650 pub path: PathBuf,
651 pub head: String,
652 pub branch: Option<String>,
653 pub bare: bool,
654 pub detached: bool,
655 pub locked: Option<String>,
656 pub prunable: Option<String>,
657}
658
659/// Extract the directory name from a path for display purposes.
660///
661/// Returns the last component of the path as a string, or "(unknown)" if
662/// the path has no filename or contains invalid UTF-8.
663pub fn path_dir_name(path: &std::path::Path) -> &str {
664 path.file_name()
665 .and_then(|n| n.to_str())
666 .unwrap_or("(unknown)")
667}
668
669impl WorktreeInfo {
670 /// Returns true if this worktree is prunable (directory deleted but git still tracks metadata).
671 ///
672 /// Prunable worktrees cannot be operated on - the directory doesn't exist.
673 /// Most iteration over worktrees should skip prunable ones.
674 pub fn is_prunable(&self) -> bool {
675 self.prunable.is_some()
676 }
677
678 /// Returns true if this worktree points to a real commit (not the null OID).
679 ///
680 /// Unborn branches (no commits yet) have the null OID as their HEAD.
681 pub fn has_commits(&self) -> bool {
682 self.head != NULL_OID
683 }
684
685 /// Returns the worktree directory name.
686 ///
687 /// This is the filesystem directory name (e.g., "repo.feature" from "/path/to/repo.feature").
688 /// For user-facing display with context (branch consistency, detached state),
689 /// use `worktree_display_name()` from the commands module instead.
690 pub fn dir_name(&self) -> &str {
691 path_dir_name(&self.path)
692 }
693}
694
695// Helper functions for worktree parsing
696//
697// These live in mod.rs rather than parse.rs because they bridge multiple concerns:
698// - read_rebase_branch() uses Repository (from repository.rs) to access git internals
699// - finalize_worktree() operates on WorktreeInfo (defined here in mod.rs)
700// - Both are tightly coupled to the WorktreeInfo type definition
701//
702// Placing them here avoids circular dependencies and keeps them close to WorktreeInfo.
703
704/// Helper function to read rebase branch information
705fn read_rebase_branch(worktree_path: &PathBuf) -> Option<String> {
706 let repo = Repository::current().ok()?;
707 let git_dir = repo.worktree_at(worktree_path).git_dir().ok()?;
708
709 // Check both rebase-merge and rebase-apply
710 for rebase_dir in ["rebase-merge", "rebase-apply"] {
711 let head_name_path = git_dir.join(rebase_dir).join("head-name");
712 if let Ok(content) = std::fs::read_to_string(head_name_path) {
713 let branch_ref = content.trim();
714 // Strip refs/heads/ prefix if present
715 let branch = branch_ref
716 .strip_prefix("refs/heads/")
717 .unwrap_or(branch_ref)
718 .to_string();
719 return Some(branch);
720 }
721 }
722
723 None
724}
725
726/// Finalize a worktree after parsing, filling in branch name from rebase state if needed.
727pub(crate) fn finalize_worktree(mut wt: WorktreeInfo) -> WorktreeInfo {
728 // If detached but no branch, check if we're rebasing
729 if wt.detached
730 && wt.branch.is_none()
731 && let Some(branch) = read_rebase_branch(&wt.path)
732 {
733 wt.branch = Some(branch);
734 }
735 wt
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 #[test]
743 fn test_check_integration() {
744 // Each integration reason + not integrated
745 // Tuple: (is_same_commit, is_ancestor, has_added_changes, trees_match, would_merge_add, is_patch_id_match)
746 let cases = [
747 (
748 (
749 Some(true),
750 Some(false),
751 Some(true),
752 Some(false),
753 Some(true),
754 None,
755 ),
756 Some(IntegrationReason::SameCommit),
757 ),
758 (
759 (
760 Some(false),
761 Some(true),
762 Some(true),
763 Some(false),
764 Some(true),
765 None,
766 ),
767 Some(IntegrationReason::Ancestor),
768 ),
769 (
770 (
771 Some(false),
772 Some(false),
773 Some(false),
774 Some(false),
775 Some(true),
776 None,
777 ),
778 Some(IntegrationReason::NoAddedChanges),
779 ),
780 (
781 (
782 Some(false),
783 Some(false),
784 Some(true),
785 Some(true),
786 Some(true),
787 None,
788 ),
789 Some(IntegrationReason::TreesMatch),
790 ),
791 (
792 (
793 Some(false),
794 Some(false),
795 Some(true),
796 Some(false),
797 Some(false),
798 None,
799 ),
800 Some(IntegrationReason::MergeAddsNothing),
801 ),
802 (
803 // Patch-id match (merge-tree conflicts but patch-id finds squash commit)
804 (
805 Some(false),
806 Some(false),
807 Some(true),
808 Some(false),
809 Some(true),
810 Some(true),
811 ),
812 Some(IntegrationReason::PatchIdMatch),
813 ),
814 (
815 // Not integrated (nothing matches)
816 (
817 Some(false),
818 Some(false),
819 Some(true),
820 Some(false),
821 Some(true),
822 Some(false),
823 ),
824 None,
825 ),
826 (
827 (
828 Some(true),
829 Some(true),
830 Some(false),
831 Some(true),
832 Some(false),
833 None,
834 ),
835 Some(IntegrationReason::SameCommit),
836 ), // Priority test: is_same_commit wins
837 // None values are treated conservatively (as if not integrated)
838 ((None, None, None, None, None, None), None),
839 (
840 (None, Some(true), Some(false), Some(true), Some(false), None),
841 Some(IntegrationReason::Ancestor),
842 ),
843 ];
844 for ((same, ancestor, added, trees, merge, patch_id), expected) in cases {
845 let signals = IntegrationSignals {
846 is_same_commit: same,
847 is_ancestor: ancestor,
848 has_added_changes: added,
849 trees_match: trees,
850 would_merge_add: merge,
851 is_patch_id_match: patch_id,
852 };
853 assert_eq!(
854 check_integration(&signals),
855 expected,
856 "case: {same:?},{ancestor:?},{added:?},{trees:?},{merge:?},{patch_id:?}"
857 );
858 }
859 }
860
861 #[test]
862 fn test_integration_reason_description() {
863 assert_eq!(
864 IntegrationReason::SameCommit.description(),
865 "same commit as"
866 );
867 assert_eq!(IntegrationReason::Ancestor.description(), "ancestor of");
868 assert_eq!(
869 IntegrationReason::NoAddedChanges.description(),
870 "no added changes on"
871 );
872 assert_eq!(IntegrationReason::TreesMatch.description(), "tree matches");
873 assert_eq!(
874 IntegrationReason::MergeAddsNothing.description(),
875 "all changes in"
876 );
877 assert_eq!(
878 IntegrationReason::PatchIdMatch.description(),
879 "all changes in"
880 );
881 }
882
883 #[test]
884 fn test_path_dir_name() {
885 assert_eq!(
886 path_dir_name(&PathBuf::from("/home/user/repo.feature")),
887 "repo.feature"
888 );
889 assert_eq!(path_dir_name(&PathBuf::from("/")), "(unknown)");
890 assert!(!path_dir_name(&PathBuf::from("/home/user/repo/")).is_empty());
891
892 // WorktreeInfo::dir_name
893 let wt = WorktreeInfo {
894 path: PathBuf::from("/repos/myrepo.feature"),
895 head: "abc123".into(),
896 branch: Some("feature".into()),
897 bare: false,
898 detached: false,
899 locked: None,
900 prunable: None,
901 };
902 assert_eq!(wt.dir_name(), "myrepo.feature");
903 }
904
905 #[test]
906 fn test_hook_type_display() {
907 use strum::IntoEnumIterator;
908
909 // Verify all hook types serialize to kebab-case
910 for hook in HookType::iter() {
911 let display = format!("{hook}");
912 assert!(
913 display.chars().all(|c| c.is_lowercase() || c == '-'),
914 "Hook {hook:?} should be kebab-case, got: {display}"
915 );
916 }
917 }
918
919 #[test]
920 fn test_branch_ref_from_worktree_info() {
921 let wt = WorktreeInfo {
922 path: PathBuf::from("/repo.feature"),
923 head: "abc123".into(),
924 branch: Some("feature".into()),
925 bare: false,
926 detached: false,
927 locked: None,
928 prunable: None,
929 };
930
931 let branch_ref = BranchRef::from(&wt);
932
933 assert_eq!(branch_ref.full_ref(), Some("refs/heads/feature"));
934 assert_eq!(branch_ref.short_name(), Some("feature"));
935 assert_eq!(branch_ref.commit_sha, "abc123");
936 assert_eq!(
937 branch_ref.worktree_path,
938 Some(PathBuf::from("/repo.feature"))
939 );
940 assert!(branch_ref.has_worktree());
941 assert!(!branch_ref.is_remote()); // Worktrees are always local
942 }
943
944 #[test]
945 fn test_branch_ref_local_branch() {
946 let branch_ref = BranchRef::local_branch("feature", "abc123");
947
948 assert_eq!(branch_ref.full_ref(), Some("refs/heads/feature"));
949 assert_eq!(branch_ref.short_name(), Some("feature"));
950 assert_eq!(branch_ref.commit_sha, "abc123");
951 assert_eq!(branch_ref.worktree_path, None);
952 assert!(!branch_ref.has_worktree());
953 assert!(!branch_ref.is_remote());
954 }
955
956 #[test]
957 fn test_branch_ref_remote_branch() {
958 let branch_ref = BranchRef::remote_branch("origin/feature", "abc123");
959
960 assert_eq!(branch_ref.full_ref(), Some("refs/remotes/origin/feature"));
961 assert_eq!(branch_ref.short_name(), Some("origin/feature"));
962 assert_eq!(branch_ref.commit_sha, "abc123");
963 assert_eq!(branch_ref.worktree_path, None);
964 assert!(!branch_ref.has_worktree());
965 assert!(branch_ref.is_remote());
966 }
967
968 #[test]
969 fn test_branch_ref_full_ref_disambiguates_remote_from_local() {
970 // Git allows local branches literally named `origin/foo`. Full refs
971 // make the two distinguishable even though they share a short name.
972 //
973 // This pins the type-level contract; the end-to-end guardrail against
974 // `git rev-parse` picking the wrong ref lives at
975 // `test_list_remote_row_not_shadowed_by_same_named_local_branch` in
976 // `tests/integration_tests/list.rs`.
977 let remote = BranchRef::remote_branch("origin/foo", "abc");
978 let local = BranchRef::local_branch("origin/foo", "def");
979
980 assert_eq!(remote.full_ref(), Some("refs/remotes/origin/foo"));
981 assert_eq!(local.full_ref(), Some("refs/heads/origin/foo"));
982 assert_eq!(remote.short_name(), Some("origin/foo"));
983 assert_eq!(local.short_name(), Some("origin/foo"));
984 assert!(remote.is_remote());
985 assert!(!local.is_remote());
986 }
987
988 #[test]
989 fn test_branch_ref_detached_has_no_ref() {
990 // Detached HEAD has no branch name — callers fall back to commit_sha.
991 let detached = BranchRef {
992 full_ref: None,
993 commit_sha: "abc".into(),
994 worktree_path: None,
995 };
996 assert_eq!(detached.full_ref(), None);
997 assert_eq!(detached.short_name(), None);
998 assert!(!detached.is_remote());
999 }
1000
1001 #[test]
1002 fn test_branch_tracks_ref_matching() {
1003 let test = crate::testing::TestRepo::with_initial_commit();
1004 let repo = test.path();
1005
1006 // Create a branch and set its merge config to a PR ref
1007 crate::shell_exec::Cmd::new("git")
1008 .args(["branch", "pr-branch"])
1009 .current_dir(repo)
1010 .run()
1011 .unwrap();
1012 crate::shell_exec::Cmd::new("git")
1013 .args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
1014 .current_dir(repo)
1015 .run()
1016 .unwrap();
1017 crate::shell_exec::Cmd::new("git")
1018 .args(["config", "branch.pr-branch.remote", "origin"])
1019 .current_dir(repo)
1020 .run()
1021 .unwrap();
1022
1023 assert_eq!(
1024 branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", None),
1025 Some(true),
1026 );
1027 assert_eq!(
1028 branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", Some("origin")),
1029 Some(true),
1030 );
1031 }
1032
1033 #[test]
1034 fn test_branch_tracks_ref_different_ref() {
1035 let test = crate::testing::TestRepo::with_initial_commit();
1036 let repo = test.path();
1037
1038 crate::shell_exec::Cmd::new("git")
1039 .args(["branch", "pr-branch"])
1040 .current_dir(repo)
1041 .run()
1042 .unwrap();
1043 crate::shell_exec::Cmd::new("git")
1044 .args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
1045 .current_dir(repo)
1046 .run()
1047 .unwrap();
1048
1049 // Ask about a different ref — should return Some(false)
1050 assert_eq!(
1051 branch_tracks_ref(repo, "pr-branch", "refs/pull/999/head", None),
1052 Some(false),
1053 );
1054 }
1055
1056 #[test]
1057 fn test_branch_tracks_ref_wrong_remote() {
1058 let test = crate::testing::TestRepo::with_initial_commit();
1059 let repo = test.path();
1060
1061 crate::shell_exec::Cmd::new("git")
1062 .args(["branch", "pr-branch"])
1063 .current_dir(repo)
1064 .run()
1065 .unwrap();
1066 crate::shell_exec::Cmd::new("git")
1067 .args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
1068 .current_dir(repo)
1069 .run()
1070 .unwrap();
1071 crate::shell_exec::Cmd::new("git")
1072 .args(["config", "branch.pr-branch.remote", "fork"])
1073 .current_dir(repo)
1074 .run()
1075 .unwrap();
1076
1077 assert_eq!(
1078 branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", Some("origin")),
1079 Some(false),
1080 );
1081 }
1082
1083 #[test]
1084 fn test_branch_tracks_ref_no_tracking_config() {
1085 let test = crate::testing::TestRepo::with_initial_commit();
1086 let repo = test.path();
1087
1088 // Create a branch with no tracking config
1089 crate::shell_exec::Cmd::new("git")
1090 .args(["branch", "local-only"])
1091 .current_dir(repo)
1092 .run()
1093 .unwrap();
1094
1095 // Branch exists but has no merge config — Some(false)
1096 assert_eq!(
1097 branch_tracks_ref(repo, "local-only", "refs/pull/1/head", None),
1098 Some(false),
1099 );
1100 }
1101
1102 #[test]
1103 fn test_branch_tracks_ref_nonexistent_branch() {
1104 let test = crate::testing::TestRepo::with_initial_commit();
1105 let repo = test.path();
1106
1107 // Branch doesn't exist at all — None
1108 assert_eq!(
1109 branch_tracks_ref(repo, "no-such-branch", "refs/pull/1/head", None),
1110 None,
1111 );
1112 }
1113
1114 #[test]
1115 fn test_branch_tracks_ref_invalid_repo_path() {
1116 // Invalid repo path causes Cmd::run() to fail → .ok()? returns None
1117 let bad_path = std::path::Path::new("/nonexistent/repo/path");
1118 assert_eq!(
1119 branch_tracks_ref(bad_path, "main", "refs/pull/1/head", None),
1120 None,
1121 );
1122 }
1123
1124 #[test]
1125 fn test_branch_tracks_ref_mr_ref() {
1126 let test = crate::testing::TestRepo::with_initial_commit();
1127 let repo = test.path();
1128
1129 // Test with GitLab-style MR ref
1130 crate::shell_exec::Cmd::new("git")
1131 .args(["branch", "mr-branch"])
1132 .current_dir(repo)
1133 .run()
1134 .unwrap();
1135 crate::shell_exec::Cmd::new("git")
1136 .args([
1137 "config",
1138 "branch.mr-branch.merge",
1139 "refs/merge-requests/42/head",
1140 ])
1141 .current_dir(repo)
1142 .run()
1143 .unwrap();
1144 crate::shell_exec::Cmd::new("git")
1145 .args(["config", "branch.mr-branch.remote", "origin"])
1146 .current_dir(repo)
1147 .run()
1148 .unwrap();
1149
1150 assert_eq!(
1151 branch_tracks_ref(
1152 repo,
1153 "mr-branch",
1154 "refs/merge-requests/42/head",
1155 Some("origin"),
1156 ),
1157 Some(true),
1158 );
1159 assert_eq!(
1160 branch_tracks_ref(repo, "mr-branch", "refs/pull/42/head", Some("origin")),
1161 Some(false),
1162 );
1163 }
1164
1165 #[test]
1166 fn test_branch_ref_detached_head() {
1167 let wt = WorktreeInfo {
1168 path: PathBuf::from("/repo.detached"),
1169 head: "def456".into(),
1170 branch: None, // Detached HEAD
1171 bare: false,
1172 detached: true,
1173 locked: None,
1174 prunable: None,
1175 };
1176
1177 let branch_ref = BranchRef::from(&wt);
1178
1179 assert_eq!(branch_ref.full_ref(), None);
1180 assert_eq!(branch_ref.short_name(), None);
1181 assert_eq!(branch_ref.commit_sha, "def456");
1182 assert!(branch_ref.has_worktree());
1183 }
1184}