1use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use super::direnv::{self, EnvrcApproval, LocalOverrides};
25use super::error::{Result, WorktreeError};
26use super::git;
27use super::hooks::{self, HooksPath};
28use super::identity::{RepositoryIdentity, ResolvedPath, TrunkSource};
29use super::lock::WorktreeLock;
30use super::model::{
31 BranchState, ContainerView, DiagnosticCode, DirtyState, GuardVerdict, InspectReport,
32 RepairReport, RepositoryTopology, RepositoryView, Severity, Standing, StatusReport,
33 TopologyDiagnostic, TopologyTallies, TrunkView, WorktreeKind, WorktreeState, SCHEMA,
34};
35use super::path::{self, BranchName, CONTAINER_SUFFIX};
36use super::state::{self, BranchRef};
37use super::topology::WorktreeRecord;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Detail {
42 Fast,
44 Full,
46}
47
48#[derive(Debug, Clone, Default)]
50pub struct CreateRequest {
51 pub branch: String,
53 pub base: Option<String>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(tag = "from", rename_all = "snake_case")]
82pub enum StartPoint {
83 Given {
85 reference: String,
87 },
88 RemoteTrunk {
90 remote: String,
92 branch: String,
94 },
95 RemoteBranch {
97 remote: String,
99 branch: String,
101 fetched: bool,
104 },
105 LocalTrunk {
107 branch: String,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 unreachable: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 reason: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 behind: Option<u64>,
119 },
120 Head,
122}
123
124impl StartPoint {
125 pub fn reference(&self) -> String {
135 match self {
136 StartPoint::Given { reference } => reference.clone(),
137 StartPoint::RemoteTrunk { remote, branch }
138 | StartPoint::RemoteBranch { remote, branch, .. } => format!("{remote}/{branch}"),
139 StartPoint::LocalTrunk { branch, .. } => branch.clone(),
140 StartPoint::Head => "HEAD".into(),
141 }
142 }
143
144 pub fn fallback(&self) -> Option<String> {
162 match self {
163 StartPoint::LocalTrunk {
164 branch,
165 unreachable: Some(remote),
166 reason,
167 behind,
168 } => {
169 let why = reason.as_deref().unwrap_or("it did not answer");
170 let distance = match behind {
171 Some(0) => format!("level with {remote}/{branch} as last fetched"),
172 Some(1) => format!("1 commit behind {remote}/{branch} as last fetched"),
173 Some(n) => format!("{n} commits behind {remote}/{branch} as last fetched"),
174 None => format!("{remote}/{branch} was never fetched, so how far behind is unknown"),
175 };
176 Some(format!(
177 "could not fetch {branch} from {remote} ({why}): started from the local {branch}, {distance}; \
178 once {remote} answers, `git fetch {remote} && git reset --hard {remote}/{branch}` in the worktree before the first commit"
179 ))
180 }
181 StartPoint::RemoteBranch {
182 remote,
183 branch,
184 fetched: false,
185 } => Some(format!(
186 "could not fetch {branch} from {remote}: tracking {remote}/{branch} as last fetched, which may be behind"
187 )),
188 _ => None,
189 }
190 }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
195pub struct CreateReport {
196 pub path: String,
198 pub branch: String,
200 pub branch_created: bool,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub base: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub start: Option<StartPoint>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub start_commit: Option<String>,
212 pub container: String,
214 pub container_created: bool,
216 pub existed: bool,
218 pub envrc: EnvrcApproval,
222 #[serde(default = "default_local")]
224 pub envrc_local: LocalOverrides,
225 #[serde(default = "default_hooks")]
228 pub hooks: HooksPath,
229}
230
231fn trunk_remote(primary: &Path, trunk: &str) -> Result<Option<String>> {
234 let remotes = git::try_run(primary, &["remote"])?;
235 if remotes.status != Some(0) {
236 return Ok(None);
237 }
238 let remotes: Vec<String> = remotes
239 .text()?
240 .lines()
241 .map(str::trim)
242 .filter(|r| !r.is_empty())
243 .map(str::to_string)
244 .collect();
245 for remote in &remotes {
246 let head = git::try_run(
248 primary,
249 &["symbolic-ref", "-q", &format!("refs/remotes/{remote}/HEAD")],
250 )
251 .ok()
252 .filter(|head| head.status == Some(0));
253 if let Some(head) = head {
254 if head.text()? == format!("refs/remotes/{remote}/{trunk}") {
255 return Ok(Some(remote.clone()));
256 }
257 }
258 }
259 let configured = git::try_run(
260 primary,
261 &["config", "--get", &format!("branch.{trunk}.remote")],
262 )?;
263 if configured.status == Some(0) {
264 let remote = configured.text()?;
265 if remotes.contains(&remote) {
266 return Ok(Some(remote));
267 }
268 }
269 Ok(None)
270}
271
272fn default_local() -> LocalOverrides {
273 LocalOverrides::NonePrimary
274}
275
276fn default_hooks() -> HooksPath {
277 HooksPath::NotConfigured
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
282pub struct RemoveReport {
283 pub path: String,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub branch: Option<String>,
288 pub forced: bool,
290}
291
292#[derive(Debug, Clone)]
296pub struct WorktreeService {
297 identity: RepositoryIdentity,
298 container: ResolvedPath,
299 issue_ids: Vec<String>,
300 scratch_roots: Vec<ScratchRoot>,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct ScratchRoot {
307 pub provider: Option<String>,
309 pub path: PathBuf,
311}
312
313impl WorktreeService {
314 pub fn open(start: &Path) -> Result<Self> {
319 let identity = RepositoryIdentity::discover(start)?;
320 Self::over(identity)
321 }
322
323 pub fn over(identity: RepositoryIdentity) -> Result<Self> {
325 let primary = identity.primary_worktree().path.clone();
326 let current = identity.current_worktree().path.clone();
327 let scratch_roots = declared_scratch_roots(¤t, &primary);
328 Self::over_with(identity, scratch_roots)
329 }
330
331 pub fn over_with(
333 identity: RepositoryIdentity,
334 scratch_roots: Vec<ScratchRoot>,
335 ) -> Result<Self> {
336 let container = ResolvedPath::of(path::container_root(&identity.primary_worktree().path)?);
337 let issue_ids = state::issue_ids(&identity.primary_worktree().path);
338 Ok(WorktreeService {
339 identity,
340 container,
341 issue_ids,
342 scratch_roots,
343 })
344 }
345
346 pub fn scratch_roots(&self) -> &[ScratchRoot] {
348 &self.scratch_roots
349 }
350
351 pub fn identity(&self) -> &RepositoryIdentity {
353 &self.identity
354 }
355
356 pub fn container(&self) -> &ResolvedPath {
358 &self.container
359 }
360
361 pub fn expected_path_of(&self, branch: &str) -> Result<PathBuf> {
363 path::expected_path(&self.container.path, &BranchName::parse(branch)?)
364 }
365
366 fn repository_view(&self) -> RepositoryView {
369 RepositoryView {
370 primary_worktree: display(&self.identity.primary_worktree().path),
371 git_common_dir: display(&self.identity.git_common_dir().path),
372 name: self.identity.repository_name(),
373 }
374 }
375
376 fn container_view(&self) -> ContainerView {
377 ContainerView {
378 path: display(&self.container.path),
379 suffix: CONTAINER_SUFFIX.to_string(),
380 exists: self.container.path.is_dir(),
381 }
382 }
383
384 fn trunk_view(&self) -> TrunkView {
385 let trunk = self.identity.trunk();
386 TrunkView {
387 branch: trunk.branch.clone(),
388 source: trunk.source,
389 checked_out_at: trunk
390 .branch
391 .as_deref()
392 .and_then(|b| self.identity.record_of_branch(b))
393 .map(|r| display(&r.path)),
394 }
395 }
396
397 pub fn judge(
402 &self,
403 record: &WorktreeRecord,
404 detail: Detail,
405 branches: &BTreeMap<String, BranchRef>,
406 ) -> WorktreeState {
407 let dirty = Self::measures_dirty(record, detail)
408 .then(|| state::dirty_state(&record.path).ok())
409 .flatten();
410 self.judge_with(record, branches, dirty)
411 }
412
413 fn measures_dirty(record: &WorktreeRecord, detail: Detail) -> bool {
418 detail == Detail::Full && record.path.is_dir() && !record.bare
419 }
420
421 fn judge_with(
424 &self,
425 record: &WorktreeRecord,
426 branches: &BTreeMap<String, BranchRef>,
427 dirty: Option<DirtyState>,
428 ) -> WorktreeState {
429 let resolved = ResolvedPath::of(&record.path);
430 let is_primary = self.identity.is_primary(record);
431 let exists = record.path.is_dir();
432 let trunk = self.identity.trunk();
433 let mut diagnostics = Vec::new();
434 let path_text = display(&record.path);
435
436 let label = match (&record.branch, &record.head) {
437 (Some(b), _) => b.clone(),
438 (None, Some(h)) => path::detached_label(h),
439 (None, None) => "(unborn)".to_string(),
440 };
441
442 let expected: Option<PathBuf> = match record.branch.as_deref() {
444 Some(b) => match BranchName::parse(b)
445 .and_then(|n| path::expected_path(&self.container.path, &n))
446 {
447 Ok(p) => Some(p),
448 Err(e) => {
449 diagnostics.push(TopologyDiagnostic {
450 code: match e {
451 WorktreeError::PathEscape { .. } => DiagnosticCode::PathEscape,
452 _ => DiagnosticCode::InvalidBranchName,
453 },
454 severity: Severity::Error,
455 path: Some(path_text.clone()),
456 branch: Some(b.to_string()),
457 expected: None,
458 message: e.to_string(),
459 remedy: "rename the branch (git branch -m) to a name git and the filesystem both accept".into(),
460 });
461 None
462 }
463 },
464 None => None,
465 };
466 let expected_text = expected.as_deref().map(display);
467
468 let standing = if is_primary {
469 if let Some(b) = record.branch.as_deref() {
470 if let Some(t) = trunk.branch.as_deref() {
471 if b != t {
472 diagnostics.push(TopologyDiagnostic {
473 code: DiagnosticCode::PrimaryOnNonTrunk,
474 severity: Severity::Error,
475 path: Some(path_text.clone()),
476 branch: Some(b.to_string()),
477 expected: expected_text.clone(),
478 message: format!(
479 "the primary checkout holds '{b}', and the primary checkout hosts the trunk '{t}'"
480 ),
481 remedy: format!(
482 "when the primary checkout is clean: git switch {t}; then majordomus worktree create {b}"
483 ),
484 });
485 }
486 }
487 }
488 Standing::Primary
489 } else if record.prunable.is_some() || !exists {
490 diagnostics.push(TopologyDiagnostic {
491 code: if record.prunable.is_some() {
492 DiagnosticCode::StaleRegistration
493 } else {
494 DiagnosticCode::Missing
495 },
496 severity: Severity::Warning,
497 path: Some(path_text.clone()),
498 branch: record.branch.clone(),
499 expected: expected_text.clone(),
500 message: match &record.prunable {
501 Some(reason) if !reason.is_empty() => {
502 format!("git reports it prunable: {reason}")
503 }
504 _ => "the directory git registered does not exist".into(),
505 },
506 remedy: "majordomus worktree repair".into(),
507 });
508 Standing::Missing
509 } else if record.branch.is_none() {
510 diagnostics.push(TopologyDiagnostic {
511 code: DiagnosticCode::Detached,
512 severity: Severity::Info,
513 path: Some(path_text.clone()),
514 branch: None,
515 expected: None,
516 message: "HEAD is detached: no branch, so no canonical path; nothing moves it"
517 .into(),
518 remedy: "git switch -c <branch> there, then majordomus worktree migrate".into(),
519 });
520 Standing::Detached
521 } else if let Some(root) = self.scratch_root_of(&resolved) {
522 diagnostics.push(TopologyDiagnostic {
523 code: DiagnosticCode::Ephemeral,
524 severity: Severity::Warning,
525 path: Some(path_text.clone()),
526 branch: record.branch.clone(),
527 expected: expected_text.clone(),
528 message: format!(
529 "a session's scratch checkout under {}{}, holding branch '{}' which belongs at {}; nothing moves it while the session that made it may be running",
530 display(&root.path),
531 root.provider
532 .as_deref()
533 .map(|p| format!(" (created by {p})"))
534 .unwrap_or_default(),
535 label,
536 expected_text.as_deref().unwrap_or("-")
537 ),
538 remedy: format!(
539 "detach it (git switch --detach) and continue in the canonical worktree (majordomus worktree create {}); or majordomus worktree migrate --include-ephemeral --only {}",
540 label, label
541 ),
542 });
543 Standing::Ephemeral
544 } else if resolved.same_as(&self.container) {
545 diagnostics.push(TopologyDiagnostic {
546 code: DiagnosticCode::ContainerOccupied,
547 severity: Severity::Error,
548 path: Some(path_text.clone()),
549 branch: record.branch.clone(),
550 expected: expected_text.clone(),
551 message: format!(
552 "it occupies the container {} itself; the container holds worktrees and cannot be one",
553 display(&self.container.path)
554 ),
555 remedy: "majordomus worktree migrate".into(),
556 });
557 Standing::Misplaced
558 } else if expected.as_deref().is_some_and(|e| {
559 ResolvedPath::of(e).same_as(&resolved) && !symlink_below(&self.container.path, e)
560 }) {
561 Standing::Canonical
562 } else if expected.is_some() {
563 diagnostics.push(TopologyDiagnostic {
564 code: DiagnosticCode::PathMismatch,
565 severity: Severity::Error,
566 path: Some(path_text.clone()),
567 branch: record.branch.clone(),
568 expected: expected_text.clone(),
569 message: format!(
570 "branch '{}' belongs at {}",
571 label,
572 expected_text.as_deref().unwrap_or("-")
573 ),
574 remedy: "majordomus worktree migrate".into(),
575 });
576 let inside_primary = resolved.is_inside(self.identity.primary_worktree());
577 let inside_other = self
578 .identity
579 .registered_worktrees()
580 .iter()
581 .filter(|r| r.path != record.path)
582 .any(|r| resolved.is_inside(&ResolvedPath::of(&r.path)));
583 if inside_primary || inside_other {
584 diagnostics.push(TopologyDiagnostic {
585 code: DiagnosticCode::Nested,
586 severity: Severity::Warning,
587 path: Some(path_text.clone()),
588 branch: record.branch.clone(),
589 expected: expected_text.clone(),
590 message: if inside_primary {
591 "it sits inside the primary checkout".into()
592 } else {
593 "it sits inside another worktree of this repository".into()
594 },
595 remedy: "majordomus worktree migrate".into(),
596 });
597 }
598 Standing::Misplaced
599 } else {
600 Standing::Misplaced
602 };
603
604 if standing == Standing::Misplaced {
605 if let Some(reason) = &record.locked {
606 diagnostics.push(TopologyDiagnostic {
607 code: DiagnosticCode::Locked,
608 severity: Severity::Warning,
609 path: Some(path_text.clone()),
610 branch: record.branch.clone(),
611 expected: expected_text.clone(),
612 message: if reason.is_empty() {
613 "it is locked, so git will not move it".into()
614 } else {
615 format!("it is locked ({reason}), so git will not move it")
616 },
617 remedy: format!("git worktree unlock {path_text}"),
618 });
619 }
620 if let Some(e) = expected.as_deref() {
621 if let Some(d) = self.destination_conflict(e, Some(record)) {
622 diagnostics.push(d);
623 }
624 }
625 }
626
627 if !is_primary && record.branch.as_deref().is_some_and(|b| trunk.is(b)) {
628 diagnostics.push(TopologyDiagnostic {
629 code: DiagnosticCode::TrunkInLinkedWorktree,
630 severity: Severity::Warning,
631 path: Some(path_text.clone()),
632 branch: record.branch.clone(),
633 expected: Some(display(&self.identity.primary_worktree().path)),
634 message: "the trunk is checked out in a linked worktree; the primary checkout hosts it".into(),
635 remedy: "switch the primary checkout to the trunk and remove this worktree when it is clean".into(),
636 });
637 }
638
639 WorktreeState {
640 path: path_text,
641 kind: if is_primary {
642 WorktreeKind::Primary
643 } else {
644 WorktreeKind::Linked
645 },
646 standing,
647 branch: record.branch.clone(),
648 label,
649 head: record.head.clone(),
650 detached: record.detached,
651 expected_path: if is_primary && record.branch.as_deref().is_some_and(|b| trunk.is(b)) {
652 None
653 } else {
654 expected_text
655 },
656 exists,
657 current: self.identity.is_current(record),
658 locked: record.locked.clone(),
659 prunable: record.prunable.clone(),
660 dirty,
661 upstream: record
662 .branch
663 .as_deref()
664 .and_then(|b| branches.get(b))
665 .and_then(|b| b.upstream.clone()),
666 issue: record
667 .branch
668 .as_deref()
669 .and_then(|b| state::issue_of(b, &self.issue_ids)),
670 diagnostics,
671 }
672 }
673
674 pub fn scratch_root_of(&self, path: &ResolvedPath) -> Option<ScratchRoot> {
682 let primary = self.identity.primary_worktree();
683 for root in &self.scratch_roots {
684 let resolved = ResolvedPath::of(root.path.clone());
685 if primary.is_inside(&resolved) || primary.same_as(&resolved) {
686 continue;
687 }
688 if path.is_inside(&resolved) {
689 return Some(ScratchRoot {
690 provider: root.provider.clone(),
691 path: resolved.path,
692 });
693 }
694 }
695 None
696 }
697
698 pub fn ephemeral_root_of(&self, path: &ResolvedPath) -> Option<PathBuf> {
700 self.scratch_root_of(path).map(|r| r.path)
701 }
702
703 pub(crate) fn destination_conflict(
705 &self,
706 expected: &Path,
707 own: Option<&WorktreeRecord>,
708 ) -> Option<TopologyDiagnostic> {
709 let meta = std::fs::symlink_metadata(expected).ok()?;
710 let resolved = ResolvedPath::of(expected);
711 if !meta.file_type().is_symlink() {
714 if let Some(r) = self.identity.record_at(&resolved) {
715 if own.is_some_and(|o| o.path == r.path) {
716 return None;
717 }
718 return Some(TopologyDiagnostic {
719 code: DiagnosticCode::DestinationConflict,
720 severity: Severity::Error,
721 path: own.map(|o| display(&o.path)),
722 branch: own.and_then(|o| o.branch.clone()),
723 expected: Some(display(expected)),
724 message: format!(
725 "the canonical path is occupied by a registered worktree of this repository holding {}",
726 r.branch.clone().unwrap_or_else(|| "a detached HEAD".into())
727 ),
728 remedy: "move that worktree to its own canonical path first (majordomus worktree migrate)".into(),
729 });
730 }
731 }
732 let what = if meta.file_type().is_symlink() {
733 "a symbolic link".to_string()
734 } else if meta.is_dir() {
735 if expected.join(".git").exists() {
736 "a git checkout this repository has not registered".to_string()
737 } else if std::fs::read_dir(expected)
738 .map(|mut d| d.next().is_none())
739 .unwrap_or(false)
740 {
741 "an empty directory".to_string()
742 } else {
743 "an unrelated directory with content".to_string()
744 }
745 } else {
746 "a file".to_string()
747 };
748 Some(TopologyDiagnostic {
749 code: DiagnosticCode::DestinationConflict,
750 severity: Severity::Error,
751 path: own.map(|o| display(&o.path)),
752 branch: own.and_then(|o| o.branch.clone()),
753 expected: Some(display(expected)),
754 message: format!("the canonical path is occupied by {what}; nothing overwrites it"),
755 remedy: format!(
756 "inspect and move it aside by hand: ls -la {}",
757 display(expected)
758 ),
759 })
760 }
761
762 pub fn topology(&self, detail: Detail) -> Result<RepositoryTopology> {
766 let primary = &self.identity.primary_worktree().path;
767 let trunk = self.identity.trunk();
768 let branch_refs = state::branches(primary)?;
769 let branches: BTreeMap<String, BranchRef> = branch_refs
770 .iter()
771 .map(|b| (b.name.clone(), b.clone()))
772 .collect();
773 let merged: Option<BTreeSet<String>> = match trunk.branch.as_deref() {
774 Some(t) => Some(state::merged_into_trunk(primary, t)?),
775 None => None,
776 };
777
778 let records = self.identity.registered_worktrees();
783 let probes: Vec<Option<&Path>> = records
784 .iter()
785 .map(|r| Self::measures_dirty(r, detail).then_some(r.path.as_path()))
786 .collect();
787 let mut worktrees: Vec<WorktreeState> = records
788 .iter()
789 .zip(state::dirty_states(&probes))
790 .map(|(r, d)| self.judge_with(r, &branches, d))
791 .collect();
792
793 let mut repository_diagnostics = Vec::new();
795 if trunk.branch.is_none() {
796 repository_diagnostics.push(TopologyDiagnostic {
797 code: DiagnosticCode::TrunkUnknown,
798 severity: Severity::Warning,
799 path: None,
800 branch: None,
801 expected: None,
802 message: "the trunk could not be determined: no remote HEAD, no init.defaultBranch, neither main nor master alone, and the primary checkout is detached".into(),
803 remedy: "git remote set-head origin --auto, or git config init.defaultBranch <branch>".into(),
804 });
805 }
806 let mut by_folded: BTreeMap<String, Vec<String>> = BTreeMap::new();
807 for w in &worktrees {
808 if let (Some(e), Some(b)) = (&w.expected_path, &w.branch) {
809 by_folded
810 .entry(e.to_lowercase())
811 .or_default()
812 .push(b.clone());
813 }
814 }
815 for (_, names) in by_folded.iter().filter(|(_, n)| n.len() > 1) {
816 repository_diagnostics.push(TopologyDiagnostic {
817 code: DiagnosticCode::CaseCollision,
818 severity: Severity::Warning,
819 path: None,
820 branch: Some(names.join(", ")),
821 expected: None,
822 message: "these branch names derive paths that differ only by case, which one directory on a case-insensitive filesystem".into(),
823 remedy: "rename one of the branches (git branch -m)".into(),
824 });
825 }
826
827 let by_path: BTreeMap<String, &WorktreeState> =
828 worktrees.iter().map(|w| (w.path.clone(), w)).collect();
829 let mut branch_states: Vec<BranchState> = branch_refs
830 .iter()
831 .map(|b| {
832 let is_trunk = trunk.is(&b.name);
833 let worktree_path = b.worktree.as_deref().map(display);
834 let holder = worktree_path.as_deref().and_then(|p| by_path.get(p));
835 let merged_into_trunk = merged.as_ref().map(|m| m.contains(&b.name));
836 let cleanup_eligible = !is_trunk
840 && merged_into_trunk == Some(true)
841 && match holder {
842 None => true,
843 Some(h) => {
844 h.standing != Standing::Ephemeral
845 && h.dirty.as_ref().is_some_and(|d| d.clean)
846 }
847 };
848 BranchState {
849 name: b.name.clone(),
850 head: b.head.clone(),
851 trunk: is_trunk,
852 expected_path: if is_trunk {
853 None
854 } else {
855 self.expected_path_of(&b.name).ok().map(|p| display(&p))
856 },
857 worktree: worktree_path,
858 upstream: b.upstream.clone(),
859 merged_into_trunk,
860 cleanup_eligible,
861 issue: state::issue_of(&b.name, &self.issue_ids),
862 }
863 })
864 .collect();
865 crate::order::canonical(&mut branch_states);
866
867 let mut tallies = TopologyTallies {
868 worktrees: worktrees.len(),
869 branches: branch_states.len(),
870 branches_without_worktree: branch_states
871 .iter()
872 .filter(|b| b.worktree.is_none())
873 .count(),
874 cleanup_eligible: branch_states.iter().filter(|b| b.cleanup_eligible).count(),
875 ..TopologyTallies::default()
876 };
877 for w in &worktrees {
878 match w.standing {
879 Standing::Canonical => tallies.canonical += 1,
880 Standing::Misplaced => tallies.misplaced += 1,
881 Standing::Detached => tallies.detached += 1,
882 Standing::Ephemeral => tallies.ephemeral += 1,
883 Standing::Missing => tallies.missing += 1,
884 Standing::Primary => {}
885 }
886 if w.locked.is_some() {
887 tallies.locked += 1;
888 }
889 if w.dirty.as_ref().is_some_and(|d| !d.clean) {
890 tallies.dirty += 1;
891 }
892 }
893 let mut diagnostics: Vec<TopologyDiagnostic> = worktrees
894 .iter_mut()
895 .flat_map(|w| w.diagnostics.clone())
896 .collect();
897 diagnostics.extend(repository_diagnostics);
898 tallies.errors = diagnostics
899 .iter()
900 .filter(|d| d.severity == Severity::Error)
901 .count();
902 tallies.warnings = diagnostics
903 .iter()
904 .filter(|d| d.severity == Severity::Warning)
905 .count();
906
907 Ok(RepositoryTopology {
908 schema: SCHEMA.to_string(),
909 repository: self.repository_view(),
910 container: self.container_view(),
911 trunk: self.trunk_view(),
912 observed_from: display(&self.identity.current_worktree().path),
913 valid: tallies.errors == 0,
914 worktrees,
915 branches: branch_states,
916 diagnostics,
917 tallies,
918 })
919 }
920
921 pub fn status(&self) -> Result<StatusReport> {
923 let topology = self.topology(Detail::Fast)?;
924 let record = self.identity.current_record().cloned().ok_or_else(|| {
925 WorktreeError::NotThisRepository {
926 path: self.identity.current_worktree().path.clone(),
927 git_common_dir: self.identity.git_common_dir().path.clone(),
928 }
929 })?;
930 let branches: BTreeMap<String, BranchRef> =
931 state::branches(&self.identity.primary_worktree().path)?
932 .into_iter()
933 .map(|b| (b.name.clone(), b))
934 .collect();
935 let worktree = self.judge(&record, Detail::Full, &branches);
936 let canonical = is_in_place(&worktree, self.identity.trunk().source);
937 Ok(StatusReport {
938 schema: SCHEMA.to_string(),
939 repository: topology.repository,
940 container: topology.container,
941 trunk: topology.trunk,
942 worktree,
943 canonical,
944 repository_errors: topology.tallies.errors,
945 })
946 }
947
948 pub fn guard(&self) -> Result<GuardVerdict> {
950 let status = self.status()?;
951 let w = status.worktree;
952 let exempt = matches!(w.standing, Standing::Detached)
953 || (w.standing == Standing::Primary && w.expected_path.is_none());
954 let reason = w
955 .diagnostics
956 .iter()
957 .find(|d| d.severity == Severity::Error || d.code == DiagnosticCode::Ephemeral)
958 .cloned();
959 Ok(GuardVerdict {
960 ok: status.canonical,
961 path: w.path,
962 branch: w.branch,
963 expected_path: w.expected_path,
964 standing: w.standing,
965 exempt,
966 reason: if status.canonical { None } else { reason },
967 })
968 }
969
970 pub fn inspect(&self, branch: &str) -> Result<InspectReport> {
972 let name = BranchName::parse(branch)?;
973 let expected = path::expected_path(&self.container.path, &name)?;
974 let primary = &self.identity.primary_worktree().path;
975 let branches: BTreeMap<String, BranchRef> = state::branches(primary)?
976 .into_iter()
977 .map(|b| (b.name.clone(), b))
978 .collect();
979 let worktree = self
980 .identity
981 .record_of_branch(branch)
982 .map(|r| self.judge(r, Detail::Full, &branches));
983 let canonical = worktree
984 .as_ref()
985 .is_some_and(|w| w.standing == Standing::Canonical);
986 let mut diagnostics: Vec<TopologyDiagnostic> = worktree
987 .as_ref()
988 .map(|w| w.diagnostics.clone())
989 .unwrap_or_default();
990 if worktree.is_none() {
991 if let Some(d) = self.destination_conflict(&expected, None) {
992 diagnostics.push(d);
993 }
994 }
995 Ok(InspectReport {
996 branch: branch.to_string(),
997 branch_exists: branches.contains_key(branch),
998 expected_path: display(&expected),
999 destination_exists: std::fs::symlink_metadata(&expected).is_ok(),
1000 worktree,
1001 canonical,
1002 diagnostics,
1003 })
1004 }
1005
1006 pub fn resolve(&self, selector: &str) -> Result<WorktreeRecord> {
1012 if let Some(r) = self.identity.record_of_branch(selector) {
1013 return Ok(r.clone());
1014 }
1015 let wanted = ResolvedPath::of(PathBuf::from(selector));
1016 if let Some(r) = self.identity.record_at(&wanted) {
1017 return Ok(r.clone());
1018 }
1019 Err(WorktreeError::NoSuchWorktree {
1020 selector: selector.to_string(),
1021 })
1022 }
1023
1024 pub fn create(&self, request: &CreateRequest) -> Result<CreateReport> {
1030 self.create_inner(request, false)
1031 }
1032
1033 pub fn ensure(&self, request: &CreateRequest) -> Result<CreateReport> {
1036 self.create_inner(request, true)
1037 }
1038
1039 fn create_inner(&self, request: &CreateRequest, ensure: bool) -> Result<CreateReport> {
1040 let name = BranchName::parse(&request.branch)?;
1041 let target = path::expected_path(&self.container.path, &name)?;
1043 let primary = self.identity.primary_worktree().path.clone();
1044
1045 let unknown_here = matches!(git::branch_exists(&primary, name.as_str()), Ok(false));
1049 let planned = if request.base.is_none() && unknown_here {
1050 Some(self.plan_start(&primary, name.as_str())?)
1051 } else {
1052 None
1053 };
1054 self.create_locked(request, ensure, name, target, planned)
1055 }
1056
1057 fn create_locked(
1062 &self,
1063 request: &CreateRequest,
1064 ensure: bool,
1065 name: BranchName,
1066 target: PathBuf,
1067 planned: Option<StartPoint>,
1068 ) -> Result<CreateReport> {
1069 let resolved_target = ResolvedPath::of(&target);
1070 let primary = self.identity.primary_worktree().path.clone();
1071
1072 let _lock = WorktreeLock::acquire(&self.identity.git_common_dir().path)?;
1074 let mut identity = self.identity.clone();
1075 identity.refresh()?;
1076
1077 if let Some(existing) = identity.record_of_branch(name.as_str()) {
1078 if ResolvedPath::of(&existing.path).same_as(&resolved_target) {
1079 if ensure {
1080 let envrc = direnv::approve(&primary, &existing.path);
1083 let envrc_local = direnv::link_local(&primary, &existing.path);
1084 let hooks = hooks::carry_hooks_path(&primary, &existing.path);
1085 return Ok(CreateReport {
1086 path: display(&existing.path),
1087 branch: name.as_str().to_string(),
1088 branch_created: false,
1089 base: None,
1090 start: None,
1091 start_commit: None,
1092 container: display(&self.container.path),
1093 container_created: false,
1094 existed: true,
1095 envrc,
1096 envrc_local,
1097 hooks,
1098 });
1099 }
1100 return Err(WorktreeError::WorktreeAlreadyExists {
1101 path: existing.path.clone(),
1102 branch: name.as_str().to_string(),
1103 });
1104 }
1105 return Err(WorktreeError::BranchAlreadyCheckedOut {
1106 branch: name.as_str().to_string(),
1107 path: existing.path.clone(),
1108 expected: target,
1109 });
1110 }
1111 if let Some(d) = self.destination_conflict(&target, None) {
1112 return Err(WorktreeError::DestinationConflict {
1113 path: target,
1114 what: d.message,
1115 });
1116 }
1117 if !git::branch_name_is_valid(&primary, name.as_str())? {
1118 return Err(WorktreeError::InvalidBranchName {
1119 given: name.as_str().to_string(),
1120 reason: "git does not accept it as a reference name".into(),
1121 });
1122 }
1123 let branch_exists = git::branch_exists(&primary, name.as_str())?;
1124 let start = if branch_exists {
1125 None
1126 } else {
1127 let start = match (&request.base, planned) {
1128 (Some(given), _) => StartPoint::Given {
1129 reference: given.clone(),
1130 },
1131 (None, Some(planned)) => planned,
1132 (None, None) => self.plan_start(&primary, name.as_str())?,
1133 };
1134 let base = start.reference();
1135 if !git::rev_exists(&primary, &base)? {
1136 return Err(WorktreeError::BaseDoesNotExist { base });
1137 }
1138 Some(start)
1139 };
1140 let base = start.as_ref().map(StartPoint::reference);
1141
1142 let container_created = self.ensure_container()?;
1143 if let Some(parent) = target.parent() {
1144 std::fs::create_dir_all(parent).map_err(|e| WorktreeError::io(parent, &e))?;
1145 }
1146 let target_text = display(&target);
1147 let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
1148 match (&start, &base) {
1149 (None, _) | (_, None) => {
1150 args.push("--".into());
1151 args.push(target_text.clone());
1152 args.push(name.as_str().to_string());
1153 }
1154 (Some(start), Some(base)) => {
1155 match start {
1156 StartPoint::RemoteBranch { .. } => args.push("--track".into()),
1158 StartPoint::RemoteTrunk { .. } => args.push("--no-track".into()),
1161 _ => {}
1162 }
1163 args.push("-b".into());
1164 args.push(name.as_str().to_string());
1165 args.push("--".into());
1166 args.push(target_text.clone());
1167 args.push(base.clone());
1168 }
1169 }
1170 git::run(&primary, &args)?;
1171
1172 identity.refresh()?;
1174 let registered = identity
1175 .record_at(&resolved_target)
1176 .cloned()
1177 .ok_or_else(|| WorktreeError::MigrationUnsafe {
1178 path: target.clone(),
1179 reason: "git reported success and `git worktree list` does not show the worktree; \
1180 nothing was cleaned up because that would risk deleting real work"
1181 .into(),
1182 })?;
1183 let envrc = direnv::approve(&primary, ®istered.path);
1185 let envrc_local = direnv::link_local(&primary, ®istered.path);
1186 let hooks = hooks::carry_hooks_path(&primary, ®istered.path);
1188 let start_commit = match start {
1189 Some(_) => git::head_of(®istered.path).ok().flatten(),
1191 None => None,
1192 };
1193 Ok(CreateReport {
1194 path: display(®istered.path),
1195 branch: name.as_str().to_string(),
1196 branch_created: base.is_some(),
1197 base,
1198 start,
1199 start_commit,
1200 container: display(&self.container.path),
1201 container_created,
1202 existed: false,
1203 envrc,
1204 envrc_local,
1205 hooks,
1206 })
1207 }
1208
1209 fn plan_start(&self, primary: &Path, branch: &str) -> Result<StartPoint> {
1217 let Some(trunk) = self.identity.trunk().branch.clone() else {
1218 return Ok(StartPoint::Head);
1219 };
1220 let Some(remote) = trunk_remote(primary, &trunk)? else {
1221 return Ok(StartPoint::LocalTrunk {
1222 branch: trunk,
1223 unreachable: None,
1224 reason: None,
1225 behind: None,
1226 });
1227 };
1228 let tracking = |b: &str| format!("refs/remotes/{remote}/{b}");
1229 let refspec = |b: &str| format!("+refs/heads/{b}:{}", tracking(b));
1230 let trunk_fetch =
1231 git::fetch_bounded(primary, &remote, &[refspec(&trunk)], git::FETCH_TIMEOUT);
1232 if trunk_fetch.is_ok() {
1233 match git::fetch_bounded(primary, &remote, &[refspec(branch)], git::FETCH_TIMEOUT) {
1234 Ok(()) => {
1235 return Ok(StartPoint::RemoteBranch {
1236 remote,
1237 branch: branch.to_string(),
1238 fetched: true,
1239 })
1240 }
1241 Err(git::FetchFailure::NoSuchRemoteRef) => {
1244 return Ok(StartPoint::RemoteTrunk {
1245 remote,
1246 branch: trunk,
1247 })
1248 }
1249 Err(git::FetchFailure::Unreachable(_)) => {}
1250 }
1251 }
1252 if git::rev_exists(primary, &tracking(branch))? {
1254 return Ok(StartPoint::RemoteBranch {
1255 remote,
1256 branch: branch.to_string(),
1257 fetched: false,
1258 });
1259 }
1260 let reason = match trunk_fetch {
1261 Ok(()) => {
1263 return Ok(StartPoint::RemoteTrunk {
1264 remote,
1265 branch: trunk,
1266 })
1267 }
1268 Err(git::FetchFailure::NoSuchRemoteRef) => format!("{remote} has no branch {trunk}"),
1269 Err(git::FetchFailure::Unreachable(why)) => why,
1270 };
1271 let behind = git::commits_between(primary, &trunk, &tracking(&trunk));
1272 Ok(StartPoint::LocalTrunk {
1273 branch: trunk,
1274 unreachable: Some(remote),
1275 reason: Some(reason),
1276 behind,
1277 })
1278 }
1279
1280 pub(crate) fn ensure_container(&self) -> Result<bool> {
1284 let p = &self.container.path;
1285 if p.is_dir() {
1286 return Ok(false);
1287 }
1288 if std::fs::symlink_metadata(p).is_ok() {
1289 return Err(WorktreeError::ContainerNotADirectory { path: p.clone() });
1290 }
1291 std::fs::create_dir_all(p).map_err(|e| WorktreeError::io(p, &e))?;
1292 Ok(true)
1293 }
1294
1295 pub fn remove(&self, selector: &str, force: bool) -> Result<RemoveReport> {
1300 let _lock = WorktreeLock::acquire(&self.identity.git_common_dir().path)?;
1301 let mut identity = self.identity.clone();
1302 identity.refresh()?;
1303 let record = self.resolve(selector)?;
1304 if identity.is_primary(&record) {
1305 return Err(WorktreeError::PrimaryWorktreeProtected {
1306 path: record.path.clone(),
1307 operation: "remove".into(),
1308 });
1309 }
1310 let locked = record.locked.is_some();
1311 if locked && !force {
1312 return Err(WorktreeError::LockedWorktree {
1313 path: record.path.clone(),
1314 reason: record.locked.clone(),
1315 });
1316 }
1317 let mut forced = false;
1318 if record.path.is_dir() {
1319 let dirty = state::dirty_state(&record.path)?;
1320 if !dirty.clean {
1321 if !force {
1322 return Err(WorktreeError::DirtyWorktree {
1323 path: record.path.clone(),
1324 summary: dirty.summary(),
1325 operation: "remove".into(),
1326 });
1327 }
1328 forced = true;
1329 }
1330 }
1331 let mut args: Vec<String> = vec!["worktree".into(), "remove".into()];
1332 if forced || locked {
1333 args.push("--force".into());
1334 }
1335 if locked {
1336 args.push("--force".into());
1337 }
1338 args.push("--".into());
1339 args.push(display(&record.path));
1340 git::run(&identity.primary_worktree().path, &args)?;
1341 Ok(RemoveReport {
1342 path: display(&record.path),
1343 branch: record.branch.clone(),
1344 forced: forced || locked,
1345 })
1346 }
1347
1348 pub fn repair(&self, dry_run: bool) -> Result<RepairReport> {
1353 let _lock = WorktreeLock::acquire(&self.identity.git_common_dir().path)?;
1354 let primary = &self.identity.primary_worktree().path;
1355 let mut args: Vec<String> = vec!["worktree".into(), "prune".into(), "--verbose".into()];
1356 if dry_run {
1357 args.push("--dry-run".into());
1358 }
1359 let pruned = lines_of(&git::run(primary, &args)?.stdout);
1360 let repaired = if dry_run {
1361 Vec::new()
1362 } else {
1363 let mut args: Vec<String> = vec!["worktree".into(), "repair".into()];
1364 for r in self.identity.registered_worktrees().iter().skip(1) {
1365 if r.path.is_dir() {
1366 args.push(display(&r.path));
1367 }
1368 }
1369 let out = git::run(primary, &args)?;
1370 let mut lines = lines_of(&out.stdout);
1371 lines.extend(lines_of(out.stderr.as_bytes()));
1372 lines
1373 };
1374 let hooks = if dry_run {
1377 BTreeMap::new()
1378 } else {
1379 self.identity
1380 .registered_worktrees()
1381 .iter()
1382 .skip(1)
1383 .filter(|r| r.path.is_dir())
1384 .map(|r| (display(&r.path), hooks::carry_hooks_path(primary, &r.path)))
1385 .collect()
1386 };
1387 Ok(RepairReport {
1388 pruned,
1389 repaired,
1390 hooks,
1391 applied: !dry_run,
1392 })
1393 }
1394}
1395
1396fn symlink_below(container: &Path, path: &Path) -> bool {
1400 let Ok(rest) = path.strip_prefix(container) else {
1401 return false;
1402 };
1403 let mut cursor = container.to_path_buf();
1404 for c in rest.components() {
1405 cursor.push(c);
1406 if std::fs::symlink_metadata(&cursor)
1407 .map(|m| m.file_type().is_symlink())
1408 .unwrap_or(false)
1409 {
1410 return true;
1411 }
1412 }
1413 false
1414}
1415
1416fn is_in_place(w: &WorktreeState, trunk_source: TrunkSource) -> bool {
1419 match w.standing {
1420 Standing::Canonical | Standing::Detached => true,
1421 Standing::Primary => {
1422 trunk_source == TrunkSource::Unknown
1423 || !w
1424 .diagnostics
1425 .iter()
1426 .any(|d| d.code == DiagnosticCode::PrimaryOnNonTrunk)
1427 }
1428 Standing::Misplaced | Standing::Missing | Standing::Ephemeral => false,
1429 }
1430}
1431
1432pub fn declared_scratch_roots(current: &Path, primary: &Path) -> Vec<ScratchRoot> {
1442 let Ok(share) = crate::share::Share::locate(None, current) else {
1443 return Vec::new();
1444 };
1445 let Ok(decls) = share.providers() else {
1446 return Vec::new();
1447 };
1448 scratch_roots_from(&decls, primary)
1449}
1450
1451pub fn scratch_roots_from(
1453 decls: &crate::share::ProviderDeclarations,
1454 primary: &Path,
1455) -> Vec<ScratchRoot> {
1456 let mut out: Vec<ScratchRoot> = decls
1457 .scratch_roots(primary)
1458 .into_iter()
1459 .map(|(provider, path)| ScratchRoot { provider, path })
1460 .collect();
1461 if decls.scratch_roots.iter().any(|r| r == "$TMPDIR") {
1462 let temp = std::env::temp_dir();
1463 if !out.iter().any(|r| r.provider.is_none() && r.path == temp) {
1464 out.push(ScratchRoot {
1465 provider: None,
1466 path: temp,
1467 });
1468 }
1469 }
1470 out
1471}
1472
1473fn lines_of(bytes: &[u8]) -> Vec<String> {
1474 String::from_utf8_lossy(bytes)
1475 .lines()
1476 .map(str::trim)
1477 .filter(|l| !l.is_empty())
1478 .map(str::to_string)
1479 .collect()
1480}
1481
1482pub(crate) fn display(p: &Path) -> String {
1485 p.display().to_string()
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490 use super::*;
1491
1492 fn dist_declarations() -> crate::share::ProviderDeclarations {
1494 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share");
1495 crate::share::Share::locate(Some(&dir), &dir)
1496 .unwrap()
1497 .providers()
1498 .unwrap()
1499 }
1500
1501 struct Repo {
1506 _home: tempfile::TempDir,
1507 primary: PathBuf,
1508 }
1509
1510 impl Repo {
1511 fn new() -> Self {
1512 let home = tempfile::tempdir().expect("tempdir");
1513 let primary = home.path().join("dev/repo");
1514 std::fs::create_dir_all(&primary).expect("mkdir");
1515 let r = Repo {
1516 _home: home,
1517 primary,
1518 };
1519 r.git(&["init", "-q", "-b", "master", "."]);
1520 r.git(&["config", "user.email", "t@example.com"]);
1521 r.git(&["config", "user.name", "t"]);
1522 r.git(&["commit", "-q", "--allow-empty", "-m", "root"]);
1523 r
1524 }
1525
1526 fn git(&self, args: &[&str]) {
1527 let ok = std::process::Command::new("git")
1528 .arg("-C")
1529 .arg(&self.primary)
1530 .args(args)
1531 .output()
1532 .expect("git runs")
1533 .status
1534 .success();
1535 assert!(ok, "git {args:?}");
1536 }
1537
1538 fn worktree(&self, branch: &str, at: &Path) {
1539 if let Some(parent) = at.parent() {
1540 std::fs::create_dir_all(parent).expect("mkdir");
1541 }
1542 self.git(&["worktree", "add", "-q", "-b", branch, &at.to_string_lossy()]);
1543 }
1544
1545 fn service(&self) -> WorktreeService {
1546 WorktreeService::open(&self.primary).expect("a service")
1547 }
1548 }
1549
1550 #[test]
1551 fn a_worktree_resolves_by_branch_or_by_path_and_says_so_when_it_is_neither() {
1552 let repo = Repo::new();
1553 let svc = repo.service();
1554 let home = svc.expected_path_of("feature/x").expect("a path");
1555 repo.worktree("feature/x", &home);
1556 let svc = repo.service();
1557
1558 let by_branch = svc.resolve("feature/x").expect("resolved by branch");
1560 assert_eq!(by_branch.branch.as_deref(), Some("feature/x"));
1561 let by_path = svc
1563 .resolve(&home.to_string_lossy())
1564 .expect("resolved by path");
1565 assert_eq!(by_path.path, by_branch.path);
1566 let err = svc.resolve("feature/never").expect_err("no such worktree");
1568 assert!(err.to_string().contains("feature/never"), "{err}");
1569 }
1570
1571 #[test]
1572 fn a_branch_whose_worktree_is_home_passes_the_guard_and_one_that_is_not_does_not() {
1573 let repo = Repo::new();
1574 let home = repo
1575 .service()
1576 .expected_path_of("feature/home")
1577 .expect("path");
1578 repo.worktree("feature/home", &home);
1579
1580 let inside = WorktreeService::open(&home).expect("a service inside the worktree");
1582 let verdict = inside.guard().expect("a verdict");
1583 assert!(verdict.ok, "{verdict:?}");
1584 assert!(verdict.reason.is_none());
1585 assert_eq!(verdict.branch.as_deref(), Some("feature/home"));
1586
1587 let away = repo.primary.parent().unwrap().join("elsewhere");
1589 repo.worktree("feature/away", &away);
1590 let outside = WorktreeService::open(&away).expect("a service inside the worktree");
1591 let verdict = outside.guard().expect("a verdict");
1592 assert!(!verdict.ok, "{verdict:?}");
1593 assert!(verdict.reason.is_some(), "a refusal carries its reason");
1594 assert!(verdict.expected_path.is_some(), "and where it belongs");
1595 }
1596
1597 #[test]
1598 fn the_expected_path_of_a_branch_is_under_the_container_and_a_bad_name_is_refused() {
1599 let repo = Repo::new();
1600 let svc = repo.service();
1601 let container = svc.container().path.clone();
1602
1603 let path = svc.expected_path_of("feature/nested/deep").expect("a path");
1604 assert!(path.starts_with(&container), "{path:?}");
1605 assert!(path.ends_with("feature/nested/deep"), "{path:?}");
1606
1607 for bad in ["../escape", "..", "feature/../../escape"] {
1609 assert!(
1610 svc.expected_path_of(bad).is_err(),
1611 "{bad} resolved to a path"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn inspecting_a_branch_reports_where_it_belongs_whether_or_not_it_is_there() {
1618 let repo = Repo::new();
1619 let svc = repo.service();
1620
1621 let absent = svc.inspect("feature/absent").expect("a report");
1623 assert!(
1624 absent.expected_path.contains("feature/absent"),
1625 "{}",
1626 absent.expected_path
1627 );
1628
1629 let home = svc.expected_path_of("feature/there").expect("a path");
1630 repo.worktree("feature/there", &home);
1631 let present = repo.service().inspect("feature/there").expect("a report");
1632 assert_eq!(present.expected_path, display(&home));
1633 }
1634
1635 #[test]
1636 fn the_declared_scratch_roots_name_the_temporary_directory_and_every_providers_own() {
1637 let primary = Path::new("/srv/repo");
1638 let roots = scratch_roots_from(&dist_declarations(), primary);
1639 let own: Vec<&PathBuf> = roots
1640 .iter()
1641 .filter(|r| r.provider.is_none())
1642 .map(|r| &r.path)
1643 .collect();
1644 assert!(
1645 own.contains(&&std::env::temp_dir()),
1646 "the tool's own roots hold the temporary directory: {own:?}"
1647 );
1648 assert!(own.contains(&&PathBuf::from("/tmp")));
1649 let claude = roots
1650 .iter()
1651 .find(|r| r.provider.as_deref() == Some("claude-code"))
1652 .expect("claude-code declares a root");
1653 assert_eq!(
1654 claude.path,
1655 PathBuf::from("/srv/repo/.claude/worktrees"),
1656 "expanded against the primary checkout"
1657 );
1658 let bb = roots
1659 .iter()
1660 .find(|r| r.provider.as_deref() == Some("bb"))
1661 .expect("bb declares a root");
1662 assert!(
1663 bb.path
1664 .ends_with("plugins/environment-git-worktree/host-data/worktrees"),
1665 "{}",
1666 bb.path.display()
1667 );
1668 let home = std::env::var("HOME").unwrap();
1669 assert!(
1670 bb.path
1671 .starts_with(std::env::var("BB_DATA_DIR").unwrap_or(format!("{home}/.bb"))),
1672 "the data directory defaults to ~/.bb: {}",
1673 bb.path.display()
1674 );
1675 }
1676
1677 #[test]
1678 fn a_checkout_under_an_orchestrators_root_is_a_scratch_checkout_named_after_it() {
1679 let home = tempfile::tempdir().unwrap();
1680 let primary = home.path().join("elsewhere/repo");
1681 std::fs::create_dir_all(&primary).unwrap();
1682 let run = |args: &[&str]| {
1683 assert!(std::process::Command::new("git")
1684 .arg("-C")
1685 .arg(&primary)
1686 .args(args)
1687 .status()
1688 .unwrap()
1689 .success());
1690 };
1691 run(&["init", "-q", "."]);
1692 run(&[
1693 "-c",
1694 "user.email=t@example.com",
1695 "-c",
1696 "user.name=t",
1697 "commit",
1698 "-q",
1699 "--allow-empty",
1700 "-m",
1701 "i",
1702 ]);
1703 let identity = RepositoryIdentity::discover(&primary).unwrap();
1704 let data = home.path().join("bb-data");
1707 let roots: Vec<ScratchRoot> = scratch_roots_from(&dist_declarations(), &primary)
1708 .into_iter()
1709 .map(|r| match r.provider.as_deref() {
1710 Some("bb") => ScratchRoot {
1711 provider: r.provider,
1712 path: data.join("plugins/environment-git-worktree/host-data/worktrees"),
1713 },
1714 _ => r,
1715 })
1716 .collect();
1717 let svc = WorktreeService::over_with(identity, roots).unwrap();
1718 let thread = ResolvedPath::of(
1719 data.join("plugins/environment-git-worktree/host-data/worktrees/thr_1/repo"),
1720 );
1721 let root = svc
1722 .scratch_root_of(&thread)
1723 .expect("a thread's checkout is a scratch checkout");
1724 assert_eq!(root.provider.as_deref(), Some("bb"));
1725 let sibling = ResolvedPath::of(home.path().join("sibling"));
1726 assert!(svc.scratch_root_of(&sibling).is_none());
1727 }
1728
1729 #[test]
1730 fn a_checkout_under_the_temporary_directory_is_ephemeral_unless_the_repository_lives_there() {
1731 let home = tempfile::tempdir().unwrap();
1734 let primary = home.path().join("elsewhere/repo");
1735 std::fs::create_dir_all(&primary).unwrap();
1736 let run = |args: &[&str]| {
1737 assert!(std::process::Command::new("git")
1738 .arg("-C")
1739 .arg(&primary)
1740 .args(args)
1741 .status()
1742 .unwrap()
1743 .success());
1744 };
1745 run(&["init", "-q", "."]);
1746 run(&[
1747 "-c",
1748 "user.email=t@example.com",
1749 "-c",
1750 "user.name=t",
1751 "commit",
1752 "-q",
1753 "--allow-empty",
1754 "-m",
1755 "i",
1756 ]);
1757 let identity = RepositoryIdentity::discover(&primary).unwrap();
1758 let svc = WorktreeService::over_with(
1759 identity,
1760 scratch_roots_from(&dist_declarations(), &primary),
1761 )
1762 .unwrap();
1763 let agent = ResolvedPath::of(primary.join(".claude/worktrees/x"));
1766 assert_eq!(
1767 svc.scratch_root_of(&agent).unwrap().provider.as_deref(),
1768 Some("claude-code")
1769 );
1770 let sibling = ResolvedPath::of(home.path().join("sibling"));
1771 assert!(svc.ephemeral_root_of(&sibling).is_none(), "the repository lives in the temporary directory, so nothing beside it is a scratch checkout");
1772 }
1773
1774 #[test]
1777 fn a_create_report_without_hooks_reads_as_not_configured() {
1778 assert_eq!(default_hooks(), HooksPath::NotConfigured);
1779 }
1780
1781 #[test]
1784 fn a_repair_names_the_hooks_of_every_linked_worktree_and_a_dry_run_none() {
1785 let repo = Repo::new();
1786 let home = repo.service().expected_path_of("topic").expect("a path");
1787 repo.worktree("topic", &home);
1788 let svc = repo.service();
1789
1790 let dry = svc.repair(true).expect("a dry run");
1791 assert!(!dry.applied);
1792 assert!(dry.hooks.is_empty(), "{dry:?}");
1793
1794 let applied = svc.repair(false).expect("a repair");
1795 assert!(applied.applied);
1796 let paths: Vec<&String> = applied.hooks.keys().collect();
1797 assert_eq!(paths.len(), 1, "{applied:?}");
1798 assert!(paths[0].ends_with("topic"), "{applied:?}");
1799 }
1800
1801 fn git_bytes(repo: &Repo, args: &[&[u8]]) {
1803 use std::os::unix::ffi::OsStrExt;
1804 let ok = std::process::Command::new("git")
1805 .arg("-C")
1806 .arg(&repo.primary)
1807 .args(args.iter().map(|a| std::ffi::OsStr::from_bytes(a)))
1808 .output()
1809 .expect("git runs")
1810 .status
1811 .success();
1812 assert!(ok, "git {args:?}");
1813 }
1814
1815 fn with_origin() -> (Repo, PathBuf) {
1817 let repo = Repo::new();
1818 let origin = repo._home.path().join("origin.git");
1819 let url = origin.to_string_lossy().to_string();
1820 repo.git(&["init", "-q", "--bare", &url]);
1821 repo.git(&["remote", "add", "origin", &url]);
1822 repo.git(&["push", "-q", "-u", "origin", "master"]);
1823 (repo, origin)
1824 }
1825
1826 fn push_only(repo: &Repo, branch: &str) {
1829 repo.git(&["checkout", "-q", "-b", "side"]);
1830 repo.git(&["commit", "-q", "--allow-empty", "-m", "side"]);
1831 repo.git(&["push", "-q", "origin", &format!("side:{branch}")]);
1832 repo.git(&["checkout", "-q", "master"]);
1833 repo.git(&["branch", "-q", "-D", "side"]);
1834 repo.git(&["update-ref", "-d", &format!("refs/remotes/origin/{branch}")]);
1835 }
1836
1837 fn request(branch: &str, base: Option<&str>) -> CreateRequest {
1838 CreateRequest {
1839 branch: branch.into(),
1840 base: base.map(str::to_string),
1841 }
1842 }
1843
1844 #[test]
1845 fn a_start_point_names_its_ref_and_says_when_it_is_not_the_remotes_state() {
1846 let given = StartPoint::Given {
1847 reference: "v1.0".into(),
1848 };
1849 assert_eq!(given.reference(), "v1.0");
1850 assert_eq!(given.fallback(), None);
1851 assert_eq!(StartPoint::Head.reference(), "HEAD");
1852 assert_eq!(StartPoint::Head.fallback(), None);
1853 let local = |behind, reason: Option<&str>| StartPoint::LocalTrunk {
1854 branch: "master".into(),
1855 unreachable: Some("origin".into()),
1856 reason: reason.map(str::to_string),
1857 behind,
1858 };
1859 assert_eq!(local(Some(0), None).reference(), "master");
1860 let level = local(Some(0), None).fallback().expect("said");
1861 assert!(level.contains("(it did not answer)"), "{level}");
1862 assert!(level.contains("level with origin/master"), "{level}");
1863 let one = local(Some(1), Some("offline")).fallback().expect("said");
1864 assert!(one.contains("(offline)"), "{one}");
1865 assert!(one.contains("1 commit behind origin/master"), "{one}");
1866 let many = local(Some(4), Some("offline")).fallback().expect("said");
1867 assert!(many.contains("4 commits behind origin/master"), "{many}");
1868 let never = local(None, Some("offline")).fallback().expect("said");
1869 assert!(never.contains("was never fetched"), "{never}");
1870 let alone = StartPoint::LocalTrunk {
1871 branch: "master".into(),
1872 unreachable: None,
1873 reason: None,
1874 behind: None,
1875 };
1876 assert_eq!(alone.fallback(), None);
1877 let stale = StartPoint::RemoteBranch {
1878 remote: "origin".into(),
1879 branch: "fix/x".into(),
1880 fetched: false,
1881 };
1882 assert_eq!(stale.reference(), "origin/fix/x");
1883 let said = stale.fallback().expect("said");
1884 assert!(
1885 said.contains("tracking origin/fix/x as last fetched"),
1886 "{said}"
1887 );
1888 let fresh = StartPoint::RemoteBranch {
1889 remote: "origin".into(),
1890 branch: "fix/x".into(),
1891 fetched: true,
1892 };
1893 assert_eq!(fresh.fallback(), None);
1894 }
1895
1896 #[test]
1897 fn the_trunks_remote_is_the_one_whose_head_names_it_else_the_configured_one() {
1898 let home = tempfile::tempdir().expect("tempdir");
1899 let absent = home.path().join("absent");
1901 assert_eq!(trunk_remote(&absent, "master").expect("an answer"), None);
1902 assert!(trunk_remote(Path::new("no\0where"), "master").is_err());
1904
1905 let (repo, _origin) = with_origin();
1906 let p = repo.primary.clone();
1907 assert_eq!(
1909 trunk_remote(&p, "master").expect("an answer"),
1910 Some("origin".into())
1911 );
1912 let other = repo._home.path().join("other.git");
1914 repo.git(&["init", "-q", "--bare", &other.to_string_lossy()]);
1915 repo.git(&["remote", "add", "other", &other.to_string_lossy()]);
1916 repo.git(&[
1917 "symbolic-ref",
1918 "refs/remotes/origin/HEAD",
1919 "refs/remotes/origin/develop",
1920 ]);
1921 repo.git(&[
1922 "symbolic-ref",
1923 "refs/remotes/other/HEAD",
1924 "refs/remotes/other/master",
1925 ]);
1926 assert_eq!(
1927 trunk_remote(&p, "master").expect("an answer"),
1928 Some("other".into())
1929 );
1930 repo.git(&["config", "branch.lonely.remote", "gone"]);
1932 assert_eq!(trunk_remote(&p, "lonely").expect("an answer"), None);
1933 assert!(trunk_remote(&p, "ma\0ster").is_err());
1935 git_bytes(&repo, &[b"config", b"branch.odd.remote", b"o\xff"]);
1937 assert!(trunk_remote(&p, "odd").is_err());
1938 repo.git(&["symbolic-ref", "-d", "refs/remotes/other/HEAD"]);
1940 std::fs::write(
1941 p.join(".git/refs/remotes/origin/HEAD"),
1942 b"ref: refs/remotes/origin/\xff\n",
1943 )
1944 .expect("write");
1945 assert!(trunk_remote(&p, "master").is_err());
1946 }
1947
1948 #[test]
1949 fn a_remote_whose_name_is_not_text_refuses_the_create_before_anything_is_made() {
1950 let (repo, _origin) = with_origin();
1951 let svc = repo.service();
1952 git_bytes(&repo, &[b"remote", b"add", b"o\xff", b"/nowhere"]);
1953 assert!(trunk_remote(&repo.primary, "master").is_err());
1954 assert!(svc.create(&request("feature/odd", None)).is_err());
1955 let (name, target) = placed(&svc, "feature/odd");
1957 assert!(svc
1958 .create_locked(&request("feature/odd", None), false, name, target, None)
1959 .is_err());
1960 assert!(!svc
1961 .expected_path_of("feature/odd")
1962 .expect("a path")
1963 .exists());
1964 }
1965
1966 #[test]
1967 fn a_repository_with_no_trunk_starts_from_head_and_one_with_no_remote_from_the_trunk() {
1968 let repo = Repo::new();
1969 assert_eq!(
1970 repo.service()
1971 .plan_start(&repo.primary, "feature/x")
1972 .expect("a plan"),
1973 StartPoint::LocalTrunk {
1974 branch: "master".into(),
1975 unreachable: None,
1976 reason: None,
1977 behind: None,
1978 }
1979 );
1980 repo.git(&["branch", "-m", "master", "topic"]);
1981 repo.git(&["checkout", "-q", "--detach"]);
1982 let svc = repo.service();
1983 if svc.identity.trunk().branch.is_some() {
1984 eprintln!("a trunk was found in the environment's configuration; nothing to test");
1985 return;
1986 }
1987 assert_eq!(
1988 svc.plan_start(&repo.primary, "feature/x").expect("a plan"),
1989 StartPoint::Head
1990 );
1991 let made = svc.create(&request("feature/x", None)).expect("created");
1992 assert_eq!(made.base.as_deref(), Some("HEAD"));
1993 }
1994
1995 #[test]
1996 fn the_remote_decides_where_a_branch_starts_and_its_last_fetched_refs_when_it_cannot() {
1997 let (repo, origin) = with_origin();
1998 let p = repo.primary.clone();
1999 let svc = repo.service();
2000 let plan = |b: &str| svc.plan_start(&p, b).expect("a plan");
2001 let remote_trunk = StartPoint::RemoteTrunk {
2002 remote: "origin".into(),
2003 branch: "master".into(),
2004 };
2005
2006 push_only(&repo, "fix/theirs");
2008 assert_eq!(
2009 plan("fix/theirs"),
2010 StartPoint::RemoteBranch {
2011 remote: "origin".into(),
2012 branch: "fix/theirs".into(),
2013 fetched: true,
2014 }
2015 );
2016 assert_eq!(plan("feature/new"), remote_trunk);
2018
2019 push_only(&repo, "fix/locked");
2022 let lock = p.join(".git/refs/remotes/origin/fix/locked.lock");
2023 std::fs::create_dir_all(lock.parent().expect("a parent")).expect("mkdir");
2024 std::fs::write(&lock, "").expect("write");
2025 assert_eq!(plan("fix/locked"), remote_trunk);
2026 std::fs::remove_file(&lock).expect("unlock");
2027 repo.git(&["update-ref", "refs/remotes/origin/fix/locked", "master"]);
2028 std::fs::write(&lock, "").expect("lock again");
2029 assert_eq!(
2030 plan("fix/locked"),
2031 StartPoint::RemoteBranch {
2032 remote: "origin".into(),
2033 branch: "fix/locked".into(),
2034 fetched: false,
2035 }
2036 );
2037 std::fs::remove_file(&lock).expect("unlock");
2038
2039 let gone = origin.with_file_name("gone.git");
2042 repo.git(&["remote", "set-url", "origin", &gone.to_string_lossy()]);
2043 assert_eq!(
2044 plan("fix/locked"),
2045 StartPoint::RemoteBranch {
2046 remote: "origin".into(),
2047 branch: "fix/locked".into(),
2048 fetched: false,
2049 }
2050 );
2051 match plan("feature/offline") {
2052 StartPoint::LocalTrunk {
2053 branch,
2054 unreachable,
2055 reason,
2056 behind,
2057 } => {
2058 assert_eq!(branch, "master");
2059 assert_eq!(unreachable.as_deref(), Some("origin"));
2060 assert!(reason.is_some());
2061 assert_eq!(behind, Some(0));
2062 }
2063 other => panic!("{other:?}"),
2064 }
2065 assert!(svc.plan_start(&p, "feature/no\0pe").is_err());
2067
2068 let empty = origin.with_file_name("empty.git");
2070 repo.git(&["init", "-q", "--bare", &empty.to_string_lossy()]);
2071 repo.git(&["remote", "set-url", "origin", &empty.to_string_lossy()]);
2072 repo.git(&["update-ref", "-d", "refs/remotes/origin/master"]);
2073 assert_eq!(
2074 plan("feature/empty"),
2075 StartPoint::LocalTrunk {
2076 branch: "master".into(),
2077 unreachable: Some("origin".into()),
2078 reason: Some("origin has no branch master".into()),
2079 behind: None,
2080 }
2081 );
2082 }
2083
2084 #[test]
2085 fn a_given_base_is_taken_as_given_and_an_existing_branch_is_checked_out_as_it_is() {
2086 let repo = Repo::new();
2087 let svc = repo.service();
2088 let made = svc
2089 .create(&request("feature/given", Some("master")))
2090 .expect("created");
2091 assert_eq!(
2092 made.start,
2093 Some(StartPoint::Given {
2094 reference: "master".into()
2095 })
2096 );
2097 assert!(made.branch_created);
2098 assert!(made.start_commit.is_some());
2099 assert!(svc
2100 .create(&request("feature/nothing", Some("no-such-ref")))
2101 .is_err());
2102
2103 repo.git(&["branch", "feature/there"]);
2104 let svc = repo.service();
2105 let made = svc
2106 .create(&request("feature/there", None))
2107 .expect("created");
2108 assert!(!made.branch_created);
2109 assert_eq!(made.start, None);
2110 assert_eq!(made.base, None);
2111 assert_eq!(made.start_commit, None);
2112 }
2113
2114 fn placed(svc: &WorktreeService, branch: &str) -> (BranchName, PathBuf) {
2118 let name = BranchName::parse(branch).expect("a branch name");
2119 let target = path::expected_path(&svc.container.path, &name).expect("a place");
2120 (name, target)
2121 }
2122
2123 #[test]
2124 fn a_branch_deleted_while_the_create_waits_for_the_lock_is_planned_under_it() {
2125 let repo = Repo::new();
2128 let svc = repo.service();
2129 let (name, target) = placed(&svc, "feature/raced");
2130 let made = svc
2131 .create_locked(&request("feature/raced", None), false, name, target, None)
2132 .expect("created");
2133 assert!(made.branch_created);
2134 assert_eq!(
2135 made.start,
2136 Some(StartPoint::LocalTrunk {
2137 branch: "master".into(),
2138 unreachable: None,
2139 reason: None,
2140 behind: None,
2141 })
2142 );
2143 }
2144}