Skip to main content

grit_lib/worktree/
core.rs

1//! Linked worktree registry: discovery, listing, and admin-dir layout.
2//!
3//! Git stores linked worktrees under `<common-git-dir>/worktrees/<id>/` with
4//! `gitdir`, `commondir`, `HEAD`, and optional `locked` / `prunable` files.
5//! This module reads that layout and implements linked worktree add/remove.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::branch_tracking::write_branch_upstream_config;
11use crate::check_ref_format::validate_branch_short_name;
12use crate::config::ConfigSet;
13#[cfg(test)]
14use crate::environment::RepositoryOptions;
15use crate::error::{Error, Result};
16use crate::git_path::{absolute_path_for_gitfile_stored_path, path_for_disk_compare};
17use crate::porcelain::branch::{resolve_create_tracking, TrackMode};
18use crate::porcelain::branch_error::BranchError;
19use crate::porcelain::checkout::checkout_between_trees;
20use crate::porcelain::checkout_reflog::{
21    create_branch_ref_with_reflog, reset_branch_ref_with_reflog,
22};
23use crate::porcelain::status::{status, StatusOptions};
24use crate::progress::NullProgress;
25use crate::refs;
26use crate::repo::{common_git_dir_for_config, Repository};
27use crate::rev_parse::resolve_revision;
28use crate::state::{resolve_head, resolve_linked_worktree_head, HeadState};
29
30use super::error::{WorktreeError, WorktreeOperation};
31
32/// Why Git marks a linked worktree as `prunable` in `worktree list --porcelain`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PrunableReason {
35    /// Admin path is not a directory (`not a valid directory`).
36    AdminNotValidDirectory,
37    /// `<admin>/gitdir` is missing (`gitdir file does not exist`).
38    GitdirFileMissing,
39    /// Resolved gitdir target is missing (`gitdir file points to non-existent location`).
40    GitdirPointsElsewhere,
41    /// Working tree root directory is missing while the gitdir file still exists.
42    WorktreePathMissing,
43    /// `<admin>/gitdir` is empty or unreadable (`invalid gitdir file`).
44    InvalidGitdirFile,
45    /// `<admin>/prunable` exists (grit/Git admin marker forcing prune eligibility).
46    ExplicitMarker,
47}
48
49impl PrunableReason {
50    /// Message text after `prunable` in `git worktree list --porcelain`.
51    #[must_use]
52    pub fn porcelain_message(self) -> &'static str {
53        match self {
54            Self::AdminNotValidDirectory => "not a valid directory",
55            Self::GitdirFileMissing => "gitdir file does not exist",
56            Self::GitdirPointsElsewhere => "gitdir file points to non-existent location",
57            Self::WorktreePathMissing => "gitdir file points to non-existent location",
58            Self::InvalidGitdirFile => "invalid gitdir file",
59            Self::ExplicitMarker => "gitdir file points to non-existent location",
60        }
61    }
62}
63
64/// One row returned by [`list_worktrees`].
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct WorktreeEntry {
67    /// Absolute path to the working tree (for bare main worktree, the common git dir).
68    pub path: PathBuf,
69    /// Resolved HEAD for this worktree.
70    pub head: HeadState,
71    /// Whether this worktree is bare (only the main entry can be bare).
72    pub is_bare: bool,
73    /// True when `<admin>/locked` exists.
74    pub is_locked: bool,
75    /// Contents of the `locked` file when non-empty.
76    pub lock_reason: Option<String>,
77    /// Administrative directory: common git dir for main, else `worktrees/<id>/`.
78    pub admin_dir: PathBuf,
79    /// True when [`Self::prunable_reason`] is set (Git `prunable` in `worktree list`).
80    pub is_prunable: bool,
81    /// Typed prune reason when [`Self::is_prunable`] is true.
82    pub prunable_reason: Option<PrunableReason>,
83    /// True when this row is the worktree [`Repository`] was opened from.
84    pub is_current: bool,
85}
86
87/// Shared git directory for `git_dir` (follows `commondir` for linked worktrees).
88#[must_use]
89pub fn common_git_dir(git_dir: &Path) -> PathBuf {
90    common_git_dir_for_config(git_dir)
91}
92
93/// Resolve HEAD for a linked worktree admin dir (`HEAD` local, branch refs in `common`).
94#[must_use]
95pub fn resolve_linked_head(admin: &Path, _common: &Path) -> HeadState {
96    resolve_head(admin).unwrap_or(HeadState::Invalid)
97}
98
99/// Number of registered worktrees (main + linked entries under `worktrees/`).
100#[must_use]
101pub fn registered_worktree_count(common: &Path) -> usize {
102    let worktrees_dir = common.join("worktrees");
103    if !worktrees_dir.is_dir() {
104        return 1;
105    }
106    let linked = fs::read_dir(&worktrees_dir)
107        .into_iter()
108        .flatten()
109        .flatten()
110        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
111        .count();
112    1 + linked
113}
114
115/// Whether `common` is configured as a bare repository (`core.bare=true`).
116#[must_use]
117pub fn is_bare_repository(common: &Path) -> bool {
118    ConfigSet::load(
119        &crate::environment::Environment::empty(),
120        Some(common),
121        true,
122    )
123    .ok()
124    .and_then(|cfg| cfg.get_bool("core.bare"))
125    .and_then(|r| r.ok())
126    .unwrap_or_else(|| {
127        // Heuristic when config is missing: bare repos usually are not named `.git`.
128        !common.ends_with(".git") && common.join("config").is_file()
129    })
130}
131
132/// Where a local branch is checked out among registered worktrees.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct BranchWorktreeCheckout {
135    /// Short branch name (e.g. `feat`).
136    pub branch: String,
137    /// Working tree path for the checkout.
138    pub worktree_path: PathBuf,
139    /// Administrative git directory for that worktree.
140    pub admin_dir: PathBuf,
141}
142
143/// Administrative git directory for the worktree `repo` was opened from.
144#[must_use]
145pub fn worktree_admin_dir(repo: &Repository) -> PathBuf {
146    let common = common_git_dir(repo.git_dir());
147    if repo.git_dir().starts_with(common.join("worktrees")) {
148        repo.git_dir().to_path_buf()
149    } else {
150        common
151    }
152}
153
154fn head_is_branch(head: &HeadState, branch_short_name: &str) -> bool {
155    match head {
156        HeadState::Branch {
157            short_name,
158            refname,
159            ..
160        } => {
161            short_name == branch_short_name || refname == &format!("refs/heads/{branch_short_name}")
162        }
163        _ => false,
164    }
165}
166
167pub(crate) fn admin_dirs_match(a: &Path, b: &Path) -> bool {
168    path_for_disk_compare(a) == path_for_disk_compare(b) || a == b
169}
170
171/// Find a worktree that has `branch_short_name` checked out, optionally skipping one admin dir.
172///
173/// `exclude_admin_dir` is typically the current worktree's admin dir so a switch to the branch
174/// already checked out here is allowed.
175pub fn find_worktree_with_branch_checked_out(
176    repo: &Repository,
177    branch_short_name: &str,
178    exclude_admin_dir: Option<&Path>,
179) -> Result<Option<BranchWorktreeCheckout>> {
180    for entry in list_worktrees(repo)? {
181        if exclude_admin_dir.is_some_and(|ex| admin_dirs_match(&entry.admin_dir, ex)) {
182            continue;
183        }
184        if head_is_branch(&entry.head, branch_short_name) {
185            return Ok(Some(BranchWorktreeCheckout {
186                branch: branch_short_name.to_owned(),
187                worktree_path: entry.path.clone(),
188                admin_dir: entry.admin_dir.clone(),
189            }));
190        }
191    }
192    Ok(None)
193}
194
195/// Refuse when another worktree already has `branch_short_name` checked out.
196///
197/// # Errors
198///
199/// Returns [`Error::BranchCheckedOutInWorktree`] when a different worktree holds the branch.
200pub fn ensure_branch_not_checked_out_elsewhere(
201    repo: &Repository,
202    branch_short_name: &str,
203) -> Result<()> {
204    let exclude = worktree_admin_dir(repo);
205    if let Some(found) =
206        find_worktree_with_branch_checked_out(repo, branch_short_name, Some(&exclude))?
207    {
208        return Err(branch_checked_out_error(
209            branch_short_name,
210            &found.worktree_path,
211        ));
212    }
213    Ok(())
214}
215
216/// Refuse when any worktree (including linked) has `branch_short_name` checked out.
217///
218/// # Errors
219///
220/// Returns [`Error::BranchCheckedOutInWorktree`] when any registered worktree holds the branch.
221pub fn ensure_branch_not_checked_out_in_any_worktree(
222    repo: &Repository,
223    branch_short_name: &str,
224) -> Result<()> {
225    if let Some(found) = find_worktree_with_branch_checked_out(repo, branch_short_name, None)? {
226        return Err(branch_checked_out_error(
227            branch_short_name,
228            &found.worktree_path,
229        ));
230    }
231    Ok(())
232}
233
234fn branch_checked_out_error(branch: &str, worktree_path: &Path) -> Error {
235    Error::BranchCheckedOutInWorktree {
236        branch: branch.to_owned(),
237        worktree_path: crate::git_path::path_for_error_message(worktree_path),
238    }
239}
240
241/// Enumerate the main and linked worktrees for `repo`.
242///
243/// Order matches Git: main worktree first, then linked worktrees sorted by admin id.
244pub fn list_worktrees(repo: &Repository) -> Result<Vec<WorktreeEntry>> {
245    let common = common_git_dir(repo.git_dir());
246    let git_dir = repo.git_dir();
247    let opened_from_linked = git_dir.starts_with(common.join("worktrees"));
248    let mut entries = Vec::new();
249
250    let bare = if opened_from_linked {
251        is_bare_repository(&common)
252    } else {
253        repo.is_bare()
254    };
255    let main_path = if bare {
256        path_for_disk_compare(&common)
257    } else if let Some(wt) = repo.work_tree() {
258        // When opened from the main worktree, use the discovered work tree path.
259        if repo.git_dir() == common || !repo.git_dir().starts_with(common.join("worktrees")) {
260            path_for_disk_compare(wt)
261        } else {
262            path_for_disk_compare(common.parent().unwrap_or(&common))
263        }
264    } else {
265        path_for_disk_compare(common.parent().unwrap_or(&common))
266    };
267
268    let main_head = if opened_from_linked || repo.refs_on_disk() {
269        resolve_head(&common)
270    } else {
271        crate::state::resolve_head_for_repository(repo)
272    }
273    .unwrap_or(HeadState::Invalid);
274    entries.push(WorktreeEntry {
275        path: main_path,
276        head: main_head,
277        is_bare: bare,
278        is_locked: false,
279        lock_reason: None,
280        admin_dir: common.clone(),
281        is_prunable: false,
282        prunable_reason: None,
283        is_current: !opened_from_linked && repo_opened_from_admin(git_dir, &common),
284    });
285
286    let worktrees_dir = common.join("worktrees");
287    if !worktrees_dir.is_dir() {
288        return Ok(entries);
289    }
290
291    let mut names: Vec<String> = fs::read_dir(&worktrees_dir)
292        .map_err(Error::Io)?
293        .filter_map(|e| e.ok())
294        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
295        .map(|e| e.file_name().to_string_lossy().into_owned())
296        .collect();
297    names.sort();
298
299    for name in names {
300        let admin = worktrees_dir.join(&name);
301        let wt_head = resolve_linked_worktree_head(repo, &admin);
302        let wt_path = read_worktree_path(&admin)?;
303        let (is_locked, lock_reason) = read_lock_state(&admin)?;
304        let (is_prunable, prunable_reason) =
305            linked_worktree_prunable_state(&admin, is_locked, &wt_path);
306        let is_current = opened_from_linked && git_dir == admin.as_path();
307        entries.push(WorktreeEntry {
308            path: wt_path,
309            head: wt_head,
310            is_bare: false,
311            is_locked,
312            lock_reason,
313            admin_dir: admin,
314            is_prunable,
315            prunable_reason,
316            is_current,
317        });
318    }
319
320    Ok(entries)
321}
322
323#[inline]
324fn repo_opened_from_admin(git_dir: &Path, admin: &Path) -> bool {
325    git_dir == admin || path_for_disk_compare(git_dir) == path_for_disk_compare(admin)
326}
327
328/// Last path component of `path`, without trailing directory separators (Git `worktree_basename`).
329#[must_use]
330pub fn worktree_path_basename(path: &Path) -> String {
331    let s = path.to_string_lossy();
332    let trimmed = s.trim_end_matches(['/', '\\']);
333    trimmed
334        .rsplit(['/', '\\'])
335        .next()
336        .unwrap_or(trimmed)
337        .to_owned()
338}
339
340/// Sanitize a path basename for use as `worktrees/<id>/` (Git `sanitize_refname_component`).
341#[must_use]
342pub fn sanitize_worktree_id_component(name: &str) -> String {
343    if name == "@" {
344        return "-".to_owned();
345    }
346
347    let mut out = String::new();
348    let mut last = '\0';
349    let chars: Vec<char> = name.chars().collect();
350    let mut i = 0;
351    while i < chars.len() {
352        let ch = chars[i];
353        if ch.is_ascii_control()
354            || matches!(ch, ':' | '?' | '[' | '\\' | '^' | '~' | ' ' | '\t' | '*')
355        {
356            if (out.is_empty() && last != '-') || !out.is_empty() {
357                out.push('-');
358            }
359            last = '-';
360            i += 1;
361            continue;
362        }
363        if ch == '.' && i + 1 < chars.len() && chars[i + 1] == '.' {
364            if last == '.' {
365                out.pop();
366            } else {
367                out.push('.');
368                last = '.';
369            }
370            i += 2;
371            continue;
372        }
373        if ch == '@' && i + 1 < chars.len() && chars[i + 1] == '{' {
374            if let Some(last_ch) = out.pop() {
375                if last_ch != '-' {
376                    out.push('-');
377                }
378            }
379            last = '-';
380            i += 2;
381            continue;
382        }
383        if ch == '.' && out.is_empty() {
384            out.push('-');
385            last = '-';
386            i += 1;
387            continue;
388        }
389        out.push(ch);
390        last = ch;
391        i += 1;
392    }
393
394    const LOCK_SUFFIX: &str = ".lock";
395    while out.ends_with(LOCK_SUFFIX) {
396        out.truncate(out.len() - LOCK_SUFFIX.len());
397    }
398    while out.ends_with('.') {
399        out.pop();
400    }
401    out
402}
403
404/// Pick a unique `<common>/worktrees/<id>/` directory for a new linked worktree at `wt_path`.
405///
406/// Git uses the sanitized basename and appends `1`, `2`, … when the admin dir already exists.
407#[must_use]
408pub fn allocate_worktree_admin_dir(common: &Path, wt_path: &Path) -> PathBuf {
409    let worktrees_dir = common.join("worktrees");
410    let base = sanitize_worktree_id_component(&worktree_path_basename(wt_path));
411    let base = if base.is_empty() {
412        "worktree".to_owned()
413    } else {
414        base
415    };
416
417    let mut counter = 0u32;
418    loop {
419        let id = if counter == 0 {
420            base.clone()
421        } else {
422            format!("{base}{counter}")
423        };
424        let admin = worktrees_dir.join(&id);
425        if !admin.exists() {
426            return admin;
427        }
428        counter = counter.saturating_add(1);
429        if counter == 0 {
430            break;
431        }
432    }
433    worktrees_dir.join(format!("{base}{}", std::process::id()))
434}
435
436/// Copy `config.worktree` into a linked worktree admin dir, stripping keys Git omits
437/// when `extensions.worktreeConfig` is enabled (`core.bare`, `core.worktree`).
438pub fn copy_filtered_worktree_config(source_git_dir: &Path, admin_dir: &Path) -> Result<()> {
439    let src = source_git_dir.join("config.worktree");
440    if !src.is_file() {
441        return Ok(());
442    }
443    let dst = admin_dir.join("config.worktree");
444    fs::copy(&src, &dst).map_err(Error::Io)?;
445    strip_worktree_config_keys(&dst, &["core.bare", "core.worktree"])?;
446    Ok(())
447}
448
449fn strip_worktree_config_keys(path: &Path, keys: &[&str]) -> Result<()> {
450    let content = fs::read_to_string(path).map_err(Error::Io)?;
451    let mut kept = Vec::new();
452    let mut section: Option<String> = None;
453    for line in content.lines() {
454        let trimmed = line.trim();
455        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
456            kept.push(line);
457            continue;
458        }
459        if trimmed.starts_with('[') {
460            let end = trimmed.find(']').unwrap_or(trimmed.len());
461            let name = trimmed[1..end].trim().to_ascii_lowercase();
462            section = Some(name);
463            kept.push(line);
464            continue;
465        }
466        if let Some((key, _)) = trimmed.split_once('=') {
467            let key = key.trim().to_ascii_lowercase();
468            let full = match section.as_deref() {
469                Some(sec) => format!("{sec}.{key}"),
470                None => key.clone(),
471            };
472            if keys.iter().any(|k| full.eq_ignore_ascii_case(k)) {
473                continue;
474            }
475        } else if keys.iter().any(|k| trimmed.eq_ignore_ascii_case(k)) {
476            continue;
477        }
478        kept.push(line);
479    }
480    let mut out = kept.join("\n");
481    if !out.is_empty() {
482        out.push('\n');
483    }
484    fs::write(path, out).map_err(Error::Io)
485}
486
487/// How [`add_linked_worktree`] attaches `HEAD` to a branch.
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum WorktreeBranch {
490    /// Check out an existing local branch (its tip must match the start commit).
491    Existing(String),
492    /// Create `refs/heads/<name>` at the start commit (fails when the branch exists).
493    Create(String),
494    /// Create or reset `refs/heads/<name>` to the start commit (`git worktree add -B`).
495    CreateOrReset(String),
496    /// Detached HEAD at the start commit.
497    Detach,
498}
499
500/// Options for [`add_linked_worktree`].
501#[derive(Debug, Clone)]
502pub struct AddLinkedWorktreeOptions {
503    /// Directory for the new working tree (created when missing).
504    pub path: PathBuf,
505    /// Branch mode. When `None`, a new branch named after the path basename is created.
506    pub branch: Option<WorktreeBranch>,
507    /// Start commit (revision spec). Defaults to the current `HEAD` commit.
508    pub start_point: Option<String>,
509    /// Replace a stale registration when the working tree directory is missing.
510    pub force: bool,
511    /// When set, write `locked` before checkout finishes (same order as Git).
512    pub lock: Option<LockWorktreeOptions>,
513    /// Register the worktree and set `HEAD` without populating the working tree or index.
514    pub no_checkout: bool,
515    /// When the start point is a remote-tracking branch, configure upstream for the new branch.
516    pub track: bool,
517}
518
519/// Options for locking during [`add_linked_worktree`] or
520/// [`RepositoryWorktrees::lock`](crate::repo_handles::RepositoryWorktrees::lock).
521#[derive(Debug, Clone, Default)]
522pub struct LockWorktreeOptions {
523    /// Optional reason stored in the `locked` file (shown by `git worktree list`).
524    pub reason: Option<String>,
525}
526
527/// Options for [`prune_linked_worktrees`].
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
529pub struct PruneWorktreesOptions {
530    /// List prunable worktrees without removing admin directories.
531    pub dry_run: bool,
532}
533
534/// One linked worktree removed or that would be removed by [`prune_linked_worktrees`].
535#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct PrunedWorktreeEntry {
537    /// Working tree path from the registry.
538    pub path: PathBuf,
539    /// Why the entry is prunable (matches `git worktree prune` messaging).
540    pub reason: String,
541}
542
543/// Register and populate a linked worktree (Git `worktree add` layout).
544///
545/// On-disk stores initialize `HEAD` through the repository's selected ref backend.
546/// Reftable worktrees keep their symbolic or detached HEAD in a separate stack,
547/// with discovery placeholders in the admin directory. `no_checkout` still creates
548/// this ref metadata, but leaves the index and tracked files absent.
549///
550/// # Errors
551///
552/// Fails when the repository is bare, the path is occupied, the branch is already
553/// checked out elsewhere, or checkout cannot complete.
554pub fn add_linked_worktree(
555    repo: &Repository,
556    opts: &AddLinkedWorktreeOptions,
557) -> Result<WorktreeEntry> {
558    let common = common_git_dir(repo.git_dir());
559    if repo.is_bare() {
560        return Err(WorktreeError::BareRepository.into());
561    }
562
563    let wt_path = if opts.path.exists() {
564        path_for_disk_compare(&opts.path)
565    } else {
566        opts.path.clone()
567    };
568    let admin_plan = plan_admin_dir_for_add(&common, &wt_path, opts.force)?;
569    ensure_empty_or_missing(&wt_path)?;
570
571    let (start_spec, start_oid) = resolve_worktree_start_commit(repo, opts)?;
572    let tree_oid = repo.odb().commit_tree(&start_oid)?;
573
574    let plan = plan_worktree_head(repo, opts, &wt_path, &start_spec, start_oid)?;
575    if let Some(ref branch_ref) = plan.head_ref {
576        branch_checked_out_elsewhere(repo, branch_ref)?;
577    }
578
579    let admin = admin_plan.admin_dir;
580    let worktree_absent_before = !wt_path.exists();
581    let mut stale_guard = admin_plan
582        .stale_admins
583        .map(StaleWorktreeAdminBackup::remove_and_backup)
584        .transpose()?;
585
586    let mut branch_rollback = None;
587    let finish = (|| -> Result<WorktreeEntry> {
588        if let Some(ref bw) = plan.branch_write {
589            let branch_ref = format!("refs/heads/{}", bw.branch_short);
590            let previous_tip = repo.resolve_ref_oid(&branch_ref).ok();
591            branch_rollback = Some(BranchRefRollback {
592                repo,
593                branch_ref: branch_ref.clone(),
594                previous_tip,
595                committed: false,
596            });
597            create_worktree_branch(
598                repo,
599                &bw.branch_short,
600                &bw.start_spec,
601                bw.start_oid,
602                bw.force_reset,
603            )?;
604        }
605        if let Some(ref branch_short) = plan.track_branch {
606            maybe_track_new_branch(repo, opts, branch_short, &start_spec, start_oid)?;
607        }
608
609        fs::create_dir_all(&admin).map_err(Error::Io)?;
610        if let Some(guard) = stale_guard.as_mut() {
611            guard.set_new_admin(&admin);
612        }
613        fs::create_dir_all(&wt_path).map_err(Error::Io)?;
614        if let Some(guard) = stale_guard.as_mut() {
615            guard.track_replacement_worktree(&wt_path, worktree_absent_before);
616        }
617
618        let wt_git = wt_path.join(".git");
619        fs::write(
620            &wt_git,
621            format!(
622                "gitdir: {}\n",
623                absolute_path_for_gitfile_stored_path(&admin)
624            ),
625        )
626        .map_err(Error::Io)?;
627        let wt_git_abs = absolute_path_for_gitfile_stored_path(&wt_git);
628        fs::write(admin.join("commondir"), "../..\n").map_err(Error::Io)?;
629        fs::write(admin.join("gitdir"), format!("{wt_git_abs}\n")).map_err(Error::Io)?;
630
631        let head = plan
632            .head_ref
633            .as_ref()
634            .map_or(crate::refs::store::RawRef::Direct(start_oid), |target| {
635                crate::refs::store::RawRef::Symbolic(target.clone())
636            });
637        if repo.refs_on_disk() {
638            crate::refs::store::initialize_head(&admin, head)?;
639        } else {
640            // Injected stores share branches, with each linked HEAD kept in its admin file.
641            let content = match head {
642                crate::refs::store::RawRef::Symbolic(target) => format!("ref: {target}\n"),
643                crate::refs::store::RawRef::Direct(oid) => format!("{oid}\n"),
644            };
645            fs::write(admin.join("HEAD"), content).map_err(Error::Io)?;
646        }
647
648        copy_filtered_worktree_config(&common, &admin)?;
649
650        if let Some(lock) = &opts.lock {
651            write_admin_lock_file(&admin, lock.reason.as_deref())?;
652        }
653
654        if !opts.no_checkout {
655            let linked = repo.open_linked_worktree(&admin, &wt_path)?;
656            checkout_between_trees(&linked, None, &tree_oid)?;
657        }
658
659        linked_worktree_entry(repo, &admin)
660    })();
661
662    match finish {
663        Ok(entry) => {
664            if let Some(guard) = stale_guard.take() {
665                guard.commit();
666            }
667            if let Some(mut rollback) = branch_rollback {
668                rollback.commit();
669            }
670            Ok(entry)
671        }
672        Err(err) => Err(err),
673    }
674}
675
676/// Remove admin directories for linked worktrees whose working tree is gone.
677///
678/// Skips locked worktrees. Returns paths to pruned working trees (for reporting).
679///
680/// # Errors
681///
682/// Propagates I/O failures while reading or removing admin directories.
683pub fn prune_linked_worktrees(
684    repo: &Repository,
685    opts: &PruneWorktreesOptions,
686) -> Result<Vec<PrunedWorktreeEntry>> {
687    let common = common_git_dir(repo.git_dir());
688    let worktrees_dir = common.join("worktrees");
689    if !worktrees_dir.is_dir() {
690        return Ok(Vec::new());
691    }
692
693    let mut pruned = Vec::new();
694    for entry in list_worktrees(repo)? {
695        if entry.admin_dir == common || entry.is_locked || !entry.is_prunable {
696            continue;
697        }
698        let reason = prune_reason(&entry.admin_dir, &entry.path);
699        pruned.push(PrunedWorktreeEntry {
700            path: entry.path.clone(),
701            reason,
702        });
703        if !opts.dry_run {
704            fs::remove_dir_all(&entry.admin_dir).map_err(Error::Io)?;
705        }
706    }
707    Ok(pruned)
708}
709
710fn prune_reason(admin: &Path, wt_path: &Path) -> String {
711    let prunable_file = admin.join("prunable");
712    if prunable_file.is_file() {
713        if let Ok(content) = fs::read_to_string(&prunable_file) {
714            let trimmed = content.trim();
715            if !trimmed.is_empty() {
716                return trimmed.to_owned();
717            }
718        }
719    }
720    if !wt_path.exists() {
721        return "gitdir file points to non-existent location".into();
722    }
723    "prunable".into()
724}
725
726fn write_admin_lock_file(admin: &Path, reason: Option<&str>) -> Result<()> {
727    let body = reason.unwrap_or("");
728    fs::write(admin.join("locked"), body).map_err(Error::Io)
729}
730
731struct WorktreeHeadPlan {
732    head_ref: Option<String>,
733    branch_write: Option<BranchWritePlan>,
734    track_branch: Option<String>,
735}
736
737struct BranchWritePlan {
738    branch_short: String,
739    start_spec: String,
740    start_oid: crate::objects::ObjectId,
741    force_reset: bool,
742}
743
744struct BranchRefRollback<'a> {
745    repo: &'a Repository,
746    branch_ref: String,
747    previous_tip: Option<crate::objects::ObjectId>,
748    committed: bool,
749}
750
751impl BranchRefRollback<'_> {
752    fn commit(&mut self) {
753        self.committed = true;
754    }
755}
756
757impl Drop for BranchRefRollback<'_> {
758    fn drop(&mut self) {
759        if self.committed {
760            return;
761        }
762        let _ = match self.previous_tip {
763            None => refs::delete_ref_on_store(self.repo.refs(), &self.branch_ref),
764            Some(old) => refs::update_direct_ref_on_store(
765                self.repo.refs(),
766                self.repo.git_dir(),
767                &self.branch_ref,
768                &old,
769            ),
770        };
771    }
772}
773
774fn resolve_worktree_start_commit(
775    repo: &Repository,
776    opts: &AddLinkedWorktreeOptions,
777) -> Result<(String, crate::objects::ObjectId)> {
778    if let Some(WorktreeBranch::Existing(name)) = &opts.branch {
779        if opts.start_point.is_none() {
780            let oid = resolve_existing_branch_tip(repo, name)?;
781            return Ok((name.clone(), oid));
782        }
783    }
784    let start_spec = opts.start_point.as_deref().unwrap_or("HEAD").to_owned();
785    let start_oid = resolve_revision(repo, &start_spec)?;
786    Ok((start_spec, start_oid))
787}
788
789fn plan_worktree_head(
790    repo: &Repository,
791    opts: &AddLinkedWorktreeOptions,
792    wt_path: &Path,
793    start_spec: &str,
794    start_oid: crate::objects::ObjectId,
795) -> Result<WorktreeHeadPlan> {
796    match &opts.branch {
797        Some(WorktreeBranch::Detach) => Ok(WorktreeHeadPlan {
798            head_ref: None,
799            branch_write: None,
800            track_branch: None,
801        }),
802        Some(WorktreeBranch::Existing(name)) => {
803            let branch_ref = prepare_existing_branch(repo, name, start_oid)?;
804            Ok(WorktreeHeadPlan {
805                head_ref: Some(branch_ref),
806                branch_write: None,
807                track_branch: None,
808            })
809        }
810        Some(WorktreeBranch::Create(name)) => Ok(WorktreeHeadPlan {
811            head_ref: Some(format!("refs/heads/{name}")),
812            branch_write: Some(BranchWritePlan {
813                branch_short: name.clone(),
814                start_spec: start_spec.to_owned(),
815                start_oid,
816                force_reset: false,
817            }),
818            track_branch: if opts.track { Some(name.clone()) } else { None },
819        }),
820        Some(WorktreeBranch::CreateOrReset(name)) => Ok(WorktreeHeadPlan {
821            head_ref: Some(format!("refs/heads/{name}")),
822            branch_write: Some(BranchWritePlan {
823                branch_short: name.clone(),
824                start_spec: start_spec.to_owned(),
825                start_oid,
826                force_reset: true,
827            }),
828            track_branch: if opts.track { Some(name.clone()) } else { None },
829        }),
830        None => {
831            if let Some(start) = opts.start_point.as_deref() {
832                if let Some(branch_ref) = resolve_existing_branch(repo, start) {
833                    let tip = repo.resolve_ref_oid(&branch_ref)?;
834                    if tip != start_oid {
835                        return Err(WorktreeError::BranchTipMismatch {
836                            branch: branch_ref
837                                .strip_prefix("refs/heads/")
838                                .unwrap_or(&branch_ref)
839                                .to_owned(),
840                        }
841                        .into());
842                    }
843                    return Ok(WorktreeHeadPlan {
844                        head_ref: Some(branch_ref),
845                        branch_write: None,
846                        track_branch: None,
847                    });
848                }
849                return Ok(WorktreeHeadPlan {
850                    head_ref: None,
851                    branch_write: None,
852                    track_branch: None,
853                });
854            }
855            let branch = worktree_path_basename(wt_path);
856            validate_branch_name(&branch)?;
857            Ok(WorktreeHeadPlan {
858                head_ref: Some(format!("refs/heads/{branch}")),
859                branch_write: Some(BranchWritePlan {
860                    branch_short: branch.clone(),
861                    start_spec: start_spec.to_owned(),
862                    start_oid,
863                    force_reset: false,
864                }),
865                track_branch: if opts.track { Some(branch) } else { None },
866            })
867        }
868    }
869}
870
871fn prepare_existing_branch(
872    repo: &Repository,
873    name: &str,
874    start_oid: crate::objects::ObjectId,
875) -> Result<String> {
876    let tip = resolve_existing_branch_tip(repo, name)?;
877    if tip != start_oid {
878        return Err(WorktreeError::BranchTipMismatch {
879            branch: name.to_owned(),
880        }
881        .into());
882    }
883    Ok(format!("refs/heads/{name}"))
884}
885
886fn validate_branch_name(name: &str) -> Result<()> {
887    validate_branch_short_name(name).map_err(|reason| {
888        BranchError::InvalidName {
889            name: name.to_owned(),
890            reason: reason.to_string(),
891        }
892        .into()
893    })
894}
895
896fn resolve_existing_branch_tip(repo: &Repository, name: &str) -> Result<crate::objects::ObjectId> {
897    validate_branch_name(name)?;
898    repo.resolve_ref_oid(&format!("refs/heads/{name}"))
899        .map_err(|_| {
900            BranchError::NotFound {
901                name: name.to_owned(),
902            }
903            .into()
904        })
905}
906
907fn create_worktree_branch(
908    repo: &Repository,
909    name: &str,
910    start_spec: &str,
911    start_oid: crate::objects::ObjectId,
912    force_reset: bool,
913) -> Result<String> {
914    validate_branch_name(name)?;
915    let branch_ref = format!("refs/heads/{name}");
916    let existing = repo.references().read_existing(&branch_ref)?;
917    if existing.is_some() && !force_reset {
918        return Err(BranchError::AlreadyExists {
919            name: name.to_owned(),
920        }
921        .into());
922    }
923    if let Some(existing_ref) = existing {
924        ensure_branch_not_checked_out_in_any_worktree(repo, name)?;
925        reset_branch_ref_with_reflog(repo, &branch_ref, start_oid, start_spec, &existing_ref)?;
926    } else {
927        create_branch_ref_with_reflog(repo, &branch_ref, start_oid, start_spec)?;
928    }
929    Ok(branch_ref)
930}
931
932fn maybe_track_new_branch(
933    repo: &Repository,
934    opts: &AddLinkedWorktreeOptions,
935    branch_short: &str,
936    start_spec: &str,
937    start_oid: crate::objects::ObjectId,
938) -> Result<()> {
939    if !opts.track {
940        return Ok(());
941    }
942    let mode = match opts.start_point.as_deref() {
943        Some(s) if s != "HEAD" => TrackMode::Always,
944        _ => TrackMode::Auto,
945    };
946    match resolve_create_tracking(repo, branch_short, start_spec, start_oid, mode) {
947        Ok(Some((remote, merge))) => {
948            write_branch_upstream_config(repo, branch_short, &remote, &merge)
949        }
950        Ok(None) => Ok(()),
951        Err(Error::Branch(BranchError::UpstreamNotTracking { .. })) => Ok(()),
952        Err(err) => Err(err),
953    }
954}
955
956fn linked_worktree_entry(repo: &Repository, admin: &Path) -> Result<WorktreeEntry> {
957    let wt_path = read_worktree_path(admin)?;
958    let wt_head = resolve_linked_worktree_head(repo, admin);
959    let (is_locked, lock_reason) = read_lock_state(admin)?;
960    let (is_prunable, prunable_reason) = linked_worktree_prunable_state(admin, is_locked, &wt_path);
961    Ok(WorktreeEntry {
962        path: wt_path,
963        head: wt_head,
964        is_bare: false,
965        is_locked,
966        lock_reason,
967        admin_dir: admin.to_path_buf(),
968        is_prunable,
969        prunable_reason,
970        is_current: admin_dirs_match(admin, &worktree_admin_dir(repo)),
971    })
972}
973
974/// Match [`worktree_entry_is_prunable`] syscall order; attach a reason only when prunable.
975fn linked_worktree_prunable_state(
976    admin: &Path,
977    is_locked: bool,
978    wt_path: &Path,
979) -> (bool, Option<PrunableReason>) {
980    if is_locked {
981        return (false, None);
982    }
983    if admin.join("prunable").is_file() {
984        return (true, Some(PrunableReason::ExplicitMarker));
985    }
986    if wt_path.exists() {
987        return (false, None);
988    }
989    (
990        true,
991        Some(linked_worktree_prunable_reason_when_missing(admin)),
992    )
993}
994
995fn linked_worktree_prunable_reason_when_missing(admin: &Path) -> PrunableReason {
996    if !admin.is_dir() {
997        return PrunableReason::AdminNotValidDirectory;
998    }
999    let gitdir_file = admin.join("gitdir");
1000    if !gitdir_file.is_file() {
1001        return PrunableReason::GitdirFileMissing;
1002    }
1003    let raw = fs::read_to_string(&gitdir_file).unwrap_or_default();
1004    let trimmed = raw.trim();
1005    if trimmed.is_empty() {
1006        return PrunableReason::InvalidGitdirFile;
1007    }
1008    let mut dotgit = PathBuf::from(trimmed);
1009    if dotgit.is_relative() {
1010        dotgit = admin.join(dotgit);
1011    }
1012    let dotgit = path_for_disk_compare(&dotgit);
1013    if !dotgit.is_file() {
1014        return PrunableReason::GitdirPointsElsewhere;
1015    }
1016    PrunableReason::WorktreePathMissing
1017}
1018
1019/// Resolve `path` for [`add_linked_worktree`].
1020///
1021/// Relative paths are interpreted from `relative_to` (typically the process working
1022/// directory at the CLI boundary). The result is absolute and stable for registry
1023/// comparison even when the leaf directory does not exist yet.
1024///
1025/// # Errors
1026///
1027/// Returns [`WorktreeError::NoFileName`] when `path` has no final component.
1028pub fn resolve_linked_worktree_add_path(path: &Path, relative_to: &Path) -> Result<PathBuf> {
1029    if path.exists() {
1030        return Ok(path_for_disk_compare(path));
1031    }
1032    let absolute = if path.is_absolute() {
1033        path.to_path_buf()
1034    } else {
1035        relative_to.join(path)
1036    };
1037    if let Some(parent) = absolute.parent().filter(|p| p.exists()) {
1038        let Some(name) = absolute.file_name() else {
1039            return Err(WorktreeError::NoFileName { path: absolute }.into());
1040        };
1041        let mut resolved = path_for_disk_compare(parent);
1042        resolved.push(name);
1043        return Ok(resolved);
1044    }
1045    Ok(absolute)
1046}
1047
1048struct WorktreeAdminAddPlan {
1049    admin_dir: PathBuf,
1050    stale_admins: Option<Vec<PathBuf>>,
1051}
1052
1053/// On failure after stale admin dirs were moved aside, restore them.
1054struct StaleWorktreeAdminBackup {
1055    moved_aside: Vec<(PathBuf, PathBuf)>,
1056    new_admin: Option<PathBuf>,
1057    replacement_worktree: Option<(PathBuf, bool)>,
1058}
1059
1060impl StaleWorktreeAdminBackup {
1061    fn remove_and_backup(stale_admins: Vec<PathBuf>) -> Result<Self> {
1062        let mut moved_aside = Vec::new();
1063        for (i, admin) in stale_admins.into_iter().enumerate() {
1064            let id = admin
1065                .file_name()
1066                .map(|n| n.to_string_lossy().into_owned())
1067                .unwrap_or_else(|| "wt".to_owned());
1068            let aside = admin
1069                .parent()
1070                .ok_or_else(|| WorktreeError::InvalidAdminDir {
1071                    path: admin.clone(),
1072                })?
1073                .join(format!(".{id}-stale-backup-{i}"));
1074            if aside.exists() {
1075                fs::remove_dir_all(&aside).map_err(Error::Io)?;
1076            }
1077            fs::rename(&admin, &aside).map_err(Error::Io)?;
1078            moved_aside.push((admin, aside));
1079        }
1080        Ok(Self {
1081            moved_aside,
1082            new_admin: None,
1083            replacement_worktree: None,
1084        })
1085    }
1086
1087    fn set_new_admin(&mut self, admin: &Path) {
1088        self.new_admin = Some(admin.to_path_buf());
1089    }
1090
1091    fn track_replacement_worktree(&mut self, path: &Path, absent_before_replacement: bool) {
1092        self.replacement_worktree = Some((path.to_path_buf(), absent_before_replacement));
1093    }
1094
1095    fn commit(mut self) {
1096        for (_, aside) in self.moved_aside.drain(..) {
1097            let _ = fs::remove_dir_all(aside);
1098        }
1099    }
1100}
1101
1102impl Drop for StaleWorktreeAdminBackup {
1103    fn drop(&mut self) {
1104        if self.moved_aside.is_empty() {
1105            return;
1106        }
1107        if let Some((wt_path, absent_before)) = self.replacement_worktree.take() {
1108            if absent_before {
1109                let _ = fs::remove_dir_all(&wt_path);
1110            } else if wt_path.join(".git").is_file() {
1111                let _ = fs::remove_file(wt_path.join(".git"));
1112            }
1113        }
1114        if let Some(new_admin) = self.new_admin.take() {
1115            let _ = fs::remove_dir_all(new_admin);
1116        }
1117        for (admin, aside) in &self.moved_aside {
1118            let _ = fs::remove_dir_all(admin);
1119            let _ = fs::rename(aside, admin);
1120        }
1121    }
1122}
1123
1124fn linked_admins_for_worktree_path(common: &Path, wt_path: &Path) -> Result<Vec<PathBuf>> {
1125    let target = path_for_disk_compare(wt_path);
1126    let worktrees_dir = common.join("worktrees");
1127    if !worktrees_dir.is_dir() {
1128        return Ok(Vec::new());
1129    }
1130    let mut admins = Vec::new();
1131    for entry in fs::read_dir(&worktrees_dir).map_err(Error::Io)? {
1132        let entry = entry.map_err(Error::Io)?;
1133        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1134            continue;
1135        }
1136        let admin = entry.path();
1137        let registered = read_worktree_path(&admin)?;
1138        if path_for_disk_compare(&registered) == target {
1139            admins.push(admin);
1140        }
1141    }
1142    Ok(admins)
1143}
1144
1145fn plan_admin_dir_for_add(
1146    common: &Path,
1147    wt_path: &Path,
1148    force: bool,
1149) -> Result<WorktreeAdminAddPlan> {
1150    let existing = linked_admins_for_worktree_path(common, wt_path)?;
1151    if existing.is_empty() {
1152        return Ok(WorktreeAdminAddPlan {
1153            admin_dir: allocate_worktree_admin_dir(common, wt_path),
1154            stale_admins: None,
1155        });
1156    }
1157
1158    if wt_path.exists() {
1159        return Err(WorktreeError::PathRegistered {
1160            path: wt_path.to_path_buf(),
1161        }
1162        .into());
1163    }
1164    if !force {
1165        return Err(WorktreeError::PathRegisteredMissing {
1166            path: wt_path.to_path_buf(),
1167        }
1168        .into());
1169    }
1170
1171    let worktrees_dir = common.join("worktrees");
1172    let base = sanitize_worktree_id_component(&worktree_path_basename(wt_path));
1173    let base = if base.is_empty() {
1174        "worktree".to_owned()
1175    } else {
1176        base
1177    };
1178    let preferred = worktrees_dir.join(&base);
1179    let admin_dir = existing
1180        .iter()
1181        .find(|admin| {
1182            admin
1183                .file_name()
1184                .is_some_and(|name| name.to_string_lossy() == base)
1185        })
1186        .cloned()
1187        .unwrap_or_else(|| {
1188            if preferred.exists() {
1189                allocate_worktree_admin_dir(common, wt_path)
1190            } else {
1191                preferred
1192            }
1193        });
1194    Ok(WorktreeAdminAddPlan {
1195        admin_dir,
1196        stale_admins: Some(existing),
1197    })
1198}
1199
1200/// Remove a linked worktree and its admin directory.
1201///
1202/// # Errors
1203///
1204/// Returns [`WorktreeError::NotRegistered`] for an unknown `path`,
1205/// [`WorktreeError::MainWorktree`] for the main worktree, and, unless `force` is set,
1206/// [`WorktreeError::Locked`] or [`WorktreeError::HasLocalChanges`].
1207pub fn remove_linked_worktree(repo: &Repository, path: &Path, force: bool) -> Result<()> {
1208    let entry = find_linked_worktree(repo, path, WorktreeOperation::Remove)?;
1209    if entry.is_locked && !force {
1210        return Err(WorktreeError::Locked {
1211            reason: entry.lock_reason,
1212        }
1213        .into());
1214    }
1215
1216    let linked = repo.open_linked_worktree(&entry.admin_dir, &entry.path)?;
1217    if !force && worktree_has_local_changes(&linked)? {
1218        return Err(WorktreeError::HasLocalChanges.into());
1219    }
1220
1221    fs::remove_dir_all(&entry.admin_dir).map_err(Error::Io)?;
1222    if entry.path.exists() {
1223        fs::remove_dir_all(&entry.path).map_err(Error::Io)?;
1224    }
1225    Ok(())
1226}
1227
1228/// The registered linked worktree whose root is `path`, for a mutation named by `operation`.
1229///
1230/// # Errors
1231///
1232/// [`WorktreeError::NotRegistered`] when no worktree is registered at `path`, and
1233/// [`WorktreeError::MainWorktree`] when `path` is the main working tree.
1234pub(crate) fn find_linked_worktree(
1235    repo: &Repository,
1236    path: &Path,
1237    operation: WorktreeOperation,
1238) -> Result<WorktreeEntry> {
1239    let target = path_for_disk_compare(path);
1240    let Some(entry) = list_worktrees(repo)?.into_iter().find(|e| e.path == target) else {
1241        return Err(WorktreeError::NotRegistered { path: target }.into());
1242    };
1243    if entry.admin_dir == common_git_dir(repo.git_dir()) {
1244        return Err(WorktreeError::MainWorktree { operation }.into());
1245    }
1246    Ok(entry)
1247}
1248
1249/// Write `<admin>/locked` for the linked worktree at `path`.
1250///
1251/// # Errors
1252///
1253/// Fails as [`find_linked_worktree`] does, with [`WorktreeError::AlreadyLocked`] when the
1254/// worktree is already locked, or with I/O errors writing the file.
1255pub(crate) fn lock_linked_worktree(
1256    repo: &Repository,
1257    path: &Path,
1258    reason: Option<&str>,
1259) -> Result<()> {
1260    let entry = find_linked_worktree(repo, path, WorktreeOperation::Lock)?;
1261    if entry.is_locked {
1262        return Err(WorktreeError::AlreadyLocked {
1263            reason: entry.lock_reason,
1264        }
1265        .into());
1266    }
1267    write_admin_lock_file(&entry.admin_dir, reason)
1268}
1269
1270/// Delete `<admin>/locked` for the linked worktree at `path`.
1271///
1272/// # Errors
1273///
1274/// Fails as [`find_linked_worktree`] does, with [`WorktreeError::NotLocked`] when the worktree
1275/// is not locked, or with I/O errors removing the file.
1276pub(crate) fn unlock_linked_worktree(repo: &Repository, path: &Path) -> Result<()> {
1277    let entry = find_linked_worktree(repo, path, WorktreeOperation::Unlock)?;
1278    if !entry.is_locked {
1279        return Err(WorktreeError::NotLocked.into());
1280    }
1281    fs::remove_file(entry.admin_dir.join("locked")).map_err(Error::Io)
1282}
1283
1284fn resolve_existing_branch(repo: &Repository, spec: &str) -> Option<String> {
1285    let full = if spec.starts_with("refs/heads/") {
1286        spec.to_owned()
1287    } else {
1288        format!("refs/heads/{spec}")
1289    };
1290    repo.resolve_ref_oid(&full).ok()?;
1291    Some(full)
1292}
1293
1294fn branch_checked_out_elsewhere(repo: &Repository, branch_ref: &str) -> Result<()> {
1295    for wt in list_worktrees(repo)? {
1296        if let HeadState::Branch { refname, .. } = &wt.head {
1297            if refname == branch_ref {
1298                return Err(branch_checked_out_error(
1299                    branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref),
1300                    &wt.path,
1301                ));
1302            }
1303        }
1304    }
1305    Ok(())
1306}
1307
1308fn ensure_empty_or_missing(path: &Path) -> Result<()> {
1309    if !path.exists() {
1310        return Ok(());
1311    }
1312    if !path.is_dir() {
1313        return Err(WorktreeError::PathExistsNotDirectory {
1314            path: path.to_path_buf(),
1315        }
1316        .into());
1317    }
1318    let mut entries = fs::read_dir(path).map_err(Error::Io)?;
1319    if entries.next().is_some() {
1320        return Err(WorktreeError::PathExists {
1321            path: path.to_path_buf(),
1322        }
1323        .into());
1324    }
1325    Ok(())
1326}
1327
1328fn worktree_has_local_changes(repo: &Repository) -> Result<bool> {
1329    let model = status(
1330        repo,
1331        &StatusOptions::default(),
1332        &mut NullProgress,
1333        &crate::progress::NeverCancel,
1334    )?;
1335    Ok(!model.staged.is_empty()
1336        || !model.unstaged.is_empty()
1337        || !model.conflicts.is_empty()
1338        || !model.untracked.is_empty())
1339}
1340
1341/// Read the working tree path from `<admin>/gitdir` (parent of the worktree `.git` file).
1342pub fn read_worktree_path(admin: &Path) -> Result<PathBuf> {
1343    let gitdir_path = admin.join("gitdir");
1344    if !gitdir_path.is_file() {
1345        return Ok(admin.to_path_buf());
1346    }
1347    let raw = fs::read_to_string(&gitdir_path).map_err(Error::Io)?;
1348    let mut p = PathBuf::from(raw.trim());
1349    if p.is_relative() {
1350        p = admin.join(p);
1351    }
1352    let parent = p.parent().unwrap_or(&p).to_path_buf();
1353    Ok(path_for_disk_compare(&parent))
1354}
1355
1356fn read_lock_state(admin: &Path) -> Result<(bool, Option<String>)> {
1357    let locked_file = admin.join("locked");
1358    if !locked_file.is_file() {
1359        return Ok((false, None));
1360    }
1361    let content = fs::read_to_string(&locked_file).map_err(Error::Io)?;
1362    let reason = content.trim();
1363    if reason.is_empty() {
1364        Ok((true, None))
1365    } else {
1366        Ok((true, Some(reason.to_owned())))
1367    }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372    use super::*;
1373    use crate::repo::Repository;
1374    use std::fs;
1375    use tempfile::TempDir;
1376
1377    #[test]
1378    fn list_main_worktree_only() {
1379        let tmp = TempDir::new().unwrap();
1380        let root = tmp.path().join("repo");
1381        fs::create_dir_all(root.join(".git")).unwrap();
1382        fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
1383        fs::create_dir_all(root.join(".git/objects")).unwrap();
1384        fs::write(
1385            root.join(".git/config"),
1386            "[core]\n\trepositoryformatversion = 0\n",
1387        )
1388        .unwrap();
1389
1390        let repo = Repository::open(
1391            &root.join(".git"),
1392            &RepositoryOptions::empty().with_work_tree(&root),
1393        )
1394        .unwrap();
1395        let list = list_worktrees(&repo).unwrap();
1396        assert_eq!(list.len(), 1);
1397        assert_eq!(list[0].path, path_for_disk_compare(&root));
1398        assert!(!list[0].is_bare);
1399    }
1400
1401    #[test]
1402    fn allocate_unique_worktree_id() {
1403        let tmp = TempDir::new().unwrap();
1404        let common = tmp.path().join("git");
1405        fs::create_dir_all(common.join("worktrees/here")).unwrap();
1406        let admin = allocate_worktree_admin_dir(&common, Path::new("/tmp/sub/here"));
1407        assert_eq!(admin, common.join("worktrees/here1"));
1408    }
1409
1410    #[test]
1411    fn strip_worktree_config_removes_core_bare_and_worktree() {
1412        let tmp = TempDir::new().unwrap();
1413        let path = tmp.path().join("config.worktree");
1414        fs::write(
1415            &path,
1416            "[core]\n\tbare = true\n\tworktree = /wt\n[bogus]\n\tkey = value\n",
1417        )
1418        .unwrap();
1419        strip_worktree_config_keys(&path, &["core.bare", "core.worktree"]).unwrap();
1420        let out = fs::read_to_string(&path).unwrap();
1421        assert!(out.contains("bogus"));
1422        assert!(!out.contains("bare"));
1423        assert!(!out.contains("worktree"));
1424    }
1425
1426    #[test]
1427    fn sanitize_funny_worktree_name() {
1428        assert_eq!(
1429            sanitize_worktree_id_component(".  weird*..?.lock.lock"),
1430            "---weird-.-"
1431        );
1432    }
1433
1434    #[test]
1435    fn read_worktree_path_from_gitdir_file() {
1436        let tmp = TempDir::new().unwrap();
1437        let admin = tmp.path().join("wt-admin");
1438        fs::create_dir_all(&admin).unwrap();
1439        let wt = tmp.path().join("linked");
1440        fs::create_dir_all(wt.join(".git")).unwrap();
1441        fs::write(
1442            admin.join("gitdir"),
1443            format!("{}\n", wt.join(".git").display()),
1444        )
1445        .unwrap();
1446        let path = read_worktree_path(&admin).unwrap();
1447        assert_eq!(path, path_for_disk_compare(&wt));
1448    }
1449
1450    #[test]
1451    fn add_and_remove_linked_worktree_via_library() {
1452        use std::process::Command;
1453        let tmp = TempDir::new().unwrap();
1454        let main = tmp.path().join("main");
1455        fs::create_dir_all(&main).unwrap();
1456        let git = |args: &[&str]| {
1457            let out = Command::new("git")
1458                .env("GIT_AUTHOR_NAME", "Test")
1459                .env("GIT_AUTHOR_EMAIL", "test@example.com")
1460                .env("GIT_COMMITTER_NAME", "Test")
1461                .env("GIT_COMMITTER_EMAIL", "test@example.com")
1462                .current_dir(&main)
1463                .args(args)
1464                .output()
1465                .unwrap();
1466            assert!(out.status.success(), "git {args:?}");
1467        };
1468        git(&["init", "-q", "-b", "main"]);
1469        fs::write(main.join("t"), b"1\n").unwrap();
1470        git(&["add", "t"]);
1471        git(&["commit", "-qm", "init"]);
1472
1473        let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1474        let linked = tmp.path().join("linked");
1475        add_linked_worktree(
1476            &repo,
1477            &AddLinkedWorktreeOptions {
1478                path: linked.clone(),
1479                branch: Some(WorktreeBranch::Create("wt".into())),
1480                start_point: Some("HEAD".into()),
1481                force: false,
1482                lock: None,
1483                no_checkout: false,
1484                track: false,
1485            },
1486        )
1487        .unwrap();
1488        assert_eq!(list_worktrees(&repo).unwrap().len(), 2);
1489        remove_linked_worktree(&repo, &linked, false).unwrap();
1490        assert_eq!(list_worktrees(&repo).unwrap().len(), 1);
1491    }
1492
1493    #[test]
1494    fn registered_worktree_count_and_basename() {
1495        let tmp = TempDir::new().unwrap();
1496        let common = tmp.path().join("git");
1497        fs::create_dir_all(common.join("worktrees/a")).unwrap();
1498        fs::create_dir_all(common.join("worktrees/b")).unwrap();
1499        assert_eq!(registered_worktree_count(&common), 3);
1500        assert_eq!(
1501            worktree_path_basename(Path::new("/tmp/my/project/")),
1502            "project"
1503        );
1504    }
1505
1506    #[test]
1507    fn registered_worktree_count_without_worktrees_dir() {
1508        let tmp = TempDir::new().unwrap();
1509        let common = tmp.path().join("only-main");
1510        fs::create_dir_all(&common).unwrap();
1511        assert_eq!(registered_worktree_count(&common), 1);
1512    }
1513
1514    #[test]
1515    fn common_git_dir_and_bare_detection() {
1516        let tmp = TempDir::new().unwrap();
1517        let git_dir = tmp.path().join("bare.git");
1518        fs::create_dir_all(&git_dir).unwrap();
1519        fs::write(
1520            git_dir.join("config"),
1521            "[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
1522        )
1523        .unwrap();
1524        assert!(is_bare_repository(&git_dir));
1525        assert_eq!(common_git_dir(&git_dir), git_dir);
1526    }
1527
1528    #[test]
1529    fn sanitize_worktree_id_special_cases() {
1530        assert_eq!(sanitize_worktree_id_component("@"), "-");
1531        assert!(!sanitize_worktree_id_component("@{upstream}").contains('@'));
1532        assert_eq!(sanitize_worktree_id_component("..name"), ".name");
1533        assert_eq!(sanitize_worktree_id_component(".lock.lock"), "-lock");
1534        let tmp = TempDir::new().unwrap();
1535        let common = tmp.path().join("git");
1536        fs::create_dir_all(&common).unwrap();
1537        assert_eq!(
1538            allocate_worktree_admin_dir(&common, Path::new("/")),
1539            common.join("worktrees/worktree")
1540        );
1541    }
1542
1543    #[test]
1544    fn copy_filtered_worktree_config_no_source_is_ok() {
1545        let tmp = TempDir::new().unwrap();
1546        let admin = tmp.path().join("admin");
1547        fs::create_dir_all(&admin).unwrap();
1548        copy_filtered_worktree_config(tmp.path(), &admin).unwrap();
1549        assert!(!admin.join("config.worktree").exists());
1550    }
1551
1552    #[test]
1553    fn copy_filtered_worktree_config_copies_file() {
1554        let tmp = TempDir::new().unwrap();
1555        let common = tmp.path().join("common");
1556        let admin = tmp.path().join("admin");
1557        fs::create_dir_all(&common).unwrap();
1558        fs::create_dir_all(&admin).unwrap();
1559        fs::write(
1560            common.join("config.worktree"),
1561            "[core]\n\tbare = false\n\tworktree = /x\n[ext]\n\tk = v\n",
1562        )
1563        .unwrap();
1564        copy_filtered_worktree_config(&common, &admin).unwrap();
1565        let out = fs::read_to_string(admin.join("config.worktree")).unwrap();
1566        assert!(out.contains("[ext]"));
1567        assert!(!out.contains("worktree"));
1568    }
1569
1570    #[test]
1571    fn read_lock_state_and_missing_gitdir_path() {
1572        let tmp = TempDir::new().unwrap();
1573        let admin = tmp.path().join("admin");
1574        fs::create_dir_all(&admin).unwrap();
1575        assert_eq!(read_worktree_path(&admin).unwrap(), admin);
1576        fs::write(admin.join("locked"), "maintenance\n").unwrap();
1577        let (locked, reason) = read_lock_state(&admin).unwrap();
1578        assert!(locked);
1579        assert_eq!(reason.as_deref(), Some("maintenance"));
1580    }
1581
1582    #[test]
1583    fn ensure_empty_or_missing_rejects_nonempty_dir() {
1584        let tmp = TempDir::new().unwrap();
1585        let dir = tmp.path().join("occupied");
1586        fs::create_dir_all(&dir).unwrap();
1587        fs::write(dir.join("file"), b"x").unwrap();
1588        assert!(matches!(
1589            ensure_empty_or_missing(&dir),
1590            Err(Error::Worktree(WorktreeError::PathExists { path })) if path == dir
1591        ));
1592        assert!(matches!(
1593            ensure_empty_or_missing(&dir.join("file")),
1594            Err(Error::Worktree(
1595                WorktreeError::PathExistsNotDirectory { .. }
1596            ))
1597        ));
1598        let missing = tmp.path().join("missing");
1599        assert!(ensure_empty_or_missing(&missing).is_ok());
1600    }
1601
1602    #[test]
1603    fn add_linked_worktree_rejects_bare_repository() {
1604        let tmp = TempDir::new().unwrap();
1605        let bare = tmp.path().join("bare.git");
1606        fs::create_dir_all(bare.join("objects")).unwrap();
1607        fs::write(bare.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1608        fs::write(
1609            bare.join("config"),
1610            "[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
1611        )
1612        .unwrap();
1613        let repo = Repository::open(&bare, &RepositoryOptions::empty()).unwrap();
1614        let err = add_linked_worktree(
1615            &repo,
1616            &AddLinkedWorktreeOptions {
1617                path: tmp.path().join("wt"),
1618                branch: Some(WorktreeBranch::Create("wt".into())),
1619                start_point: None,
1620                force: false,
1621                lock: None,
1622                no_checkout: false,
1623                track: false,
1624            },
1625        )
1626        .unwrap_err();
1627        assert!(matches!(
1628            err,
1629            Error::Worktree(WorktreeError::BareRepository)
1630        ));
1631    }
1632
1633    #[test]
1634    fn list_worktrees_from_linked_checkout_normalizes_main_path() {
1635        use std::process::Command;
1636        let tmp = TempDir::new().unwrap();
1637        let main = tmp.path().join("main");
1638        fs::create_dir_all(&main).unwrap();
1639        let git = |args: &[&str]| {
1640            assert!(Command::new("git")
1641                .env("GIT_AUTHOR_NAME", "Test")
1642                .env("GIT_AUTHOR_EMAIL", "test@example.com")
1643                .env("GIT_COMMITTER_NAME", "Test")
1644                .env("GIT_COMMITTER_EMAIL", "test@example.com")
1645                .current_dir(&main)
1646                .args(args)
1647                .output()
1648                .unwrap()
1649                .status
1650                .success());
1651        };
1652        git(&["init", "-q", "-b", "main"]);
1653        fs::write(main.join("t"), b"1\n").unwrap();
1654        git(&["add", "t"]);
1655        git(&["commit", "-qm", "init"]);
1656
1657        let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1658        let linked = tmp.path().join("linked");
1659        add_linked_worktree(
1660            &repo,
1661            &AddLinkedWorktreeOptions {
1662                path: linked.clone(),
1663                branch: Some(WorktreeBranch::Create("wt".into())),
1664                start_point: Some("HEAD".into()),
1665                force: false,
1666                lock: None,
1667                no_checkout: false,
1668                track: false,
1669            },
1670        )
1671        .unwrap();
1672
1673        let linked_repo =
1674            Repository::discover(Some(linked.as_path()), &RepositoryOptions::empty()).unwrap();
1675        let list = list_worktrees(&linked_repo).unwrap();
1676        assert_eq!(list.len(), 2);
1677        let expected_main = path_for_disk_compare(&main);
1678        assert_eq!(list[0].path, expected_main);
1679        #[cfg(windows)]
1680        {
1681            let displayed = list[0].path.display().to_string();
1682            assert!(
1683                !displayed.starts_with(r"\\?\"),
1684                "main worktree path must not use the verbatim prefix: {displayed}"
1685            );
1686        }
1687    }
1688
1689    #[test]
1690    fn branch_checkout_exclusivity_helpers() {
1691        use std::process::Command;
1692        let tmp = TempDir::new().unwrap();
1693        let main = tmp.path().join("main");
1694        fs::create_dir_all(&main).unwrap();
1695        let git = |args: &[&str]| {
1696            assert!(Command::new("git")
1697                .env("GIT_AUTHOR_NAME", "Test")
1698                .env("GIT_AUTHOR_EMAIL", "test@example.com")
1699                .env("GIT_COMMITTER_NAME", "Test")
1700                .env("GIT_COMMITTER_EMAIL", "test@example.com")
1701                .current_dir(&main)
1702                .args(args)
1703                .output()
1704                .unwrap()
1705                .status
1706                .success());
1707        };
1708        git(&["init", "-q", "-b", "main"]);
1709        fs::write(main.join("t"), b"1\n").unwrap();
1710        git(&["add", "t"]);
1711        git(&["commit", "-qm", "init"]);
1712
1713        let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1714        let linked = tmp.path().join("linked");
1715        add_linked_worktree(
1716            &repo,
1717            &AddLinkedWorktreeOptions {
1718                path: linked.clone(),
1719                branch: Some(WorktreeBranch::Create("feature".into())),
1720                start_point: Some("HEAD".into()),
1721                force: false,
1722                lock: None,
1723                no_checkout: false,
1724                track: false,
1725            },
1726        )
1727        .unwrap();
1728
1729        let linked_repo =
1730            Repository::discover(Some(linked.as_path()), &RepositoryOptions::empty()).unwrap();
1731        let linked_admin = linked_repo
1732            .git_dir()
1733            .canonicalize()
1734            .unwrap_or_else(|_| linked_repo.git_dir().to_path_buf());
1735        assert_eq!(worktree_admin_dir(&linked_repo), linked_admin);
1736
1737        let found = find_worktree_with_branch_checked_out(&repo, "feature", None)
1738            .unwrap()
1739            .expect("linked checkout");
1740        assert_eq!(found.branch, "feature");
1741
1742        assert!(ensure_branch_not_checked_out_in_any_worktree(&repo, "feature").is_err());
1743        assert!(ensure_branch_not_checked_out_elsewhere(&linked_repo, "feature").is_ok());
1744        assert!(ensure_branch_not_checked_out_elsewhere(&repo, "feature").is_err());
1745    }
1746
1747    #[test]
1748    fn missing_registered_path_rejects_add_without_force() {
1749        use std::process::Command;
1750        let tmp = TempDir::new().unwrap();
1751        let main = tmp.path().join("main");
1752        fs::create_dir_all(&main).unwrap();
1753        let git = |args: &[&str]| {
1754            assert!(Command::new("git")
1755                .current_dir(&main)
1756                .args(args)
1757                .output()
1758                .unwrap()
1759                .status
1760                .success());
1761        };
1762        git(&["init", "-q", "-b", "main"]);
1763        fs::write(main.join("t"), b"1\n").unwrap();
1764        git(&["add", "t"]);
1765        git(&["commit", "-qm", "init"]);
1766
1767        let repo =
1768            Repository::discover(Some(&main), &crate::environment::RepositoryOptions::empty())
1769                .unwrap();
1770        let linked = tmp.path().join("linked");
1771        add_linked_worktree(
1772            &repo,
1773            &AddLinkedWorktreeOptions {
1774                path: linked.clone(),
1775                branch: Some(WorktreeBranch::Create("a".into())),
1776                start_point: Some("HEAD".into()),
1777                force: false,
1778                lock: None,
1779                no_checkout: false,
1780                track: false,
1781            },
1782        )
1783        .unwrap();
1784        fs::remove_dir_all(&linked).unwrap();
1785        assert!(matches!(
1786            add_linked_worktree(
1787                &repo,
1788                &AddLinkedWorktreeOptions {
1789                    path: linked.clone(),
1790                    branch: Some(WorktreeBranch::Create("b".into())),
1791                    start_point: Some("HEAD".into()),
1792                    force: false,
1793                    lock: None,
1794                    no_checkout: false,
1795                    track: false,
1796                },
1797            )
1798            .unwrap_err(),
1799            Error::Worktree(WorktreeError::PathRegisteredMissing { .. })
1800        ));
1801    }
1802
1803    #[test]
1804    fn remove_linked_worktree_refuses_main_and_locked() {
1805        use std::process::Command;
1806        let tmp = TempDir::new().unwrap();
1807        let main = tmp.path().join("main");
1808        fs::create_dir_all(&main).unwrap();
1809        let git = |args: &[&str]| {
1810            assert!(Command::new("git")
1811                .env("GIT_AUTHOR_NAME", "Test")
1812                .env("GIT_AUTHOR_EMAIL", "test@example.com")
1813                .env("GIT_COMMITTER_NAME", "Test")
1814                .env("GIT_COMMITTER_EMAIL", "test@example.com")
1815                .current_dir(&main)
1816                .args(args)
1817                .output()
1818                .unwrap()
1819                .status
1820                .success());
1821        };
1822        git(&["init", "-q", "-b", "main"]);
1823        fs::write(main.join("t"), b"1\n").unwrap();
1824        git(&["add", "t"]);
1825        git(&["commit", "-qm", "init"]);
1826
1827        let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1828        assert!(matches!(
1829            remove_linked_worktree(&repo, &main, false),
1830            Err(Error::Worktree(WorktreeError::MainWorktree {
1831                operation: WorktreeOperation::Remove
1832            }))
1833        ));
1834
1835        let linked = tmp.path().join("linked");
1836        add_linked_worktree(
1837            &repo,
1838            &AddLinkedWorktreeOptions {
1839                path: linked.clone(),
1840                branch: Some(WorktreeBranch::Create("wt".into())),
1841                start_point: Some("HEAD".into()),
1842                force: false,
1843                lock: None,
1844                no_checkout: false,
1845                track: false,
1846            },
1847        )
1848        .unwrap();
1849        let common = common_git_dir(repo.git_dir());
1850        let admin = fs::read_dir(common.join("worktrees"))
1851            .unwrap()
1852            .find_map(|e| e.ok())
1853            .map(|e| e.path())
1854            .expect("admin");
1855        fs::write(admin.join("locked"), "hold\n").unwrap();
1856        assert!(matches!(
1857            remove_linked_worktree(&repo, &linked, false),
1858            Err(Error::Worktree(WorktreeError::Locked { reason: Some(r) })) if r == "hold"
1859        ));
1860        assert!(remove_linked_worktree(&repo, &linked, true).is_ok());
1861        assert!(matches!(
1862            remove_linked_worktree(&repo, &linked, false),
1863            Err(Error::Worktree(WorktreeError::NotRegistered { .. }))
1864        ));
1865    }
1866}