Skip to main content

wt/worktree/
service.rs

1//! The stateless worktree service (issue #95): discover, enumerate, create,
2//! and remove worktrees non-interactively.
3//!
4//! Nothing here prompts, reads stdin, or writes to stdout/stderr. Operations
5//! take injected [`GitCli`] and [`HookRunner`] handles, return typed errors
6//! from [`crate::error::Error`], and report side observations (hook failures,
7//! submodule outcomes, copied files) as data on the outcome structs so callers
8//! decide how to present them. The CLI commands and the TUI wrap this service
9//! with prompting and rendering; embedders call it directly.
10
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use crate::config::wtconfig::WtMeta;
15use crate::config::{self, Config, wtconfig};
16use crate::copy::{CopyOutcome, copy_ignored_files};
17use crate::cx::Env;
18use crate::error::{Error, Result};
19use crate::git::cli::GitCli;
20use crate::git::discover::Repo;
21use crate::git::submodule::seed::SeedReport;
22use crate::git::{branch_ref, default_branch, is_ancestor, ops, resolve_hex};
23use crate::hooks::{HookContext, HookRunner};
24use crate::model::Worktree;
25use crate::query::{self, Resolved};
26use crate::slug::slugify_with_fallback;
27use crate::template::{self, TemplateVars};
28use crate::worktree::{materialize, rows};
29
30/// How long a mutation waits for the advisory repository lock (issue #99)
31/// before failing with [`Error::LockUnavailable`].
32const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
33
34/// A held advisory repository lock (issue #99): while alive, no other `wt` (or
35/// embedder going through this library) can mutate worktrees or `wt.*`
36/// metadata in the repository. Released on drop.
37///
38/// [`Workspace::create`] and [`Workspace::remove`] take the lock internally
39/// around their mutation regions (hooks run *outside* it, so a hook that
40/// re-enters `wt` cannot deadlock) — do not hold a `RepoLock` while calling
41/// them. Take one directly to make your own read-check-write sequence over
42/// `wt.*` metadata atomic against concurrent writers.
43///
44/// Acquiring the lock also validates the repository's metadata schema, so
45/// every acquisition fails with [`Error::SchemaTooNew`] rather than hand back
46/// a lock over a repository this build cannot interpret (issue #106).
47pub struct RepoLock {
48    _marker: gix_lock::Marker,
49}
50
51/// The directory holding the advisory lock file: the repository's common git
52/// directory, shared by every linked worktree (`.git` of the primary worktree,
53/// or the repository itself when bare).
54fn lock_dir(root: &Path) -> PathBuf {
55    let dot_git = root.join(".git");
56    if dot_git.is_dir() {
57        dot_git
58    } else {
59        root.to_path_buf()
60    }
61}
62
63/// Re-reads the repository's metadata schema and refuses a version this build
64/// cannot interpret.
65///
66/// The read goes through a *freshly opened* repository deliberately: `gix`
67/// snapshots the git config when a repository is opened, so a handle obtained
68/// before the lock cannot see a `wt.schema` bump that landed while the caller
69/// waited for it (issue #106).
70fn ensure_schema_supported_now(root: &Path) -> Result<()> {
71    wtconfig::ensure_schema_supported(Repo::discover(root)?.gix())
72}
73
74/// Acquires the repo-level advisory mutation lock, waiting (with backoff) up
75/// to `timeout` for a concurrent holder to finish.
76///
77/// The metadata schema is validated *after* the lock is held (issue #106), so
78/// the gate covers the window it exists to protect: a concurrent `wt.schema`
79/// bump either lands before the check and is refused, or is excluded until the
80/// mutation finishes. Every mutating path takes the lock, so folding the check
81/// in here is what makes "no mutation runs against an unsupported schema" hold
82/// by construction rather than by each call site remembering to ask. A refusal
83/// drops the lock on the way out, leaving nothing held.
84pub(crate) fn acquire_repo_lock(root: &Path, timeout: Duration) -> Result<RepoLock> {
85    let resource = lock_dir(root).join("wt-mutation");
86    let marker = gix_lock::Marker::acquire_to_hold_resource(
87        &resource,
88        gix_lock::acquire::Fail::AfterDurationWithBackoff(timeout),
89        None,
90    )
91    .map_err(|e| Error::LockUnavailable {
92        path: format!("{}.lock", resource.display()),
93        reason: e.to_string(),
94    })?;
95    let lock = RepoLock { _marker: marker };
96    ensure_schema_supported_now(root)?;
97    Ok(lock)
98}
99
100/// Acquires the repo-level advisory mutation lock with the standard timeout.
101/// This is the form the command handlers use: they hold the repo root inside a
102/// `Session` rather than a [`Workspace`], which is what [`Workspace::lock`]
103/// serves for library consumers.
104#[cfg(feature = "cli")]
105pub(crate) fn lock_repo(root: &Path) -> Result<RepoLock> {
106    acquire_repo_lock(root, LOCK_TIMEOUT)
107}
108
109/// A discovered repository with its resolved configuration and environment
110/// snapshot: the entry point of the stateless worktree API.
111pub struct Workspace {
112    repo: Repo,
113    primary_root: PathBuf,
114    config: Config,
115    env: Env,
116}
117
118/// Borrowed workspace state threaded through the service functions, so the CLI
119/// (which owns the same parts inside its `Session`) can call them without
120/// constructing a [`Workspace`].
121pub(crate) struct WorkspaceParts<'a> {
122    /// The discovered repository.
123    pub(crate) repo: &'a Repo,
124    /// The merged configuration.
125    pub(crate) config: &'a Config,
126    /// The primary worktree root (or bare repo path).
127    pub(crate) root: &'a Path,
128    /// The environment snapshot (template `{home}` expansion).
129    pub(crate) env: &'a Env,
130}
131
132impl Workspace {
133    /// Discovers the repository containing `dir`, resolves the primary worktree
134    /// root, and loads the merged configuration. Returns
135    /// [`Error::NotInRepo`] when `dir` is not inside a Git repository.
136    pub fn discover(dir: &Path, env: &Env, git: &dyn GitCli) -> Result<Workspace> {
137        let repo = Repo::discover(dir)?;
138        let workdir = repo.current_workdir().unwrap_or_else(|| repo.git_dir());
139        // `gix`'s common-dir resolution is unreliable through linked worktrees,
140        // so the primary root comes from `git rev-parse` (spec §4).
141        let common = git.run(
142            &workdir,
143            &["rev-parse", "--path-format=absolute", "--git-common-dir"],
144        )?;
145        let common = PathBuf::from(common.trim());
146        let primary_root = if repo.is_bare() {
147            common
148        } else {
149            common.parent().map(Path::to_path_buf).unwrap_or(common)
150        };
151        let config = config::load(Some(&primary_root), env)?;
152        // Refuse a repository stamped with a future metadata schema up front
153        // (issue #99); reading it could silently misinterpret `wt.*` keys.
154        wtconfig::ensure_schema_supported(repo.gix())?;
155        Ok(Workspace {
156            repo,
157            primary_root,
158            config,
159            env: env.clone(),
160        })
161    }
162
163    /// Acquires the repository's advisory mutation lock (issue #99). See
164    /// [`RepoLock`] for the holding rules.
165    ///
166    /// Fails with [`Error::SchemaTooNew`] when the repository is stamped with a
167    /// metadata schema this build cannot interpret, re-read under the lock
168    /// (issue #106) — so the version an embedder validated at discovery is
169    /// still the version it holds the lock over.
170    pub fn lock(&self) -> Result<RepoLock> {
171        acquire_repo_lock(&self.primary_root, LOCK_TIMEOUT)
172    }
173
174    /// The primary worktree root (or the repository path when bare). This is
175    /// the `repo_root` expected by the [`wtconfig`] write functions.
176    pub fn root(&self) -> &Path {
177        &self.primary_root
178    }
179
180    /// The merged configuration (defaults, global `config.toml`, repo
181    /// `.wt.toml`).
182    pub fn config(&self) -> &Config {
183        &self.config
184    }
185
186    /// Whether the primary repository is bare.
187    pub fn is_bare(&self) -> bool {
188        self.repo.is_bare()
189    }
190
191    /// Enumerates worktrees with their synchronous fields only (path, branch,
192    /// slug, current/main/missing/detached markers).
193    pub fn enumerate(&self, git: &dyn GitCli) -> Result<Vec<Worktree>> {
194        rows::enumerate_worktrees(&self.fresh_repo()?, git)
195    }
196
197    /// Enumerates worktrees fully enriched: dirty/untracked status,
198    /// ahead/behind, merge state, tip commits, and the cached PR metadata from
199    /// `wt.*` config.
200    pub fn list(&self, git: &dyn GitCli) -> Result<Vec<Worktree>> {
201        rows::build_worktrees(&self.fresh_repo()?, git)
202    }
203
204    /// Reads the `wt.*` metadata recorded for `branch`.
205    pub fn read_meta(&self, branch: &str) -> Result<WtMeta> {
206        Ok(wtconfig::read_meta(self.fresh_repo()?.gix(), branch))
207    }
208
209    /// Writes the `Some` fields of `update` to `branch`'s `wt.*` metadata,
210    /// leaving every other recorded key alone (issue #95).
211    ///
212    /// The whole update is applied under the advisory repository lock (issue
213    /// #99), so a concurrent writer never observes half a bundle — do not call
214    /// this while already holding a [`RepoLock`]. To make a read-check-write
215    /// sequence atomic, take [`Workspace::lock`] and drive the [`wtconfig`]
216    /// setters directly instead.
217    ///
218    /// The schema gate is the lock's (issue #106): nothing here runs ahead of
219    /// it, so there is no separate check to keep in step.
220    pub fn write_meta(&self, git: &dyn GitCli, branch: &str, update: &MetaUpdate) -> Result<()> {
221        let _lock = self.lock()?;
222        apply_meta(git, &self.primary_root, branch, update)
223    }
224
225    /// Removes every `wt.<branch>.*` key, under the advisory repository lock.
226    /// A branch with no recorded metadata is not an error.
227    pub fn clear_meta(&self, git: &dyn GitCli, branch: &str) -> Result<()> {
228        let _lock = self.lock()?;
229        wtconfig::clear_meta(git, &self.primary_root, branch)
230    }
231
232    /// Creates (or reuses) a linked worktree per `options`: resolves the target
233    /// from the configured path template, creates the branch off its base when
234    /// needed, records `wt.*` metadata, runs the copy step, the `post_create`
235    /// hook, and (when requested) submodule initialization. Partial failures
236    /// before the hook are rolled back (spec §13).
237    pub fn create(
238        &self,
239        git: &dyn GitCli,
240        hooks: &dyn HookRunner,
241        options: &CreateOptions,
242    ) -> Result<CreatedWorktree> {
243        let repo = self.fresh_repo()?;
244        create_in(&self.parts(&repo), git, hooks, options)
245    }
246
247    /// Removes `worktree` under `options`, enforcing the dirty/unpushed safety
248    /// guards (returning [`Error::RemoveGuarded`] when they block), running the
249    /// `pre_remove` hook, pruning a missing worktree, and deleting a
250    /// fully-merged wt-created branch per the configuration.
251    pub fn remove(
252        &self,
253        git: &dyn GitCli,
254        hooks: &dyn HookRunner,
255        worktree: &Worktree,
256        options: &RemoveOptions,
257    ) -> Result<RemovedWorktree> {
258        let repo = self.fresh_repo()?;
259        remove_in(&self.parts(&repo), git, hooks, worktree, options)
260    }
261
262    /// Re-opens the repository for one operation. `gix` snapshots the git
263    /// config when a repository is opened, while `wt.*` metadata is written
264    /// through the `git` subprocess — a long-lived `Workspace` reading through
265    /// the discovery-time handle would see stale metadata, so every operation
266    /// reads through a fresh one.
267    fn fresh_repo(&self) -> Result<Repo> {
268        let dir = self
269            .repo
270            .current_workdir()
271            .unwrap_or_else(|| self.repo.git_dir());
272        Repo::discover(&dir)
273    }
274
275    /// The borrowed parts view of this workspace over `repo`.
276    fn parts<'a>(&'a self, repo: &'a Repo) -> WorkspaceParts<'a> {
277        WorkspaceParts {
278            repo,
279            config: &self.config,
280            root: &self.primary_root,
281            env: &self.env,
282        }
283    }
284
285    /// Decomposes into the parts the CLI session keeps.
286    #[cfg_attr(not(feature = "cli"), allow(dead_code))]
287    pub(crate) fn into_session_parts(self) -> (Repo, PathBuf, Config) {
288        (self.repo, self.primary_root, self.config)
289    }
290}
291
292/// A `wt.<branch>.*` metadata update for [`Workspace::write_meta`]: every
293/// `Some` field is written and every `None` field leaves the recorded value
294/// untouched, so a caller refreshing one key cannot clobber the rest.
295#[derive(Debug, Clone, Default, PartialEq, Eq)]
296pub struct MetaUpdate {
297    /// The base ref the branch was created from (spec §3).
298    pub base_ref: Option<String>,
299    /// The associated PR number (spec §7).
300    pub pr_number: Option<u64>,
301    /// The cached PR state, so `wt list` can show it offline.
302    pub pr_state: Option<String>,
303    /// The cached PR title.
304    pub pr_title: Option<String>,
305    /// The cached PR URL.
306    pub pr_url: Option<String>,
307    /// The linked GitHub issue number (issue #100).
308    pub issue_number: Option<u64>,
309    /// The cached issue title.
310    pub issue_title: Option<String>,
311    /// The cached issue URL.
312    pub issue_url: Option<String>,
313    /// The generated implementation brief, persisted for embedders.
314    pub issue_brief: Option<String>,
315    /// Marks the branch as created by `wt` (spec §10), which is what allows a
316    /// later remove to delete it. There is no un-marking: `false` leaves the
317    /// recorded flag as it is.
318    pub created_by_wt: bool,
319}
320
321/// Applies a [`MetaUpdate`] with no locking, for callers already inside a
322/// locked mutation region ([`create_in`], the `wt pr` checkout path).
323pub(crate) fn apply_meta(
324    git: &dyn GitCli,
325    root: &Path,
326    branch: &str,
327    update: &MetaUpdate,
328) -> Result<()> {
329    if let Some(base_ref) = &update.base_ref {
330        wtconfig::write_base_ref(git, root, branch, base_ref)?;
331    }
332    if let Some(number) = update.pr_number {
333        wtconfig::write_pr_number(git, root, branch, number)?;
334    }
335    if let Some(state) = &update.pr_state {
336        wtconfig::write_pr_state(git, root, branch, state)?;
337    }
338    if let Some(title) = &update.pr_title {
339        wtconfig::write_pr_title(git, root, branch, title)?;
340    }
341    if let Some(url) = &update.pr_url {
342        wtconfig::write_pr_url(git, root, branch, url)?;
343    }
344    if let Some(number) = update.issue_number {
345        wtconfig::write_issue_number(git, root, branch, number)?;
346    }
347    if let Some(title) = &update.issue_title {
348        wtconfig::write_issue_title(git, root, branch, title)?;
349    }
350    if let Some(url) = &update.issue_url {
351        wtconfig::write_issue_url(git, root, branch, url)?;
352    }
353    if let Some(brief) = &update.issue_brief {
354        wtconfig::write_issue_brief(git, root, branch, brief)?;
355    }
356    if update.created_by_wt {
357        wtconfig::mark_created_by_wt(git, root, branch)?;
358    }
359    Ok(())
360}
361
362/// Options for [`Workspace::create`].
363#[derive(Debug, Clone, Default)]
364pub struct CreateOptions {
365    /// The branch to check out, created off `base` when it does not exist.
366    pub branch: String,
367    /// Explicit base ref for a new branch. `None` resolves the configured
368    /// `default_base`, then the repository default branch, then `HEAD`.
369    /// Ignored when the branch already exists.
370    pub base: Option<String>,
371    /// Set this ref as the new branch's upstream (`--track`).
372    pub track: Option<String>,
373    /// Copy-source worktree query (spec §8); `None` copies from the current
374    /// worktree (or the primary root).
375    pub copy_from: Option<String>,
376    /// Initialize uninitialized submodules after creation. The service never
377    /// prompts: callers resolve their `submodules.init` policy (and any flag
378    /// override) to a boolean first.
379    pub init_submodules: bool,
380    /// Seed those submodules from the repository's own local object stores
381    /// rather than re-cloning them from their remotes. Only an accelerator: the
382    /// stock `submodule update --init --recursive` still runs afterwards and
383    /// determines the result, so this cannot change the outcome. Resolved by the
384    /// caller from `submodules.seed`. Ignored when `init_submodules` is false.
385    pub seed_submodules: bool,
386    /// Materialize the worktree by copy-on-write cloning an existing one's files
387    /// rather than checking them out. Requires a CoW filesystem and a source
388    /// worktree already at the same tree; when either is missing this silently
389    /// uses the normal checkout. Resolved by the caller from `create.reflink`.
390    pub reflink: bool,
391    /// Skip the `post_create` hook.
392    pub no_hooks: bool,
393}
394
395/// Options for [`Workspace::remove`]. The worktree-removal force is decoupled
396/// from the branch-deletion force: the TUI confirm dialog forces removal of a
397/// dirty worktree without ever force-deleting an unmerged branch (spec §10/§12).
398#[derive(Debug, Clone, Copy, Default)]
399pub struct RemoveOptions {
400    /// Skip the dirty/unpushed guards and pass `--force` to
401    /// `git worktree remove`.
402    pub force_remove: bool,
403    /// Permit deleting a branch that is not fully merged into its base.
404    pub force_branch: bool,
405    /// Always keep the local branch.
406    pub keep_branch: bool,
407    /// Skip the `pre_remove` hook.
408    pub no_hooks: bool,
409}
410
411/// How a hook invocation went, reported as data (the service never prints).
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub enum HookOutcome {
414    /// No hook was configured, hooks were disabled, or the operation's path
415    /// does not run the hook (e.g. reusing an existing worktree).
416    Skipped,
417    /// The hook ran and exited zero.
418    Succeeded,
419    /// The hook ran and exited with this non-zero status.
420    ExitedNonZero(i32),
421    /// The hook could not be run at all.
422    Failed(String),
423}
424
425/// How the post-create submodule initialization went.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum SubmodulesOutcome {
428    /// Initialization was not requested or nothing was uninitialized.
429    Skipped,
430    /// This many pending submodules were initialized.
431    Initialized(usize),
432    /// Initialization was attempted for `pending` submodules but failed.
433    /// Non-fatal: the worktree exists and is usable.
434    Failed {
435        /// How many submodules were uninitialized when the attempt started.
436        pending: usize,
437        /// The failure, rendered.
438        error: String,
439    },
440}
441
442/// What the local-mirror seeding step managed to do, as counts plus the
443/// failures. Purely informational: seeding is always followed by a stock
444/// `git submodule update --init --recursive`, so a non-empty `failed` here does
445/// not mean the submodules are unpopulated.
446///
447/// Paths are relative to the worktree, so nested submodules read as
448/// `outer/inner`.
449#[derive(Debug, Clone, Default, PartialEq, Eq)]
450#[non_exhaustive]
451pub struct SubmoduleSeeding {
452    /// Submodules populated from a local mirror, sharing its objects.
453    pub seeded: Vec<String>,
454    /// Submodules with no local mirror, left to be cloned from their remote.
455    pub skipped: Vec<String>,
456    /// Submodules whose seeding failed, with the rendered error. The stock pass
457    /// still had its chance to populate these.
458    pub failed: Vec<(String, String)>,
459}
460
461impl From<SeedReport> for SubmoduleSeeding {
462    fn from(report: SeedReport) -> Self {
463        Self {
464            seeded: report.seeded,
465            skipped: report.skipped,
466            failed: report.failed,
467        }
468    }
469}
470
471/// The outcome of [`Workspace::create`].
472#[derive(Debug, Clone)]
473#[non_exhaustive]
474pub struct CreatedWorktree {
475    /// The worktree's path.
476    pub path: PathBuf,
477    /// The checked-out branch.
478    pub branch: String,
479    /// The base the branch was created from, or `None` when it already
480    /// existed.
481    pub base_ref: Option<String>,
482    /// Whether an existing worktree at the configured target was reused
483    /// instead of created (the idempotent path; no copy step, no hook).
484    pub reused: bool,
485    /// What the copy step did (spec §8).
486    pub copy: CopyOutcome,
487    /// How the `post_create` hook went. Never fatal (spec §8).
488    pub post_create: HookOutcome,
489    /// How submodule initialization went. Never fatal.
490    pub submodules: SubmodulesOutcome,
491    /// What the local-mirror seeding accelerator did, if it ran.
492    pub submodule_seeding: SubmoduleSeeding,
493    /// Whether the worktree's content was copy-on-write cloned from an existing
494    /// worktree rather than checked out. `false` covers both "not requested" and
495    /// "requested but unavailable"; the result is identical either way.
496    pub reflinked: bool,
497}
498
499/// The outcome of [`Workspace::remove`].
500#[derive(Debug, Clone)]
501#[non_exhaustive]
502pub struct RemovedWorktree {
503    /// Whether the local branch was deleted along with the worktree.
504    pub branch_deleted: bool,
505    /// Whether the dirty/unpushed guards would have blocked and were
506    /// overridden by [`RemoveOptions::force_remove`] — the data-loss-risk case
507    /// a caller should surface.
508    pub forced_past_guards: bool,
509    /// Whether `--force` was passed to `git worktree remove` solely because the
510    /// worktree contained populated submodules, which git refuses to remove
511    /// without it. Distinct from [`Self::forced_past_guards`]: no `wt` guard was
512    /// overridden and there is no data-loss risk to report, so callers must not
513    /// present this as a forced removal.
514    pub forced_for_submodules: bool,
515    /// How the `pre_remove` hook went. A failing hook aborts the removal
516    /// (with a typed error) unless `force_remove` downgraded it to an outcome
517    /// reported here.
518    pub pre_remove: HookOutcome,
519}
520
521/// Resolves the base ref for a new branch: `explicit`, then the configured
522/// `default_base`, then the repository default branch, then `HEAD`. The second
523/// element is `true` on the final `HEAD` fallback, which callers may want to
524/// surface as a warning.
525pub(crate) fn resolve_base(repo: &Repo, config: &Config, explicit: Option<&str>) -> (String, bool) {
526    if let Some(explicit) = explicit {
527        return (explicit.to_string(), false);
528    }
529    if let Some(base) = &config.default_base {
530        return (base.clone(), false);
531    }
532    if let Some(branch) = default_branch(repo.gix()) {
533        return (branch, false);
534    }
535    ("HEAD".to_string(), true)
536}
537
538/// The path `create_in` would materialize `branch` at, without creating
539/// anything.
540///
541/// Exists so a caller that previews the target before confirming (`wt issue`)
542/// derives it from the same slug and template logic `create_in` uses, rather
543/// than reimplementing it and drifting.
544#[cfg(feature = "cli")]
545pub(crate) fn preview_target(
546    ws: &WorkspaceParts<'_>,
547    branch: &str,
548    base: Option<&str>,
549) -> Result<std::path::PathBuf> {
550    let (slug, _) = target_slug(ws, branch, base)?;
551    render_target(ws.config, ws.root, branch, &slug, ws.env)
552}
553
554/// The directory slug for `branch`, derived exactly as [`create_in`] derives it.
555///
556/// The commit only matters for a branch name that slugifies to nothing, where it
557/// is the fallback — but that is precisely the case a preview would otherwise get
558/// wrong, so the rule is shared rather than restated: an existing branch keys off
559/// its own tip, a new one off the base it will fork from.
560fn target_slug(
561    ws: &WorkspaceParts<'_>,
562    branch: &str,
563    base: Option<&str>,
564) -> Result<(String, String)> {
565    let commit = match resolve_hex(ws.repo.gix(), &branch_ref(branch)) {
566        Some(oid) => oid,
567        None => {
568            let base_ref = resolve_base(ws.repo, ws.config, base).0;
569            resolve_hex(ws.repo.gix(), &base_ref)
570                .ok_or_else(|| Error::operation(format!("base ref {base_ref:?} not found")))?
571        }
572    };
573    let short_hash = commit.get(..7).unwrap_or(&commit).to_string();
574    let slug = slugify_with_fallback(branch, &short_hash);
575    Ok((slug, short_hash))
576}
577
578/// Creates (or reuses) a worktree per `options`; see [`Workspace::create`].
579pub(crate) fn create_in(
580    ws: &WorkspaceParts<'_>,
581    git: &dyn GitCli,
582    hooks: &dyn HookRunner,
583    options: &CreateOptions,
584) -> Result<CreatedWorktree> {
585    // Refuse an unsupported schema before the enumeration and base resolution
586    // below, so a stamped repository fails fast. This is only the fast path:
587    // the authoritative gate runs under the lock (issue #106), because a bump
588    // can still land between here and the acquisition.
589    wtconfig::ensure_schema_supported(ws.repo.gix())?;
590    let branch = options.branch.clone();
591    let worktrees = rows::enumerate_worktrees(ws.repo, git)?;
592    let branch_exists = resolve_hex(ws.repo.gix(), &branch_ref(&branch)).is_some();
593
594    let base_ref = if branch_exists {
595        None
596    } else {
597        Some(resolve_base(ws.repo, ws.config, options.base.as_deref()).0)
598    };
599    // Shared with `preview_target`, so a confirmation preview cannot name a
600    // different directory than the one that gets created. This also performs the
601    // base-ref existence check, erroring before anything is created.
602    let (slug, short_hash) = target_slug(ws, &branch, options.base.as_deref())?;
603
604    // If the branch is already checked out, either reuse (same target) or
605    // refuse. The reuse path is idempotent: no copy step, no hook.
606    if let Some(existing) = worktrees
607        .iter()
608        .find(|w| w.branch.as_deref() == Some(branch.as_str()))
609    {
610        let preview = render_target(ws.config, ws.root, &branch, &slug, ws.env)?;
611        if same_path(&existing.path, &preview) {
612            return Ok(CreatedWorktree {
613                path: existing.path.clone(),
614                branch,
615                base_ref: None,
616                reused: true,
617                copy: CopyOutcome::default(),
618                post_create: HookOutcome::Skipped,
619                submodules: SubmodulesOutcome::Skipped,
620                submodule_seeding: SubmoduleSeeding::default(),
621                reflinked: false,
622            });
623        }
624        return Err(Error::operation(format!(
625            "branch {branch:?} is already checked out at {}",
626            existing.path.display()
627        )));
628    }
629
630    // The mutation region — target resolution through metadata + copy — runs
631    // under the advisory repository lock (issue #99) so two concurrent
632    // creators cannot interleave into a corrupt state. The lock is released
633    // before the hook runs: a hook that re-enters `wt` must not deadlock.
634    let lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
635    let target = resolve_target(
636        ws.config,
637        ws.root,
638        &branch,
639        &slug,
640        &short_hash,
641        ws.env,
642        ws.repo.is_bare(),
643    )?;
644    if let Some(parent) = target.parent() {
645        std::fs::create_dir_all(parent)?;
646    }
647
648    // Decide up front whether the content can be cloned copy-on-write from an
649    // existing worktree instead of checked out. This has to happen before the
650    // add, because the CoW path needs `--no-checkout`. `None` is the normal
651    // checkout and is never an error.
652    let reflink_plan = if options.reflink {
653        let source = copy_source(ws, &worktrees, options.copy_from.as_deref()).ok();
654        target
655            .parent()
656            .and_then(|parent| materialize::plan(source.as_deref(), parent))
657    } else {
658        None
659    };
660    let no_checkout = reflink_plan.is_some();
661
662    // Create the worktree (git is atomic here).
663    let target_str = target.to_string_lossy().into_owned();
664    if let Some(base) = &base_ref {
665        // `--no-track` keeps the new branch from inheriting the base as its
666        // upstream (issue #43); `--track` opts into an explicit one.
667        ops::worktree_add_branch(git, ws.root, &branch, &target_str, base, true, no_checkout)?;
668    } else {
669        ops::worktree_add(git, ws.root, &target_str, &branch, no_checkout)?;
670    }
671
672    // Materialize the content. A failure inside falls back to a stock checkout,
673    // so the worktree is populated either way; only a failure to do even that
674    // is fatal, and it rolls back like any other post-add step.
675    let reflinked = match &reflink_plan {
676        Some(plan) => match materialize::apply(git, &target, plan) {
677            Ok(used_cow) => used_cow,
678            Err(e) => {
679                rollback_worktree(git, ws.root, &target, &branch, base_ref.is_some(), false);
680                return Err(e);
681            }
682        },
683        None => false,
684    };
685
686    // Steps after creation but before the hook are rolled back on failure (§13).
687    let copy = match post_create_steps(ws, git, &worktrees, &branch, &base_ref, &target, options) {
688        Ok(outcome) => outcome,
689        Err(e) => {
690            // Metadata is written only for a wt-created branch, so delete the
691            // branch and clear metadata together on that condition.
692            let created = base_ref.is_some();
693            rollback_worktree(git, ws.root, &target, &branch, created, created);
694            return Err(e);
695        }
696    };
697    drop(lock);
698
699    // The post-create hook: a failure is an outcome, not a rollback (§8).
700    let ctx = HookContext {
701        worktree_path: target.clone(),
702        branch: branch.clone(),
703        repo_root: ws.root.to_path_buf(),
704        base_ref: base_ref.clone(),
705        pr_number: None,
706    };
707    let post_create = match (options.no_hooks, ws.config.hooks_post_create.as_deref()) {
708        (true, _) | (false, None) => HookOutcome::Skipped,
709        (false, Some(command)) => match hooks.run(command, &ctx) {
710            Ok(0) => HookOutcome::Succeeded,
711            Ok(code) => HookOutcome::ExitedNonZero(code),
712            Err(e) => HookOutcome::Failed(e.to_string()),
713        },
714    };
715
716    // A copy-on-write materialization also brought the submodules' *files*
717    // across, but their `.git` gitlinks still point into the source worktree.
718    // Give them git directories of their own so the pass below recognizes them
719    // instead of cloning over the top. Best-effort — anything missed is just a
720    // normal clone.
721    let attached = match reflinked.then(|| common_git_dir(git, ws.root)) {
722        Some(Ok(common)) => materialize::attach_submodules(git, &target, &common),
723        _ => Vec::new(),
724    };
725    if !attached.is_empty() {
726        tracing::debug!(count = attached.len(), "attached copied submodules");
727    }
728
729    // Attaching clones each submodule from the repository's own mirror, which
730    // records that mirror path as its `origin`. That has to be undone here, not
731    // left to the pass below: an attached submodule reports as *initialized*, so
732    // the pass sees nothing pending and skips — and mirror paths left as origins
733    // would silently fetch from, and push into, the primary worktree's object
734    // store instead of the real upstream.
735    let attach_sync = if attached.is_empty() {
736        Ok(())
737    } else {
738        crate::git::submodule::sync(git, &target)
739    };
740
741    // Submodule initialization, when the caller resolved its policy to "yes".
742    // Non-fatal: the worktree already exists.
743    let (submodules, seed) = match attach_sync {
744        Err(e) => (
745            SubmodulesOutcome::Failed {
746                pending: attached.len(),
747                error: e.to_string(),
748            },
749            SeedReport::default(),
750        ),
751        Ok(()) if options.init_submodules => {
752            populate_submodules(git, &target, options.seed_submodules)?
753        }
754        Ok(()) => (SubmodulesOutcome::Skipped, SeedReport::default()),
755    };
756
757    Ok(CreatedWorktree {
758        path: target,
759        branch,
760        base_ref,
761        reused: false,
762        copy,
763        post_create,
764        submodules,
765        submodule_seeding: seed.into(),
766        reflinked,
767    })
768}
769
770/// The repository's common git directory — the shared `.git` that holds
771/// `modules/`, as opposed to a linked worktree's private git dir.
772fn common_git_dir(git: &dyn GitCli, root: &Path) -> Result<PathBuf> {
773    let out = git.run(
774        root,
775        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
776    )?;
777    Ok(PathBuf::from(out.trim()))
778}
779
780/// Populates `target`'s submodules, seeding from the repository's own local
781/// mirrors first when enabled. See [`crate::git::submodule::populate`] for why
782/// seeding cannot change the outcome.
783fn populate_submodules(
784    git: &dyn GitCli,
785    target: &Path,
786    seed_enabled: bool,
787) -> Result<(SubmodulesOutcome, SeedReport)> {
788    let pending = crate::git::submodule::uninitialized(git, target)?;
789    if pending.is_empty() {
790        return Ok((SubmodulesOutcome::Skipped, SeedReport::default()));
791    }
792    let (seed, result) = crate::git::submodule::populate(git, target, seed_enabled);
793    let outcome = match result {
794        Ok(()) => SubmodulesOutcome::Initialized(pending.len()),
795        Err(e) => SubmodulesOutcome::Failed {
796            pending: pending.len(),
797            error: e.to_string(),
798        },
799    };
800    Ok((outcome, seed))
801}
802
803/// Records metadata, sets an explicit upstream, and runs the copy step — the
804/// region rolled back when any step fails.
805fn post_create_steps(
806    ws: &WorkspaceParts<'_>,
807    git: &dyn GitCli,
808    worktrees: &[Worktree],
809    branch: &str,
810    base_ref: &Option<String>,
811    target: &Path,
812    options: &CreateOptions,
813) -> Result<CopyOutcome> {
814    if let Some(base) = base_ref {
815        // A wt-created branch records its base and "created by wt" (§3/§10).
816        // The caller already holds the repo lock, so this applies the bundle
817        // directly rather than through `Workspace::write_meta`.
818        apply_meta(
819            git,
820            ws.root,
821            branch,
822            &MetaUpdate {
823                base_ref: Some(base.clone()),
824                created_by_wt: true,
825                ..MetaUpdate::default()
826            },
827        )?;
828    }
829    // `--track <REF>` sets an explicit upstream (issue #43); a bad ref fails
830    // here, inside the rolled-back region.
831    if let Some(upstream) = &options.track {
832        ops::set_upstream(git, ws.root, branch, upstream)?;
833    }
834    let source = copy_source(ws, worktrees, options.copy_from.as_deref())?;
835    copy_ignored_files(git, &source, target, &ws.config.copy)
836}
837
838/// Resolves the copy source worktree: the `copy_from` query, else the current
839/// worktree, else the primary root (spec §8).
840fn copy_source(
841    ws: &WorkspaceParts<'_>,
842    worktrees: &[Worktree],
843    copy_from: Option<&str>,
844) -> Result<PathBuf> {
845    if let Some(q) = copy_from {
846        return match query::resolve(worktrees, q) {
847            Resolved::One(index) => Ok(worktrees[index].path.clone()),
848            Resolved::Ambiguous(_) => {
849                Err(Error::operation(format!("--copy-from {q:?} is ambiguous")))
850            }
851            Resolved::NotFound => Err(Error::NotFound {
852                query: q.to_string(),
853            }),
854        };
855    }
856    Ok(ws
857        .repo
858        .current_workdir()
859        .unwrap_or_else(|| ws.root.to_path_buf()))
860}
861
862/// Removes an already-resolved `worktree`; see [`Workspace::remove`].
863pub(crate) fn remove_in(
864    ws: &WorkspaceParts<'_>,
865    git: &dyn GitCli,
866    hooks: &dyn HookRunner,
867    worktree: &Worktree,
868    options: &RemoveOptions,
869) -> Result<RemovedWorktree> {
870    // This one is load-bearing outside the lock: the `wt.*` metadata read just
871    // below, the guards, and the `pre_remove` hook all run before the lock is
872    // taken (a hook that re-enters `wt` must not deadlock, issue #99), and
873    // reading `wt.*` under an unsupported schema could misinterpret it. The
874    // mutation itself is gated again under the lock (issue #106).
875    wtconfig::ensure_schema_supported(ws.repo.gix())?;
876    if worktree.is_main {
877        return Err(Error::operation("refusing to remove the primary worktree"));
878    }
879    let meta = worktree
880        .branch
881        .as_deref()
882        .map(|b| wtconfig::read_meta(ws.repo.gix(), b))
883        .unwrap_or_default();
884    let default = default_branch(ws.repo.gix());
885
886    // A missing worktree: prune the admin record; no guards or hook apply.
887    if worktree.is_missing {
888        let _lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
889        ops::worktree_prune(git, ws.root)?;
890        let branch_deleted = maybe_delete_branch(ws, git, worktree, &meta, options, &default);
891        clear_metadata(git, ws.root, worktree);
892        return Ok(RemovedWorktree {
893            branch_deleted,
894            forced_past_guards: false,
895            forced_for_submodules: false,
896            pre_remove: HookOutcome::Skipped,
897        });
898    }
899
900    // `git worktree remove` refuses outright — `fatal: working trees containing
901    // submodules cannot be moved or removed` — for *any* worktree with a
902    // populated submodule, dirty or not, and only `--force` gets past it.
903    let needs_submodule_force =
904        crate::git::submodule::any_initialized(git, &worktree.path).unwrap_or(false);
905
906    // Safety guards (spec §10/§12). Forcing git past the submodule refusal also
907    // takes its refusal to delete a worktree holding *untracked* files with it,
908    // and `remove.untracked_blocks` is off by default — so count untracked files
909    // here whenever that force is what we are about to do. Otherwise a worktree
910    // with submodules would quietly lose files that an identical worktree
911    // without them is protected from losing.
912    let untracked_blocks = ws.config.remove_untracked_blocks || needs_submodule_force;
913    let guard = rows::guard_status(worktree, untracked_blocks);
914    if guard.blocks() && !options.force_remove {
915        return Err(Error::RemoveGuarded {
916            dirty: guard.dirty,
917            unpushed: guard.unpushed,
918        });
919    }
920    let forced_past_guards = guard.blocks() && options.force_remove;
921    // Git needed forcing, but no `wt` guard was overridden to get there: no
922    // data-loss risk to report, so this must not read as a forced removal.
923    let forced_for_submodules = needs_submodule_force && !options.force_remove;
924
925    // The pre-remove hook may abort; `force_remove` downgrades a failure to an
926    // outcome and proceeds.
927    let ctx = HookContext {
928        worktree_path: worktree.path.clone(),
929        branch: worktree.branch.clone().unwrap_or_default(),
930        repo_root: ws.root.to_path_buf(),
931        base_ref: meta.base_ref.clone(),
932        pr_number: meta.pr_number,
933    };
934    let pre_remove = match (options.no_hooks, ws.config.hooks_pre_remove.as_deref()) {
935        (true, _) | (false, None) => HookOutcome::Skipped,
936        (false, Some(command)) => match hooks.run(command, &ctx) {
937            Ok(0) => HookOutcome::Succeeded,
938            Ok(code) if options.force_remove => HookOutcome::ExitedNonZero(code),
939            Ok(code) => {
940                return Err(Error::operation(format!(
941                    "pre_remove hook exited with status {code}; aborting (use --force to override)"
942                )));
943            }
944            Err(e) if options.force_remove => HookOutcome::Failed(e.to_string()),
945            Err(e) => return Err(e),
946        },
947    };
948
949    // Remove the worktree, holding the advisory lock (issue #99). Acquired
950    // *after* the hook so a hook that re-enters `wt` cannot deadlock.
951    let _lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
952    let path = worktree.path.to_string_lossy().into_owned();
953
954    // The guards above are the real safety decision, and they took the submodule
955    // force into account, so passing it to git now weakens nothing.
956    ops::worktree_remove(
957        git,
958        ws.root,
959        &path,
960        options.force_remove || needs_submodule_force,
961        false,
962    )?;
963
964    let branch_deleted = maybe_delete_branch(ws, git, worktree, &meta, options, &default);
965    clear_metadata(git, ws.root, worktree);
966    Ok(RemovedWorktree {
967        branch_deleted,
968        forced_past_guards,
969        forced_for_submodules,
970        pre_remove,
971    })
972}
973
974/// Deletes the branch if it is wt-created and either fully merged (and the
975/// config allows it) or `force_branch` (for an unmerged branch). Returns
976/// whether the branch was deleted.
977fn maybe_delete_branch(
978    ws: &WorkspaceParts<'_>,
979    git: &dyn GitCli,
980    worktree: &Worktree,
981    meta: &WtMeta,
982    options: &RemoveOptions,
983    default: &Option<String>,
984) -> bool {
985    let Some(branch) = &worktree.branch else {
986        return false;
987    };
988    if options.keep_branch || !meta.created_by_wt {
989        return false;
990    }
991    let base = meta.base_ref.clone().or_else(|| default.clone());
992    let merged = base
993        .as_deref()
994        .is_some_and(|b| is_ancestor(ws.repo.gix(), &branch_ref(branch), b));
995    let should_delete = if merged {
996        ws.config.remove_delete_merged_branch
997    } else {
998        options.force_branch
999    };
1000    if !should_delete {
1001        return false;
1002    }
1003    ops::delete_branch(git, ws.root, branch, true).is_ok()
1004}
1005
1006/// Clears the worktree's `wt.*` metadata, best-effort.
1007fn clear_metadata(git: &dyn GitCli, root: &Path, worktree: &Worktree) {
1008    if let Some(branch) = &worktree.branch {
1009        let _ = wtconfig::clear_meta(git, root, branch);
1010    }
1011}
1012
1013/// Whether two paths refer to the same location, comparing canonicalized forms
1014/// when possible (handles `/private` symlinks on macOS).
1015pub(crate) fn same_path(a: &Path, b: &Path) -> bool {
1016    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
1017    canon(a) == canon(b)
1018}
1019
1020/// The git directory used for the `.git`-containment check (spec §6).
1021pub(crate) fn git_dir_of(root: &Path, is_bare: bool) -> PathBuf {
1022    if is_bare {
1023        root.to_path_buf()
1024    } else {
1025        root.join(".git")
1026    }
1027}
1028
1029/// Renders the worktree store path for a branch with the given slug (spec §6).
1030pub(crate) fn render_target(
1031    config: &Config,
1032    root: &Path,
1033    branch: &str,
1034    slug: &str,
1035    env: &Env,
1036) -> Result<PathBuf> {
1037    let vars = TemplateVars {
1038        repo_parent: root
1039            .parent()
1040            .map_or_else(|| root.to_path_buf(), Path::to_path_buf),
1041        repo: root
1042            .file_name()
1043            .map(|n| n.to_string_lossy().into_owned())
1044            .unwrap_or_default(),
1045        repo_root: root.to_path_buf(),
1046        branch: branch.to_string(),
1047        branch_slug: slug.to_string(),
1048        home: env
1049            .get("HOME")
1050            .map(PathBuf::from)
1051            .unwrap_or_else(|| PathBuf::from("~")),
1052    };
1053    template::render(&config.path_template, &vars)
1054}
1055
1056/// Resolves the final target path: renders it, rejects the `.git` directory,
1057/// and on collision with an unrelated path appends `-<short_hash>` (erroring if
1058/// both are occupied). Spec §6.
1059pub(crate) fn resolve_target(
1060    config: &Config,
1061    root: &Path,
1062    branch: &str,
1063    slug: &str,
1064    short_hash: &str,
1065    env: &Env,
1066    is_bare: bool,
1067) -> Result<PathBuf> {
1068    let target = render_target(config, root, branch, slug, env)?;
1069    template::ensure_outside_git(&target, &git_dir_of(root, is_bare))?;
1070    if !target.exists() {
1071        return Ok(target);
1072    }
1073    let alt = render_target(config, root, branch, &format!("{slug}-{short_hash}"), env)?;
1074    if alt.exists() {
1075        return Err(Error::operation(format!(
1076            "target path already exists: {}",
1077            target.display()
1078        )));
1079    }
1080    Ok(alt)
1081}
1082
1083/// Runs a best-effort cleanup git command: on failure it logs a breadcrumb and
1084/// continues rather than aborting the caller. Used by the rollback and prune
1085/// cleanup paths, where a failed step must not stop the wider operation. `step`
1086/// is a short label identifying the command in the log.
1087pub(crate) fn run_best_effort(git: &dyn GitCli, root: &Path, args: &[&str], step: &str) {
1088    match git.run_raw(root, args) {
1089        Ok(out) if out.success => {}
1090        Ok(out) => {
1091            tracing::debug!(step, stderr = %out.stderr.trim(), "best-effort cleanup step failed");
1092        }
1093        Err(error) => {
1094            tracing::debug!(step, %error, "best-effort cleanup step could not run");
1095        }
1096    }
1097}
1098
1099/// Rolls back a partially-created worktree (spec §13): removes the worktree and
1100/// prunes, optionally deletes the branch (only when it was created here), and
1101/// optionally clears the `wt.*` metadata written during the operation, so
1102/// nothing half-created is left behind. The two flags are independent: `wt pr`
1103/// on a *pre-existing* branch keeps the branch but still clears the metadata it
1104/// wrote. Best-effort.
1105pub(crate) fn rollback_worktree(
1106    git: &dyn GitCli,
1107    root: &Path,
1108    target: &Path,
1109    branch: &str,
1110    delete_branch: bool,
1111    clear_meta: bool,
1112) {
1113    let target_str = target.to_string_lossy();
1114    run_best_effort(
1115        git,
1116        root,
1117        &["worktree", "remove", "--force", &target_str],
1118        "rollback: worktree remove",
1119    );
1120    run_best_effort(
1121        git,
1122        root,
1123        &["worktree", "prune"],
1124        "rollback: worktree prune",
1125    );
1126    if delete_branch {
1127        run_best_effort(
1128            git,
1129            root,
1130            &["branch", "-D", branch],
1131            "rollback: branch delete",
1132        );
1133    }
1134    if clear_meta {
1135        // Remove the metadata written before the failure (else a later worktree
1136        // on this branch name would show stale PR/base info, or a wrongly-set
1137        // `createdByWt` could cause its branch to be deleted on remove).
1138        let _ = wtconfig::clear_meta(git, root, branch);
1139    }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145    use crate::git::cli::RealGit;
1146    use crate::hooks::RealHookRunner;
1147    use crate::testutil::{TestRepo, give_upstream};
1148    use std::collections::HashMap;
1149
1150    fn env() -> Env {
1151        Env::from_map(HashMap::new())
1152    }
1153
1154    fn workspace(repo: &TestRepo) -> Workspace {
1155        Workspace::discover(repo.root(), &env(), &RealGit).unwrap()
1156    }
1157
1158    fn create_opts(branch: &str) -> CreateOptions {
1159        CreateOptions {
1160            branch: branch.to_string(),
1161            no_hooks: true,
1162            ..Default::default()
1163        }
1164    }
1165
1166    #[test]
1167    fn discover_resolves_root_config_and_bareness() {
1168        let repo = TestRepo::init();
1169        let ws = workspace(&repo);
1170        assert!(!ws.is_bare());
1171        assert_eq!(
1172            std::fs::canonicalize(ws.root()).unwrap(),
1173            std::fs::canonicalize(repo.root()).unwrap()
1174        );
1175        assert_eq!(ws.config().pr_default_remote, "origin");
1176    }
1177
1178    #[test]
1179    fn discover_outside_a_repo_is_not_in_repo() {
1180        let dir = tempfile::tempdir().unwrap();
1181        assert!(matches!(
1182            Workspace::discover(dir.path(), &env(), &RealGit),
1183            Err(Error::NotInRepo)
1184        ));
1185    }
1186
1187    #[test]
1188    fn discover_from_linked_worktree_finds_primary_root() {
1189        let repo = TestRepo::init();
1190        repo.add_worktree("feature/x", "../wt-x");
1191        let linked = repo.root().parent().unwrap().join("wt-x");
1192        let ws = Workspace::discover(&linked, &env(), &RealGit).unwrap();
1193        assert_eq!(
1194            std::fs::canonicalize(ws.root()).unwrap(),
1195            std::fs::canonicalize(repo.root()).unwrap()
1196        );
1197    }
1198
1199    #[test]
1200    fn create_new_branch_records_metadata_and_copies_nothing() {
1201        let repo = TestRepo::init();
1202        let ws = workspace(&repo);
1203        let created = ws
1204            .create(&RealGit, &RealHookRunner, &create_opts("feature/login"))
1205            .unwrap();
1206        assert!(!created.reused);
1207        assert_eq!(created.branch, "feature/login");
1208        assert_eq!(created.base_ref.as_deref(), Some("main"));
1209        assert!(created.path.is_dir());
1210        assert!(
1211            created.path.ends_with("feature-login")
1212                || created.path.to_string_lossy().contains("feature-login")
1213        );
1214        assert_eq!(created.post_create, HookOutcome::Skipped);
1215        assert_eq!(created.submodules, SubmodulesOutcome::Skipped);
1216        assert!(created.copy.copied.is_empty());
1217        let meta = ws.read_meta("feature/login").unwrap();
1218        assert_eq!(meta.base_ref.as_deref(), Some("main"));
1219        assert!(meta.created_by_wt);
1220    }
1221
1222    #[test]
1223    fn create_existing_branch_does_not_mark_created() {
1224        let repo = TestRepo::init();
1225        repo.git(&["branch", "existing"]);
1226        let ws = workspace(&repo);
1227        let created = ws
1228            .create(&RealGit, &RealHookRunner, &create_opts("existing"))
1229            .unwrap();
1230        assert!(created.base_ref.is_none());
1231        assert!(!ws.read_meta("existing").unwrap().created_by_wt);
1232    }
1233
1234    #[test]
1235    fn create_is_idempotent_at_the_same_target() {
1236        let repo = TestRepo::init();
1237        let ws = workspace(&repo);
1238        let first = ws
1239            .create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1240            .unwrap();
1241        let second = ws
1242            .create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1243            .unwrap();
1244        assert!(!first.reused);
1245        assert!(second.reused);
1246        assert_eq!(second.path, first.path);
1247        assert_eq!(second.post_create, HookOutcome::Skipped);
1248    }
1249
1250    #[test]
1251    fn create_refuses_branch_checked_out_elsewhere() {
1252        let repo = TestRepo::init();
1253        repo.add_worktree("dup", "../manual-dup");
1254        let ws = workspace(&repo);
1255        let err = ws
1256            .create(&RealGit, &RealHookRunner, &create_opts("dup"))
1257            .unwrap_err();
1258        assert!(err.to_string().contains("already checked out"));
1259    }
1260
1261    // `preview_target` exists for the CLI's confirmation preview, so its tests
1262    // only compile where it does.
1263    #[cfg(feature = "cli")]
1264    #[test]
1265    fn preview_target_names_the_directory_create_actually_makes() {
1266        // The slug's commit is only consulted for a branch name that slugifies to
1267        // nothing — and there a *new* branch keys off the base it forks from, not
1268        // off itself. A preview deriving the hash from the (absent) branch would
1269        // name a different directory than the one created, so `wt issue` would
1270        // confirm one path and produce another.
1271        // "_" is a legal git branch name whose slug is empty, so it is the case
1272        // that actually consults the commit hash.
1273        for branch in ["feat/7-add-login", "_"] {
1274            let repo = TestRepo::init();
1275            let ws = workspace(&repo);
1276            let fresh = Repo::discover(repo.root()).unwrap();
1277            let previewed = preview_target(&ws.parts(&fresh), branch, None).unwrap();
1278            let created = ws
1279                .create(&RealGit, &RealHookRunner, &create_opts(branch))
1280                .unwrap();
1281            assert_eq!(
1282                previewed, created.path,
1283                "preview and creation disagree for {branch:?}"
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn a_slugless_branch_is_named_after_the_base_it_forks_from() {
1290        // Rule 5 of the slug contract: an empty slug falls back to the short hash
1291        // of the *base ref*, not of the branch (which does not exist yet). Pinned
1292        // separately from the preview test, which can only prove the two agree —
1293        // not that the rule they share is the right one.
1294        let repo = TestRepo::init();
1295        let ws = workspace(&repo);
1296        let base = Repo::discover(repo.root())
1297            .ok()
1298            .and_then(|r| resolve_hex(r.gix(), "main"))
1299            .expect("main resolves");
1300        let short = &base[..7];
1301
1302        let created = ws
1303            .create(&RealGit, &RealHookRunner, &create_opts("_"))
1304            .unwrap();
1305        let name = created
1306            .path
1307            .file_name()
1308            .map(|n| n.to_string_lossy().into_owned())
1309            .unwrap_or_default();
1310        assert!(
1311            name.ends_with(short),
1312            "{name} should end with the base short hash {short}"
1313        );
1314    }
1315
1316    #[test]
1317    fn create_with_explicit_base_records_it() {
1318        let repo = TestRepo::init();
1319        repo.git(&["branch", "base-branch"]);
1320        let ws = workspace(&repo);
1321        let mut opts = create_opts("derived");
1322        opts.base = Some("base-branch".into());
1323        let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
1324        assert_eq!(created.base_ref.as_deref(), Some("base-branch"));
1325        assert_eq!(
1326            ws.read_meta("derived").unwrap().base_ref.as_deref(),
1327            Some("base-branch")
1328        );
1329    }
1330
1331    #[test]
1332    fn create_with_unknown_base_errors() {
1333        let repo = TestRepo::init();
1334        let ws = workspace(&repo);
1335        let mut opts = create_opts("orphan");
1336        opts.base = Some("no-such-ref".into());
1337        let err = ws.create(&RealGit, &RealHookRunner, &opts).unwrap_err();
1338        assert!(err.to_string().contains("not found"));
1339    }
1340
1341    #[test]
1342    fn create_reports_hook_outcomes_without_failing() {
1343        let repo = TestRepo::init();
1344        repo.write(".wt.toml", "[hooks]\npost_create = \"exit 3\"\n");
1345        repo.commit_all("config");
1346        let ws = workspace(&repo);
1347        let mut opts = create_opts("hooked");
1348        opts.no_hooks = false;
1349        let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
1350        assert_eq!(created.post_create, HookOutcome::ExitedNonZero(3));
1351        assert!(created.path.is_dir());
1352    }
1353
1354    #[test]
1355    fn create_copies_ignored_files() {
1356        let repo = TestRepo::init();
1357        std::fs::write(repo.root().join(".wt.toml"), "copy = [\".env\"]\n").unwrap();
1358        repo.write(".env", "SECRET=1\n");
1359        let ws = workspace(&repo);
1360        let created = ws
1361            .create(&RealGit, &RealHookRunner, &create_opts("withenv"))
1362            .unwrap();
1363        assert_eq!(created.copy.copied.len(), 1);
1364        assert!(created.path.join(".env").exists());
1365    }
1366
1367    #[test]
1368    fn create_rolls_back_when_a_post_add_step_fails() {
1369        use crate::git::cli::{GitCli, GitOutput};
1370        struct FailConfig(RealGit);
1371        impl GitCli for FailConfig {
1372            fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput> {
1373                if args.first() == Some(&"config") && args.iter().any(|a| a.starts_with("wt.")) {
1374                    return Ok(GitOutput {
1375                        success: false,
1376                        stdout: String::new(),
1377                        stderr: "simulated failure".into(),
1378                    });
1379                }
1380                self.0.run_raw(repo, args)
1381            }
1382        }
1383        let repo = TestRepo::init();
1384        let ws = workspace(&repo);
1385        let err = ws
1386            .create(
1387                &FailConfig(RealGit),
1388                &RealHookRunner,
1389                &create_opts("rollme"),
1390            )
1391            .unwrap_err();
1392        assert!(err.to_string().contains("simulated failure"));
1393        assert!(repo.git(&["branch", "--list", "rollme"]).trim().is_empty());
1394        assert!(!repo.git(&["worktree", "list"]).contains("rollme"));
1395    }
1396
1397    /// Finds the row for `branch` in a fresh enriched listing.
1398    fn row_for(ws: &Workspace, branch: &str) -> Worktree {
1399        ws.list(&RealGit)
1400            .unwrap()
1401            .into_iter()
1402            .find(|w| w.branch.as_deref() == Some(branch))
1403            .unwrap()
1404    }
1405
1406    #[test]
1407    fn list_enumerate_and_meta_expose_worktrees() {
1408        let repo = TestRepo::init();
1409        let ws = workspace(&repo);
1410        ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1411            .unwrap();
1412        let shallow = ws.enumerate(&RealGit).unwrap();
1413        assert_eq!(shallow.len(), 2);
1414        // The shallow pass has no status; the enriched pass does.
1415        assert!(shallow.iter().all(|w| w.dirty.is_none()));
1416        let feat = row_for(&ws, "feature/x");
1417        assert_eq!(feat.dirty, Some(false));
1418        assert_eq!(feat.base_ref.as_deref(), Some("main"));
1419    }
1420
1421    #[test]
1422    fn write_meta_applies_only_the_set_fields() {
1423        let repo = TestRepo::init();
1424        let ws = workspace(&repo);
1425        ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1426            .unwrap();
1427        ws.write_meta(
1428            &RealGit,
1429            "feature/x",
1430            &MetaUpdate {
1431                pr_number: Some(7),
1432                pr_state: Some("open".into()),
1433                pr_title: Some("Add x".into()),
1434                pr_url: Some("https://example.test/7".into()),
1435                ..MetaUpdate::default()
1436            },
1437        )
1438        .unwrap();
1439        let meta = ws.read_meta("feature/x").unwrap();
1440        assert_eq!(meta.pr_number, Some(7));
1441        assert_eq!(meta.pr_state.as_deref(), Some("open"));
1442        assert_eq!(meta.pr_title.as_deref(), Some("Add x"));
1443        assert_eq!(meta.pr_url.as_deref(), Some("https://example.test/7"));
1444        // The keys `create` recorded survive an update that does not name them.
1445        assert_eq!(meta.base_ref.as_deref(), Some("main"));
1446        assert!(meta.created_by_wt);
1447
1448        // A narrower update refreshes one key and leaves the rest.
1449        ws.write_meta(
1450            &RealGit,
1451            "feature/x",
1452            &MetaUpdate {
1453                pr_state: Some("merged".into()),
1454                ..MetaUpdate::default()
1455            },
1456        )
1457        .unwrap();
1458        let meta = ws.read_meta("feature/x").unwrap();
1459        assert_eq!(meta.pr_state.as_deref(), Some("merged"));
1460        assert_eq!(meta.pr_number, Some(7));
1461        assert_eq!(meta.pr_title.as_deref(), Some("Add x"));
1462    }
1463
1464    #[test]
1465    fn write_meta_marks_created_by_wt_but_never_unmarks() {
1466        let repo = TestRepo::init();
1467        let ws = workspace(&repo);
1468        repo.git(&["branch", "solo"]);
1469        // An empty update writes nothing at all.
1470        ws.write_meta(&RealGit, "solo", &MetaUpdate::default())
1471            .unwrap();
1472        assert_eq!(ws.read_meta("solo").unwrap(), WtMeta::default());
1473
1474        ws.write_meta(
1475            &RealGit,
1476            "solo",
1477            &MetaUpdate {
1478                created_by_wt: true,
1479                ..MetaUpdate::default()
1480            },
1481        )
1482        .unwrap();
1483        assert!(ws.read_meta("solo").unwrap().created_by_wt);
1484        // `false` is "leave it alone", not "un-mark".
1485        ws.write_meta(&RealGit, "solo", &MetaUpdate::default())
1486            .unwrap();
1487        assert!(ws.read_meta("solo").unwrap().created_by_wt);
1488    }
1489
1490    #[test]
1491    fn clear_meta_removes_the_section_and_tolerates_a_missing_one() {
1492        let repo = TestRepo::init();
1493        let ws = workspace(&repo);
1494        ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1495            .unwrap();
1496        assert!(ws.read_meta("feature/x").unwrap().created_by_wt);
1497        ws.clear_meta(&RealGit, "feature/x").unwrap();
1498        assert_eq!(ws.read_meta("feature/x").unwrap(), WtMeta::default());
1499        // Clearing a branch that never had metadata is not an error.
1500        ws.clear_meta(&RealGit, "never-recorded").unwrap();
1501    }
1502
1503    #[test]
1504    fn metadata_writes_take_the_repo_lock() {
1505        // The bundle must not interleave with another writer (issue #99).
1506        let repo = TestRepo::init();
1507        let ws = workspace(&repo);
1508        repo.git(&["branch", "locked"]);
1509        let held = ws.lock().unwrap();
1510        let err = ws
1511            .write_meta(
1512                &RealGit,
1513                "locked",
1514                &MetaUpdate {
1515                    pr_number: Some(1),
1516                    ..MetaUpdate::default()
1517                },
1518            )
1519            .unwrap_err();
1520        assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
1521        let err = ws.clear_meta(&RealGit, "locked").unwrap_err();
1522        assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
1523        drop(held);
1524        ws.clear_meta(&RealGit, "locked").unwrap();
1525    }
1526
1527    #[test]
1528    fn metadata_writes_refuse_a_future_schema() {
1529        let repo = TestRepo::init();
1530        let ws = workspace(&repo);
1531        repo.git(&["branch", "stamped"]);
1532        repo.git(&["config", "wt.schema", "3"]);
1533        let err = ws
1534            .write_meta(&RealGit, "stamped", &MetaUpdate::default())
1535            .unwrap_err();
1536        assert!(
1537            matches!(err, Error::SchemaTooNew { found: 3, .. }),
1538            "{err:?}"
1539        );
1540        let err = ws.clear_meta(&RealGit, "stamped").unwrap_err();
1541        assert!(
1542            matches!(err, Error::SchemaTooNew { found: 3, .. }),
1543            "{err:?}"
1544        );
1545    }
1546
1547    #[test]
1548    fn remove_blocked_by_guards_is_a_typed_error() {
1549        let repo = TestRepo::init();
1550        let ws = workspace(&repo);
1551        ws.create(&RealGit, &RealHookRunner, &create_opts("topic"))
1552            .unwrap();
1553        // No upstream -> unpushed; clean -> not dirty.
1554        let row = row_for(&ws, "topic");
1555        let err = ws
1556            .remove(
1557                &RealGit,
1558                &RealHookRunner,
1559                &row,
1560                &RemoveOptions {
1561                    no_hooks: true,
1562                    ..Default::default()
1563                },
1564            )
1565            .unwrap_err();
1566        match err {
1567            Error::RemoveGuarded { dirty, unpushed } => {
1568                assert!(!dirty);
1569                assert!(unpushed);
1570            }
1571            other => panic!("expected RemoveGuarded, got {other:?}"),
1572        }
1573    }
1574
1575    #[test]
1576    fn create_seeds_submodules_without_reaching_a_remote() {
1577        // The user-visible win. The fixture's submodule URL is a file path, and
1578        // git denies file-protocol submodule clones, so a worktree that had to
1579        // clone from the recorded URL would come up empty. Seeding populates it
1580        // from the repository's own object store instead, and the file-protocol
1581        // opt-in it needs for that is its own, scoped to the mirror path.
1582        let repo = TestRepo::init();
1583        repo.add_submodule("libs/sub");
1584        let ws = workspace(&repo);
1585        let created = ws
1586            .create(
1587                &RealGit,
1588                &RealHookRunner,
1589                &CreateOptions {
1590                    branch: "topic".to_string(),
1591                    init_submodules: true,
1592                    seed_submodules: true,
1593                    no_hooks: true,
1594                    ..Default::default()
1595                },
1596            )
1597            .unwrap();
1598
1599        assert!(
1600            created.path.join("libs/sub/sub.txt").exists(),
1601            "submodule was not populated"
1602        );
1603        assert_eq!(
1604            created.submodule_seeding.seeded,
1605            vec!["libs/sub".to_string()]
1606        );
1607        assert!(created.submodule_seeding.failed.is_empty());
1608        assert!(matches!(
1609            created.submodules,
1610            SubmodulesOutcome::Initialized(1)
1611        ));
1612    }
1613
1614    #[test]
1615    fn reflink_materialization_matches_a_normal_checkout() {
1616        // The CoW path must be indistinguishable in result from a checkout: same
1617        // tracked content, clean status, same submodule state. Skipped where the
1618        // filesystem has no reflink support, since there is nothing to assert.
1619        let Some(repo) = TestRepo::init_cow() else {
1620            return;
1621        };
1622        repo.add_submodule("libs/sub");
1623        repo.write("tracked.txt", "content\n");
1624        repo.write(".gitignore", "build.out\n");
1625        repo.commit_all("add a tracked file and an ignore rule");
1626        // An ignored build artifact. Carrying these across is much of the point
1627        // of asking for a CoW clone, and because it is ignored the new worktree
1628        // is still clean.
1629        repo.write("build.out", "artifact\n");
1630        // Untracked work-in-progress, which belongs to the source worktree
1631        // alone. Copying it would hand the new worktree a dirty status it never
1632        // earned, and bypass the `copy` patterns that decide what travels.
1633        repo.write("scratch.txt", "unsaved\n");
1634        std::fs::create_dir_all(repo.root().join("wip")).unwrap();
1635        repo.write("wip/notes.md", "later\n");
1636        // A repository that hides untracked files from `git status` must not
1637        // thereby switch the protection off.
1638        repo.git(&["config", "status.showUntrackedFiles", "no"]);
1639
1640        let ws = workspace(&repo);
1641        let created = ws
1642            .create(
1643                &RealGit,
1644                &RealHookRunner,
1645                &CreateOptions {
1646                    branch: "topic".to_string(),
1647                    init_submodules: true,
1648                    seed_submodules: true,
1649                    reflink: true,
1650                    no_hooks: true,
1651                    ..Default::default()
1652                },
1653            )
1654            .unwrap();
1655
1656        assert!(created.reflinked, "the CoW path did not run");
1657        // Tracked content is right and the worktree is clean.
1658        assert_eq!(
1659            std::fs::read_to_string(created.path.join("tracked.txt")).unwrap(),
1660            "content\n"
1661        );
1662        let status = repo.git(&["-C", &created.path.to_string_lossy(), "status", "--short"]);
1663        assert!(
1664            status.trim().is_empty(),
1665            "worktree is not clean: {status:?}"
1666        );
1667        // The submodule came across and is recognized, not left uninitialized.
1668        assert!(created.path.join("libs/sub/sub.txt").exists());
1669        let subs = repo.git(&[
1670            "-C",
1671            &created.path.to_string_lossy(),
1672            "submodule",
1673            "status",
1674            "--recursive",
1675        ]);
1676        assert!(
1677            subs.starts_with(' '),
1678            "submodule not in sync after a reflink create: {subs:?}"
1679        );
1680        // And it points at the real upstream. Attaching clones from the
1681        // repository's own mirror, so without the follow-up sync `origin` would
1682        // be `<repo>/.git/modules/libs/sub` and every later fetch or push in
1683        // this submodule would go to the primary worktree's object store.
1684        let origin =
1685            |dir: &Path| repo.git(&["-C", &dir.to_string_lossy(), "config", "remote.origin.url"]);
1686        assert_eq!(
1687            origin(&created.path.join("libs/sub")),
1688            origin(&repo.root().join("libs/sub")),
1689            "the reflinked submodule kept the local mirror as its origin"
1690        );
1691        // The ignored artifact rode along; the untracked work did not.
1692        assert!(created.path.join("build.out").exists());
1693        assert!(
1694            !created.path.join("scratch.txt").exists(),
1695            "an untracked source file was cloned into the new worktree"
1696        );
1697        assert!(
1698            !created.path.join("wip/notes.md").exists(),
1699            "an untracked source directory was cloned into the new worktree"
1700        );
1701    }
1702
1703    #[test]
1704    fn reflink_declines_when_the_source_is_at_a_different_tree() {
1705        // Falling back must still produce a correct worktree, just not a cloned
1706        // one: the new branch is based on the *old* commit, whose tree differs
1707        // from the source worktree's current one.
1708        let repo = TestRepo::init();
1709        let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1710        repo.write("only-on-head.txt", "later\n");
1711        repo.commit_all("move the source ahead");
1712
1713        let ws = workspace(&repo);
1714        let created = ws
1715            .create(
1716                &RealGit,
1717                &RealHookRunner,
1718                &CreateOptions {
1719                    branch: "topic".to_string(),
1720                    base: Some(base),
1721                    reflink: true,
1722                    no_hooks: true,
1723                    ..Default::default()
1724                },
1725            )
1726            .unwrap();
1727
1728        assert!(!created.reflinked, "cloned across differing trees");
1729        // The worktree still has the right content for its own base.
1730        assert!(!created.path.join("only-on-head.txt").exists());
1731        let status = repo.git(&["-C", &created.path.to_string_lossy(), "status", "--short"]);
1732        assert!(
1733            status.trim().is_empty(),
1734            "worktree is not clean: {status:?}"
1735        );
1736    }
1737
1738    #[test]
1739    fn create_without_seeding_leaves_the_clone_to_git() {
1740        // The escape hatch. With seeding off, the same fixture falls back to a
1741        // real clone from the recorded file:// URL, which git denies — so the
1742        // submodule stays unpopulated and the failure is reported rather than
1743        // silently swallowed. This is exactly the behaviour before this feature.
1744        let repo = TestRepo::init();
1745        repo.add_submodule("libs/sub");
1746        let ws = workspace(&repo);
1747        let created = ws
1748            .create(
1749                &RealGit,
1750                &RealHookRunner,
1751                &CreateOptions {
1752                    branch: "topic".to_string(),
1753                    init_submodules: true,
1754                    seed_submodules: false,
1755                    no_hooks: true,
1756                    ..Default::default()
1757                },
1758            )
1759            .unwrap();
1760
1761        assert!(created.submodule_seeding.seeded.is_empty());
1762        assert!(!created.path.join("libs/sub/sub.txt").exists());
1763        assert!(matches!(
1764            created.submodules,
1765            SubmodulesOutcome::Failed { .. }
1766        ));
1767    }
1768
1769    #[test]
1770    fn remove_succeeds_on_a_worktree_containing_submodules() {
1771        // `git worktree remove` refuses any worktree with a populated submodule
1772        // unless forced, so without the submodule-aware force this removal fails
1773        // with `fatal: working trees containing submodules cannot be moved or
1774        // removed` even though nothing is dirty.
1775        let repo = TestRepo::init();
1776        repo.add_submodule("libs/sub");
1777        let ws = workspace(&repo);
1778        let created = ws
1779            .create(&RealGit, &RealHookRunner, &create_opts("topic"))
1780            .unwrap();
1781        // Populate the submodule in the new worktree. A linked worktree does not
1782        // share `.git/modules`, so git clones from the recorded URL — which is a
1783        // file path here, and file-protocol submodule clones are denied by
1784        // default. The fixture opts in; production URLs do not need this.
1785        repo.git(&[
1786            "-C",
1787            &created.path.to_string_lossy(),
1788            "-c",
1789            "protocol.file.allow=always",
1790            "submodule",
1791            "update",
1792            "--init",
1793        ]);
1794        assert!(created.path.join("libs/sub/sub.txt").exists());
1795        // Clear the unpushed guard so the removal is not blocked for an
1796        // unrelated reason.
1797        give_upstream(&repo, "topic");
1798
1799        let row = row_for(&ws, "topic");
1800        let removed = ws
1801            .remove(
1802                &RealGit,
1803                &RealHookRunner,
1804                &row,
1805                &RemoveOptions {
1806                    no_hooks: true,
1807                    ..Default::default()
1808                },
1809            )
1810            .unwrap();
1811        assert!(!created.path.exists(), "worktree directory still present");
1812        assert!(
1813            removed.forced_for_submodules,
1814            "removal should record that git needed forcing for submodules"
1815        );
1816        assert!(
1817            !removed.forced_past_guards,
1818            "no wt guard was overridden, so this must not read as a forced removal"
1819        );
1820    }
1821
1822    #[test]
1823    fn remove_guards_untracked_files_when_submodules_force_git() {
1824        // Forcing git past `working trees containing submodules cannot be
1825        // removed` also discards its refusal to delete untracked files, and
1826        // `remove.untracked_blocks` is off by default. Without the guard the
1827        // scratch file below would be deleted with nothing having checked.
1828        let repo = TestRepo::init();
1829        repo.add_submodule("libs/sub");
1830        let ws = workspace(&repo);
1831        let created = ws
1832            .create(&RealGit, &RealHookRunner, &create_opts("topic"))
1833            .unwrap();
1834        repo.git(&[
1835            "-C",
1836            &created.path.to_string_lossy(),
1837            "-c",
1838            "protocol.file.allow=always",
1839            "submodule",
1840            "update",
1841            "--init",
1842        ]);
1843        give_upstream(&repo, "topic");
1844        std::fs::write(created.path.join("scratch.txt"), "unsaved\n").unwrap();
1845
1846        let row = row_for(&ws, "topic");
1847        let err = ws
1848            .remove(
1849                &RealGit,
1850                &RealHookRunner,
1851                &row,
1852                &RemoveOptions {
1853                    no_hooks: true,
1854                    ..Default::default()
1855                },
1856            )
1857            .unwrap_err();
1858        assert!(matches!(err, Error::RemoveGuarded { dirty: true, .. }));
1859        assert!(created.path.join("scratch.txt").exists());
1860
1861        // `--force` is still the way through, and still reports honestly.
1862        let row = row_for(&ws, "topic");
1863        let removed = ws
1864            .remove(
1865                &RealGit,
1866                &RealHookRunner,
1867                &row,
1868                &RemoveOptions {
1869                    no_hooks: true,
1870                    force_remove: true,
1871                    ..Default::default()
1872                },
1873            )
1874            .unwrap();
1875        assert!(!created.path.exists());
1876        assert!(removed.forced_past_guards);
1877    }
1878
1879    #[test]
1880    fn remove_without_submodules_does_not_report_a_submodule_force() {
1881        let repo = TestRepo::init();
1882        let ws = workspace(&repo);
1883        ws.create(&RealGit, &RealHookRunner, &create_opts("topic"))
1884            .unwrap();
1885        give_upstream(&repo, "topic");
1886        let row = row_for(&ws, "topic");
1887        let removed = ws
1888            .remove(
1889                &RealGit,
1890                &RealHookRunner,
1891                &row,
1892                &RemoveOptions {
1893                    no_hooks: true,
1894                    ..Default::default()
1895                },
1896            )
1897            .unwrap();
1898        assert!(!removed.forced_for_submodules);
1899        assert!(!removed.forced_past_guards);
1900    }
1901
1902    #[test]
1903    fn remove_force_reports_forced_past_guards() {
1904        let repo = TestRepo::init();
1905        let ws = workspace(&repo);
1906        ws.create(&RealGit, &RealHookRunner, &create_opts("forced"))
1907            .unwrap();
1908        let row = row_for(&ws, "forced");
1909        let removed = ws
1910            .remove(
1911                &RealGit,
1912                &RealHookRunner,
1913                &row,
1914                &RemoveOptions {
1915                    force_remove: true,
1916                    force_branch: true,
1917                    no_hooks: true,
1918                    keep_branch: false,
1919                },
1920            )
1921            .unwrap();
1922        assert!(removed.forced_past_guards);
1923        assert!(!repo.git(&["worktree", "list"]).contains("forced"));
1924        // Merged wt-created branch is deleted per config.
1925        assert!(removed.branch_deleted);
1926        // Metadata is cleared.
1927        assert_eq!(ws.read_meta("forced").unwrap(), WtMeta::default());
1928    }
1929
1930    #[test]
1931    fn remove_refuses_the_primary_worktree() {
1932        let repo = TestRepo::init();
1933        let ws = workspace(&repo);
1934        let main = row_for(&ws, "main");
1935        let err = ws
1936            .remove(&RealGit, &RealHookRunner, &main, &RemoveOptions::default())
1937            .unwrap_err();
1938        assert!(err.to_string().contains("primary"));
1939    }
1940
1941    #[test]
1942    fn remove_missing_worktree_prunes_without_guards() {
1943        let repo = TestRepo::init();
1944        let ws = workspace(&repo);
1945        let created = ws
1946            .create(&RealGit, &RealHookRunner, &create_opts("gone"))
1947            .unwrap();
1948        std::fs::remove_dir_all(&created.path).unwrap();
1949        let row = row_for(&ws, "gone");
1950        assert!(row.is_missing);
1951        let removed = ws
1952            .remove(
1953                &RealGit,
1954                &RealHookRunner,
1955                &row,
1956                &RemoveOptions {
1957                    no_hooks: true,
1958                    ..Default::default()
1959                },
1960            )
1961            .unwrap();
1962        assert_eq!(removed.pre_remove, HookOutcome::Skipped);
1963        assert!(!repo.git(&["worktree", "list"]).contains("gone"));
1964    }
1965
1966    #[test]
1967    fn remove_failing_pre_remove_hook_aborts_unless_forced() {
1968        let repo = TestRepo::init();
1969        repo.write(".wt.toml", "[hooks]\npre_remove = \"exit 5\"\n");
1970        repo.commit_all("config");
1971        let ws = workspace(&repo);
1972        ws.create(&RealGit, &RealHookRunner, &create_opts("hooked"))
1973            .unwrap();
1974        // Give the branch an upstream so guards do not block first.
1975        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1976        repo.git(&["update-ref", "refs/remotes/origin/hooked", &head]);
1977        repo.git(&["config", "branch.hooked.remote", "origin"]);
1978        repo.git(&["config", "branch.hooked.merge", "refs/heads/hooked"]);
1979        let row = row_for(&ws, "hooked");
1980        let err = ws
1981            .remove(&RealGit, &RealHookRunner, &row, &RemoveOptions::default())
1982            .unwrap_err();
1983        assert!(
1984            err.to_string()
1985                .contains("pre_remove hook exited with status 5")
1986        );
1987        // Forced: the failure is downgraded to an outcome and removal proceeds.
1988        let removed = ws
1989            .remove(
1990                &RealGit,
1991                &RealHookRunner,
1992                &row,
1993                &RemoveOptions {
1994                    force_remove: true,
1995                    ..Default::default()
1996                },
1997            )
1998            .unwrap();
1999        assert_eq!(removed.pre_remove, HookOutcome::ExitedNonZero(5));
2000        assert!(!repo.git(&["worktree", "list"]).contains("hooked"));
2001    }
2002
2003    #[test]
2004    fn discover_refuses_a_future_schema() {
2005        let repo = TestRepo::init();
2006        repo.git(&["config", "wt.schema", "99"]);
2007        let err = Workspace::discover(repo.root(), &env(), &RealGit)
2008            .err()
2009            .expect("a future schema must refuse discovery");
2010        assert!(matches!(err, Error::SchemaTooNew { found: 99, .. }));
2011    }
2012
2013    #[test]
2014    fn mutations_refuse_a_schema_stamped_after_discovery() {
2015        // A long-lived Workspace must not mutate a repo that was upgraded
2016        // underneath it: create/remove re-check through a fresh handle.
2017        let repo = TestRepo::init();
2018        let ws = workspace(&repo);
2019        repo.git(&["config", "wt.schema", "2"]);
2020        let err = ws
2021            .create(&RealGit, &RealHookRunner, &create_opts("late"))
2022            .unwrap_err();
2023        assert!(matches!(err, Error::SchemaTooNew { found: 2, .. }));
2024    }
2025
2026    #[test]
2027    fn lock_is_exclusive_and_released_on_drop() {
2028        let repo = TestRepo::init();
2029        let ws = workspace(&repo);
2030        let held = ws.lock().unwrap();
2031        // A second acquisition times out while the first is held...
2032        let err = acquire_repo_lock(ws.root(), Duration::from_millis(50))
2033            .err()
2034            .expect("the held lock must exclude a second holder");
2035        match &err {
2036            Error::LockUnavailable { path, .. } => {
2037                assert!(path.ends_with("wt-mutation.lock"), "{path}");
2038            }
2039            other => panic!("expected LockUnavailable, got {other:?}"),
2040        }
2041        // ...and succeeds once the holder is dropped.
2042        drop(held);
2043        acquire_repo_lock(ws.root(), Duration::from_millis(50)).unwrap();
2044    }
2045
2046    #[test]
2047    fn a_schema_refusal_does_not_strand_the_lock() {
2048        // The schema gate runs with the lock held (issue #106), so its refusal
2049        // has to give the lock back on the way out — otherwise one stamped
2050        // repository would wedge every later mutation for the full timeout.
2051        let repo = TestRepo::init();
2052        let ws = workspace(&repo);
2053        repo.git(&["config", "wt.schema", "2"]);
2054        let err = acquire_repo_lock(ws.root(), Duration::from_millis(50))
2055            .err()
2056            .expect("a future schema must refuse the acquisition");
2057        assert!(
2058            matches!(err, Error::SchemaTooNew { found: 2, .. }),
2059            "{err:?}"
2060        );
2061        assert!(!ws.root().join(".git/wt-mutation.lock").exists());
2062
2063        repo.git(&["config", "--unset", "wt.schema"]);
2064        acquire_repo_lock(ws.root(), Duration::from_millis(50)).unwrap();
2065    }
2066
2067    #[test]
2068    fn the_lock_gate_reads_a_bare_repository_too() {
2069        // The gate re-opens the repository from the lock root (issue #106), and
2070        // for a bare primary that root *is* the git directory rather than a
2071        // worktree containing one — so acquisition must not depend on a
2072        // workdir. `wt` supports bare primaries, so this is a real shape.
2073        let repo = TestRepo::init_bare();
2074        acquire_repo_lock(repo.root(), Duration::from_millis(50)).unwrap();
2075
2076        repo.git(&["config", "wt.schema", "2"]);
2077        let err = acquire_repo_lock(repo.root(), Duration::from_millis(50))
2078            .err()
2079            .expect("a bare repository is gated like any other");
2080        assert!(
2081            matches!(err, Error::SchemaTooNew { found: 2, .. }),
2082            "{err:?}"
2083        );
2084    }
2085
2086    #[test]
2087    fn a_schema_bump_landing_during_the_lock_wait_is_refused() {
2088        // The window issue #106 is about: a writer that passed the schema gate
2089        // and then blocked on the lock must not mutate against the version it
2090        // finally holds the lock over. Ordering is forced, not slept on — the
2091        // writer signals once it has a Workspace, and this thread holds the
2092        // lock across the bump, so the writer can only reach the mutation
2093        // after `wt.schema` is already 2.
2094        let repo = TestRepo::init();
2095        let ws = workspace(&repo);
2096        repo.git(&["branch", "stamped"]);
2097        let held = ws.lock().unwrap();
2098
2099        let (discovered, wait) = std::sync::mpsc::channel();
2100        let root = repo.root().to_path_buf();
2101        let writer = std::thread::spawn(move || {
2102            let ws = Workspace::discover(&root, &env(), &RealGit)?;
2103            discovered.send(()).expect("the test thread outlives this");
2104            ws.write_meta(
2105                &RealGit,
2106                "stamped",
2107                &MetaUpdate {
2108                    pr_number: Some(1),
2109                    ..MetaUpdate::default()
2110                },
2111            )
2112        });
2113
2114        wait.recv().expect("the writer discovers before it blocks");
2115        repo.git(&["config", "wt.schema", "2"]);
2116        drop(held);
2117
2118        let err = writer
2119            .join()
2120            .expect("the writer must not panic")
2121            .expect_err("the bumped schema must refuse the blocked write");
2122        assert!(
2123            matches!(err, Error::SchemaTooNew { found: 2, .. }),
2124            "{err:?}"
2125        );
2126        // The refusal is total: nothing was written on the way to it.
2127        repo.git(&["config", "--unset", "wt.schema"]);
2128        assert_eq!(ws.read_meta("stamped").unwrap(), WtMeta::default());
2129    }
2130
2131    #[test]
2132    fn create_releases_the_lock_before_the_post_create_hook() {
2133        // A hook that re-enters wt must not deadlock (issue #99): the hook
2134        // itself proves the lock file is gone by the time it runs.
2135        let repo = TestRepo::init();
2136        repo.write(
2137            ".wt.toml",
2138            "[hooks]\npost_create = \"test ! -e \\\"$WT_REPO_ROOT/.git/wt-mutation.lock\\\"\"\n",
2139        );
2140        repo.commit_all("config");
2141        let ws = workspace(&repo);
2142        let mut opts = create_opts("hookfree");
2143        opts.no_hooks = false;
2144        let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
2145        assert_eq!(created.post_create, HookOutcome::Succeeded);
2146    }
2147
2148    #[test]
2149    fn concurrent_creates_on_one_branch_do_not_corrupt_metadata() {
2150        // Two writers race to create the same branch (issue #99): exactly one
2151        // wins, the loser gets a clean error, and the metadata ends up
2152        // consistent — one worktree, one baseRef, createdByWt set once.
2153        let repo = TestRepo::init();
2154        let root = repo.root().to_path_buf();
2155        let spawn = |root: PathBuf| {
2156            std::thread::spawn(move || {
2157                let ws = Workspace::discover(&root, &env(), &RealGit)?;
2158                ws.create(&RealGit, &RealHookRunner, &create_opts("feat/race"))
2159            })
2160        };
2161        let a = spawn(root.clone());
2162        let b = spawn(root);
2163        let results = [a.join().unwrap(), b.join().unwrap()];
2164        let ok = results.iter().filter(|r| r.is_ok()).count();
2165        // Both may succeed only if one reused the other's finished worktree;
2166        // never may both claim to have created it.
2167        let created = results
2168            .iter()
2169            .filter(|r| r.as_ref().is_ok_and(|c| !c.reused))
2170            .count();
2171        assert!(ok >= 1, "at least one racer must win: {results:?}");
2172        assert_eq!(created, 1, "exactly one racer creates: {results:?}");
2173
2174        let ws = workspace(&repo);
2175        let rows = ws.list(&RealGit).unwrap();
2176        let race_rows: Vec<_> = rows
2177            .iter()
2178            .filter(|w| w.branch.as_deref() == Some("feat/race"))
2179            .collect();
2180        assert_eq!(race_rows.len(), 1);
2181        let meta = ws.read_meta("feat/race").unwrap();
2182        assert_eq!(meta.base_ref.as_deref(), Some("main"));
2183        assert!(meta.created_by_wt);
2184    }
2185
2186    #[test]
2187    fn resolve_base_falls_back_to_head_only_without_default() {
2188        let repo = TestRepo::init();
2189        let ws = workspace(&repo);
2190        let r = Repo::discover(repo.root()).unwrap();
2191        assert_eq!(
2192            resolve_base(&r, ws.config(), Some("explicit")),
2193            ("explicit".into(), false)
2194        );
2195        // The repo default branch resolves without a HEAD fallback.
2196        assert_eq!(resolve_base(&r, ws.config(), None), ("main".into(), false));
2197        // A configured default_base wins over the repo default branch.
2198        let mut config = ws.config().clone();
2199        config.default_base = Some("trunk".into());
2200        assert_eq!(resolve_base(&r, &config, None), ("trunk".into(), false));
2201    }
2202}