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(
254 &self,
255 git: &dyn GitCli,
256 hooks: &dyn HookRunner,
257 worktree: &Worktree,
258 options: &RemoveOptions,
259 ) -> Result<RemovedWorktree> {
260 let repo = self.fresh_repo()?;
261 remove_in(&self.parts(&repo), git, hooks, worktree, options)
262 }
263
264 fn fresh_repo(&self) -> Result<Repo> {
270 let dir = self
271 .repo
272 .current_workdir()
273 .unwrap_or_else(|| self.repo.git_dir());
274 Repo::discover(&dir)
275 }
276
277 fn parts<'a>(&'a self, repo: &'a Repo) -> WorkspaceParts<'a> {
279 WorkspaceParts {
280 repo,
281 config: &self.config,
282 root: &self.primary_root,
283 env: &self.env,
284 }
285 }
286
287 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
289 pub(crate) fn into_session_parts(self) -> (Repo, PathBuf, Config) {
290 (self.repo, self.primary_root, self.config)
291 }
292}
293
294#[derive(Debug, Clone, Default, PartialEq, Eq)]
298pub struct MetaUpdate {
299 pub base_ref: Option<String>,
301 pub pr_number: Option<u64>,
303 pub pr_state: Option<String>,
305 pub pr_title: Option<String>,
307 pub pr_url: Option<String>,
309 pub issue_number: Option<u64>,
311 pub issue_title: Option<String>,
313 pub issue_url: Option<String>,
315 pub issue_brief: Option<String>,
317 pub created_by_wt: bool,
321}
322
323pub(crate) fn apply_meta(
326 git: &dyn GitCli,
327 root: &Path,
328 branch: &str,
329 update: &MetaUpdate,
330) -> Result<()> {
331 if let Some(base_ref) = &update.base_ref {
332 wtconfig::write_base_ref(git, root, branch, base_ref)?;
333 }
334 if let Some(number) = update.pr_number {
335 wtconfig::write_pr_number(git, root, branch, number)?;
336 }
337 if let Some(state) = &update.pr_state {
338 wtconfig::write_pr_state(git, root, branch, state)?;
339 }
340 if let Some(title) = &update.pr_title {
341 wtconfig::write_pr_title(git, root, branch, title)?;
342 }
343 if let Some(url) = &update.pr_url {
344 wtconfig::write_pr_url(git, root, branch, url)?;
345 }
346 if let Some(number) = update.issue_number {
347 wtconfig::write_issue_number(git, root, branch, number)?;
348 }
349 if let Some(title) = &update.issue_title {
350 wtconfig::write_issue_title(git, root, branch, title)?;
351 }
352 if let Some(url) = &update.issue_url {
353 wtconfig::write_issue_url(git, root, branch, url)?;
354 }
355 if let Some(brief) = &update.issue_brief {
356 wtconfig::write_issue_brief(git, root, branch, brief)?;
357 }
358 if update.created_by_wt {
359 wtconfig::mark_created_by_wt(git, root, branch)?;
360 }
361 Ok(())
362}
363
364#[derive(Debug, Clone, Default)]
366pub struct CreateOptions {
367 pub branch: String,
369 pub base: Option<String>,
373 pub track: Option<String>,
375 pub copy_from: Option<String>,
378 pub init_submodules: bool,
382 pub seed_submodules: bool,
388 pub reflink: bool,
393 pub no_hooks: bool,
395}
396
397#[derive(Debug, Clone, Copy, Default)]
401pub struct RemoveOptions {
402 pub force_remove: bool,
405 pub force_branch: bool,
407 pub keep_branch: bool,
409 pub no_hooks: bool,
411 pub unlock: bool,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
421pub enum HookOutcome {
422 Skipped,
425 Succeeded,
427 ExitedNonZero(i32),
429 Failed(String),
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
435pub enum SubmodulesOutcome {
436 Skipped,
438 Initialized(usize),
440 Failed {
443 pending: usize,
445 error: String,
447 },
448}
449
450#[derive(Debug, Clone, Default, PartialEq, Eq)]
458#[non_exhaustive]
459pub struct SubmoduleSeeding {
460 pub seeded: Vec<String>,
462 pub skipped: Vec<String>,
464 pub failed: Vec<(String, String)>,
467}
468
469impl From<SeedReport> for SubmoduleSeeding {
470 fn from(report: SeedReport) -> Self {
471 Self {
472 seeded: report.seeded,
473 skipped: report.skipped,
474 failed: report.failed,
475 }
476 }
477}
478
479#[derive(Debug, Clone)]
481#[non_exhaustive]
482pub struct CreatedWorktree {
483 pub path: PathBuf,
485 pub branch: String,
487 pub base_ref: Option<String>,
490 pub reused: bool,
493 pub copy: CopyOutcome,
495 pub post_create: HookOutcome,
497 pub submodules: SubmodulesOutcome,
499 pub submodule_seeding: SubmoduleSeeding,
501 pub reflinked: bool,
505}
506
507#[derive(Debug, Clone)]
509#[non_exhaustive]
510pub struct RemovedWorktree {
511 pub branch_deleted: bool,
513 pub forced_past_guards: bool,
517 pub forced_for_submodules: bool,
523 pub pre_remove: HookOutcome,
527}
528
529pub(crate) fn resolve_base(repo: &Repo, config: &Config, explicit: Option<&str>) -> (String, bool) {
534 if let Some(explicit) = explicit {
535 return (explicit.to_string(), false);
536 }
537 if let Some(base) = &config.default_base {
538 return (base.clone(), false);
539 }
540 if let Some(branch) = default_branch(repo.gix()) {
541 return (branch, false);
542 }
543 ("HEAD".to_string(), true)
544}
545
546#[cfg(feature = "cli")]
553pub(crate) fn preview_target(
554 ws: &WorkspaceParts<'_>,
555 branch: &str,
556 base: Option<&str>,
557) -> Result<std::path::PathBuf> {
558 let (slug, _) = target_slug(ws, branch, base)?;
559 render_target(ws.config, ws.root, branch, &slug, ws.env)
560}
561
562fn target_slug(
569 ws: &WorkspaceParts<'_>,
570 branch: &str,
571 base: Option<&str>,
572) -> Result<(String, String)> {
573 let commit = match resolve_hex(ws.repo.gix(), &branch_ref(branch)) {
574 Some(oid) => oid,
575 None => {
576 let base_ref = resolve_base(ws.repo, ws.config, base).0;
577 resolve_hex(ws.repo.gix(), &base_ref)
578 .ok_or_else(|| Error::operation(format!("base ref {base_ref:?} not found")))?
579 }
580 };
581 let short_hash = commit.get(..7).unwrap_or(&commit).to_string();
582 let slug = slugify_with_fallback(branch, &short_hash);
583 Ok((slug, short_hash))
584}
585
586pub(crate) fn create_in(
588 ws: &WorkspaceParts<'_>,
589 git: &dyn GitCli,
590 hooks: &dyn HookRunner,
591 options: &CreateOptions,
592) -> Result<CreatedWorktree> {
593 wtconfig::ensure_schema_supported(ws.repo.gix())?;
598 let branch = options.branch.clone();
599 let worktrees = rows::enumerate_worktrees(ws.repo, git)?;
600 let branch_exists = resolve_hex(ws.repo.gix(), &branch_ref(&branch)).is_some();
601
602 let base_ref = if branch_exists {
603 None
604 } else {
605 Some(resolve_base(ws.repo, ws.config, options.base.as_deref()).0)
606 };
607 let (slug, short_hash) = target_slug(ws, &branch, options.base.as_deref())?;
611
612 if let Some(existing) = worktrees
615 .iter()
616 .find(|w| w.branch.as_deref() == Some(branch.as_str()))
617 {
618 let preview = render_target(ws.config, ws.root, &branch, &slug, ws.env)?;
619 if same_path(&existing.path, &preview) {
620 return Ok(CreatedWorktree {
621 path: existing.path.clone(),
622 branch,
623 base_ref: None,
624 reused: true,
625 copy: CopyOutcome::default(),
626 post_create: HookOutcome::Skipped,
627 submodules: SubmodulesOutcome::Skipped,
628 submodule_seeding: SubmoduleSeeding::default(),
629 reflinked: false,
630 });
631 }
632 return Err(Error::operation(format!(
633 "branch {branch:?} is already checked out at {}",
634 existing.path.display()
635 )));
636 }
637
638 let lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
643 let target = resolve_target(
644 ws.config,
645 ws.root,
646 &branch,
647 &slug,
648 &short_hash,
649 ws.env,
650 ws.repo.is_bare(),
651 )?;
652 if let Some(parent) = target.parent() {
653 std::fs::create_dir_all(parent)?;
654 }
655
656 let reflink_plan = if options.reflink {
661 let source = copy_source(ws, &worktrees, options.copy_from.as_deref()).ok();
662 target
663 .parent()
664 .and_then(|parent| materialize::plan(source.as_deref(), parent))
665 } else {
666 None
667 };
668 let no_checkout = reflink_plan.is_some();
669
670 let target_str = target.to_string_lossy().into_owned();
672 if let Some(base) = &base_ref {
673 ops::worktree_add_branch(git, ws.root, &branch, &target_str, base, true, no_checkout)?;
676 } else {
677 ops::worktree_add(git, ws.root, &target_str, &branch, no_checkout)?;
678 }
679
680 let reflinked = match &reflink_plan {
684 Some(plan) => match materialize::apply(git, &target, plan) {
685 Ok(used_cow) => used_cow,
686 Err(e) => {
687 rollback_worktree(git, ws.root, &target, &branch, base_ref.is_some(), false);
688 return Err(e);
689 }
690 },
691 None => false,
692 };
693
694 let copy = match post_create_steps(ws, git, &worktrees, &branch, &base_ref, &target, options) {
696 Ok(outcome) => outcome,
697 Err(e) => {
698 let created = base_ref.is_some();
701 rollback_worktree(git, ws.root, &target, &branch, created, created);
702 return Err(e);
703 }
704 };
705 drop(lock);
706
707 let ctx = HookContext {
709 worktree_path: target.clone(),
710 branch: branch.clone(),
711 repo_root: ws.root.to_path_buf(),
712 base_ref: base_ref.clone(),
713 pr_number: None,
714 };
715 let post_create = match (options.no_hooks, ws.config.hooks_post_create.as_deref()) {
716 (true, _) | (false, None) => HookOutcome::Skipped,
717 (false, Some(command)) => match hooks.run(command, &ctx) {
718 Ok(0) => HookOutcome::Succeeded,
719 Ok(code) => HookOutcome::ExitedNonZero(code),
720 Err(e) => HookOutcome::Failed(e.to_string()),
721 },
722 };
723
724 let attached = match reflinked.then(|| common_git_dir(git, ws.root)) {
730 Some(Ok(common)) => materialize::attach_submodules(git, &target, &common),
731 _ => Vec::new(),
732 };
733 if !attached.is_empty() {
734 tracing::debug!(count = attached.len(), "attached copied submodules");
735 }
736
737 let attach_sync = if attached.is_empty() {
744 Ok(())
745 } else {
746 crate::git::submodule::sync(git, &target)
747 };
748
749 let (submodules, seed) = match attach_sync {
752 Err(e) => (
753 SubmodulesOutcome::Failed {
754 pending: attached.len(),
755 error: e.to_string(),
756 },
757 SeedReport::default(),
758 ),
759 Ok(()) if options.init_submodules => {
760 populate_submodules(git, &target, options.seed_submodules)?
761 }
762 Ok(()) => (SubmodulesOutcome::Skipped, SeedReport::default()),
763 };
764
765 Ok(CreatedWorktree {
766 path: target,
767 branch,
768 base_ref,
769 reused: false,
770 copy,
771 post_create,
772 submodules,
773 submodule_seeding: seed.into(),
774 reflinked,
775 })
776}
777
778fn common_git_dir(git: &dyn GitCli, root: &Path) -> Result<PathBuf> {
781 let out = git.run(
782 root,
783 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
784 )?;
785 Ok(PathBuf::from(out.trim()))
786}
787
788fn populate_submodules(
792 git: &dyn GitCli,
793 target: &Path,
794 seed_enabled: bool,
795) -> Result<(SubmodulesOutcome, SeedReport)> {
796 let pending = crate::git::submodule::uninitialized(git, target)?;
797 if pending.is_empty() {
798 return Ok((SubmodulesOutcome::Skipped, SeedReport::default()));
799 }
800 let (seed, result) = crate::git::submodule::populate(git, target, seed_enabled);
801 let outcome = match result {
802 Ok(()) => SubmodulesOutcome::Initialized(pending.len()),
803 Err(e) => SubmodulesOutcome::Failed {
804 pending: pending.len(),
805 error: e.to_string(),
806 },
807 };
808 Ok((outcome, seed))
809}
810
811fn post_create_steps(
814 ws: &WorkspaceParts<'_>,
815 git: &dyn GitCli,
816 worktrees: &[Worktree],
817 branch: &str,
818 base_ref: &Option<String>,
819 target: &Path,
820 options: &CreateOptions,
821) -> Result<CopyOutcome> {
822 if let Some(base) = base_ref {
823 apply_meta(
827 git,
828 ws.root,
829 branch,
830 &MetaUpdate {
831 base_ref: Some(base.clone()),
832 created_by_wt: true,
833 ..MetaUpdate::default()
834 },
835 )?;
836 }
837 if let Some(upstream) = &options.track {
840 ops::set_upstream(git, ws.root, branch, upstream)?;
841 }
842 let source = copy_source(ws, worktrees, options.copy_from.as_deref())?;
843 copy_ignored_files(git, &source, target, &ws.config.copy)
844}
845
846fn copy_source(
849 ws: &WorkspaceParts<'_>,
850 worktrees: &[Worktree],
851 copy_from: Option<&str>,
852) -> Result<PathBuf> {
853 if let Some(q) = copy_from {
854 return match query::resolve(worktrees, q) {
855 Resolved::One(index) => Ok(worktrees[index].path.clone()),
856 Resolved::Ambiguous(_) => {
857 Err(Error::operation(format!("--copy-from {q:?} is ambiguous")))
858 }
859 Resolved::NotFound => Err(Error::NotFound {
860 query: q.to_string(),
861 }),
862 };
863 }
864 Ok(ws
865 .repo
866 .current_workdir()
867 .unwrap_or_else(|| ws.root.to_path_buf()))
868}
869
870pub(crate) fn remove_in(
872 ws: &WorkspaceParts<'_>,
873 git: &dyn GitCli,
874 hooks: &dyn HookRunner,
875 worktree: &Worktree,
876 options: &RemoveOptions,
877) -> Result<RemovedWorktree> {
878 wtconfig::ensure_schema_supported(ws.repo.gix())?;
884 if worktree.is_main {
885 return Err(Error::operation("refusing to remove the primary worktree"));
886 }
887 let meta = worktree
888 .branch
889 .as_deref()
890 .map(|b| wtconfig::read_meta(ws.repo.gix(), b))
891 .unwrap_or_default();
892 let default = default_branch(ws.repo.gix());
893
894 let lock = lock_state(git, ws.root, &worktree.path)?;
899 if let Some(reason) = &lock
900 && !options.unlock
901 {
902 return Err(Error::WorktreeLocked {
903 reason: reason.clone(),
904 });
905 }
906
907 if worktree.is_missing {
909 let _lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
910 if lock.is_some() {
911 let path = worktree.path.to_string_lossy().into_owned();
914 ops::worktree_remove(git, ws.root, &path, true, true)?;
915 }
916 ops::worktree_prune(git, ws.root)?;
917 let branch_deleted = maybe_delete_branch(ws, git, worktree, &meta, options, &default);
918 clear_metadata(git, ws.root, worktree);
919 return Ok(RemovedWorktree {
920 branch_deleted,
921 forced_past_guards: false,
922 forced_for_submodules: false,
923 pre_remove: HookOutcome::Skipped,
924 });
925 }
926
927 let needs_submodule_force =
931 crate::git::submodule::any_initialized(git, &worktree.path).unwrap_or(false);
932
933 let unlocking = lock.is_some();
941 let untracked_blocks = ws.config.remove_untracked_blocks || needs_submodule_force || unlocking;
942 let guard = rows::guard_status(worktree, untracked_blocks);
943 if guard.blocks() && !options.force_remove {
944 return Err(Error::RemoveGuarded {
945 dirty: guard.dirty,
946 unpushed: guard.unpushed,
947 });
948 }
949 let forced_past_guards = guard.blocks() && options.force_remove;
950 let forced_for_submodules = needs_submodule_force && !options.force_remove;
953
954 let ctx = HookContext {
957 worktree_path: worktree.path.clone(),
958 branch: worktree.branch.clone().unwrap_or_default(),
959 repo_root: ws.root.to_path_buf(),
960 base_ref: meta.base_ref.clone(),
961 pr_number: meta.pr_number,
962 };
963 let pre_remove = match (options.no_hooks, ws.config.hooks_pre_remove.as_deref()) {
964 (true, _) | (false, None) => HookOutcome::Skipped,
965 (false, Some(command)) => match hooks.run(command, &ctx) {
966 Ok(0) => HookOutcome::Succeeded,
967 Ok(code) if options.force_remove => HookOutcome::ExitedNonZero(code),
968 Ok(code) => {
969 return Err(Error::operation(format!(
970 "pre_remove hook exited with status {code}; aborting (use --force to override)"
971 )));
972 }
973 Err(e) if options.force_remove => HookOutcome::Failed(e.to_string()),
974 Err(e) => return Err(e),
975 },
976 };
977
978 let _lock = acquire_repo_lock(ws.root, LOCK_TIMEOUT)?;
981 let path = worktree.path.to_string_lossy().into_owned();
982
983 ops::worktree_remove(
988 git,
989 ws.root,
990 &path,
991 options.force_remove || needs_submodule_force,
992 unlocking,
993 )?;
994
995 let branch_deleted = maybe_delete_branch(ws, git, worktree, &meta, options, &default);
996 clear_metadata(git, ws.root, worktree);
997 Ok(RemovedWorktree {
998 branch_deleted,
999 forced_past_guards,
1000 forced_for_submodules,
1001 pre_remove,
1002 })
1003}
1004
1005fn lock_state(git: &dyn GitCli, root: &Path, path: &Path) -> Result<Option<Option<String>>> {
1009 let raws = crate::git::worktrees::enumerate(git, root)?;
1010 Ok(raws
1011 .into_iter()
1012 .find(|raw| raw.is_locked && same_path(&raw.path, path))
1013 .map(|raw| raw.lock_reason))
1014}
1015
1016fn maybe_delete_branch(
1020 ws: &WorkspaceParts<'_>,
1021 git: &dyn GitCli,
1022 worktree: &Worktree,
1023 meta: &WtMeta,
1024 options: &RemoveOptions,
1025 default: &Option<String>,
1026) -> bool {
1027 let Some(branch) = &worktree.branch else {
1028 return false;
1029 };
1030 if options.keep_branch || !meta.created_by_wt {
1031 return false;
1032 }
1033 let base = meta.base_ref.clone().or_else(|| default.clone());
1034 let merged = base
1035 .as_deref()
1036 .is_some_and(|b| is_ancestor(ws.repo.gix(), &branch_ref(branch), b));
1037 let should_delete = if merged {
1038 ws.config.remove_delete_merged_branch
1039 } else {
1040 options.force_branch
1041 };
1042 if !should_delete {
1043 return false;
1044 }
1045 ops::delete_branch(git, ws.root, branch, true).is_ok()
1046}
1047
1048fn clear_metadata(git: &dyn GitCli, root: &Path, worktree: &Worktree) {
1050 if let Some(branch) = &worktree.branch {
1051 let _ = wtconfig::clear_meta(git, root, branch);
1052 }
1053}
1054
1055pub(crate) fn same_path(a: &Path, b: &Path) -> bool {
1058 let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
1059 canon(a) == canon(b)
1060}
1061
1062pub(crate) fn git_dir_of(root: &Path, is_bare: bool) -> PathBuf {
1064 if is_bare {
1065 root.to_path_buf()
1066 } else {
1067 root.join(".git")
1068 }
1069}
1070
1071pub(crate) fn render_target(
1073 config: &Config,
1074 root: &Path,
1075 branch: &str,
1076 slug: &str,
1077 env: &Env,
1078) -> Result<PathBuf> {
1079 let vars = TemplateVars {
1080 repo_parent: root
1081 .parent()
1082 .map_or_else(|| root.to_path_buf(), Path::to_path_buf),
1083 repo: root
1084 .file_name()
1085 .map(|n| n.to_string_lossy().into_owned())
1086 .unwrap_or_default(),
1087 repo_root: root.to_path_buf(),
1088 branch: branch.to_string(),
1089 branch_slug: slug.to_string(),
1090 home: env
1091 .get("HOME")
1092 .map(PathBuf::from)
1093 .unwrap_or_else(|| PathBuf::from("~")),
1094 };
1095 template::render(&config.path_template, &vars)
1096}
1097
1098pub(crate) fn resolve_target(
1102 config: &Config,
1103 root: &Path,
1104 branch: &str,
1105 slug: &str,
1106 short_hash: &str,
1107 env: &Env,
1108 is_bare: bool,
1109) -> Result<PathBuf> {
1110 let target = render_target(config, root, branch, slug, env)?;
1111 template::ensure_outside_git(&target, &git_dir_of(root, is_bare))?;
1112 if !target.exists() {
1113 return Ok(target);
1114 }
1115 let alt = render_target(config, root, branch, &format!("{slug}-{short_hash}"), env)?;
1116 if alt.exists() {
1117 return Err(Error::operation(format!(
1118 "target path already exists: {}",
1119 target.display()
1120 )));
1121 }
1122 Ok(alt)
1123}
1124
1125pub(crate) fn run_best_effort(git: &dyn GitCli, root: &Path, args: &[&str], step: &str) {
1130 match git.run_raw(root, args) {
1131 Ok(out) if out.success => {}
1132 Ok(out) => {
1133 tracing::debug!(step, stderr = %out.stderr.trim(), "best-effort cleanup step failed");
1134 }
1135 Err(error) => {
1136 tracing::debug!(step, %error, "best-effort cleanup step could not run");
1137 }
1138 }
1139}
1140
1141pub(crate) fn rollback_worktree(
1148 git: &dyn GitCli,
1149 root: &Path,
1150 target: &Path,
1151 branch: &str,
1152 delete_branch: bool,
1153 clear_meta: bool,
1154) {
1155 let target_str = target.to_string_lossy();
1156 run_best_effort(
1157 git,
1158 root,
1159 &["worktree", "remove", "--force", &target_str],
1160 "rollback: worktree remove",
1161 );
1162 run_best_effort(
1163 git,
1164 root,
1165 &["worktree", "prune"],
1166 "rollback: worktree prune",
1167 );
1168 if delete_branch {
1169 run_best_effort(
1170 git,
1171 root,
1172 &["branch", "-D", branch],
1173 "rollback: branch delete",
1174 );
1175 }
1176 if clear_meta {
1177 let _ = wtconfig::clear_meta(git, root, branch);
1181 }
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187 use crate::git::cli::RealGit;
1188 use crate::hooks::RealHookRunner;
1189 use crate::testutil::{TestRepo, give_upstream};
1190 use std::collections::HashMap;
1191
1192 fn env() -> Env {
1193 Env::from_map(HashMap::new())
1194 }
1195
1196 fn workspace(repo: &TestRepo) -> Workspace {
1197 Workspace::discover(repo.root(), &env(), &RealGit).unwrap()
1198 }
1199
1200 fn create_opts(branch: &str) -> CreateOptions {
1201 CreateOptions {
1202 branch: branch.to_string(),
1203 no_hooks: true,
1204 ..Default::default()
1205 }
1206 }
1207
1208 #[test]
1209 fn discover_resolves_root_config_and_bareness() {
1210 let repo = TestRepo::init();
1211 let ws = workspace(&repo);
1212 assert!(!ws.is_bare());
1213 assert_eq!(
1214 std::fs::canonicalize(ws.root()).unwrap(),
1215 std::fs::canonicalize(repo.root()).unwrap()
1216 );
1217 assert_eq!(ws.config().pr_default_remote, "origin");
1218 }
1219
1220 #[test]
1221 fn discover_outside_a_repo_is_not_in_repo() {
1222 let dir = tempfile::tempdir().unwrap();
1223 assert!(matches!(
1224 Workspace::discover(dir.path(), &env(), &RealGit),
1225 Err(Error::NotInRepo)
1226 ));
1227 }
1228
1229 #[test]
1230 fn discover_from_linked_worktree_finds_primary_root() {
1231 let repo = TestRepo::init();
1232 repo.add_worktree("feature/x", "../wt-x");
1233 let linked = repo.root().parent().unwrap().join("wt-x");
1234 let ws = Workspace::discover(&linked, &env(), &RealGit).unwrap();
1235 assert_eq!(
1236 std::fs::canonicalize(ws.root()).unwrap(),
1237 std::fs::canonicalize(repo.root()).unwrap()
1238 );
1239 }
1240
1241 #[test]
1242 fn create_new_branch_records_metadata_and_copies_nothing() {
1243 let repo = TestRepo::init();
1244 let ws = workspace(&repo);
1245 let created = ws
1246 .create(&RealGit, &RealHookRunner, &create_opts("feature/login"))
1247 .unwrap();
1248 assert!(!created.reused);
1249 assert_eq!(created.branch, "feature/login");
1250 assert_eq!(created.base_ref.as_deref(), Some("main"));
1251 assert!(created.path.is_dir());
1252 assert!(
1253 created.path.ends_with("feature-login")
1254 || created.path.to_string_lossy().contains("feature-login")
1255 );
1256 assert_eq!(created.post_create, HookOutcome::Skipped);
1257 assert_eq!(created.submodules, SubmodulesOutcome::Skipped);
1258 assert!(created.copy.copied.is_empty());
1259 let meta = ws.read_meta("feature/login").unwrap();
1260 assert_eq!(meta.base_ref.as_deref(), Some("main"));
1261 assert!(meta.created_by_wt);
1262 }
1263
1264 #[test]
1265 fn create_existing_branch_does_not_mark_created() {
1266 let repo = TestRepo::init();
1267 repo.git(&["branch", "existing"]);
1268 let ws = workspace(&repo);
1269 let created = ws
1270 .create(&RealGit, &RealHookRunner, &create_opts("existing"))
1271 .unwrap();
1272 assert!(created.base_ref.is_none());
1273 assert!(!ws.read_meta("existing").unwrap().created_by_wt);
1274 }
1275
1276 #[test]
1277 fn create_is_idempotent_at_the_same_target() {
1278 let repo = TestRepo::init();
1279 let ws = workspace(&repo);
1280 let first = ws
1281 .create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1282 .unwrap();
1283 let second = ws
1284 .create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1285 .unwrap();
1286 assert!(!first.reused);
1287 assert!(second.reused);
1288 assert_eq!(second.path, first.path);
1289 assert_eq!(second.post_create, HookOutcome::Skipped);
1290 }
1291
1292 #[test]
1293 fn create_refuses_branch_checked_out_elsewhere() {
1294 let repo = TestRepo::init();
1295 repo.add_worktree("dup", "../manual-dup");
1296 let ws = workspace(&repo);
1297 let err = ws
1298 .create(&RealGit, &RealHookRunner, &create_opts("dup"))
1299 .unwrap_err();
1300 assert!(err.to_string().contains("already checked out"));
1301 }
1302
1303 #[cfg(feature = "cli")]
1306 #[test]
1307 fn preview_target_names_the_directory_create_actually_makes() {
1308 for branch in ["feat/7-add-login", "_"] {
1316 let repo = TestRepo::init();
1317 let ws = workspace(&repo);
1318 let fresh = Repo::discover(repo.root()).unwrap();
1319 let previewed = preview_target(&ws.parts(&fresh), branch, None).unwrap();
1320 let created = ws
1321 .create(&RealGit, &RealHookRunner, &create_opts(branch))
1322 .unwrap();
1323 assert_eq!(
1324 previewed, created.path,
1325 "preview and creation disagree for {branch:?}"
1326 );
1327 }
1328 }
1329
1330 #[test]
1331 fn a_slugless_branch_is_named_after_the_base_it_forks_from() {
1332 let repo = TestRepo::init();
1337 let ws = workspace(&repo);
1338 let base = Repo::discover(repo.root())
1339 .ok()
1340 .and_then(|r| resolve_hex(r.gix(), "main"))
1341 .expect("main resolves");
1342 let short = &base[..7];
1343
1344 let created = ws
1345 .create(&RealGit, &RealHookRunner, &create_opts("_"))
1346 .unwrap();
1347 let name = created
1348 .path
1349 .file_name()
1350 .map(|n| n.to_string_lossy().into_owned())
1351 .unwrap_or_default();
1352 assert!(
1353 name.ends_with(short),
1354 "{name} should end with the base short hash {short}"
1355 );
1356 }
1357
1358 #[test]
1359 fn create_with_explicit_base_records_it() {
1360 let repo = TestRepo::init();
1361 repo.git(&["branch", "base-branch"]);
1362 let ws = workspace(&repo);
1363 let mut opts = create_opts("derived");
1364 opts.base = Some("base-branch".into());
1365 let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
1366 assert_eq!(created.base_ref.as_deref(), Some("base-branch"));
1367 assert_eq!(
1368 ws.read_meta("derived").unwrap().base_ref.as_deref(),
1369 Some("base-branch")
1370 );
1371 }
1372
1373 #[test]
1374 fn create_with_unknown_base_errors() {
1375 let repo = TestRepo::init();
1376 let ws = workspace(&repo);
1377 let mut opts = create_opts("orphan");
1378 opts.base = Some("no-such-ref".into());
1379 let err = ws.create(&RealGit, &RealHookRunner, &opts).unwrap_err();
1380 assert!(err.to_string().contains("not found"));
1381 }
1382
1383 #[test]
1384 fn create_reports_hook_outcomes_without_failing() {
1385 let repo = TestRepo::init();
1386 repo.write(".wt.toml", "[hooks]\npost_create = \"exit 3\"\n");
1387 repo.commit_all("config");
1388 let ws = workspace(&repo);
1389 let mut opts = create_opts("hooked");
1390 opts.no_hooks = false;
1391 let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
1392 assert_eq!(created.post_create, HookOutcome::ExitedNonZero(3));
1393 assert!(created.path.is_dir());
1394 }
1395
1396 #[test]
1397 fn create_copies_ignored_files() {
1398 let repo = TestRepo::init();
1399 std::fs::write(repo.root().join(".wt.toml"), "copy = [\".env\"]\n").unwrap();
1400 repo.write(".env", "SECRET=1\n");
1401 let ws = workspace(&repo);
1402 let created = ws
1403 .create(&RealGit, &RealHookRunner, &create_opts("withenv"))
1404 .unwrap();
1405 assert_eq!(created.copy.copied.len(), 1);
1406 assert!(created.path.join(".env").exists());
1407 }
1408
1409 #[test]
1410 fn create_rolls_back_when_a_post_add_step_fails() {
1411 use crate::git::cli::{GitCli, GitOutput};
1412 struct FailConfig(RealGit);
1413 impl GitCli for FailConfig {
1414 fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput> {
1415 if args.first() == Some(&"config") && args.iter().any(|a| a.starts_with("wt.")) {
1416 return Ok(GitOutput {
1417 success: false,
1418 stdout: String::new(),
1419 stderr: "simulated failure".into(),
1420 });
1421 }
1422 self.0.run_raw(repo, args)
1423 }
1424 }
1425 let repo = TestRepo::init();
1426 let ws = workspace(&repo);
1427 let err = ws
1428 .create(
1429 &FailConfig(RealGit),
1430 &RealHookRunner,
1431 &create_opts("rollme"),
1432 )
1433 .unwrap_err();
1434 assert!(err.to_string().contains("simulated failure"));
1435 assert!(repo.git(&["branch", "--list", "rollme"]).trim().is_empty());
1436 assert!(!repo.git(&["worktree", "list"]).contains("rollme"));
1437 }
1438
1439 fn row_for(ws: &Workspace, branch: &str) -> Worktree {
1441 ws.list(&RealGit)
1442 .unwrap()
1443 .into_iter()
1444 .find(|w| w.branch.as_deref() == Some(branch))
1445 .unwrap()
1446 }
1447
1448 #[test]
1449 fn list_enumerate_and_meta_expose_worktrees() {
1450 let repo = TestRepo::init();
1451 let ws = workspace(&repo);
1452 ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1453 .unwrap();
1454 let shallow = ws.enumerate(&RealGit).unwrap();
1455 assert_eq!(shallow.len(), 2);
1456 assert!(shallow.iter().all(|w| w.dirty.is_none()));
1458 let feat = row_for(&ws, "feature/x");
1459 assert_eq!(feat.dirty, Some(false));
1460 assert_eq!(feat.base_ref.as_deref(), Some("main"));
1461 }
1462
1463 #[test]
1464 fn write_meta_applies_only_the_set_fields() {
1465 let repo = TestRepo::init();
1466 let ws = workspace(&repo);
1467 ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1468 .unwrap();
1469 ws.write_meta(
1470 &RealGit,
1471 "feature/x",
1472 &MetaUpdate {
1473 pr_number: Some(7),
1474 pr_state: Some("open".into()),
1475 pr_title: Some("Add x".into()),
1476 pr_url: Some("https://example.test/7".into()),
1477 ..MetaUpdate::default()
1478 },
1479 )
1480 .unwrap();
1481 let meta = ws.read_meta("feature/x").unwrap();
1482 assert_eq!(meta.pr_number, Some(7));
1483 assert_eq!(meta.pr_state.as_deref(), Some("open"));
1484 assert_eq!(meta.pr_title.as_deref(), Some("Add x"));
1485 assert_eq!(meta.pr_url.as_deref(), Some("https://example.test/7"));
1486 assert_eq!(meta.base_ref.as_deref(), Some("main"));
1488 assert!(meta.created_by_wt);
1489
1490 ws.write_meta(
1492 &RealGit,
1493 "feature/x",
1494 &MetaUpdate {
1495 pr_state: Some("merged".into()),
1496 ..MetaUpdate::default()
1497 },
1498 )
1499 .unwrap();
1500 let meta = ws.read_meta("feature/x").unwrap();
1501 assert_eq!(meta.pr_state.as_deref(), Some("merged"));
1502 assert_eq!(meta.pr_number, Some(7));
1503 assert_eq!(meta.pr_title.as_deref(), Some("Add x"));
1504 }
1505
1506 #[test]
1507 fn write_meta_marks_created_by_wt_but_never_unmarks() {
1508 let repo = TestRepo::init();
1509 let ws = workspace(&repo);
1510 repo.git(&["branch", "solo"]);
1511 ws.write_meta(&RealGit, "solo", &MetaUpdate::default())
1513 .unwrap();
1514 assert_eq!(ws.read_meta("solo").unwrap(), WtMeta::default());
1515
1516 ws.write_meta(
1517 &RealGit,
1518 "solo",
1519 &MetaUpdate {
1520 created_by_wt: true,
1521 ..MetaUpdate::default()
1522 },
1523 )
1524 .unwrap();
1525 assert!(ws.read_meta("solo").unwrap().created_by_wt);
1526 ws.write_meta(&RealGit, "solo", &MetaUpdate::default())
1528 .unwrap();
1529 assert!(ws.read_meta("solo").unwrap().created_by_wt);
1530 }
1531
1532 #[test]
1533 fn clear_meta_removes_the_section_and_tolerates_a_missing_one() {
1534 let repo = TestRepo::init();
1535 let ws = workspace(&repo);
1536 ws.create(&RealGit, &RealHookRunner, &create_opts("feature/x"))
1537 .unwrap();
1538 assert!(ws.read_meta("feature/x").unwrap().created_by_wt);
1539 ws.clear_meta(&RealGit, "feature/x").unwrap();
1540 assert_eq!(ws.read_meta("feature/x").unwrap(), WtMeta::default());
1541 ws.clear_meta(&RealGit, "never-recorded").unwrap();
1543 }
1544
1545 #[test]
1546 fn metadata_writes_take_the_repo_lock() {
1547 let repo = TestRepo::init();
1549 let ws = workspace(&repo);
1550 repo.git(&["branch", "locked"]);
1551 let held = ws.lock().unwrap();
1552 let err = ws
1553 .write_meta(
1554 &RealGit,
1555 "locked",
1556 &MetaUpdate {
1557 pr_number: Some(1),
1558 ..MetaUpdate::default()
1559 },
1560 )
1561 .unwrap_err();
1562 assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
1563 let err = ws.clear_meta(&RealGit, "locked").unwrap_err();
1564 assert!(matches!(err, Error::LockUnavailable { .. }), "{err:?}");
1565 drop(held);
1566 ws.clear_meta(&RealGit, "locked").unwrap();
1567 }
1568
1569 #[test]
1570 fn metadata_writes_refuse_a_future_schema() {
1571 let repo = TestRepo::init();
1572 let ws = workspace(&repo);
1573 repo.git(&["branch", "stamped"]);
1574 repo.git(&["config", "wt.schema", "3"]);
1575 let err = ws
1576 .write_meta(&RealGit, "stamped", &MetaUpdate::default())
1577 .unwrap_err();
1578 assert!(
1579 matches!(err, Error::SchemaTooNew { found: 3, .. }),
1580 "{err:?}"
1581 );
1582 let err = ws.clear_meta(&RealGit, "stamped").unwrap_err();
1583 assert!(
1584 matches!(err, Error::SchemaTooNew { found: 3, .. }),
1585 "{err:?}"
1586 );
1587 }
1588
1589 #[test]
1590 fn remove_blocked_by_guards_is_a_typed_error() {
1591 let repo = TestRepo::init();
1592 let ws = workspace(&repo);
1593 ws.create(&RealGit, &RealHookRunner, &create_opts("topic"))
1594 .unwrap();
1595 let row = row_for(&ws, "topic");
1597 let err = ws
1598 .remove(
1599 &RealGit,
1600 &RealHookRunner,
1601 &row,
1602 &RemoveOptions {
1603 no_hooks: true,
1604 ..Default::default()
1605 },
1606 )
1607 .unwrap_err();
1608 match err {
1609 Error::RemoveGuarded { dirty, unpushed } => {
1610 assert!(!dirty);
1611 assert!(unpushed);
1612 }
1613 other => panic!("expected RemoveGuarded, got {other:?}"),
1614 }
1615 }
1616
1617 #[test]
1618 fn create_seeds_submodules_without_reaching_a_remote() {
1619 let repo = TestRepo::init();
1625 repo.add_submodule("libs/sub");
1626 let ws = workspace(&repo);
1627 let created = ws
1628 .create(
1629 &RealGit,
1630 &RealHookRunner,
1631 &CreateOptions {
1632 branch: "topic".to_string(),
1633 init_submodules: true,
1634 seed_submodules: true,
1635 no_hooks: true,
1636 ..Default::default()
1637 },
1638 )
1639 .unwrap();
1640
1641 assert!(
1642 created.path.join("libs/sub/sub.txt").exists(),
1643 "submodule was not populated"
1644 );
1645 assert_eq!(
1646 created.submodule_seeding.seeded,
1647 vec!["libs/sub".to_string()]
1648 );
1649 assert!(created.submodule_seeding.failed.is_empty());
1650 assert!(matches!(
1651 created.submodules,
1652 SubmodulesOutcome::Initialized(1)
1653 ));
1654 }
1655
1656 #[test]
1657 fn reflink_materialization_matches_a_normal_checkout() {
1658 let Some(repo) = TestRepo::init_cow() else {
1662 return;
1663 };
1664 repo.add_submodule("libs/sub");
1665 repo.write("tracked.txt", "content\n");
1666 repo.write(".gitignore", "build.out\n");
1667 repo.commit_all("add a tracked file and an ignore rule");
1668 repo.write("build.out", "artifact\n");
1672 repo.write("scratch.txt", "unsaved\n");
1676 std::fs::create_dir_all(repo.root().join("wip")).unwrap();
1677 repo.write("wip/notes.md", "later\n");
1678 repo.git(&["config", "status.showUntrackedFiles", "no"]);
1681
1682 let ws = workspace(&repo);
1683 let created = ws
1684 .create(
1685 &RealGit,
1686 &RealHookRunner,
1687 &CreateOptions {
1688 branch: "topic".to_string(),
1689 init_submodules: true,
1690 seed_submodules: true,
1691 reflink: true,
1692 no_hooks: true,
1693 ..Default::default()
1694 },
1695 )
1696 .unwrap();
1697
1698 assert!(created.reflinked, "the CoW path did not run");
1699 assert_eq!(
1701 std::fs::read_to_string(created.path.join("tracked.txt")).unwrap(),
1702 "content\n"
1703 );
1704 let status = repo.git(&["-C", &created.path.to_string_lossy(), "status", "--short"]);
1705 assert!(
1706 status.trim().is_empty(),
1707 "worktree is not clean: {status:?}"
1708 );
1709 assert!(created.path.join("libs/sub/sub.txt").exists());
1711 let subs = repo.git(&[
1712 "-C",
1713 &created.path.to_string_lossy(),
1714 "submodule",
1715 "status",
1716 "--recursive",
1717 ]);
1718 assert!(
1719 subs.starts_with(' '),
1720 "submodule not in sync after a reflink create: {subs:?}"
1721 );
1722 let origin =
1727 |dir: &Path| repo.git(&["-C", &dir.to_string_lossy(), "config", "remote.origin.url"]);
1728 assert_eq!(
1729 origin(&created.path.join("libs/sub")),
1730 origin(&repo.root().join("libs/sub")),
1731 "the reflinked submodule kept the local mirror as its origin"
1732 );
1733 assert!(created.path.join("build.out").exists());
1735 assert!(
1736 !created.path.join("scratch.txt").exists(),
1737 "an untracked source file was cloned into the new worktree"
1738 );
1739 assert!(
1740 !created.path.join("wip/notes.md").exists(),
1741 "an untracked source directory was cloned into the new worktree"
1742 );
1743 }
1744
1745 #[test]
1746 fn reflink_declines_when_the_source_is_at_a_different_tree() {
1747 let repo = TestRepo::init();
1751 let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1752 repo.write("only-on-head.txt", "later\n");
1753 repo.commit_all("move the source ahead");
1754
1755 let ws = workspace(&repo);
1756 let created = ws
1757 .create(
1758 &RealGit,
1759 &RealHookRunner,
1760 &CreateOptions {
1761 branch: "topic".to_string(),
1762 base: Some(base),
1763 reflink: true,
1764 no_hooks: true,
1765 ..Default::default()
1766 },
1767 )
1768 .unwrap();
1769
1770 assert!(!created.reflinked, "cloned across differing trees");
1771 assert!(!created.path.join("only-on-head.txt").exists());
1773 let status = repo.git(&["-C", &created.path.to_string_lossy(), "status", "--short"]);
1774 assert!(
1775 status.trim().is_empty(),
1776 "worktree is not clean: {status:?}"
1777 );
1778 }
1779
1780 #[test]
1781 fn create_without_seeding_leaves_the_clone_to_git() {
1782 let repo = TestRepo::init();
1787 repo.add_submodule("libs/sub");
1788 let ws = workspace(&repo);
1789 let created = ws
1790 .create(
1791 &RealGit,
1792 &RealHookRunner,
1793 &CreateOptions {
1794 branch: "topic".to_string(),
1795 init_submodules: true,
1796 seed_submodules: false,
1797 no_hooks: true,
1798 ..Default::default()
1799 },
1800 )
1801 .unwrap();
1802
1803 assert!(created.submodule_seeding.seeded.is_empty());
1804 assert!(!created.path.join("libs/sub/sub.txt").exists());
1805 assert!(matches!(
1806 created.submodules,
1807 SubmodulesOutcome::Failed { .. }
1808 ));
1809 }
1810
1811 #[test]
1812 fn remove_succeeds_on_a_worktree_containing_submodules() {
1813 let repo = TestRepo::init();
1818 repo.add_submodule("libs/sub");
1819 let ws = workspace(&repo);
1820 let created = ws
1821 .create(&RealGit, &RealHookRunner, &create_opts("topic"))
1822 .unwrap();
1823 repo.git(&[
1828 "-C",
1829 &created.path.to_string_lossy(),
1830 "-c",
1831 "protocol.file.allow=always",
1832 "submodule",
1833 "update",
1834 "--init",
1835 ]);
1836 assert!(created.path.join("libs/sub/sub.txt").exists());
1837 give_upstream(&repo, "topic");
1840
1841 let row = row_for(&ws, "topic");
1842 let removed = ws
1843 .remove(
1844 &RealGit,
1845 &RealHookRunner,
1846 &row,
1847 &RemoveOptions {
1848 no_hooks: true,
1849 ..Default::default()
1850 },
1851 )
1852 .unwrap();
1853 assert!(!created.path.exists(), "worktree directory still present");
1854 assert!(
1855 removed.forced_for_submodules,
1856 "removal should record that git needed forcing for submodules"
1857 );
1858 assert!(
1859 !removed.forced_past_guards,
1860 "no wt guard was overridden, so this must not read as a forced removal"
1861 );
1862 }
1863
1864 #[test]
1865 fn remove_guards_untracked_files_when_submodules_force_git() {
1866 let repo = TestRepo::init();
1871 repo.add_submodule("libs/sub");
1872 let ws = workspace(&repo);
1873 let created = ws
1874 .create(&RealGit, &RealHookRunner, &create_opts("topic"))
1875 .unwrap();
1876 repo.git(&[
1877 "-C",
1878 &created.path.to_string_lossy(),
1879 "-c",
1880 "protocol.file.allow=always",
1881 "submodule",
1882 "update",
1883 "--init",
1884 ]);
1885 give_upstream(&repo, "topic");
1886 std::fs::write(created.path.join("scratch.txt"), "unsaved\n").unwrap();
1887
1888 let row = row_for(&ws, "topic");
1889 let err = ws
1890 .remove(
1891 &RealGit,
1892 &RealHookRunner,
1893 &row,
1894 &RemoveOptions {
1895 no_hooks: true,
1896 ..Default::default()
1897 },
1898 )
1899 .unwrap_err();
1900 assert!(matches!(err, Error::RemoveGuarded { dirty: true, .. }));
1901 assert!(created.path.join("scratch.txt").exists());
1902
1903 let row = row_for(&ws, "topic");
1905 let removed = ws
1906 .remove(
1907 &RealGit,
1908 &RealHookRunner,
1909 &row,
1910 &RemoveOptions {
1911 no_hooks: true,
1912 force_remove: true,
1913 ..Default::default()
1914 },
1915 )
1916 .unwrap();
1917 assert!(!created.path.exists());
1918 assert!(removed.forced_past_guards);
1919 }
1920
1921 #[test]
1922 fn remove_without_submodules_does_not_report_a_submodule_force() {
1923 let repo = TestRepo::init();
1924 let ws = workspace(&repo);
1925 ws.create(&RealGit, &RealHookRunner, &create_opts("topic"))
1926 .unwrap();
1927 give_upstream(&repo, "topic");
1928 let row = row_for(&ws, "topic");
1929 let removed = ws
1930 .remove(
1931 &RealGit,
1932 &RealHookRunner,
1933 &row,
1934 &RemoveOptions {
1935 no_hooks: true,
1936 ..Default::default()
1937 },
1938 )
1939 .unwrap();
1940 assert!(!removed.forced_for_submodules);
1941 assert!(!removed.forced_past_guards);
1942 }
1943
1944 #[test]
1945 fn remove_force_reports_forced_past_guards() {
1946 let repo = TestRepo::init();
1947 let ws = workspace(&repo);
1948 ws.create(&RealGit, &RealHookRunner, &create_opts("forced"))
1949 .unwrap();
1950 let row = row_for(&ws, "forced");
1951 let removed = ws
1952 .remove(
1953 &RealGit,
1954 &RealHookRunner,
1955 &row,
1956 &RemoveOptions {
1957 force_remove: true,
1958 force_branch: true,
1959 no_hooks: true,
1960 keep_branch: false,
1961 unlock: false,
1962 },
1963 )
1964 .unwrap();
1965 assert!(removed.forced_past_guards);
1966 assert!(!repo.git(&["worktree", "list"]).contains("forced"));
1967 assert!(removed.branch_deleted);
1969 assert_eq!(ws.read_meta("forced").unwrap(), WtMeta::default());
1971 }
1972
1973 #[test]
1974 fn remove_refuses_the_primary_worktree() {
1975 let repo = TestRepo::init();
1976 let ws = workspace(&repo);
1977 let main = row_for(&ws, "main");
1978 let err = ws
1979 .remove(&RealGit, &RealHookRunner, &main, &RemoveOptions::default())
1980 .unwrap_err();
1981 assert!(err.to_string().contains("primary"));
1982 }
1983
1984 #[test]
1985 fn remove_missing_worktree_prunes_without_guards() {
1986 let repo = TestRepo::init();
1987 let ws = workspace(&repo);
1988 let created = ws
1989 .create(&RealGit, &RealHookRunner, &create_opts("gone"))
1990 .unwrap();
1991 std::fs::remove_dir_all(&created.path).unwrap();
1992 let row = row_for(&ws, "gone");
1993 assert!(row.is_missing);
1994 let removed = ws
1995 .remove(
1996 &RealGit,
1997 &RealHookRunner,
1998 &row,
1999 &RemoveOptions {
2000 no_hooks: true,
2001 ..Default::default()
2002 },
2003 )
2004 .unwrap();
2005 assert_eq!(removed.pre_remove, HookOutcome::Skipped);
2006 assert!(!repo.git(&["worktree", "list"]).contains("gone"));
2007 }
2008
2009 #[test]
2010 fn remove_locked_worktree_needs_unlock_even_with_force() {
2011 let repo = TestRepo::init();
2014 repo.write(".wt.toml", "[hooks]\npre_remove = \"touch hook-ran\"\n");
2015 repo.commit_all("config");
2016 let ws = workspace(&repo);
2017 let created = ws
2018 .create(&RealGit, &RealHookRunner, &create_opts("held"))
2019 .unwrap();
2020 let dir = created.path.to_string_lossy().into_owned();
2021 repo.git(&["worktree", "lock", "--reason", "some reason", &dir]);
2022 let row = row_for(&ws, "held");
2023 for force_remove in [false, true] {
2024 let err = ws
2025 .remove(
2026 &RealGit,
2027 &RealHookRunner,
2028 &row,
2029 &RemoveOptions {
2030 force_remove,
2031 ..Default::default()
2032 },
2033 )
2034 .unwrap_err();
2035 assert!(
2036 matches!(&err, Error::WorktreeLocked { reason: Some(r) } if r == "some reason"),
2037 "{err:?}"
2038 );
2039 assert_eq!(
2040 err.to_string(),
2041 "worktree is locked (some reason); use --locked"
2042 );
2043 }
2044 assert!(created.path.exists());
2045 assert!(!created.path.join("hook-ran").exists(), "hook ran first");
2046
2047 ws.remove(
2049 &RealGit,
2050 &RealHookRunner,
2051 &row,
2052 &RemoveOptions {
2053 force_remove: true,
2054 unlock: true,
2055 no_hooks: true,
2056 ..Default::default()
2057 },
2058 )
2059 .unwrap();
2060 assert!(!created.path.exists());
2061 assert!(!repo.git(&["worktree", "list"]).contains("held"));
2062 }
2063
2064 #[test]
2065 fn remove_locked_worktree_without_a_reason() {
2066 let repo = TestRepo::init();
2067 let ws = workspace(&repo);
2068 let created = ws
2069 .create(&RealGit, &RealHookRunner, &create_opts("bare-lock"))
2070 .unwrap();
2071 repo.git(&["worktree", "lock", &created.path.to_string_lossy()]);
2072 let row = row_for(&ws, "bare-lock");
2073 let err = ws
2074 .remove(&RealGit, &RealHookRunner, &row, &RemoveOptions::default())
2075 .unwrap_err();
2076 assert!(matches!(err, Error::WorktreeLocked { reason: None }));
2077 assert_eq!(err.to_string(), "worktree is locked; use --locked");
2078 }
2079
2080 #[test]
2081 fn remove_missing_locked_worktree_needs_unlock() {
2082 let repo = TestRepo::init();
2085 let ws = workspace(&repo);
2086 let created = ws
2087 .create(&RealGit, &RealHookRunner, &create_opts("lost"))
2088 .unwrap();
2089 repo.git(&[
2090 "worktree",
2091 "lock",
2092 "--reason",
2093 "pid 1",
2094 &created.path.to_string_lossy(),
2095 ]);
2096 std::fs::remove_dir_all(&created.path).unwrap();
2097 let row = row_for(&ws, "lost");
2098 assert!(row.is_missing);
2099 let err = ws
2100 .remove(&RealGit, &RealHookRunner, &row, &RemoveOptions::default())
2101 .unwrap_err();
2102 assert!(matches!(err, Error::WorktreeLocked { .. }));
2103 ws.remove(
2104 &RealGit,
2105 &RealHookRunner,
2106 &row,
2107 &RemoveOptions {
2108 unlock: true,
2109 ..Default::default()
2110 },
2111 )
2112 .unwrap();
2113 assert!(!repo.git(&["worktree", "list"]).contains("lost"));
2114 }
2115
2116 #[test]
2117 fn remove_with_unlock_on_an_unlocked_worktree_does_not_force_git() {
2118 let repo = TestRepo::init();
2121 let ws = workspace(&repo);
2122 let created = ws
2123 .create(&RealGit, &RealHookRunner, &create_opts("free"))
2124 .unwrap();
2125 give_upstream(&repo, "free");
2126 std::fs::write(created.path.join("scratch.txt"), "unsaved\n").unwrap();
2127 let row = row_for(&ws, "free");
2128 let unlock = RemoveOptions {
2129 unlock: true,
2130 no_hooks: true,
2131 ..Default::default()
2132 };
2133 let err = ws
2134 .remove(&RealGit, &RealHookRunner, &row, &unlock)
2135 .unwrap_err();
2136 assert!(matches!(err, Error::Subprocess { .. }), "{err:?}");
2137 assert!(created.path.join("scratch.txt").exists());
2138
2139 std::fs::remove_file(created.path.join("scratch.txt")).unwrap();
2140 let row = row_for(&ws, "free");
2141 ws.remove(&RealGit, &RealHookRunner, &row, &unlock).unwrap();
2142 assert!(!created.path.exists());
2143 }
2144
2145 #[test]
2146 fn remove_unlock_guards_untracked_files() {
2147 let repo = TestRepo::init();
2150 let ws = workspace(&repo);
2151 let created = ws
2152 .create(&RealGit, &RealHookRunner, &create_opts("held-dirty"))
2153 .unwrap();
2154 give_upstream(&repo, "held-dirty");
2155 repo.git(&["worktree", "lock", &created.path.to_string_lossy()]);
2156 std::fs::write(created.path.join("scratch.txt"), "unsaved\n").unwrap();
2157 let row = row_for(&ws, "held-dirty");
2158 let err = ws
2159 .remove(
2160 &RealGit,
2161 &RealHookRunner,
2162 &row,
2163 &RemoveOptions {
2164 unlock: true,
2165 no_hooks: true,
2166 ..Default::default()
2167 },
2168 )
2169 .unwrap_err();
2170 assert!(matches!(err, Error::RemoveGuarded { dirty: true, .. }));
2171 assert!(created.path.join("scratch.txt").exists());
2172 }
2173
2174 #[test]
2175 fn remove_failing_pre_remove_hook_aborts_unless_forced() {
2176 let repo = TestRepo::init();
2177 repo.write(".wt.toml", "[hooks]\npre_remove = \"exit 5\"\n");
2178 repo.commit_all("config");
2179 let ws = workspace(&repo);
2180 ws.create(&RealGit, &RealHookRunner, &create_opts("hooked"))
2181 .unwrap();
2182 let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
2184 repo.git(&["update-ref", "refs/remotes/origin/hooked", &head]);
2185 repo.git(&["config", "branch.hooked.remote", "origin"]);
2186 repo.git(&["config", "branch.hooked.merge", "refs/heads/hooked"]);
2187 let row = row_for(&ws, "hooked");
2188 let err = ws
2189 .remove(&RealGit, &RealHookRunner, &row, &RemoveOptions::default())
2190 .unwrap_err();
2191 assert!(
2192 err.to_string()
2193 .contains("pre_remove hook exited with status 5")
2194 );
2195 let removed = ws
2197 .remove(
2198 &RealGit,
2199 &RealHookRunner,
2200 &row,
2201 &RemoveOptions {
2202 force_remove: true,
2203 ..Default::default()
2204 },
2205 )
2206 .unwrap();
2207 assert_eq!(removed.pre_remove, HookOutcome::ExitedNonZero(5));
2208 assert!(!repo.git(&["worktree", "list"]).contains("hooked"));
2209 }
2210
2211 #[test]
2212 fn discover_refuses_a_future_schema() {
2213 let repo = TestRepo::init();
2214 repo.git(&["config", "wt.schema", "99"]);
2215 let err = Workspace::discover(repo.root(), &env(), &RealGit)
2216 .err()
2217 .expect("a future schema must refuse discovery");
2218 assert!(matches!(err, Error::SchemaTooNew { found: 99, .. }));
2219 }
2220
2221 #[test]
2222 fn mutations_refuse_a_schema_stamped_after_discovery() {
2223 let repo = TestRepo::init();
2226 let ws = workspace(&repo);
2227 repo.git(&["config", "wt.schema", "2"]);
2228 let err = ws
2229 .create(&RealGit, &RealHookRunner, &create_opts("late"))
2230 .unwrap_err();
2231 assert!(matches!(err, Error::SchemaTooNew { found: 2, .. }));
2232 }
2233
2234 #[test]
2235 fn lock_is_exclusive_and_released_on_drop() {
2236 let repo = TestRepo::init();
2237 let ws = workspace(&repo);
2238 let held = ws.lock().unwrap();
2239 let err = acquire_repo_lock(ws.root(), Duration::from_millis(50))
2241 .err()
2242 .expect("the held lock must exclude a second holder");
2243 match &err {
2244 Error::LockUnavailable { path, .. } => {
2245 assert!(path.ends_with("wt-mutation.lock"), "{path}");
2246 }
2247 other => panic!("expected LockUnavailable, got {other:?}"),
2248 }
2249 drop(held);
2251 acquire_repo_lock(ws.root(), Duration::from_millis(50)).unwrap();
2252 }
2253
2254 #[test]
2255 fn a_schema_refusal_does_not_strand_the_lock() {
2256 let repo = TestRepo::init();
2260 let ws = workspace(&repo);
2261 repo.git(&["config", "wt.schema", "2"]);
2262 let err = acquire_repo_lock(ws.root(), Duration::from_millis(50))
2263 .err()
2264 .expect("a future schema must refuse the acquisition");
2265 assert!(
2266 matches!(err, Error::SchemaTooNew { found: 2, .. }),
2267 "{err:?}"
2268 );
2269 assert!(!ws.root().join(".git/wt-mutation.lock").exists());
2270
2271 repo.git(&["config", "--unset", "wt.schema"]);
2272 acquire_repo_lock(ws.root(), Duration::from_millis(50)).unwrap();
2273 }
2274
2275 #[test]
2276 fn the_lock_gate_reads_a_bare_repository_too() {
2277 let repo = TestRepo::init_bare();
2282 acquire_repo_lock(repo.root(), Duration::from_millis(50)).unwrap();
2283
2284 repo.git(&["config", "wt.schema", "2"]);
2285 let err = acquire_repo_lock(repo.root(), Duration::from_millis(50))
2286 .err()
2287 .expect("a bare repository is gated like any other");
2288 assert!(
2289 matches!(err, Error::SchemaTooNew { found: 2, .. }),
2290 "{err:?}"
2291 );
2292 }
2293
2294 #[test]
2295 fn a_schema_bump_landing_during_the_lock_wait_is_refused() {
2296 let repo = TestRepo::init();
2303 let ws = workspace(&repo);
2304 repo.git(&["branch", "stamped"]);
2305 let held = ws.lock().unwrap();
2306
2307 let (discovered, wait) = std::sync::mpsc::channel();
2308 let root = repo.root().to_path_buf();
2309 let writer = std::thread::spawn(move || {
2310 let ws = Workspace::discover(&root, &env(), &RealGit)?;
2311 discovered.send(()).expect("the test thread outlives this");
2312 ws.write_meta(
2313 &RealGit,
2314 "stamped",
2315 &MetaUpdate {
2316 pr_number: Some(1),
2317 ..MetaUpdate::default()
2318 },
2319 )
2320 });
2321
2322 wait.recv().expect("the writer discovers before it blocks");
2323 repo.git(&["config", "wt.schema", "2"]);
2324 drop(held);
2325
2326 let err = writer
2327 .join()
2328 .expect("the writer must not panic")
2329 .expect_err("the bumped schema must refuse the blocked write");
2330 assert!(
2331 matches!(err, Error::SchemaTooNew { found: 2, .. }),
2332 "{err:?}"
2333 );
2334 repo.git(&["config", "--unset", "wt.schema"]);
2336 assert_eq!(ws.read_meta("stamped").unwrap(), WtMeta::default());
2337 }
2338
2339 #[test]
2340 fn create_releases_the_lock_before_the_post_create_hook() {
2341 let repo = TestRepo::init();
2344 repo.write(
2345 ".wt.toml",
2346 "[hooks]\npost_create = \"test ! -e \\\"$WT_REPO_ROOT/.git/wt-mutation.lock\\\"\"\n",
2347 );
2348 repo.commit_all("config");
2349 let ws = workspace(&repo);
2350 let mut opts = create_opts("hookfree");
2351 opts.no_hooks = false;
2352 let created = ws.create(&RealGit, &RealHookRunner, &opts).unwrap();
2353 assert_eq!(created.post_create, HookOutcome::Succeeded);
2354 }
2355
2356 #[test]
2357 fn concurrent_creates_on_one_branch_do_not_corrupt_metadata() {
2358 let repo = TestRepo::init();
2362 let root = repo.root().to_path_buf();
2363 let spawn = |root: PathBuf| {
2364 std::thread::spawn(move || {
2365 let ws = Workspace::discover(&root, &env(), &RealGit)?;
2366 ws.create(&RealGit, &RealHookRunner, &create_opts("feat/race"))
2367 })
2368 };
2369 let a = spawn(root.clone());
2370 let b = spawn(root);
2371 let results = [a.join().unwrap(), b.join().unwrap()];
2372 let ok = results.iter().filter(|r| r.is_ok()).count();
2373 let created = results
2376 .iter()
2377 .filter(|r| r.as_ref().is_ok_and(|c| !c.reused))
2378 .count();
2379 assert!(ok >= 1, "at least one racer must win: {results:?}");
2380 assert_eq!(created, 1, "exactly one racer creates: {results:?}");
2381
2382 let ws = workspace(&repo);
2383 let rows = ws.list(&RealGit).unwrap();
2384 let race_rows: Vec<_> = rows
2385 .iter()
2386 .filter(|w| w.branch.as_deref() == Some("feat/race"))
2387 .collect();
2388 assert_eq!(race_rows.len(), 1);
2389 let meta = ws.read_meta("feat/race").unwrap();
2390 assert_eq!(meta.base_ref.as_deref(), Some("main"));
2391 assert!(meta.created_by_wt);
2392 }
2393
2394 #[test]
2395 fn resolve_base_falls_back_to_head_only_without_default() {
2396 let repo = TestRepo::init();
2397 let ws = workspace(&repo);
2398 let r = Repo::discover(repo.root()).unwrap();
2399 assert_eq!(
2400 resolve_base(&r, ws.config(), Some("explicit")),
2401 ("explicit".into(), false)
2402 );
2403 assert_eq!(resolve_base(&r, ws.config(), None), ("main".into(), false));
2405 let mut config = ws.config().clone();
2407 config.default_base = Some("trunk".into());
2408 assert_eq!(resolve_base(&r, &config, None), ("trunk".into(), false));
2409 }
2410}