workon/worktree.rs
1//! Worktree descriptor and metadata access.
2//!
3//! This module provides the core `WorktreeDescriptor` type that wraps git2's `Worktree`
4//! and exposes rich metadata about worktree state.
5//!
6//! ## Completed Metadata Methods
7//!
8//! The following metadata is fully implemented and working:
9//! - **Basic info**: `name()`, `path()`, `branch()`
10//! - **State detection**: `is_detached()`, `is_dirty()`, `is_valid()`, `is_locked()`
11//! - **Remote tracking**: `remote()`, `remote_branch()`, `remote_url()`, `remote_fetch_url()`, `remote_push_url()`
12//! - **Commit info**: `head_commit()`
13//! - **Status checks**: `has_unpushed_commits()`, `is_behind_upstream()`, `has_gone_upstream()`, `is_merged_into()`
14//!
15//! These methods enable status filtering (`--dirty`, `--ahead`, `--behind`, `--gone`) and
16//! interactive display with status indicators.
17//!
18//! ## Branch Types
19//!
20//! Supports three branch types for worktree creation:
21//! - **Normal**: Standard branch, tracks existing or creates from HEAD
22//! - **Orphan**: Independent history with initial empty commit (for documentation, gh-pages, etc.)
23//! - **Detached**: Detached HEAD state (for exploring specific commits)
24//!
25//! - **Activity tracking**: `last_activity()`, `is_stale()`
26//!
27//! ## Future Extensions
28//!
29//! Planned metadata methods for smart worktree management:
30//!
31//! TODO: Add worktree notes/descriptions support
32//! - Store user-provided notes/context for worktrees
33//! - Help remember why a worktree was created
34//! - Storage strategy TBD (git notes, config, or metadata file)
35
36use std::{fmt, fs::create_dir_all, path::Path};
37
38use git2::WorktreeAddOptions;
39use git2::{Repository, Worktree};
40use log::debug;
41
42use crate::error::{Result, WorktreeError};
43use crate::workon_root;
44
45/// Type of branch to create for a new worktree
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum BranchType {
48 /// Normal branch - track existing or create from HEAD
49 #[default]
50 Normal,
51 /// Orphan branch - independent history with initial empty commit
52 Orphan,
53 /// Detached HEAD
54 Detached,
55}
56
57/// A handle to a git worktree with rich metadata access.
58///
59/// Wraps a [`git2::Worktree`] and exposes branch state, remote tracking info,
60/// commit history, and status checks used by the CLI commands.
61pub struct WorktreeDescriptor {
62 worktree: Worktree,
63}
64
65impl WorktreeDescriptor {
66 /// Open a worktree by name within the given repository.
67 pub fn new(repo: &Repository, name: &str) -> Result<Self> {
68 Ok(Self {
69 worktree: repo.find_worktree(name)?,
70 })
71 }
72
73 /// Wrap an existing [`git2::Worktree`] without a repository lookup.
74 pub fn of(worktree: Worktree) -> Self {
75 Self { worktree }
76 }
77
78 /// Returns the name of the worktree, or `None` if the name is invalid UTF-8.
79 pub fn name(&self) -> Option<&str> {
80 self.worktree.name().ok().flatten()
81 }
82
83 /// Returns the filesystem path to the worktree's working directory.
84 pub fn path(&self) -> &Path {
85 self.worktree.path()
86 }
87
88 /// Returns the branch name if the worktree is on a branch, or None if detached.
89 ///
90 /// This reads the HEAD file from the worktree's git directory to determine
91 /// if HEAD points to a branch reference or directly to a commit SHA.
92 pub fn branch(&self) -> Result<Option<String>> {
93 use std::fs;
94
95 // Get the path to the worktree's HEAD file
96 let git_dir = self.worktree.path().join(".git");
97 let head_path = if git_dir.is_file() {
98 // Linked worktree - read .git file to find actual git directory
99 let git_file_content = fs::read_to_string(&git_dir)?;
100 let git_dir_path = git_file_content
101 .strip_prefix("gitdir: ")
102 .and_then(|s| s.trim().strip_suffix('\n').or(Some(s.trim())))
103 .ok_or(WorktreeError::InvalidGitFile)?;
104 Path::new(git_dir_path).join("HEAD")
105 } else {
106 // Main worktree
107 git_dir.join("HEAD")
108 };
109
110 let head_content = fs::read_to_string(&head_path)?;
111
112 // HEAD file contains either:
113 // - "ref: refs/heads/branch-name" for a branch
114 // - A direct SHA for detached HEAD
115 if let Some(ref_line) = head_content.strip_prefix("ref: ") {
116 let ref_name = ref_line.trim();
117 Ok(ref_name.strip_prefix("refs/heads/").map(|s| s.to_string()))
118 } else {
119 // Direct SHA - detached HEAD
120 Ok(None)
121 }
122 }
123
124 /// Returns true if the worktree has a detached HEAD (not on a branch).
125 pub fn is_detached(&self) -> Result<bool> {
126 Ok(self.branch()?.is_none())
127 }
128
129 /// Returns true if the worktree has uncommitted changes (dirty working tree).
130 ///
131 /// This includes:
132 /// - Modified files (staged or unstaged)
133 /// - New untracked files
134 /// - Deleted files
135 pub fn is_dirty(&self) -> Result<bool> {
136 let repo = Repository::open(self.path())?;
137 let statuses = repo.statuses(None)?;
138 Ok(!statuses.is_empty())
139 }
140
141 /// Returns true if the worktree has a lock file.
142 ///
143 /// Locked worktrees are protected from pruning unless `--include-locked`
144 /// or `--force` is used.
145 pub fn is_locked(&self) -> Result<bool> {
146 Ok(!matches!(
147 self.worktree.is_locked()?,
148 git2::WorktreeLockStatus::Unlocked
149 ))
150 }
151
152 /// Returns true if the worktree's path and git metadata are intact.
153 ///
154 /// A worktree is invalid if its directory is missing or its git
155 /// metadata is broken.
156 pub fn is_valid(&self) -> bool {
157 self.worktree.validate().is_ok()
158 }
159
160 /// Returns true if the worktree has uncommitted changes to tracked files.
161 ///
162 /// Unlike `is_dirty()`, this excludes untracked files. Use this when
163 /// untracked files should not block an operation (e.g. pruning a worktree
164 /// whose remote branch is gone).
165 pub fn has_tracked_changes(&self) -> Result<bool> {
166 let repo = Repository::open(self.path())?;
167 let mut opts = git2::StatusOptions::new();
168 opts.include_untracked(false);
169 let statuses = repo.statuses(Some(&mut opts))?;
170 Ok(!statuses.is_empty())
171 }
172
173 /// Returns true if the worktree's branch has unpushed commits (ahead of upstream).
174 ///
175 /// Returns false if:
176 /// - The worktree is detached (no branch)
177 /// - The branch has no upstream configured
178 /// - The branch is up to date with upstream
179 ///
180 /// Returns true if:
181 /// - The branch has commits ahead of its upstream
182 /// - The upstream is configured but the remote reference is gone (conservative)
183 pub fn has_unpushed_commits(&self) -> Result<bool> {
184 // Get the branch name - return false if detached
185 let branch_name = match self.branch()? {
186 Some(name) => name,
187 None => return Ok(false), // Detached HEAD, no branch to check
188 };
189
190 // Open the repository (use the bare repo, not the worktree)
191 let repo = Repository::open(self.path())?;
192
193 // Find the local branch
194 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
195 Ok(b) => b,
196 Err(_) => return Ok(false), // Branch doesn't exist
197 };
198
199 // Check if upstream is configured via git config
200 let config = repo.config()?;
201 let remote_key = format!("branch.{}.remote", branch_name);
202
203 // If no upstream is configured, there can't be unpushed commits
204 let _remote = match config.get_string(&remote_key) {
205 Ok(r) => r,
206 Err(_) => return Ok(false), // No remote configured
207 };
208
209 // Get the upstream branch
210 let upstream = match branch.upstream() {
211 Ok(u) => u,
212 Err(_) => {
213 // Upstream is configured but ref is gone - conservatively assume unpushed
214 return Ok(true);
215 }
216 };
217
218 // Get the local and upstream commit OIDs
219 let local_oid = branch
220 .get()
221 .target()
222 .ok_or(WorktreeError::NoLocalBranchTarget)?;
223 let upstream_oid = upstream
224 .get()
225 .target()
226 .ok_or(WorktreeError::NoBranchTarget)?;
227
228 // Check if local is ahead of upstream
229 let (ahead, _behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
230
231 Ok(ahead > 0)
232 }
233
234 /// Returns true if the worktree's branch is behind its upstream.
235 ///
236 /// Returns false if:
237 /// - The worktree is detached (no branch)
238 /// - The branch has no upstream configured
239 /// - The branch is up to date with upstream
240 /// - The upstream is configured but the remote reference is gone
241 ///
242 /// Returns true if:
243 /// - The branch has commits behind its upstream
244 pub fn is_behind_upstream(&self) -> Result<bool> {
245 // Get the branch name - return false if detached
246 let branch_name = match self.branch()? {
247 Some(name) => name,
248 None => return Ok(false), // Detached HEAD, no branch to check
249 };
250
251 // Open the repository
252 let repo = Repository::open(self.path())?;
253
254 // Find the local branch
255 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
256 Ok(b) => b,
257 Err(_) => return Ok(false), // Branch doesn't exist
258 };
259
260 // Check if upstream is configured via git config
261 let config = repo.config()?;
262 let remote_key = format!("branch.{}.remote", branch_name);
263
264 // If no upstream is configured, can't be behind
265 let _remote = match config.get_string(&remote_key) {
266 Ok(r) => r,
267 Err(_) => return Ok(false), // No remote configured
268 };
269
270 // Get the upstream branch
271 let upstream = match branch.upstream() {
272 Ok(u) => u,
273 Err(_) => {
274 // Upstream is configured but ref is gone - can't be behind non-existent branch
275 return Ok(false);
276 }
277 };
278
279 // Get the local and upstream commit OIDs
280 let local_oid = branch
281 .get()
282 .target()
283 .ok_or(WorktreeError::NoLocalBranchTarget)?;
284 let upstream_oid = upstream
285 .get()
286 .target()
287 .ok_or(WorktreeError::NoBranchTarget)?;
288
289 // Check if local is behind upstream
290 let (_ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
291
292 Ok(behind > 0)
293 }
294
295 /// Returns true if the worktree's upstream branch reference is gone (deleted on remote).
296 ///
297 /// Returns false if:
298 /// - The worktree is detached (no branch)
299 /// - The branch has no upstream configured
300 /// - The upstream branch reference exists
301 ///
302 /// Returns true if:
303 /// - Upstream is configured (branch.{name}.remote exists in config)
304 /// - But the upstream branch reference cannot be found
305 pub fn has_gone_upstream(&self) -> Result<bool> {
306 // Get the branch name - return false if detached
307 let branch_name = match self.branch()? {
308 Some(name) => name,
309 None => return Ok(false), // Detached HEAD, no branch to check
310 };
311
312 // Open the repository
313 let repo = Repository::open(self.path())?;
314
315 // Find the local branch
316 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
317 Ok(b) => b,
318 Err(_) => return Ok(false), // Branch doesn't exist
319 };
320
321 // Check if upstream is configured via git config
322 let config = repo.config()?;
323 let remote_key = format!("branch.{}.remote", branch_name);
324
325 // If no upstream is configured, it's not "gone"
326 match config.get_string(&remote_key) {
327 Ok(_) => {
328 // Upstream is configured - check if the reference exists
329 match branch.upstream() {
330 Ok(_) => Ok(false), // Upstream exists
331 Err(_) => Ok(true), // Upstream configured but ref is gone
332 }
333 }
334 Err(_) => Ok(false), // No upstream configured
335 }
336 }
337
338 /// Returns true if the worktree's branch has been merged into the target branch.
339 ///
340 /// A branch is considered merged if its HEAD commit is reachable from the target branch,
341 /// meaning all commits in this branch exist in the target branch's history.
342 ///
343 /// Returns false if:
344 /// - The worktree is detached (no branch)
345 /// - The target branch doesn't exist
346 /// - The branch has commits not in the target branch
347 ///
348 /// Returns true if:
349 /// - All commits in this branch are reachable from the target branch
350 pub fn is_merged_into(&self, target_branch: &str) -> Result<bool> {
351 // Get the branch name - return false if detached
352 let branch_name = match self.branch()? {
353 Some(name) => name,
354 None => return Ok(false), // Detached HEAD, no branch to check
355 };
356
357 // Don't consider the target branch as merged into itself
358 if branch_name == target_branch {
359 return Ok(false);
360 }
361
362 // Open the bare repository (not the worktree) to check actual branch states
363 // The worktree's .git points to the commondir (bare repo)
364 let worktree_repo = Repository::open(self.path())?;
365 let commondir = worktree_repo.commondir();
366 let repo = Repository::open(commondir)?;
367
368 // Find the current branch
369 let current_branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
370 Ok(b) => b,
371 Err(_) => return Ok(false), // Branch doesn't exist
372 };
373
374 // Find the target branch
375 let target = match repo.find_branch(target_branch, git2::BranchType::Local) {
376 Ok(b) => b,
377 Err(_) => return Ok(false), // Target branch doesn't exist
378 };
379
380 // Get commit OIDs
381 let current_oid = current_branch
382 .get()
383 .target()
384 .ok_or(WorktreeError::NoCurrentBranchTarget)?;
385 let target_oid = target.get().target().ok_or(WorktreeError::NoBranchTarget)?;
386
387 // If they point to the same commit, the branch is merged
388 if current_oid == target_oid {
389 return Ok(true);
390 }
391
392 // Check if current branch's commit is reachable from target
393 // This means target is a descendant of (or equal to) current
394 Ok(repo.graph_descendant_of(target_oid, current_oid)?)
395 }
396
397 /// Returns the commit hash (SHA) of the worktree's current HEAD.
398 ///
399 /// Returns None if HEAD cannot be resolved (e.g., empty repository).
400 pub fn head_commit(&self) -> Result<Option<String>> {
401 let repo = Repository::open(self.path())?;
402
403 // Try to resolve HEAD to a commit and extract the OID immediately
404 let commit_oid = match repo.head() {
405 Ok(head) => match head.peel_to_commit() {
406 Ok(commit) => Some(commit.id()),
407 Err(_) => return Ok(None), // HEAD exists but can't resolve to commit
408 },
409 Err(_) => return Ok(None), // No HEAD (unborn branch)
410 };
411
412 Ok(commit_oid.map(|oid| oid.to_string()))
413 }
414
415 /// Returns the timestamp of the HEAD commit as the last activity time.
416 ///
417 /// Returns None if:
418 /// - HEAD cannot be resolved (empty/unborn repository)
419 /// - HEAD cannot be peeled to a commit
420 pub fn last_activity(&self) -> Result<Option<i64>> {
421 let repo = Repository::open(self.path())?;
422 let seconds = match repo.head() {
423 Ok(head) => match head.peel_to_commit() {
424 Ok(commit) => Some(commit.time().seconds()),
425 Err(_) => None,
426 },
427 Err(_) => None,
428 };
429 Ok(seconds)
430 }
431
432 /// Returns true if the worktree's last activity is older than `days` days.
433 ///
434 /// Returns false if:
435 /// - Last activity cannot be determined
436 /// - The worktree has recent activity within the threshold
437 pub fn is_stale(&self, days: u32) -> Result<bool> {
438 let last = match self.last_activity()? {
439 Some(ts) => ts,
440 None => return Ok(false),
441 };
442 let now = std::time::SystemTime::now()
443 .duration_since(std::time::UNIX_EPOCH)
444 .map_err(std::io::Error::other)?
445 .as_secs() as i64;
446 let threshold = i64::from(days) * 86400;
447 Ok((now - last) > threshold)
448 }
449
450 /// Returns the name of the remote that the worktree's branch tracks (e.g., "origin").
451 ///
452 /// Returns None if:
453 /// - The worktree is detached (no branch)
454 /// - The branch has no upstream configured
455 pub fn remote(&self) -> Result<Option<String>> {
456 // Get the branch name - return None if detached
457 let branch_name = match self.branch()? {
458 Some(name) => name,
459 None => return Ok(None), // Detached HEAD, no branch to check
460 };
461
462 let repo = Repository::open(self.path())?;
463 let config = repo.config()?;
464
465 // Check for branch.<name>.remote in git config
466 let remote_key = format!("branch.{}.remote", branch_name);
467 match config.get_string(&remote_key) {
468 Ok(remote) => Ok(Some(remote)),
469 Err(_) => Ok(None), // No remote configured
470 }
471 }
472
473 /// Returns the full name of the upstream remote branch (e.g., "refs/remotes/origin/main").
474 ///
475 /// Returns None if:
476 /// - The worktree is detached (no branch)
477 /// - The branch has no upstream configured
478 pub fn remote_branch(&self) -> Result<Option<String>> {
479 // Get the branch name - return None if detached
480 let branch_name = match self.branch()? {
481 Some(name) => name,
482 None => return Ok(None), // Detached HEAD, no branch to check
483 };
484
485 let repo = Repository::open(self.path())?;
486
487 // Find the local branch and get its upstream, extracting the name immediately
488 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
489 Ok(b) => b,
490 Err(_) => return Ok(None), // Branch doesn't exist
491 };
492
493 let upstream_name = match branch.upstream() {
494 Ok(upstream) => match upstream.name() {
495 Ok(Some(name)) => Some(name.to_string()),
496 _ => None,
497 },
498 Err(_) => return Ok(None), // No upstream configured
499 };
500
501 Ok(upstream_name)
502 }
503
504 /// Returns the default URL for the remote (usually the fetch URL).
505 ///
506 /// Returns None if:
507 /// - The worktree is detached (no branch)
508 /// - The branch has no upstream configured
509 /// - The remote has no URL configured
510 pub fn remote_url(&self) -> Result<Option<String>> {
511 // Get the remote name
512 let remote_name = match self.remote()? {
513 Some(name) => name,
514 None => return Ok(None),
515 };
516
517 let repo = Repository::open(self.path())?;
518
519 // Find the remote and extract the URL immediately
520 let url = match repo.find_remote(&remote_name) {
521 Ok(remote) => remote.url().ok().map(|s| s.to_string()),
522 Err(_) => return Ok(None), // Remote doesn't exist
523 };
524
525 Ok(url)
526 }
527
528 /// Returns the fetch URL for the remote.
529 ///
530 /// Returns None if:
531 /// - The worktree is detached (no branch)
532 /// - The branch has no upstream configured
533 /// - The remote has no fetch URL configured
534 pub fn remote_fetch_url(&self) -> Result<Option<String>> {
535 // Get the remote name
536 let remote_name = match self.remote()? {
537 Some(name) => name,
538 None => return Ok(None),
539 };
540
541 let repo = Repository::open(self.path())?;
542
543 // Find the remote and extract the fetch URL immediately
544 let url = match repo.find_remote(&remote_name) {
545 Ok(remote) => remote.url().ok().map(|s| s.to_string()),
546 Err(_) => return Ok(None), // Remote doesn't exist
547 };
548
549 Ok(url)
550 }
551
552 /// Returns the push URL for the remote.
553 ///
554 /// Returns None if:
555 /// - The worktree is detached (no branch)
556 /// - The branch has no upstream configured
557 /// - The remote has no push URL configured (falls back to fetch URL)
558 pub fn remote_push_url(&self) -> Result<Option<String>> {
559 // Get the remote name
560 let remote_name = match self.remote()? {
561 Some(name) => name,
562 None => return Ok(None),
563 };
564
565 let repo = Repository::open(self.path())?;
566
567 // Find the remote and extract the push URL (or fallback to fetch URL) immediately
568 let url = match repo.find_remote(&remote_name) {
569 Ok(remote) => remote
570 .pushurl()
571 .ok()
572 .flatten()
573 .or_else(|| remote.url().ok())
574 .map(|s| s.to_string()),
575 Err(_) => return Ok(None), // Remote doesn't exist
576 };
577
578 Ok(url)
579 }
580}
581
582impl fmt::Debug for WorktreeDescriptor {
583 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
584 write!(f, "WorktreeDescriptor({:?})", self.worktree.path())
585 }
586}
587
588impl fmt::Display for WorktreeDescriptor {
589 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
590 write!(f, "{}", self.worktree.path().display())
591 }
592}
593
594/// Return all worktrees registered with the repository.
595pub fn get_worktrees(repo: &Repository) -> Result<Vec<WorktreeDescriptor>> {
596 repo.worktrees()?
597 .into_iter()
598 .map(|name| {
599 let name = name?.ok_or(WorktreeError::InvalidName)?;
600 WorktreeDescriptor::new(repo, name)
601 })
602 .collect()
603}
604
605/// Return the worktree that contains the current working directory.
606///
607/// Returns [`WorktreeError::NotInWorktree`] if the current directory is not
608/// inside any registered worktree.
609pub fn current_worktree(repo: &Repository) -> Result<WorktreeDescriptor> {
610 let current_dir = std::env::current_dir().map_err(std::io::Error::other)?;
611
612 let worktrees = get_worktrees(repo)?;
613 worktrees
614 .into_iter()
615 .find(|wt| current_dir.starts_with(wt.path()))
616 .ok_or_else(|| WorktreeError::NotInWorktree.into())
617}
618
619/// Find a worktree by its name or by its branch name.
620///
621/// Returns [`WorktreeError::NotFound`] if no matching worktree exists.
622pub fn find_worktree(repo: &Repository, name: &str) -> Result<WorktreeDescriptor> {
623 let worktrees = get_worktrees(repo)?;
624 worktrees
625 .into_iter()
626 .find(|wt| {
627 // Match by worktree name or branch name
628 wt.name() == Some(name) || wt.branch().ok().flatten().as_deref() == Some(name)
629 })
630 .ok_or_else(|| WorktreeError::NotFound(name.to_string()).into())
631}
632
633/// Find the worktree whose checked-out branch is `branch`.
634///
635/// Unlike [`find_worktree`] this never matches by worktree name: after an
636/// in-place checkout a stack-home worktree's name routinely diverges from its
637/// branch, and a name match would treat "a worktree once created for T" as
638/// "T is checked out" — wrong for resolution decisions.
639///
640/// Returns [`WorktreeError::NotFound`] if no worktree has `branch` checked out.
641pub fn find_worktree_by_branch(repo: &Repository, branch: &str) -> Result<WorktreeDescriptor> {
642 let worktrees = get_worktrees(repo)?;
643 worktrees
644 .into_iter()
645 .find(|wt| wt.branch().ok().flatten().as_deref() == Some(branch))
646 .ok_or_else(|| WorktreeError::NotFound(branch.to_string()).into())
647}
648
649/// Result of looking up which remote carries a branch.
650pub enum RemoteResolution {
651 /// Exactly one remote (or a clear winner by priority) found.
652 Single { remote: String, oid: git2::Oid },
653 /// Two or more equally-preferred remotes carry the branch — user must choose.
654 Ambiguous(Vec<String>),
655 /// No remote has the branch.
656 None,
657}
658
659/// Find which remote(s) carry a branch, ranked by the shared
660/// [`remote_priority`](crate::pr::remote_priority) precedence
661/// (`upstream → origin → others`).
662///
663/// Returns `Ambiguous` when two or more equally-preferred remotes both carry
664/// the branch.
665pub fn resolve_remote_tracking(repo: &Repository, branch_name: &str) -> RemoteResolution {
666 let branches = match repo.branches(Some(git2::BranchType::Remote)) {
667 Ok(b) => b,
668 Err(_) => return RemoteResolution::None,
669 };
670
671 let mut candidates: Vec<(String, git2::Oid)> = branches
672 .flatten()
673 .filter_map(|(branch, _)| {
674 let name = branch.name().ok()??;
675 let (remote, br) = name.split_once('/')?;
676 if br != branch_name {
677 return None;
678 }
679 Some((remote.to_string(), branch.get().target()?))
680 })
681 .collect();
682
683 if candidates.is_empty() {
684 return RemoteResolution::None;
685 }
686
687 candidates.sort_by_key(|(r, _)| crate::pr::remote_priority(r));
688
689 if candidates.len() >= 2
690 && crate::pr::remote_priority(&candidates[0].0)
691 == crate::pr::remote_priority(&candidates[1].0)
692 {
693 return RemoteResolution::Ambiguous(candidates.into_iter().map(|(r, _)| r).collect());
694 }
695
696 let (remote, oid) = candidates.remove(0);
697 RemoteResolution::Single { remote, oid }
698}
699
700/// Create local branch `branch` from `remote`'s tracking ref and set its upstream.
701///
702/// The single place where a local branch is materialized from a remote tracking
703/// branch — keeping creation and upstream wiring together so no caller can get
704/// one without the other. Used by [`add_worktree`] when it resolves the remote
705/// itself, and by callers that resolved an ambiguous remote (e.g. by prompting).
706pub fn create_branch_from_remote(repo: &Repository, branch: &str, remote: &str) -> Result<()> {
707 let remote_ref = repo.find_reference(&format!("refs/remotes/{}/{}", remote, branch))?;
708 let commit = remote_ref.peel_to_commit()?;
709 let mut local_branch = repo.branch(branch, &commit, false)?;
710 local_branch.set_upstream(Some(&format!("{}/{}", remote, branch)))?;
711 Ok(())
712}
713
714/// Create a new worktree for the given branch.
715///
716/// The worktree directory is placed under the workon root (see [`workon_root`]).
717/// Branch names containing `/` are supported; parent directories are created
718/// automatically and the worktree is named after the final path component.
719///
720/// When `explicit_worktree_name` is `Some`, that value is used as the worktree
721/// directory name and filesystem path instead of deriving it from `branch_name`.
722/// This allows the worktree directory and the branch to have different names.
723///
724/// # Branch types
725///
726/// - [`BranchType::Normal`] — uses an existing local branch, creates a local branch
727/// from a matching remote tracking branch (setting upstream automatically), or
728/// creates a new branch from `base_branch` (or HEAD if `base_branch` is `None`).
729/// - [`BranchType::Orphan`] — creates an independent branch with no shared history,
730/// seeded with an empty initial commit.
731/// - [`BranchType::Detached`] — creates a worktree with a detached HEAD pointing to
732/// the current HEAD commit.
733pub fn add_worktree(
734 repo: &Repository,
735 branch_name: &str,
736 explicit_worktree_name: Option<&str>,
737 branch_type: BranchType,
738 base_branch: Option<&str>,
739 lock: bool,
740) -> Result<WorktreeDescriptor> {
741 // git worktree add <branch>
742 debug!(
743 "adding worktree for branch {:?} with type: {:?}",
744 branch_name, branch_type
745 );
746
747 let reference = match branch_type {
748 BranchType::Orphan => {
749 debug!("creating orphan branch {:?}", branch_name);
750 // For orphan branches, we'll create the branch after the worktree
751 None
752 }
753 BranchType::Detached => {
754 debug!("creating detached HEAD worktree at {:?}", branch_name);
755 // For detached worktrees, we don't create or use a branch reference
756 None
757 }
758 BranchType::Normal => {
759 let branch = match repo.find_branch(branch_name, git2::BranchType::Local) {
760 Ok(b) => b,
761 Err(e) => {
762 debug!("local branch not found: {:?}", e);
763 debug!("looking for remote tracking branch for {:?}", branch_name);
764 match resolve_remote_tracking(repo, branch_name) {
765 RemoteResolution::Single {
766 remote: remote_name,
767 ..
768 } => {
769 debug!(
770 "found remote tracking branch {}/{}, creating local branch",
771 remote_name, branch_name
772 );
773 create_branch_from_remote(repo, branch_name, &remote_name)?;
774 repo.find_branch(branch_name, git2::BranchType::Local)?
775 }
776 RemoteResolution::Ambiguous(_) | RemoteResolution::None => {
777 debug!(
778 "no remote tracking branch found, creating new local branch {:?}",
779 branch_name
780 );
781
782 // Determine which commit to branch from
783 let base_commit = if let Some(base) = base_branch {
784 // Branch from specified base branch
785 debug!("branching from base branch {:?}", base);
786 // Try local branch first, then remote branch
787 let base_branch =
788 match repo.find_branch(base, git2::BranchType::Local) {
789 Ok(b) => b,
790 Err(_) => {
791 debug!("base branch not found as local, trying remote");
792 repo.find_branch(base, git2::BranchType::Remote)?
793 }
794 };
795 base_branch.into_reference().peel_to_commit()?
796 } else {
797 // Default: branch from HEAD
798 repo.head()?.peel_to_commit()?
799 };
800
801 repo.branch(branch_name, &base_commit, false)?
802 }
803 }
804 }
805 };
806
807 Some(branch.into_reference())
808 }
809 };
810
811 let root = workon_root(repo)?;
812
813 // Determine worktree name and path.
814 // When an explicit name is provided, use it directly.
815 // Otherwise, derive from branch_name: git does not support worktree names with
816 // slashes, so take the basename of the branch name as the worktree name.
817 let (worktree_name, worktree_path) = if let Some(alias) = explicit_worktree_name {
818 let name = match Path::new(alias).file_name() {
819 Some(basename) => basename.to_str().ok_or(WorktreeError::InvalidName)?,
820 None => alias,
821 };
822 (name, root.join(alias))
823 } else {
824 let name = match Path::new(&branch_name).file_name() {
825 Some(basename) => basename.to_str().ok_or(WorktreeError::InvalidName)?,
826 None => branch_name,
827 };
828 (name, root.join(branch_name))
829 };
830
831 // Create parent directories if the branch name contains slashes
832 if let Some(parent) = worktree_path.parent() {
833 create_dir_all(parent)?;
834 }
835
836 let mut opts = WorktreeAddOptions::new();
837 if let Some(ref r) = reference {
838 opts.reference(Some(r));
839 }
840 if lock {
841 opts.lock(true);
842 }
843
844 debug!(
845 "adding worktree {} at {}",
846 worktree_name,
847 worktree_path.display()
848 );
849
850 let worktree = repo.worktree(worktree_name, worktree_path.as_path(), Some(&opts))?;
851
852 // For detached worktrees, set HEAD to point directly to a commit SHA
853 if branch_type == BranchType::Detached {
854 debug!("setting up detached HEAD for worktree {:?}", branch_name);
855
856 use std::fs;
857
858 // Get the current HEAD commit SHA
859 let head_commit = repo.head()?.peel_to_commit()?;
860 let commit_sha = head_commit.id().to_string();
861
862 // Write the commit SHA directly to the worktree's HEAD file
863 let git_dir = repo.path().join("worktrees").join(worktree_name);
864 let head_path = git_dir.join("HEAD");
865 fs::write(&head_path, format!("{}\n", commit_sha).as_bytes())?;
866
867 debug!(
868 "detached HEAD setup complete for worktree {:?} at {}",
869 branch_name, commit_sha
870 );
871 }
872
873 // For orphan branches, create an initial empty commit with no parent
874 if branch_type == BranchType::Orphan {
875 debug!(
876 "setting up orphan branch {:?} with initial empty commit",
877 branch_name
878 );
879
880 use std::fs;
881
882 // Get the common directory (bare repo path) - important when running from a worktree
883 let common_dir = repo.commondir();
884
885 // First, manually set HEAD to point to the new branch as a symbolic reference
886 // This ensures we're not trying to update an existing branch
887 let git_dir = common_dir.join("worktrees").join(worktree_name);
888 let head_path = git_dir.join("HEAD");
889 let branch_ref = format!("ref: refs/heads/{}\n", branch_name);
890 fs::write(&head_path, branch_ref.as_bytes())?;
891
892 // Remove any existing branch ref that libgit2 may have created
893 let branch_ref_path = common_dir.join("refs/heads").join(branch_name);
894 let _ = fs::remove_file(&branch_ref_path);
895
896 // Open the worktree repository
897 let worktree_repo = Repository::open(&worktree_path)?;
898
899 // Remove all files from the working directory (but keep .git)
900 for entry in fs::read_dir(&worktree_path)? {
901 let entry = entry?;
902 let path = entry.path();
903 if path.file_name() != Some(std::ffi::OsStr::new(".git")) {
904 if path.is_dir() {
905 fs::remove_dir_all(&path)?;
906 } else {
907 fs::remove_file(&path)?;
908 }
909 }
910 }
911
912 // Clear the index to start fresh
913 let mut index = worktree_repo.index()?;
914 index.clear()?;
915 index.write()?;
916
917 // Create an empty tree for the initial commit
918 let tree_id = index.write_tree()?;
919 let tree = worktree_repo.find_tree(tree_id)?;
920
921 // Create signature for the commit
922 let config = worktree_repo.config()?;
923 let sig = worktree_repo.signature().or_else(|_| {
924 // Fallback if no git config is set
925 git2::Signature::now(
926 config
927 .get_string("user.name")
928 .unwrap_or_else(|_| "git-workon".to_string())
929 .as_str(),
930 config
931 .get_string("user.email")
932 .unwrap_or_else(|_| "git-workon@localhost".to_string())
933 .as_str(),
934 )
935 })?;
936
937 // Create initial commit with no parents (orphan)
938 worktree_repo.commit(
939 Some("HEAD"),
940 &sig,
941 &sig,
942 "Initial commit",
943 &tree,
944 &[], // No parents - this makes it an orphan
945 )?;
946
947 debug!("orphan branch setup complete for {:?}", branch_name);
948 }
949
950 Ok(WorktreeDescriptor::of(worktree))
951}
952
953/// Set upstream tracking for a worktree branch
954///
955/// Configures the branch in the worktree to track a remote branch by setting
956/// `branch.*.remote` and `branch.*.merge` configuration entries.
957///
958/// This is particularly important for PR worktrees to ensure they properly track
959/// the PR's remote branch.
960pub fn set_upstream_tracking(
961 worktree: &WorktreeDescriptor,
962 remote: &str,
963 remote_ref: &str,
964) -> Result<()> {
965 let repo = Repository::open(worktree.path())?;
966 let mut config = repo.config()?;
967
968 let head = repo.head()?;
969 let branch_name = head
970 .shorthand()
971 .ok()
972 .ok_or(WorktreeError::NoCurrentBranchTarget)?;
973
974 // Set branch.*.remote
975 let remote_key = format!("branch.{}.remote", branch_name);
976 config.set_str(&remote_key, remote)?;
977
978 // Set branch.*.merge
979 let merge_key = format!("branch.{}.merge", branch_name);
980 config.set_str(&merge_key, remote_ref)?;
981
982 debug!(
983 "Set upstream tracking: {} -> {}/{}",
984 branch_name, remote, remote_ref
985 );
986 Ok(())
987}