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;
11mod repository;
12mod url;
13
14#[cfg(test)]
15mod test;
16
17// Global semaphore for limiting concurrent heavy git operations
18// to reduce mmap thrash on shared commit-graph and pack files.
19//
20// Permit count of 4 was chosen based on:
21// - Typical CPU core counts (4-8 cores common on developer machines)
22// - Empirical testing showing 25.6% improvement on 4-worktree repos
23// - Balance between parallelism and mmap contention
24// - With 4 permits: operations remain fast, overall throughput improves
25//
26// Heavy operations protected:
27// - git rev-list --count (accesses commit-graph via mmap)
28// - git diff --numstat (accesses pack files and indexes via mmap)
29use crate::sync::Semaphore;
30use std::sync::LazyLock;
31static HEAVY_OPS_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(4));
32
33/// The null OID returned by git when no commits exist (e.g., `git rev-parse HEAD` on an unborn branch).
34pub const NULL_OID: &str = "0000000000000000000000000000000000000000";
35
36// Re-exports from submodules
37pub(crate) use diff::DiffStats;
38pub use diff::{LineDiff, parse_numstat_line};
39pub use error::{
40    // Structured command failure info
41    FailedCommand,
42    // Typed error enum (Display produces styled output)
43    GitError,
44    // Special-handling error enum (Display produces styled output)
45    HookErrorWithHint,
46    // Platform-specific reference type (PR vs MR)
47    RefContext,
48    RefType,
49    // CLI context for enriching switch suggestions in error hints
50    SwitchSuggestionCtx,
51    WorktrunkError,
52    // Error inspection functions
53    add_hook_skip_hint,
54    exit_code,
55};
56pub use parse::{parse_porcelain_z, parse_untracked_files};
57pub use recover::{current_or_recover, cwd_removed_hint};
58pub use repository::{Branch, Repository, ResolvedWorktree, WorkingTree, set_base_path};
59pub use url::GitRemoteUrl;
60pub use url::{parse_owner_repo, parse_remote_owner};
61/// Why branch content is considered integrated into the target branch.
62///
63/// Used by both `wt list` (for status symbols) and `wt remove` (for messages).
64/// Each variant corresponds to a specific integration check. In `wt list`,
65/// three symbols represent these checks:
66/// - `_` for [`SameCommit`](Self::SameCommit) with clean working tree (empty)
67/// - `–` for [`SameCommit`](Self::SameCommit) with dirty working tree
68/// - `⊂` for all others (content integrated via different history)
69///
70/// The checks are ordered by cost (cheapest first):
71/// 1. [`SameCommit`](Self::SameCommit) - commit SHA comparison (~1ms)
72/// 2. [`Ancestor`](Self::Ancestor) - ancestor check (~1ms)
73/// 3. [`NoAddedChanges`](Self::NoAddedChanges) - three-dot diff (~50-100ms)
74/// 4. [`TreesMatch`](Self::TreesMatch) - tree SHA comparison (~100-300ms)
75/// 5. [`MergeAddsNothing`](Self::MergeAddsNothing) - merge simulation (~500ms-2s)
76#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, strum::IntoStaticStr)]
77#[serde(rename_all = "kebab-case")]
78#[strum(serialize_all = "kebab-case")]
79pub enum IntegrationReason {
80    /// Branch HEAD is literally the same commit as target.
81    ///
82    /// Used by `wt remove` to determine if branch is safely deletable.
83    /// In `wt list`, same-commit state is shown via `MainState::Empty` (`_`) or
84    /// `MainState::SameCommit` (`–`) depending on working tree cleanliness.
85    SameCommit,
86
87    /// Branch HEAD is an ancestor of target (target has moved past this branch).
88    ///
89    /// Symbol in `wt list`: `⊂`
90    Ancestor,
91
92    /// Three-dot diff (`main...branch`) shows no files.
93    /// The branch has no file changes beyond the merge-base.
94    ///
95    /// Symbol in `wt list`: `⊂`
96    NoAddedChanges,
97
98    /// Branch tree SHA equals target tree SHA.
99    /// Commit history differs but file contents are identical.
100    ///
101    /// Symbol in `wt list`: `⊂`
102    TreesMatch,
103
104    /// Simulated merge (`git merge-tree`) produces the same tree as target.
105    /// The branch has changes, but they're already in target via a different path.
106    ///
107    /// Symbol in `wt list`: `⊂`
108    MergeAddsNothing,
109}
110
111impl IntegrationReason {
112    /// Human-readable description for use in messages (e.g., `wt remove` output).
113    ///
114    /// Returns a phrase that expects the target branch name to follow
115    /// (e.g., "same commit as" + "main" → "same commit as main").
116    pub fn description(&self) -> &'static str {
117        match self {
118            Self::SameCommit => "same commit as",
119            Self::Ancestor => "ancestor of",
120            Self::NoAddedChanges => "no added changes on",
121            Self::TreesMatch => "tree matches",
122            Self::MergeAddsNothing => "all changes in",
123        }
124    }
125
126    /// Status symbol used in `wt list` for this integration reason.
127    ///
128    /// - `SameCommit` → `_` (matches `MainState::Empty`)
129    /// - Others → `⊂` (matches `MainState::Integrated`)
130    pub fn symbol(&self) -> &'static str {
131        match self {
132            Self::SameCommit => "_",
133            _ => "⊂",
134        }
135    }
136}
137
138/// Integration signals for checking if a branch is integrated into target.
139///
140/// `None` means "unknown/failed to check". The check functions treat `None`
141/// conservatively (as if not integrated).
142///
143/// Used by:
144/// - `wt list`: Built from parallel task results
145/// - `wt remove`/`wt merge`: Built via [`compute_integration_lazy`]
146#[derive(Debug, Default)]
147pub struct IntegrationSignals {
148    pub is_same_commit: Option<bool>,
149    pub is_ancestor: Option<bool>,
150    pub has_added_changes: Option<bool>,
151    pub trees_match: Option<bool>,
152    pub would_merge_add: Option<bool>,
153}
154
155/// Canonical integration check using pre-computed signals.
156///
157/// Checks signals in priority order (cheapest first). Returns as soon as any
158/// integration reason is found.
159///
160/// `None` values are treated conservatively: unknown signals don't match.
161/// This is the single source of truth for integration priority logic.
162pub fn check_integration(signals: &IntegrationSignals) -> Option<IntegrationReason> {
163    // Priority 1 (cheapest): Same commit as target
164    if signals.is_same_commit == Some(true) {
165        return Some(IntegrationReason::SameCommit);
166    }
167
168    // Priority 2 (cheap): Branch is ancestor of target (target has moved past)
169    if signals.is_ancestor == Some(true) {
170        return Some(IntegrationReason::Ancestor);
171    }
172
173    // Priority 3: No file changes beyond merge-base (empty three-dot diff)
174    if signals.has_added_changes == Some(false) {
175        return Some(IntegrationReason::NoAddedChanges);
176    }
177
178    // Priority 4: Tree SHA matches target (handles squash merge/rebase)
179    if signals.trees_match == Some(true) {
180        return Some(IntegrationReason::TreesMatch);
181    }
182
183    // Priority 5 (most expensive ~500ms-2s): Merge would not add anything
184    if signals.would_merge_add == Some(false) {
185        return Some(IntegrationReason::MergeAddsNothing);
186    }
187
188    None
189}
190
191/// Compute integration signals lazily with short-circuit evaluation.
192///
193/// Runs git commands in priority order, stopping as soon as integration is
194/// confirmed. This avoids expensive checks (like `would_merge_add` which
195/// takes ~500ms-2s) when cheaper checks succeed.
196///
197/// Used by `wt remove` and `wt merge` for single-branch checks.
198/// For batch operations, use parallel tasks to build [`IntegrationSignals`] directly.
199#[allow(clippy::field_reassign_with_default)] // Intentional: short-circuit populates fields incrementally
200pub fn compute_integration_lazy(
201    repo: &Repository,
202    branch: &str,
203    target: &str,
204) -> anyhow::Result<IntegrationSignals> {
205    let mut signals = IntegrationSignals::default();
206
207    // Priority 1: Same commit
208    signals.is_same_commit = Some(repo.same_commit(branch, target)?);
209    if signals.is_same_commit == Some(true) {
210        return Ok(signals);
211    }
212
213    // Priority 2: Ancestor
214    signals.is_ancestor = Some(repo.is_ancestor(branch, target)?);
215    if signals.is_ancestor == Some(true) {
216        return Ok(signals);
217    }
218
219    // Priority 3: No added changes
220    signals.has_added_changes = Some(repo.has_added_changes(branch, target)?);
221    if signals.has_added_changes == Some(false) {
222        return Ok(signals);
223    }
224
225    // Priority 4: Trees match
226    signals.trees_match = Some(repo.trees_match(branch, target)?);
227    if signals.trees_match == Some(true) {
228        return Ok(signals);
229    }
230
231    // Priority 5: Would merge add (most expensive)
232    signals.would_merge_add = Some(repo.would_merge_add_to_target(branch, target)?);
233
234    Ok(signals)
235}
236
237/// Category of branch for completion display
238#[derive(Debug, Clone, PartialEq)]
239pub enum BranchCategory {
240    /// Branch has an active worktree
241    Worktree,
242    /// Local branch without worktree
243    Local,
244    /// Remote-only branch (includes remote names — multiple if same branch on multiple remotes)
245    Remote(Vec<String>),
246}
247
248/// Branch information for shell completions
249#[derive(Debug, Clone)]
250pub struct CompletionBranch {
251    /// Branch name (local name for remotes, e.g., "fix" not "origin/fix")
252    pub name: String,
253    /// Unix timestamp of last commit
254    pub timestamp: i64,
255    /// Category for sorting and display
256    pub category: BranchCategory,
257}
258
259// Re-export parsing helpers for internal use
260pub(crate) use parse::DefaultBranchName;
261
262use crate::shell_exec::Cmd;
263
264/// Check if a local branch is tracking a specific remote ref.
265///
266/// Returns `Some(true)` if the branch is configured to track the given ref.
267/// Returns `Some(false)` if the branch exists but tracks something else (or nothing).
268/// Returns `None` if the branch doesn't exist.
269///
270/// Used by PR/MR checkout to detect when a branch name collision exists.
271///
272/// TODO: This only checks `branch.<name>.merge`, not `branch.<name>.remote`. A branch
273/// could track the right ref but have the wrong remote configured, which matters for
274/// fork PRs/MRs where refs live on the target repo. Consider checking both values.
275///
276/// # Arguments
277/// * `repo_root` - Path to run git commands from
278/// * `branch` - Local branch name to check
279/// * `expected_ref` - Full ref path (e.g., `refs/pull/101/head` or `refs/merge-requests/42/head`)
280pub fn branch_tracks_ref(
281    repo_root: &std::path::Path,
282    branch: &str,
283    expected_ref: &str,
284) -> Option<bool> {
285    let config_key = format!("branch.{}.merge", branch);
286    let output = Cmd::new("git")
287        .args(["config", "--get", &config_key])
288        .current_dir(repo_root)
289        .run()
290        .ok()?;
291
292    if !output.status.success() {
293        // Config key doesn't exist - branch might not track anything
294        // Check if branch exists at all
295        let branch_exists = Cmd::new("git")
296            .args([
297                "show-ref",
298                "--verify",
299                "--quiet",
300                &format!("refs/heads/{}", branch),
301            ])
302            .current_dir(repo_root)
303            .run()
304            .map(|o| o.status.success())
305            .unwrap_or(false);
306
307        return if branch_exists { Some(false) } else { None };
308    }
309
310    let merge_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
311    Some(merge_ref == expected_ref)
312}
313
314// Note: HookType and WorktreeInfo are defined in this module and are already public.
315// They're accessible as git::HookType and git::WorktreeInfo without needing re-export.
316
317/// Hook types for git operations
318#[derive(
319    Debug,
320    Clone,
321    Copy,
322    PartialEq,
323    Eq,
324    clap::ValueEnum,
325    strum::Display,
326    strum::EnumString,
327    strum::EnumIter,
328)]
329#[strum(serialize_all = "kebab-case")]
330pub enum HookType {
331    PreSwitch,
332    PostCreate,
333    PostStart,
334    PostSwitch,
335    PreCommit,
336    PreMerge,
337    PostMerge,
338    PreRemove,
339    PostRemove,
340}
341
342/// Reference to a branch for parallel task execution.
343///
344/// Works for both worktree items (has path) and branch-only items (no worktree).
345/// The `Option<PathBuf>` makes the worktree distinction explicit instead of using
346/// empty paths as a sentinel value.
347///
348/// # Construction
349///
350/// - From a worktree: `BranchRef::from(&worktree_info)`
351/// - For a local branch: `BranchRef::local_branch("feature", "abc123")`
352/// - For a remote branch: `BranchRef::remote_branch("origin/feature", "abc123")`
353///
354/// # Working Tree Access
355///
356/// For worktree-specific operations, use [`working_tree()`](Self::working_tree)
357/// which returns `Some(WorkingTree)` only when this ref has a worktree path.
358#[derive(Debug, Clone)]
359pub struct BranchRef {
360    /// Branch name (e.g., "main", "feature/auth", "origin/feature").
361    /// None for detached HEAD.
362    pub branch: Option<String>,
363    /// Commit SHA this branch/worktree points to.
364    pub commit_sha: String,
365    /// Path to worktree, if this branch has one.
366    /// None for branch-only items (remote branches, local branches without worktrees).
367    pub worktree_path: Option<PathBuf>,
368    /// True if this is a remote-tracking ref (e.g., "origin/feature").
369    /// Remote branches inherently exist on the remote and don't need push config.
370    // TODO(full-refs): Consider refactoring to store full refs (e.g., "refs/remotes/origin/feature"
371    // or "refs/heads/feature") instead of short names + is_remote flag. Full refs are self-describing
372    // and unambiguous, but would require changes throughout the codebase and user input resolution.
373    pub is_remote: bool,
374}
375
376impl BranchRef {
377    /// Create a BranchRef for a local branch without a worktree.
378    pub fn local_branch(branch: &str, commit_sha: &str) -> Self {
379        Self {
380            branch: Some(branch.to_string()),
381            commit_sha: commit_sha.to_string(),
382            worktree_path: None,
383            is_remote: false,
384        }
385    }
386
387    /// Create a BranchRef for a remote-tracking branch.
388    ///
389    /// Remote branches (e.g., "origin/feature") are refs under refs/remotes/.
390    /// They inherently exist on the remote and don't need upstream tracking config.
391    pub fn remote_branch(branch: &str, commit_sha: &str) -> Self {
392        Self {
393            branch: Some(branch.to_string()),
394            commit_sha: commit_sha.to_string(),
395            worktree_path: None,
396            is_remote: true,
397        }
398    }
399
400    /// Get a working tree handle for this branch's worktree.
401    ///
402    /// Returns `Some(WorkingTree)` if this branch has a worktree path,
403    /// `None` for branch-only items.
404    pub fn working_tree<'a>(&self, repo: &'a Repository) -> Option<WorkingTree<'a>> {
405        self.worktree_path
406            .as_ref()
407            .map(|p| repo.worktree_at(p.clone()))
408    }
409
410    /// Returns true if this branch has a worktree.
411    pub fn has_worktree(&self) -> bool {
412        self.worktree_path.is_some()
413    }
414}
415
416impl From<&WorktreeInfo> for BranchRef {
417    fn from(wt: &WorktreeInfo) -> Self {
418        Self {
419            branch: wt.branch.clone(),
420            commit_sha: wt.head.clone(),
421            worktree_path: Some(wt.path.clone()),
422            is_remote: false, // Worktrees are always local
423        }
424    }
425}
426
427/// Parsed worktree data from `git worktree list --porcelain`.
428///
429/// This is a data record containing metadata about a worktree.
430/// For running commands in a worktree, use [`WorkingTree`] via
431/// [`Repository::worktree_at()`] or [`BranchRef::working_tree()`].
432#[derive(Debug, Clone, PartialEq, serde::Serialize)]
433pub struct WorktreeInfo {
434    pub path: PathBuf,
435    pub head: String,
436    pub branch: Option<String>,
437    pub bare: bool,
438    pub detached: bool,
439    pub locked: Option<String>,
440    pub prunable: Option<String>,
441}
442
443/// Extract the directory name from a path for display purposes.
444///
445/// Returns the last component of the path as a string, or "(unknown)" if
446/// the path has no filename or contains invalid UTF-8.
447pub fn path_dir_name(path: &std::path::Path) -> &str {
448    path.file_name()
449        .and_then(|n| n.to_str())
450        .unwrap_or("(unknown)")
451}
452
453impl WorktreeInfo {
454    /// Returns true if this worktree is prunable (directory deleted but git still tracks metadata).
455    ///
456    /// Prunable worktrees cannot be operated on - the directory doesn't exist.
457    /// Most iteration over worktrees should skip prunable ones.
458    pub fn is_prunable(&self) -> bool {
459        self.prunable.is_some()
460    }
461
462    /// Returns true if this worktree points to a real commit (not the null OID).
463    ///
464    /// Unborn branches (no commits yet) have the null OID as their HEAD.
465    pub fn has_commits(&self) -> bool {
466        self.head != NULL_OID
467    }
468
469    /// Returns the worktree directory name.
470    ///
471    /// This is the filesystem directory name (e.g., "repo.feature" from "/path/to/repo.feature").
472    /// For user-facing display with context (branch consistency, detached state),
473    /// use `worktree_display_name()` from the commands module instead.
474    pub fn dir_name(&self) -> &str {
475        path_dir_name(&self.path)
476    }
477}
478
479// Helper functions for worktree parsing
480//
481// These live in mod.rs rather than parse.rs because they bridge multiple concerns:
482// - read_rebase_branch() uses Repository (from repository.rs) to access git internals
483// - finalize_worktree() operates on WorktreeInfo (defined here in mod.rs)
484// - Both are tightly coupled to the WorktreeInfo type definition
485//
486// Placing them here avoids circular dependencies and keeps them close to WorktreeInfo.
487
488/// Helper function to read rebase branch information
489fn read_rebase_branch(worktree_path: &PathBuf) -> Option<String> {
490    let repo = Repository::current().ok()?;
491    let git_dir = repo.worktree_at(worktree_path).git_dir().ok()?;
492
493    // Check both rebase-merge and rebase-apply
494    for rebase_dir in ["rebase-merge", "rebase-apply"] {
495        let head_name_path = git_dir.join(rebase_dir).join("head-name");
496        if let Ok(content) = std::fs::read_to_string(head_name_path) {
497            let branch_ref = content.trim();
498            // Strip refs/heads/ prefix if present
499            let branch = branch_ref
500                .strip_prefix("refs/heads/")
501                .unwrap_or(branch_ref)
502                .to_string();
503            return Some(branch);
504        }
505    }
506
507    None
508}
509
510/// Finalize a worktree after parsing, filling in branch name from rebase state if needed.
511pub(crate) fn finalize_worktree(mut wt: WorktreeInfo) -> WorktreeInfo {
512    // If detached but no branch, check if we're rebasing
513    if wt.detached
514        && wt.branch.is_none()
515        && let Some(branch) = read_rebase_branch(&wt.path)
516    {
517        wt.branch = Some(branch);
518    }
519    wt
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn test_check_integration() {
528        // Each integration reason + not integrated
529        // Tuple: (is_same_commit, is_ancestor, has_added_changes, trees_match, would_merge_add)
530        let cases = [
531            (
532                (Some(true), Some(false), Some(true), Some(false), Some(true)),
533                Some(IntegrationReason::SameCommit),
534            ),
535            (
536                (Some(false), Some(true), Some(true), Some(false), Some(true)),
537                Some(IntegrationReason::Ancestor),
538            ),
539            (
540                (
541                    Some(false),
542                    Some(false),
543                    Some(false),
544                    Some(false),
545                    Some(true),
546                ),
547                Some(IntegrationReason::NoAddedChanges),
548            ),
549            (
550                (Some(false), Some(false), Some(true), Some(true), Some(true)),
551                Some(IntegrationReason::TreesMatch),
552            ),
553            (
554                (
555                    Some(false),
556                    Some(false),
557                    Some(true),
558                    Some(false),
559                    Some(false),
560                ),
561                Some(IntegrationReason::MergeAddsNothing),
562            ),
563            (
564                (
565                    Some(false),
566                    Some(false),
567                    Some(true),
568                    Some(false),
569                    Some(true),
570                ),
571                None,
572            ), // Not integrated
573            (
574                (Some(true), Some(true), Some(false), Some(true), Some(false)),
575                Some(IntegrationReason::SameCommit),
576            ), // Priority test: is_same_commit wins
577            // None values are treated conservatively (as if not integrated)
578            ((None, None, None, None, None), None),
579            (
580                (None, Some(true), Some(false), Some(true), Some(false)),
581                Some(IntegrationReason::Ancestor),
582            ),
583        ];
584        for ((same, ancestor, added, trees, merge), expected) in cases {
585            let signals = IntegrationSignals {
586                is_same_commit: same,
587                is_ancestor: ancestor,
588                has_added_changes: added,
589                trees_match: trees,
590                would_merge_add: merge,
591            };
592            assert_eq!(
593                check_integration(&signals),
594                expected,
595                "case: {same:?},{ancestor:?},{added:?},{trees:?},{merge:?}"
596            );
597        }
598    }
599
600    #[test]
601    fn test_integration_reason_description() {
602        assert_eq!(
603            IntegrationReason::SameCommit.description(),
604            "same commit as"
605        );
606        assert_eq!(IntegrationReason::Ancestor.description(), "ancestor of");
607        assert_eq!(
608            IntegrationReason::NoAddedChanges.description(),
609            "no added changes on"
610        );
611        assert_eq!(IntegrationReason::TreesMatch.description(), "tree matches");
612        assert_eq!(
613            IntegrationReason::MergeAddsNothing.description(),
614            "all changes in"
615        );
616    }
617
618    #[test]
619    fn test_path_dir_name() {
620        assert_eq!(
621            path_dir_name(&PathBuf::from("/home/user/repo.feature")),
622            "repo.feature"
623        );
624        assert_eq!(path_dir_name(&PathBuf::from("/")), "(unknown)");
625        assert!(!path_dir_name(&PathBuf::from("/home/user/repo/")).is_empty());
626
627        // WorktreeInfo::dir_name
628        let wt = WorktreeInfo {
629            path: PathBuf::from("/repos/myrepo.feature"),
630            head: "abc123".into(),
631            branch: Some("feature".into()),
632            bare: false,
633            detached: false,
634            locked: None,
635            prunable: None,
636        };
637        assert_eq!(wt.dir_name(), "myrepo.feature");
638    }
639
640    #[test]
641    fn test_hook_type_display() {
642        use strum::IntoEnumIterator;
643
644        // Verify all hook types serialize to kebab-case
645        for hook in HookType::iter() {
646            let display = format!("{hook}");
647            assert!(
648                display.chars().all(|c| c.is_lowercase() || c == '-'),
649                "Hook {hook:?} should be kebab-case, got: {display}"
650            );
651        }
652    }
653
654    #[test]
655    fn test_branch_ref_from_worktree_info() {
656        let wt = WorktreeInfo {
657            path: PathBuf::from("/repo.feature"),
658            head: "abc123".into(),
659            branch: Some("feature".into()),
660            bare: false,
661            detached: false,
662            locked: None,
663            prunable: None,
664        };
665
666        let branch_ref = BranchRef::from(&wt);
667
668        assert_eq!(branch_ref.branch, Some("feature".to_string()));
669        assert_eq!(branch_ref.commit_sha, "abc123");
670        assert_eq!(
671            branch_ref.worktree_path,
672            Some(PathBuf::from("/repo.feature"))
673        );
674        assert!(branch_ref.has_worktree());
675        assert!(!branch_ref.is_remote); // Worktrees are always local
676    }
677
678    #[test]
679    fn test_branch_ref_local_branch() {
680        let branch_ref = BranchRef::local_branch("feature", "abc123");
681
682        assert_eq!(branch_ref.branch, Some("feature".to_string()));
683        assert_eq!(branch_ref.commit_sha, "abc123");
684        assert_eq!(branch_ref.worktree_path, None);
685        assert!(!branch_ref.has_worktree());
686        assert!(!branch_ref.is_remote);
687    }
688
689    #[test]
690    fn test_branch_ref_remote_branch() {
691        let branch_ref = BranchRef::remote_branch("origin/feature", "abc123");
692
693        assert_eq!(branch_ref.branch, Some("origin/feature".to_string()));
694        assert_eq!(branch_ref.commit_sha, "abc123");
695        assert_eq!(branch_ref.worktree_path, None);
696        assert!(!branch_ref.has_worktree());
697        assert!(branch_ref.is_remote);
698    }
699
700    #[test]
701    fn test_branch_ref_detached_head() {
702        let wt = WorktreeInfo {
703            path: PathBuf::from("/repo.detached"),
704            head: "def456".into(),
705            branch: None, // Detached HEAD
706            bare: false,
707            detached: true,
708            locked: None,
709            prunable: None,
710        };
711
712        let branch_ref = BranchRef::from(&wt);
713
714        assert_eq!(branch_ref.branch, None);
715        assert_eq!(branch_ref.commit_sha, "def456");
716        assert!(branch_ref.has_worktree());
717    }
718}