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