1use std::path::{Path, PathBuf};
19
20use objects::object::StateId;
21use serde::Serialize;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ClonePlanOptions {
33 pub remote: String,
34 pub local: PathBuf,
35 pub thread: Option<String>,
36 pub depth: Option<u32>,
38 pub lazy: bool,
39 pub filter: Option<String>,
40 pub recursive: bool,
41 pub insecure: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ClonePlanFacts {
48 pub destination_exists: bool,
50 pub remote_source: CloneRemoteSource,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum CloneRemoteSource {
61 Local {
63 path: PathBuf,
64 has_heddle: bool,
66 is_git: bool,
68 },
69 Network {
71 has_repo_path: bool,
73 },
74 Unparsed,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum CloneMode {
85 LocalHeddle { remote_path: PathBuf },
87 LocalGitOverlay { remote_path: PathBuf },
89 GitOverlayUrl,
91 NetworkHosted { recursive: bool },
93}
94
95impl CloneMode {
96 pub fn kind_label(&self) -> &'static str {
98 match self {
99 Self::LocalHeddle { .. } => "local",
100 Self::LocalGitOverlay { .. } | Self::GitOverlayUrl => "git-overlay",
101 Self::NetworkHosted { recursive: true } => "monorepo",
102 Self::NetworkHosted { recursive: false } => "network",
103 }
104 }
105
106 pub fn is_network(&self) -> bool {
107 matches!(self, Self::NetworkHosted { .. })
108 }
109
110 pub fn is_git_overlay(&self) -> bool {
111 matches!(self, Self::LocalGitOverlay { .. } | Self::GitOverlayUrl)
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct CloneSecurityPreflight {
118 pub allow_insecure: bool,
120 pub requires_network_session: bool,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct ClonePlan {
128 pub destination: PathBuf,
129 pub remote: String,
130 pub mode: CloneMode,
131 pub thread: Option<String>,
132 pub depth: Option<u32>,
134 pub lazy: bool,
135 pub filter: Option<String>,
136 pub recursive: bool,
137 pub effective_lazy: bool,
139 pub security: CloneSecurityPreflight,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum UnsupportedCloneFlag {
149 Filter,
150 Lazy,
151 Depth,
152}
153
154impl UnsupportedCloneFlag {
155 pub fn as_str(self) -> &'static str {
156 match self {
157 Self::Filter => "--filter",
158 Self::Lazy => "--lazy",
159 Self::Depth => "--depth",
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ClonePlanError {
167 DestinationExists { path: PathBuf },
169 MonorepoRequiresHosted { remote: String },
171 RemoteLooksLikeMissingLocalPath { remote: String },
173 InvalidRemoteUrl { remote: String },
175 UnsupportedOption {
177 flag: UnsupportedCloneFlag,
178 mode: &'static str,
180 value: Option<String>,
182 },
183}
184
185impl std::fmt::Display for ClonePlanError {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 match self {
188 Self::DestinationExists { path } => {
189 write!(f, "local path '{}' already exists", path.display())
190 }
191 Self::MonorepoRequiresHosted { remote } => write!(
192 f,
193 "--recursive monorepo clone requires a hosted spool remote; '{remote}' is not one"
194 ),
195 Self::RemoteLooksLikeMissingLocalPath { remote } => {
196 write!(f, "remote repository '{remote}' does not exist")
197 }
198 Self::InvalidRemoteUrl { remote } => write!(f, "invalid remote URL: {remote}"),
199 Self::UnsupportedOption { flag, mode, value } => {
200 let flag_label = value
201 .as_deref()
202 .map(|v| format!("{} {v}", flag.as_str()))
203 .unwrap_or_else(|| flag.as_str().to_string());
204 write!(f, "{flag_label} is not supported for {mode} clones")
205 }
206 }
207 }
208}
209
210impl std::error::Error for ClonePlanError {}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct AdoptPlanOptions {
219 pub path: Option<PathBuf>,
221 pub repo_flag: Option<PathBuf>,
223 pub cwd: PathBuf,
225 pub refs: Vec<String>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct AdoptPlan {
232 pub start_path: PathBuf,
234 pub refs: Vec<String>,
235 pub import_all_refs: bool,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum AdoptPlanError {
242 PathConflict { positional: PathBuf, repo: PathBuf },
244}
245
246impl std::fmt::Display for AdoptPlanError {
247 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248 match self {
249 Self::PathConflict { positional, repo } => write!(
250 f,
251 "adopt path '{}' conflicts with --repo '{}'",
252 positional.display(),
253 repo.display()
254 ),
255 }
256 }
257}
258
259impl std::error::Error for AdoptPlanError {}
260
261pub fn absolute_path(path: &Path, cwd: &Path) -> PathBuf {
271 if path.is_absolute() {
272 path.to_path_buf()
273 } else {
274 cwd.join(path)
275 }
276}
277
278pub fn resolve_clone_destination(local: &Path, cwd: &Path) -> PathBuf {
280 absolute_path(local, cwd)
281}
282
283pub fn validate_clone_destination(
285 destination: &Path,
286 destination_exists: bool,
287) -> Result<(), ClonePlanError> {
288 if destination_exists {
289 Err(ClonePlanError::DestinationExists {
290 path: destination.to_path_buf(),
291 })
292 } else {
293 Ok(())
294 }
295}
296
297pub fn normalize_clone_depth(depth: Option<u32>) -> Option<u32> {
299 depth.filter(|depth| *depth > 0)
300}
301
302pub fn looks_like_local_path(remote: &str) -> bool {
306 let path = Path::new(remote);
307 path.is_absolute()
308 || remote == "."
309 || remote == ".."
310 || remote.starts_with("./")
311 || remote.starts_with("../")
312 || remote.starts_with("~/")
313}
314
315pub fn looks_like_git_overlay_url(remote: &str) -> bool {
319 remote.contains("://") || remote.starts_with("git@")
320}
321
322pub fn resolve_adopt_start_path(
326 positional: Option<&Path>,
327 repo_flag: Option<&Path>,
328 cwd: &Path,
329) -> Result<PathBuf, AdoptPlanError> {
330 match (positional, repo_flag) {
331 (Some(positional), Some(repo_path)) => {
332 if absolute_path(positional, cwd) != absolute_path(repo_path, cwd) {
333 return Err(AdoptPlanError::PathConflict {
334 positional: positional.to_path_buf(),
335 repo: repo_path.to_path_buf(),
336 });
337 }
338 Ok(positional.to_path_buf())
339 }
340 (Some(positional), None) => Ok(positional.to_path_buf()),
341 (None, Some(repo_path)) => Ok(repo_path.to_path_buf()),
342 (None, None) => Ok(cwd.to_path_buf()),
343 }
344}
345
346pub fn assemble_clone_security_preflight(
352 mode: &CloneMode,
353 insecure: bool,
354) -> CloneSecurityPreflight {
355 if mode.is_network() {
356 CloneSecurityPreflight {
357 allow_insecure: insecure,
358 requires_network_session: true,
359 }
360 } else {
361 CloneSecurityPreflight {
362 allow_insecure: false,
363 requires_network_session: false,
364 }
365 }
366}
367
368pub fn select_clone_mode(
374 remote: &str,
375 recursive: bool,
376 source: &CloneRemoteSource,
377) -> Result<CloneMode, ClonePlanError> {
378 match source {
379 CloneRemoteSource::Local {
380 path,
381 has_heddle,
382 is_git,
383 } => {
384 if recursive {
385 return Err(ClonePlanError::MonorepoRequiresHosted {
386 remote: remote.to_string(),
387 });
388 }
389 if !has_heddle && *is_git {
390 Ok(CloneMode::LocalGitOverlay {
391 remote_path: path.clone(),
392 })
393 } else {
394 Ok(CloneMode::LocalHeddle {
395 remote_path: path.clone(),
396 })
397 }
398 }
399 CloneRemoteSource::Network { .. } => Ok(CloneMode::NetworkHosted { recursive }),
400 CloneRemoteSource::Unparsed => {
401 if recursive {
402 return Err(ClonePlanError::MonorepoRequiresHosted {
403 remote: remote.to_string(),
404 });
405 }
406 if looks_like_local_path(remote) {
407 return Err(ClonePlanError::RemoteLooksLikeMissingLocalPath {
408 remote: remote.to_string(),
409 });
410 }
411 if looks_like_git_overlay_url(remote) {
412 Ok(CloneMode::GitOverlayUrl)
413 } else {
414 Err(ClonePlanError::InvalidRemoteUrl {
415 remote: remote.to_string(),
416 })
417 }
418 }
419 }
420}
421
422pub fn validate_clone_mode_options(
424 mode: &CloneMode,
425 depth: Option<u32>,
426 lazy: bool,
427 filter: Option<&str>,
428) -> Result<(), ClonePlanError> {
429 match mode {
430 CloneMode::LocalGitOverlay { .. } | CloneMode::GitOverlayUrl => {
431 if let Some(value) = filter {
432 return Err(ClonePlanError::UnsupportedOption {
433 flag: UnsupportedCloneFlag::Filter,
434 mode: mode.kind_label(),
435 value: Some(value.to_string()),
436 });
437 }
438 if lazy {
439 return Err(ClonePlanError::UnsupportedOption {
440 flag: UnsupportedCloneFlag::Lazy,
441 mode: mode.kind_label(),
442 value: None,
443 });
444 }
445 if depth.is_some() {
446 return Err(ClonePlanError::UnsupportedOption {
447 flag: UnsupportedCloneFlag::Depth,
448 mode: mode.kind_label(),
449 value: None,
450 });
451 }
452 }
453 CloneMode::LocalHeddle { .. } => {
454 if let Some(value) = filter {
455 return Err(ClonePlanError::UnsupportedOption {
456 flag: UnsupportedCloneFlag::Filter,
457 mode: mode.kind_label(),
458 value: Some(value.to_string()),
459 });
460 }
461 if lazy {
462 return Err(ClonePlanError::UnsupportedOption {
463 flag: UnsupportedCloneFlag::Lazy,
464 mode: mode.kind_label(),
465 value: Some("true".to_string()),
466 });
467 }
468 }
469 CloneMode::NetworkHosted { recursive: true } => {
470 if filter.is_some() {
471 return Err(ClonePlanError::UnsupportedOption {
472 flag: UnsupportedCloneFlag::Filter,
473 mode: mode.kind_label(),
474 value: None,
475 });
476 }
477 if lazy {
478 return Err(ClonePlanError::UnsupportedOption {
479 flag: UnsupportedCloneFlag::Lazy,
480 mode: mode.kind_label(),
481 value: None,
482 });
483 }
484 if depth.is_some() {
485 return Err(ClonePlanError::UnsupportedOption {
486 flag: UnsupportedCloneFlag::Depth,
487 mode: mode.kind_label(),
488 value: None,
489 });
490 }
491 }
492 CloneMode::NetworkHosted { recursive: false } => {}
493 }
494 Ok(())
495}
496
497pub fn plan_clone(
505 options: &ClonePlanOptions,
506 facts: &ClonePlanFacts,
507) -> Result<ClonePlan, ClonePlanError> {
508 validate_clone_destination(&options.local, facts.destination_exists)?;
509
510 let mode = select_clone_mode(&options.remote, options.recursive, &facts.remote_source)?;
511 let depth = normalize_clone_depth(options.depth);
512 validate_clone_mode_options(&mode, depth, options.lazy, options.filter.as_deref())?;
513
514 let security = assemble_clone_security_preflight(&mode, options.insecure);
515 let effective_lazy = if mode.is_network() {
516 options.lazy || options.filter.is_some()
517 } else {
518 false
519 };
520
521 Ok(ClonePlan {
522 destination: options.local.clone(),
523 remote: options.remote.clone(),
524 mode,
525 thread: options.thread.clone(),
526 depth,
527 lazy: options.lazy,
528 filter: options.filter.clone(),
529 recursive: options.recursive,
530 effective_lazy,
531 security,
532 })
533}
534
535#[derive(Debug, Clone, PartialEq, Eq)]
537pub enum CloneThreadSelectError {
538 RequestedNotAdvertised { requested: String },
540 NoAdvertisedThreads,
542}
543
544impl std::fmt::Display for CloneThreadSelectError {
545 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546 match self {
547 Self::RequestedNotAdvertised { requested } => {
548 write!(f, "thread '{requested}' is not advertised by the remote")
549 }
550 Self::NoAdvertisedThreads => {
551 write!(f, "remote advertised no threads to check out")
552 }
553 }
554 }
555}
556
557impl std::error::Error for CloneThreadSelectError {}
558
559fn short_clone_thread_name(name: &str) -> &str {
560 name.strip_prefix("refs/heads/").unwrap_or(name)
561}
562
563pub fn select_clone_checkout_thread<'a>(
570 requested: Option<&str>,
571 advertised_head: Option<&str>,
572 advertised_threads: impl IntoIterator<Item = &'a str>,
573) -> Result<String, CloneThreadSelectError> {
574 let mut threads = advertised_threads
575 .into_iter()
576 .filter(|thread| !thread.starts_with("refs/"))
577 .filter(|thread| !thread.is_empty())
578 .map(str::to_string)
579 .collect::<Vec<_>>();
580 threads.sort();
581 threads.dedup();
582
583 if let Some(requested) = requested {
584 let requested = short_clone_thread_name(requested);
585 if threads.iter().any(|thread| thread == requested) {
586 return Ok(requested.to_string());
587 }
588 return Err(CloneThreadSelectError::RequestedNotAdvertised {
589 requested: requested.to_string(),
590 });
591 }
592
593 if let Some(head) = advertised_head {
594 let head = short_clone_thread_name(head);
595 if threads.iter().any(|thread| thread == head) {
596 return Ok(head.to_string());
597 }
598 }
599
600 if threads.iter().any(|thread| thread == "main") {
601 return Ok("main".to_string());
602 }
603
604 threads
605 .into_iter()
606 .next()
607 .ok_or(CloneThreadSelectError::NoAdvertisedThreads)
608}
609
610pub fn plan_adopt(options: &AdoptPlanOptions) -> Result<AdoptPlan, AdoptPlanError> {
614 let start_path = resolve_adopt_start_path(
615 options.path.as_deref(),
616 options.repo_flag.as_deref(),
617 &options.cwd,
618 )?;
619 Ok(AdoptPlan {
620 start_path,
621 refs: options.refs.clone(),
622 import_all_refs: options.refs.is_empty(),
623 })
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
647pub enum MonorepoEdgeSkipReason {
648 Unspecified,
649 Unreadable,
650 Cycle,
651 DepthBounded,
652}
653
654impl MonorepoEdgeSkipReason {
655 pub fn as_str(self) -> &'static str {
656 match self {
657 Self::Unspecified => "unspecified",
658 Self::Unreadable => "unreadable",
659 Self::Cycle => "cycle",
660 Self::DepthBounded => "depth-bounded",
661 }
662 }
663
664 pub fn from_wire_i32(value: i32) -> Option<Self> {
669 match value {
670 0 => Some(Self::Unspecified),
671 1 => Some(Self::Unreadable),
672 2 => Some(Self::Cycle),
673 3 => Some(Self::DepthBounded),
674 _ => None,
675 }
676 }
677}
678
679pub fn monorepo_rel_display(rel_path: &Path) -> String {
681 if rel_path.as_os_str().is_empty() {
682 ".".to_string()
683 } else {
684 rel_path.display().to_string()
685 }
686}
687
688#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
690pub struct MonorepoCloneJsonReport {
691 pub output_kind: &'static str,
692 pub action: &'static str,
693 pub status: &'static str,
694 pub success: bool,
695 pub transport: &'static str,
696 pub local: String,
697 pub placed: Vec<MonorepoPlacedJsonRow>,
698 pub skipped: Vec<MonorepoSkippedJsonRow>,
699}
700
701#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
703pub struct MonorepoPlacedJsonRow {
704 pub spool_id: String,
705 pub path: String,
706 pub content_state: Option<String>,
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
711pub struct MonorepoSkippedJsonRow {
712 pub child_spool_id: String,
713 pub mount_name: String,
714 pub path: String,
715 pub reason: String,
716}
717
718pub fn assemble_monorepo_clone_json_report(
720 local_path: &Path,
721 summary: &MonorepoCloneResultSummary,
722) -> MonorepoCloneJsonReport {
723 let placed = summary
724 .placed
725 .iter()
726 .map(|node| MonorepoPlacedJsonRow {
727 spool_id: node.spool_id.clone(),
728 path: node.rel_path.display().to_string(),
729 content_state: node.content_state.map(|s| s.to_string()),
730 })
731 .collect();
732 let skipped = summary
733 .skipped
734 .iter()
735 .map(|sk| MonorepoSkippedJsonRow {
736 child_spool_id: sk.child_spool_id.clone(),
737 mount_name: sk.mount_name.clone(),
738 path: sk.rel_path.display().to_string(),
739 reason: sk.reason_label().to_string(),
740 })
741 .collect();
742 MonorepoCloneJsonReport {
743 output_kind: "clone_monorepo",
744 action: "clone",
745 status: "cloned",
746 success: true,
747 transport: "heddle",
748 local: local_path.display().to_string(),
749 placed,
750 skipped,
751 }
752}
753
754#[derive(Debug, Clone, PartialEq, Eq)]
756pub struct MonorepoEdgeFacts {
757 pub mount_name: String,
759 pub child_spool_id: String,
761 pub child: Option<MonorepoNodeFacts>,
764 pub skip_reason: Option<MonorepoEdgeSkipReason>,
767}
768
769#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct MonorepoNodeFacts {
772 pub spool_id: String,
773 pub content_state: Option<StateId>,
777 pub edges: Vec<MonorepoEdgeFacts>,
778}
779
780#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct MonorepoNodePlan {
783 pub spool_id: String,
784 pub content_state: Option<StateId>,
785 pub rel_path: PathBuf,
787}
788
789impl MonorepoNodePlan {
790 pub fn dest_path(&self, clone_root: &Path) -> PathBuf {
792 if self.rel_path.as_os_str().is_empty() {
793 clone_root.to_path_buf()
794 } else {
795 clone_root.join(&self.rel_path)
796 }
797 }
798}
799
800#[derive(Debug, Clone, PartialEq, Eq)]
802pub struct MonorepoSkippedChild {
803 pub child_spool_id: String,
804 pub mount_name: String,
805 pub rel_path: PathBuf,
807 pub reason: MonorepoEdgeSkipReason,
808}
809
810impl MonorepoSkippedChild {
811 pub fn reason_label(&self) -> &'static str {
812 self.reason.as_str()
813 }
814}
815
816#[derive(Debug, Clone, PartialEq, Eq, Default)]
818pub struct MonorepoClonePlan {
819 pub nodes: Vec<MonorepoNodePlan>,
821 pub skipped: Vec<MonorepoSkippedChild>,
823}
824
825#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
827pub enum MonorepoClonePlanError {
828 #[error(
829 "invalid monorepo mount name '{mount_name}' for child '{child_spool_id}': mount names must be exactly one relative path component"
830 )]
831 InvalidMountName {
832 child_spool_id: String,
833 mount_name: String,
834 },
835}
836
837pub fn validate_monorepo_clone_options(
842 depth: Option<u32>,
843 lazy: bool,
844 filter: Option<&str>,
845) -> Result<(), ClonePlanError> {
846 validate_clone_mode_options(
847 &CloneMode::NetworkHosted { recursive: true },
848 depth,
849 lazy,
850 filter,
851 )
852}
853
854pub fn plan_monorepo_clone(
859 root: &MonorepoNodeFacts,
860) -> Result<MonorepoClonePlan, MonorepoClonePlanError> {
861 let mut plan = MonorepoClonePlan::default();
862 walk_monorepo_node(&mut plan, root, PathBuf::new())?;
863 Ok(plan)
864}
865
866fn walk_monorepo_node(
867 plan: &mut MonorepoClonePlan,
868 node: &MonorepoNodeFacts,
869 rel_path: PathBuf,
870) -> Result<(), MonorepoClonePlanError> {
871 plan.nodes.push(MonorepoNodePlan {
873 spool_id: node.spool_id.clone(),
874 content_state: node.content_state,
875 rel_path: rel_path.clone(),
876 });
877
878 for edge in &node.edges {
879 validate_monorepo_mount_name(edge)?;
880 let child_rel = rel_path.join(&edge.mount_name);
881 match &edge.child {
882 Some(child) => walk_monorepo_node(plan, child, child_rel)?,
883 None => {
884 let reason = edge
885 .skip_reason
886 .unwrap_or(MonorepoEdgeSkipReason::Unspecified);
887 plan.skipped.push(MonorepoSkippedChild {
888 child_spool_id: edge.child_spool_id.clone(),
889 mount_name: edge.mount_name.clone(),
890 rel_path: child_rel,
891 reason,
892 });
893 }
894 }
895 }
896 Ok(())
897}
898
899fn validate_monorepo_mount_name(edge: &MonorepoEdgeFacts) -> Result<(), MonorepoClonePlanError> {
900 let mut components = Path::new(&edge.mount_name).components();
901 let is_one_normal_component =
902 matches!(components.next(), Some(std::path::Component::Normal(_)))
903 && components.next().is_none()
904 && !edge.mount_name.contains(['/', '\\']);
905 if is_one_normal_component {
906 return Ok(());
907 }
908 Err(MonorepoClonePlanError::InvalidMountName {
909 child_spool_id: edge.child_spool_id.clone(),
910 mount_name: edge.mount_name.clone(),
911 })
912}
913
914#[derive(Debug, Clone, PartialEq, Eq)]
928pub enum MonorepoNodeExecutionStep {
929 ValidateDest,
931 InitRepo,
933 FetchContent { state: StateId },
935 MaterializeState { state: StateId },
938 RecordMapping,
940}
941
942impl MonorepoNodeExecutionStep {
943 pub fn as_str(&self) -> &'static str {
945 match self {
946 Self::ValidateDest => "validate_dest",
947 Self::InitRepo => "init_repo",
948 Self::FetchContent { .. } => "fetch_content",
949 Self::MaterializeState { .. } => "materialize_state",
950 Self::RecordMapping => "record_mapping",
951 }
952 }
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
960pub struct MonorepoNodeStepOptions {
961 pub record_mapping: bool,
963}
964
965impl Default for MonorepoNodeStepOptions {
966 fn default() -> Self {
967 Self {
968 record_mapping: true,
969 }
970 }
971}
972
973#[derive(Debug, Clone, PartialEq, Eq)]
975pub struct MonorepoNodeExecution {
976 pub node: MonorepoNodePlan,
977 pub steps: Vec<MonorepoNodeExecutionStep>,
978}
979
980#[derive(Debug, Clone, PartialEq, Eq, Default)]
985pub struct MonorepoExecutionPlan {
986 pub nodes: Vec<MonorepoNodeExecution>,
988 pub skipped: Vec<MonorepoSkippedChild>,
990}
991
992impl MonorepoExecutionPlan {
993 pub fn node_count(&self) -> usize {
995 self.nodes.len()
996 }
997}
998
999pub fn plan_monorepo_node_steps(
1007 node: &MonorepoNodePlan,
1008 options: &MonorepoNodeStepOptions,
1009) -> Vec<MonorepoNodeExecutionStep> {
1010 let mut steps = vec![
1011 MonorepoNodeExecutionStep::ValidateDest,
1012 MonorepoNodeExecutionStep::InitRepo,
1013 ];
1014 if let Some(state) = node.content_state {
1015 steps.push(MonorepoNodeExecutionStep::FetchContent { state });
1016 steps.push(MonorepoNodeExecutionStep::MaterializeState { state });
1017 }
1018 if options.record_mapping {
1019 steps.push(MonorepoNodeExecutionStep::RecordMapping);
1020 }
1021 steps
1022}
1023
1024pub fn plan_monorepo_execution(
1028 clone_plan: &MonorepoClonePlan,
1029 options: &MonorepoNodeStepOptions,
1030) -> MonorepoExecutionPlan {
1031 MonorepoExecutionPlan {
1032 nodes: clone_plan
1033 .nodes
1034 .iter()
1035 .map(|node| MonorepoNodeExecution {
1036 node: node.clone(),
1037 steps: plan_monorepo_node_steps(node, options),
1038 })
1039 .collect(),
1040 skipped: clone_plan.skipped.clone(),
1041 }
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq)]
1055pub enum MonorepoNodeExecutionError {
1056 EmptySteps,
1058 MissingStep { step: &'static str },
1060 OutOfOrder {
1062 step: &'static str,
1063 detail: &'static str,
1064 },
1065 MaterializeWithoutFetch,
1067 FetchWithoutMaterialize,
1069 FetchMaterializeStateMismatch {
1071 fetch: StateId,
1072 materialize: StateId,
1073 },
1074}
1075
1076impl std::fmt::Display for MonorepoNodeExecutionError {
1077 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1078 match self {
1079 Self::EmptySteps => write!(f, "monorepo node execution steps are empty"),
1080 Self::MissingStep { step } => {
1081 write!(f, "monorepo node execution missing required step '{step}'")
1082 }
1083 Self::OutOfOrder { step, detail } => {
1084 write!(
1085 f,
1086 "monorepo node execution step '{step}' out of order: {detail}"
1087 )
1088 }
1089 Self::MaterializeWithoutFetch => {
1090 write!(f, "monorepo MaterializeState requires FetchContent first")
1091 }
1092 Self::FetchWithoutMaterialize => write!(
1093 f,
1094 "monorepo FetchContent requires a following MaterializeState"
1095 ),
1096 Self::FetchMaterializeStateMismatch { fetch, materialize } => write!(
1097 f,
1098 "monorepo FetchContent state {fetch} does not match MaterializeState {materialize}"
1099 ),
1100 }
1101 }
1102}
1103
1104impl std::error::Error for MonorepoNodeExecutionError {}
1105
1106fn monorepo_step_rank(step: &MonorepoNodeExecutionStep) -> u8 {
1108 match step {
1109 MonorepoNodeExecutionStep::ValidateDest => 0,
1110 MonorepoNodeExecutionStep::InitRepo => 1,
1111 MonorepoNodeExecutionStep::FetchContent { .. } => 2,
1112 MonorepoNodeExecutionStep::MaterializeState { .. } => 3,
1113 MonorepoNodeExecutionStep::RecordMapping => 4,
1114 }
1115}
1116
1117pub fn validate_monorepo_node_execution(
1128 steps: &[MonorepoNodeExecutionStep],
1129) -> Result<(), MonorepoNodeExecutionError> {
1130 if steps.is_empty() {
1131 return Err(MonorepoNodeExecutionError::EmptySteps);
1132 }
1133
1134 let mut seen_validate = false;
1135 let mut seen_init = false;
1136 let mut pending_fetch: Option<StateId> = None;
1137 let mut last_rank: Option<u8> = None;
1138
1139 for step in steps {
1140 let rank = monorepo_step_rank(step);
1141 if let Some(prev) = last_rank
1142 && rank <= prev
1143 {
1144 return Err(MonorepoNodeExecutionError::OutOfOrder {
1145 step: step.as_str(),
1146 detail: "steps must be unique and strictly increasing in rank",
1147 });
1148 }
1149 last_rank = Some(rank);
1150
1151 match step {
1152 MonorepoNodeExecutionStep::ValidateDest => {
1153 seen_validate = true;
1154 }
1155 MonorepoNodeExecutionStep::InitRepo => {
1156 if !seen_validate {
1157 return Err(MonorepoNodeExecutionError::OutOfOrder {
1158 step: step.as_str(),
1159 detail: "InitRepo requires ValidateDest first",
1160 });
1161 }
1162 seen_init = true;
1163 }
1164 MonorepoNodeExecutionStep::FetchContent { state } => {
1165 if !seen_init {
1166 return Err(MonorepoNodeExecutionError::OutOfOrder {
1167 step: step.as_str(),
1168 detail: "Init before Fetch",
1169 });
1170 }
1171 pending_fetch = Some(*state);
1172 }
1173 MonorepoNodeExecutionStep::MaterializeState { state } => {
1174 if !seen_init {
1175 return Err(MonorepoNodeExecutionError::OutOfOrder {
1176 step: step.as_str(),
1177 detail: "Init before Materialize",
1178 });
1179 }
1180 match pending_fetch {
1181 None => return Err(MonorepoNodeExecutionError::MaterializeWithoutFetch),
1182 Some(fetch) if fetch != *state => {
1183 return Err(MonorepoNodeExecutionError::FetchMaterializeStateMismatch {
1184 fetch,
1185 materialize: *state,
1186 });
1187 }
1188 Some(_) => {
1189 pending_fetch = None;
1190 }
1191 }
1192 }
1193 MonorepoNodeExecutionStep::RecordMapping => {
1194 if !seen_init {
1195 return Err(MonorepoNodeExecutionError::OutOfOrder {
1196 step: step.as_str(),
1197 detail: "Init before RecordMapping",
1198 });
1199 }
1200 if pending_fetch.is_some() {
1201 return Err(MonorepoNodeExecutionError::FetchWithoutMaterialize);
1202 }
1203 }
1204 }
1205 }
1206
1207 if !seen_validate {
1208 return Err(MonorepoNodeExecutionError::MissingStep {
1209 step: MonorepoNodeExecutionStep::ValidateDest.as_str(),
1210 });
1211 }
1212 if !seen_init {
1213 return Err(MonorepoNodeExecutionError::MissingStep {
1214 step: MonorepoNodeExecutionStep::InitRepo.as_str(),
1215 });
1216 }
1217 if pending_fetch.is_some() {
1218 return Err(MonorepoNodeExecutionError::FetchWithoutMaterialize);
1219 }
1220
1221 Ok(())
1222}
1223
1224pub fn validate_monorepo_execution(
1226 plan: &MonorepoExecutionPlan,
1227) -> Result<(), MonorepoNodeExecutionError> {
1228 for node_exec in &plan.nodes {
1229 validate_monorepo_node_execution(&node_exec.steps)?;
1230 }
1231 Ok(())
1232}
1233
1234#[derive(Debug, Clone, PartialEq, Eq)]
1238pub struct MonorepoExecutionProgress {
1239 pub node_index: usize,
1241 pub total_nodes: usize,
1243 pub node_display: usize,
1245 pub step: &'static str,
1247}
1248
1249impl MonorepoExecutionProgress {
1250 pub fn label(&self) -> String {
1252 format!("[{}/{}] {}", self.node_display, self.total_nodes, self.step)
1253 }
1254}
1255
1256pub fn monorepo_execution_progress(
1260 node_index: usize,
1261 total: usize,
1262 step: &MonorepoNodeExecutionStep,
1263) -> MonorepoExecutionProgress {
1264 MonorepoExecutionProgress {
1265 node_index,
1266 total_nodes: total,
1267 node_display: node_index.saturating_add(1),
1268 step: step.as_str(),
1269 }
1270}
1271
1272#[derive(Debug, Clone, PartialEq, Eq)]
1274pub struct MonorepoPlacedNodeSummary {
1275 pub spool_id: String,
1276 pub rel_path: PathBuf,
1278 pub content_state: Option<StateId>,
1279 pub materialized_content: bool,
1281}
1282
1283#[derive(Debug, Clone, PartialEq, Eq, Default)]
1288pub struct MonorepoCloneResultSummary {
1289 pub placed_count: usize,
1290 pub skipped_count: usize,
1291 pub placed: Vec<MonorepoPlacedNodeSummary>,
1292 pub skipped: Vec<MonorepoSkippedChild>,
1293}
1294
1295impl MonorepoCloneResultSummary {
1296 pub fn headline(&self, root_path: &str) -> String {
1298 let unit = if self.placed_count == 1 {
1299 "spool"
1300 } else {
1301 "spools"
1302 };
1303 format!(
1304 "Cloned monorepo {root_path} ({} {unit} placed).",
1305 self.placed_count
1306 )
1307 }
1308
1309 pub fn skipped_header(&self) -> Option<String> {
1311 if self.skipped_count == 0 {
1312 None
1313 } else {
1314 Some(format!(
1315 "{} child spool(s) skipped (not part of your coherent slice):",
1316 self.skipped_count
1317 ))
1318 }
1319 }
1320}
1321
1322pub fn assemble_monorepo_clone_result_summary(
1327 plan: &MonorepoExecutionPlan,
1328) -> MonorepoCloneResultSummary {
1329 let placed: Vec<MonorepoPlacedNodeSummary> = plan
1330 .nodes
1331 .iter()
1332 .map(|node_exec| {
1333 let materialized_content = node_exec
1334 .steps
1335 .iter()
1336 .any(|step| matches!(step, MonorepoNodeExecutionStep::MaterializeState { .. }));
1337 MonorepoPlacedNodeSummary {
1338 spool_id: node_exec.node.spool_id.clone(),
1339 rel_path: node_exec.node.rel_path.clone(),
1340 content_state: node_exec.node.content_state,
1341 materialized_content,
1342 }
1343 })
1344 .collect();
1345 MonorepoCloneResultSummary {
1346 placed_count: placed.len(),
1347 skipped_count: plan.skipped.len(),
1348 placed,
1349 skipped: plan.skipped.clone(),
1350 }
1351}
1352
1353#[cfg(test)]
1358mod tests {
1359 use super::*;
1360
1361 fn base_clone_options(remote: &str, local: &str) -> ClonePlanOptions {
1362 ClonePlanOptions {
1363 remote: remote.to_string(),
1364 local: PathBuf::from(local),
1365 thread: None,
1366 depth: None,
1367 lazy: false,
1368 filter: None,
1369 recursive: false,
1370 insecure: false,
1371 }
1372 }
1373
1374 #[test]
1375 fn absolute_path_joins_relative_against_cwd() {
1376 let cwd = Path::new("/work");
1377 assert_eq!(
1378 absolute_path(Path::new("dest"), cwd),
1379 PathBuf::from("/work/dest")
1380 );
1381 assert_eq!(
1382 absolute_path(Path::new("/abs/dest"), cwd),
1383 PathBuf::from("/abs/dest")
1384 );
1385 }
1386
1387 #[test]
1388 fn resolve_clone_destination_uses_absolute_policy() {
1389 let cwd = Path::new("/tmp/repo");
1390 assert_eq!(
1391 resolve_clone_destination(Path::new("clone-here"), cwd),
1392 PathBuf::from("/tmp/repo/clone-here")
1393 );
1394 }
1395
1396 #[test]
1397 fn validate_clone_destination_refuses_existing() {
1398 assert!(matches!(
1399 validate_clone_destination(Path::new("/tmp/x"), true),
1400 Err(ClonePlanError::DestinationExists { .. })
1401 ));
1402 assert!(validate_clone_destination(Path::new("/tmp/x"), false).is_ok());
1403 }
1404
1405 #[test]
1406 fn normalize_clone_depth_drops_zero() {
1407 assert_eq!(normalize_clone_depth(None), None);
1408 assert_eq!(normalize_clone_depth(Some(0)), None);
1409 assert_eq!(normalize_clone_depth(Some(1)), Some(1));
1410 assert_eq!(normalize_clone_depth(Some(5)), Some(5));
1411 }
1412
1413 #[test]
1414 fn looks_like_local_path_shapes() {
1415 assert!(looks_like_local_path("/abs/path"));
1416 assert!(looks_like_local_path("."));
1417 assert!(looks_like_local_path(".."));
1418 assert!(looks_like_local_path("./rel"));
1419 assert!(looks_like_local_path("../up"));
1420 assert!(looks_like_local_path("~/home"));
1421 assert!(!looks_like_local_path("host:8421/repo"));
1422 assert!(!looks_like_local_path("https://example.com/repo.git"));
1423 }
1424
1425 #[test]
1426 fn looks_like_git_overlay_url_shapes() {
1427 assert!(looks_like_git_overlay_url("https://example.com/repo.git"));
1428 assert!(looks_like_git_overlay_url("git@github.com:org/repo.git"));
1429 assert!(looks_like_git_overlay_url("ssh://git@host/repo.git"));
1430 assert!(!looks_like_git_overlay_url("localhost:8421/acme/heddle"));
1431 assert!(!looks_like_git_overlay_url("/local/path"));
1432 }
1433
1434 #[test]
1435 fn plan_clone_refuses_existing_destination() {
1436 let opts = base_clone_options("file:///src", "/dest");
1437 let err = plan_clone(
1438 &opts,
1439 &ClonePlanFacts {
1440 destination_exists: true,
1441 remote_source: CloneRemoteSource::Local {
1442 path: PathBuf::from("/src"),
1443 has_heddle: true,
1444 is_git: false,
1445 },
1446 },
1447 )
1448 .unwrap_err();
1449 assert!(matches!(err, ClonePlanError::DestinationExists { .. }));
1450 }
1451
1452 #[test]
1453 fn plan_clone_local_heddle_vs_git_overlay() {
1454 let opts = base_clone_options("file:///src", "/dest");
1455 let heddle = plan_clone(
1456 &opts,
1457 &ClonePlanFacts {
1458 destination_exists: false,
1459 remote_source: CloneRemoteSource::Local {
1460 path: PathBuf::from("/src"),
1461 has_heddle: true,
1462 is_git: true,
1463 },
1464 },
1465 )
1466 .unwrap();
1467 assert!(matches!(heddle.mode, CloneMode::LocalHeddle { .. }));
1468 assert!(!heddle.security.requires_network_session);
1469
1470 let git = plan_clone(
1471 &opts,
1472 &ClonePlanFacts {
1473 destination_exists: false,
1474 remote_source: CloneRemoteSource::Local {
1475 path: PathBuf::from("/src"),
1476 has_heddle: false,
1477 is_git: true,
1478 },
1479 },
1480 )
1481 .unwrap();
1482 assert!(matches!(git.mode, CloneMode::LocalGitOverlay { .. }));
1483 }
1484
1485 #[test]
1486 fn plan_clone_network_security_and_effective_lazy() {
1487 let mut opts = base_clone_options("heddle://host:1/repo", "/dest");
1488 opts.insecure = true;
1489 opts.lazy = false;
1490 opts.filter = Some("blob:none".into());
1491 opts.depth = Some(0);
1492
1493 let plan = plan_clone(
1494 &opts,
1495 &ClonePlanFacts {
1496 destination_exists: false,
1497 remote_source: CloneRemoteSource::Network {
1498 has_repo_path: true,
1499 },
1500 },
1501 )
1502 .unwrap();
1503
1504 assert_eq!(plan.mode, CloneMode::NetworkHosted { recursive: false });
1505 assert!(plan.security.requires_network_session);
1506 assert!(plan.security.allow_insecure);
1507 assert!(plan.effective_lazy);
1508 assert_eq!(plan.depth, None);
1509 }
1510
1511 #[test]
1512 fn plan_clone_monorepo_requires_hosted() {
1513 let mut opts = base_clone_options("/local/repo", "/dest");
1514 opts.recursive = true;
1515 let err = plan_clone(
1516 &opts,
1517 &ClonePlanFacts {
1518 destination_exists: false,
1519 remote_source: CloneRemoteSource::Local {
1520 path: PathBuf::from("/local/repo"),
1521 has_heddle: true,
1522 is_git: false,
1523 },
1524 },
1525 )
1526 .unwrap_err();
1527 assert!(matches!(err, ClonePlanError::MonorepoRequiresHosted { .. }));
1528
1529 let mut opts = base_clone_options("https://example.com/r.git", "/dest");
1530 opts.recursive = true;
1531 let err = plan_clone(
1532 &opts,
1533 &ClonePlanFacts {
1534 destination_exists: false,
1535 remote_source: CloneRemoteSource::Unparsed,
1536 },
1537 )
1538 .unwrap_err();
1539 assert!(matches!(err, ClonePlanError::MonorepoRequiresHosted { .. }));
1540 }
1541
1542 #[test]
1543 fn plan_clone_unparsed_git_url_and_invalid() {
1544 let plan = plan_clone(
1545 &base_clone_options("https://example.com/r.git", "/dest"),
1546 &ClonePlanFacts {
1547 destination_exists: false,
1548 remote_source: CloneRemoteSource::Unparsed,
1549 },
1550 )
1551 .unwrap();
1552 assert_eq!(plan.mode, CloneMode::GitOverlayUrl);
1553
1554 let err = plan_clone(
1555 &base_clone_options("not-a-remote", "/dest"),
1556 &ClonePlanFacts {
1557 destination_exists: false,
1558 remote_source: CloneRemoteSource::Unparsed,
1559 },
1560 )
1561 .unwrap_err();
1562 assert!(matches!(err, ClonePlanError::InvalidRemoteUrl { .. }));
1563
1564 let err = plan_clone(
1565 &base_clone_options("./missing", "/dest"),
1566 &ClonePlanFacts {
1567 destination_exists: false,
1568 remote_source: CloneRemoteSource::Unparsed,
1569 },
1570 )
1571 .unwrap_err();
1572 assert!(matches!(
1573 err,
1574 ClonePlanError::RemoteLooksLikeMissingLocalPath { .. }
1575 ));
1576 }
1577
1578 #[test]
1579 fn plan_clone_rejects_unsupported_mode_options() {
1580 let mut opts = base_clone_options("https://example.com/r.git", "/dest");
1581 opts.depth = Some(1);
1582 let err = plan_clone(
1583 &opts,
1584 &ClonePlanFacts {
1585 destination_exists: false,
1586 remote_source: CloneRemoteSource::Unparsed,
1587 },
1588 )
1589 .unwrap_err();
1590 assert!(matches!(
1591 err,
1592 ClonePlanError::UnsupportedOption {
1593 flag: UnsupportedCloneFlag::Depth,
1594 mode: "git-overlay",
1595 ..
1596 }
1597 ));
1598
1599 let mut opts = base_clone_options("file:///src", "/dest");
1600 opts.lazy = true;
1601 let err = plan_clone(
1602 &opts,
1603 &ClonePlanFacts {
1604 destination_exists: false,
1605 remote_source: CloneRemoteSource::Local {
1606 path: PathBuf::from("/src"),
1607 has_heddle: true,
1608 is_git: false,
1609 },
1610 },
1611 )
1612 .unwrap_err();
1613 assert!(matches!(
1614 err,
1615 ClonePlanError::UnsupportedOption {
1616 flag: UnsupportedCloneFlag::Lazy,
1617 mode: "local",
1618 ..
1619 }
1620 ));
1621
1622 let mut opts = base_clone_options("heddle://h:1/r", "/dest");
1623 opts.recursive = true;
1624 opts.filter = Some("blob:none".into());
1625 let err = plan_clone(
1626 &opts,
1627 &ClonePlanFacts {
1628 destination_exists: false,
1629 remote_source: CloneRemoteSource::Network {
1630 has_repo_path: true,
1631 },
1632 },
1633 )
1634 .unwrap_err();
1635 assert!(matches!(
1636 err,
1637 ClonePlanError::UnsupportedOption {
1638 flag: UnsupportedCloneFlag::Filter,
1639 mode: "monorepo",
1640 ..
1641 }
1642 ));
1643 }
1644
1645 #[test]
1646 fn select_clone_checkout_thread_priority_and_fail_closed() {
1647 assert_eq!(
1648 select_clone_checkout_thread(Some("feature"), None, ["main", "feature"]).unwrap(),
1649 "feature"
1650 );
1651 assert_eq!(
1652 select_clone_checkout_thread(None, Some("trunk"), ["alpha", "main", "trunk"]).unwrap(),
1653 "trunk"
1654 );
1655 assert_eq!(
1656 select_clone_checkout_thread(None, None, ["master", "main"]).unwrap(),
1657 "main"
1658 );
1659 assert_eq!(
1660 select_clone_checkout_thread(None, None, ["refs/heads/trunk", "trunk"]).unwrap(),
1661 "trunk"
1662 );
1663 assert_eq!(
1664 select_clone_checkout_thread(
1665 Some("refs/heads/feature"),
1666 Some("main"),
1667 ["feature", "main"]
1668 )
1669 .unwrap(),
1670 "feature"
1671 );
1672 assert!(matches!(
1673 select_clone_checkout_thread(Some("missing"), None, ["main"]),
1674 Err(CloneThreadSelectError::RequestedNotAdvertised { requested })
1675 if requested == "missing"
1676 ));
1677 assert!(matches!(
1678 select_clone_checkout_thread(None, None, ["refs/heads/only"]),
1679 Err(CloneThreadSelectError::NoAdvertisedThreads)
1680 ));
1681 }
1682
1683 #[test]
1684 fn plan_adopt_path_resolution_and_conflict() {
1685 let cwd = PathBuf::from("/work");
1686 let plan = plan_adopt(&AdoptPlanOptions {
1687 path: None,
1688 repo_flag: None,
1689 cwd: cwd.clone(),
1690 refs: vec![],
1691 })
1692 .unwrap();
1693 assert_eq!(plan.start_path, cwd);
1694 assert!(plan.import_all_refs);
1695
1696 let plan = plan_adopt(&AdoptPlanOptions {
1697 path: Some(PathBuf::from("repo")),
1698 repo_flag: None,
1699 cwd: PathBuf::from("/work"),
1700 refs: vec!["main".into()],
1701 })
1702 .unwrap();
1703 assert_eq!(plan.start_path, PathBuf::from("repo"));
1704 assert!(!plan.import_all_refs);
1705
1706 let plan = plan_adopt(&AdoptPlanOptions {
1707 path: Some(PathBuf::from("repo")),
1708 repo_flag: Some(PathBuf::from("/work/repo")),
1709 cwd: PathBuf::from("/work"),
1710 refs: vec![],
1711 })
1712 .unwrap();
1713 assert_eq!(plan.start_path, PathBuf::from("repo"));
1714
1715 let err = plan_adopt(&AdoptPlanOptions {
1716 path: Some(PathBuf::from("a")),
1717 repo_flag: Some(PathBuf::from("b")),
1718 cwd: PathBuf::from("/work"),
1719 refs: vec![],
1720 })
1721 .unwrap_err();
1722 assert!(matches!(err, AdoptPlanError::PathConflict { .. }));
1723 }
1724
1725 #[test]
1726 fn assemble_security_only_for_network() {
1727 let local = assemble_clone_security_preflight(
1728 &CloneMode::LocalHeddle {
1729 remote_path: PathBuf::from("/s"),
1730 },
1731 true,
1732 );
1733 assert!(!local.allow_insecure);
1734 assert!(!local.requires_network_session);
1735
1736 let net =
1737 assemble_clone_security_preflight(&CloneMode::NetworkHosted { recursive: false }, true);
1738 assert!(net.allow_insecure);
1739 assert!(net.requires_network_session);
1740 }
1741
1742 fn cid(seed: u8) -> StateId {
1745 StateId::from_bytes([seed; 32])
1746 }
1747
1748 fn leaf(spool_id: &str, content: u8) -> MonorepoNodeFacts {
1749 MonorepoNodeFacts {
1750 spool_id: spool_id.to_string(),
1751 content_state: Some(cid(content)),
1752 edges: vec![],
1753 }
1754 }
1755
1756 fn selected_edge(mount: &str, child_id: &str, child: MonorepoNodeFacts) -> MonorepoEdgeFacts {
1757 MonorepoEdgeFacts {
1758 mount_name: mount.to_string(),
1759 child_spool_id: child_id.to_string(),
1760 child: Some(child),
1761 skip_reason: None,
1762 }
1763 }
1764
1765 fn skipped_edge(
1766 mount: &str,
1767 child_id: &str,
1768 reason: MonorepoEdgeSkipReason,
1769 ) -> MonorepoEdgeFacts {
1770 MonorepoEdgeFacts {
1771 mount_name: mount.to_string(),
1772 child_spool_id: child_id.to_string(),
1773 child: None,
1774 skip_reason: Some(reason),
1775 }
1776 }
1777
1778 fn fixture_tree() -> MonorepoNodeFacts {
1783 let grandchild = leaf("acme/grandchild", 3);
1784 let child_a = MonorepoNodeFacts {
1785 spool_id: "acme/child-a".to_string(),
1786 content_state: Some(cid(2)),
1787 edges: vec![selected_edge("vendor", "acme/grandchild", grandchild)],
1788 };
1789 MonorepoNodeFacts {
1790 spool_id: "acme/root".to_string(),
1791 content_state: Some(cid(1)),
1792 edges: vec![
1793 selected_edge("libs", "acme/child-a", child_a),
1794 skipped_edge("secret", "acme/child-b", MonorepoEdgeSkipReason::Unreadable),
1795 ],
1796 }
1797 }
1798
1799 #[test]
1800 fn plan_monorepo_places_nodes_at_mount_paths_in_preorder() {
1801 let plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
1802
1803 assert_eq!(plan.nodes.len(), 3, "root + child-a + grandchild");
1804
1805 assert_eq!(plan.nodes[0].spool_id, "acme/root");
1806 assert_eq!(plan.nodes[0].rel_path, PathBuf::new());
1807 assert_eq!(plan.nodes[0].content_state, Some(cid(1)));
1808
1809 assert_eq!(plan.nodes[1].spool_id, "acme/child-a");
1810 assert_eq!(plan.nodes[1].rel_path, PathBuf::from("libs"));
1811 assert_eq!(plan.nodes[1].content_state, Some(cid(2)));
1812
1813 assert_eq!(plan.nodes[2].spool_id, "acme/grandchild");
1814 assert_eq!(plan.nodes[2].rel_path, PathBuf::from("libs").join("vendor"));
1815 assert_eq!(plan.nodes[2].content_state, Some(cid(3)));
1816 }
1817
1818 #[test]
1819 fn plan_monorepo_records_skipped_children_and_does_not_select_them() {
1820 let plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
1821
1822 assert_eq!(plan.skipped.len(), 1);
1823 let sk = &plan.skipped[0];
1824 assert_eq!(sk.child_spool_id, "acme/child-b");
1825 assert_eq!(sk.mount_name, "secret");
1826 assert_eq!(sk.rel_path, PathBuf::from("secret"));
1827 assert_eq!(sk.reason, MonorepoEdgeSkipReason::Unreadable);
1828 assert_eq!(sk.reason_label(), "unreadable");
1829
1830 assert!(
1831 plan.nodes.iter().all(|n| n.spool_id != "acme/child-b"),
1832 "skipped child must not appear as a materialize node"
1833 );
1834 }
1835
1836 #[test]
1837 fn monorepo_node_dest_path_joins_root() {
1838 let plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
1839 let root = Path::new("/tmp/mono");
1840
1841 assert_eq!(plan.nodes[0].dest_path(root), PathBuf::from("/tmp/mono"));
1842 assert_eq!(
1843 plan.nodes[1].dest_path(root),
1844 PathBuf::from("/tmp/mono/libs")
1845 );
1846 assert_eq!(
1847 plan.nodes[2].dest_path(root),
1848 PathBuf::from("/tmp/mono/libs/vendor")
1849 );
1850 }
1851
1852 #[test]
1853 fn plan_monorepo_empty_content_still_walks_children() {
1854 let child = leaf("acme/child", 5);
1855 let root = MonorepoNodeFacts {
1856 spool_id: "acme/root".to_string(),
1857 content_state: None,
1858 edges: vec![selected_edge("sub", "acme/child", child)],
1859 };
1860 let plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
1861
1862 assert_eq!(plan.nodes.len(), 2);
1863 assert_eq!(plan.nodes[0].spool_id, "acme/root");
1864 assert_eq!(plan.nodes[0].content_state, None);
1865 assert_eq!(plan.nodes[1].spool_id, "acme/child");
1866 assert_eq!(plan.nodes[1].rel_path, PathBuf::from("sub"));
1867 assert_eq!(plan.nodes[1].content_state, Some(cid(5)));
1868 }
1869
1870 #[test]
1871 fn plan_monorepo_rejects_mounts_that_are_not_one_relative_component() {
1872 for mount in [
1873 "",
1874 ".",
1875 "..",
1876 "../victim",
1877 "/tmp/victim",
1878 "libs/child",
1879 r"libs\child",
1880 ] {
1881 let root = MonorepoNodeFacts {
1882 spool_id: "acme/root".to_string(),
1883 content_state: Some(cid(1)),
1884 edges: vec![selected_edge(mount, "acme/child", leaf("acme/child", 2))],
1885 };
1886 assert!(
1887 matches!(
1888 plan_monorepo_clone(&root),
1889 Err(MonorepoClonePlanError::InvalidMountName {
1890 child_spool_id,
1891 mount_name,
1892 }) if child_spool_id == "acme/child" && mount_name == mount
1893 ),
1894 "mount {mount:?} must fail before clone I/O"
1895 );
1896 }
1897 }
1898
1899 #[test]
1900 fn plan_monorepo_missing_skip_reason_defaults_to_unspecified() {
1901 let root = MonorepoNodeFacts {
1902 spool_id: "root".to_string(),
1903 content_state: Some(cid(1)),
1904 edges: vec![MonorepoEdgeFacts {
1905 mount_name: "m".into(),
1906 child_spool_id: "child".into(),
1907 child: None,
1908 skip_reason: None,
1909 }],
1910 };
1911 let plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
1912 assert_eq!(plan.skipped.len(), 1);
1913 assert_eq!(plan.skipped[0].reason, MonorepoEdgeSkipReason::Unspecified);
1914 assert_eq!(plan.skipped[0].reason_label(), "unspecified");
1915 }
1916
1917 #[test]
1918 fn monorepo_rel_display_and_wire_skip() {
1919 assert_eq!(monorepo_rel_display(Path::new("")), ".");
1920 assert_eq!(monorepo_rel_display(Path::new("libs")), "libs");
1921 assert_eq!(
1922 MonorepoEdgeSkipReason::from_wire_i32(1),
1923 Some(MonorepoEdgeSkipReason::Unreadable)
1924 );
1925 assert_eq!(MonorepoEdgeSkipReason::from_wire_i32(99), None);
1926 }
1927
1928 #[test]
1929 fn monorepo_edge_skip_labels_are_stable() {
1930 for (reason, label) in [
1931 (MonorepoEdgeSkipReason::Unreadable, "unreadable"),
1932 (MonorepoEdgeSkipReason::Cycle, "cycle"),
1933 (MonorepoEdgeSkipReason::DepthBounded, "depth-bounded"),
1934 ] {
1935 assert_eq!(reason.as_str(), label);
1936 let root = MonorepoNodeFacts {
1937 spool_id: "root".to_string(),
1938 content_state: Some(cid(1)),
1939 edges: vec![skipped_edge("m", "child", reason)],
1940 };
1941 let plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
1942 assert_eq!(plan.skipped[0].reason_label(), label);
1943 }
1944 }
1945
1946 #[test]
1947 fn validate_monorepo_clone_options_refuses_filter_lazy_depth() {
1948 assert!(validate_monorepo_clone_options(None, false, None).is_ok());
1949
1950 assert!(matches!(
1951 validate_monorepo_clone_options(None, false, Some("blob:none")),
1952 Err(ClonePlanError::UnsupportedOption {
1953 flag: UnsupportedCloneFlag::Filter,
1954 mode: "monorepo",
1955 ..
1956 })
1957 ));
1958 assert!(matches!(
1959 validate_monorepo_clone_options(None, true, None),
1960 Err(ClonePlanError::UnsupportedOption {
1961 flag: UnsupportedCloneFlag::Lazy,
1962 mode: "monorepo",
1963 ..
1964 })
1965 ));
1966 assert!(matches!(
1967 validate_monorepo_clone_options(Some(1), false, None),
1968 Err(ClonePlanError::UnsupportedOption {
1969 flag: UnsupportedCloneFlag::Depth,
1970 mode: "monorepo",
1971 ..
1972 })
1973 ));
1974 }
1975
1976 #[test]
1977 fn selected_edge_with_skip_reason_still_descends() {
1978 let child = leaf("acme/child", 2);
1980 let root = MonorepoNodeFacts {
1981 spool_id: "root".to_string(),
1982 content_state: Some(cid(1)),
1983 edges: vec![MonorepoEdgeFacts {
1984 mount_name: "sub".into(),
1985 child_spool_id: "acme/child".into(),
1986 child: Some(child),
1987 skip_reason: Some(MonorepoEdgeSkipReason::Unreadable),
1988 }],
1989 };
1990 let plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
1991 assert_eq!(plan.nodes.len(), 2);
1992 assert!(plan.skipped.is_empty());
1993 assert_eq!(plan.nodes[1].spool_id, "acme/child");
1994 }
1995
1996 #[test]
1999 fn plan_monorepo_node_steps_full_content_order() {
2000 let node = MonorepoNodePlan {
2001 spool_id: "acme/root".into(),
2002 content_state: Some(cid(1)),
2003 rel_path: PathBuf::new(),
2004 };
2005 let steps = plan_monorepo_node_steps(&node, &MonorepoNodeStepOptions::default());
2006 assert_eq!(
2007 steps
2008 .iter()
2009 .map(MonorepoNodeExecutionStep::as_str)
2010 .collect::<Vec<_>>(),
2011 [
2012 "validate_dest",
2013 "init_repo",
2014 "fetch_content",
2015 "materialize_state",
2016 "record_mapping",
2017 ]
2018 );
2019 assert_eq!(
2020 steps[2],
2021 MonorepoNodeExecutionStep::FetchContent { state: cid(1) }
2022 );
2023 assert_eq!(
2024 steps[3],
2025 MonorepoNodeExecutionStep::MaterializeState { state: cid(1) }
2026 );
2027 }
2028
2029 #[test]
2030 fn plan_monorepo_node_steps_empty_content_skips_fetch_and_materialize() {
2031 let node = MonorepoNodePlan {
2032 spool_id: "acme/empty".into(),
2033 content_state: None,
2034 rel_path: PathBuf::from("libs"),
2035 };
2036 let steps = plan_monorepo_node_steps(&node, &MonorepoNodeStepOptions::default());
2037 assert_eq!(
2038 steps,
2039 vec![
2040 MonorepoNodeExecutionStep::ValidateDest,
2041 MonorepoNodeExecutionStep::InitRepo,
2042 MonorepoNodeExecutionStep::RecordMapping,
2043 ]
2044 );
2045 }
2046
2047 #[test]
2048 fn plan_monorepo_node_steps_can_omit_record_mapping() {
2049 let node = MonorepoNodePlan {
2050 spool_id: "acme/root".into(),
2051 content_state: Some(cid(9)),
2052 rel_path: PathBuf::new(),
2053 };
2054 let steps = plan_monorepo_node_steps(
2055 &node,
2056 &MonorepoNodeStepOptions {
2057 record_mapping: false,
2058 },
2059 );
2060 assert_eq!(
2061 steps
2062 .iter()
2063 .map(MonorepoNodeExecutionStep::as_str)
2064 .collect::<Vec<_>>(),
2065 [
2066 "validate_dest",
2067 "init_repo",
2068 "fetch_content",
2069 "materialize_state",
2070 ]
2071 );
2072 assert!(
2073 !steps
2074 .iter()
2075 .any(|s| matches!(s, MonorepoNodeExecutionStep::RecordMapping))
2076 );
2077 }
2078
2079 #[test]
2080 fn plan_monorepo_execution_preserves_preorder_and_skipped() {
2081 let clone_plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
2082 let exec = plan_monorepo_execution(&clone_plan, &MonorepoNodeStepOptions::default());
2083
2084 assert_eq!(exec.node_count(), 3);
2085 assert_eq!(exec.nodes.len(), clone_plan.nodes.len());
2086 assert_eq!(exec.skipped, clone_plan.skipped);
2087
2088 assert_eq!(exec.nodes[0].node.spool_id, "acme/root");
2090 assert_eq!(exec.nodes[1].node.spool_id, "acme/child-a");
2091 assert_eq!(exec.nodes[2].node.spool_id, "acme/grandchild");
2092 assert_eq!(
2093 exec.nodes[2].node.rel_path,
2094 PathBuf::from("libs").join("vendor")
2095 );
2096
2097 for node_exec in &exec.nodes {
2099 assert_eq!(
2100 node_exec
2101 .steps
2102 .iter()
2103 .map(MonorepoNodeExecutionStep::as_str)
2104 .collect::<Vec<_>>(),
2105 [
2106 "validate_dest",
2107 "init_repo",
2108 "fetch_content",
2109 "materialize_state",
2110 "record_mapping",
2111 ]
2112 );
2113 }
2114 }
2115
2116 #[test]
2117 fn plan_monorepo_execution_empty_root_still_emits_scaffold_steps() {
2118 let child = leaf("acme/child", 5);
2119 let root = MonorepoNodeFacts {
2120 spool_id: "acme/root".to_string(),
2121 content_state: None,
2122 edges: vec![selected_edge("sub", "acme/child", child)],
2123 };
2124 let clone_plan = plan_monorepo_clone(&root).expect("plan monorepo clone");
2125 let exec = plan_monorepo_execution(&clone_plan, &MonorepoNodeStepOptions::default());
2126
2127 assert_eq!(exec.nodes[0].node.content_state, None);
2128 assert_eq!(
2129 exec.nodes[0].steps,
2130 vec![
2131 MonorepoNodeExecutionStep::ValidateDest,
2132 MonorepoNodeExecutionStep::InitRepo,
2133 MonorepoNodeExecutionStep::RecordMapping,
2134 ]
2135 );
2136 assert!(
2138 exec.nodes[1]
2139 .steps
2140 .iter()
2141 .any(|s| matches!(s, MonorepoNodeExecutionStep::FetchContent { .. }))
2142 );
2143 }
2144
2145 #[test]
2148 fn validate_monorepo_node_execution_accepts_planner_output() {
2149 let full = MonorepoNodePlan {
2150 spool_id: "acme/root".into(),
2151 content_state: Some(cid(1)),
2152 rel_path: PathBuf::new(),
2153 };
2154 let empty = MonorepoNodePlan {
2155 spool_id: "acme/empty".into(),
2156 content_state: None,
2157 rel_path: PathBuf::from("libs"),
2158 };
2159 assert!(
2160 validate_monorepo_node_execution(&plan_monorepo_node_steps(
2161 &full,
2162 &MonorepoNodeStepOptions::default()
2163 ))
2164 .is_ok()
2165 );
2166 assert!(
2167 validate_monorepo_node_execution(&plan_monorepo_node_steps(
2168 &empty,
2169 &MonorepoNodeStepOptions::default()
2170 ))
2171 .is_ok()
2172 );
2173 assert!(
2174 validate_monorepo_node_execution(&plan_monorepo_node_steps(
2175 &full,
2176 &MonorepoNodeStepOptions {
2177 record_mapping: false
2178 }
2179 ))
2180 .is_ok()
2181 );
2182 }
2183
2184 #[test]
2185 fn validate_monorepo_node_execution_rejects_empty_and_missing_scaffold() {
2186 assert_eq!(
2187 validate_monorepo_node_execution(&[]),
2188 Err(MonorepoNodeExecutionError::EmptySteps)
2189 );
2190 assert_eq!(
2191 validate_monorepo_node_execution(&[MonorepoNodeExecutionStep::InitRepo]),
2192 Err(MonorepoNodeExecutionError::OutOfOrder {
2193 step: "init_repo",
2194 detail: "InitRepo requires ValidateDest first",
2195 })
2196 );
2197 assert_eq!(
2198 validate_monorepo_node_execution(&[MonorepoNodeExecutionStep::ValidateDest]),
2199 Err(MonorepoNodeExecutionError::MissingStep { step: "init_repo" })
2200 );
2201 }
2202
2203 #[test]
2204 fn validate_monorepo_node_execution_requires_init_before_fetch() {
2205 let steps = vec![
2206 MonorepoNodeExecutionStep::ValidateDest,
2207 MonorepoNodeExecutionStep::FetchContent { state: cid(1) },
2208 MonorepoNodeExecutionStep::MaterializeState { state: cid(1) },
2209 ];
2210 assert_eq!(
2211 validate_monorepo_node_execution(&steps),
2212 Err(MonorepoNodeExecutionError::OutOfOrder {
2213 step: "fetch_content",
2214 detail: "Init before Fetch",
2215 })
2216 );
2217 }
2218
2219 #[test]
2220 fn validate_monorepo_node_execution_pairs_fetch_and_materialize() {
2221 let fetch_only = vec![
2222 MonorepoNodeExecutionStep::ValidateDest,
2223 MonorepoNodeExecutionStep::InitRepo,
2224 MonorepoNodeExecutionStep::FetchContent { state: cid(1) },
2225 ];
2226 assert_eq!(
2227 validate_monorepo_node_execution(&fetch_only),
2228 Err(MonorepoNodeExecutionError::FetchWithoutMaterialize)
2229 );
2230
2231 let materialize_only = vec![
2232 MonorepoNodeExecutionStep::ValidateDest,
2233 MonorepoNodeExecutionStep::InitRepo,
2234 MonorepoNodeExecutionStep::MaterializeState { state: cid(1) },
2235 ];
2236 assert_eq!(
2237 validate_monorepo_node_execution(&materialize_only),
2238 Err(MonorepoNodeExecutionError::MaterializeWithoutFetch)
2239 );
2240
2241 let mismatch = vec![
2242 MonorepoNodeExecutionStep::ValidateDest,
2243 MonorepoNodeExecutionStep::InitRepo,
2244 MonorepoNodeExecutionStep::FetchContent { state: cid(1) },
2245 MonorepoNodeExecutionStep::MaterializeState { state: cid(2) },
2246 ];
2247 assert_eq!(
2248 validate_monorepo_node_execution(&mismatch),
2249 Err(MonorepoNodeExecutionError::FetchMaterializeStateMismatch {
2250 fetch: cid(1),
2251 materialize: cid(2),
2252 })
2253 );
2254 }
2255
2256 #[test]
2257 fn validate_monorepo_node_execution_rejects_duplicate_or_reordered_steps() {
2258 let dup = vec![
2259 MonorepoNodeExecutionStep::ValidateDest,
2260 MonorepoNodeExecutionStep::InitRepo,
2261 MonorepoNodeExecutionStep::InitRepo,
2262 ];
2263 assert!(matches!(
2264 validate_monorepo_node_execution(&dup),
2265 Err(MonorepoNodeExecutionError::OutOfOrder {
2266 step: "init_repo",
2267 ..
2268 })
2269 ));
2270
2271 let reordered = vec![
2272 MonorepoNodeExecutionStep::InitRepo,
2273 MonorepoNodeExecutionStep::ValidateDest,
2274 ];
2275 assert!(matches!(
2276 validate_monorepo_node_execution(&reordered),
2277 Err(MonorepoNodeExecutionError::OutOfOrder { .. })
2278 ));
2279 }
2280
2281 #[test]
2282 fn validate_monorepo_execution_accepts_full_plan() {
2283 let clone_plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
2284 let exec = plan_monorepo_execution(&clone_plan, &MonorepoNodeStepOptions::default());
2285 assert!(validate_monorepo_execution(&exec).is_ok());
2286 }
2287
2288 #[test]
2289 fn monorepo_execution_progress_labels_are_stable() {
2290 let step = MonorepoNodeExecutionStep::InitRepo;
2291 let progress = monorepo_execution_progress(0, 3, &step);
2292 assert_eq!(progress.node_index, 0);
2293 assert_eq!(progress.total_nodes, 3);
2294 assert_eq!(progress.node_display, 1);
2295 assert_eq!(progress.step, "init_repo");
2296 assert_eq!(progress.label(), "[1/3] init_repo");
2297
2298 let fetch = MonorepoNodeExecutionStep::FetchContent { state: cid(9) };
2299 let p2 = monorepo_execution_progress(2, 3, &fetch);
2300 assert_eq!(p2.label(), "[3/3] fetch_content");
2301 }
2302
2303 #[test]
2304 fn assemble_monorepo_clone_result_summary_counts_placed_and_skipped() {
2305 let clone_plan = plan_monorepo_clone(&fixture_tree()).expect("plan monorepo clone");
2306 let exec = plan_monorepo_execution(&clone_plan, &MonorepoNodeStepOptions::default());
2307 let summary = assemble_monorepo_clone_result_summary(&exec);
2308
2309 assert_eq!(summary.placed_count, 3);
2310 assert_eq!(summary.skipped_count, 1);
2311 assert_eq!(summary.placed.len(), 3);
2312 assert_eq!(summary.skipped.len(), 1);
2313 assert_eq!(summary.placed[0].spool_id, "acme/root");
2314 assert!(summary.placed[0].materialized_content);
2315 assert_eq!(summary.skipped[0].child_spool_id, "acme/child-b");
2316
2317 assert_eq!(
2318 summary.headline("acme/root"),
2319 "Cloned monorepo acme/root (3 spools placed)."
2320 );
2321 assert_eq!(
2322 summary.skipped_header().as_deref(),
2323 Some("1 child spool(s) skipped (not part of your coherent slice):")
2324 );
2325
2326 let single = MonorepoCloneResultSummary {
2328 placed_count: 1,
2329 skipped_count: 0,
2330 placed: vec![],
2331 skipped: vec![],
2332 };
2333 assert_eq!(
2334 single.headline("solo"),
2335 "Cloned monorepo solo (1 spool placed)."
2336 );
2337 assert!(single.skipped_header().is_none());
2338 }
2339
2340 #[test]
2341 fn assemble_summary_marks_empty_content_nodes_not_materialized() {
2342 let root = MonorepoNodeFacts {
2343 spool_id: "acme/root".to_string(),
2344 content_state: None,
2345 edges: vec![],
2346 };
2347 let exec = plan_monorepo_execution(
2348 &plan_monorepo_clone(&root).expect("plan monorepo clone"),
2349 &MonorepoNodeStepOptions::default(),
2350 );
2351 let summary = assemble_monorepo_clone_result_summary(&exec);
2352 assert_eq!(summary.placed_count, 1);
2353 assert!(!summary.placed[0].materialized_content);
2354 assert_eq!(summary.placed[0].content_state, None);
2355 }
2356}