Skip to main content

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}