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