use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::config::wtconfig::WtMeta;
use crate::config::{self, Config, wtconfig};
use crate::copy::{CopyOutcome, copy_ignored_files};
use crate::cx::Env;
use crate::error::{Error, Result};
use crate::git::cli::GitCli;
use crate::git::discover::Repo;
use crate::git::submodule::seed::SeedReport;
use crate::git::{branch_ref, default_branch, is_ancestor, ops, resolve_hex};
use crate::hooks::{HookContext, HookRunner};
use crate::model::Worktree;
use crate::query::{self, Resolved};
use crate::slug::slugify_with_fallback;
use crate::template::{self, TemplateVars};
use crate::worktree::{materialize, rows};
const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
pub struct RepoLock {
_marker: gix_lock::Marker,
}
fn lock_dir(root: &Path) -> PathBuf {
let dot_git = root.join(".git");
if dot_git.is_dir() {
dot_git
} else {
root.to_path_buf()
}
}
fn ensure_schema_supported_now(root: &Path) -> Result<()> {
wtconfig::ensure_schema_supported(Repo::discover(root)?.gix())
}
pub(crate) fn acquire_repo_lock(root: &Path, timeout: Duration) -> Result<RepoLock> {
let resource = lock_dir(root).join("wt-mutation");
let marker = gix_lock::Marker::acquire_to_hold_resource(
&resource,
gix_lock::acquire::Fail::AfterDurationWithBackoff(timeout),
None,
)
.map_err(|e| Error::LockUnavailable {
path: format!("{}.lock", resource.display()),
reason: e.to_string(),
})?;
let lock = RepoLock { _marker: marker };
ensure_schema_supported_now(root)?;
Ok(lock)
}
#[cfg(feature = "cli")]
pub(crate) fn lock_repo(root: &Path) -> Result<RepoLock> {
acquire_repo_lock(root, LOCK_TIMEOUT)
}
pub struct Workspace {
repo: Repo,
primary_root: PathBuf,
config: Config,
env: Env,
}
pub(crate) struct WorkspaceParts<'a> {
pub(crate) repo: &'a Repo,
pub(crate) config: &'a Config,
pub(crate) root: &'a Path,
pub(crate) env: &'a Env,
}
impl Workspace {
pub fn discover(dir: &Path, env: &Env, git: &dyn GitCli) -> Result<Workspace> {
let repo = Repo::discover(dir)?;
let workdir = repo.current_workdir().unwrap_or_else(|| repo.git_dir());
let common = git.run(
&workdir,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)?;
let common = PathBuf::from(common.trim());
let primary_root = if repo.is_bare() {
common
} else {
common.parent().map(Path::to_path_buf).unwrap_or(common)
};
let config = config::load(Some(&primary_root), env)?;
wtconfig::ensure_schema_supported(repo.gix())?;
Ok(Workspace {
repo,
primary_root,
config,
env: env.clone(),
})
}
pub fn lock(&self) -> Result<RepoLock> {
acquire_repo_lock(&self.primary_root, LOCK_TIMEOUT)
}
pub fn root(&self) -> &Path {
&self.primary_root
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn is_bare(&self) -> bool {
self.repo.is_bare()
}
pub fn enumerate(&self, git: &dyn GitCli) -> Result<Vec<Worktree>> {
rows::enumerate_worktrees(&self.fresh_repo()?, git)
}
pub fn list(&self, git: &dyn GitCli) -> Result<Vec<Worktree>> {
rows::build_worktrees(&self.fresh_repo()?, git)
}
pub fn read_meta(&self, branch: &str) -> Result<WtMeta> {
Ok(wtconfig::read_meta(self.fresh_repo()?.gix(), branch))
}
pub fn write_meta(&self, git: &dyn GitCli, branch: &str, update: &MetaUpdate) -> Result<()> {
let _lock = self.lock()?;
apply_meta(git, &self.primary_root, branch, update)
}
pub fn clear_meta(&self, git: &dyn GitCli, branch: &str) -> Result<()> {
let _lock = self.lock()?;
wtconfig::clear_meta(git, &self.primary_root, branch)
}
pub fn create(
&self,
git: &dyn GitCli,
hooks: &dyn HookRunner,
options: &CreateOptions,
) -> Result<CreatedWorktree> {
let repo = self.fresh_repo()?;
create_in(&self.parts(&repo), git, hooks, options)
}
pub fn remove(
&self,
git: &dyn GitCli,
hooks: &dyn HookRunner,
worktree: &Worktree,
options: &RemoveOptions,
) -> Result<RemovedWorktree> {
let repo = self.fresh_repo()?;
remove_in(&self.parts(&repo), git, hooks, worktree, options)
}
fn fresh_repo(&self) -> Result<Repo> {
let dir = self
.repo
.current_workdir()
.unwrap_or_else(|| self.repo.git_dir());
Repo::discover(&dir)
}
fn parts<'a>(&'a self, repo: &'a Repo) -> WorkspaceParts<'a> {
WorkspaceParts {
repo,
config: &self.config,
root: &self.primary_root,
env: &self.env,
}
}
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
pub(crate) fn into_session_parts(self) -> (Repo, PathBuf, Config) {
(self.repo, self.primary_root, self.config)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MetaUpdate {
pub base_ref: Option<String>,
pub pr_number: Option<u64>,
pub pr_state: Option<String>,
pub pr_title: Option<String>,
pub pr_url: Option<String>,
pub issue_number: Option<u64>,
pub issue_title: Option<String>,
pub issue_url: Option<String>,
pub issue_brief: Option<String>,
pub created_by_wt: bool,
}
pub(crate) fn apply_meta(
git: &dyn GitCli,
root: &Path,
branch: &str,
update: &MetaUpdate,
) -> Result<()> {
if let Some(base_ref) = &update.base_ref {
wtconfig::write_base_ref(git, root, branch, base_ref)?;
}
if let Some(number) = update.pr_number {
wtconfig::write_pr_number(git, root, branch, number)?;
}
if let Some(state) = &update.pr_state {
wtconfig::write_pr_state(git, root, branch, state)?;
}
if let Some(title) = &update.pr_title {
wtconfig::write_pr_title(git, root, branch, title)?;
}
if let Some(url) = &update.pr_url {
wtconfig::write_pr_url(git, root, branch, url)?;
}
if let Some(number) = update.issue_number {
wtconfig::write_issue_number(git, root, branch, number)?;
}
if let Some(title) = &update.issue_title {
wtconfig::write_issue_title(git, root, branch, title)?;
}
if let Some(url) = &update.issue_url {
wtconfig::write_issue_url(git, root, branch, url)?;
}
if let Some(brief) = &update.issue_brief {
wtconfig::write_issue_brief(git, root, branch, brief)?;
}
if update.created_by_wt {
wtconfig::mark_created_by_wt(git, root, branch)?;
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct CreateOptions {
pub branch: String,
pub base: Option<String>,
pub track: Option<String>,
pub copy_from: Option<String>,
pub init_submodules: bool,
pub seed_submodules: bool,
pub reflink: bool,
pub no_hooks: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RemoveOptions {
pub force_remove: bool,
pub force_branch: bool,
pub keep_branch: bool,
pub no_hooks: bool,
pub unlock: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookOutcome {
Skipped,
Succeeded,
ExitedNonZero(i32),
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubmodulesOutcome {
Skipped,
Initialized(usize),
Failed {
pending: usize,
error: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct SubmoduleSeeding {
pub seeded: Vec<String>,
pub skipped: Vec<String>,
pub failed: Vec<(String, String)>,
}
impl From<SeedReport> for SubmoduleSeeding {
fn from(report: SeedReport) -> Self {
Self {
seeded: report.seeded,
skipped: report.skipped,
failed: report.failed,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CreatedWorktree {
pub path: PathBuf,
pub branch: String,
pub base_ref: Option<String>,
pub reused: bool,
pub copy: CopyOutcome,
pub post_create: HookOutcome,
pub submodules: SubmodulesOutcome,
pub submodule_seeding: SubmoduleSeeding,
pub reflinked: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RemovedWorktree {
pub branch_deleted: bool,
pub forced_past_guards: bool,
pub forced_for_submodules: bool,
pub pre_remove: HookOutcome,
}
pub(crate) fn resolve_base(repo: &Repo, config: &Config, explicit: Option<&str>) -> (String, bool) {
if let Some(explicit) = explicit {
return (explicit.to_string(), false);
}
if let Some(base) = &config.default_base {
return (base.clone(), false);
}
if let Some(branch) = default_branch(repo.gix()) {
return (branch, false);
}
("HEAD".to_string(), true)
}
#[cfg(feature = "cli")]
pub(crate) fn preview_target(
ws: &WorkspaceParts<'_>,
branch: &str,
base: Option<&str>,
) -> Result<std::path::PathBuf> {
let (slug, _) = target_slug(ws, branch, base)?;
render_target(ws.config, ws.root, branch, &slug, ws.env)
}
fn target_slug(
ws: &WorkspaceParts<'_>,
branch: &str,
base: Option<&str>,
) -> Result<(String, String)> {
let commit = match resolve_hex(ws.repo.gix(), &branch_ref(branch)) {
Some(oid) => oid,
None => {
let base_ref = resolve_base(ws.repo, ws.config, base).0;
resolve_hex(ws.repo.gix(), &base_ref)
.ok_or_else(|| Error::operation(format!("base ref {base_ref:?} not found")))?
}
};
let short_hash = commit.get(..7).unwrap_or(&commit).to_string();
let slug = slugify_with_fallback(branch, &short_hash);
Ok((slug, short_hash))
}
pub(crate) fn create_in(
ws: &WorkspaceParts<'_>,
git: &dyn GitCli,
hooks: &dyn HookRunner,
options: &CreateOptions,
) -> Result<CreatedWorktree> {
wtconfig::ensure_schema_supported(ws.repo.gix())?;
let branch = options.branch.clone();
let worktrees = rows::enumerate_worktrees(ws.repo, git)?;
let branch_exists = resolve_hex(ws.repo.gix(), &branch_ref(&branch)).is_some();
let base_ref = if branch_exists {
None
} else {
Some(resolve_base(ws.repo, ws.config, options.base.as_deref()).0)
};
let (slug, short_hash) = target_slug(ws, &branch, options.base.as_deref())?;
if let Some(existing) = worktrees
.iter()
.find(|w| w.branch.as_deref() == Some(branch.as_str()))
{
let preview = render_target(ws.config, ws.root, &branch, &slug, ws.env)?;
if same_path(&existing.path, &preview) {
return Ok(CreatedWorktree {
path: existing.path.clone(),
branch,
base_ref: None,
reused: true,
copy: CopyOutcome::default(),
post_create: HookOutcome::Skipped,
submodules: SubmodulesOutcome::Skipped,
submodule_seeding: SubmoduleSeeding::default(),
reflinked: false,
});
}
return Err(Error::operation(format!(
"branch {branch:?} is already checked out at {}",
existing.path.display()
)));
}
let lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
let target = resolve_target(
ws.config,
ws.root,
&branch,
&slug,
&short_hash,
ws.env,
ws.repo.is_bare(),
)?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let reflink_plan = if options.reflink {
let source = copy_source(ws, &worktrees, options.copy_from.as_deref()).ok();
target
.parent()
.and_then(|parent| materialize::plan(source.as_deref(), parent))
} else {
None
};
let no_checkout = reflink_plan.is_some();
let target_str = target.to_string_lossy().into_owned();
if let Some(base) = &base_ref {
ops::worktree_add_branch(git, ws.root, &branch, &target_str, base, true, no_checkout)?;
} else {
ops::worktree_add(git, ws.root, &target_str, &branch, no_checkout)?;
}
let reflinked = match &reflink_plan {
Some(plan) => match materialize::apply(git, &target, plan) {
Ok(used_cow) => used_cow,
Err(e) => {
rollback_worktree(git, ws.root, &target, &branch, base_ref.is_some(), false);
return Err(e);
}
},
None => false,
};
let copy = match post_create_steps(ws, git, &worktrees, &branch, &base_ref, &target, options) {
Ok(outcome) => outcome,
Err(e) => {
let created = base_ref.is_some();
rollback_worktree(git, ws.root, &target, &branch, created, created);
return Err(e);
}
};
drop(lock);
let ctx = HookContext {
worktree_path: target.clone(),
branch: branch.clone(),
repo_root: ws.root.to_path_buf(),
base_ref: base_ref.clone(),
pr_number: None,
};
let post_create = match (options.no_hooks, ws.config.hooks_post_create.as_deref()) {
(true, _) | (false, None) => HookOutcome::Skipped,
(false, Some(command)) => match hooks.run(command, &ctx) {
Ok(0) => HookOutcome::Succeeded,
Ok(code) => HookOutcome::ExitedNonZero(code),
Err(e) => HookOutcome::Failed(e.to_string()),
},
};
let attached = match reflinked.then(|| common_git_dir(git, ws.root)) {
Some(Ok(common)) => materialize::attach_submodules(git, &target, &common),
_ => Vec::new(),
};
if !attached.is_empty() {
tracing::debug!(count = attached.len(), "attached copied submodules");
}
let attach_sync = if attached.is_empty() {
Ok(())
} else {
crate::git::submodule::sync(git, &target)
};
let (submodules, seed) = match attach_sync {
Err(e) => (
SubmodulesOutcome::Failed {
pending: attached.len(),
error: e.to_string(),
},
SeedReport::default(),
),
Ok(()) if options.init_submodules => {
populate_submodules(git, &target, options.seed_submodules)?
}
Ok(()) => (SubmodulesOutcome::Skipped, SeedReport::default()),
};
Ok(CreatedWorktree {
path: target,
branch,
base_ref,
reused: false,
copy,
post_create,
submodules,
submodule_seeding: seed.into(),
reflinked,
})
}
fn common_git_dir(git: &dyn GitCli, root: &Path) -> Result<PathBuf> {
let out = git.run(
root,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)?;
Ok(PathBuf::from(out.trim()))
}
fn populate_submodules(
git: &dyn GitCli,
target: &Path,
seed_enabled: bool,
) -> Result<(SubmodulesOutcome, SeedReport)> {
let pending = crate::git::submodule::uninitialized(git, target)?;
if pending.is_empty() {
return Ok((SubmodulesOutcome::Skipped, SeedReport::default()));
}
let (seed, result) = crate::git::submodule::populate(git, target, seed_enabled);
let outcome = match result {
Ok(()) => SubmodulesOutcome::Initialized(pending.len()),
Err(e) => SubmodulesOutcome::Failed {
pending: pending.len(),
error: e.to_string(),
},
};
Ok((outcome, seed))
}
fn post_create_steps(
ws: &WorkspaceParts<'_>,
git: &dyn GitCli,
worktrees: &[Worktree],
branch: &str,
base_ref: &Option<String>,
target: &Path,
options: &CreateOptions,
) -> Result<CopyOutcome> {
if let Some(base) = base_ref {
apply_meta(
git,
ws.root,
branch,
&MetaUpdate {
base_ref: Some(base.clone()),
created_by_wt: true,
..MetaUpdate::default()
},
)?;
}
if let Some(upstream) = &options.track {
ops::set_upstream(git, ws.root, branch, upstream)?;
}
let source = copy_source(ws, worktrees, options.copy_from.as_deref())?;
copy_ignored_files(git, &source, target, &ws.config.copy)
}
fn copy_source(
ws: &WorkspaceParts<'_>,
worktrees: &[Worktree],
copy_from: Option<&str>,
) -> Result<PathBuf> {
if let Some(q) = copy_from {
return match query::resolve(worktrees, q) {
Resolved::One(index) => Ok(worktrees[index].path.clone()),
Resolved::Ambiguous(_) => {
Err(Error::operation(format!("--copy-from {q:?} is ambiguous")))
}
Resolved::NotFound => Err(Error::NotFound {
query: q.to_string(),
}),
};
}
Ok(ws
.repo
.current_workdir()
.unwrap_or_else(|| ws.root.to_path_buf()))
}
pub(crate) fn remove_in(
ws: &WorkspaceParts<'_>,
git: &dyn GitCli,
hooks: &dyn HookRunner,
worktree: &Worktree,
options: &RemoveOptions,
) -> Result<RemovedWorktree> {
wtconfig::ensure_schema_supported(ws.repo.gix())?;
if worktree.is_main {
return Err(Error::operation("refusing to remove the primary worktree"));
}
let meta = worktree
.branch
.as_deref()
.map(|b| wtconfig::read_meta(ws.repo.gix(), b))
.unwrap_or_default();
let default = default_branch(ws.repo.gix());
let lock = lock_state(git, ws.root, &worktree.path)?;
if let Some(reason) = &lock
&& !options.unlock
{
return Err(Error::WorktreeLocked {
reason: reason.clone(),
});
}
if worktree.is_missing {
let _lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
if lock.is_some() {
let path = worktree.path.to_string_lossy().into_owned();
ops::worktree_remove(git, ws.root, &path, true, true)?;
}
ops::worktree_prune(git, ws.root)?;
let branch_deleted = maybe_delete_branch(ws, git, worktree, &meta, options, &default);
clear_metadata(git, ws.root, worktree);
return Ok(RemovedWorktree {
branch_deleted,
forced_past_guards: false,
forced_for_submodules: false,
pre_remove: HookOutcome::Skipped,
});
}
let needs_submodule_force =
crate::git::submodule::any_initialized(git, &worktree.path).unwrap_or(false);
let unlocking = lock.is_some();
let untracked_blocks = ws.config.remove_untracked_blocks || needs_submodule_force || unlocking;
let guard = rows::guard_status(worktree, untracked_blocks);
if guard.blocks() && !options.force_remove {
return Err(Error::RemoveGuarded {
dirty: guard.dirty,
unpushed: guard.unpushed,
});
}
let forced_past_guards = guard.blocks() && options.force_remove;
let forced_for_submodules = needs_submodule_force && !options.force_remove;
let ctx = HookContext {
worktree_path: worktree.path.clone(),
branch: worktree.branch.clone().unwrap_or_default(),
repo_root: ws.root.to_path_buf(),
base_ref: meta.base_ref.clone(),
pr_number: meta.pr_number,
};
let pre_remove = match (options.no_hooks, ws.config.hooks_pre_remove.as_deref()) {
(true, _) | (false, None) => HookOutcome::Skipped,
(false, Some(command)) => match hooks.run(command, &ctx) {
Ok(0) => HookOutcome::Succeeded,
Ok(code) if options.force_remove => HookOutcome::ExitedNonZero(code),
Ok(code) => {
return Err(Error::operation(format!(
"pre_remove hook exited with status {code}; aborting (use --force to override)"
)));
}
Err(e) if options.force_remove => HookOutcome::Failed(e.to_string()),
Err(e) => return Err(e),
},
};
let _lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
let path = worktree.path.to_string_lossy().into_owned();
ops::worktree_remove(
git,
ws.root,
&path,
options.force_remove || needs_submodule_force,
unlocking,
)?;
let branch_deleted = maybe_delete_branch(ws, git, worktree, &meta, options, &default);
clear_metadata(git, ws.root, worktree);
Ok(RemovedWorktree {
branch_deleted,
forced_past_guards,
forced_for_submodules,
pre_remove,
})
}
fn lock_state(git: &dyn GitCli, root: &Path, path: &Path) -> Result<Option<Option<String>>> {
let raws = crate::git::worktrees::enumerate(git, root)?;
Ok(raws
.into_iter()
.find(|raw| raw.is_locked && same_path(&raw.path, path))
.map(|raw| raw.lock_reason))
}
fn maybe_delete_branch(
ws: &WorkspaceParts<'_>,
git: &dyn GitCli,
worktree: &Worktree,
meta: &WtMeta,
options: &RemoveOptions,
default: &Option<String>,
) -> bool {
let Some(branch) = &worktree.branch else {
return false;
};
if options.keep_branch || !meta.created_by_wt {
return false;
}
let base = meta.base_ref.clone().or_else(|| default.clone());
let merged = base
.as_deref()
.is_some_and(|b| is_ancestor(ws.repo.gix(), &branch_ref(branch), b));
let should_delete = if merged {
ws.config.remove_delete_merged_branch
} else {
options.force_branch
};
if !should_delete {
return false;
}
ops::delete_branch(git, ws.root, branch, true).is_ok()
}
fn clear_metadata(git: &dyn GitCli, root: &Path, worktree: &Worktree) {
if let Some(branch) = &worktree.branch {
let _ = wtconfig::clear_meta(git, root, branch);
}
}
pub(crate) fn same_path(a: &Path, b: &Path) -> bool {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canon(a) == canon(b)
}
pub(crate) fn git_dir_of(root: &Path, is_bare: bool) -> PathBuf {
if is_bare {
root.to_path_buf()
} else {
root.join(".git")
}
}
pub(crate) fn render_target(
config: &Config,
root: &Path,
branch: &str,
slug: &str,
env: &Env,
) -> Result<PathBuf> {
let vars = TemplateVars {
repo_parent: root
.parent()
.map_or_else(|| root.to_path_buf(), Path::to_path_buf),
repo: root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
repo_root: root.to_path_buf(),
branch: branch.to_string(),
branch_slug: slug.to_string(),
home: env
.get("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("~")),
};
template::render(&config.path_template, &vars)
}
pub(crate) fn resolve_target(
config: &Config,
root: &Path,
branch: &str,
slug: &str,
short_hash: &str,
env: &Env,
is_bare: bool,
) -> Result<PathBuf> {
let target = render_target(config, root, branch, slug, env)?;
template::ensure_outside_git(&target, &git_dir_of(root, is_bare))?;
if !target.exists() {
return Ok(target);
}
let alt = render_target(config, root, branch, &format!("{slug}-{short_hash}"), env)?;
if alt.exists() {
return Err(Error::operation(format!(
"target path already exists: {}",
target.display()
)));
}
Ok(alt)
}
pub(crate) fn run_best_effort(git: &dyn GitCli, root: &Path, args: &[&str], step: &str) {
match git.run_raw(root, args) {
Ok(out) if out.success => {}
Ok(out) => {
tracing::debug!(step, stderr = %out.stderr.trim(), "best-effort cleanup step failed");
}
Err(error) => {
tracing::debug!(step, %error, "best-effort cleanup step could not run");
}
}
}
pub(crate) fn rollback_worktree(
git: &dyn GitCli,
root: &Path,
target: &Path,
branch: &str,
delete_branch: bool,
clear_meta: bool,
) {
let target_str = target.to_string_lossy();
run_best_effort(
git,
root,
&["worktree", "remove", "--force", &target_str],
"rollback: worktree remove",
);
run_best_effort(
git,
root,
&["worktree", "prune"],
"rollback: worktree prune",
);
if delete_branch {
run_best_effort(
git,
root,
&["branch", "-D", branch],
"rollback: branch delete",
);
}
if clear_meta {
let _ = wtconfig::clear_meta(git, root, branch);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::cli::RealGit;
use crate::hooks::RealHookRunner;
use crate::testutil::{TestRepo, give_upstream};
use std::collections::HashMap;
fn env() -> Env {
Env::from_map(HashMap::new())
}
fn workspace(repo: &TestRepo) -> Workspace {
Workspace::discover(repo.root(), &env(), &RealGit).unwrap()
}
fn create_opts(branch: &str) -> CreateOptions {
CreateOptions {
branch: branch.to_string(),
no_hooks: true,
..Default::default()
}
}
#[test]
fn discover_resolves_root_config_and_bareness() {
let repo = TestRepo::init();
let ws = workspace(&repo);
assert!(!ws.is_bare());
assert_eq!(
std::fs::canonicalize(ws.root()).unwrap(),
std::fs::canonicalize(repo.root()).unwrap()
);
assert_eq!(ws.config().pr_default_remote, "origin");
}
#[test]
fn discover_outside_a_repo_is_not_in_repo() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
Workspace::discover(dir.path(), &env(), &RealGit),
Err(Error::NotInRepo)
));
}
#[test]
fn discover_from_linked_worktree_finds_primary_root() {
let repo = TestRepo::init();
repo.add_worktree("feature/x", "../wt-x");
let linked = repo.root().parent().unwrap().join("wt-x");
let ws = Workspace::discover(&linked, &env(), &RealGit).unwrap();
assert_eq!(
std::fs::canonicalize(ws.root()).unwrap(),
std::fs::canonicalize(repo.root()).unwrap()
);
}
#[test]
fn create_new_branch_records_metadata_and_copies_nothing() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("feature/login"))
.unwrap();
assert!(!created.reused);
assert_eq!(created.branch, "feature/login");
assert_eq!(created.base_ref.as_deref(), Some("main"));
assert!(created.path.is_dir());
assert!(
created.path.ends_with("feature-login")
|| created.path.to_string_lossy().contains("feature-login")
);
assert_eq!(created.post_create, HookOutcome::Skipped);
assert_eq!(created.submodules, SubmodulesOutcome::Skipped);
assert!(created.copy.copied.is_empty());
let meta = ws.read_meta("feature/login").unwrap();
assert_eq!(meta.base_ref.as_deref(), Some("main"));
assert!(meta.created_by_wt);
}
#[test]
fn create_existing_branch_does_not_mark_created() {
let repo = TestRepo::init();
repo.git(&["branch", "existing"]);
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("existing"))
.unwrap();
assert!(created.base_ref.is_none());
assert!(!ws.read_meta("existing").unwrap().created_by_wt);
}
#[test]
fn create_is_idempotent_at_the_same_target() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let first = ws
.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
.unwrap();
let second = ws
.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
.unwrap();
assert!(!first.reused);
assert!(second.reused);
assert_eq!(second.path, first.path);
assert_eq!(second.post_create, HookOutcome::Skipped);
}
#[test]
fn create_refuses_branch_checked_out_elsewhere() {
let repo = TestRepo::init();
repo.add_worktree("dup", "../manual-dup");
let ws = workspace(&repo);
let err = ws
.create(&RealGit, &RealHookRunner, &create_opts("dup"))
.unwrap_err();
assert!(err.to_string().contains("already checked out"));
}
#[cfg(feature = "cli")]
#[test]
fn preview_target_names_the_directory_create_actually_makes() {
for branch in ["feat/7-add-login", "_"] {
let repo = TestRepo::init();
let ws = workspace(&repo);
let fresh = Repo::discover(repo.root()).unwrap();
let previewed = preview_target(&ws.parts(&fresh), branch, None).unwrap();
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts(branch))
.unwrap();
assert_eq!(
previewed, created.path,
"preview and creation disagree for {branch:?}"
);
}
}
#[test]
fn a_slugless_branch_is_named_after_the_base_it_forks_from() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let base = Repo::discover(repo.root())
.ok()
.and_then(|r| resolve_hex(r.gix(), "main"))
.expect("main resolves");
let short = &base[..7];
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("_"))
.unwrap();
let name = created
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
assert!(
name.ends_with(short),
"{name} should end with the base short hash {short}"
);
}
#[test]
fn create_with_explicit_base_records_it() {
let repo = TestRepo::init();
repo.git(&["branch", "base-branch"]);
let ws = workspace(&repo);
let mut opts = create_opts("derived");
opts.base = Some("base-branch".into());
let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
assert_eq!(created.base_ref.as_deref(), Some("base-branch"));
assert_eq!(
ws.read_meta("derived").unwrap().base_ref.as_deref(),
Some("base-branch")
);
}
#[test]
fn create_with_unknown_base_errors() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let mut opts = create_opts("orphan");
opts.base = Some("no-such-ref".into());
let err = ws.create(&RealGit, &RealHookRunner, &opts).unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn create_reports_hook_outcomes_without_failing() {
let repo = TestRepo::init();
repo.write(".wt.toml", "[hooks]\npost_create = \"exit 3\"\n");
repo.commit_all("config");
let ws = workspace(&repo);
let mut opts = create_opts("hooked");
opts.no_hooks = false;
let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
assert_eq!(created.post_create, HookOutcome::ExitedNonZero(3));
assert!(created.path.is_dir());
}
#[test]
fn create_copies_ignored_files() {
let repo = TestRepo::init();
std::fs::write(repo.root().join(".wt.toml"), "copy = [\".env\"]\n").unwrap();
repo.write(".env", "SECRET=1\n");
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("withenv"))
.unwrap();
assert_eq!(created.copy.copied.len(), 1);
assert!(created.path.join(".env").exists());
}
#[test]
fn create_rolls_back_when_a_post_add_step_fails() {
use crate::git::cli::{GitCli, GitOutput};
struct FailConfig(RealGit);
impl GitCli for FailConfig {
fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput> {
if args.first() == Some(&"config") && args.iter().any(|a| a.starts_with("wt.")) {
return Ok(GitOutput {
success: false,
stdout: String::new(),
stderr: "simulated failure".into(),
});
}
self.0.run_raw(repo, args)
}
}
let repo = TestRepo::init();
let ws = workspace(&repo);
let err = ws
.create(
&FailConfig(RealGit),
&RealHookRunner,
&create_opts("rollme"),
)
.unwrap_err();
assert!(err.to_string().contains("simulated failure"));
assert!(repo.git(&["branch", "--list", "rollme"]).trim().is_empty());
assert!(!repo.git(&["worktree", "list"]).contains("rollme"));
}
fn row_for(ws: &Workspace, branch: &str) -> Worktree {
ws.list(&RealGit)
.unwrap()
.into_iter()
.find(|w| w.branch.as_deref() == Some(branch))
.unwrap()
}
#[test]
fn list_enumerate_and_meta_expose_worktrees() {
let repo = TestRepo::init();
let ws = workspace(&repo);
ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
.unwrap();
let shallow = ws.enumerate(&RealGit).unwrap();
assert_eq!(shallow.len(), 2);
assert!(shallow.iter().all(|w| w.dirty.is_none()));
let feat = row_for(&ws, "feature/x");
assert_eq!(feat.dirty, Some(false));
assert_eq!(feat.base_ref.as_deref(), Some("main"));
}
#[test]
fn write_meta_applies_only_the_set_fields() {
let repo = TestRepo::init();
let ws = workspace(&repo);
ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
.unwrap();
ws.write_meta(
&RealGit,
"feature/x",
&MetaUpdate {
pr_number: Some(7),
pr_state: Some("open".into()),
pr_title: Some("Add x".into()),
pr_url: Some("https://example.test/7".into()),
..MetaUpdate::default()
},
)
.unwrap();
let meta = ws.read_meta("feature/x").unwrap();
assert_eq!(meta.pr_number, Some(7));
assert_eq!(meta.pr_state.as_deref(), Some("open"));
assert_eq!(meta.pr_title.as_deref(), Some("Add x"));
assert_eq!(meta.pr_url.as_deref(), Some("https://example.test/7"));
assert_eq!(meta.base_ref.as_deref(), Some("main"));
assert!(meta.created_by_wt);
ws.write_meta(
&RealGit,
"feature/x",
&MetaUpdate {
pr_state: Some("merged".into()),
..MetaUpdate::default()
},
)
.unwrap();
let meta = ws.read_meta("feature/x").unwrap();
assert_eq!(meta.pr_state.as_deref(), Some("merged"));
assert_eq!(meta.pr_number, Some(7));
assert_eq!(meta.pr_title.as_deref(), Some("Add x"));
}
#[test]
fn write_meta_marks_created_by_wt_but_never_unmarks() {
let repo = TestRepo::init();
let ws = workspace(&repo);
repo.git(&["branch", "solo"]);
ws.write_meta(&RealGit, "solo", &MetaUpdate::default())
.unwrap();
assert_eq!(ws.read_meta("solo").unwrap(), WtMeta::default());
ws.write_meta(
&RealGit,
"solo",
&MetaUpdate {
created_by_wt: true,
..MetaUpdate::default()
},
)
.unwrap();
assert!(ws.read_meta("solo").unwrap().created_by_wt);
ws.write_meta(&RealGit, "solo", &MetaUpdate::default())
.unwrap();
assert!(ws.read_meta("solo").unwrap().created_by_wt);
}
#[test]
fn clear_meta_removes_the_section_and_tolerates_a_missing_one() {
let repo = TestRepo::init();
let ws = workspace(&repo);
ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
.unwrap();
assert!(ws.read_meta("feature/x").unwrap().created_by_wt);
ws.clear_meta(&RealGit, "feature/x").unwrap();
assert_eq!(ws.read_meta("feature/x").unwrap(), WtMeta::default());
ws.clear_meta(&RealGit, "never-recorded").unwrap();
}
#[test]
fn metadata_writes_take_the_repo_lock() {
let repo = TestRepo::init();
let ws = workspace(&repo);
repo.git(&["branch", "locked"]);
let held = ws.lock().unwrap();
let err = ws
.write_meta(
&RealGit,
"locked",
&MetaUpdate {
pr_number: Some(1),
..MetaUpdate::default()
},
)
.unwrap_err();
assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
let err = ws.clear_meta(&RealGit, "locked").unwrap_err();
assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
drop(held);
ws.clear_meta(&RealGit, "locked").unwrap();
}
#[test]
fn metadata_writes_refuse_a_future_schema() {
let repo = TestRepo::init();
let ws = workspace(&repo);
repo.git(&["branch", "stamped"]);
repo.git(&["config", "wt.schema", "3"]);
let err = ws
.write_meta(&RealGit, "stamped", &MetaUpdate::default())
.unwrap_err();
assert!(
matches!(err, Error::SchemaTooNew { found: 3, .. }),
"{err:?}"
);
let err = ws.clear_meta(&RealGit, "stamped").unwrap_err();
assert!(
matches!(err, Error::SchemaTooNew { found: 3, .. }),
"{err:?}"
);
}
#[test]
fn remove_blocked_by_guards_is_a_typed_error() {
let repo = TestRepo::init();
let ws = workspace(&repo);
ws.create(&RealGit, &RealHookRunner, &create_opts("topic"))
.unwrap();
let row = row_for(&ws, "topic");
let err = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
no_hooks: true,
..Default::default()
},
)
.unwrap_err();
match err {
Error::RemoveGuarded { dirty, unpushed } => {
assert!(!dirty);
assert!(unpushed);
}
other => panic!("expected RemoveGuarded, got {other:?}"),
}
}
#[test]
fn create_seeds_submodules_without_reaching_a_remote() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let ws = workspace(&repo);
let created = ws
.create(
&RealGit,
&RealHookRunner,
&CreateOptions {
branch: "topic".to_string(),
init_submodules: true,
seed_submodules: true,
no_hooks: true,
..Default::default()
},
)
.unwrap();
assert!(
created.path.join("libs/sub/sub.txt").exists(),
"submodule was not populated"
);
assert_eq!(
created.submodule_seeding.seeded,
vec!["libs/sub".to_string()]
);
assert!(created.submodule_seeding.failed.is_empty());
assert!(matches!(
created.submodules,
SubmodulesOutcome::Initialized(1)
));
}
#[test]
fn reflink_materialization_matches_a_normal_checkout() {
let Some(repo) = TestRepo::init_cow() else {
return;
};
repo.add_submodule("libs/sub");
repo.write("tracked.txt", "content\n");
repo.write(".gitignore", "build.out\n");
repo.commit_all("add a tracked file and an ignore rule");
repo.write("build.out", "artifact\n");
repo.write("scratch.txt", "unsaved\n");
std::fs::create_dir_all(repo.root().join("wip")).unwrap();
repo.write("wip/notes.md", "later\n");
repo.git(&["config", "status.showUntrackedFiles", "no"]);
let ws = workspace(&repo);
let created = ws
.create(
&RealGit,
&RealHookRunner,
&CreateOptions {
branch: "topic".to_string(),
init_submodules: true,
seed_submodules: true,
reflink: true,
no_hooks: true,
..Default::default()
},
)
.unwrap();
assert!(created.reflinked, "the CoW path did not run");
assert_eq!(
std::fs::read_to_string(created.path.join("tracked.txt")).unwrap(),
"content\n"
);
let status = repo.git(&["-C", &created.path.to_string_lossy(), "status", "--short"]);
assert!(
status.trim().is_empty(),
"worktree is not clean: {status:?}"
);
assert!(created.path.join("libs/sub/sub.txt").exists());
let subs = repo.git(&[
"-C",
&created.path.to_string_lossy(),
"submodule",
"status",
"--recursive",
]);
assert!(
subs.starts_with(' '),
"submodule not in sync after a reflink create: {subs:?}"
);
let origin =
|dir: &Path| repo.git(&["-C", &dir.to_string_lossy(), "config", "remote.origin.url"]);
assert_eq!(
origin(&created.path.join("libs/sub")),
origin(&repo.root().join("libs/sub")),
"the reflinked submodule kept the local mirror as its origin"
);
assert!(created.path.join("build.out").exists());
assert!(
!created.path.join("scratch.txt").exists(),
"an untracked source file was cloned into the new worktree"
);
assert!(
!created.path.join("wip/notes.md").exists(),
"an untracked source directory was cloned into the new worktree"
);
}
#[test]
fn reflink_declines_when_the_source_is_at_a_different_tree() {
let repo = TestRepo::init();
let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.write("only-on-head.txt", "later\n");
repo.commit_all("move the source ahead");
let ws = workspace(&repo);
let created = ws
.create(
&RealGit,
&RealHookRunner,
&CreateOptions {
branch: "topic".to_string(),
base: Some(base),
reflink: true,
no_hooks: true,
..Default::default()
},
)
.unwrap();
assert!(!created.reflinked, "cloned across differing trees");
assert!(!created.path.join("only-on-head.txt").exists());
let status = repo.git(&["-C", &created.path.to_string_lossy(), "status", "--short"]);
assert!(
status.trim().is_empty(),
"worktree is not clean: {status:?}"
);
}
#[test]
fn create_without_seeding_leaves_the_clone_to_git() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let ws = workspace(&repo);
let created = ws
.create(
&RealGit,
&RealHookRunner,
&CreateOptions {
branch: "topic".to_string(),
init_submodules: true,
seed_submodules: false,
no_hooks: true,
..Default::default()
},
)
.unwrap();
assert!(created.submodule_seeding.seeded.is_empty());
assert!(!created.path.join("libs/sub/sub.txt").exists());
assert!(matches!(
created.submodules,
SubmodulesOutcome::Failed { .. }
));
}
#[test]
fn remove_succeeds_on_a_worktree_containing_submodules() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("topic"))
.unwrap();
repo.git(&[
"-C",
&created.path.to_string_lossy(),
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
]);
assert!(created.path.join("libs/sub/sub.txt").exists());
give_upstream(&repo, "topic");
let row = row_for(&ws, "topic");
let removed = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
no_hooks: true,
..Default::default()
},
)
.unwrap();
assert!(!created.path.exists(), "worktree directory still present");
assert!(
removed.forced_for_submodules,
"removal should record that git needed forcing for submodules"
);
assert!(
!removed.forced_past_guards,
"no wt guard was overridden, so this must not read as a forced removal"
);
}
#[test]
fn remove_guards_untracked_files_when_submodules_force_git() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("topic"))
.unwrap();
repo.git(&[
"-C",
&created.path.to_string_lossy(),
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
]);
give_upstream(&repo, "topic");
std::fs::write(created.path.join("scratch.txt"), "unsaved\n").unwrap();
let row = row_for(&ws, "topic");
let err = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
no_hooks: true,
..Default::default()
},
)
.unwrap_err();
assert!(matches!(err, Error::RemoveGuarded { dirty: true, .. }));
assert!(created.path.join("scratch.txt").exists());
let row = row_for(&ws, "topic");
let removed = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
no_hooks: true,
force_remove: true,
..Default::default()
},
)
.unwrap();
assert!(!created.path.exists());
assert!(removed.forced_past_guards);
}
#[test]
fn remove_without_submodules_does_not_report_a_submodule_force() {
let repo = TestRepo::init();
let ws = workspace(&repo);
ws.create(&RealGit, &RealHookRunner, &create_opts("topic"))
.unwrap();
give_upstream(&repo, "topic");
let row = row_for(&ws, "topic");
let removed = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
no_hooks: true,
..Default::default()
},
)
.unwrap();
assert!(!removed.forced_for_submodules);
assert!(!removed.forced_past_guards);
}
#[test]
fn remove_force_reports_forced_past_guards() {
let repo = TestRepo::init();
let ws = workspace(&repo);
ws.create(&RealGit, &RealHookRunner, &create_opts("forced"))
.unwrap();
let row = row_for(&ws, "forced");
let removed = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
force_remove: true,
force_branch: true,
no_hooks: true,
keep_branch: false,
unlock: false,
},
)
.unwrap();
assert!(removed.forced_past_guards);
assert!(!repo.git(&["worktree", "list"]).contains("forced"));
assert!(removed.branch_deleted);
assert_eq!(ws.read_meta("forced").unwrap(), WtMeta::default());
}
#[test]
fn remove_refuses_the_primary_worktree() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let main = row_for(&ws, "main");
let err = ws
.remove(&RealGit, &RealHookRunner, &main, &RemoveOptions::default())
.unwrap_err();
assert!(err.to_string().contains("primary"));
}
#[test]
fn remove_missing_worktree_prunes_without_guards() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("gone"))
.unwrap();
std::fs::remove_dir_all(&created.path).unwrap();
let row = row_for(&ws, "gone");
assert!(row.is_missing);
let removed = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
no_hooks: true,
..Default::default()
},
)
.unwrap();
assert_eq!(removed.pre_remove, HookOutcome::Skipped);
assert!(!repo.git(&["worktree", "list"]).contains("gone"));
}
#[test]
fn remove_locked_worktree_needs_unlock_even_with_force() {
let repo = TestRepo::init();
repo.write(".wt.toml", "[hooks]\npre_remove = \"touch hook-ran\"\n");
repo.commit_all("config");
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("held"))
.unwrap();
let dir = created.path.to_string_lossy().into_owned();
repo.git(&["worktree", "lock", "--reason", "some reason", &dir]);
let row = row_for(&ws, "held");
for force_remove in [false, true] {
let err = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
force_remove,
..Default::default()
},
)
.unwrap_err();
assert!(
matches!(&err, Error::WorktreeLocked { reason: Some(r) } if r == "some reason"),
"{err:?}"
);
assert_eq!(
err.to_string(),
"worktree is locked (some reason); use --locked"
);
}
assert!(created.path.exists());
assert!(!created.path.join("hook-ran").exists(), "hook ran first");
ws.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
force_remove: true,
unlock: true,
no_hooks: true,
..Default::default()
},
)
.unwrap();
assert!(!created.path.exists());
assert!(!repo.git(&["worktree", "list"]).contains("held"));
}
#[test]
fn remove_locked_worktree_without_a_reason() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("bare-lock"))
.unwrap();
repo.git(&["worktree", "lock", &created.path.to_string_lossy()]);
let row = row_for(&ws, "bare-lock");
let err = ws
.remove(&RealGit, &RealHookRunner, &row, &RemoveOptions::default())
.unwrap_err();
assert!(matches!(err, Error::WorktreeLocked { reason: None }));
assert_eq!(err.to_string(), "worktree is locked; use --locked");
}
#[test]
fn remove_missing_locked_worktree_needs_unlock() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("lost"))
.unwrap();
repo.git(&[
"worktree",
"lock",
"--reason",
"pid 1",
&created.path.to_string_lossy(),
]);
std::fs::remove_dir_all(&created.path).unwrap();
let row = row_for(&ws, "lost");
assert!(row.is_missing);
let err = ws
.remove(&RealGit, &RealHookRunner, &row, &RemoveOptions::default())
.unwrap_err();
assert!(matches!(err, Error::WorktreeLocked { .. }));
ws.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
unlock: true,
..Default::default()
},
)
.unwrap();
assert!(!repo.git(&["worktree", "list"]).contains("lost"));
}
#[test]
fn remove_with_unlock_on_an_unlocked_worktree_does_not_force_git() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("free"))
.unwrap();
give_upstream(&repo, "free");
std::fs::write(created.path.join("scratch.txt"), "unsaved\n").unwrap();
let row = row_for(&ws, "free");
let unlock = RemoveOptions {
unlock: true,
no_hooks: true,
..Default::default()
};
let err = ws
.remove(&RealGit, &RealHookRunner, &row, &unlock)
.unwrap_err();
assert!(matches!(err, Error::Subprocess { .. }), "{err:?}");
assert!(created.path.join("scratch.txt").exists());
std::fs::remove_file(created.path.join("scratch.txt")).unwrap();
let row = row_for(&ws, "free");
ws.remove(&RealGit, &RealHookRunner, &row, &unlock).unwrap();
assert!(!created.path.exists());
}
#[test]
fn remove_unlock_guards_untracked_files() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let created = ws
.create(&RealGit, &RealHookRunner, &create_opts("held-dirty"))
.unwrap();
give_upstream(&repo, "held-dirty");
repo.git(&["worktree", "lock", &created.path.to_string_lossy()]);
std::fs::write(created.path.join("scratch.txt"), "unsaved\n").unwrap();
let row = row_for(&ws, "held-dirty");
let err = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
unlock: true,
no_hooks: true,
..Default::default()
},
)
.unwrap_err();
assert!(matches!(err, Error::RemoveGuarded { dirty: true, .. }));
assert!(created.path.join("scratch.txt").exists());
}
#[test]
fn remove_failing_pre_remove_hook_aborts_unless_forced() {
let repo = TestRepo::init();
repo.write(".wt.toml", "[hooks]\npre_remove = \"exit 5\"\n");
repo.commit_all("config");
let ws = workspace(&repo);
ws.create(&RealGit, &RealHookRunner, &create_opts("hooked"))
.unwrap();
let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/hooked", &head]);
repo.git(&["config", "branch.hooked.remote", "origin"]);
repo.git(&["config", "branch.hooked.merge", "refs/heads/hooked"]);
let row = row_for(&ws, "hooked");
let err = ws
.remove(&RealGit, &RealHookRunner, &row, &RemoveOptions::default())
.unwrap_err();
assert!(
err.to_string()
.contains("pre_remove hook exited with status 5")
);
let removed = ws
.remove(
&RealGit,
&RealHookRunner,
&row,
&RemoveOptions {
force_remove: true,
..Default::default()
},
)
.unwrap();
assert_eq!(removed.pre_remove, HookOutcome::ExitedNonZero(5));
assert!(!repo.git(&["worktree", "list"]).contains("hooked"));
}
#[test]
fn discover_refuses_a_future_schema() {
let repo = TestRepo::init();
repo.git(&["config", "wt.schema", "99"]);
let err = Workspace::discover(repo.root(), &env(), &RealGit)
.err()
.expect("a future schema must refuse discovery");
assert!(matches!(err, Error::SchemaTooNew { found: 99, .. }));
}
#[test]
fn mutations_refuse_a_schema_stamped_after_discovery() {
let repo = TestRepo::init();
let ws = workspace(&repo);
repo.git(&["config", "wt.schema", "2"]);
let err = ws
.create(&RealGit, &RealHookRunner, &create_opts("late"))
.unwrap_err();
assert!(matches!(err, Error::SchemaTooNew { found: 2, .. }));
}
#[test]
fn lock_is_exclusive_and_released_on_drop() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let held = ws.lock().unwrap();
let err = acquire_repo_lock(ws.root(), Duration::from_millis(50))
.err()
.expect("the held lock must exclude a second holder");
match &err {
Error::LockUnavailable { path, .. } => {
assert!(path.ends_with("wt-mutation.lock"), "{path}");
}
other => panic!("expected LockUnavailable, got {other:?}"),
}
drop(held);
acquire_repo_lock(ws.root(), Duration::from_millis(50)).unwrap();
}
#[test]
fn a_schema_refusal_does_not_strand_the_lock() {
let repo = TestRepo::init();
let ws = workspace(&repo);
repo.git(&["config", "wt.schema", "2"]);
let err = acquire_repo_lock(ws.root(), Duration::from_millis(50))
.err()
.expect("a future schema must refuse the acquisition");
assert!(
matches!(err, Error::SchemaTooNew { found: 2, .. }),
"{err:?}"
);
assert!(!ws.root().join(".git/wt-mutation.lock").exists());
repo.git(&["config", "--unset", "wt.schema"]);
acquire_repo_lock(ws.root(), Duration::from_millis(50)).unwrap();
}
#[test]
fn the_lock_gate_reads_a_bare_repository_too() {
let repo = TestRepo::init_bare();
acquire_repo_lock(repo.root(), Duration::from_millis(50)).unwrap();
repo.git(&["config", "wt.schema", "2"]);
let err = acquire_repo_lock(repo.root(), Duration::from_millis(50))
.err()
.expect("a bare repository is gated like any other");
assert!(
matches!(err, Error::SchemaTooNew { found: 2, .. }),
"{err:?}"
);
}
#[test]
fn a_schema_bump_landing_during_the_lock_wait_is_refused() {
let repo = TestRepo::init();
let ws = workspace(&repo);
repo.git(&["branch", "stamped"]);
let held = ws.lock().unwrap();
let (discovered, wait) = std::sync::mpsc::channel();
let root = repo.root().to_path_buf();
let writer = std::thread::spawn(move || {
let ws = Workspace::discover(&root, &env(), &RealGit)?;
discovered.send(()).expect("the test thread outlives this");
ws.write_meta(
&RealGit,
"stamped",
&MetaUpdate {
pr_number: Some(1),
..MetaUpdate::default()
},
)
});
wait.recv().expect("the writer discovers before it blocks");
repo.git(&["config", "wt.schema", "2"]);
drop(held);
let err = writer
.join()
.expect("the writer must not panic")
.expect_err("the bumped schema must refuse the blocked write");
assert!(
matches!(err, Error::SchemaTooNew { found: 2, .. }),
"{err:?}"
);
repo.git(&["config", "--unset", "wt.schema"]);
assert_eq!(ws.read_meta("stamped").unwrap(), WtMeta::default());
}
#[test]
fn create_releases_the_lock_before_the_post_create_hook() {
let repo = TestRepo::init();
repo.write(
".wt.toml",
"[hooks]\npost_create = \"test ! -e \\\"$WT_REPO_ROOT/.git/wt-mutation.lock\\\"\"\n",
);
repo.commit_all("config");
let ws = workspace(&repo);
let mut opts = create_opts("hookfree");
opts.no_hooks = false;
let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
assert_eq!(created.post_create, HookOutcome::Succeeded);
}
#[test]
fn concurrent_creates_on_one_branch_do_not_corrupt_metadata() {
let repo = TestRepo::init();
let root = repo.root().to_path_buf();
let spawn = |root: PathBuf| {
std::thread::spawn(move || {
let ws = Workspace::discover(&root, &env(), &RealGit)?;
ws.create(&RealGit, &RealHookRunner, &create_opts("feat/race"))
})
};
let a = spawn(root.clone());
let b = spawn(root);
let results = [a.join().unwrap(), b.join().unwrap()];
let ok = results.iter().filter(|r| r.is_ok()).count();
let created = results
.iter()
.filter(|r| r.as_ref().is_ok_and(|c| !c.reused))
.count();
assert!(ok >= 1, "at least one racer must win: {results:?}");
assert_eq!(created, 1, "exactly one racer creates: {results:?}");
let ws = workspace(&repo);
let rows = ws.list(&RealGit).unwrap();
let race_rows: Vec<_> = rows
.iter()
.filter(|w| w.branch.as_deref() == Some("feat/race"))
.collect();
assert_eq!(race_rows.len(), 1);
let meta = ws.read_meta("feat/race").unwrap();
assert_eq!(meta.base_ref.as_deref(), Some("main"));
assert!(meta.created_by_wt);
}
#[test]
fn resolve_base_falls_back_to_head_only_without_default() {
let repo = TestRepo::init();
let ws = workspace(&repo);
let r = Repo::discover(repo.root()).unwrap();
assert_eq!(
resolve_base(&r, ws.config(), Some("explicit")),
("explicit".into(), false)
);
assert_eq!(resolve_base(&r, ws.config(), None), ("main".into(), false));
let mut config = ws.config().clone();
config.default_base = Some("trunk".into());
assert_eq!(resolve_base(&r, &config, None), ("trunk".into(), false));
}
}