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