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