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