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