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}