1use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::branch_tracking::write_branch_upstream_config;
11use crate::check_ref_format::validate_branch_short_name;
12use crate::config::ConfigSet;
13#[cfg(test)]
14use crate::environment::RepositoryOptions;
15use crate::error::{Error, Result};
16use crate::git_path::{absolute_path_for_gitfile_stored_path, path_for_disk_compare};
17use crate::hooks::run_post_checkout;
18use crate::porcelain::branch::{resolve_create_tracking, TrackMode};
19use crate::porcelain::branch_error::BranchError;
20use crate::porcelain::checkout::checkout_between_trees;
21use crate::porcelain::checkout_reflog::{
22 create_branch_ref_with_reflog, reset_branch_ref_with_reflog,
23};
24use crate::porcelain::status::{status, StatusOptions};
25use crate::progress::NullProgress;
26use crate::refs;
27use crate::repo::{common_git_dir_for_config, Repository};
28use crate::rev_parse::resolve_revision;
29use crate::state::{resolve_head, resolve_linked_worktree_head, HeadState};
30
31use super::error::{WorktreeError, WorktreeOperation};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PrunableReason {
36 AdminNotValidDirectory,
38 GitdirFileMissing,
40 GitdirPointsElsewhere,
42 WorktreePathMissing,
44 InvalidGitdirFile,
46 ExplicitMarker,
48}
49
50impl PrunableReason {
51 #[must_use]
53 pub fn porcelain_message(self) -> &'static str {
54 match self {
55 Self::AdminNotValidDirectory => "not a valid directory",
56 Self::GitdirFileMissing => "gitdir file does not exist",
57 Self::GitdirPointsElsewhere => "gitdir file points to non-existent location",
58 Self::WorktreePathMissing => "gitdir file points to non-existent location",
59 Self::InvalidGitdirFile => "invalid gitdir file",
60 Self::ExplicitMarker => "gitdir file points to non-existent location",
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct WorktreeEntry {
68 pub path: PathBuf,
70 pub head: HeadState,
72 pub is_bare: bool,
74 pub is_locked: bool,
76 pub lock_reason: Option<String>,
78 pub admin_dir: PathBuf,
80 pub is_prunable: bool,
82 pub prunable_reason: Option<PrunableReason>,
84 pub is_current: bool,
86}
87
88#[must_use]
90pub fn common_git_dir(git_dir: &Path) -> PathBuf {
91 common_git_dir_for_config(git_dir)
92}
93
94#[must_use]
96pub fn registered_worktree_count(common: &Path) -> usize {
97 let worktrees_dir = common.join("worktrees");
98 if !worktrees_dir.is_dir() {
99 return 1;
100 }
101 let linked = fs::read_dir(&worktrees_dir)
102 .into_iter()
103 .flatten()
104 .flatten()
105 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
106 .count();
107 1 + linked
108}
109
110#[must_use]
112pub fn is_bare_repository(common: &Path) -> bool {
113 ConfigSet::load(
114 &crate::environment::Environment::empty(),
115 Some(common),
116 true,
117 )
118 .ok()
119 .and_then(|cfg| cfg.get_bool("core.bare"))
120 .and_then(|r| r.ok())
121 .unwrap_or_else(|| {
122 !common.ends_with(".git") && common.join("config").is_file()
124 })
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct BranchWorktreeCheckout {
130 pub branch: String,
132 pub worktree_path: PathBuf,
134 pub admin_dir: PathBuf,
136}
137
138#[must_use]
140pub fn worktree_admin_dir(repo: &Repository) -> PathBuf {
141 let common = common_git_dir(repo.git_dir());
142 if repo.git_dir().starts_with(common.join("worktrees")) {
143 repo.git_dir().to_path_buf()
144 } else {
145 common
146 }
147}
148
149fn head_is_branch(head: &HeadState, branch_short_name: &str) -> bool {
150 match head {
151 HeadState::Branch {
152 short_name,
153 refname,
154 ..
155 } => {
156 short_name == branch_short_name || refname == &format!("refs/heads/{branch_short_name}")
157 }
158 _ => false,
159 }
160}
161
162fn operation_holds_branch(entry: &WorktreeEntry, branch_short_name: &str) -> bool {
166 if !matches!(entry.head, HeadState::Detached { .. }) {
167 return false;
168 }
169 let refname = format!("refs/heads/{branch_short_name}");
170 let first_line = |file: &str| {
171 fs::read_to_string(entry.admin_dir.join(file))
172 .ok()
173 .and_then(|text| text.lines().next().map(|line| line.trim().to_owned()))
174 };
175 let rebasing = ["rebase-merge/head-name", "rebase-apply/head-name"]
176 .into_iter()
177 .any(|file| first_line(file).as_deref() == Some(refname.as_str()));
178 rebasing
179 || first_line("BISECT_START")
180 .is_some_and(|start| start == branch_short_name || start == refname)
181}
182
183pub(crate) fn admin_dirs_match(a: &Path, b: &Path) -> bool {
184 path_for_disk_compare(a) == path_for_disk_compare(b) || a == b
185}
186
187pub fn find_worktree_with_branch_checked_out(
196 repo: &Repository,
197 branch_short_name: &str,
198 exclude_admin_dir: Option<&Path>,
199) -> Result<Option<BranchWorktreeCheckout>> {
200 for entry in list_worktrees(repo)? {
201 if entry.is_bare {
202 continue;
203 }
204 if exclude_admin_dir.is_some_and(|ex| admin_dirs_match(&entry.admin_dir, ex)) {
205 continue;
206 }
207 if head_is_branch(&entry.head, branch_short_name)
208 || operation_holds_branch(&entry, branch_short_name)
209 {
210 return Ok(Some(BranchWorktreeCheckout {
211 branch: branch_short_name.to_owned(),
212 worktree_path: entry.path.clone(),
213 admin_dir: entry.admin_dir.clone(),
214 }));
215 }
216 }
217 Ok(None)
218}
219
220pub fn ensure_branch_not_checked_out_elsewhere(
226 repo: &Repository,
227 branch_short_name: &str,
228) -> Result<()> {
229 let exclude = worktree_admin_dir(repo);
230 if let Some(found) =
231 find_worktree_with_branch_checked_out(repo, branch_short_name, Some(&exclude))?
232 {
233 return Err(branch_checked_out_error(
234 branch_short_name,
235 &found.worktree_path,
236 ));
237 }
238 Ok(())
239}
240
241pub fn ensure_branch_not_checked_out_in_any_worktree(
247 repo: &Repository,
248 branch_short_name: &str,
249) -> Result<()> {
250 if let Some(found) = find_worktree_with_branch_checked_out(repo, branch_short_name, None)? {
251 return Err(branch_checked_out_error(
252 branch_short_name,
253 &found.worktree_path,
254 ));
255 }
256 Ok(())
257}
258
259fn branch_checked_out_error(branch: &str, worktree_path: &Path) -> Error {
260 Error::BranchCheckedOutInWorktree {
261 branch: branch.to_owned(),
262 worktree_path: crate::git_path::path_for_error_message(worktree_path),
263 }
264}
265
266pub fn list_worktrees(repo: &Repository) -> Result<Vec<WorktreeEntry>> {
270 let common = common_git_dir(repo.git_dir());
271 let git_dir = repo.git_dir();
272 let opened_from_linked = git_dir.starts_with(common.join("worktrees"));
273 let mut entries = Vec::new();
274
275 let bare = if opened_from_linked {
276 is_bare_repository(&common)
277 } else {
278 repo.is_bare()
279 };
280 let main_path = if bare {
281 path_for_disk_compare(&common)
282 } else if let Some(wt) = repo.work_tree() {
283 if repo.git_dir() == common || !repo.git_dir().starts_with(common.join("worktrees")) {
285 path_for_disk_compare(wt)
286 } else {
287 path_for_disk_compare(common.parent().unwrap_or(&common))
288 }
289 } else {
290 path_for_disk_compare(common.parent().unwrap_or(&common))
291 };
292
293 let main_head = if opened_from_linked || (repo.refs_on_disk() && git_dir != common) {
294 resolve_head(&common)
295 } else {
296 crate::state::resolve_head_for_repository(repo)
297 }
298 .unwrap_or(HeadState::Invalid);
299 entries.push(WorktreeEntry {
300 path: main_path,
301 head: main_head,
302 is_bare: bare,
303 is_locked: false,
304 lock_reason: None,
305 admin_dir: common.clone(),
306 is_prunable: false,
307 prunable_reason: None,
308 is_current: !opened_from_linked && repo_opened_from_admin(git_dir, &common),
309 });
310
311 let worktrees_dir = common.join("worktrees");
312 if !worktrees_dir.is_dir() {
313 return Ok(entries);
314 }
315
316 let mut names: Vec<String> = fs::read_dir(&worktrees_dir)
317 .map_err(Error::Io)?
318 .filter_map(|e| e.ok())
319 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
320 .map(|e| e.file_name().to_string_lossy().into_owned())
321 .collect();
322 names.sort();
323
324 for name in names {
325 let admin = worktrees_dir.join(&name);
326 let wt_head = resolve_linked_worktree_head(repo, &admin);
327 let wt_path = read_worktree_path(&admin)?;
328 let (is_locked, lock_reason) = read_lock_state(&admin)?;
329 let (is_prunable, prunable_reason) =
330 linked_worktree_prunable_state(&admin, is_locked, &wt_path);
331 let is_current = opened_from_linked && git_dir == admin.as_path();
332 entries.push(WorktreeEntry {
333 path: wt_path,
334 head: wt_head,
335 is_bare: false,
336 is_locked,
337 lock_reason,
338 admin_dir: admin,
339 is_prunable,
340 prunable_reason,
341 is_current,
342 });
343 }
344
345 Ok(entries)
346}
347
348#[inline]
349fn repo_opened_from_admin(git_dir: &Path, admin: &Path) -> bool {
350 git_dir == admin || path_for_disk_compare(git_dir) == path_for_disk_compare(admin)
351}
352
353#[must_use]
355pub fn worktree_path_basename(path: &Path) -> String {
356 let s = path.to_string_lossy();
357 let trimmed = s.trim_end_matches(['/', '\\']);
358 trimmed
359 .rsplit(['/', '\\'])
360 .next()
361 .unwrap_or(trimmed)
362 .to_owned()
363}
364
365#[must_use]
367pub fn sanitize_worktree_id_component(name: &str) -> String {
368 if name == "@" {
369 return "-".to_owned();
370 }
371
372 let mut out = String::new();
373 let mut last = '\0';
374 let chars: Vec<char> = name.chars().collect();
375 let mut i = 0;
376 while i < chars.len() {
377 let ch = chars[i];
378 if ch.is_ascii_control()
379 || matches!(ch, ':' | '?' | '[' | '\\' | '^' | '~' | ' ' | '\t' | '*')
380 {
381 if (out.is_empty() && last != '-') || !out.is_empty() {
382 out.push('-');
383 }
384 last = '-';
385 i += 1;
386 continue;
387 }
388 if ch == '.' && i + 1 < chars.len() && chars[i + 1] == '.' {
389 if last == '.' {
390 out.pop();
391 } else {
392 out.push('.');
393 last = '.';
394 }
395 i += 2;
396 continue;
397 }
398 if ch == '@' && i + 1 < chars.len() && chars[i + 1] == '{' {
399 if let Some(last_ch) = out.pop() {
400 if last_ch != '-' {
401 out.push('-');
402 }
403 }
404 last = '-';
405 i += 2;
406 continue;
407 }
408 if ch == '.' && out.is_empty() {
409 out.push('-');
410 last = '-';
411 i += 1;
412 continue;
413 }
414 out.push(ch);
415 last = ch;
416 i += 1;
417 }
418
419 const LOCK_SUFFIX: &str = ".lock";
420 while out.ends_with(LOCK_SUFFIX) {
421 out.truncate(out.len() - LOCK_SUFFIX.len());
422 }
423 while out.ends_with('.') {
424 out.pop();
425 }
426 out
427}
428
429#[must_use]
433pub fn allocate_worktree_admin_dir(common: &Path, wt_path: &Path) -> PathBuf {
434 let worktrees_dir = common.join("worktrees");
435 let base = sanitize_worktree_id_component(&worktree_path_basename(wt_path));
436 let base = if base.is_empty() {
437 "worktree".to_owned()
438 } else {
439 base
440 };
441
442 let mut counter = 0u32;
443 loop {
444 let id = if counter == 0 {
445 base.clone()
446 } else {
447 format!("{base}{counter}")
448 };
449 let admin = worktrees_dir.join(&id);
450 if !admin.exists() {
451 return admin;
452 }
453 counter = counter.saturating_add(1);
454 if counter == 0 {
455 break;
456 }
457 }
458 worktrees_dir.join(format!("{base}{}", std::process::id()))
459}
460
461pub fn copy_filtered_worktree_config(source_git_dir: &Path, admin_dir: &Path) -> Result<()> {
464 let src = source_git_dir.join("config.worktree");
465 if !src.is_file() {
466 return Ok(());
467 }
468 let dst = admin_dir.join("config.worktree");
469 fs::copy(&src, &dst).map_err(Error::Io)?;
470 strip_worktree_config_keys(&dst, &["core.bare", "core.worktree"])?;
471 Ok(())
472}
473
474fn strip_worktree_config_keys(path: &Path, keys: &[&str]) -> Result<()> {
475 let content = fs::read_to_string(path).map_err(Error::Io)?;
476 let mut kept = Vec::new();
477 let mut section: Option<String> = None;
478 for line in content.lines() {
479 let trimmed = line.trim();
480 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
481 kept.push(line);
482 continue;
483 }
484 if trimmed.starts_with('[') {
485 let end = trimmed.find(']').unwrap_or(trimmed.len());
486 let name = trimmed[1..end].trim().to_ascii_lowercase();
487 section = Some(name);
488 kept.push(line);
489 continue;
490 }
491 if let Some((key, _)) = trimmed.split_once('=') {
492 let key = key.trim().to_ascii_lowercase();
493 let full = match section.as_deref() {
494 Some(sec) => format!("{sec}.{key}"),
495 None => key.clone(),
496 };
497 if keys.iter().any(|k| full.eq_ignore_ascii_case(k)) {
498 continue;
499 }
500 } else if keys.iter().any(|k| trimmed.eq_ignore_ascii_case(k)) {
501 continue;
502 }
503 kept.push(line);
504 }
505 let mut out = kept.join("\n");
506 if !out.is_empty() {
507 out.push('\n');
508 }
509 fs::write(path, out).map_err(Error::Io)
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
514pub enum WorktreeBranch {
515 Existing(String),
517 Create(String),
519 CreateOrReset(String),
521 Detach,
523 Orphan(String),
527}
528
529#[derive(Debug, Clone)]
531pub struct AddLinkedWorktreeOptions {
532 pub path: PathBuf,
534 pub branch: Option<WorktreeBranch>,
536 pub start_point: Option<String>,
538 pub force: bool,
540 pub lock: Option<LockWorktreeOptions>,
542 pub no_checkout: bool,
544 pub track: bool,
546}
547
548#[derive(Debug, Clone, Default)]
551pub struct LockWorktreeOptions {
552 pub reason: Option<String>,
554}
555
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
558pub struct PruneWorktreesOptions {
559 pub dry_run: bool,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct PrunedWorktreeEntry {
566 pub path: PathBuf,
568 pub reason: String,
570}
571
572pub fn add_linked_worktree(
588 repo: &Repository,
589 opts: &AddLinkedWorktreeOptions,
590) -> Result<WorktreeEntry> {
591 let common = common_git_dir(repo.git_dir());
593
594 let wt_path = if opts.path.exists() {
595 path_for_disk_compare(&opts.path)
596 } else {
597 opts.path.clone()
598 };
599 let admin_plan = plan_admin_dir_for_add(&common, &wt_path, opts.force)?;
600 ensure_empty_or_missing(&wt_path)?;
601
602 let (start_oid, tree_oid, plan) = if let Some(WorktreeBranch::Orphan(name)) = &opts.branch {
605 (None, None, plan_orphan_worktree_head(repo, name)?)
606 } else {
607 let (start_spec, start_oid) = resolve_worktree_start_commit(repo, opts)?;
608 let tree_oid = repo.odb().commit_tree(&start_oid)?;
609 let plan = plan_worktree_head(repo, opts, &wt_path, &start_spec, start_oid)?;
610 (Some(start_oid), Some(tree_oid), plan)
611 };
612 if let Some(ref branch_ref) = plan.head_ref {
613 let short = branch_ref
614 .strip_prefix("refs/heads/")
615 .unwrap_or(branch_ref.as_str());
616 ensure_branch_not_checked_out_in_any_worktree(repo, short)?;
617 }
618
619 let admin = admin_plan.admin_dir;
620 let worktree_absent_before = !wt_path.exists();
621 let mut stale_guard = admin_plan
622 .stale_admins
623 .map(StaleWorktreeAdminBackup::remove_and_backup)
624 .transpose()?;
625
626 let mut branch_rollback = None;
627 let finish = (|| -> Result<WorktreeEntry> {
628 if let Some(ref bw) = plan.branch_write {
629 let branch_ref = format!("refs/heads/{}", bw.branch_short);
630 let previous_tip = repo.resolve_ref_oid(&branch_ref).ok();
631 branch_rollback = Some(BranchRefRollback {
632 repo,
633 branch_ref: branch_ref.clone(),
634 previous_tip,
635 committed: false,
636 });
637 create_worktree_branch(
638 repo,
639 &bw.branch_short,
640 &bw.start_spec,
641 bw.start_oid,
642 bw.force_reset,
643 )?;
644 }
645 if let (Some(branch_short), Some(bw)) = (&plan.track_branch, &plan.branch_write) {
646 maybe_track_new_branch(repo, opts, branch_short, &bw.start_spec, bw.start_oid)?;
647 }
648
649 fs::create_dir_all(&admin).map_err(Error::Io)?;
650 if let Some(guard) = stale_guard.as_mut() {
651 guard.set_new_admin(&admin);
652 }
653 fs::create_dir_all(&wt_path).map_err(Error::Io)?;
654 if let Some(guard) = stale_guard.as_mut() {
655 guard.track_replacement_worktree(&wt_path, worktree_absent_before);
656 }
657
658 let wt_git = wt_path.join(".git");
659 fs::write(
660 &wt_git,
661 format!(
662 "gitdir: {}\n",
663 absolute_path_for_gitfile_stored_path(&admin)
664 ),
665 )
666 .map_err(Error::Io)?;
667 let wt_git_abs = absolute_path_for_gitfile_stored_path(&wt_git);
668 fs::write(admin.join("commondir"), "../..\n").map_err(Error::Io)?;
669 fs::write(admin.join("gitdir"), format!("{wt_git_abs}\n")).map_err(Error::Io)?;
670
671 let head = match (&plan.head_ref, start_oid) {
672 (Some(target), _) => crate::refs::store::RawRef::Symbolic(target.clone()),
673 (None, Some(start_oid)) => crate::refs::store::RawRef::Direct(start_oid),
674 (None, None) => unreachable!("an orphan worktree has a HEAD branch"),
675 };
676 if repo.refs_on_disk() {
677 crate::refs::store::initialize_head(&admin, head)?;
678 } else {
679 let content = match head {
681 crate::refs::store::RawRef::Symbolic(target) => format!("ref: {target}\n"),
682 crate::refs::store::RawRef::Direct(oid) => format!("{oid}\n"),
683 };
684 fs::write(admin.join("HEAD"), content).map_err(Error::Io)?;
685 }
686
687 copy_filtered_worktree_config(&common, &admin)?;
688
689 if let Some(lock) = &opts.lock {
690 write_admin_lock_file(&admin, lock.reason.as_deref())?;
691 }
692
693 if let (false, Some(tree_oid)) = (opts.no_checkout, tree_oid) {
694 let linked = repo.open_linked_worktree(&admin, &wt_path)?;
695 checkout_between_trees(&linked, None, &tree_oid)?;
696 }
697
698 linked_worktree_entry(repo, &admin)
699 })();
700
701 match finish {
702 Ok(entry) => {
703 if let Some(guard) = stale_guard.take() {
704 guard.commit();
705 }
706 if let Some(mut rollback) = branch_rollback {
707 rollback.commit();
708 }
709 if !opts.no_checkout {
710 if let Some(oid) = start_oid {
711 let linked = repo.open_linked_worktree(&admin, &wt_path)?;
712 run_post_checkout(&linked, None, oid, true);
713 }
714 }
715 Ok(entry)
716 }
717 Err(err) => Err(err),
718 }
719}
720
721pub fn prune_linked_worktrees(
729 repo: &Repository,
730 opts: &PruneWorktreesOptions,
731) -> Result<Vec<PrunedWorktreeEntry>> {
732 let common = common_git_dir(repo.git_dir());
733 let worktrees_dir = common.join("worktrees");
734 if !worktrees_dir.is_dir() {
735 return Ok(Vec::new());
736 }
737
738 let mut pruned = Vec::new();
739 for entry in list_worktrees(repo)? {
740 if entry.admin_dir == common || entry.is_locked || !entry.is_prunable {
741 continue;
742 }
743 let reason = prune_reason(&entry.admin_dir, &entry.path);
744 pruned.push(PrunedWorktreeEntry {
745 path: entry.path.clone(),
746 reason,
747 });
748 if !opts.dry_run {
749 fs::remove_dir_all(&entry.admin_dir).map_err(Error::Io)?;
750 }
751 }
752 Ok(pruned)
753}
754
755fn prune_reason(admin: &Path, wt_path: &Path) -> String {
756 let prunable_file = admin.join("prunable");
757 if prunable_file.is_file() {
758 if let Ok(content) = fs::read_to_string(&prunable_file) {
759 let trimmed = content.trim();
760 if !trimmed.is_empty() {
761 return trimmed.to_owned();
762 }
763 }
764 }
765 if !wt_path.exists() {
766 return "gitdir file points to non-existent location".into();
767 }
768 "prunable".into()
769}
770
771fn write_admin_lock_file(admin: &Path, reason: Option<&str>) -> Result<()> {
772 let body = reason.unwrap_or("");
773 fs::write(admin.join("locked"), body).map_err(Error::Io)
774}
775
776struct WorktreeHeadPlan {
777 head_ref: Option<String>,
778 branch_write: Option<BranchWritePlan>,
779 track_branch: Option<String>,
780}
781
782struct BranchWritePlan {
783 branch_short: String,
784 start_spec: String,
785 start_oid: crate::objects::ObjectId,
786 force_reset: bool,
787}
788
789struct BranchRefRollback<'a> {
790 repo: &'a Repository,
791 branch_ref: String,
792 previous_tip: Option<crate::objects::ObjectId>,
793 committed: bool,
794}
795
796impl BranchRefRollback<'_> {
797 fn commit(&mut self) {
798 self.committed = true;
799 }
800}
801
802impl Drop for BranchRefRollback<'_> {
803 fn drop(&mut self) {
804 if self.committed {
805 return;
806 }
807 let _ = match self.previous_tip {
808 None => refs::delete_ref_on_store(self.repo.refs(), &self.branch_ref),
809 Some(old) => refs::update_direct_ref_on_store(
810 self.repo.refs(),
811 self.repo.git_dir(),
812 &self.branch_ref,
813 &old,
814 ),
815 };
816 }
817}
818
819fn resolve_worktree_start_commit(
820 repo: &Repository,
821 opts: &AddLinkedWorktreeOptions,
822) -> Result<(String, crate::objects::ObjectId)> {
823 if let Some(WorktreeBranch::Existing(name)) = &opts.branch {
824 if opts.start_point.is_none() {
825 let oid = resolve_existing_branch_tip(repo, name)?;
826 return Ok((name.clone(), oid));
827 }
828 }
829 let start_spec = opts.start_point.as_deref().unwrap_or("HEAD").to_owned();
830 let start_oid = resolve_revision(repo, &start_spec)?;
831 Ok((start_spec, start_oid))
832}
833
834fn plan_orphan_worktree_head(repo: &Repository, name: &str) -> Result<WorktreeHeadPlan> {
836 validate_branch_name(name)?;
837 let branch_ref = format!("refs/heads/{name}");
838 if repo.references().read_existing(&branch_ref)?.is_some() {
839 return Err(BranchError::AlreadyExists {
840 name: name.to_owned(),
841 }
842 .into());
843 }
844 Ok(WorktreeHeadPlan {
845 head_ref: Some(branch_ref),
846 branch_write: None,
847 track_branch: None,
848 })
849}
850
851fn plan_worktree_head(
852 repo: &Repository,
853 opts: &AddLinkedWorktreeOptions,
854 wt_path: &Path,
855 start_spec: &str,
856 start_oid: crate::objects::ObjectId,
857) -> Result<WorktreeHeadPlan> {
858 match &opts.branch {
859 Some(WorktreeBranch::Orphan(name)) => plan_orphan_worktree_head(repo, name),
860 Some(WorktreeBranch::Detach) => Ok(WorktreeHeadPlan {
861 head_ref: None,
862 branch_write: None,
863 track_branch: None,
864 }),
865 Some(WorktreeBranch::Existing(name)) => {
866 let branch_ref = prepare_existing_branch(repo, name, start_oid)?;
867 Ok(WorktreeHeadPlan {
868 head_ref: Some(branch_ref),
869 branch_write: None,
870 track_branch: None,
871 })
872 }
873 Some(WorktreeBranch::Create(name)) => Ok(WorktreeHeadPlan {
874 head_ref: Some(format!("refs/heads/{name}")),
875 branch_write: Some(BranchWritePlan {
876 branch_short: name.clone(),
877 start_spec: start_spec.to_owned(),
878 start_oid,
879 force_reset: false,
880 }),
881 track_branch: if opts.track { Some(name.clone()) } else { None },
882 }),
883 Some(WorktreeBranch::CreateOrReset(name)) => Ok(WorktreeHeadPlan {
884 head_ref: Some(format!("refs/heads/{name}")),
885 branch_write: Some(BranchWritePlan {
886 branch_short: name.clone(),
887 start_spec: start_spec.to_owned(),
888 start_oid,
889 force_reset: true,
890 }),
891 track_branch: if opts.track { Some(name.clone()) } else { None },
892 }),
893 None => {
894 if let Some(start) = opts.start_point.as_deref() {
895 if let Some(branch_ref) = resolve_existing_branch(repo, start) {
896 let tip = repo.resolve_ref_oid(&branch_ref)?;
897 if tip != start_oid {
898 return Err(WorktreeError::BranchTipMismatch {
899 branch: branch_ref
900 .strip_prefix("refs/heads/")
901 .unwrap_or(&branch_ref)
902 .to_owned(),
903 }
904 .into());
905 }
906 return Ok(WorktreeHeadPlan {
907 head_ref: Some(branch_ref),
908 branch_write: None,
909 track_branch: None,
910 });
911 }
912 return Ok(WorktreeHeadPlan {
913 head_ref: None,
914 branch_write: None,
915 track_branch: None,
916 });
917 }
918 let branch = worktree_path_basename(wt_path);
919 validate_branch_name(&branch)?;
920 Ok(WorktreeHeadPlan {
921 head_ref: Some(format!("refs/heads/{branch}")),
922 branch_write: Some(BranchWritePlan {
923 branch_short: branch.clone(),
924 start_spec: start_spec.to_owned(),
925 start_oid,
926 force_reset: false,
927 }),
928 track_branch: if opts.track { Some(branch) } else { None },
929 })
930 }
931 }
932}
933
934fn prepare_existing_branch(
935 repo: &Repository,
936 name: &str,
937 start_oid: crate::objects::ObjectId,
938) -> Result<String> {
939 let tip = resolve_existing_branch_tip(repo, name)?;
940 if tip != start_oid {
941 return Err(WorktreeError::BranchTipMismatch {
942 branch: name.to_owned(),
943 }
944 .into());
945 }
946 Ok(format!("refs/heads/{name}"))
947}
948
949fn validate_branch_name(name: &str) -> Result<()> {
950 validate_branch_short_name(name).map_err(|reason| {
951 BranchError::InvalidName {
952 name: name.to_owned(),
953 reason: reason.to_string(),
954 }
955 .into()
956 })
957}
958
959fn resolve_existing_branch_tip(repo: &Repository, name: &str) -> Result<crate::objects::ObjectId> {
960 validate_branch_name(name)?;
961 repo.resolve_ref_oid(&format!("refs/heads/{name}"))
962 .map_err(|_| {
963 BranchError::NotFound {
964 name: name.to_owned(),
965 }
966 .into()
967 })
968}
969
970fn create_worktree_branch(
971 repo: &Repository,
972 name: &str,
973 start_spec: &str,
974 start_oid: crate::objects::ObjectId,
975 force_reset: bool,
976) -> Result<String> {
977 validate_branch_name(name)?;
978 let branch_ref = format!("refs/heads/{name}");
979 let existing = repo.references().read_existing(&branch_ref)?;
980 if existing.is_some() && !force_reset {
981 return Err(BranchError::AlreadyExists {
982 name: name.to_owned(),
983 }
984 .into());
985 }
986 if let Some(existing_ref) = existing {
987 ensure_branch_not_checked_out_in_any_worktree(repo, name)?;
988 reset_branch_ref_with_reflog(repo, &branch_ref, start_oid, start_spec, &existing_ref)?;
989 } else {
990 create_branch_ref_with_reflog(repo, &branch_ref, start_oid, start_spec)?;
991 }
992 Ok(branch_ref)
993}
994
995fn maybe_track_new_branch(
996 repo: &Repository,
997 opts: &AddLinkedWorktreeOptions,
998 branch_short: &str,
999 start_spec: &str,
1000 start_oid: crate::objects::ObjectId,
1001) -> Result<()> {
1002 if !opts.track {
1003 return Ok(());
1004 }
1005 let mode = match opts.start_point.as_deref() {
1006 Some(s) if s != "HEAD" => TrackMode::Always,
1007 _ => TrackMode::Auto,
1008 };
1009 match resolve_create_tracking(repo, branch_short, start_spec, start_oid, mode) {
1010 Ok(Some((remote, merge))) => {
1011 write_branch_upstream_config(repo, branch_short, &remote, &merge)
1012 }
1013 Ok(None) => Ok(()),
1014 Err(Error::Branch(BranchError::UpstreamNotTracking { .. })) => Ok(()),
1015 Err(err) => Err(err),
1016 }
1017}
1018
1019fn linked_worktree_entry(repo: &Repository, admin: &Path) -> Result<WorktreeEntry> {
1020 let wt_path = read_worktree_path(admin)?;
1021 let wt_head = resolve_linked_worktree_head(repo, admin);
1022 let (is_locked, lock_reason) = read_lock_state(admin)?;
1023 let (is_prunable, prunable_reason) = linked_worktree_prunable_state(admin, is_locked, &wt_path);
1024 Ok(WorktreeEntry {
1025 path: wt_path,
1026 head: wt_head,
1027 is_bare: false,
1028 is_locked,
1029 lock_reason,
1030 admin_dir: admin.to_path_buf(),
1031 is_prunable,
1032 prunable_reason,
1033 is_current: admin_dirs_match(admin, &worktree_admin_dir(repo)),
1034 })
1035}
1036
1037fn linked_worktree_prunable_state(
1039 admin: &Path,
1040 is_locked: bool,
1041 wt_path: &Path,
1042) -> (bool, Option<PrunableReason>) {
1043 if is_locked {
1044 return (false, None);
1045 }
1046 if admin.join("prunable").is_file() {
1047 return (true, Some(PrunableReason::ExplicitMarker));
1048 }
1049 if linked_worktree_gitfile(admin).map_or_else(|| wt_path.exists(), |gitfile| gitfile.exists()) {
1052 return (false, None);
1053 }
1054 (
1055 true,
1056 Some(linked_worktree_prunable_reason_when_missing(admin)),
1057 )
1058}
1059
1060fn linked_worktree_gitfile(admin: &Path) -> Option<PathBuf> {
1062 let raw = fs::read_to_string(admin.join("gitdir")).ok()?;
1063 let trimmed = raw.trim();
1064 if trimmed.is_empty() {
1065 return None;
1066 }
1067 let gitfile = PathBuf::from(trimmed);
1068 Some(if gitfile.is_relative() {
1069 admin.join(gitfile)
1070 } else {
1071 gitfile
1072 })
1073}
1074
1075fn linked_worktree_prunable_reason_when_missing(admin: &Path) -> PrunableReason {
1076 if !admin.is_dir() {
1077 return PrunableReason::AdminNotValidDirectory;
1078 }
1079 let gitdir_file = admin.join("gitdir");
1080 if !gitdir_file.is_file() {
1081 return PrunableReason::GitdirFileMissing;
1082 }
1083 let raw = fs::read_to_string(&gitdir_file).unwrap_or_default();
1084 let trimmed = raw.trim();
1085 if trimmed.is_empty() {
1086 return PrunableReason::InvalidGitdirFile;
1087 }
1088 let mut dotgit = PathBuf::from(trimmed);
1089 if dotgit.is_relative() {
1090 dotgit = admin.join(dotgit);
1091 }
1092 let dotgit = path_for_disk_compare(&dotgit);
1093 if !dotgit.is_file() {
1094 return PrunableReason::GitdirPointsElsewhere;
1095 }
1096 PrunableReason::WorktreePathMissing
1097}
1098
1099pub fn resolve_linked_worktree_add_path(path: &Path, relative_to: &Path) -> Result<PathBuf> {
1109 if path.exists() {
1110 return Ok(path_for_disk_compare(path));
1111 }
1112 let absolute = if path.is_absolute() {
1113 path.to_path_buf()
1114 } else {
1115 relative_to.join(path)
1116 };
1117 if let Some(parent) = absolute.parent().filter(|p| p.exists()) {
1118 let Some(name) = absolute.file_name() else {
1119 return Err(WorktreeError::NoFileName { path: absolute }.into());
1120 };
1121 let mut resolved = path_for_disk_compare(parent);
1122 resolved.push(name);
1123 return Ok(resolved);
1124 }
1125 Ok(absolute)
1126}
1127
1128struct WorktreeAdminAddPlan {
1129 admin_dir: PathBuf,
1130 stale_admins: Option<Vec<PathBuf>>,
1131}
1132
1133struct StaleWorktreeAdminBackup {
1135 moved_aside: Vec<(PathBuf, PathBuf)>,
1136 new_admin: Option<PathBuf>,
1137 replacement_worktree: Option<(PathBuf, bool)>,
1138}
1139
1140impl StaleWorktreeAdminBackup {
1141 fn remove_and_backup(stale_admins: Vec<PathBuf>) -> Result<Self> {
1142 let mut moved_aside = Vec::new();
1143 for (i, admin) in stale_admins.into_iter().enumerate() {
1144 let id = admin
1145 .file_name()
1146 .map(|n| n.to_string_lossy().into_owned())
1147 .unwrap_or_else(|| "wt".to_owned());
1148 let aside = admin
1149 .parent()
1150 .ok_or_else(|| WorktreeError::InvalidAdminDir {
1151 path: admin.clone(),
1152 })?
1153 .join(format!(".{id}-stale-backup-{i}"));
1154 if aside.exists() {
1155 fs::remove_dir_all(&aside).map_err(Error::Io)?;
1156 }
1157 fs::rename(&admin, &aside).map_err(Error::Io)?;
1158 moved_aside.push((admin, aside));
1159 }
1160 Ok(Self {
1161 moved_aside,
1162 new_admin: None,
1163 replacement_worktree: None,
1164 })
1165 }
1166
1167 fn set_new_admin(&mut self, admin: &Path) {
1168 self.new_admin = Some(admin.to_path_buf());
1169 }
1170
1171 fn track_replacement_worktree(&mut self, path: &Path, absent_before_replacement: bool) {
1172 self.replacement_worktree = Some((path.to_path_buf(), absent_before_replacement));
1173 }
1174
1175 fn commit(mut self) {
1176 for (_, aside) in self.moved_aside.drain(..) {
1177 let _ = fs::remove_dir_all(aside);
1178 }
1179 }
1180}
1181
1182impl Drop for StaleWorktreeAdminBackup {
1183 fn drop(&mut self) {
1184 if self.moved_aside.is_empty() {
1185 return;
1186 }
1187 if let Some((wt_path, absent_before)) = self.replacement_worktree.take() {
1188 if absent_before {
1189 let _ = fs::remove_dir_all(&wt_path);
1190 } else if wt_path.join(".git").is_file() {
1191 let _ = fs::remove_file(wt_path.join(".git"));
1192 }
1193 }
1194 if let Some(new_admin) = self.new_admin.take() {
1195 let _ = fs::remove_dir_all(new_admin);
1196 }
1197 for (admin, aside) in &self.moved_aside {
1198 let _ = fs::remove_dir_all(admin);
1199 let _ = fs::rename(aside, admin);
1200 }
1201 }
1202}
1203
1204fn linked_admins_for_worktree_path(common: &Path, wt_path: &Path) -> Result<Vec<PathBuf>> {
1205 let target = path_for_disk_compare(wt_path);
1206 let worktrees_dir = common.join("worktrees");
1207 if !worktrees_dir.is_dir() {
1208 return Ok(Vec::new());
1209 }
1210 let mut admins = Vec::new();
1211 for entry in fs::read_dir(&worktrees_dir).map_err(Error::Io)? {
1212 let entry = entry.map_err(Error::Io)?;
1213 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1214 continue;
1215 }
1216 let admin = entry.path();
1217 let registered = read_worktree_path(&admin)?;
1218 if path_for_disk_compare(®istered) == target {
1219 admins.push(admin);
1220 }
1221 }
1222 Ok(admins)
1223}
1224
1225fn plan_admin_dir_for_add(
1226 common: &Path,
1227 wt_path: &Path,
1228 force: bool,
1229) -> Result<WorktreeAdminAddPlan> {
1230 let existing = linked_admins_for_worktree_path(common, wt_path)?;
1231 if existing.is_empty() {
1232 return Ok(WorktreeAdminAddPlan {
1233 admin_dir: allocate_worktree_admin_dir(common, wt_path),
1234 stale_admins: None,
1235 });
1236 }
1237
1238 if wt_path.exists() {
1239 return Err(WorktreeError::PathRegistered {
1240 path: wt_path.to_path_buf(),
1241 }
1242 .into());
1243 }
1244 if !force {
1245 return Err(WorktreeError::PathRegisteredMissing {
1246 path: wt_path.to_path_buf(),
1247 }
1248 .into());
1249 }
1250
1251 let worktrees_dir = common.join("worktrees");
1252 let base = sanitize_worktree_id_component(&worktree_path_basename(wt_path));
1253 let base = if base.is_empty() {
1254 "worktree".to_owned()
1255 } else {
1256 base
1257 };
1258 let preferred = worktrees_dir.join(&base);
1259 let admin_dir = existing
1260 .iter()
1261 .find(|admin| {
1262 admin
1263 .file_name()
1264 .is_some_and(|name| name.to_string_lossy() == base)
1265 })
1266 .cloned()
1267 .unwrap_or_else(|| {
1268 if preferred.exists() {
1269 allocate_worktree_admin_dir(common, wt_path)
1270 } else {
1271 preferred
1272 }
1273 });
1274 Ok(WorktreeAdminAddPlan {
1275 admin_dir,
1276 stale_admins: Some(existing),
1277 })
1278}
1279
1280pub fn remove_linked_worktree(repo: &Repository, path: &Path, force: bool) -> Result<()> {
1288 let entry = find_linked_worktree(repo, path, WorktreeOperation::Remove)?;
1289 if entry.is_locked && !force {
1290 return Err(WorktreeError::Locked {
1291 reason: entry.lock_reason,
1292 }
1293 .into());
1294 }
1295
1296 let linked = repo.open_linked_worktree(&entry.admin_dir, &entry.path)?;
1297 if !force && worktree_has_local_changes(&linked)? {
1298 return Err(WorktreeError::HasLocalChanges.into());
1299 }
1300
1301 fs::remove_dir_all(&entry.admin_dir).map_err(Error::Io)?;
1302 if entry.path.exists() {
1303 fs::remove_dir_all(&entry.path).map_err(Error::Io)?;
1304 }
1305 Ok(())
1306}
1307
1308pub(crate) fn find_linked_worktree(
1315 repo: &Repository,
1316 path: &Path,
1317 operation: WorktreeOperation,
1318) -> Result<WorktreeEntry> {
1319 let target = path_for_disk_compare(path);
1320 let Some(entry) = list_worktrees(repo)?.into_iter().find(|e| e.path == target) else {
1321 return Err(WorktreeError::NotRegistered { path: target }.into());
1322 };
1323 if entry.admin_dir == common_git_dir(repo.git_dir()) {
1324 return Err(WorktreeError::MainWorktree { operation }.into());
1325 }
1326 Ok(entry)
1327}
1328
1329pub(crate) fn lock_linked_worktree(
1336 repo: &Repository,
1337 path: &Path,
1338 reason: Option<&str>,
1339) -> Result<()> {
1340 let entry = find_linked_worktree(repo, path, WorktreeOperation::Lock)?;
1341 if entry.is_locked {
1342 return Err(WorktreeError::AlreadyLocked {
1343 reason: entry.lock_reason,
1344 }
1345 .into());
1346 }
1347 write_admin_lock_file(&entry.admin_dir, reason)
1348}
1349
1350pub(crate) fn unlock_linked_worktree(repo: &Repository, path: &Path) -> Result<()> {
1357 let entry = find_linked_worktree(repo, path, WorktreeOperation::Unlock)?;
1358 if !entry.is_locked {
1359 return Err(WorktreeError::NotLocked.into());
1360 }
1361 fs::remove_file(entry.admin_dir.join("locked")).map_err(Error::Io)
1362}
1363
1364fn resolve_existing_branch(repo: &Repository, spec: &str) -> Option<String> {
1365 let full = if spec.starts_with("refs/heads/") {
1366 spec.to_owned()
1367 } else {
1368 format!("refs/heads/{spec}")
1369 };
1370 repo.resolve_ref_oid(&full).ok()?;
1371 Some(full)
1372}
1373
1374fn ensure_empty_or_missing(path: &Path) -> Result<()> {
1375 if !path.exists() {
1376 return Ok(());
1377 }
1378 if !path.is_dir() {
1379 return Err(WorktreeError::PathExistsNotDirectory {
1380 path: path.to_path_buf(),
1381 }
1382 .into());
1383 }
1384 let mut entries = fs::read_dir(path).map_err(Error::Io)?;
1385 if entries.next().is_some() {
1386 return Err(WorktreeError::PathExists {
1387 path: path.to_path_buf(),
1388 }
1389 .into());
1390 }
1391 Ok(())
1392}
1393
1394fn worktree_has_local_changes(repo: &Repository) -> Result<bool> {
1395 let model = status(
1396 repo,
1397 &StatusOptions::default(),
1398 &mut NullProgress,
1399 &crate::progress::NeverCancel,
1400 )?;
1401 Ok(!model.staged.is_empty()
1402 || !model.unstaged.is_empty()
1403 || !model.conflicts.is_empty()
1404 || !model.untracked.is_empty())
1405}
1406
1407pub fn read_worktree_path(admin: &Path) -> Result<PathBuf> {
1409 let gitdir_path = admin.join("gitdir");
1410 if !gitdir_path.is_file() {
1411 return Ok(admin.to_path_buf());
1412 }
1413 let raw = fs::read_to_string(&gitdir_path).map_err(Error::Io)?;
1414 let mut p = PathBuf::from(raw.trim());
1415 if p.is_relative() {
1416 p = admin.join(p);
1417 }
1418 let parent = p.parent().unwrap_or(&p).to_path_buf();
1419 Ok(path_for_disk_compare(&parent))
1420}
1421
1422fn read_lock_state(admin: &Path) -> Result<(bool, Option<String>)> {
1423 let locked_file = admin.join("locked");
1424 if !locked_file.is_file() {
1425 return Ok((false, None));
1426 }
1427 let content = fs::read_to_string(&locked_file).map_err(Error::Io)?;
1428 let reason = content.trim();
1429 if reason.is_empty() {
1430 Ok((true, None))
1431 } else {
1432 Ok((true, Some(reason.to_owned())))
1433 }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438 use super::*;
1439 use crate::git_path::CanonicalizeExt;
1440 use crate::repo::Repository;
1441 use std::fs;
1442 use tempfile::TempDir;
1443
1444 #[test]
1445 fn list_main_worktree_only() {
1446 let tmp = TempDir::new().unwrap();
1447 let root = tmp.path().join("repo");
1448 fs::create_dir_all(root.join(".git")).unwrap();
1449 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
1450 fs::create_dir_all(root.join(".git/objects")).unwrap();
1451 fs::write(
1452 root.join(".git/config"),
1453 "[core]\n\trepositoryformatversion = 0\n",
1454 )
1455 .unwrap();
1456
1457 let repo = Repository::open(
1458 &root.join(".git"),
1459 &RepositoryOptions::empty().with_work_tree(&root),
1460 )
1461 .unwrap();
1462 let list = list_worktrees(&repo).unwrap();
1463 assert_eq!(list.len(), 1);
1464 assert_eq!(list[0].path, path_for_disk_compare(&root));
1465 assert!(!list[0].is_bare);
1466 }
1467
1468 #[cfg_attr(
1469 not(feature = "full-tests"),
1470 ignore = "starts system git or is heavy; run with --features full-tests"
1471 )]
1472 #[test]
1473 fn a_branch_being_rebased_or_bisected_in_a_worktree_is_checked_out_there() {
1474 let tmp = TempDir::new().unwrap();
1475 let main = tmp.path().join("main");
1476 fs::create_dir_all(&main).unwrap();
1477 let git = |dir: &Path, args: &[&str]| grit_test_support::git(dir, args);
1478 git(&main, &["init", "-q", "-b", "main"]);
1479 fs::write(main.join("f"), "base\n").unwrap();
1480 git(&main, &["add", "f"]);
1481 git(&main, &["commit", "-q", "-m", "base"]);
1482 git(&main, &["branch", "feature"]);
1483 git(&main, &["branch", "other"]);
1484 for n in 1..=3 {
1485 fs::write(main.join("f"), format!("main {n}\n")).unwrap();
1486 git(&main, &["commit", "-q", "-am", "main"]);
1487 }
1488 git(&main, &["switch", "-q", "feature"]);
1489 fs::write(main.join("f"), "feature\n").unwrap();
1490 git(&main, &["commit", "-q", "-am", "feature"]);
1491 git(&main, &["switch", "-q", "main"]);
1492 let linked = tmp.path().join("linked");
1493 git(
1494 &main,
1495 &["worktree", "add", "-q", linked.to_str().unwrap(), "feature"],
1496 );
1497 let rebase = grit_test_support::git_cmd(&["rebase", "main"])
1499 .in_dir(&linked)
1500 .exec();
1501 assert!(!rebase.ok(), "the rebase stops on a conflict");
1502 let repo = Repository::discover(Some(&main), &RepositoryOptions::empty()).unwrap();
1503 let found = find_worktree_with_branch_checked_out(&repo, "feature", None)
1504 .unwrap()
1505 .expect("the rebase holds feature");
1506 assert_eq!(found.worktree_path, path_for_disk_compare(&linked));
1507 assert!(matches!(
1508 ensure_branch_not_checked_out_in_any_worktree(&repo, "feature"),
1509 Err(Error::BranchCheckedOutInWorktree { .. })
1510 ));
1511 git(&linked, &["rebase", "--abort"]);
1512
1513 git(&linked, &["switch", "-q", "other"]);
1515 git(&linked, &["bisect", "start", "main", "other"]);
1516 let head = list_worktrees(&repo).unwrap().remove(1).head;
1517 assert!(matches!(head, HeadState::Detached { .. }), "{head:?}");
1518 assert!(find_worktree_with_branch_checked_out(&repo, "other", None)
1519 .unwrap()
1520 .is_some());
1521 git(&linked, &["bisect", "reset"]);
1522 git(&linked, &["switch", "-q", "--detach", "HEAD"]);
1523 assert_eq!(
1524 find_worktree_with_branch_checked_out(&repo, "other", None).unwrap(),
1525 None,
1526 "a plain detached HEAD holds nothing"
1527 );
1528 assert_eq!(
1529 find_worktree_with_branch_checked_out(&repo, "feature", None).unwrap(),
1530 None
1531 );
1532 }
1533
1534 #[cfg_attr(
1535 not(feature = "full-tests"),
1536 ignore = "starts system git or is heavy; run with --features full-tests"
1537 )]
1538 #[test]
1539 fn allocate_unique_worktree_id() {
1540 let tmp = TempDir::new().unwrap();
1541 let common = tmp.path().join("git");
1542 fs::create_dir_all(common.join("worktrees/here")).unwrap();
1543 let admin = allocate_worktree_admin_dir(&common, Path::new("/tmp/sub/here"));
1544 assert_eq!(admin, common.join("worktrees/here1"));
1545 }
1546
1547 #[test]
1548 fn strip_worktree_config_removes_core_bare_and_worktree() {
1549 let tmp = TempDir::new().unwrap();
1550 let path = tmp.path().join("config.worktree");
1551 fs::write(
1552 &path,
1553 "[core]\n\tbare = true\n\tworktree = /wt\n[bogus]\n\tkey = value\n",
1554 )
1555 .unwrap();
1556 strip_worktree_config_keys(&path, &["core.bare", "core.worktree"]).unwrap();
1557 let out = fs::read_to_string(&path).unwrap();
1558 assert!(out.contains("bogus"));
1559 assert!(!out.contains("bare"));
1560 assert!(!out.contains("worktree"));
1561 }
1562
1563 #[cfg_attr(
1564 not(feature = "full-tests"),
1565 ignore = "starts system git or is heavy; run with --features full-tests"
1566 )]
1567 #[test]
1568 fn sanitize_funny_worktree_name() {
1569 assert_eq!(
1570 sanitize_worktree_id_component(". weird*..?.lock.lock"),
1571 "---weird-.-"
1572 );
1573 }
1574
1575 #[test]
1576 fn read_worktree_path_from_gitdir_file() {
1577 let tmp = TempDir::new().unwrap();
1578 let admin = tmp.path().join("wt-admin");
1579 fs::create_dir_all(&admin).unwrap();
1580 let wt = tmp.path().join("linked");
1581 fs::create_dir_all(wt.join(".git")).unwrap();
1582 fs::write(
1583 admin.join("gitdir"),
1584 format!("{}\n", wt.join(".git").display()),
1585 )
1586 .unwrap();
1587 let path = read_worktree_path(&admin).unwrap();
1588 assert_eq!(path, path_for_disk_compare(&wt));
1589 }
1590
1591 #[cfg_attr(
1592 not(feature = "full-tests"),
1593 ignore = "starts system git or is heavy; run with --features full-tests"
1594 )]
1595 #[test]
1596 fn add_and_remove_linked_worktree_via_library() {
1597 use std::process::Command;
1598 let tmp = TempDir::new().unwrap();
1599 let main = tmp.path().join("main");
1600 fs::create_dir_all(&main).unwrap();
1601 let git = |args: &[&str]| {
1602 let out = Command::new("git")
1603 .env("GIT_AUTHOR_NAME", "Test")
1604 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1605 .env("GIT_COMMITTER_NAME", "Test")
1606 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1607 .current_dir(&main)
1608 .args(args)
1609 .output()
1610 .unwrap();
1611 assert!(out.status.success(), "git {args:?}");
1612 };
1613 git(&["init", "-q", "-b", "main"]);
1614 fs::write(main.join("t"), b"1\n").unwrap();
1615 git(&["add", "t"]);
1616 git(&["commit", "-qm", "init"]);
1617
1618 let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1619 let linked = tmp.path().join("linked");
1620 add_linked_worktree(
1621 &repo,
1622 &AddLinkedWorktreeOptions {
1623 path: linked.clone(),
1624 branch: Some(WorktreeBranch::Create("wt".into())),
1625 start_point: Some("HEAD".into()),
1626 force: false,
1627 lock: None,
1628 no_checkout: false,
1629 track: false,
1630 },
1631 )
1632 .unwrap();
1633 assert_eq!(list_worktrees(&repo).unwrap().len(), 2);
1634 remove_linked_worktree(&repo, &linked, false).unwrap();
1635 assert_eq!(list_worktrees(&repo).unwrap().len(), 1);
1636 }
1637
1638 #[test]
1639 fn registered_worktree_count_and_basename() {
1640 let tmp = TempDir::new().unwrap();
1641 let common = tmp.path().join("git");
1642 fs::create_dir_all(common.join("worktrees/a")).unwrap();
1643 fs::create_dir_all(common.join("worktrees/b")).unwrap();
1644 assert_eq!(registered_worktree_count(&common), 3);
1645 assert_eq!(
1646 worktree_path_basename(Path::new("/tmp/my/project/")),
1647 "project"
1648 );
1649 }
1650
1651 #[test]
1652 fn registered_worktree_count_without_worktrees_dir() {
1653 let tmp = TempDir::new().unwrap();
1654 let common = tmp.path().join("only-main");
1655 fs::create_dir_all(&common).unwrap();
1656 assert_eq!(registered_worktree_count(&common), 1);
1657 }
1658
1659 #[test]
1660 fn common_git_dir_and_bare_detection() {
1661 let tmp = TempDir::new().unwrap();
1662 let git_dir = tmp.path().join("bare.git");
1663 fs::create_dir_all(&git_dir).unwrap();
1664 fs::write(
1665 git_dir.join("config"),
1666 "[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
1667 )
1668 .unwrap();
1669 assert!(is_bare_repository(&git_dir));
1670 assert_eq!(common_git_dir(&git_dir), git_dir);
1671 }
1672
1673 #[cfg_attr(
1674 not(feature = "full-tests"),
1675 ignore = "starts system git or is heavy; run with --features full-tests"
1676 )]
1677 #[test]
1678 fn sanitize_worktree_id_special_cases() {
1679 assert_eq!(sanitize_worktree_id_component("@"), "-");
1680 assert!(!sanitize_worktree_id_component("@{upstream}").contains('@'));
1681 assert_eq!(sanitize_worktree_id_component("..name"), ".name");
1682 assert_eq!(sanitize_worktree_id_component(".lock.lock"), "-lock");
1683 let tmp = TempDir::new().unwrap();
1684 let common = tmp.path().join("git");
1685 fs::create_dir_all(&common).unwrap();
1686 assert_eq!(
1687 allocate_worktree_admin_dir(&common, Path::new("/")),
1688 common.join("worktrees/worktree")
1689 );
1690 }
1691
1692 #[test]
1693 fn copy_filtered_worktree_config_no_source_is_ok() {
1694 let tmp = TempDir::new().unwrap();
1695 let admin = tmp.path().join("admin");
1696 fs::create_dir_all(&admin).unwrap();
1697 copy_filtered_worktree_config(tmp.path(), &admin).unwrap();
1698 assert!(!admin.join("config.worktree").exists());
1699 }
1700
1701 #[test]
1702 fn copy_filtered_worktree_config_copies_file() {
1703 let tmp = TempDir::new().unwrap();
1704 let common = tmp.path().join("common");
1705 let admin = tmp.path().join("admin");
1706 fs::create_dir_all(&common).unwrap();
1707 fs::create_dir_all(&admin).unwrap();
1708 fs::write(
1709 common.join("config.worktree"),
1710 "[core]\n\tbare = false\n\tworktree = /x\n[ext]\n\tk = v\n",
1711 )
1712 .unwrap();
1713 copy_filtered_worktree_config(&common, &admin).unwrap();
1714 let out = fs::read_to_string(admin.join("config.worktree")).unwrap();
1715 assert!(out.contains("[ext]"));
1716 assert!(!out.contains("worktree"));
1717 }
1718
1719 #[test]
1720 fn read_lock_state_and_missing_gitdir_path() {
1721 let tmp = TempDir::new().unwrap();
1722 let admin = tmp.path().join("admin");
1723 fs::create_dir_all(&admin).unwrap();
1724 assert_eq!(read_worktree_path(&admin).unwrap(), admin);
1725 fs::write(admin.join("locked"), "maintenance\n").unwrap();
1726 let (locked, reason) = read_lock_state(&admin).unwrap();
1727 assert!(locked);
1728 assert_eq!(reason.as_deref(), Some("maintenance"));
1729 }
1730
1731 #[test]
1732 fn ensure_empty_or_missing_rejects_nonempty_dir() {
1733 let tmp = TempDir::new().unwrap();
1734 let dir = tmp.path().join("occupied");
1735 fs::create_dir_all(&dir).unwrap();
1736 fs::write(dir.join("file"), b"x").unwrap();
1737 assert!(matches!(
1738 ensure_empty_or_missing(&dir),
1739 Err(Error::Worktree(WorktreeError::PathExists { path })) if path == dir
1740 ));
1741 assert!(matches!(
1742 ensure_empty_or_missing(&dir.join("file")),
1743 Err(Error::Worktree(
1744 WorktreeError::PathExistsNotDirectory { .. }
1745 ))
1746 ));
1747 let missing = tmp.path().join("missing");
1748 assert!(ensure_empty_or_missing(&missing).is_ok());
1749 }
1750
1751 #[cfg_attr(
1752 not(feature = "full-tests"),
1753 ignore = "starts system git or is heavy; run with --features full-tests"
1754 )]
1755 #[test]
1756 fn add_linked_worktree_to_bare_repository() {
1757 let tmp = TempDir::new().unwrap();
1758 let bare = tmp.path().join("bare.git");
1759 fs::create_dir_all(bare.join("objects")).unwrap();
1760 fs::write(bare.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1761 fs::write(
1762 bare.join("config"),
1763 "[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
1764 )
1765 .unwrap();
1766 let repo = Repository::open(&bare, &RepositoryOptions::empty()).unwrap();
1767 let wt = tmp.path().join("wt");
1768 let entry = add_linked_worktree(
1769 &repo,
1770 &AddLinkedWorktreeOptions {
1771 path: wt.clone(),
1772 branch: Some(WorktreeBranch::Orphan("wt".into())),
1773 start_point: None,
1774 force: false,
1775 lock: None,
1776 no_checkout: false,
1777 track: false,
1778 },
1779 )
1780 .unwrap();
1781 assert_eq!(
1782 entry.admin_dir,
1783 fs::canonicalize(&bare)
1784 .unwrap()
1785 .join("worktrees")
1786 .join("wt")
1787 );
1788 assert_eq!(
1789 fs::read_to_string(entry.admin_dir.join("HEAD")).unwrap(),
1790 "ref: refs/heads/wt\n"
1791 );
1792 assert_eq!(
1793 fs::read_to_string(entry.admin_dir.join("commondir")).unwrap(),
1794 "../..\n"
1795 );
1796 assert!(wt.join(".git").is_file());
1797 }
1798
1799 #[cfg_attr(
1800 not(feature = "full-tests"),
1801 ignore = "starts system git or is heavy; run with --features full-tests"
1802 )]
1803 #[test]
1804 fn list_worktrees_from_linked_checkout_normalizes_main_path() {
1805 use std::process::Command;
1806 let tmp = TempDir::new().unwrap();
1807 let main = tmp.path().join("main");
1808 fs::create_dir_all(&main).unwrap();
1809 let git = |args: &[&str]| {
1810 assert!(Command::new("git")
1811 .env("GIT_AUTHOR_NAME", "Test")
1812 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1813 .env("GIT_COMMITTER_NAME", "Test")
1814 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1815 .current_dir(&main)
1816 .args(args)
1817 .output()
1818 .unwrap()
1819 .status
1820 .success());
1821 };
1822 git(&["init", "-q", "-b", "main"]);
1823 fs::write(main.join("t"), b"1\n").unwrap();
1824 git(&["add", "t"]);
1825 git(&["commit", "-qm", "init"]);
1826
1827 let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1828 let linked = tmp.path().join("linked");
1829 add_linked_worktree(
1830 &repo,
1831 &AddLinkedWorktreeOptions {
1832 path: linked.clone(),
1833 branch: Some(WorktreeBranch::Create("wt".into())),
1834 start_point: Some("HEAD".into()),
1835 force: false,
1836 lock: None,
1837 no_checkout: false,
1838 track: false,
1839 },
1840 )
1841 .unwrap();
1842
1843 let linked_repo =
1844 Repository::discover(Some(linked.as_path()), &RepositoryOptions::empty()).unwrap();
1845 let list = list_worktrees(&linked_repo).unwrap();
1846 assert_eq!(list.len(), 2);
1847 let expected_main = path_for_disk_compare(&main);
1848 assert_eq!(list[0].path, expected_main);
1849 #[cfg(windows)]
1850 {
1851 let displayed = list[0].path.display().to_string();
1852 assert!(
1853 !displayed.starts_with(r"\\?\"),
1854 "main worktree path must not use the verbatim prefix: {displayed}"
1855 );
1856 }
1857 }
1858
1859 #[cfg_attr(
1860 not(feature = "full-tests"),
1861 ignore = "starts system git or is heavy; run with --features full-tests"
1862 )]
1863 #[test]
1864 fn branch_checkout_exclusivity_helpers() {
1865 use std::process::Command;
1866 let tmp = TempDir::new().unwrap();
1867 let main = tmp.path().join("main");
1868 fs::create_dir_all(&main).unwrap();
1869 let git = |args: &[&str]| {
1870 assert!(Command::new("git")
1871 .env("GIT_AUTHOR_NAME", "Test")
1872 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1873 .env("GIT_COMMITTER_NAME", "Test")
1874 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1875 .current_dir(&main)
1876 .args(args)
1877 .output()
1878 .unwrap()
1879 .status
1880 .success());
1881 };
1882 git(&["init", "-q", "-b", "main"]);
1883 fs::write(main.join("t"), b"1\n").unwrap();
1884 git(&["add", "t"]);
1885 git(&["commit", "-qm", "init"]);
1886
1887 let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1888 let linked = tmp.path().join("linked");
1889 add_linked_worktree(
1890 &repo,
1891 &AddLinkedWorktreeOptions {
1892 path: linked.clone(),
1893 branch: Some(WorktreeBranch::Create("feature".into())),
1894 start_point: Some("HEAD".into()),
1895 force: false,
1896 lock: None,
1897 no_checkout: false,
1898 track: false,
1899 },
1900 )
1901 .unwrap();
1902
1903 let linked_repo =
1904 Repository::discover(Some(linked.as_path()), &RepositoryOptions::empty()).unwrap();
1905 let linked_admin = linked_repo
1906 .git_dir()
1907 .canonicalize_simplified()
1908 .unwrap_or_else(|_| linked_repo.git_dir().to_path_buf());
1909 assert_eq!(worktree_admin_dir(&linked_repo), linked_admin);
1910
1911 let found = find_worktree_with_branch_checked_out(&repo, "feature", None)
1912 .unwrap()
1913 .expect("linked checkout");
1914 assert_eq!(found.branch, "feature");
1915
1916 assert!(ensure_branch_not_checked_out_in_any_worktree(&repo, "feature").is_err());
1917 assert!(ensure_branch_not_checked_out_elsewhere(&linked_repo, "feature").is_ok());
1918 assert!(ensure_branch_not_checked_out_elsewhere(&repo, "feature").is_err());
1919 }
1920
1921 #[cfg_attr(
1922 not(feature = "full-tests"),
1923 ignore = "starts system git or is heavy; run with --features full-tests"
1924 )]
1925 #[test]
1926 fn missing_registered_path_rejects_add_without_force() {
1927 use std::process::Command;
1928 let tmp = TempDir::new().unwrap();
1929 let main = tmp.path().join("main");
1930 fs::create_dir_all(&main).unwrap();
1931 let git = |args: &[&str]| {
1932 assert!(Command::new("git")
1933 .current_dir(&main)
1934 .args(args)
1935 .output()
1936 .unwrap()
1937 .status
1938 .success());
1939 };
1940 git(&["init", "-q", "-b", "main"]);
1941 fs::write(main.join("t"), b"1\n").unwrap();
1942 git(&["add", "t"]);
1943 git(&["commit", "-qm", "init"]);
1944
1945 let repo =
1946 Repository::discover(Some(&main), &crate::environment::RepositoryOptions::empty())
1947 .unwrap();
1948 let linked = tmp.path().join("linked");
1949 add_linked_worktree(
1950 &repo,
1951 &AddLinkedWorktreeOptions {
1952 path: linked.clone(),
1953 branch: Some(WorktreeBranch::Create("a".into())),
1954 start_point: Some("HEAD".into()),
1955 force: false,
1956 lock: None,
1957 no_checkout: false,
1958 track: false,
1959 },
1960 )
1961 .unwrap();
1962 fs::remove_dir_all(&linked).unwrap();
1963 assert!(matches!(
1964 add_linked_worktree(
1965 &repo,
1966 &AddLinkedWorktreeOptions {
1967 path: linked.clone(),
1968 branch: Some(WorktreeBranch::Create("b".into())),
1969 start_point: Some("HEAD".into()),
1970 force: false,
1971 lock: None,
1972 no_checkout: false,
1973 track: false,
1974 },
1975 )
1976 .unwrap_err(),
1977 Error::Worktree(WorktreeError::PathRegisteredMissing { .. })
1978 ));
1979 }
1980
1981 #[cfg_attr(
1982 not(feature = "full-tests"),
1983 ignore = "starts system git or is heavy; run with --features full-tests"
1984 )]
1985 #[test]
1986 fn remove_linked_worktree_refuses_main_and_locked() {
1987 use std::process::Command;
1988 let tmp = TempDir::new().unwrap();
1989 let main = tmp.path().join("main");
1990 fs::create_dir_all(&main).unwrap();
1991 let git = |args: &[&str]| {
1992 assert!(Command::new("git")
1993 .env("GIT_AUTHOR_NAME", "Test")
1994 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1995 .env("GIT_COMMITTER_NAME", "Test")
1996 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1997 .current_dir(&main)
1998 .args(args)
1999 .output()
2000 .unwrap()
2001 .status
2002 .success());
2003 };
2004 git(&["init", "-q", "-b", "main"]);
2005 fs::write(main.join("t"), b"1\n").unwrap();
2006 git(&["add", "t"]);
2007 git(&["commit", "-qm", "init"]);
2008
2009 let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
2010 assert!(matches!(
2011 remove_linked_worktree(&repo, &main, false),
2012 Err(Error::Worktree(WorktreeError::MainWorktree {
2013 operation: WorktreeOperation::Remove
2014 }))
2015 ));
2016
2017 let linked = tmp.path().join("linked");
2018 add_linked_worktree(
2019 &repo,
2020 &AddLinkedWorktreeOptions {
2021 path: linked.clone(),
2022 branch: Some(WorktreeBranch::Create("wt".into())),
2023 start_point: Some("HEAD".into()),
2024 force: false,
2025 lock: None,
2026 no_checkout: false,
2027 track: false,
2028 },
2029 )
2030 .unwrap();
2031 let common = common_git_dir(repo.git_dir());
2032 let admin = fs::read_dir(common.join("worktrees"))
2033 .unwrap()
2034 .find_map(|e| e.ok())
2035 .map(|e| e.path())
2036 .expect("admin");
2037 fs::write(admin.join("locked"), "hold\n").unwrap();
2038 assert!(matches!(
2039 remove_linked_worktree(&repo, &linked, false),
2040 Err(Error::Worktree(WorktreeError::Locked { reason: Some(r) })) if r == "hold"
2041 ));
2042 assert!(remove_linked_worktree(&repo, &linked, true).is_ok());
2043 assert!(matches!(
2044 remove_linked_worktree(&repo, &linked, false),
2045 Err(Error::Worktree(WorktreeError::NotRegistered { .. }))
2046 ));
2047 }
2048}