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