Skip to main content

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