Skip to main content

worktrunk/git/
mod.rs

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