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