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::porcelain::branch::{resolve_create_tracking, TrackMode};
18use crate::porcelain::branch_error::BranchError;
19use crate::porcelain::checkout::checkout_between_trees;
20use crate::porcelain::checkout_reflog::{
21 create_branch_ref_with_reflog, reset_branch_ref_with_reflog,
22};
23use crate::porcelain::status::{status, StatusOptions};
24use crate::progress::NullProgress;
25use crate::refs;
26use crate::repo::{common_git_dir_for_config, Repository};
27use crate::rev_parse::resolve_revision;
28use crate::state::{resolve_head, resolve_linked_worktree_head, HeadState};
29
30use super::error::{WorktreeError, WorktreeOperation};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PrunableReason {
35 AdminNotValidDirectory,
37 GitdirFileMissing,
39 GitdirPointsElsewhere,
41 WorktreePathMissing,
43 InvalidGitdirFile,
45 ExplicitMarker,
47}
48
49impl PrunableReason {
50 #[must_use]
52 pub fn porcelain_message(self) -> &'static str {
53 match self {
54 Self::AdminNotValidDirectory => "not a valid directory",
55 Self::GitdirFileMissing => "gitdir file does not exist",
56 Self::GitdirPointsElsewhere => "gitdir file points to non-existent location",
57 Self::WorktreePathMissing => "gitdir file points to non-existent location",
58 Self::InvalidGitdirFile => "invalid gitdir file",
59 Self::ExplicitMarker => "gitdir file points to non-existent location",
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct WorktreeEntry {
67 pub path: PathBuf,
69 pub head: HeadState,
71 pub is_bare: bool,
73 pub is_locked: bool,
75 pub lock_reason: Option<String>,
77 pub admin_dir: PathBuf,
79 pub is_prunable: bool,
81 pub prunable_reason: Option<PrunableReason>,
83 pub is_current: bool,
85}
86
87#[must_use]
89pub fn common_git_dir(git_dir: &Path) -> PathBuf {
90 common_git_dir_for_config(git_dir)
91}
92
93#[must_use]
95pub fn resolve_linked_head(admin: &Path, _common: &Path) -> HeadState {
96 resolve_head(admin).unwrap_or(HeadState::Invalid)
97}
98
99#[must_use]
101pub fn registered_worktree_count(common: &Path) -> usize {
102 let worktrees_dir = common.join("worktrees");
103 if !worktrees_dir.is_dir() {
104 return 1;
105 }
106 let linked = fs::read_dir(&worktrees_dir)
107 .into_iter()
108 .flatten()
109 .flatten()
110 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
111 .count();
112 1 + linked
113}
114
115#[must_use]
117pub fn is_bare_repository(common: &Path) -> bool {
118 ConfigSet::load(
119 &crate::environment::Environment::empty(),
120 Some(common),
121 true,
122 )
123 .ok()
124 .and_then(|cfg| cfg.get_bool("core.bare"))
125 .and_then(|r| r.ok())
126 .unwrap_or_else(|| {
127 !common.ends_with(".git") && common.join("config").is_file()
129 })
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct BranchWorktreeCheckout {
135 pub branch: String,
137 pub worktree_path: PathBuf,
139 pub admin_dir: PathBuf,
141}
142
143#[must_use]
145pub fn worktree_admin_dir(repo: &Repository) -> PathBuf {
146 let common = common_git_dir(repo.git_dir());
147 if repo.git_dir().starts_with(common.join("worktrees")) {
148 repo.git_dir().to_path_buf()
149 } else {
150 common
151 }
152}
153
154fn head_is_branch(head: &HeadState, branch_short_name: &str) -> bool {
155 match head {
156 HeadState::Branch {
157 short_name,
158 refname,
159 ..
160 } => {
161 short_name == branch_short_name || refname == &format!("refs/heads/{branch_short_name}")
162 }
163 _ => false,
164 }
165}
166
167pub(crate) fn admin_dirs_match(a: &Path, b: &Path) -> bool {
168 path_for_disk_compare(a) == path_for_disk_compare(b) || a == b
169}
170
171pub fn find_worktree_with_branch_checked_out(
176 repo: &Repository,
177 branch_short_name: &str,
178 exclude_admin_dir: Option<&Path>,
179) -> Result<Option<BranchWorktreeCheckout>> {
180 for entry in list_worktrees(repo)? {
181 if exclude_admin_dir.is_some_and(|ex| admin_dirs_match(&entry.admin_dir, ex)) {
182 continue;
183 }
184 if head_is_branch(&entry.head, branch_short_name) {
185 return Ok(Some(BranchWorktreeCheckout {
186 branch: branch_short_name.to_owned(),
187 worktree_path: entry.path.clone(),
188 admin_dir: entry.admin_dir.clone(),
189 }));
190 }
191 }
192 Ok(None)
193}
194
195pub fn ensure_branch_not_checked_out_elsewhere(
201 repo: &Repository,
202 branch_short_name: &str,
203) -> Result<()> {
204 let exclude = worktree_admin_dir(repo);
205 if let Some(found) =
206 find_worktree_with_branch_checked_out(repo, branch_short_name, Some(&exclude))?
207 {
208 return Err(branch_checked_out_error(
209 branch_short_name,
210 &found.worktree_path,
211 ));
212 }
213 Ok(())
214}
215
216pub fn ensure_branch_not_checked_out_in_any_worktree(
222 repo: &Repository,
223 branch_short_name: &str,
224) -> Result<()> {
225 if let Some(found) = find_worktree_with_branch_checked_out(repo, branch_short_name, None)? {
226 return Err(branch_checked_out_error(
227 branch_short_name,
228 &found.worktree_path,
229 ));
230 }
231 Ok(())
232}
233
234fn branch_checked_out_error(branch: &str, worktree_path: &Path) -> Error {
235 Error::BranchCheckedOutInWorktree {
236 branch: branch.to_owned(),
237 worktree_path: crate::git_path::path_for_error_message(worktree_path),
238 }
239}
240
241pub fn list_worktrees(repo: &Repository) -> Result<Vec<WorktreeEntry>> {
245 let common = common_git_dir(repo.git_dir());
246 let git_dir = repo.git_dir();
247 let opened_from_linked = git_dir.starts_with(common.join("worktrees"));
248 let mut entries = Vec::new();
249
250 let bare = if opened_from_linked {
251 is_bare_repository(&common)
252 } else {
253 repo.is_bare()
254 };
255 let main_path = if bare {
256 path_for_disk_compare(&common)
257 } else if let Some(wt) = repo.work_tree() {
258 if repo.git_dir() == common || !repo.git_dir().starts_with(common.join("worktrees")) {
260 path_for_disk_compare(wt)
261 } else {
262 path_for_disk_compare(common.parent().unwrap_or(&common))
263 }
264 } else {
265 path_for_disk_compare(common.parent().unwrap_or(&common))
266 };
267
268 let main_head = if opened_from_linked || repo.refs_on_disk() {
269 resolve_head(&common)
270 } else {
271 crate::state::resolve_head_for_repository(repo)
272 }
273 .unwrap_or(HeadState::Invalid);
274 entries.push(WorktreeEntry {
275 path: main_path,
276 head: main_head,
277 is_bare: bare,
278 is_locked: false,
279 lock_reason: None,
280 admin_dir: common.clone(),
281 is_prunable: false,
282 prunable_reason: None,
283 is_current: !opened_from_linked && repo_opened_from_admin(git_dir, &common),
284 });
285
286 let worktrees_dir = common.join("worktrees");
287 if !worktrees_dir.is_dir() {
288 return Ok(entries);
289 }
290
291 let mut names: Vec<String> = fs::read_dir(&worktrees_dir)
292 .map_err(Error::Io)?
293 .filter_map(|e| e.ok())
294 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
295 .map(|e| e.file_name().to_string_lossy().into_owned())
296 .collect();
297 names.sort();
298
299 for name in names {
300 let admin = worktrees_dir.join(&name);
301 let wt_head = resolve_linked_worktree_head(repo, &admin);
302 let wt_path = read_worktree_path(&admin)?;
303 let (is_locked, lock_reason) = read_lock_state(&admin)?;
304 let (is_prunable, prunable_reason) =
305 linked_worktree_prunable_state(&admin, is_locked, &wt_path);
306 let is_current = opened_from_linked && git_dir == admin.as_path();
307 entries.push(WorktreeEntry {
308 path: wt_path,
309 head: wt_head,
310 is_bare: false,
311 is_locked,
312 lock_reason,
313 admin_dir: admin,
314 is_prunable,
315 prunable_reason,
316 is_current,
317 });
318 }
319
320 Ok(entries)
321}
322
323#[inline]
324fn repo_opened_from_admin(git_dir: &Path, admin: &Path) -> bool {
325 git_dir == admin || path_for_disk_compare(git_dir) == path_for_disk_compare(admin)
326}
327
328#[must_use]
330pub fn worktree_path_basename(path: &Path) -> String {
331 let s = path.to_string_lossy();
332 let trimmed = s.trim_end_matches(['/', '\\']);
333 trimmed
334 .rsplit(['/', '\\'])
335 .next()
336 .unwrap_or(trimmed)
337 .to_owned()
338}
339
340#[must_use]
342pub fn sanitize_worktree_id_component(name: &str) -> String {
343 if name == "@" {
344 return "-".to_owned();
345 }
346
347 let mut out = String::new();
348 let mut last = '\0';
349 let chars: Vec<char> = name.chars().collect();
350 let mut i = 0;
351 while i < chars.len() {
352 let ch = chars[i];
353 if ch.is_ascii_control()
354 || matches!(ch, ':' | '?' | '[' | '\\' | '^' | '~' | ' ' | '\t' | '*')
355 {
356 if (out.is_empty() && last != '-') || !out.is_empty() {
357 out.push('-');
358 }
359 last = '-';
360 i += 1;
361 continue;
362 }
363 if ch == '.' && i + 1 < chars.len() && chars[i + 1] == '.' {
364 if last == '.' {
365 out.pop();
366 } else {
367 out.push('.');
368 last = '.';
369 }
370 i += 2;
371 continue;
372 }
373 if ch == '@' && i + 1 < chars.len() && chars[i + 1] == '{' {
374 if let Some(last_ch) = out.pop() {
375 if last_ch != '-' {
376 out.push('-');
377 }
378 }
379 last = '-';
380 i += 2;
381 continue;
382 }
383 if ch == '.' && out.is_empty() {
384 out.push('-');
385 last = '-';
386 i += 1;
387 continue;
388 }
389 out.push(ch);
390 last = ch;
391 i += 1;
392 }
393
394 const LOCK_SUFFIX: &str = ".lock";
395 while out.ends_with(LOCK_SUFFIX) {
396 out.truncate(out.len() - LOCK_SUFFIX.len());
397 }
398 while out.ends_with('.') {
399 out.pop();
400 }
401 out
402}
403
404#[must_use]
408pub fn allocate_worktree_admin_dir(common: &Path, wt_path: &Path) -> PathBuf {
409 let worktrees_dir = common.join("worktrees");
410 let base = sanitize_worktree_id_component(&worktree_path_basename(wt_path));
411 let base = if base.is_empty() {
412 "worktree".to_owned()
413 } else {
414 base
415 };
416
417 let mut counter = 0u32;
418 loop {
419 let id = if counter == 0 {
420 base.clone()
421 } else {
422 format!("{base}{counter}")
423 };
424 let admin = worktrees_dir.join(&id);
425 if !admin.exists() {
426 return admin;
427 }
428 counter = counter.saturating_add(1);
429 if counter == 0 {
430 break;
431 }
432 }
433 worktrees_dir.join(format!("{base}{}", std::process::id()))
434}
435
436pub fn copy_filtered_worktree_config(source_git_dir: &Path, admin_dir: &Path) -> Result<()> {
439 let src = source_git_dir.join("config.worktree");
440 if !src.is_file() {
441 return Ok(());
442 }
443 let dst = admin_dir.join("config.worktree");
444 fs::copy(&src, &dst).map_err(Error::Io)?;
445 strip_worktree_config_keys(&dst, &["core.bare", "core.worktree"])?;
446 Ok(())
447}
448
449fn strip_worktree_config_keys(path: &Path, keys: &[&str]) -> Result<()> {
450 let content = fs::read_to_string(path).map_err(Error::Io)?;
451 let mut kept = Vec::new();
452 let mut section: Option<String> = None;
453 for line in content.lines() {
454 let trimmed = line.trim();
455 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
456 kept.push(line);
457 continue;
458 }
459 if trimmed.starts_with('[') {
460 let end = trimmed.find(']').unwrap_or(trimmed.len());
461 let name = trimmed[1..end].trim().to_ascii_lowercase();
462 section = Some(name);
463 kept.push(line);
464 continue;
465 }
466 if let Some((key, _)) = trimmed.split_once('=') {
467 let key = key.trim().to_ascii_lowercase();
468 let full = match section.as_deref() {
469 Some(sec) => format!("{sec}.{key}"),
470 None => key.clone(),
471 };
472 if keys.iter().any(|k| full.eq_ignore_ascii_case(k)) {
473 continue;
474 }
475 } else if keys.iter().any(|k| trimmed.eq_ignore_ascii_case(k)) {
476 continue;
477 }
478 kept.push(line);
479 }
480 let mut out = kept.join("\n");
481 if !out.is_empty() {
482 out.push('\n');
483 }
484 fs::write(path, out).map_err(Error::Io)
485}
486
487#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum WorktreeBranch {
490 Existing(String),
492 Create(String),
494 CreateOrReset(String),
496 Detach,
498}
499
500#[derive(Debug, Clone)]
502pub struct AddLinkedWorktreeOptions {
503 pub path: PathBuf,
505 pub branch: Option<WorktreeBranch>,
507 pub start_point: Option<String>,
509 pub force: bool,
511 pub lock: Option<LockWorktreeOptions>,
513 pub no_checkout: bool,
515 pub track: bool,
517}
518
519#[derive(Debug, Clone, Default)]
522pub struct LockWorktreeOptions {
523 pub reason: Option<String>,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
529pub struct PruneWorktreesOptions {
530 pub dry_run: bool,
532}
533
534#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct PrunedWorktreeEntry {
537 pub path: PathBuf,
539 pub reason: String,
541}
542
543pub fn add_linked_worktree(
555 repo: &Repository,
556 opts: &AddLinkedWorktreeOptions,
557) -> Result<WorktreeEntry> {
558 let common = common_git_dir(repo.git_dir());
559 if repo.is_bare() {
560 return Err(WorktreeError::BareRepository.into());
561 }
562
563 let wt_path = if opts.path.exists() {
564 path_for_disk_compare(&opts.path)
565 } else {
566 opts.path.clone()
567 };
568 let admin_plan = plan_admin_dir_for_add(&common, &wt_path, opts.force)?;
569 ensure_empty_or_missing(&wt_path)?;
570
571 let (start_spec, start_oid) = resolve_worktree_start_commit(repo, opts)?;
572 let tree_oid = repo.odb().commit_tree(&start_oid)?;
573
574 let plan = plan_worktree_head(repo, opts, &wt_path, &start_spec, start_oid)?;
575 if let Some(ref branch_ref) = plan.head_ref {
576 branch_checked_out_elsewhere(repo, branch_ref)?;
577 }
578
579 let admin = admin_plan.admin_dir;
580 let worktree_absent_before = !wt_path.exists();
581 let mut stale_guard = admin_plan
582 .stale_admins
583 .map(StaleWorktreeAdminBackup::remove_and_backup)
584 .transpose()?;
585
586 let mut branch_rollback = None;
587 let finish = (|| -> Result<WorktreeEntry> {
588 if let Some(ref bw) = plan.branch_write {
589 let branch_ref = format!("refs/heads/{}", bw.branch_short);
590 let previous_tip = repo.resolve_ref_oid(&branch_ref).ok();
591 branch_rollback = Some(BranchRefRollback {
592 repo,
593 branch_ref: branch_ref.clone(),
594 previous_tip,
595 committed: false,
596 });
597 create_worktree_branch(
598 repo,
599 &bw.branch_short,
600 &bw.start_spec,
601 bw.start_oid,
602 bw.force_reset,
603 )?;
604 }
605 if let Some(ref branch_short) = plan.track_branch {
606 maybe_track_new_branch(repo, opts, branch_short, &start_spec, start_oid)?;
607 }
608
609 fs::create_dir_all(&admin).map_err(Error::Io)?;
610 if let Some(guard) = stale_guard.as_mut() {
611 guard.set_new_admin(&admin);
612 }
613 fs::create_dir_all(&wt_path).map_err(Error::Io)?;
614 if let Some(guard) = stale_guard.as_mut() {
615 guard.track_replacement_worktree(&wt_path, worktree_absent_before);
616 }
617
618 let wt_git = wt_path.join(".git");
619 fs::write(
620 &wt_git,
621 format!(
622 "gitdir: {}\n",
623 absolute_path_for_gitfile_stored_path(&admin)
624 ),
625 )
626 .map_err(Error::Io)?;
627 let wt_git_abs = absolute_path_for_gitfile_stored_path(&wt_git);
628 fs::write(admin.join("commondir"), "../..\n").map_err(Error::Io)?;
629 fs::write(admin.join("gitdir"), format!("{wt_git_abs}\n")).map_err(Error::Io)?;
630
631 let head = plan
632 .head_ref
633 .as_ref()
634 .map_or(crate::refs::store::RawRef::Direct(start_oid), |target| {
635 crate::refs::store::RawRef::Symbolic(target.clone())
636 });
637 if repo.refs_on_disk() {
638 crate::refs::store::initialize_head(&admin, head)?;
639 } else {
640 let content = match head {
642 crate::refs::store::RawRef::Symbolic(target) => format!("ref: {target}\n"),
643 crate::refs::store::RawRef::Direct(oid) => format!("{oid}\n"),
644 };
645 fs::write(admin.join("HEAD"), content).map_err(Error::Io)?;
646 }
647
648 copy_filtered_worktree_config(&common, &admin)?;
649
650 if let Some(lock) = &opts.lock {
651 write_admin_lock_file(&admin, lock.reason.as_deref())?;
652 }
653
654 if !opts.no_checkout {
655 let linked = repo.open_linked_worktree(&admin, &wt_path)?;
656 checkout_between_trees(&linked, None, &tree_oid)?;
657 }
658
659 linked_worktree_entry(repo, &admin)
660 })();
661
662 match finish {
663 Ok(entry) => {
664 if let Some(guard) = stale_guard.take() {
665 guard.commit();
666 }
667 if let Some(mut rollback) = branch_rollback {
668 rollback.commit();
669 }
670 Ok(entry)
671 }
672 Err(err) => Err(err),
673 }
674}
675
676pub fn prune_linked_worktrees(
684 repo: &Repository,
685 opts: &PruneWorktreesOptions,
686) -> Result<Vec<PrunedWorktreeEntry>> {
687 let common = common_git_dir(repo.git_dir());
688 let worktrees_dir = common.join("worktrees");
689 if !worktrees_dir.is_dir() {
690 return Ok(Vec::new());
691 }
692
693 let mut pruned = Vec::new();
694 for entry in list_worktrees(repo)? {
695 if entry.admin_dir == common || entry.is_locked || !entry.is_prunable {
696 continue;
697 }
698 let reason = prune_reason(&entry.admin_dir, &entry.path);
699 pruned.push(PrunedWorktreeEntry {
700 path: entry.path.clone(),
701 reason,
702 });
703 if !opts.dry_run {
704 fs::remove_dir_all(&entry.admin_dir).map_err(Error::Io)?;
705 }
706 }
707 Ok(pruned)
708}
709
710fn prune_reason(admin: &Path, wt_path: &Path) -> String {
711 let prunable_file = admin.join("prunable");
712 if prunable_file.is_file() {
713 if let Ok(content) = fs::read_to_string(&prunable_file) {
714 let trimmed = content.trim();
715 if !trimmed.is_empty() {
716 return trimmed.to_owned();
717 }
718 }
719 }
720 if !wt_path.exists() {
721 return "gitdir file points to non-existent location".into();
722 }
723 "prunable".into()
724}
725
726fn write_admin_lock_file(admin: &Path, reason: Option<&str>) -> Result<()> {
727 let body = reason.unwrap_or("");
728 fs::write(admin.join("locked"), body).map_err(Error::Io)
729}
730
731struct WorktreeHeadPlan {
732 head_ref: Option<String>,
733 branch_write: Option<BranchWritePlan>,
734 track_branch: Option<String>,
735}
736
737struct BranchWritePlan {
738 branch_short: String,
739 start_spec: String,
740 start_oid: crate::objects::ObjectId,
741 force_reset: bool,
742}
743
744struct BranchRefRollback<'a> {
745 repo: &'a Repository,
746 branch_ref: String,
747 previous_tip: Option<crate::objects::ObjectId>,
748 committed: bool,
749}
750
751impl BranchRefRollback<'_> {
752 fn commit(&mut self) {
753 self.committed = true;
754 }
755}
756
757impl Drop for BranchRefRollback<'_> {
758 fn drop(&mut self) {
759 if self.committed {
760 return;
761 }
762 let _ = match self.previous_tip {
763 None => refs::delete_ref_on_store(self.repo.refs(), &self.branch_ref),
764 Some(old) => refs::update_direct_ref_on_store(
765 self.repo.refs(),
766 self.repo.git_dir(),
767 &self.branch_ref,
768 &old,
769 ),
770 };
771 }
772}
773
774fn resolve_worktree_start_commit(
775 repo: &Repository,
776 opts: &AddLinkedWorktreeOptions,
777) -> Result<(String, crate::objects::ObjectId)> {
778 if let Some(WorktreeBranch::Existing(name)) = &opts.branch {
779 if opts.start_point.is_none() {
780 let oid = resolve_existing_branch_tip(repo, name)?;
781 return Ok((name.clone(), oid));
782 }
783 }
784 let start_spec = opts.start_point.as_deref().unwrap_or("HEAD").to_owned();
785 let start_oid = resolve_revision(repo, &start_spec)?;
786 Ok((start_spec, start_oid))
787}
788
789fn plan_worktree_head(
790 repo: &Repository,
791 opts: &AddLinkedWorktreeOptions,
792 wt_path: &Path,
793 start_spec: &str,
794 start_oid: crate::objects::ObjectId,
795) -> Result<WorktreeHeadPlan> {
796 match &opts.branch {
797 Some(WorktreeBranch::Detach) => Ok(WorktreeHeadPlan {
798 head_ref: None,
799 branch_write: None,
800 track_branch: None,
801 }),
802 Some(WorktreeBranch::Existing(name)) => {
803 let branch_ref = prepare_existing_branch(repo, name, start_oid)?;
804 Ok(WorktreeHeadPlan {
805 head_ref: Some(branch_ref),
806 branch_write: None,
807 track_branch: None,
808 })
809 }
810 Some(WorktreeBranch::Create(name)) => Ok(WorktreeHeadPlan {
811 head_ref: Some(format!("refs/heads/{name}")),
812 branch_write: Some(BranchWritePlan {
813 branch_short: name.clone(),
814 start_spec: start_spec.to_owned(),
815 start_oid,
816 force_reset: false,
817 }),
818 track_branch: if opts.track { Some(name.clone()) } else { None },
819 }),
820 Some(WorktreeBranch::CreateOrReset(name)) => Ok(WorktreeHeadPlan {
821 head_ref: Some(format!("refs/heads/{name}")),
822 branch_write: Some(BranchWritePlan {
823 branch_short: name.clone(),
824 start_spec: start_spec.to_owned(),
825 start_oid,
826 force_reset: true,
827 }),
828 track_branch: if opts.track { Some(name.clone()) } else { None },
829 }),
830 None => {
831 if let Some(start) = opts.start_point.as_deref() {
832 if let Some(branch_ref) = resolve_existing_branch(repo, start) {
833 let tip = repo.resolve_ref_oid(&branch_ref)?;
834 if tip != start_oid {
835 return Err(WorktreeError::BranchTipMismatch {
836 branch: branch_ref
837 .strip_prefix("refs/heads/")
838 .unwrap_or(&branch_ref)
839 .to_owned(),
840 }
841 .into());
842 }
843 return Ok(WorktreeHeadPlan {
844 head_ref: Some(branch_ref),
845 branch_write: None,
846 track_branch: None,
847 });
848 }
849 return Ok(WorktreeHeadPlan {
850 head_ref: None,
851 branch_write: None,
852 track_branch: None,
853 });
854 }
855 let branch = worktree_path_basename(wt_path);
856 validate_branch_name(&branch)?;
857 Ok(WorktreeHeadPlan {
858 head_ref: Some(format!("refs/heads/{branch}")),
859 branch_write: Some(BranchWritePlan {
860 branch_short: branch.clone(),
861 start_spec: start_spec.to_owned(),
862 start_oid,
863 force_reset: false,
864 }),
865 track_branch: if opts.track { Some(branch) } else { None },
866 })
867 }
868 }
869}
870
871fn prepare_existing_branch(
872 repo: &Repository,
873 name: &str,
874 start_oid: crate::objects::ObjectId,
875) -> Result<String> {
876 let tip = resolve_existing_branch_tip(repo, name)?;
877 if tip != start_oid {
878 return Err(WorktreeError::BranchTipMismatch {
879 branch: name.to_owned(),
880 }
881 .into());
882 }
883 Ok(format!("refs/heads/{name}"))
884}
885
886fn validate_branch_name(name: &str) -> Result<()> {
887 validate_branch_short_name(name).map_err(|reason| {
888 BranchError::InvalidName {
889 name: name.to_owned(),
890 reason: reason.to_string(),
891 }
892 .into()
893 })
894}
895
896fn resolve_existing_branch_tip(repo: &Repository, name: &str) -> Result<crate::objects::ObjectId> {
897 validate_branch_name(name)?;
898 repo.resolve_ref_oid(&format!("refs/heads/{name}"))
899 .map_err(|_| {
900 BranchError::NotFound {
901 name: name.to_owned(),
902 }
903 .into()
904 })
905}
906
907fn create_worktree_branch(
908 repo: &Repository,
909 name: &str,
910 start_spec: &str,
911 start_oid: crate::objects::ObjectId,
912 force_reset: bool,
913) -> Result<String> {
914 validate_branch_name(name)?;
915 let branch_ref = format!("refs/heads/{name}");
916 let existing = repo.references().read_existing(&branch_ref)?;
917 if existing.is_some() && !force_reset {
918 return Err(BranchError::AlreadyExists {
919 name: name.to_owned(),
920 }
921 .into());
922 }
923 if let Some(existing_ref) = existing {
924 ensure_branch_not_checked_out_in_any_worktree(repo, name)?;
925 reset_branch_ref_with_reflog(repo, &branch_ref, start_oid, start_spec, &existing_ref)?;
926 } else {
927 create_branch_ref_with_reflog(repo, &branch_ref, start_oid, start_spec)?;
928 }
929 Ok(branch_ref)
930}
931
932fn maybe_track_new_branch(
933 repo: &Repository,
934 opts: &AddLinkedWorktreeOptions,
935 branch_short: &str,
936 start_spec: &str,
937 start_oid: crate::objects::ObjectId,
938) -> Result<()> {
939 if !opts.track {
940 return Ok(());
941 }
942 let mode = match opts.start_point.as_deref() {
943 Some(s) if s != "HEAD" => TrackMode::Always,
944 _ => TrackMode::Auto,
945 };
946 match resolve_create_tracking(repo, branch_short, start_spec, start_oid, mode) {
947 Ok(Some((remote, merge))) => {
948 write_branch_upstream_config(repo, branch_short, &remote, &merge)
949 }
950 Ok(None) => Ok(()),
951 Err(Error::Branch(BranchError::UpstreamNotTracking { .. })) => Ok(()),
952 Err(err) => Err(err),
953 }
954}
955
956fn linked_worktree_entry(repo: &Repository, admin: &Path) -> Result<WorktreeEntry> {
957 let wt_path = read_worktree_path(admin)?;
958 let wt_head = resolve_linked_worktree_head(repo, admin);
959 let (is_locked, lock_reason) = read_lock_state(admin)?;
960 let (is_prunable, prunable_reason) = linked_worktree_prunable_state(admin, is_locked, &wt_path);
961 Ok(WorktreeEntry {
962 path: wt_path,
963 head: wt_head,
964 is_bare: false,
965 is_locked,
966 lock_reason,
967 admin_dir: admin.to_path_buf(),
968 is_prunable,
969 prunable_reason,
970 is_current: admin_dirs_match(admin, &worktree_admin_dir(repo)),
971 })
972}
973
974fn linked_worktree_prunable_state(
976 admin: &Path,
977 is_locked: bool,
978 wt_path: &Path,
979) -> (bool, Option<PrunableReason>) {
980 if is_locked {
981 return (false, None);
982 }
983 if admin.join("prunable").is_file() {
984 return (true, Some(PrunableReason::ExplicitMarker));
985 }
986 if wt_path.exists() {
987 return (false, None);
988 }
989 (
990 true,
991 Some(linked_worktree_prunable_reason_when_missing(admin)),
992 )
993}
994
995fn linked_worktree_prunable_reason_when_missing(admin: &Path) -> PrunableReason {
996 if !admin.is_dir() {
997 return PrunableReason::AdminNotValidDirectory;
998 }
999 let gitdir_file = admin.join("gitdir");
1000 if !gitdir_file.is_file() {
1001 return PrunableReason::GitdirFileMissing;
1002 }
1003 let raw = fs::read_to_string(&gitdir_file).unwrap_or_default();
1004 let trimmed = raw.trim();
1005 if trimmed.is_empty() {
1006 return PrunableReason::InvalidGitdirFile;
1007 }
1008 let mut dotgit = PathBuf::from(trimmed);
1009 if dotgit.is_relative() {
1010 dotgit = admin.join(dotgit);
1011 }
1012 let dotgit = path_for_disk_compare(&dotgit);
1013 if !dotgit.is_file() {
1014 return PrunableReason::GitdirPointsElsewhere;
1015 }
1016 PrunableReason::WorktreePathMissing
1017}
1018
1019pub fn resolve_linked_worktree_add_path(path: &Path, relative_to: &Path) -> Result<PathBuf> {
1029 if path.exists() {
1030 return Ok(path_for_disk_compare(path));
1031 }
1032 let absolute = if path.is_absolute() {
1033 path.to_path_buf()
1034 } else {
1035 relative_to.join(path)
1036 };
1037 if let Some(parent) = absolute.parent().filter(|p| p.exists()) {
1038 let Some(name) = absolute.file_name() else {
1039 return Err(WorktreeError::NoFileName { path: absolute }.into());
1040 };
1041 let mut resolved = path_for_disk_compare(parent);
1042 resolved.push(name);
1043 return Ok(resolved);
1044 }
1045 Ok(absolute)
1046}
1047
1048struct WorktreeAdminAddPlan {
1049 admin_dir: PathBuf,
1050 stale_admins: Option<Vec<PathBuf>>,
1051}
1052
1053struct StaleWorktreeAdminBackup {
1055 moved_aside: Vec<(PathBuf, PathBuf)>,
1056 new_admin: Option<PathBuf>,
1057 replacement_worktree: Option<(PathBuf, bool)>,
1058}
1059
1060impl StaleWorktreeAdminBackup {
1061 fn remove_and_backup(stale_admins: Vec<PathBuf>) -> Result<Self> {
1062 let mut moved_aside = Vec::new();
1063 for (i, admin) in stale_admins.into_iter().enumerate() {
1064 let id = admin
1065 .file_name()
1066 .map(|n| n.to_string_lossy().into_owned())
1067 .unwrap_or_else(|| "wt".to_owned());
1068 let aside = admin
1069 .parent()
1070 .ok_or_else(|| WorktreeError::InvalidAdminDir {
1071 path: admin.clone(),
1072 })?
1073 .join(format!(".{id}-stale-backup-{i}"));
1074 if aside.exists() {
1075 fs::remove_dir_all(&aside).map_err(Error::Io)?;
1076 }
1077 fs::rename(&admin, &aside).map_err(Error::Io)?;
1078 moved_aside.push((admin, aside));
1079 }
1080 Ok(Self {
1081 moved_aside,
1082 new_admin: None,
1083 replacement_worktree: None,
1084 })
1085 }
1086
1087 fn set_new_admin(&mut self, admin: &Path) {
1088 self.new_admin = Some(admin.to_path_buf());
1089 }
1090
1091 fn track_replacement_worktree(&mut self, path: &Path, absent_before_replacement: bool) {
1092 self.replacement_worktree = Some((path.to_path_buf(), absent_before_replacement));
1093 }
1094
1095 fn commit(mut self) {
1096 for (_, aside) in self.moved_aside.drain(..) {
1097 let _ = fs::remove_dir_all(aside);
1098 }
1099 }
1100}
1101
1102impl Drop for StaleWorktreeAdminBackup {
1103 fn drop(&mut self) {
1104 if self.moved_aside.is_empty() {
1105 return;
1106 }
1107 if let Some((wt_path, absent_before)) = self.replacement_worktree.take() {
1108 if absent_before {
1109 let _ = fs::remove_dir_all(&wt_path);
1110 } else if wt_path.join(".git").is_file() {
1111 let _ = fs::remove_file(wt_path.join(".git"));
1112 }
1113 }
1114 if let Some(new_admin) = self.new_admin.take() {
1115 let _ = fs::remove_dir_all(new_admin);
1116 }
1117 for (admin, aside) in &self.moved_aside {
1118 let _ = fs::remove_dir_all(admin);
1119 let _ = fs::rename(aside, admin);
1120 }
1121 }
1122}
1123
1124fn linked_admins_for_worktree_path(common: &Path, wt_path: &Path) -> Result<Vec<PathBuf>> {
1125 let target = path_for_disk_compare(wt_path);
1126 let worktrees_dir = common.join("worktrees");
1127 if !worktrees_dir.is_dir() {
1128 return Ok(Vec::new());
1129 }
1130 let mut admins = Vec::new();
1131 for entry in fs::read_dir(&worktrees_dir).map_err(Error::Io)? {
1132 let entry = entry.map_err(Error::Io)?;
1133 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1134 continue;
1135 }
1136 let admin = entry.path();
1137 let registered = read_worktree_path(&admin)?;
1138 if path_for_disk_compare(®istered) == target {
1139 admins.push(admin);
1140 }
1141 }
1142 Ok(admins)
1143}
1144
1145fn plan_admin_dir_for_add(
1146 common: &Path,
1147 wt_path: &Path,
1148 force: bool,
1149) -> Result<WorktreeAdminAddPlan> {
1150 let existing = linked_admins_for_worktree_path(common, wt_path)?;
1151 if existing.is_empty() {
1152 return Ok(WorktreeAdminAddPlan {
1153 admin_dir: allocate_worktree_admin_dir(common, wt_path),
1154 stale_admins: None,
1155 });
1156 }
1157
1158 if wt_path.exists() {
1159 return Err(WorktreeError::PathRegistered {
1160 path: wt_path.to_path_buf(),
1161 }
1162 .into());
1163 }
1164 if !force {
1165 return Err(WorktreeError::PathRegisteredMissing {
1166 path: wt_path.to_path_buf(),
1167 }
1168 .into());
1169 }
1170
1171 let worktrees_dir = common.join("worktrees");
1172 let base = sanitize_worktree_id_component(&worktree_path_basename(wt_path));
1173 let base = if base.is_empty() {
1174 "worktree".to_owned()
1175 } else {
1176 base
1177 };
1178 let preferred = worktrees_dir.join(&base);
1179 let admin_dir = existing
1180 .iter()
1181 .find(|admin| {
1182 admin
1183 .file_name()
1184 .is_some_and(|name| name.to_string_lossy() == base)
1185 })
1186 .cloned()
1187 .unwrap_or_else(|| {
1188 if preferred.exists() {
1189 allocate_worktree_admin_dir(common, wt_path)
1190 } else {
1191 preferred
1192 }
1193 });
1194 Ok(WorktreeAdminAddPlan {
1195 admin_dir,
1196 stale_admins: Some(existing),
1197 })
1198}
1199
1200pub fn remove_linked_worktree(repo: &Repository, path: &Path, force: bool) -> Result<()> {
1208 let entry = find_linked_worktree(repo, path, WorktreeOperation::Remove)?;
1209 if entry.is_locked && !force {
1210 return Err(WorktreeError::Locked {
1211 reason: entry.lock_reason,
1212 }
1213 .into());
1214 }
1215
1216 let linked = repo.open_linked_worktree(&entry.admin_dir, &entry.path)?;
1217 if !force && worktree_has_local_changes(&linked)? {
1218 return Err(WorktreeError::HasLocalChanges.into());
1219 }
1220
1221 fs::remove_dir_all(&entry.admin_dir).map_err(Error::Io)?;
1222 if entry.path.exists() {
1223 fs::remove_dir_all(&entry.path).map_err(Error::Io)?;
1224 }
1225 Ok(())
1226}
1227
1228pub(crate) fn find_linked_worktree(
1235 repo: &Repository,
1236 path: &Path,
1237 operation: WorktreeOperation,
1238) -> Result<WorktreeEntry> {
1239 let target = path_for_disk_compare(path);
1240 let Some(entry) = list_worktrees(repo)?.into_iter().find(|e| e.path == target) else {
1241 return Err(WorktreeError::NotRegistered { path: target }.into());
1242 };
1243 if entry.admin_dir == common_git_dir(repo.git_dir()) {
1244 return Err(WorktreeError::MainWorktree { operation }.into());
1245 }
1246 Ok(entry)
1247}
1248
1249pub(crate) fn lock_linked_worktree(
1256 repo: &Repository,
1257 path: &Path,
1258 reason: Option<&str>,
1259) -> Result<()> {
1260 let entry = find_linked_worktree(repo, path, WorktreeOperation::Lock)?;
1261 if entry.is_locked {
1262 return Err(WorktreeError::AlreadyLocked {
1263 reason: entry.lock_reason,
1264 }
1265 .into());
1266 }
1267 write_admin_lock_file(&entry.admin_dir, reason)
1268}
1269
1270pub(crate) fn unlock_linked_worktree(repo: &Repository, path: &Path) -> Result<()> {
1277 let entry = find_linked_worktree(repo, path, WorktreeOperation::Unlock)?;
1278 if !entry.is_locked {
1279 return Err(WorktreeError::NotLocked.into());
1280 }
1281 fs::remove_file(entry.admin_dir.join("locked")).map_err(Error::Io)
1282}
1283
1284fn resolve_existing_branch(repo: &Repository, spec: &str) -> Option<String> {
1285 let full = if spec.starts_with("refs/heads/") {
1286 spec.to_owned()
1287 } else {
1288 format!("refs/heads/{spec}")
1289 };
1290 repo.resolve_ref_oid(&full).ok()?;
1291 Some(full)
1292}
1293
1294fn branch_checked_out_elsewhere(repo: &Repository, branch_ref: &str) -> Result<()> {
1295 for wt in list_worktrees(repo)? {
1296 if let HeadState::Branch { refname, .. } = &wt.head {
1297 if refname == branch_ref {
1298 return Err(branch_checked_out_error(
1299 branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref),
1300 &wt.path,
1301 ));
1302 }
1303 }
1304 }
1305 Ok(())
1306}
1307
1308fn ensure_empty_or_missing(path: &Path) -> Result<()> {
1309 if !path.exists() {
1310 return Ok(());
1311 }
1312 if !path.is_dir() {
1313 return Err(WorktreeError::PathExistsNotDirectory {
1314 path: path.to_path_buf(),
1315 }
1316 .into());
1317 }
1318 let mut entries = fs::read_dir(path).map_err(Error::Io)?;
1319 if entries.next().is_some() {
1320 return Err(WorktreeError::PathExists {
1321 path: path.to_path_buf(),
1322 }
1323 .into());
1324 }
1325 Ok(())
1326}
1327
1328fn worktree_has_local_changes(repo: &Repository) -> Result<bool> {
1329 let model = status(
1330 repo,
1331 &StatusOptions::default(),
1332 &mut NullProgress,
1333 &crate::progress::NeverCancel,
1334 )?;
1335 Ok(!model.staged.is_empty()
1336 || !model.unstaged.is_empty()
1337 || !model.conflicts.is_empty()
1338 || !model.untracked.is_empty())
1339}
1340
1341pub fn read_worktree_path(admin: &Path) -> Result<PathBuf> {
1343 let gitdir_path = admin.join("gitdir");
1344 if !gitdir_path.is_file() {
1345 return Ok(admin.to_path_buf());
1346 }
1347 let raw = fs::read_to_string(&gitdir_path).map_err(Error::Io)?;
1348 let mut p = PathBuf::from(raw.trim());
1349 if p.is_relative() {
1350 p = admin.join(p);
1351 }
1352 let parent = p.parent().unwrap_or(&p).to_path_buf();
1353 Ok(path_for_disk_compare(&parent))
1354}
1355
1356fn read_lock_state(admin: &Path) -> Result<(bool, Option<String>)> {
1357 let locked_file = admin.join("locked");
1358 if !locked_file.is_file() {
1359 return Ok((false, None));
1360 }
1361 let content = fs::read_to_string(&locked_file).map_err(Error::Io)?;
1362 let reason = content.trim();
1363 if reason.is_empty() {
1364 Ok((true, None))
1365 } else {
1366 Ok((true, Some(reason.to_owned())))
1367 }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::*;
1373 use crate::repo::Repository;
1374 use std::fs;
1375 use tempfile::TempDir;
1376
1377 #[test]
1378 fn list_main_worktree_only() {
1379 let tmp = TempDir::new().unwrap();
1380 let root = tmp.path().join("repo");
1381 fs::create_dir_all(root.join(".git")).unwrap();
1382 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
1383 fs::create_dir_all(root.join(".git/objects")).unwrap();
1384 fs::write(
1385 root.join(".git/config"),
1386 "[core]\n\trepositoryformatversion = 0\n",
1387 )
1388 .unwrap();
1389
1390 let repo = Repository::open(
1391 &root.join(".git"),
1392 &RepositoryOptions::empty().with_work_tree(&root),
1393 )
1394 .unwrap();
1395 let list = list_worktrees(&repo).unwrap();
1396 assert_eq!(list.len(), 1);
1397 assert_eq!(list[0].path, path_for_disk_compare(&root));
1398 assert!(!list[0].is_bare);
1399 }
1400
1401 #[test]
1402 fn allocate_unique_worktree_id() {
1403 let tmp = TempDir::new().unwrap();
1404 let common = tmp.path().join("git");
1405 fs::create_dir_all(common.join("worktrees/here")).unwrap();
1406 let admin = allocate_worktree_admin_dir(&common, Path::new("/tmp/sub/here"));
1407 assert_eq!(admin, common.join("worktrees/here1"));
1408 }
1409
1410 #[test]
1411 fn strip_worktree_config_removes_core_bare_and_worktree() {
1412 let tmp = TempDir::new().unwrap();
1413 let path = tmp.path().join("config.worktree");
1414 fs::write(
1415 &path,
1416 "[core]\n\tbare = true\n\tworktree = /wt\n[bogus]\n\tkey = value\n",
1417 )
1418 .unwrap();
1419 strip_worktree_config_keys(&path, &["core.bare", "core.worktree"]).unwrap();
1420 let out = fs::read_to_string(&path).unwrap();
1421 assert!(out.contains("bogus"));
1422 assert!(!out.contains("bare"));
1423 assert!(!out.contains("worktree"));
1424 }
1425
1426 #[test]
1427 fn sanitize_funny_worktree_name() {
1428 assert_eq!(
1429 sanitize_worktree_id_component(". weird*..?.lock.lock"),
1430 "---weird-.-"
1431 );
1432 }
1433
1434 #[test]
1435 fn read_worktree_path_from_gitdir_file() {
1436 let tmp = TempDir::new().unwrap();
1437 let admin = tmp.path().join("wt-admin");
1438 fs::create_dir_all(&admin).unwrap();
1439 let wt = tmp.path().join("linked");
1440 fs::create_dir_all(wt.join(".git")).unwrap();
1441 fs::write(
1442 admin.join("gitdir"),
1443 format!("{}\n", wt.join(".git").display()),
1444 )
1445 .unwrap();
1446 let path = read_worktree_path(&admin).unwrap();
1447 assert_eq!(path, path_for_disk_compare(&wt));
1448 }
1449
1450 #[test]
1451 fn add_and_remove_linked_worktree_via_library() {
1452 use std::process::Command;
1453 let tmp = TempDir::new().unwrap();
1454 let main = tmp.path().join("main");
1455 fs::create_dir_all(&main).unwrap();
1456 let git = |args: &[&str]| {
1457 let out = Command::new("git")
1458 .env("GIT_AUTHOR_NAME", "Test")
1459 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1460 .env("GIT_COMMITTER_NAME", "Test")
1461 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1462 .current_dir(&main)
1463 .args(args)
1464 .output()
1465 .unwrap();
1466 assert!(out.status.success(), "git {args:?}");
1467 };
1468 git(&["init", "-q", "-b", "main"]);
1469 fs::write(main.join("t"), b"1\n").unwrap();
1470 git(&["add", "t"]);
1471 git(&["commit", "-qm", "init"]);
1472
1473 let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1474 let linked = tmp.path().join("linked");
1475 add_linked_worktree(
1476 &repo,
1477 &AddLinkedWorktreeOptions {
1478 path: linked.clone(),
1479 branch: Some(WorktreeBranch::Create("wt".into())),
1480 start_point: Some("HEAD".into()),
1481 force: false,
1482 lock: None,
1483 no_checkout: false,
1484 track: false,
1485 },
1486 )
1487 .unwrap();
1488 assert_eq!(list_worktrees(&repo).unwrap().len(), 2);
1489 remove_linked_worktree(&repo, &linked, false).unwrap();
1490 assert_eq!(list_worktrees(&repo).unwrap().len(), 1);
1491 }
1492
1493 #[test]
1494 fn registered_worktree_count_and_basename() {
1495 let tmp = TempDir::new().unwrap();
1496 let common = tmp.path().join("git");
1497 fs::create_dir_all(common.join("worktrees/a")).unwrap();
1498 fs::create_dir_all(common.join("worktrees/b")).unwrap();
1499 assert_eq!(registered_worktree_count(&common), 3);
1500 assert_eq!(
1501 worktree_path_basename(Path::new("/tmp/my/project/")),
1502 "project"
1503 );
1504 }
1505
1506 #[test]
1507 fn registered_worktree_count_without_worktrees_dir() {
1508 let tmp = TempDir::new().unwrap();
1509 let common = tmp.path().join("only-main");
1510 fs::create_dir_all(&common).unwrap();
1511 assert_eq!(registered_worktree_count(&common), 1);
1512 }
1513
1514 #[test]
1515 fn common_git_dir_and_bare_detection() {
1516 let tmp = TempDir::new().unwrap();
1517 let git_dir = tmp.path().join("bare.git");
1518 fs::create_dir_all(&git_dir).unwrap();
1519 fs::write(
1520 git_dir.join("config"),
1521 "[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
1522 )
1523 .unwrap();
1524 assert!(is_bare_repository(&git_dir));
1525 assert_eq!(common_git_dir(&git_dir), git_dir);
1526 }
1527
1528 #[test]
1529 fn sanitize_worktree_id_special_cases() {
1530 assert_eq!(sanitize_worktree_id_component("@"), "-");
1531 assert!(!sanitize_worktree_id_component("@{upstream}").contains('@'));
1532 assert_eq!(sanitize_worktree_id_component("..name"), ".name");
1533 assert_eq!(sanitize_worktree_id_component(".lock.lock"), "-lock");
1534 let tmp = TempDir::new().unwrap();
1535 let common = tmp.path().join("git");
1536 fs::create_dir_all(&common).unwrap();
1537 assert_eq!(
1538 allocate_worktree_admin_dir(&common, Path::new("/")),
1539 common.join("worktrees/worktree")
1540 );
1541 }
1542
1543 #[test]
1544 fn copy_filtered_worktree_config_no_source_is_ok() {
1545 let tmp = TempDir::new().unwrap();
1546 let admin = tmp.path().join("admin");
1547 fs::create_dir_all(&admin).unwrap();
1548 copy_filtered_worktree_config(tmp.path(), &admin).unwrap();
1549 assert!(!admin.join("config.worktree").exists());
1550 }
1551
1552 #[test]
1553 fn copy_filtered_worktree_config_copies_file() {
1554 let tmp = TempDir::new().unwrap();
1555 let common = tmp.path().join("common");
1556 let admin = tmp.path().join("admin");
1557 fs::create_dir_all(&common).unwrap();
1558 fs::create_dir_all(&admin).unwrap();
1559 fs::write(
1560 common.join("config.worktree"),
1561 "[core]\n\tbare = false\n\tworktree = /x\n[ext]\n\tk = v\n",
1562 )
1563 .unwrap();
1564 copy_filtered_worktree_config(&common, &admin).unwrap();
1565 let out = fs::read_to_string(admin.join("config.worktree")).unwrap();
1566 assert!(out.contains("[ext]"));
1567 assert!(!out.contains("worktree"));
1568 }
1569
1570 #[test]
1571 fn read_lock_state_and_missing_gitdir_path() {
1572 let tmp = TempDir::new().unwrap();
1573 let admin = tmp.path().join("admin");
1574 fs::create_dir_all(&admin).unwrap();
1575 assert_eq!(read_worktree_path(&admin).unwrap(), admin);
1576 fs::write(admin.join("locked"), "maintenance\n").unwrap();
1577 let (locked, reason) = read_lock_state(&admin).unwrap();
1578 assert!(locked);
1579 assert_eq!(reason.as_deref(), Some("maintenance"));
1580 }
1581
1582 #[test]
1583 fn ensure_empty_or_missing_rejects_nonempty_dir() {
1584 let tmp = TempDir::new().unwrap();
1585 let dir = tmp.path().join("occupied");
1586 fs::create_dir_all(&dir).unwrap();
1587 fs::write(dir.join("file"), b"x").unwrap();
1588 assert!(matches!(
1589 ensure_empty_or_missing(&dir),
1590 Err(Error::Worktree(WorktreeError::PathExists { path })) if path == dir
1591 ));
1592 assert!(matches!(
1593 ensure_empty_or_missing(&dir.join("file")),
1594 Err(Error::Worktree(
1595 WorktreeError::PathExistsNotDirectory { .. }
1596 ))
1597 ));
1598 let missing = tmp.path().join("missing");
1599 assert!(ensure_empty_or_missing(&missing).is_ok());
1600 }
1601
1602 #[test]
1603 fn add_linked_worktree_rejects_bare_repository() {
1604 let tmp = TempDir::new().unwrap();
1605 let bare = tmp.path().join("bare.git");
1606 fs::create_dir_all(bare.join("objects")).unwrap();
1607 fs::write(bare.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1608 fs::write(
1609 bare.join("config"),
1610 "[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
1611 )
1612 .unwrap();
1613 let repo = Repository::open(&bare, &RepositoryOptions::empty()).unwrap();
1614 let err = add_linked_worktree(
1615 &repo,
1616 &AddLinkedWorktreeOptions {
1617 path: tmp.path().join("wt"),
1618 branch: Some(WorktreeBranch::Create("wt".into())),
1619 start_point: None,
1620 force: false,
1621 lock: None,
1622 no_checkout: false,
1623 track: false,
1624 },
1625 )
1626 .unwrap_err();
1627 assert!(matches!(
1628 err,
1629 Error::Worktree(WorktreeError::BareRepository)
1630 ));
1631 }
1632
1633 #[test]
1634 fn list_worktrees_from_linked_checkout_normalizes_main_path() {
1635 use std::process::Command;
1636 let tmp = TempDir::new().unwrap();
1637 let main = tmp.path().join("main");
1638 fs::create_dir_all(&main).unwrap();
1639 let git = |args: &[&str]| {
1640 assert!(Command::new("git")
1641 .env("GIT_AUTHOR_NAME", "Test")
1642 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1643 .env("GIT_COMMITTER_NAME", "Test")
1644 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1645 .current_dir(&main)
1646 .args(args)
1647 .output()
1648 .unwrap()
1649 .status
1650 .success());
1651 };
1652 git(&["init", "-q", "-b", "main"]);
1653 fs::write(main.join("t"), b"1\n").unwrap();
1654 git(&["add", "t"]);
1655 git(&["commit", "-qm", "init"]);
1656
1657 let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1658 let linked = tmp.path().join("linked");
1659 add_linked_worktree(
1660 &repo,
1661 &AddLinkedWorktreeOptions {
1662 path: linked.clone(),
1663 branch: Some(WorktreeBranch::Create("wt".into())),
1664 start_point: Some("HEAD".into()),
1665 force: false,
1666 lock: None,
1667 no_checkout: false,
1668 track: false,
1669 },
1670 )
1671 .unwrap();
1672
1673 let linked_repo =
1674 Repository::discover(Some(linked.as_path()), &RepositoryOptions::empty()).unwrap();
1675 let list = list_worktrees(&linked_repo).unwrap();
1676 assert_eq!(list.len(), 2);
1677 let expected_main = path_for_disk_compare(&main);
1678 assert_eq!(list[0].path, expected_main);
1679 #[cfg(windows)]
1680 {
1681 let displayed = list[0].path.display().to_string();
1682 assert!(
1683 !displayed.starts_with(r"\\?\"),
1684 "main worktree path must not use the verbatim prefix: {displayed}"
1685 );
1686 }
1687 }
1688
1689 #[test]
1690 fn branch_checkout_exclusivity_helpers() {
1691 use std::process::Command;
1692 let tmp = TempDir::new().unwrap();
1693 let main = tmp.path().join("main");
1694 fs::create_dir_all(&main).unwrap();
1695 let git = |args: &[&str]| {
1696 assert!(Command::new("git")
1697 .env("GIT_AUTHOR_NAME", "Test")
1698 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1699 .env("GIT_COMMITTER_NAME", "Test")
1700 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1701 .current_dir(&main)
1702 .args(args)
1703 .output()
1704 .unwrap()
1705 .status
1706 .success());
1707 };
1708 git(&["init", "-q", "-b", "main"]);
1709 fs::write(main.join("t"), b"1\n").unwrap();
1710 git(&["add", "t"]);
1711 git(&["commit", "-qm", "init"]);
1712
1713 let repo = Repository::discover(Some(main.as_path()), &RepositoryOptions::empty()).unwrap();
1714 let linked = tmp.path().join("linked");
1715 add_linked_worktree(
1716 &repo,
1717 &AddLinkedWorktreeOptions {
1718 path: linked.clone(),
1719 branch: Some(WorktreeBranch::Create("feature".into())),
1720 start_point: Some("HEAD".into()),
1721 force: false,
1722 lock: None,
1723 no_checkout: false,
1724 track: false,
1725 },
1726 )
1727 .unwrap();
1728
1729 let linked_repo =
1730 Repository::discover(Some(linked.as_path()), &RepositoryOptions::empty()).unwrap();
1731 let linked_admin = linked_repo
1732 .git_dir()
1733 .canonicalize()
1734 .unwrap_or_else(|_| linked_repo.git_dir().to_path_buf());
1735 assert_eq!(worktree_admin_dir(&linked_repo), linked_admin);
1736
1737 let found = find_worktree_with_branch_checked_out(&repo, "feature", None)
1738 .unwrap()
1739 .expect("linked checkout");
1740 assert_eq!(found.branch, "feature");
1741
1742 assert!(ensure_branch_not_checked_out_in_any_worktree(&repo, "feature").is_err());
1743 assert!(ensure_branch_not_checked_out_elsewhere(&linked_repo, "feature").is_ok());
1744 assert!(ensure_branch_not_checked_out_elsewhere(&repo, "feature").is_err());
1745 }
1746
1747 #[test]
1748 fn missing_registered_path_rejects_add_without_force() {
1749 use std::process::Command;
1750 let tmp = TempDir::new().unwrap();
1751 let main = tmp.path().join("main");
1752 fs::create_dir_all(&main).unwrap();
1753 let git = |args: &[&str]| {
1754 assert!(Command::new("git")
1755 .current_dir(&main)
1756 .args(args)
1757 .output()
1758 .unwrap()
1759 .status
1760 .success());
1761 };
1762 git(&["init", "-q", "-b", "main"]);
1763 fs::write(main.join("t"), b"1\n").unwrap();
1764 git(&["add", "t"]);
1765 git(&["commit", "-qm", "init"]);
1766
1767 let repo =
1768 Repository::discover(Some(&main), &crate::environment::RepositoryOptions::empty())
1769 .unwrap();
1770 let linked = tmp.path().join("linked");
1771 add_linked_worktree(
1772 &repo,
1773 &AddLinkedWorktreeOptions {
1774 path: linked.clone(),
1775 branch: Some(WorktreeBranch::Create("a".into())),
1776 start_point: Some("HEAD".into()),
1777 force: false,
1778 lock: None,
1779 no_checkout: false,
1780 track: false,
1781 },
1782 )
1783 .unwrap();
1784 fs::remove_dir_all(&linked).unwrap();
1785 assert!(matches!(
1786 add_linked_worktree(
1787 &repo,
1788 &AddLinkedWorktreeOptions {
1789 path: linked.clone(),
1790 branch: Some(WorktreeBranch::Create("b".into())),
1791 start_point: Some("HEAD".into()),
1792 force: false,
1793 lock: None,
1794 no_checkout: false,
1795 track: false,
1796 },
1797 )
1798 .unwrap_err(),
1799 Error::Worktree(WorktreeError::PathRegisteredMissing { .. })
1800 ));
1801 }
1802
1803 #[test]
1804 fn remove_linked_worktree_refuses_main_and_locked() {
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 assert!(matches!(
1829 remove_linked_worktree(&repo, &main, false),
1830 Err(Error::Worktree(WorktreeError::MainWorktree {
1831 operation: WorktreeOperation::Remove
1832 }))
1833 ));
1834
1835 let linked = tmp.path().join("linked");
1836 add_linked_worktree(
1837 &repo,
1838 &AddLinkedWorktreeOptions {
1839 path: linked.clone(),
1840 branch: Some(WorktreeBranch::Create("wt".into())),
1841 start_point: Some("HEAD".into()),
1842 force: false,
1843 lock: None,
1844 no_checkout: false,
1845 track: false,
1846 },
1847 )
1848 .unwrap();
1849 let common = common_git_dir(repo.git_dir());
1850 let admin = fs::read_dir(common.join("worktrees"))
1851 .unwrap()
1852 .find_map(|e| e.ok())
1853 .map(|e| e.path())
1854 .expect("admin");
1855 fs::write(admin.join("locked"), "hold\n").unwrap();
1856 assert!(matches!(
1857 remove_linked_worktree(&repo, &linked, false),
1858 Err(Error::Worktree(WorktreeError::Locked { reason: Some(r) })) if r == "hold"
1859 ));
1860 assert!(remove_linked_worktree(&repo, &linked, true).is_ok());
1861 assert!(matches!(
1862 remove_linked_worktree(&repo, &linked, false),
1863 Err(Error::Worktree(WorktreeError::NotRegistered { .. }))
1864 ));
1865 }
1866}