1use 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
30const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
33
34pub struct RepoLock {
48 _marker: gix_lock::Marker,
49}
50
51fn 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
63fn ensure_schema_supported_now(root: &Path) -> Result<()> {
71 wtconfig::ensure_schema_supported(Repo::discover(root)?.gix())
72}
73
74pub(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#[cfg(feature = "cli")]
105pub(crate) fn lock_repo(root: &Path) -> Result<RepoLock> {
106 acquire_repo_lock(root, LOCK_TIMEOUT)
107}
108
109pub struct Workspace {
112 repo: Repo,
113 primary_root: PathBuf,
114 config: Config,
115 env: Env,
116}
117
118pub(crate) struct WorkspaceParts<'a> {
122 pub(crate) repo: &'a Repo,
124 pub(crate) config: &'a Config,
126 pub(crate) root: &'a Path,
128 pub(crate) env: &'a Env,
130}
131
132impl Workspace {
133 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 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 wtconfig::ensure_schema_supported(repo.gix())?;
155 Ok(Workspace {
156 repo,
157 primary_root,
158 config,
159 env: env.clone(),
160 })
161 }
162
163 pub fn lock(&self) -> Result<RepoLock> {
171 acquire_repo_lock(&self.primary_root, LOCK_TIMEOUT)
172 }
173
174 pub fn root(&self) -> &Path {
177 &self.primary_root
178 }
179
180 pub fn config(&self) -> &Config {
183 &self.config
184 }
185
186 pub fn is_bare(&self) -> bool {
188 self.repo.is_bare()
189 }
190
191 pub fn enumerate(&self, git: &dyn GitCli) -> Result<Vec<Worktree>> {
194 rows::enumerate_worktrees(&self.fresh_repo()?, git)
195 }
196
197 pub fn list(&self, git: &dyn GitCli) -> Result<Vec<Worktree>> {
201 rows::build_worktrees(&self.fresh_repo()?, git)
202 }
203
204 pub fn read_meta(&self, branch: &str) -> Result<WtMeta> {
206 Ok(wtconfig::read_meta(self.fresh_repo()?.gix(), branch))
207 }
208
209 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 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 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 pub fn remove(
252 &self,
253 git: &dyn GitCli,
254 hooks: &dyn HookRunner,
255 worktree: &Worktree,
256 options: &RemoveOptions,
257 ) -> Result<RemovedWorktree> {
258 let repo = self.fresh_repo()?;
259 remove_in(&self.parts(&repo), git, hooks, worktree, options)
260 }
261
262 fn fresh_repo(&self) -> Result<Repo> {
268 let dir = self
269 .repo
270 .current_workdir()
271 .unwrap_or_else(|| self.repo.git_dir());
272 Repo::discover(&dir)
273 }
274
275 fn parts<'a>(&'a self, repo: &'a Repo) -> WorkspaceParts<'a> {
277 WorkspaceParts {
278 repo,
279 config: &self.config,
280 root: &self.primary_root,
281 env: &self.env,
282 }
283 }
284
285 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
287 pub(crate) fn into_session_parts(self) -> (Repo, PathBuf, Config) {
288 (self.repo, self.primary_root, self.config)
289 }
290}
291
292#[derive(Debug, Clone, Default, PartialEq, Eq)]
296pub struct MetaUpdate {
297 pub base_ref: Option<String>,
299 pub pr_number: Option<u64>,
301 pub pr_state: Option<String>,
303 pub pr_title: Option<String>,
305 pub pr_url: Option<String>,
307 pub issue_number: Option<u64>,
309 pub issue_title: Option<String>,
311 pub issue_url: Option<String>,
313 pub issue_brief: Option<String>,
315 pub created_by_wt: bool,
319}
320
321pub(crate) fn apply_meta(
324 git: &dyn GitCli,
325 root: &Path,
326 branch: &str,
327 update: &MetaUpdate,
328) -> Result<()> {
329 if let Some(base_ref) = &update.base_ref {
330 wtconfig::write_base_ref(git, root, branch, base_ref)?;
331 }
332 if let Some(number) = update.pr_number {
333 wtconfig::write_pr_number(git, root, branch, number)?;
334 }
335 if let Some(state) = &update.pr_state {
336 wtconfig::write_pr_state(git, root, branch, state)?;
337 }
338 if let Some(title) = &update.pr_title {
339 wtconfig::write_pr_title(git, root, branch, title)?;
340 }
341 if let Some(url) = &update.pr_url {
342 wtconfig::write_pr_url(git, root, branch, url)?;
343 }
344 if let Some(number) = update.issue_number {
345 wtconfig::write_issue_number(git, root, branch, number)?;
346 }
347 if let Some(title) = &update.issue_title {
348 wtconfig::write_issue_title(git, root, branch, title)?;
349 }
350 if let Some(url) = &update.issue_url {
351 wtconfig::write_issue_url(git, root, branch, url)?;
352 }
353 if let Some(brief) = &update.issue_brief {
354 wtconfig::write_issue_brief(git, root, branch, brief)?;
355 }
356 if update.created_by_wt {
357 wtconfig::mark_created_by_wt(git, root, branch)?;
358 }
359 Ok(())
360}
361
362#[derive(Debug, Clone, Default)]
364pub struct CreateOptions {
365 pub branch: String,
367 pub base: Option<String>,
371 pub track: Option<String>,
373 pub copy_from: Option<String>,
376 pub init_submodules: bool,
380 pub seed_submodules: bool,
386 pub reflink: bool,
391 pub no_hooks: bool,
393}
394
395#[derive(Debug, Clone, Copy, Default)]
399pub struct RemoveOptions {
400 pub force_remove: bool,
403 pub force_branch: bool,
405 pub keep_branch: bool,
407 pub no_hooks: bool,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
413pub enum HookOutcome {
414 Skipped,
417 Succeeded,
419 ExitedNonZero(i32),
421 Failed(String),
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum SubmodulesOutcome {
428 Skipped,
430 Initialized(usize),
432 Failed {
435 pending: usize,
437 error: String,
439 },
440}
441
442#[derive(Debug, Clone, Default, PartialEq, Eq)]
450#[non_exhaustive]
451pub struct SubmoduleSeeding {
452 pub seeded: Vec<String>,
454 pub skipped: Vec<String>,
456 pub failed: Vec<(String, String)>,
459}
460
461impl From<SeedReport> for SubmoduleSeeding {
462 fn from(report: SeedReport) -> Self {
463 Self {
464 seeded: report.seeded,
465 skipped: report.skipped,
466 failed: report.failed,
467 }
468 }
469}
470
471#[derive(Debug, Clone)]
473#[non_exhaustive]
474pub struct CreatedWorktree {
475 pub path: PathBuf,
477 pub branch: String,
479 pub base_ref: Option<String>,
482 pub reused: bool,
485 pub copy: CopyOutcome,
487 pub post_create: HookOutcome,
489 pub submodules: SubmodulesOutcome,
491 pub submodule_seeding: SubmoduleSeeding,
493 pub reflinked: bool,
497}
498
499#[derive(Debug, Clone)]
501#[non_exhaustive]
502pub struct RemovedWorktree {
503 pub branch_deleted: bool,
505 pub forced_past_guards: bool,
509 pub forced_for_submodules: bool,
515 pub pre_remove: HookOutcome,
519}
520
521pub(crate) fn resolve_base(repo: &Repo, config: &Config, explicit: Option<&str>) -> (String, bool) {
526 if let Some(explicit) = explicit {
527 return (explicit.to_string(), false);
528 }
529 if let Some(base) = &config.default_base {
530 return (base.clone(), false);
531 }
532 if let Some(branch) = default_branch(repo.gix()) {
533 return (branch, false);
534 }
535 ("HEAD".to_string(), true)
536}
537
538#[cfg(feature = "cli")]
545pub(crate) fn preview_target(
546 ws: &WorkspaceParts<'_>,
547 branch: &str,
548 base: Option<&str>,
549) -> Result<std::path::PathBuf> {
550 let (slug, _) = target_slug(ws, branch, base)?;
551 render_target(ws.config, ws.root, branch, &slug, ws.env)
552}
553
554fn target_slug(
561 ws: &WorkspaceParts<'_>,
562 branch: &str,
563 base: Option<&str>,
564) -> Result<(String, String)> {
565 let commit = match resolve_hex(ws.repo.gix(), &branch_ref(branch)) {
566 Some(oid) => oid,
567 None => {
568 let base_ref = resolve_base(ws.repo, ws.config, base).0;
569 resolve_hex(ws.repo.gix(), &base_ref)
570 .ok_or_else(|| Error::operation(format!("base ref {base_ref:?} not found")))?
571 }
572 };
573 let short_hash = commit.get(..7).unwrap_or(&commit).to_string();
574 let slug = slugify_with_fallback(branch, &short_hash);
575 Ok((slug, short_hash))
576}
577
578pub(crate) fn create_in(
580 ws: &WorkspaceParts<'_>,
581 git: &dyn GitCli,
582 hooks: &dyn HookRunner,
583 options: &CreateOptions,
584) -> Result<CreatedWorktree> {
585 wtconfig::ensure_schema_supported(ws.repo.gix())?;
590 let branch = options.branch.clone();
591 let worktrees = rows::enumerate_worktrees(ws.repo, git)?;
592 let branch_exists = resolve_hex(ws.repo.gix(), &branch_ref(&branch)).is_some();
593
594 let base_ref = if branch_exists {
595 None
596 } else {
597 Some(resolve_base(ws.repo, ws.config, options.base.as_deref()).0)
598 };
599 let (slug, short_hash) = target_slug(ws, &branch, options.base.as_deref())?;
603
604 if let Some(existing) = worktrees
607 .iter()
608 .find(|w| w.branch.as_deref() == Some(branch.as_str()))
609 {
610 let preview = render_target(ws.config, ws.root, &branch, &slug, ws.env)?;
611 if same_path(&existing.path, &preview) {
612 return Ok(CreatedWorktree {
613 path: existing.path.clone(),
614 branch,
615 base_ref: None,
616 reused: true,
617 copy: CopyOutcome::default(),
618 post_create: HookOutcome::Skipped,
619 submodules: SubmodulesOutcome::Skipped,
620 submodule_seeding: SubmoduleSeeding::default(),
621 reflinked: false,
622 });
623 }
624 return Err(Error::operation(format!(
625 "branch {branch:?} is already checked out at {}",
626 existing.path.display()
627 )));
628 }
629
630 let lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
635 let target = resolve_target(
636 ws.config,
637 ws.root,
638 &branch,
639 &slug,
640 &short_hash,
641 ws.env,
642 ws.repo.is_bare(),
643 )?;
644 if let Some(parent) = target.parent() {
645 std::fs::create_dir_all(parent)?;
646 }
647
648 let reflink_plan = if options.reflink {
653 let source = copy_source(ws, &worktrees, options.copy_from.as_deref()).ok();
654 target
655 .parent()
656 .and_then(|parent| materialize::plan(source.as_deref(), parent))
657 } else {
658 None
659 };
660 let no_checkout = reflink_plan.is_some();
661
662 let target_str = target.to_string_lossy().into_owned();
664 if let Some(base) = &base_ref {
665 ops::worktree_add_branch(git, ws.root, &branch, &target_str, base, true, no_checkout)?;
668 } else {
669 ops::worktree_add(git, ws.root, &target_str, &branch, no_checkout)?;
670 }
671
672 let reflinked = match &reflink_plan {
676 Some(plan) => match materialize::apply(git, &target, plan) {
677 Ok(used_cow) => used_cow,
678 Err(e) => {
679 rollback_worktree(git, ws.root, &target, &branch, base_ref.is_some(), false);
680 return Err(e);
681 }
682 },
683 None => false,
684 };
685
686 let copy = match post_create_steps(ws, git, &worktrees, &branch, &base_ref, &target, options) {
688 Ok(outcome) => outcome,
689 Err(e) => {
690 let created = base_ref.is_some();
693 rollback_worktree(git, ws.root, &target, &branch, created, created);
694 return Err(e);
695 }
696 };
697 drop(lock);
698
699 let ctx = HookContext {
701 worktree_path: target.clone(),
702 branch: branch.clone(),
703 repo_root: ws.root.to_path_buf(),
704 base_ref: base_ref.clone(),
705 pr_number: None,
706 };
707 let post_create = match (options.no_hooks, ws.config.hooks_post_create.as_deref()) {
708 (true, _) | (false, None) => HookOutcome::Skipped,
709 (false, Some(command)) => match hooks.run(command, &ctx) {
710 Ok(0) => HookOutcome::Succeeded,
711 Ok(code) => HookOutcome::ExitedNonZero(code),
712 Err(e) => HookOutcome::Failed(e.to_string()),
713 },
714 };
715
716 let attached = match reflinked.then(|| common_git_dir(git, ws.root)) {
722 Some(Ok(common)) => materialize::attach_submodules(git, &target, &common),
723 _ => Vec::new(),
724 };
725 if !attached.is_empty() {
726 tracing::debug!(count = attached.len(), "attached copied submodules");
727 }
728
729 let attach_sync = if attached.is_empty() {
736 Ok(())
737 } else {
738 crate::git::submodule::sync(git, &target)
739 };
740
741 let (submodules, seed) = match attach_sync {
744 Err(e) => (
745 SubmodulesOutcome::Failed {
746 pending: attached.len(),
747 error: e.to_string(),
748 },
749 SeedReport::default(),
750 ),
751 Ok(()) if options.init_submodules => {
752 populate_submodules(git, &target, options.seed_submodules)?
753 }
754 Ok(()) => (SubmodulesOutcome::Skipped, SeedReport::default()),
755 };
756
757 Ok(CreatedWorktree {
758 path: target,
759 branch,
760 base_ref,
761 reused: false,
762 copy,
763 post_create,
764 submodules,
765 submodule_seeding: seed.into(),
766 reflinked,
767 })
768}
769
770fn common_git_dir(git: &dyn GitCli, root: &Path) -> Result<PathBuf> {
773 let out = git.run(
774 root,
775 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
776 )?;
777 Ok(PathBuf::from(out.trim()))
778}
779
780fn populate_submodules(
784 git: &dyn GitCli,
785 target: &Path,
786 seed_enabled: bool,
787) -> Result<(SubmodulesOutcome, SeedReport)> {
788 let pending = crate::git::submodule::uninitialized(git, target)?;
789 if pending.is_empty() {
790 return Ok((SubmodulesOutcome::Skipped, SeedReport::default()));
791 }
792 let (seed, result) = crate::git::submodule::populate(git, target, seed_enabled);
793 let outcome = match result {
794 Ok(()) => SubmodulesOutcome::Initialized(pending.len()),
795 Err(e) => SubmodulesOutcome::Failed {
796 pending: pending.len(),
797 error: e.to_string(),
798 },
799 };
800 Ok((outcome, seed))
801}
802
803fn post_create_steps(
806 ws: &WorkspaceParts<'_>,
807 git: &dyn GitCli,
808 worktrees: &[Worktree],
809 branch: &str,
810 base_ref: &Option<String>,
811 target: &Path,
812 options: &CreateOptions,
813) -> Result<CopyOutcome> {
814 if let Some(base) = base_ref {
815 apply_meta(
819 git,
820 ws.root,
821 branch,
822 &MetaUpdate {
823 base_ref: Some(base.clone()),
824 created_by_wt: true,
825 ..MetaUpdate::default()
826 },
827 )?;
828 }
829 if let Some(upstream) = &options.track {
832 ops::set_upstream(git, ws.root, branch, upstream)?;
833 }
834 let source = copy_source(ws, worktrees, options.copy_from.as_deref())?;
835 copy_ignored_files(git, &source, target, &ws.config.copy)
836}
837
838fn copy_source(
841 ws: &WorkspaceParts<'_>,
842 worktrees: &[Worktree],
843 copy_from: Option<&str>,
844) -> Result<PathBuf> {
845 if let Some(q) = copy_from {
846 return match query::resolve(worktrees, q) {
847 Resolved::One(index) => Ok(worktrees[index].path.clone()),
848 Resolved::Ambiguous(_) => {
849 Err(Error::operation(format!("--copy-from {q:?} is ambiguous")))
850 }
851 Resolved::NotFound => Err(Error::NotFound {
852 query: q.to_string(),
853 }),
854 };
855 }
856 Ok(ws
857 .repo
858 .current_workdir()
859 .unwrap_or_else(|| ws.root.to_path_buf()))
860}
861
862pub(crate) fn remove_in(
864 ws: &WorkspaceParts<'_>,
865 git: &dyn GitCli,
866 hooks: &dyn HookRunner,
867 worktree: &Worktree,
868 options: &RemoveOptions,
869) -> Result<RemovedWorktree> {
870 wtconfig::ensure_schema_supported(ws.repo.gix())?;
876 if worktree.is_main {
877 return Err(Error::operation("refusing to remove the primary worktree"));
878 }
879 let meta = worktree
880 .branch
881 .as_deref()
882 .map(|b| wtconfig::read_meta(ws.repo.gix(), b))
883 .unwrap_or_default();
884 let default = default_branch(ws.repo.gix());
885
886 if worktree.is_missing {
888 let _lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
889 ops::worktree_prune(git, ws.root)?;
890 let branch_deleted = maybe_delete_branch(ws, git, worktree, &meta, options, &default);
891 clear_metadata(git, ws.root, worktree);
892 return Ok(RemovedWorktree {
893 branch_deleted,
894 forced_past_guards: false,
895 forced_for_submodules: false,
896 pre_remove: HookOutcome::Skipped,
897 });
898 }
899
900 let needs_submodule_force =
904 crate::git::submodule::any_initialized(git, &worktree.path).unwrap_or(false);
905
906 let untracked_blocks = ws.config.remove_untracked_blocks || needs_submodule_force;
913 let guard = rows::guard_status(worktree, untracked_blocks);
914 if guard.blocks() && !options.force_remove {
915 return Err(Error::RemoveGuarded {
916 dirty: guard.dirty,
917 unpushed: guard.unpushed,
918 });
919 }
920 let forced_past_guards = guard.blocks() && options.force_remove;
921 let forced_for_submodules = needs_submodule_force && !options.force_remove;
924
925 let ctx = HookContext {
928 worktree_path: worktree.path.clone(),
929 branch: worktree.branch.clone().unwrap_or_default(),
930 repo_root: ws.root.to_path_buf(),
931 base_ref: meta.base_ref.clone(),
932 pr_number: meta.pr_number,
933 };
934 let pre_remove = match (options.no_hooks, ws.config.hooks_pre_remove.as_deref()) {
935 (true, _) | (false, None) => HookOutcome::Skipped,
936 (false, Some(command)) => match hooks.run(command, &ctx) {
937 Ok(0) => HookOutcome::Succeeded,
938 Ok(code) if options.force_remove => HookOutcome::ExitedNonZero(code),
939 Ok(code) => {
940 return Err(Error::operation(format!(
941 "pre_remove hook exited with status {code}; aborting (use --force to override)"
942 )));
943 }
944 Err(e) if options.force_remove => HookOutcome::Failed(e.to_string()),
945 Err(e) => return Err(e),
946 },
947 };
948
949 let _lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
952 let path = worktree.path.to_string_lossy().into_owned();
953
954 ops::worktree_remove(
957 git,
958 ws.root,
959 &path,
960 options.force_remove || needs_submodule_force,
961 false,
962 )?;
963
964 let branch_deleted = maybe_delete_branch(ws, git, worktree, &meta, options, &default);
965 clear_metadata(git, ws.root, worktree);
966 Ok(RemovedWorktree {
967 branch_deleted,
968 forced_past_guards,
969 forced_for_submodules,
970 pre_remove,
971 })
972}
973
974fn maybe_delete_branch(
978 ws: &WorkspaceParts<'_>,
979 git: &dyn GitCli,
980 worktree: &Worktree,
981 meta: &WtMeta,
982 options: &RemoveOptions,
983 default: &Option<String>,
984) -> bool {
985 let Some(branch) = &worktree.branch else {
986 return false;
987 };
988 if options.keep_branch || !meta.created_by_wt {
989 return false;
990 }
991 let base = meta.base_ref.clone().or_else(|| default.clone());
992 let merged = base
993 .as_deref()
994 .is_some_and(|b| is_ancestor(ws.repo.gix(), &branch_ref(branch), b));
995 let should_delete = if merged {
996 ws.config.remove_delete_merged_branch
997 } else {
998 options.force_branch
999 };
1000 if !should_delete {
1001 return false;
1002 }
1003 ops::delete_branch(git, ws.root, branch, true).is_ok()
1004}
1005
1006fn clear_metadata(git: &dyn GitCli, root: &Path, worktree: &Worktree) {
1008 if let Some(branch) = &worktree.branch {
1009 let _ = wtconfig::clear_meta(git, root, branch);
1010 }
1011}
1012
1013pub(crate) fn same_path(a: &Path, b: &Path) -> bool {
1016 let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
1017 canon(a) == canon(b)
1018}
1019
1020pub(crate) fn git_dir_of(root: &Path, is_bare: bool) -> PathBuf {
1022 if is_bare {
1023 root.to_path_buf()
1024 } else {
1025 root.join(".git")
1026 }
1027}
1028
1029pub(crate) fn render_target(
1031 config: &Config,
1032 root: &Path,
1033 branch: &str,
1034 slug: &str,
1035 env: &Env,
1036) -> Result<PathBuf> {
1037 let vars = TemplateVars {
1038 repo_parent: root
1039 .parent()
1040 .map_or_else(|| root.to_path_buf(), Path::to_path_buf),
1041 repo: root
1042 .file_name()
1043 .map(|n| n.to_string_lossy().into_owned())
1044 .unwrap_or_default(),
1045 repo_root: root.to_path_buf(),
1046 branch: branch.to_string(),
1047 branch_slug: slug.to_string(),
1048 home: env
1049 .get("HOME")
1050 .map(PathBuf::from)
1051 .unwrap_or_else(|| PathBuf::from("~")),
1052 };
1053 template::render(&config.path_template, &vars)
1054}
1055
1056pub(crate) fn resolve_target(
1060 config: &Config,
1061 root: &Path,
1062 branch: &str,
1063 slug: &str,
1064 short_hash: &str,
1065 env: &Env,
1066 is_bare: bool,
1067) -> Result<PathBuf> {
1068 let target = render_target(config, root, branch, slug, env)?;
1069 template::ensure_outside_git(&target, &git_dir_of(root, is_bare))?;
1070 if !target.exists() {
1071 return Ok(target);
1072 }
1073 let alt = render_target(config, root, branch, &format!("{slug}-{short_hash}"), env)?;
1074 if alt.exists() {
1075 return Err(Error::operation(format!(
1076 "target path already exists: {}",
1077 target.display()
1078 )));
1079 }
1080 Ok(alt)
1081}
1082
1083pub(crate) fn run_best_effort(git: &dyn GitCli, root: &Path, args: &[&str], step: &str) {
1088 match git.run_raw(root, args) {
1089 Ok(out) if out.success => {}
1090 Ok(out) => {
1091 tracing::debug!(step, stderr = %out.stderr.trim(), "best-effort cleanup step failed");
1092 }
1093 Err(error) => {
1094 tracing::debug!(step, %error, "best-effort cleanup step could not run");
1095 }
1096 }
1097}
1098
1099pub(crate) fn rollback_worktree(
1106 git: &dyn GitCli,
1107 root: &Path,
1108 target: &Path,
1109 branch: &str,
1110 delete_branch: bool,
1111 clear_meta: bool,
1112) {
1113 let target_str = target.to_string_lossy();
1114 run_best_effort(
1115 git,
1116 root,
1117 &["worktree", "remove", "--force", &target_str],
1118 "rollback: worktree remove",
1119 );
1120 run_best_effort(
1121 git,
1122 root,
1123 &["worktree", "prune"],
1124 "rollback: worktree prune",
1125 );
1126 if delete_branch {
1127 run_best_effort(
1128 git,
1129 root,
1130 &["branch", "-D", branch],
1131 "rollback: branch delete",
1132 );
1133 }
1134 if clear_meta {
1135 let _ = wtconfig::clear_meta(git, root, branch);
1139 }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use super::*;
1145 use crate::git::cli::RealGit;
1146 use crate::hooks::RealHookRunner;
1147 use crate::testutil::{TestRepo, give_upstream};
1148 use std::collections::HashMap;
1149
1150 fn env() -> Env {
1151 Env::from_map(HashMap::new())
1152 }
1153
1154 fn workspace(repo: &TestRepo) -> Workspace {
1155 Workspace::discover(repo.root(), &env(), &RealGit).unwrap()
1156 }
1157
1158 fn create_opts(branch: &str) -> CreateOptions {
1159 CreateOptions {
1160 branch: branch.to_string(),
1161 no_hooks: true,
1162 ..Default::default()
1163 }
1164 }
1165
1166 #[test]
1167 fn discover_resolves_root_config_and_bareness() {
1168 let repo = TestRepo::init();
1169 let ws = workspace(&repo);
1170 assert!(!ws.is_bare());
1171 assert_eq!(
1172 std::fs::canonicalize(ws.root()).unwrap(),
1173 std::fs::canonicalize(repo.root()).unwrap()
1174 );
1175 assert_eq!(ws.config().pr_default_remote, "origin");
1176 }
1177
1178 #[test]
1179 fn discover_outside_a_repo_is_not_in_repo() {
1180 let dir = tempfile::tempdir().unwrap();
1181 assert!(matches!(
1182 Workspace::discover(dir.path(), &env(), &RealGit),
1183 Err(Error::NotInRepo)
1184 ));
1185 }
1186
1187 #[test]
1188 fn discover_from_linked_worktree_finds_primary_root() {
1189 let repo = TestRepo::init();
1190 repo.add_worktree("feature/x", "../wt-x");
1191 let linked = repo.root().parent().unwrap().join("wt-x");
1192 let ws = Workspace::discover(&linked, &env(), &RealGit).unwrap();
1193 assert_eq!(
1194 std::fs::canonicalize(ws.root()).unwrap(),
1195 std::fs::canonicalize(repo.root()).unwrap()
1196 );
1197 }
1198
1199 #[test]
1200 fn create_new_branch_records_metadata_and_copies_nothing() {
1201 let repo = TestRepo::init();
1202 let ws = workspace(&repo);
1203 let created = ws
1204 .create(&RealGit, &RealHookRunner, &create_opts("feature/login"))
1205 .unwrap();
1206 assert!(!created.reused);
1207 assert_eq!(created.branch, "feature/login");
1208 assert_eq!(created.base_ref.as_deref(), Some("main"));
1209 assert!(created.path.is_dir());
1210 assert!(
1211 created.path.ends_with("feature-login")
1212 || created.path.to_string_lossy().contains("feature-login")
1213 );
1214 assert_eq!(created.post_create, HookOutcome::Skipped);
1215 assert_eq!(created.submodules, SubmodulesOutcome::Skipped);
1216 assert!(created.copy.copied.is_empty());
1217 let meta = ws.read_meta("feature/login").unwrap();
1218 assert_eq!(meta.base_ref.as_deref(), Some("main"));
1219 assert!(meta.created_by_wt);
1220 }
1221
1222 #[test]
1223 fn create_existing_branch_does_not_mark_created() {
1224 let repo = TestRepo::init();
1225 repo.git(&["branch", "existing"]);
1226 let ws = workspace(&repo);
1227 let created = ws
1228 .create(&RealGit, &RealHookRunner, &create_opts("existing"))
1229 .unwrap();
1230 assert!(created.base_ref.is_none());
1231 assert!(!ws.read_meta("existing").unwrap().created_by_wt);
1232 }
1233
1234 #[test]
1235 fn create_is_idempotent_at_the_same_target() {
1236 let repo = TestRepo::init();
1237 let ws = workspace(&repo);
1238 let first = ws
1239 .create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1240 .unwrap();
1241 let second = ws
1242 .create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1243 .unwrap();
1244 assert!(!first.reused);
1245 assert!(second.reused);
1246 assert_eq!(second.path, first.path);
1247 assert_eq!(second.post_create, HookOutcome::Skipped);
1248 }
1249
1250 #[test]
1251 fn create_refuses_branch_checked_out_elsewhere() {
1252 let repo = TestRepo::init();
1253 repo.add_worktree("dup", "../manual-dup");
1254 let ws = workspace(&repo);
1255 let err = ws
1256 .create(&RealGit, &RealHookRunner, &create_opts("dup"))
1257 .unwrap_err();
1258 assert!(err.to_string().contains("already checked out"));
1259 }
1260
1261 #[cfg(feature = "cli")]
1264 #[test]
1265 fn preview_target_names_the_directory_create_actually_makes() {
1266 for branch in ["feat/7-add-login", "_"] {
1274 let repo = TestRepo::init();
1275 let ws = workspace(&repo);
1276 let fresh = Repo::discover(repo.root()).unwrap();
1277 let previewed = preview_target(&ws.parts(&fresh), branch, None).unwrap();
1278 let created = ws
1279 .create(&RealGit, &RealHookRunner, &create_opts(branch))
1280 .unwrap();
1281 assert_eq!(
1282 previewed, created.path,
1283 "preview and creation disagree for {branch:?}"
1284 );
1285 }
1286 }
1287
1288 #[test]
1289 fn a_slugless_branch_is_named_after_the_base_it_forks_from() {
1290 let repo = TestRepo::init();
1295 let ws = workspace(&repo);
1296 let base = Repo::discover(repo.root())
1297 .ok()
1298 .and_then(|r| resolve_hex(r.gix(), "main"))
1299 .expect("main resolves");
1300 let short = &base[..7];
1301
1302 let created = ws
1303 .create(&RealGit, &RealHookRunner, &create_opts("_"))
1304 .unwrap();
1305 let name = created
1306 .path
1307 .file_name()
1308 .map(|n| n.to_string_lossy().into_owned())
1309 .unwrap_or_default();
1310 assert!(
1311 name.ends_with(short),
1312 "{name} should end with the base short hash {short}"
1313 );
1314 }
1315
1316 #[test]
1317 fn create_with_explicit_base_records_it() {
1318 let repo = TestRepo::init();
1319 repo.git(&["branch", "base-branch"]);
1320 let ws = workspace(&repo);
1321 let mut opts = create_opts("derived");
1322 opts.base = Some("base-branch".into());
1323 let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
1324 assert_eq!(created.base_ref.as_deref(), Some("base-branch"));
1325 assert_eq!(
1326 ws.read_meta("derived").unwrap().base_ref.as_deref(),
1327 Some("base-branch")
1328 );
1329 }
1330
1331 #[test]
1332 fn create_with_unknown_base_errors() {
1333 let repo = TestRepo::init();
1334 let ws = workspace(&repo);
1335 let mut opts = create_opts("orphan");
1336 opts.base = Some("no-such-ref".into());
1337 let err = ws.create(&RealGit, &RealHookRunner, &opts).unwrap_err();
1338 assert!(err.to_string().contains("not found"));
1339 }
1340
1341 #[test]
1342 fn create_reports_hook_outcomes_without_failing() {
1343 let repo = TestRepo::init();
1344 repo.write(".wt.toml", "[hooks]\npost_create = \"exit 3\"\n");
1345 repo.commit_all("config");
1346 let ws = workspace(&repo);
1347 let mut opts = create_opts("hooked");
1348 opts.no_hooks = false;
1349 let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
1350 assert_eq!(created.post_create, HookOutcome::ExitedNonZero(3));
1351 assert!(created.path.is_dir());
1352 }
1353
1354 #[test]
1355 fn create_copies_ignored_files() {
1356 let repo = TestRepo::init();
1357 std::fs::write(repo.root().join(".wt.toml"), "copy = [\".env\"]\n").unwrap();
1358 repo.write(".env", "SECRET=1\n");
1359 let ws = workspace(&repo);
1360 let created = ws
1361 .create(&RealGit, &RealHookRunner, &create_opts("withenv"))
1362 .unwrap();
1363 assert_eq!(created.copy.copied.len(), 1);
1364 assert!(created.path.join(".env").exists());
1365 }
1366
1367 #[test]
1368 fn create_rolls_back_when_a_post_add_step_fails() {
1369 use crate::git::cli::{GitCli, GitOutput};
1370 struct FailConfig(RealGit);
1371 impl GitCli for FailConfig {
1372 fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput> {
1373 if args.first() == Some(&"config") && args.iter().any(|a| a.starts_with("wt.")) {
1374 return Ok(GitOutput {
1375 success: false,
1376 stdout: String::new(),
1377 stderr: "simulated failure".into(),
1378 });
1379 }
1380 self.0.run_raw(repo, args)
1381 }
1382 }
1383 let repo = TestRepo::init();
1384 let ws = workspace(&repo);
1385 let err = ws
1386 .create(
1387 &FailConfig(RealGit),
1388 &RealHookRunner,
1389 &create_opts("rollme"),
1390 )
1391 .unwrap_err();
1392 assert!(err.to_string().contains("simulated failure"));
1393 assert!(repo.git(&["branch", "--list", "rollme"]).trim().is_empty());
1394 assert!(!repo.git(&["worktree", "list"]).contains("rollme"));
1395 }
1396
1397 fn row_for(ws: &Workspace, branch: &str) -> Worktree {
1399 ws.list(&RealGit)
1400 .unwrap()
1401 .into_iter()
1402 .find(|w| w.branch.as_deref() == Some(branch))
1403 .unwrap()
1404 }
1405
1406 #[test]
1407 fn list_enumerate_and_meta_expose_worktrees() {
1408 let repo = TestRepo::init();
1409 let ws = workspace(&repo);
1410 ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1411 .unwrap();
1412 let shallow = ws.enumerate(&RealGit).unwrap();
1413 assert_eq!(shallow.len(), 2);
1414 assert!(shallow.iter().all(|w| w.dirty.is_none()));
1416 let feat = row_for(&ws, "feature/x");
1417 assert_eq!(feat.dirty, Some(false));
1418 assert_eq!(feat.base_ref.as_deref(), Some("main"));
1419 }
1420
1421 #[test]
1422 fn write_meta_applies_only_the_set_fields() {
1423 let repo = TestRepo::init();
1424 let ws = workspace(&repo);
1425 ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1426 .unwrap();
1427 ws.write_meta(
1428 &RealGit,
1429 "feature/x",
1430 &MetaUpdate {
1431 pr_number: Some(7),
1432 pr_state: Some("open".into()),
1433 pr_title: Some("Add x".into()),
1434 pr_url: Some("https://example.test/7".into()),
1435 ..MetaUpdate::default()
1436 },
1437 )
1438 .unwrap();
1439 let meta = ws.read_meta("feature/x").unwrap();
1440 assert_eq!(meta.pr_number, Some(7));
1441 assert_eq!(meta.pr_state.as_deref(), Some("open"));
1442 assert_eq!(meta.pr_title.as_deref(), Some("Add x"));
1443 assert_eq!(meta.pr_url.as_deref(), Some("https://example.test/7"));
1444 assert_eq!(meta.base_ref.as_deref(), Some("main"));
1446 assert!(meta.created_by_wt);
1447
1448 ws.write_meta(
1450 &RealGit,
1451 "feature/x",
1452 &MetaUpdate {
1453 pr_state: Some("merged".into()),
1454 ..MetaUpdate::default()
1455 },
1456 )
1457 .unwrap();
1458 let meta = ws.read_meta("feature/x").unwrap();
1459 assert_eq!(meta.pr_state.as_deref(), Some("merged"));
1460 assert_eq!(meta.pr_number, Some(7));
1461 assert_eq!(meta.pr_title.as_deref(), Some("Add x"));
1462 }
1463
1464 #[test]
1465 fn write_meta_marks_created_by_wt_but_never_unmarks() {
1466 let repo = TestRepo::init();
1467 let ws = workspace(&repo);
1468 repo.git(&["branch", "solo"]);
1469 ws.write_meta(&RealGit, "solo", &MetaUpdate::default())
1471 .unwrap();
1472 assert_eq!(ws.read_meta("solo").unwrap(), WtMeta::default());
1473
1474 ws.write_meta(
1475 &RealGit,
1476 "solo",
1477 &MetaUpdate {
1478 created_by_wt: true,
1479 ..MetaUpdate::default()
1480 },
1481 )
1482 .unwrap();
1483 assert!(ws.read_meta("solo").unwrap().created_by_wt);
1484 ws.write_meta(&RealGit, "solo", &MetaUpdate::default())
1486 .unwrap();
1487 assert!(ws.read_meta("solo").unwrap().created_by_wt);
1488 }
1489
1490 #[test]
1491 fn clear_meta_removes_the_section_and_tolerates_a_missing_one() {
1492 let repo = TestRepo::init();
1493 let ws = workspace(&repo);
1494 ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1495 .unwrap();
1496 assert!(ws.read_meta("feature/x").unwrap().created_by_wt);
1497 ws.clear_meta(&RealGit, "feature/x").unwrap();
1498 assert_eq!(ws.read_meta("feature/x").unwrap(), WtMeta::default());
1499 ws.clear_meta(&RealGit, "never-recorded").unwrap();
1501 }
1502
1503 #[test]
1504 fn metadata_writes_take_the_repo_lock() {
1505 let repo = TestRepo::init();
1507 let ws = workspace(&repo);
1508 repo.git(&["branch", "locked"]);
1509 let held = ws.lock().unwrap();
1510 let err = ws
1511 .write_meta(
1512 &RealGit,
1513 "locked",
1514 &MetaUpdate {
1515 pr_number: Some(1),
1516 ..MetaUpdate::default()
1517 },
1518 )
1519 .unwrap_err();
1520 assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
1521 let err = ws.clear_meta(&RealGit, "locked").unwrap_err();
1522 assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
1523 drop(held);
1524 ws.clear_meta(&RealGit, "locked").unwrap();
1525 }
1526
1527 #[test]
1528 fn metadata_writes_refuse_a_future_schema() {
1529 let repo = TestRepo::init();
1530 let ws = workspace(&repo);
1531 repo.git(&["branch", "stamped"]);
1532 repo.git(&["config", "wt.schema", "3"]);
1533 let err = ws
1534 .write_meta(&RealGit, "stamped", &MetaUpdate::default())
1535 .unwrap_err();
1536 assert!(
1537 matches!(err, Error::SchemaTooNew { found: 3, .. }),
1538 "{err:?}"
1539 );
1540 let err = ws.clear_meta(&RealGit, "stamped").unwrap_err();
1541 assert!(
1542 matches!(err, Error::SchemaTooNew { found: 3, .. }),
1543 "{err:?}"
1544 );
1545 }
1546
1547 #[test]
1548 fn remove_blocked_by_guards_is_a_typed_error() {
1549 let repo = TestRepo::init();
1550 let ws = workspace(&repo);
1551 ws.create(&RealGit, &RealHookRunner, &create_opts("topic"))
1552 .unwrap();
1553 let row = row_for(&ws, "topic");
1555 let err = ws
1556 .remove(
1557 &RealGit,
1558 &RealHookRunner,
1559 &row,
1560 &RemoveOptions {
1561 no_hooks: true,
1562 ..Default::default()
1563 },
1564 )
1565 .unwrap_err();
1566 match err {
1567 Error::RemoveGuarded { dirty, unpushed } => {
1568 assert!(!dirty);
1569 assert!(unpushed);
1570 }
1571 other => panic!("expected RemoveGuarded, got {other:?}"),
1572 }
1573 }
1574
1575 #[test]
1576 fn create_seeds_submodules_without_reaching_a_remote() {
1577 let repo = TestRepo::init();
1583 repo.add_submodule("libs/sub");
1584 let ws = workspace(&repo);
1585 let created = ws
1586 .create(
1587 &RealGit,
1588 &RealHookRunner,
1589 &CreateOptions {
1590 branch: "topic".to_string(),
1591 init_submodules: true,
1592 seed_submodules: true,
1593 no_hooks: true,
1594 ..Default::default()
1595 },
1596 )
1597 .unwrap();
1598
1599 assert!(
1600 created.path.join("libs/sub/sub.txt").exists(),
1601 "submodule was not populated"
1602 );
1603 assert_eq!(
1604 created.submodule_seeding.seeded,
1605 vec!["libs/sub".to_string()]
1606 );
1607 assert!(created.submodule_seeding.failed.is_empty());
1608 assert!(matches!(
1609 created.submodules,
1610 SubmodulesOutcome::Initialized(1)
1611 ));
1612 }
1613
1614 #[test]
1615 fn reflink_materialization_matches_a_normal_checkout() {
1616 let Some(repo) = TestRepo::init_cow() else {
1620 return;
1621 };
1622 repo.add_submodule("libs/sub");
1623 repo.write("tracked.txt", "content\n");
1624 repo.write(".gitignore", "build.out\n");
1625 repo.commit_all("add a tracked file and an ignore rule");
1626 repo.write("build.out", "artifact\n");
1630 repo.write("scratch.txt", "unsaved\n");
1634 std::fs::create_dir_all(repo.root().join("wip")).unwrap();
1635 repo.write("wip/notes.md", "later\n");
1636 repo.git(&["config", "status.showUntrackedFiles", "no"]);
1639
1640 let ws = workspace(&repo);
1641 let created = ws
1642 .create(
1643 &RealGit,
1644 &RealHookRunner,
1645 &CreateOptions {
1646 branch: "topic".to_string(),
1647 init_submodules: true,
1648 seed_submodules: true,
1649 reflink: true,
1650 no_hooks: true,
1651 ..Default::default()
1652 },
1653 )
1654 .unwrap();
1655
1656 assert!(created.reflinked, "the CoW path did not run");
1657 assert_eq!(
1659 std::fs::read_to_string(created.path.join("tracked.txt")).unwrap(),
1660 "content\n"
1661 );
1662 let status = repo.git(&["-C", &created.path.to_string_lossy(), "status", "--short"]);
1663 assert!(
1664 status.trim().is_empty(),
1665 "worktree is not clean: {status:?}"
1666 );
1667 assert!(created.path.join("libs/sub/sub.txt").exists());
1669 let subs = repo.git(&[
1670 "-C",
1671 &created.path.to_string_lossy(),
1672 "submodule",
1673 "status",
1674 "--recursive",
1675 ]);
1676 assert!(
1677 subs.starts_with(' '),
1678 "submodule not in sync after a reflink create: {subs:?}"
1679 );
1680 let origin =
1685 |dir: &Path| repo.git(&["-C", &dir.to_string_lossy(), "config", "remote.origin.url"]);
1686 assert_eq!(
1687 origin(&created.path.join("libs/sub")),
1688 origin(&repo.root().join("libs/sub")),
1689 "the reflinked submodule kept the local mirror as its origin"
1690 );
1691 assert!(created.path.join("build.out").exists());
1693 assert!(
1694 !created.path.join("scratch.txt").exists(),
1695 "an untracked source file was cloned into the new worktree"
1696 );
1697 assert!(
1698 !created.path.join("wip/notes.md").exists(),
1699 "an untracked source directory was cloned into the new worktree"
1700 );
1701 }
1702
1703 #[test]
1704 fn reflink_declines_when_the_source_is_at_a_different_tree() {
1705 let repo = TestRepo::init();
1709 let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1710 repo.write("only-on-head.txt", "later\n");
1711 repo.commit_all("move the source ahead");
1712
1713 let ws = workspace(&repo);
1714 let created = ws
1715 .create(
1716 &RealGit,
1717 &RealHookRunner,
1718 &CreateOptions {
1719 branch: "topic".to_string(),
1720 base: Some(base),
1721 reflink: true,
1722 no_hooks: true,
1723 ..Default::default()
1724 },
1725 )
1726 .unwrap();
1727
1728 assert!(!created.reflinked, "cloned across differing trees");
1729 assert!(!created.path.join("only-on-head.txt").exists());
1731 let status = repo.git(&["-C", &created.path.to_string_lossy(), "status", "--short"]);
1732 assert!(
1733 status.trim().is_empty(),
1734 "worktree is not clean: {status:?}"
1735 );
1736 }
1737
1738 #[test]
1739 fn create_without_seeding_leaves_the_clone_to_git() {
1740 let repo = TestRepo::init();
1745 repo.add_submodule("libs/sub");
1746 let ws = workspace(&repo);
1747 let created = ws
1748 .create(
1749 &RealGit,
1750 &RealHookRunner,
1751 &CreateOptions {
1752 branch: "topic".to_string(),
1753 init_submodules: true,
1754 seed_submodules: false,
1755 no_hooks: true,
1756 ..Default::default()
1757 },
1758 )
1759 .unwrap();
1760
1761 assert!(created.submodule_seeding.seeded.is_empty());
1762 assert!(!created.path.join("libs/sub/sub.txt").exists());
1763 assert!(matches!(
1764 created.submodules,
1765 SubmodulesOutcome::Failed { .. }
1766 ));
1767 }
1768
1769 #[test]
1770 fn remove_succeeds_on_a_worktree_containing_submodules() {
1771 let repo = TestRepo::init();
1776 repo.add_submodule("libs/sub");
1777 let ws = workspace(&repo);
1778 let created = ws
1779 .create(&RealGit, &RealHookRunner, &create_opts("topic"))
1780 .unwrap();
1781 repo.git(&[
1786 "-C",
1787 &created.path.to_string_lossy(),
1788 "-c",
1789 "protocol.file.allow=always",
1790 "submodule",
1791 "update",
1792 "--init",
1793 ]);
1794 assert!(created.path.join("libs/sub/sub.txt").exists());
1795 give_upstream(&repo, "topic");
1798
1799 let row = row_for(&ws, "topic");
1800 let removed = ws
1801 .remove(
1802 &RealGit,
1803 &RealHookRunner,
1804 &row,
1805 &RemoveOptions {
1806 no_hooks: true,
1807 ..Default::default()
1808 },
1809 )
1810 .unwrap();
1811 assert!(!created.path.exists(), "worktree directory still present");
1812 assert!(
1813 removed.forced_for_submodules,
1814 "removal should record that git needed forcing for submodules"
1815 );
1816 assert!(
1817 !removed.forced_past_guards,
1818 "no wt guard was overridden, so this must not read as a forced removal"
1819 );
1820 }
1821
1822 #[test]
1823 fn remove_guards_untracked_files_when_submodules_force_git() {
1824 let repo = TestRepo::init();
1829 repo.add_submodule("libs/sub");
1830 let ws = workspace(&repo);
1831 let created = ws
1832 .create(&RealGit, &RealHookRunner, &create_opts("topic"))
1833 .unwrap();
1834 repo.git(&[
1835 "-C",
1836 &created.path.to_string_lossy(),
1837 "-c",
1838 "protocol.file.allow=always",
1839 "submodule",
1840 "update",
1841 "--init",
1842 ]);
1843 give_upstream(&repo, "topic");
1844 std::fs::write(created.path.join("scratch.txt"), "unsaved\n").unwrap();
1845
1846 let row = row_for(&ws, "topic");
1847 let err = ws
1848 .remove(
1849 &RealGit,
1850 &RealHookRunner,
1851 &row,
1852 &RemoveOptions {
1853 no_hooks: true,
1854 ..Default::default()
1855 },
1856 )
1857 .unwrap_err();
1858 assert!(matches!(err, Error::RemoveGuarded { dirty: true, .. }));
1859 assert!(created.path.join("scratch.txt").exists());
1860
1861 let row = row_for(&ws, "topic");
1863 let removed = ws
1864 .remove(
1865 &RealGit,
1866 &RealHookRunner,
1867 &row,
1868 &RemoveOptions {
1869 no_hooks: true,
1870 force_remove: true,
1871 ..Default::default()
1872 },
1873 )
1874 .unwrap();
1875 assert!(!created.path.exists());
1876 assert!(removed.forced_past_guards);
1877 }
1878
1879 #[test]
1880 fn remove_without_submodules_does_not_report_a_submodule_force() {
1881 let repo = TestRepo::init();
1882 let ws = workspace(&repo);
1883 ws.create(&RealGit, &RealHookRunner, &create_opts("topic"))
1884 .unwrap();
1885 give_upstream(&repo, "topic");
1886 let row = row_for(&ws, "topic");
1887 let removed = ws
1888 .remove(
1889 &RealGit,
1890 &RealHookRunner,
1891 &row,
1892 &RemoveOptions {
1893 no_hooks: true,
1894 ..Default::default()
1895 },
1896 )
1897 .unwrap();
1898 assert!(!removed.forced_for_submodules);
1899 assert!(!removed.forced_past_guards);
1900 }
1901
1902 #[test]
1903 fn remove_force_reports_forced_past_guards() {
1904 let repo = TestRepo::init();
1905 let ws = workspace(&repo);
1906 ws.create(&RealGit, &RealHookRunner, &create_opts("forced"))
1907 .unwrap();
1908 let row = row_for(&ws, "forced");
1909 let removed = ws
1910 .remove(
1911 &RealGit,
1912 &RealHookRunner,
1913 &row,
1914 &RemoveOptions {
1915 force_remove: true,
1916 force_branch: true,
1917 no_hooks: true,
1918 keep_branch: false,
1919 },
1920 )
1921 .unwrap();
1922 assert!(removed.forced_past_guards);
1923 assert!(!repo.git(&["worktree", "list"]).contains("forced"));
1924 assert!(removed.branch_deleted);
1926 assert_eq!(ws.read_meta("forced").unwrap(), WtMeta::default());
1928 }
1929
1930 #[test]
1931 fn remove_refuses_the_primary_worktree() {
1932 let repo = TestRepo::init();
1933 let ws = workspace(&repo);
1934 let main = row_for(&ws, "main");
1935 let err = ws
1936 .remove(&RealGit, &RealHookRunner, &main, &RemoveOptions::default())
1937 .unwrap_err();
1938 assert!(err.to_string().contains("primary"));
1939 }
1940
1941 #[test]
1942 fn remove_missing_worktree_prunes_without_guards() {
1943 let repo = TestRepo::init();
1944 let ws = workspace(&repo);
1945 let created = ws
1946 .create(&RealGit, &RealHookRunner, &create_opts("gone"))
1947 .unwrap();
1948 std::fs::remove_dir_all(&created.path).unwrap();
1949 let row = row_for(&ws, "gone");
1950 assert!(row.is_missing);
1951 let removed = ws
1952 .remove(
1953 &RealGit,
1954 &RealHookRunner,
1955 &row,
1956 &RemoveOptions {
1957 no_hooks: true,
1958 ..Default::default()
1959 },
1960 )
1961 .unwrap();
1962 assert_eq!(removed.pre_remove, HookOutcome::Skipped);
1963 assert!(!repo.git(&["worktree", "list"]).contains("gone"));
1964 }
1965
1966 #[test]
1967 fn remove_failing_pre_remove_hook_aborts_unless_forced() {
1968 let repo = TestRepo::init();
1969 repo.write(".wt.toml", "[hooks]\npre_remove = \"exit 5\"\n");
1970 repo.commit_all("config");
1971 let ws = workspace(&repo);
1972 ws.create(&RealGit, &RealHookRunner, &create_opts("hooked"))
1973 .unwrap();
1974 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1976 repo.git(&["update-ref", "refs/remotes/origin/hooked", &head]);
1977 repo.git(&["config", "branch.hooked.remote", "origin"]);
1978 repo.git(&["config", "branch.hooked.merge", "refs/heads/hooked"]);
1979 let row = row_for(&ws, "hooked");
1980 let err = ws
1981 .remove(&RealGit, &RealHookRunner, &row, &RemoveOptions::default())
1982 .unwrap_err();
1983 assert!(
1984 err.to_string()
1985 .contains("pre_remove hook exited with status 5")
1986 );
1987 let removed = ws
1989 .remove(
1990 &RealGit,
1991 &RealHookRunner,
1992 &row,
1993 &RemoveOptions {
1994 force_remove: true,
1995 ..Default::default()
1996 },
1997 )
1998 .unwrap();
1999 assert_eq!(removed.pre_remove, HookOutcome::ExitedNonZero(5));
2000 assert!(!repo.git(&["worktree", "list"]).contains("hooked"));
2001 }
2002
2003 #[test]
2004 fn discover_refuses_a_future_schema() {
2005 let repo = TestRepo::init();
2006 repo.git(&["config", "wt.schema", "99"]);
2007 let err = Workspace::discover(repo.root(), &env(), &RealGit)
2008 .err()
2009 .expect("a future schema must refuse discovery");
2010 assert!(matches!(err, Error::SchemaTooNew { found: 99, .. }));
2011 }
2012
2013 #[test]
2014 fn mutations_refuse_a_schema_stamped_after_discovery() {
2015 let repo = TestRepo::init();
2018 let ws = workspace(&repo);
2019 repo.git(&["config", "wt.schema", "2"]);
2020 let err = ws
2021 .create(&RealGit, &RealHookRunner, &create_opts("late"))
2022 .unwrap_err();
2023 assert!(matches!(err, Error::SchemaTooNew { found: 2, .. }));
2024 }
2025
2026 #[test]
2027 fn lock_is_exclusive_and_released_on_drop() {
2028 let repo = TestRepo::init();
2029 let ws = workspace(&repo);
2030 let held = ws.lock().unwrap();
2031 let err = acquire_repo_lock(ws.root(), Duration::from_millis(50))
2033 .err()
2034 .expect("the held lock must exclude a second holder");
2035 match &err {
2036 Error::LockUnavailable { path, .. } => {
2037 assert!(path.ends_with("wt-mutation.lock"), "{path}");
2038 }
2039 other => panic!("expected LockUnavailable, got {other:?}"),
2040 }
2041 drop(held);
2043 acquire_repo_lock(ws.root(), Duration::from_millis(50)).unwrap();
2044 }
2045
2046 #[test]
2047 fn a_schema_refusal_does_not_strand_the_lock() {
2048 let repo = TestRepo::init();
2052 let ws = workspace(&repo);
2053 repo.git(&["config", "wt.schema", "2"]);
2054 let err = acquire_repo_lock(ws.root(), Duration::from_millis(50))
2055 .err()
2056 .expect("a future schema must refuse the acquisition");
2057 assert!(
2058 matches!(err, Error::SchemaTooNew { found: 2, .. }),
2059 "{err:?}"
2060 );
2061 assert!(!ws.root().join(".git/wt-mutation.lock").exists());
2062
2063 repo.git(&["config", "--unset", "wt.schema"]);
2064 acquire_repo_lock(ws.root(), Duration::from_millis(50)).unwrap();
2065 }
2066
2067 #[test]
2068 fn the_lock_gate_reads_a_bare_repository_too() {
2069 let repo = TestRepo::init_bare();
2074 acquire_repo_lock(repo.root(), Duration::from_millis(50)).unwrap();
2075
2076 repo.git(&["config", "wt.schema", "2"]);
2077 let err = acquire_repo_lock(repo.root(), Duration::from_millis(50))
2078 .err()
2079 .expect("a bare repository is gated like any other");
2080 assert!(
2081 matches!(err, Error::SchemaTooNew { found: 2, .. }),
2082 "{err:?}"
2083 );
2084 }
2085
2086 #[test]
2087 fn a_schema_bump_landing_during_the_lock_wait_is_refused() {
2088 let repo = TestRepo::init();
2095 let ws = workspace(&repo);
2096 repo.git(&["branch", "stamped"]);
2097 let held = ws.lock().unwrap();
2098
2099 let (discovered, wait) = std::sync::mpsc::channel();
2100 let root = repo.root().to_path_buf();
2101 let writer = std::thread::spawn(move || {
2102 let ws = Workspace::discover(&root, &env(), &RealGit)?;
2103 discovered.send(()).expect("the test thread outlives this");
2104 ws.write_meta(
2105 &RealGit,
2106 "stamped",
2107 &MetaUpdate {
2108 pr_number: Some(1),
2109 ..MetaUpdate::default()
2110 },
2111 )
2112 });
2113
2114 wait.recv().expect("the writer discovers before it blocks");
2115 repo.git(&["config", "wt.schema", "2"]);
2116 drop(held);
2117
2118 let err = writer
2119 .join()
2120 .expect("the writer must not panic")
2121 .expect_err("the bumped schema must refuse the blocked write");
2122 assert!(
2123 matches!(err, Error::SchemaTooNew { found: 2, .. }),
2124 "{err:?}"
2125 );
2126 repo.git(&["config", "--unset", "wt.schema"]);
2128 assert_eq!(ws.read_meta("stamped").unwrap(), WtMeta::default());
2129 }
2130
2131 #[test]
2132 fn create_releases_the_lock_before_the_post_create_hook() {
2133 let repo = TestRepo::init();
2136 repo.write(
2137 ".wt.toml",
2138 "[hooks]\npost_create = \"test ! -e \\\"$WT_REPO_ROOT/.git/wt-mutation.lock\\\"\"\n",
2139 );
2140 repo.commit_all("config");
2141 let ws = workspace(&repo);
2142 let mut opts = create_opts("hookfree");
2143 opts.no_hooks = false;
2144 let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
2145 assert_eq!(created.post_create, HookOutcome::Succeeded);
2146 }
2147
2148 #[test]
2149 fn concurrent_creates_on_one_branch_do_not_corrupt_metadata() {
2150 let repo = TestRepo::init();
2154 let root = repo.root().to_path_buf();
2155 let spawn = |root: PathBuf| {
2156 std::thread::spawn(move || {
2157 let ws = Workspace::discover(&root, &env(), &RealGit)?;
2158 ws.create(&RealGit, &RealHookRunner, &create_opts("feat/race"))
2159 })
2160 };
2161 let a = spawn(root.clone());
2162 let b = spawn(root);
2163 let results = [a.join().unwrap(), b.join().unwrap()];
2164 let ok = results.iter().filter(|r| r.is_ok()).count();
2165 let created = results
2168 .iter()
2169 .filter(|r| r.as_ref().is_ok_and(|c| !c.reused))
2170 .count();
2171 assert!(ok >= 1, "at least one racer must win: {results:?}");
2172 assert_eq!(created, 1, "exactly one racer creates: {results:?}");
2173
2174 let ws = workspace(&repo);
2175 let rows = ws.list(&RealGit).unwrap();
2176 let race_rows: Vec<_> = rows
2177 .iter()
2178 .filter(|w| w.branch.as_deref() == Some("feat/race"))
2179 .collect();
2180 assert_eq!(race_rows.len(), 1);
2181 let meta = ws.read_meta("feat/race").unwrap();
2182 assert_eq!(meta.base_ref.as_deref(), Some("main"));
2183 assert!(meta.created_by_wt);
2184 }
2185
2186 #[test]
2187 fn resolve_base_falls_back_to_head_only_without_default() {
2188 let repo = TestRepo::init();
2189 let ws = workspace(&repo);
2190 let r = Repo::discover(repo.root()).unwrap();
2191 assert_eq!(
2192 resolve_base(&r, ws.config(), Some("explicit")),
2193 ("explicit".into(), false)
2194 );
2195 assert_eq!(resolve_base(&r, ws.config(), None), ("main".into(), false));
2197 let mut config = ws.config().clone();
2199 config.default_base = Some("trunk".into());
2200 assert_eq!(resolve_base(&r, &config, None), ("trunk".into(), false));
2201 }
2202}