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