1use std::{
19 collections::BTreeMap,
20 fs,
21 path::{Path, PathBuf},
22};
23
24use anyhow::{Result, anyhow};
25use cli_shared::remote::{RemoteConfig, RemoteTarget};
26use refs::Head;
27use repo::{Repository, RepositoryCapability};
28use serde::Serialize;
29use sley::{
30 GitConfig, Repository as SleyRepository,
31 plumbing::sley_config::{
32 ConfigIncludeContext, ConfigOriginKind, ConfigScope, ConfigStack, ConfigStackEntry,
33 },
34};
35
36#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
38pub struct RemoteListReport {
39 pub output_kind: &'static str,
40 pub remotes: Vec<RemoteInfo>,
41}
42
43#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct RemoteInfo {
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub output_kind: Option<&'static str>,
52 pub name: String,
53 pub url: String,
54 pub source: String,
55 pub is_default: bool,
56}
57
58impl RemoteListReport {
59 pub fn empty() -> Self {
60 Self {
61 output_kind: "remote_list",
62 remotes: Vec::new(),
63 }
64 }
65}
66
67pub fn list_remotes(repo: &Repository) -> Result<RemoteListReport> {
69 let items = merged_remote_items(repo)?;
70 let default = resolved_default_remote_name(repo)?;
71 Ok(RemoteListReport {
72 output_kind: "remote_list",
73 remotes: items
74 .into_iter()
75 .map(|(name, (url, source))| {
76 let is_default = default.as_deref() == Some(name.as_str());
77 RemoteInfo {
78 output_kind: None,
79 name,
80 url,
81 source,
82 is_default,
83 }
84 })
85 .collect(),
86 })
87}
88
89pub fn list_plain_git_remotes(root: &Path) -> RemoteListReport {
91 let items = plain_git_remote_items(root);
92 let default = plain_git_default_remote_name(root, &items);
93 RemoteListReport {
94 output_kind: "remote_list",
95 remotes: items
96 .into_iter()
97 .map(|(name, url)| {
98 let is_default = default.as_deref() == Some(name.as_str());
99 RemoteInfo {
100 output_kind: None,
101 name,
102 url,
103 source: "git".to_string(),
104 is_default,
105 }
106 })
107 .collect(),
108 }
109}
110
111pub fn show_remote(repo: &Repository, name: &str) -> Result<Option<RemoteInfo>> {
114 let items = merged_remote_items(repo)?;
115 let default = resolved_default_remote_name(repo)?;
116 let Some((url, source)) = items.get(name).cloned() else {
117 return Ok(None);
118 };
119 Ok(Some(RemoteInfo {
120 output_kind: Some("remote_show"),
121 name: name.to_string(),
122 url,
123 source,
124 is_default: default.as_deref() == Some(name),
125 }))
126}
127
128pub fn show_plain_git_remote(root: &Path, name: &str) -> Option<RemoteInfo> {
130 let items = plain_git_remote_items(root);
131 let default = plain_git_default_remote_name(root, &items);
132 let url = items.get(name)?.clone();
133 Some(RemoteInfo {
134 output_kind: Some("remote_show"),
135 name: name.to_string(),
136 url,
137 source: "git".to_string(),
138 is_default: default.as_deref() == Some(name),
139 })
140}
141
142pub fn resolve_default_remote_name(repo: &Repository, requested: Option<&str>) -> Result<String> {
144 if let Some(requested) = requested {
145 return Ok(requested.to_string());
146 }
147 if repo.capability() == RepositoryCapability::GitOverlay
148 && let Some(default) = git_overlay_default_remote_name(repo)
149 {
150 return Ok(default);
151 }
152 if let Some(default) = RemoteConfig::open(repo)
153 .map_err(anyhow::Error::new)?
154 .default_name()
155 {
156 return Ok(default.to_string());
157 }
158 Err(anyhow!(
159 "No default remote is configured; pass a remote or configure one first"
160 ))
161}
162
163pub fn resolve_default_push_remote_name(
165 repo: &Repository,
166 requested: Option<&str>,
167) -> Result<String> {
168 if let Some(requested) = requested {
169 return Ok(requested.to_string());
170 }
171 if repo.capability() != RepositoryCapability::GitOverlay {
172 return resolve_default_remote_name(repo, None);
173 }
174 git_overlay_default_push_remote_name(repo).ok_or_else(|| {
175 anyhow!("No default push remote is configured; pass a remote or configure one first")
176 })
177}
178
179pub fn resolved_default_remote_name(repo: &Repository) -> Result<Option<String>> {
181 if repo.capability() == RepositoryCapability::GitOverlay {
182 return Ok(git_overlay_default_remote_name(repo));
183 }
184 let cfg = RemoteConfig::open(repo).map_err(anyhow::Error::new)?;
185 if let Some(default) = cfg.default_name() {
186 return Ok(Some(default.to_string()));
187 }
188 Ok(None)
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum HostedPushPlan {
202 NativePerThreadFanout,
204 GitOverlayMirror,
208 NativeSingleThread,
210}
211
212pub fn all_threads_uses_single_mirror_push(capability: RepositoryCapability) -> bool {
220 capability == RepositoryCapability::GitOverlay
221}
222
223pub fn plan_hosted_push(capability: RepositoryCapability, all_threads: bool) -> HostedPushPlan {
225 if all_threads && !all_threads_uses_single_mirror_push(capability) {
226 HostedPushPlan::NativePerThreadFanout
227 } else if capability == RepositoryCapability::GitOverlay {
228 HostedPushPlan::GitOverlayMirror
229 } else {
230 HostedPushPlan::NativeSingleThread
231 }
232}
233
234pub fn uses_git_overlay_mirror_rpc(capability: RepositoryCapability) -> bool {
237 capability == RepositoryCapability::GitOverlay
238}
239
240pub fn uses_local_git_overlay_transport(
247 capability: RepositoryCapability,
248 uses_hosted_network: bool,
249) -> bool {
250 capability == RepositoryCapability::GitOverlay && !uses_hosted_network
251}
252
253pub fn default_push_thread_name(requested: Option<&str>, head: &Head) -> String {
258 if let Some(requested) = requested {
259 return requested.to_string();
260 }
261 match head {
262 Head::Attached { thread } => thread.to_string(),
263 Head::Detached { .. } => "main".to_string(),
264 }
265}
266
267pub fn default_pull_thread_name(
272 explicit_thread: Option<&str>,
273 capability: RepositoryCapability,
274 head: &Head,
275) -> String {
276 if let Some(thread) = explicit_thread {
277 return thread.to_string();
278 }
279
280 if capability == RepositoryCapability::GitOverlay
281 && let Head::Attached { thread } = head
282 {
283 return thread.to_string();
284 }
285
286 "main".to_string()
287}
288
289pub fn git_overlay_current_thread_push_ok(
295 all_threads: bool,
296 requested: Option<&str>,
297 attached: Option<&str>,
298) -> bool {
299 if all_threads {
300 return true;
301 }
302 match requested {
303 None => true,
304 Some(name) => attached == Some(name),
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
319pub enum RemotePreflightBlocker {
320 MissingRemote,
322 TransportMismatch,
324 GitOverlayThreadMismatch {
326 requested: String,
327 attached: Option<String>,
328 },
329}
330
331impl std::fmt::Display for RemotePreflightBlocker {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 match self {
334 Self::MissingRemote => write!(f, "no remote configured"),
335 Self::TransportMismatch => {
336 write!(f, "remote transport does not match repository capability")
337 }
338 Self::GitOverlayThreadMismatch {
339 requested,
340 attached,
341 } => {
342 let attached_label = attached
343 .as_deref()
344 .map(|t| format!("'{t}'"))
345 .unwrap_or_else(|| "detached HEAD".to_string());
346 write!(
347 f,
348 "git-overlay push targets the attached thread; requested '{requested}' but HEAD is {attached_label}"
349 )
350 }
351 }
352 }
353}
354
355impl std::error::Error for RemotePreflightBlocker {}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct PushPlanRequest {
360 pub capability: RepositoryCapability,
361 pub uses_hosted_network: bool,
363 pub remote: Option<String>,
365 pub has_default_remote: bool,
367 pub thread: Option<String>,
369 pub all_threads: bool,
370 pub force: bool,
371 pub head: Head,
373 pub native_local_heddle_target: bool,
376 pub transport_mismatch: bool,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum PushPath {
383 LocalGitOverlayRefs { all_threads: bool },
385 LocalNativeHeddle { all_threads: bool },
387 NativeRemote {
389 hosted: HostedPushPlan,
390 uses_mirror_rpc: bool,
392 native_all_threads_fanout: bool,
395 },
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct PushPlan {
401 pub remote: Option<String>,
403 pub all_threads: bool,
404 pub force: bool,
405 pub track_name: String,
407 pub uses_local_git_overlay: bool,
410 pub hosted: HostedPushPlan,
412 pub uses_git_overlay_mirror_rpc: bool,
414 pub native_all_threads_fanout: bool,
416 pub path: PushPath,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct PullPlanRequest {
422 pub capability: RepositoryCapability,
423 pub uses_hosted_network: bool,
424 pub remote: Option<String>,
425 pub has_default_remote: bool,
426 pub thread: Option<String>,
428 pub local_thread: Option<String>,
430 pub head: Head,
431 pub transport_mismatch: bool,
432 pub lazy: bool,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct PullPlan {
438 pub remote: Option<String>,
440 pub remote_thread: String,
442 pub local_thread: Option<String>,
444 pub uses_local_git_overlay: bool,
446 pub will_materialize: bool,
448 pub requires_clean_worktree: bool,
450 pub lazy: bool,
451}
452
453pub fn remote_missing_blocker(
455 remote: Option<&str>,
456 has_default_remote: bool,
457) -> Option<RemotePreflightBlocker> {
458 if remote.is_none() && !has_default_remote {
459 Some(RemotePreflightBlocker::MissingRemote)
460 } else {
461 None
462 }
463}
464
465pub fn transport_mismatch_blocker(
467 uses_local_git_overlay: bool,
468 transport_mismatch: bool,
469) -> Option<RemotePreflightBlocker> {
470 if !uses_local_git_overlay && transport_mismatch {
471 Some(RemotePreflightBlocker::TransportMismatch)
472 } else {
473 None
474 }
475}
476
477pub fn git_overlay_thread_mismatch_blocker(
479 all_threads: bool,
480 requested: Option<&str>,
481 attached: Option<&str>,
482) -> Option<RemotePreflightBlocker> {
483 if git_overlay_current_thread_push_ok(all_threads, requested, attached) {
484 None
485 } else {
486 Some(RemotePreflightBlocker::GitOverlayThreadMismatch {
487 requested: requested.unwrap_or("").to_string(),
488 attached: attached.map(str::to_string),
489 })
490 }
491}
492
493pub fn pull_will_materialize(local_thread: Option<&str>, remote_thread: &str, head: &Head) -> bool {
499 let track = local_thread.unwrap_or(remote_thread);
500 match head {
501 Head::Attached { thread } => thread == track,
502 Head::Detached { .. } => local_thread.is_none(),
503 }
504}
505
506pub fn pull_requires_clean_worktree(uses_local_git_overlay: bool, will_materialize: bool) -> bool {
509 uses_local_git_overlay || will_materialize
510}
511
512pub fn plan_push(request: &PushPlanRequest) -> Result<PushPlan, RemotePreflightBlocker> {
514 if let Some(blocker) =
515 remote_missing_blocker(request.remote.as_deref(), request.has_default_remote)
516 {
517 return Err(blocker);
518 }
519
520 let uses_local =
521 uses_local_git_overlay_transport(request.capability, request.uses_hosted_network);
522 let track_name = default_push_thread_name(request.thread.as_deref(), &request.head);
523 let hosted = plan_hosted_push(request.capability, request.all_threads);
524 let uses_mirror = uses_git_overlay_mirror_rpc(request.capability);
525 let native_fanout = matches!(hosted, HostedPushPlan::NativePerThreadFanout);
526
527 if uses_local {
528 if request.native_local_heddle_target {
529 return Ok(PushPlan {
530 remote: request.remote.clone(),
531 all_threads: request.all_threads,
532 force: request.force,
533 track_name,
534 uses_local_git_overlay: true,
535 hosted,
536 uses_git_overlay_mirror_rpc: uses_mirror,
537 native_all_threads_fanout: native_fanout,
538 path: PushPath::LocalNativeHeddle {
539 all_threads: request.all_threads,
540 },
541 });
542 }
543
544 let attached = match &request.head {
545 Head::Attached { thread } => Some(thread.as_str()),
546 Head::Detached { .. } => None,
547 };
548 if let Some(blocker) = git_overlay_thread_mismatch_blocker(
549 request.all_threads,
550 request.thread.as_deref(),
551 attached,
552 ) {
553 return Err(blocker);
554 }
555
556 return Ok(PushPlan {
557 remote: request.remote.clone(),
558 all_threads: request.all_threads,
559 force: request.force,
560 track_name,
561 uses_local_git_overlay: true,
562 hosted,
563 uses_git_overlay_mirror_rpc: uses_mirror,
564 native_all_threads_fanout: native_fanout,
565 path: PushPath::LocalGitOverlayRefs {
566 all_threads: request.all_threads,
567 },
568 });
569 }
570
571 if let Some(blocker) = transport_mismatch_blocker(false, request.transport_mismatch) {
572 return Err(blocker);
573 }
574
575 Ok(PushPlan {
576 remote: request.remote.clone(),
577 all_threads: request.all_threads,
578 force: request.force,
579 track_name,
580 uses_local_git_overlay: false,
581 hosted,
582 uses_git_overlay_mirror_rpc: uses_mirror,
583 native_all_threads_fanout: native_fanout,
584 path: PushPath::NativeRemote {
585 hosted,
586 uses_mirror_rpc: uses_mirror,
587 native_all_threads_fanout: native_fanout,
588 },
589 })
590}
591
592pub fn plan_pull(request: &PullPlanRequest) -> Result<PullPlan, RemotePreflightBlocker> {
594 if let Some(blocker) =
595 remote_missing_blocker(request.remote.as_deref(), request.has_default_remote)
596 {
597 return Err(blocker);
598 }
599
600 let uses_local =
601 uses_local_git_overlay_transport(request.capability, request.uses_hosted_network);
602
603 if let Some(blocker) = transport_mismatch_blocker(uses_local, request.transport_mismatch) {
604 return Err(blocker);
605 }
606
607 let remote_thread =
608 default_pull_thread_name(request.thread.as_deref(), request.capability, &request.head);
609 let will_materialize = pull_will_materialize(
610 request.local_thread.as_deref(),
611 &remote_thread,
612 &request.head,
613 );
614 let requires_clean = pull_requires_clean_worktree(uses_local, will_materialize);
615
616 Ok(PullPlan {
617 remote: request.remote.clone(),
618 remote_thread,
619 local_thread: request.local_thread.clone(),
620 uses_local_git_overlay: uses_local,
621 will_materialize,
622 requires_clean_worktree: requires_clean,
623 lazy: request.lazy,
624 })
625}
626
627pub const GIT_NOTES_REF: &str = "refs/notes/heddle";
633
634pub const GIT_NOTES_VISIBILITY_WARNING: &str =
636 "ordinary `git log --all` may show Heddle metadata commits from refs/notes/heddle";
637
638pub const FORCE_DISCARD_WARNING: &str = "remote refs may be moved back to match local Heddle state; remote commits not reachable from this checkout can be discarded";
640
641pub const COMMITS_SEEN_SCOPE: &str = "branches_and_heddle_notes";
643
644#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
646pub struct GitRemoteConfigured {
647 pub name: String,
648 pub url: String,
649}
650
651#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
653pub struct GitUpstreamConfigured {
654 pub branch: String,
655 pub remote: String,
656}
657
658#[derive(Debug, Clone, PartialEq, Eq)]
660pub struct GitOverlayPushTracking {
661 pub remote_name: String,
662 pub configured_remote: Option<GitRemoteConfigured>,
663 pub upstream_branch: Option<String>,
664}
665
666#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
671pub struct PushOutcome {
672 pub output_kind: &'static str,
673 pub action: &'static str,
674 pub status: &'static str,
675 pub success: bool,
676 pub pushed: bool,
677 pub changed: bool,
678 pub transport: &'static str,
679 #[serde(skip_serializing_if = "Option::is_none")]
680 pub remote: Option<String>,
681 #[serde(skip_serializing_if = "Option::is_none")]
682 pub push_scope: Option<&'static str>,
683 #[serde(skip_serializing_if = "Option::is_none")]
684 pub ref_scope: Option<&'static str>,
685 #[serde(skip_serializing_if = "Option::is_none")]
686 pub git_notes_ref: Option<&'static str>,
687 #[serde(skip_serializing_if = "Option::is_none")]
688 pub refs_written: Option<Vec<String>>,
689 #[serde(skip_serializing_if = "Option::is_none")]
690 pub git_notes_visibility_warning: Option<&'static str>,
691 #[serde(skip_serializing_if = "Option::is_none")]
692 pub git_tracking_remote: Option<String>,
693 #[serde(skip_serializing_if = "Option::is_none")]
694 pub git_remote_configured: Option<GitRemoteConfigured>,
695 #[serde(skip_serializing_if = "Option::is_none")]
696 pub git_upstream_configured: Option<GitUpstreamConfigured>,
697 #[serde(skip_serializing_if = "Option::is_none")]
698 pub tags_included: Option<bool>,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub force: Option<bool>,
701 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub force_discard_warning: Option<&'static str>,
703 #[serde(skip_serializing_if = "Option::is_none")]
704 pub thread: Option<String>,
705 #[serde(skip_serializing_if = "Option::is_none")]
706 pub state: Option<String>,
707 #[serde(skip_serializing_if = "Option::is_none")]
708 pub objects: Option<usize>,
709}
710
711#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
715pub struct PullOutcome {
716 pub output_kind: &'static str,
717 pub action: &'static str,
718 pub status: &'static str,
719 pub success: bool,
720 pub pulled: bool,
721 pub changed: bool,
722 pub transport: &'static str,
723 pub remote: String,
724 #[serde(skip_serializing_if = "Option::is_none")]
725 pub branch: Option<String>,
726 #[serde(skip_serializing_if = "Option::is_none")]
727 pub old_git_head: Option<String>,
728 #[serde(skip_serializing_if = "Option::is_none")]
729 pub new_git_head: Option<String>,
730 #[serde(skip_serializing_if = "Option::is_none")]
731 pub old_state: Option<String>,
732 #[serde(skip_serializing_if = "Option::is_none")]
733 pub new_state: Option<String>,
734 #[serde(skip_serializing_if = "Option::is_none")]
735 pub states_created: Option<usize>,
736 #[serde(skip_serializing_if = "Option::is_none")]
737 pub commits_seen: Option<usize>,
738 #[serde(skip_serializing_if = "Option::is_none")]
739 pub commits_seen_scope: Option<&'static str>,
740 #[serde(skip_serializing_if = "Option::is_none")]
741 pub materialized_checkout: Option<bool>,
742 #[serde(skip_serializing_if = "Option::is_none")]
743 pub changed_path_count: Option<usize>,
744 #[serde(skip_serializing_if = "Option::is_none")]
745 pub changed_paths: Option<Vec<String>>,
746 #[serde(skip_serializing_if = "Option::is_none")]
747 pub thread: Option<String>,
748 #[serde(skip_serializing_if = "Option::is_none")]
749 pub state: Option<String>,
750 #[serde(skip_serializing_if = "Option::is_none")]
751 pub objects: Option<usize>,
752}
753
754#[derive(Debug, Clone, PartialEq, Eq)]
756pub enum PushExecutionFacts {
757 GitOverlayRefs {
759 remote_name: String,
760 current_thread: Option<String>,
761 refs_written: Vec<String>,
762 tracking: Option<GitOverlayPushTracking>,
763 },
764 HeddleSingle {
766 state: Option<String>,
767 objects: Option<usize>,
768 },
769 HeddleAllThreads {
771 pushed_threads: Vec<String>,
773 failed_threads: Vec<String>,
775 objects: usize,
776 },
777}
778
779#[derive(Debug, Clone, PartialEq, Eq)]
781pub enum PullExecutionFacts {
782 GitOverlay {
784 remote: String,
785 branch: Option<String>,
786 old_git_head: Option<String>,
787 new_git_head: Option<String>,
788 old_state: Option<String>,
789 new_state: Option<String>,
790 changed: bool,
791 states_created: usize,
792 commits_seen: usize,
793 materialized_checkout: bool,
794 changed_paths: Vec<String>,
795 },
796 Heddle {
798 changed: bool,
799 remote: String,
800 thread: String,
801 state: Option<String>,
802 objects: Option<usize>,
803 },
804}
805
806pub fn push_scope_label(all_threads: bool) -> &'static str {
808 if all_threads {
809 "all_threads"
810 } else {
811 "current_thread"
812 }
813}
814
815pub fn git_overlay_ref_scope(all_threads: bool) -> &'static str {
817 if all_threads {
818 "all_threads_tags_and_heddle_notes"
819 } else {
820 "branch_and_heddle_notes"
821 }
822}
823
824pub fn push_status(ok: bool) -> &'static str {
826 if ok { "pushed" } else { "partial" }
827}
828
829pub fn pull_status(changed: bool) -> &'static str {
831 if changed { "updated" } else { "up_to_date" }
832}
833
834pub fn build_push_outcome(plan: &PushPlan, facts: PushExecutionFacts) -> PushOutcome {
839 match facts {
840 PushExecutionFacts::GitOverlayRefs {
841 remote_name,
842 current_thread,
843 refs_written,
844 tracking,
845 } => {
846 let all_threads = plan.all_threads;
847 let force = plan.force;
848 let tracking_remote = tracking.as_ref().map(|t| t.remote_name.clone());
849 let configured_remote = tracking.as_ref().and_then(|t| t.configured_remote.clone());
850 let upstream_configured = tracking.as_ref().and_then(|t| {
851 t.upstream_branch
852 .as_ref()
853 .map(|branch| GitUpstreamConfigured {
854 branch: branch.clone(),
855 remote: tracking_remote
856 .clone()
857 .unwrap_or_else(|| "origin".to_string()),
858 })
859 });
860 PushOutcome {
861 output_kind: "push",
862 action: "push",
863 status: push_status(true),
864 success: true,
865 pushed: true,
866 changed: true,
867 transport: "git",
868 remote: Some(remote_name),
869 push_scope: Some(push_scope_label(all_threads)),
870 ref_scope: Some(git_overlay_ref_scope(all_threads)),
871 git_notes_ref: Some(GIT_NOTES_REF),
872 refs_written: Some(refs_written),
873 git_notes_visibility_warning: Some(GIT_NOTES_VISIBILITY_WARNING),
874 git_tracking_remote: tracking_remote,
875 git_remote_configured: configured_remote,
876 git_upstream_configured: upstream_configured,
877 tags_included: Some(all_threads),
878 force: Some(force),
879 force_discard_warning: force.then_some(FORCE_DISCARD_WARNING),
880 thread: current_thread,
881 state: None,
882 objects: None,
883 }
884 }
885 PushExecutionFacts::HeddleSingle { state, objects } => PushOutcome {
886 output_kind: "push",
887 action: "push",
888 status: push_status(true),
889 success: true,
890 pushed: true,
891 changed: true,
892 transport: "heddle",
893 remote: None,
894 push_scope: None,
895 ref_scope: None,
896 git_notes_ref: None,
897 refs_written: None,
898 git_notes_visibility_warning: None,
899 git_tracking_remote: None,
900 git_remote_configured: None,
901 git_upstream_configured: None,
902 tags_included: None,
903 force: None,
904 force_discard_warning: None,
905 thread: None,
906 state,
907 objects,
908 },
909 PushExecutionFacts::HeddleAllThreads {
910 mut pushed_threads,
911 failed_threads,
912 objects,
913 } => {
914 let ok = failed_threads.is_empty();
915 pushed_threads.sort();
916 PushOutcome {
917 output_kind: "push",
918 action: "push",
919 status: push_status(ok),
920 success: ok,
921 pushed: ok,
922 changed: true,
923 transport: "heddle",
924 remote: None,
925 push_scope: Some(push_scope_label(true)),
926 ref_scope: None,
927 git_notes_ref: None,
928 refs_written: Some(pushed_threads),
929 git_notes_visibility_warning: None,
930 git_tracking_remote: None,
931 git_remote_configured: None,
932 git_upstream_configured: None,
933 tags_included: None,
934 force: None,
935 force_discard_warning: None,
936 thread: None,
937 state: None,
938 objects: Some(objects),
939 }
940 }
941 }
942}
943
944pub fn build_pull_outcome(_plan: Option<&PullPlan>, facts: PullExecutionFacts) -> PullOutcome {
949 match facts {
950 PullExecutionFacts::GitOverlay {
951 remote,
952 branch,
953 old_git_head,
954 new_git_head,
955 old_state,
956 new_state,
957 changed,
958 states_created,
959 commits_seen,
960 materialized_checkout,
961 changed_paths,
962 } => {
963 let path_count = changed_paths.len();
964 PullOutcome {
965 output_kind: "pull",
966 action: "pull",
967 status: pull_status(changed),
968 success: true,
969 pulled: changed,
970 changed,
971 transport: "git",
972 remote,
973 branch,
974 old_git_head,
975 new_git_head,
976 old_state,
977 new_state,
978 states_created: Some(states_created),
979 commits_seen: Some(commits_seen),
980 commits_seen_scope: Some(COMMITS_SEEN_SCOPE),
981 materialized_checkout: Some(materialized_checkout),
982 changed_path_count: Some(path_count),
983 changed_paths: Some(changed_paths),
984 thread: None,
985 state: None,
986 objects: None,
987 }
988 }
989 PullExecutionFacts::Heddle {
990 changed,
991 remote,
992 thread,
993 state,
994 objects,
995 } => PullOutcome {
996 output_kind: "pull",
997 action: "pull",
998 status: pull_status(changed),
999 success: true,
1000 pulled: changed,
1001 changed,
1002 transport: "heddle",
1003 remote,
1004 branch: None,
1005 old_git_head: None,
1006 new_git_head: None,
1007 old_state: None,
1008 new_state: None,
1009 states_created: None,
1010 commits_seen: None,
1011 commits_seen_scope: None,
1012 materialized_checkout: None,
1013 changed_path_count: None,
1014 changed_paths: None,
1015 thread: Some(thread),
1016 state,
1017 objects,
1018 },
1019 }
1020}
1021
1022pub fn summarize_push_outcome(outcome: &PushOutcome) -> String {
1024 let remote = outcome.remote.as_deref().unwrap_or("remote");
1025 match outcome.transport {
1026 "git" => {
1027 let scope = outcome.push_scope.unwrap_or("current_thread");
1028 let refs = outcome.refs_written.as_ref().map(|r| r.len()).unwrap_or(0);
1029 if outcome.force == Some(true) {
1030 format!("force-pushed {scope} ({refs} refs) to {remote}")
1031 } else {
1032 format!("pushed {scope} ({refs} refs) to {remote}")
1033 }
1034 }
1035 "heddle" if outcome.push_scope == Some("all_threads") => {
1036 let n = outcome.refs_written.as_ref().map(|r| r.len()).unwrap_or(0);
1037 if outcome.success {
1038 format!("pushed {n} threads")
1039 } else {
1040 format!("partial push: {n} threads landed")
1041 }
1042 }
1043 "heddle" => match (&outcome.state, outcome.objects) {
1044 (Some(state), Some(objects)) => {
1045 format!("pushed state {state} ({objects} objects)")
1046 }
1047 (Some(state), None) => format!("pushed state {state}"),
1048 (None, Some(objects)) => format!("pushed ({objects} objects)"),
1049 (None, None) => "pushed".to_string(),
1050 },
1051 other => format!("pushed via {other}"),
1052 }
1053}
1054
1055pub fn summarize_pull_outcome(outcome: &PullOutcome) -> String {
1057 if !outcome.changed {
1058 return format!("already up to date with {}", outcome.remote);
1059 }
1060 match outcome.transport {
1061 "git" => {
1062 let paths = outcome.changed_path_count.unwrap_or(0);
1063 let states = outcome.states_created.unwrap_or(0);
1064 format!(
1065 "pulled from {} ({states} new states, {paths} changed paths)",
1066 outcome.remote
1067 )
1068 }
1069 "heddle" => {
1070 let thread = outcome.thread.as_deref().unwrap_or("thread");
1071 match (&outcome.state, outcome.objects) {
1072 (Some(state), Some(objects)) => {
1073 format!("pulled {thread} -> {state} ({objects} objects)")
1074 }
1075 (Some(state), None) => format!("pulled {thread} -> {state}"),
1076 (None, Some(objects)) => format!("pulled {thread} ({objects} objects)"),
1077 (None, None) => format!("pulled {thread} from {}", outcome.remote),
1078 }
1079 }
1080 other => format!("pulled via {other} from {}", outcome.remote),
1081 }
1082}
1083
1084pub mod remote_advice_kind {
1090 pub const REMOTE_NOT_CONFIGURED: &str = "remote_not_configured";
1091 pub const INVALID_REMOTE_URL: &str = "invalid_remote_url";
1092 pub const REMOTE_TRANSPORT_MISMATCH: &str = "remote_transport_mismatch";
1093 pub const GIT_OVERLAY_THREAD_MISMATCH: &str = "git_overlay_thread_mismatch";
1094 pub const NAMED_THREAD_TIP_MISMATCH: &str = "named_thread_tip_mismatch";
1095 pub const REMOTE_PUSH_FAILED: &str = "remote_push_failed";
1096 pub const REMOTE_PULL_FAILED: &str = "remote_pull_failed";
1097 pub const LOCAL_LAZY_PULL_UNSUPPORTED: &str = "local_lazy_pull_unsupported";
1098}
1099
1100#[derive(Debug, Clone, PartialEq, Eq)]
1103pub enum PushFailure {
1104 Preflight(RemotePreflightBlocker),
1106 NamedThreadTipMismatch {
1108 thread: String,
1109 tip_short: String,
1110 current_short: String,
1111 },
1112 RemoteFailed { track_name: String, error: String },
1114}
1115
1116#[derive(Debug, Clone, PartialEq, Eq)]
1118pub enum PullFailure {
1119 Preflight(RemotePreflightBlocker),
1121 LocalLazyUnsupported { source_path: String },
1123 RemoteFailed {
1125 remote_thread: String,
1126 local_thread: Option<String>,
1127 error: String,
1128 },
1129}
1130
1131impl PushFailure {
1132 pub fn advice_kind(&self) -> &'static str {
1134 match self {
1135 Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
1136 remote_advice_kind::REMOTE_NOT_CONFIGURED
1137 }
1138 Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
1139 remote_advice_kind::REMOTE_TRANSPORT_MISMATCH
1140 }
1141 Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch { .. }) => {
1142 remote_advice_kind::GIT_OVERLAY_THREAD_MISMATCH
1143 }
1144 Self::NamedThreadTipMismatch { .. } => remote_advice_kind::NAMED_THREAD_TIP_MISMATCH,
1145 Self::RemoteFailed { .. } => remote_advice_kind::REMOTE_PUSH_FAILED,
1146 }
1147 }
1148
1149 pub fn primary_command(&self) -> String {
1151 match self {
1152 Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
1153 "heddle remote add <name> <url>".to_string()
1154 }
1155 Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
1156 "heddle clone <remote> <fresh-path>".to_string()
1157 }
1158 Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch {
1159 requested, ..
1160 }) => format!("heddle thread switch {requested} && heddle push"),
1161 Self::NamedThreadTipMismatch { thread, .. } => {
1162 format!("heddle thread switch {thread}")
1163 }
1164 Self::RemoteFailed { track_name, .. } => format!("heddle push {track_name}"),
1165 }
1166 }
1167
1168 pub fn recovery_hint(&self) -> String {
1170 match self {
1171 Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
1172 "Add a remote with `heddle remote add <name> <url>`, inspect remotes with `heddle remote list`, or choose one with `heddle remote set-default <name>`. Ad-hoc targets are supported without configuration: `heddle push <remote>` accepts a remote name, URL, local path, or hosted address positionally.".to_string()
1173 }
1174 Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
1175 "Use a Heddle-native remote here, or clone/adopt that Git remote in a Git-overlay checkout.".to_string()
1176 }
1177 Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch {
1178 requested, ..
1179 }) => format!(
1180 "Switch to the requested thread with `heddle thread switch {requested} && heddle push`, or pass `--all-threads`."
1181 ),
1182 Self::NamedThreadTipMismatch { thread, .. } => format!(
1183 "Switch to that thread's checkout (`heddle thread switch {thread}`), or pass `--force` to push the current state under '{thread}'."
1184 ),
1185 Self::RemoteFailed { track_name, .. } => format!(
1186 "Inspect `heddle verify`, then retry with `heddle push {track_name}` after fixing the remote."
1187 ),
1188 }
1189 }
1190}
1191
1192impl std::fmt::Display for PushFailure {
1193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1194 match self {
1195 Self::Preflight(blocker) => write!(f, "{blocker}"),
1196 Self::NamedThreadTipMismatch {
1197 thread,
1198 tip_short,
1199 current_short,
1200 } => write!(
1201 f,
1202 "thread '{thread}' already exists at {tip_short} but the current checkout is {current_short}; refusing to overwrite it"
1203 ),
1204 Self::RemoteFailed { track_name, error } => {
1205 write!(f, "Push failed for {track_name}: {error}")
1206 }
1207 }
1208 }
1209}
1210
1211impl std::error::Error for PushFailure {}
1212
1213impl PullFailure {
1214 pub fn advice_kind(&self) -> &'static str {
1216 match self {
1217 Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
1218 remote_advice_kind::REMOTE_NOT_CONFIGURED
1219 }
1220 Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
1221 remote_advice_kind::REMOTE_TRANSPORT_MISMATCH
1222 }
1223 Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch { .. }) => {
1224 remote_advice_kind::GIT_OVERLAY_THREAD_MISMATCH
1226 }
1227 Self::LocalLazyUnsupported { .. } => remote_advice_kind::LOCAL_LAZY_PULL_UNSUPPORTED,
1228 Self::RemoteFailed { .. } => remote_advice_kind::REMOTE_PULL_FAILED,
1229 }
1230 }
1231
1232 pub fn primary_command(&self) -> String {
1234 match self {
1235 Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
1236 "heddle remote add <name> <url>".to_string()
1237 }
1238 Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
1239 "heddle clone <remote> <fresh-path>".to_string()
1240 }
1241 Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch {
1242 requested, ..
1243 }) => format!("heddle thread switch {requested}"),
1244 Self::LocalLazyUnsupported { source_path } => {
1245 format!("heddle pull {source_path}")
1246 }
1247 Self::RemoteFailed {
1248 remote_thread,
1249 local_thread,
1250 ..
1251 } => {
1252 if let Some(local) = local_thread {
1253 format!("heddle pull {remote_thread} {local}")
1254 } else {
1255 format!("heddle pull {remote_thread}")
1256 }
1257 }
1258 }
1259 }
1260
1261 pub fn recovery_hint(&self) -> String {
1263 match self {
1264 Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
1265 "Add a remote with `heddle remote add <name> <url>`, inspect remotes with `heddle remote list`, or choose one with `heddle remote set-default <name>`. Ad-hoc targets are supported without configuration: `heddle pull <remote>` accepts a remote name, URL, local path, or hosted address positionally.".to_string()
1266 }
1267 Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
1268 "Use a Heddle-native remote here, or clone/adopt that Git remote in a Git-overlay checkout.".to_string()
1269 }
1270 Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch { .. }) => {
1271 "Switch to the attached thread, or omit an explicit mismatched thread name.".to_string()
1272 }
1273 Self::LocalLazyUnsupported { source_path } => format!(
1274 "Run `heddle pull {source_path}` without `--lazy`, or configure a hosted remote and retry lazy pull there."
1275 ),
1276 Self::RemoteFailed {
1277 remote_thread,
1278 local_thread,
1279 ..
1280 } => {
1281 let cmd = if let Some(local) = local_thread {
1282 format!("heddle pull {remote_thread} {local}")
1283 } else {
1284 format!("heddle pull {remote_thread}")
1285 };
1286 format!("Inspect `heddle verify`, then retry with `{cmd}` after fixing the remote.")
1287 }
1288 }
1289 }
1290}
1291
1292impl std::fmt::Display for PullFailure {
1293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1294 match self {
1295 Self::Preflight(blocker) => write!(f, "{blocker}"),
1296 Self::LocalLazyUnsupported { .. } => write!(
1297 f,
1298 "Refusing lazy pull from local remote: lazy materialization requires a hosted or network remote"
1299 ),
1300 Self::RemoteFailed {
1301 remote_thread,
1302 error,
1303 ..
1304 } => write!(f, "Pull failed from {remote_thread}: {error}"),
1305 }
1306 }
1307}
1308
1309impl std::error::Error for PullFailure {}
1310
1311impl RemotePreflightBlocker {
1312 pub fn advice_kind(&self) -> &'static str {
1314 match self {
1315 Self::MissingRemote => remote_advice_kind::REMOTE_NOT_CONFIGURED,
1316 Self::TransportMismatch => remote_advice_kind::REMOTE_TRANSPORT_MISMATCH,
1317 Self::GitOverlayThreadMismatch { .. } => {
1318 remote_advice_kind::GIT_OVERLAY_THREAD_MISMATCH
1319 }
1320 }
1321 }
1322}
1323
1324pub fn refuse_named_thread_tip_overwrite(
1331 force: bool,
1332 named_thread: Option<&str>,
1333 existing_tip_differs: bool,
1334) -> bool {
1335 named_thread.is_some() && !force && existing_tip_differs
1336}
1337
1338pub fn named_thread_tip_mismatch_failure(
1340 thread: &str,
1341 tip_short: impl Into<String>,
1342 current_short: impl Into<String>,
1343) -> PushFailure {
1344 PushFailure::NamedThreadTipMismatch {
1345 thread: thread.to_string(),
1346 tip_short: tip_short.into(),
1347 current_short: current_short.into(),
1348 }
1349}
1350
1351pub fn first_multi_thread_push_failure(failures: &[(String, String)]) -> Option<PushFailure> {
1353 failures
1354 .first()
1355 .map(|(name, err)| remote_push_failure(name, Some(err.as_str())))
1356}
1357
1358pub const UNKNOWN_TRANSPORT_ERROR: &str = "Unknown error";
1360
1361pub fn transport_error_message(error: Option<&str>) -> String {
1365 match error.map(str::trim).filter(|s| !s.is_empty()) {
1366 Some(s) => s.to_string(),
1367 None => UNKNOWN_TRANSPORT_ERROR.to_string(),
1368 }
1369}
1370
1371pub fn remote_push_failure(track_name: &str, error: Option<&str>) -> PushFailure {
1373 PushFailure::RemoteFailed {
1374 track_name: track_name.to_string(),
1375 error: transport_error_message(error),
1376 }
1377}
1378
1379pub fn remote_pull_failure(
1381 remote_thread: &str,
1382 local_thread: Option<&str>,
1383 error: Option<&str>,
1384) -> PullFailure {
1385 PullFailure::RemoteFailed {
1386 remote_thread: remote_thread.to_string(),
1387 local_thread: local_thread.map(str::to_string),
1388 error: transport_error_message(error),
1389 }
1390}
1391
1392pub fn multi_thread_failed_names(failures: &[(String, String)]) -> Vec<String> {
1394 failures.iter().map(|(thread, _)| thread.clone()).collect()
1395}
1396
1397pub fn multi_thread_reported_refs(pushed_threads: &[String]) -> Vec<String> {
1403 let mut refs = pushed_threads.to_vec();
1404 refs.sort();
1405 refs
1406}
1407
1408pub fn multi_thread_push_execution_facts(
1414 pushed_threads: Vec<String>,
1415 failures: &[(String, String)],
1416 objects: usize,
1417) -> PushExecutionFacts {
1418 PushExecutionFacts::HeddleAllThreads {
1419 pushed_threads,
1420 failed_threads: multi_thread_failed_names(failures),
1421 objects,
1422 }
1423}
1424
1425#[derive(Debug, Clone, PartialEq, Eq)]
1439pub struct HostedPushResultFields {
1440 pub success: bool,
1441 pub new_state: Option<String>,
1442 pub error: Option<String>,
1443}
1444
1445#[derive(Debug, Clone, PartialEq, Eq)]
1447pub struct HostedPullResultFields {
1448 pub success: bool,
1449 pub final_state: Option<String>,
1450 pub error: Option<String>,
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq)]
1455pub struct LocalTransferSummary {
1456 pub state: Option<String>,
1457 pub objects: Option<usize>,
1458}
1459
1460#[derive(Debug, Clone, PartialEq, Eq)]
1462pub enum HostedPushResult {
1463 Success { state: Option<String> },
1465 Failed(PushFailure),
1467}
1468
1469#[derive(Debug, Clone, PartialEq, Eq)]
1471pub enum HostedPullResult {
1472 Success { final_state: Option<String> },
1474 Failed(PullFailure),
1476}
1477
1478pub fn parse_hosted_push_result(
1483 track_name: &str,
1484 fields: &HostedPushResultFields,
1485) -> HostedPushResult {
1486 if fields.success {
1487 HostedPushResult::Success {
1488 state: fields.new_state.clone(),
1489 }
1490 } else {
1491 HostedPushResult::Failed(remote_push_failure(track_name, fields.error.as_deref()))
1492 }
1493}
1494
1495pub fn parse_hosted_pull_result(
1497 remote_thread: &str,
1498 local_thread: Option<&str>,
1499 fields: &HostedPullResultFields,
1500) -> HostedPullResult {
1501 if fields.success {
1502 HostedPullResult::Success {
1503 final_state: fields.final_state.clone(),
1504 }
1505 } else {
1506 HostedPullResult::Failed(remote_pull_failure(
1507 remote_thread,
1508 local_thread,
1509 fields.error.as_deref(),
1510 ))
1511 }
1512}
1513
1514pub fn heddle_single_push_execution_facts(
1516 state: Option<String>,
1517 objects: Option<usize>,
1518) -> PushExecutionFacts {
1519 PushExecutionFacts::HeddleSingle { state, objects }
1520}
1521
1522pub fn heddle_single_push_execution_facts_from_local(
1524 summary: &LocalTransferSummary,
1525) -> PushExecutionFacts {
1526 heddle_single_push_execution_facts(summary.state.clone(), summary.objects)
1527}
1528
1529pub fn heddle_single_push_execution_facts_from_hosted(
1535 fields: &HostedPushResultFields,
1536) -> PushExecutionFacts {
1537 heddle_single_push_execution_facts(fields.new_state.clone(), None)
1538}
1539
1540pub fn git_overlay_push_execution_facts(
1542 remote_name: String,
1543 current_thread: Option<String>,
1544 refs_written: Vec<String>,
1545 tracking: Option<GitOverlayPushTracking>,
1546) -> PushExecutionFacts {
1547 PushExecutionFacts::GitOverlayRefs {
1548 remote_name,
1549 current_thread,
1550 refs_written,
1551 tracking,
1552 }
1553}
1554
1555pub fn heddle_pull_execution_facts(
1557 changed: bool,
1558 remote: String,
1559 thread: String,
1560 state: Option<String>,
1561 objects: Option<usize>,
1562) -> PullExecutionFacts {
1563 PullExecutionFacts::Heddle {
1564 changed,
1565 remote,
1566 thread,
1567 state,
1568 objects,
1569 }
1570}
1571
1572pub fn heddle_pull_execution_facts_from_hosted(
1576 changed: bool,
1577 remote: String,
1578 thread: String,
1579 fields: &HostedPullResultFields,
1580) -> PullExecutionFacts {
1581 heddle_pull_execution_facts(changed, remote, thread, fields.final_state.clone(), None)
1582}
1583
1584pub fn heddle_pull_execution_facts_from_local(
1586 changed: bool,
1587 remote: String,
1588 thread: String,
1589 summary: &LocalTransferSummary,
1590) -> PullExecutionFacts {
1591 heddle_pull_execution_facts(
1592 changed,
1593 remote,
1594 thread,
1595 summary.state.clone(),
1596 summary.objects,
1597 )
1598}
1599
1600#[allow(clippy::too_many_arguments)]
1602pub fn git_overlay_pull_execution_facts(
1603 remote: String,
1604 branch: Option<String>,
1605 old_git_head: Option<String>,
1606 new_git_head: Option<String>,
1607 old_state: Option<String>,
1608 new_state: Option<String>,
1609 changed: bool,
1610 states_created: usize,
1611 commits_seen: usize,
1612 materialized_checkout: bool,
1613 changed_paths: Vec<String>,
1614) -> PullExecutionFacts {
1615 PullExecutionFacts::GitOverlay {
1616 remote,
1617 branch,
1618 old_git_head,
1619 new_git_head,
1620 old_state,
1621 new_state,
1622 changed,
1623 states_created,
1624 commits_seen,
1625 materialized_checkout,
1626 changed_paths,
1627 }
1628}
1629
1630pub fn pull_tip_changed(pre_target: Option<&str>, final_state: Option<&str>) -> bool {
1635 match final_state {
1636 Some(state) => pre_target != Some(state),
1637 None => false,
1638 }
1639}
1640
1641pub fn local_pull_changed(
1643 pre_target: Option<&str>,
1644 final_state: &str,
1645 objects_copied: usize,
1646) -> bool {
1647 pre_target != Some(final_state) || objects_copied > 0
1648}
1649
1650#[derive(Debug, Clone, PartialEq, Eq)]
1660pub enum MultiRefPushProgress {
1661 Begin {
1663 target: String,
1665 },
1666 ThreadSucceeded {
1668 thread: String,
1669 state_short: Option<String>,
1671 objects: Option<usize>,
1673 remote_state: Option<String>,
1675 },
1676 ThreadFailed { thread: String, error: String },
1678}
1679
1680pub fn multi_ref_push_begin(target: impl Into<String>) -> MultiRefPushProgress {
1682 MultiRefPushProgress::Begin {
1683 target: target.into(),
1684 }
1685}
1686
1687pub fn multi_ref_thread_succeeded_local(
1689 thread: impl Into<String>,
1690 state_short: Option<String>,
1691 objects: Option<usize>,
1692) -> MultiRefPushProgress {
1693 MultiRefPushProgress::ThreadSucceeded {
1694 thread: thread.into(),
1695 state_short,
1696 objects,
1697 remote_state: None,
1698 }
1699}
1700
1701pub fn multi_ref_thread_succeeded_hosted(
1703 thread: impl Into<String>,
1704 remote_state: Option<String>,
1705) -> MultiRefPushProgress {
1706 MultiRefPushProgress::ThreadSucceeded {
1707 thread: thread.into(),
1708 state_short: None,
1709 objects: None,
1710 remote_state,
1711 }
1712}
1713
1714pub fn multi_ref_thread_failed(
1716 thread: impl Into<String>,
1717 error: Option<&str>,
1718) -> MultiRefPushProgress {
1719 MultiRefPushProgress::ThreadFailed {
1720 thread: thread.into(),
1721 error: transport_error_message(error),
1722 }
1723}
1724
1725pub fn multi_ref_progress_from_hosted_thread(
1731 thread: &str,
1732 fields: &HostedPushResultFields,
1733) -> MultiRefPushProgress {
1734 if fields.success {
1735 multi_ref_thread_succeeded_hosted(thread, fields.new_state.clone())
1736 } else {
1737 multi_ref_thread_failed(thread, fields.error.as_deref())
1738 }
1739}
1740
1741pub fn format_multi_ref_push_progress(event: &MultiRefPushProgress) -> String {
1743 match event {
1744 MultiRefPushProgress::Begin { target } => {
1745 format!("pushing all threads to {target}")
1746 }
1747 MultiRefPushProgress::ThreadSucceeded {
1748 thread,
1749 state_short: Some(state),
1750 objects: Some(n),
1751 ..
1752 } => {
1753 let unit = if *n == 1 { "object" } else { "objects" };
1754 format!("pushed {state} to {thread} ({n} {unit})")
1755 }
1756 MultiRefPushProgress::ThreadSucceeded {
1757 thread,
1758 state_short: Some(state),
1759 objects: None,
1760 ..
1761 } => format!("pushed {state} to {thread}"),
1762 MultiRefPushProgress::ThreadSucceeded {
1763 thread,
1764 state_short: None,
1765 objects: Some(n),
1766 ..
1767 } => {
1768 let unit = if *n == 1 { "object" } else { "objects" };
1769 format!("pushed to {thread} ({n} {unit})")
1770 }
1771 MultiRefPushProgress::ThreadSucceeded {
1772 thread,
1773 remote_state: Some(state),
1774 ..
1775 } => format!("pushed to {thread} (remote state {state})"),
1776 MultiRefPushProgress::ThreadSucceeded { thread, .. } => {
1777 format!("pushed to {thread}")
1778 }
1779 MultiRefPushProgress::ThreadFailed { thread, error } => {
1780 format!("failed to push {thread}: {error}")
1781 }
1782 }
1783}
1784
1785pub fn format_ref_list(refs: &[String]) -> String {
1790 refs.join(", ")
1791}
1792
1793pub fn format_multi_thread_refs_detail(pushed_threads: &[String]) -> Option<String> {
1797 if pushed_threads.is_empty() {
1798 return None;
1799 }
1800 let sorted = multi_thread_reported_refs(pushed_threads);
1801 Some(format!("refs: {}", format_ref_list(&sorted)))
1802}
1803
1804pub fn format_pushing_to(target: &str) -> String {
1810 format!("pushing to {target}")
1811}
1812
1813pub fn format_pulling_from(source: &str) -> String {
1815 format!("pulling from {source}")
1816}
1817
1818pub fn format_connected_to(addr: &str) -> String {
1820 format!("connected to {addr}")
1821}
1822
1823pub fn format_remote_state_detail(state: &str) -> String {
1825 format!("remote state: {state}")
1826}
1827
1828pub fn format_mirror_success_text(remote: &str) -> String {
1830 format!("mirrored to {remote}")
1831}
1832
1833pub fn format_mirror_failure_text(remote: &str, error: &str) -> String {
1835 format!("mirror push to {remote} failed (primary push still succeeded): {error}")
1836}
1837
1838#[derive(Debug, Clone, PartialEq, Eq)]
1844pub struct PushOutcomeText {
1845 pub headline: String,
1847 pub detail_lines: Vec<String>,
1849}
1850
1851#[derive(Debug, Clone, PartialEq, Eq)]
1853pub struct PullOutcomeText {
1854 pub headline: String,
1856 pub detail_lines: Vec<String>,
1858}
1859
1860pub fn git_overlay_push_scope_description(all_threads: bool) -> &'static str {
1862 if all_threads {
1863 "all threads + Git tags + refs/notes/heddle"
1864 } else {
1865 "branch + refs/notes/heddle; tags skipped"
1866 }
1867}
1868
1869pub const ALL_THREADS_MIRROR_COVERS_NOTE: &str =
1872 "Git Projection push covers all threads (every ref shipped in one transfer)";
1873
1874pub fn all_threads_mirror_coverage_note(all_threads: bool) -> Option<&'static str> {
1882 all_threads.then_some(ALL_THREADS_MIRROR_COVERS_NOTE)
1883}
1884
1885pub fn format_push_outcome_text(
1890 outcome: &PushOutcome,
1891 track_name: Option<&str>,
1892) -> PushOutcomeText {
1893 let headline = match outcome.transport {
1894 "git" => {
1895 let remote = outcome.remote.as_deref().unwrap_or("remote");
1896 let all_threads = outcome.push_scope == Some("all_threads");
1897 let subject = if all_threads {
1898 "all threads".to_string()
1899 } else {
1900 outcome
1901 .thread
1902 .as_deref()
1903 .map(|t| format!("thread {t}"))
1904 .unwrap_or_else(|| "current thread".to_string())
1905 };
1906 format!(
1907 "pushed {subject} to {remote} ({})",
1908 git_overlay_push_scope_description(all_threads)
1909 )
1910 }
1911 "heddle" if outcome.push_scope == Some("all_threads") => summarize_push_outcome(outcome),
1912 "heddle" => {
1913 let track = track_name.or(outcome.thread.as_deref()).unwrap_or("thread");
1914 match (&outcome.state, outcome.objects) {
1915 (Some(state), Some(objects)) => {
1916 let unit = if objects == 1 { "object" } else { "objects" };
1917 format!("pushed {state} to {track} ({objects} {unit})")
1918 }
1919 (Some(state), None) => format!("pushed to {track} (state {state})"),
1920 (None, Some(objects)) => {
1921 let unit = if objects == 1 { "object" } else { "objects" };
1922 format!("pushed to {track} ({objects} {unit})")
1923 }
1924 (None, None) => format!("pushed to {track}"),
1925 }
1926 }
1927 _ => summarize_push_outcome(outcome),
1928 };
1929
1930 let mut detail_lines = Vec::new();
1931 if let Some(warning) = outcome.force_discard_warning {
1932 detail_lines.push(format!("Force: {warning}."));
1933 }
1934 if outcome.git_notes_ref.is_some() {
1935 detail_lines.push(format!(
1936 "Git interop: published {GIT_NOTES_REF}; ordinary `git log --all` may show Heddle metadata commits."
1937 ));
1938 }
1939 if let Some(configured) = &outcome.git_remote_configured {
1940 detail_lines.push(format!(
1941 "Git tracking: configured remote {} -> {} for future fetch/push.",
1942 configured.name, configured.url
1943 ));
1944 }
1945 if let Some(upstream) = &outcome.git_upstream_configured {
1946 detail_lines.push(format!(
1947 "Git tracking: branch {} tracks {}/{}.",
1948 upstream.branch, upstream.remote, upstream.branch
1949 ));
1950 }
1951
1952 PushOutcomeText {
1953 headline,
1954 detail_lines,
1955 }
1956}
1957
1958pub fn format_pull_outcome_text(outcome: &PullOutcome, max_paths: usize) -> PullOutcomeText {
1962 let headline = if !outcome.changed {
1963 format!(
1964 "already up to date with {}; repository verification checked below",
1965 outcome.remote
1966 )
1967 } else if outcome.transport == "git" {
1968 format!("pulled from {}", outcome.remote)
1969 } else if let (Some(state), Some(objects)) = (&outcome.state, outcome.objects) {
1970 let unit = if objects == 1 { "object" } else { "objects" };
1971 let thread = outcome.thread.as_deref().unwrap_or("thread");
1972 format!("pulled {state} from {thread} ({objects} {unit})")
1973 } else if outcome.transport == "heddle" {
1974 format!(
1975 "pulled from {}",
1976 outcome.thread.as_deref().unwrap_or(outcome.remote.as_str())
1977 )
1978 } else {
1979 summarize_pull_outcome(outcome)
1980 };
1981
1982 let mut detail_lines = Vec::new();
1983 if outcome.transport == "git" {
1984 if let Some(branch) = &outcome.branch {
1985 if outcome.changed {
1986 detail_lines.push(format!("Branch: {branch}"));
1987 } else if let Some(head) = &outcome.new_git_head {
1988 let short: String = head.chars().take(12).collect();
1989 detail_lines.push(format!("Branch: {branch} at {short}"));
1990 }
1991 }
1992 match (&outcome.old_git_head, &outcome.new_git_head) {
1993 (Some(old), Some(new)) if old != new => {
1994 let old_s: String = old.chars().take(12).collect();
1995 let new_s: String = new.chars().take(12).collect();
1996 detail_lines.push(format!("Git: {old_s} -> {new_s}"));
1997 }
1998 (Some(head), Some(_)) if outcome.changed => {
1999 let short: String = head.chars().take(12).collect();
2000 detail_lines.push(format!("Git: {short}"));
2001 }
2002 _ => {}
2003 }
2004 if let Some(states) = outcome.states_created {
2005 let unit = if states == 1 {
2006 "new state"
2007 } else {
2008 "new states"
2009 };
2010 detail_lines.push(format!("Imported: {states} {unit}"));
2011 }
2012 if let Some(commits) = outcome.commits_seen {
2013 let unit = if commits == 1 {
2014 "Git commit object"
2015 } else {
2016 "Git commit objects"
2017 };
2018 detail_lines.push(format!(
2019 "Scanned: {commits} {unit} across branches + refs/notes/heddle"
2020 ));
2021 }
2022 if outcome.materialized_checkout == Some(true) {
2023 detail_lines.push("Worktree: materialized checkout".to_string());
2024 }
2025 if outcome.changed
2026 && let Some(paths) = &outcome.changed_paths
2027 {
2028 detail_lines.push(format!("Changed paths: {}", paths.len()));
2029 for path in paths.iter().take(max_paths) {
2030 detail_lines.push(format!(" - {path}"));
2031 }
2032 if paths.len() > max_paths {
2033 detail_lines.push(format!(" - ... {} more", paths.len() - max_paths));
2034 }
2035 }
2036 } else if outcome.changed
2037 && let Some(state) = &outcome.state
2038 && outcome.objects.is_none()
2039 {
2040 detail_lines.push(format!("state: {state}"));
2042 }
2043
2044 PullOutcomeText {
2045 headline,
2046 detail_lines,
2047 }
2048}
2049
2050pub fn pull_should_materialize(will_materialize: bool, lazy: bool) -> bool {
2054 will_materialize && !lazy
2055}
2056
2057pub fn merged_remote_items(repo: &Repository) -> Result<BTreeMap<String, (String, String)>> {
2063 if repo.capability() == RepositoryCapability::GitOverlay {
2064 return Ok(git_overlay_config_remotes(repo)
2065 .into_iter()
2066 .map(|(name, url)| (name, (url, "git-overlay".to_string())))
2067 .collect());
2068 }
2069 let cfg = RemoteConfig::open(repo).map_err(anyhow::Error::new)?;
2070 let items: BTreeMap<String, (String, String)> = cfg
2071 .list()
2072 .into_iter()
2073 .map(|(name, remote)| {
2074 let source = configured_remote_source(repo, &remote.url);
2075 (name, (remote.url, source.to_string()))
2076 })
2077 .collect();
2078 Ok(items)
2079}
2080
2081pub fn plain_git_remote_items(root: &Path) -> BTreeMap<String, String> {
2083 let Some(ctx) = GitConfigContext::discover(root) else {
2084 return BTreeMap::new();
2085 };
2086 ctx.remotes(ctx.layered_paths())
2087}
2088
2089fn default_remote_from_items(items: &BTreeMap<String, String>) -> Option<String> {
2090 if items.contains_key("origin") {
2091 Some("origin".to_string())
2092 } else if items.len() == 1 {
2093 items.keys().next().cloned()
2094 } else {
2095 None
2096 }
2097}
2098
2099fn plain_git_default_remote_name(root: &Path, items: &BTreeMap<String, String>) -> Option<String> {
2100 let git = SleyRepository::discover(root).ok()?;
2101 let config = git.config_snapshot().ok()?;
2102 let branch = git.head().ok()?.symbolic_target.and_then(|name| {
2103 name.as_str()
2104 .strip_prefix("refs/heads/")
2105 .map(str::to_string)
2106 });
2107 branch
2108 .as_deref()
2109 .and_then(|branch| config.get("branch", Some(branch), "remote"))
2110 .or_else(|| config.get("remote", None, "pushDefault"))
2111 .map(str::to_string)
2112 .filter(|name| items.contains_key(name))
2113 .or_else(|| default_remote_from_items(items))
2114}
2115
2116fn git_overlay_default_remote_name(repo: &Repository) -> Option<String> {
2117 let git_remotes = git_overlay_config_remotes(repo);
2118 if let Some(upstream_remote) = git_upstream_remote_name(repo)
2119 && git_remotes.contains_key(&upstream_remote)
2120 {
2121 return Some(upstream_remote);
2122 }
2123 if git_remotes.contains_key("origin") {
2124 return Some("origin".to_string());
2125 }
2126 if git_remotes.len() == 1 {
2127 return git_remotes.keys().next().cloned();
2128 }
2129 None
2130}
2131
2132fn git_overlay_default_push_remote_name(repo: &Repository) -> Option<String> {
2133 let remotes = git_overlay_config_remotes(repo);
2134 let git = SleyRepository::discover(repo.root()).ok()?;
2135 let config = git.config_snapshot().ok()?;
2136 let branch = repo.git_overlay_current_branch().ok().flatten();
2137 branch
2138 .as_deref()
2139 .and_then(|branch| config.get("branch", Some(branch), "pushRemote"))
2140 .or_else(|| config.get("remote", None, "pushDefault"))
2141 .or_else(|| {
2142 branch
2143 .as_deref()
2144 .and_then(|branch| config.get("branch", Some(branch), "remote"))
2145 })
2146 .map(str::to_string)
2147 .filter(|name| remotes.contains_key(name))
2148 .or_else(|| default_remote_from_items(&remotes))
2149}
2150
2151fn git_upstream_remote_name(repo: &Repository) -> Option<String> {
2152 let branch = repo.git_overlay_current_branch().ok().flatten()?;
2153 let git = SleyRepository::discover(repo.root()).ok()?;
2154 git.config_snapshot()
2155 .ok()?
2156 .get("branch", Some(&branch), "remote")
2157 .map(str::to_string)
2158 .filter(|remote| !remote.is_empty())
2159}
2160
2161fn git_overlay_config_remotes(repo: &Repository) -> BTreeMap<String, String> {
2162 let Some(ctx) = GitConfigContext::discover(repo.root()) else {
2163 return BTreeMap::new();
2164 };
2165 ctx.remotes(ctx.layered_paths())
2166}
2167
2168fn configured_remote_source(repo: &Repository, url: &str) -> &'static str {
2169 if repo.capability() == RepositoryCapability::GitOverlay
2170 && local_remote_path(url).is_some_and(|path| is_local_git_repository(&path))
2171 {
2172 "git-overlay"
2173 } else {
2174 "heddle"
2175 }
2176}
2177
2178fn local_remote_path(url: &str) -> Option<PathBuf> {
2179 match RemoteTarget::parse(url).ok()? {
2180 RemoteTarget::Local(path) => Some(path),
2181 RemoteTarget::Network { .. } => None,
2182 }
2183}
2184
2185fn is_local_git_repository(path: &Path) -> bool {
2186 if path.join(".git").exists() {
2187 return true;
2188 }
2189 path.join("HEAD").is_file() && path.join("objects").is_dir() && path.join("refs").is_dir()
2190}
2191
2192pub fn looks_like_git_remote_url(value: &str) -> bool {
2198 let lower = value.to_ascii_lowercase();
2199 lower.starts_with("http://")
2200 || lower.starts_with("https://")
2201 || lower.starts_with("ssh://")
2202 || lower.starts_with("git://")
2203 || lower.ends_with(".git")
2204 || (value.contains('@') && value.contains(':'))
2205}
2206
2207pub fn looks_like_git_forge_remote(value: &str) -> bool {
2214 let lower = value.to_ascii_lowercase();
2215 if lower.starts_with("file://") {
2216 return false;
2217 }
2218 if looks_like_known_git_host(value) {
2219 return true;
2220 }
2221 if lower.starts_with("ssh://")
2222 || lower.starts_with("git://")
2223 || (value.contains('@') && value.contains(':') && !lower.starts_with("heddle://"))
2224 {
2225 return true;
2226 }
2227 lower.ends_with(".git") && (lower.starts_with("http://") || lower.starts_with("https://"))
2228}
2229
2230pub fn looks_like_known_git_host(value: &str) -> bool {
2232 remote_url_host(value).is_some_and(is_known_git_host)
2233}
2234
2235fn remote_url_host(value: &str) -> Option<&str> {
2236 let rest = value
2237 .strip_prefix("https://")
2238 .or_else(|| value.strip_prefix("http://"))
2239 .or_else(|| value.strip_prefix("ssh://"))
2240 .or_else(|| value.strip_prefix("git://"))
2241 .or_else(|| value.strip_prefix("heddle://"))
2242 .unwrap_or(value);
2243 let authority = if let Some((user, host_path)) = rest.split_once('@') {
2244 if user.eq_ignore_ascii_case("git") || !host_path.contains('/') {
2245 host_path
2246 } else {
2247 rest
2248 }
2249 } else {
2250 rest
2251 };
2252 let host = authority.split(['/', '\\']).next().unwrap_or(authority);
2253 if host.is_empty() {
2254 return None;
2255 }
2256 Some(host_without_port(host))
2257}
2258
2259fn host_without_port(host: &str) -> &str {
2260 host.strip_prefix('[')
2261 .and_then(|host| host.split(']').next())
2262 .unwrap_or_else(|| host.split(':').next().unwrap_or(host))
2263}
2264
2265fn is_known_git_host(host: &str) -> bool {
2266 let host = host.to_ascii_lowercase();
2267 matches!(
2268 host.as_str(),
2269 "github.com"
2270 | "www.github.com"
2271 | "gitlab.com"
2272 | "www.gitlab.com"
2273 | "bitbucket.org"
2274 | "www.bitbucket.org"
2275 | "codeberg.org"
2276 | "www.codeberg.org"
2277 ) || host.ends_with(".github.com")
2278 || host.ends_with(".gitlab.com")
2279}
2280
2281pub fn looks_like_remote_location(value: &str) -> bool {
2285 value.starts_with('/')
2286 || value.starts_with("./")
2287 || value.starts_with("../")
2288 || value.starts_with("~/")
2289 || value.contains("://")
2290 || value.contains('\\')
2291}
2292
2293pub fn remote_urls_match(left: &str, right: &str) -> bool {
2295 if left == right {
2296 return true;
2297 }
2298 let left_path = Path::new(left);
2299 let right_path = Path::new(right);
2300 match (left_path.canonicalize(), right_path.canonicalize()) {
2301 (Ok(left), Ok(right)) => left == right,
2302 _ => false,
2303 }
2304}
2305
2306pub fn message_indicates_already_exists(message: &str) -> bool {
2308 message.to_ascii_lowercase().contains("already exists")
2309}
2310
2311pub fn hosted_path_contains_internal_user_namespace(value: &str) -> bool {
2313 value.contains("__users/")
2314}
2315
2316pub fn redact_internal_hosted_paths(message: &str) -> String {
2318 message
2319 .split_whitespace()
2320 .map(|part| {
2321 if hosted_path_contains_internal_user_namespace(part) {
2322 "[user namespace]"
2323 } else {
2324 part
2325 }
2326 })
2327 .collect::<Vec<_>>()
2328 .join(" ")
2329}
2330
2331pub fn hosted_spool_display_path(
2333 namespace_slug: &str,
2334 spool_slug: &str,
2335 full_path: &str,
2336) -> String {
2337 if hosted_path_contains_internal_user_namespace(full_path) && !namespace_slug.is_empty() {
2338 format!("{namespace_slug}/{spool_slug}")
2339 } else {
2340 full_path.to_string()
2341 }
2342}
2343
2344pub fn is_native_transport_mismatch(
2349 capability: RepositoryCapability,
2350 remote_is_git_local_or_url: bool,
2351) -> bool {
2352 capability != RepositoryCapability::GitOverlay && remote_is_git_local_or_url
2353}
2354
2355#[derive(Debug, Clone, thiserror::Error)]
2357#[error("Remote '{name}' is defined in an included Git config that heddle won't edit: {path}")]
2358pub struct IncludedGitRemoteConfigError {
2359 pub name: String,
2360 pub path: PathBuf,
2361}
2362
2363impl IncludedGitRemoteConfigError {
2364 fn new(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
2365 Self {
2366 name: name.into(),
2367 path: path.into(),
2368 }
2369 }
2370}
2371
2372#[derive(Debug, Clone)]
2375pub struct GitConfigContext {
2376 git_dir: PathBuf,
2377 common_dir: PathBuf,
2378 branch: Option<String>,
2379}
2380
2381impl GitConfigContext {
2382 pub fn discover(root: &Path) -> Option<Self> {
2383 let git = SleyRepository::discover(root).ok()?;
2384 Some(Self {
2385 git_dir: git.git_dir().to_path_buf(),
2386 common_dir: git.common_dir().to_path_buf(),
2387 branch: git
2388 .head()
2389 .ok()
2390 .and_then(|head| head.symbolic_target.map(|name| name.to_string()))
2391 .and_then(|name| name.strip_prefix("refs/heads/").map(str::to_string)),
2392 })
2393 }
2394
2395 pub fn common_dir(&self) -> &Path {
2396 &self.common_dir
2397 }
2398
2399 pub fn layered_paths(&self) -> Vec<PathBuf> {
2404 let mut paths = Vec::new();
2405 if self.worktree_config_enabled() {
2406 paths.push(self.git_dir.join("config.worktree"));
2407 }
2408 paths.push(self.git_dir.join("config"));
2409 if self.common_dir != self.git_dir {
2410 paths.push(self.common_dir.join("config"));
2411 }
2412 paths
2413 }
2414
2415 fn worktree_config_enabled(&self) -> bool {
2416 let mut paths = vec![self.git_dir.join("config")];
2417 if self.common_dir != self.git_dir {
2418 paths.push(self.common_dir.join("config"));
2419 }
2420 self.load(paths)
2421 .and_then(|config| config.get_bool("extensions", None, "worktreeConfig"))
2422 .unwrap_or(false)
2423 }
2424
2425 pub fn write_file_for(
2428 &self,
2429 name: &str,
2430 ) -> std::result::Result<PathBuf, IncludedGitRemoteConfigError> {
2431 match self.defining_files_for(name).into_iter().next() {
2432 Some(path) => {
2433 if !self.owns_config_file(&path) {
2434 return Err(IncludedGitRemoteConfigError::new(name, path));
2435 }
2436 Ok(path)
2437 }
2438 None => Ok(self.common_dir.join("config")),
2439 }
2440 }
2441
2442 pub fn remove_files_for(
2445 &self,
2446 name: &str,
2447 ) -> std::result::Result<Vec<PathBuf>, IncludedGitRemoteConfigError> {
2448 let files = self.defining_files_for(name);
2449 for path in &files {
2450 if !self.owns_config_file(path) {
2451 return Err(IncludedGitRemoteConfigError::new(name, path.clone()));
2452 }
2453 }
2454 Ok(files)
2455 }
2456
2457 pub fn defining_files_for(&self, name: &str) -> Vec<PathBuf> {
2460 let mut files = Vec::new();
2461 let Some(stack) = self.config_stack() else {
2462 return files;
2463 };
2464 for entry in stack.entries.iter().rev() {
2465 if entry.section.eq_ignore_ascii_case("remote")
2466 && entry.subsection.as_deref() == Some(name)
2467 && let Some(path) = config_entry_origin_path(entry)
2468 && !files.contains(&path)
2469 {
2470 files.push(path);
2471 }
2472 }
2473 files
2474 }
2475
2476 pub fn owns_config_file(&self, path: &Path) -> bool {
2479 let target = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
2480 [&self.git_dir, &self.common_dir].into_iter().any(|root| {
2481 let root = root.canonicalize().unwrap_or_else(|_| root.clone());
2482 target.starts_with(&root)
2483 })
2484 }
2485
2486 pub fn remotes(&self, paths: Vec<PathBuf>) -> BTreeMap<String, String> {
2487 let mut remotes = BTreeMap::new();
2488 for path in paths {
2489 let Some(config) = self.load_one(&path, true) else {
2490 continue;
2491 };
2492 for section in &config.sections {
2493 if !section.name.eq_ignore_ascii_case("remote") {
2494 continue;
2495 }
2496 let Some(name) = section.subsection.as_deref() else {
2497 continue;
2498 };
2499 let Some(url) = config_section_value(section, "url") else {
2500 continue;
2501 };
2502 remotes
2503 .entry(name.to_string())
2504 .or_insert_with(|| url.to_string());
2505 }
2506 }
2507 remotes
2508 }
2509
2510 fn load(&self, paths: Vec<PathBuf>) -> Option<GitConfig> {
2511 let mut merged = GitConfig::default();
2512 for path in paths.into_iter().rev() {
2513 let Some(config) = self.load_one(&path, true) else {
2514 continue;
2515 };
2516 merged.sections.extend(config.sections);
2517 }
2518 Some(merged)
2519 }
2520
2521 fn config_stack(&self) -> Option<ConfigStack> {
2522 let context = ConfigIncludeContext {
2523 git_dir: Some(self.git_dir.clone()),
2524 current_branch: self.branch.clone(),
2525 };
2526 let mut stack = ConfigStack::new();
2527 for path in self.layered_paths().into_iter().rev() {
2528 let scope = if path
2529 .file_name()
2530 .is_some_and(|name| name == "config.worktree")
2531 {
2532 ConfigScope::Worktree
2533 } else {
2534 ConfigScope::Local
2535 };
2536 stack.push_file(&path, scope, true, &context).ok()?;
2537 }
2538 Some(stack)
2539 }
2540
2541 fn load_one(&self, path: &Path, follow_includes: bool) -> Option<GitConfig> {
2542 let bytes = fs::read(path).ok()?;
2543 let config = GitConfig::parse(&bytes).ok()?;
2544 if !follow_includes {
2545 return Some(config);
2546 }
2547 let base = path.parent().unwrap_or_else(|| Path::new("."));
2548 config
2549 .resolve_includes(
2550 base,
2551 &ConfigIncludeContext {
2552 git_dir: Some(self.git_dir.clone()),
2553 current_branch: self.branch.clone(),
2554 },
2555 )
2556 .ok()
2557 }
2558}
2559
2560fn config_entry_origin_path(entry: &ConfigStackEntry) -> Option<PathBuf> {
2561 (entry.origin.kind == ConfigOriginKind::File).then(|| PathBuf::from(&entry.origin.name))
2562}
2563
2564fn config_section_value<'a>(
2565 section: &'a sley::plumbing::sley_config::ConfigSection,
2566 key: &str,
2567) -> Option<&'a str> {
2568 section
2569 .entries
2570 .iter()
2571 .rev()
2572 .find(|entry| entry.key.eq_ignore_ascii_case(key))
2573 .and_then(|entry| entry.value.as_deref())
2574}
2575
2576pub fn included_config_error(err: IncludedGitRemoteConfigError) -> anyhow::Error {
2579 anyhow!(err)
2580}
2581
2582#[cfg(test)]
2583mod tests {
2584 use super::*;
2585
2586 fn init_git(root: &Path) {
2587 SleyRepository::init(root).expect("init git repo");
2588 }
2589
2590 #[test]
2591 fn parses_quoted_url_with_equals_and_strips_quotes() {
2592 let tmp = tempfile::TempDir::new().unwrap();
2593 init_git(tmp.path());
2594 fs::write(
2595 tmp.path().join(".git").join("config"),
2596 "[remote \"origin\"]\n\turl = \"https://example.com/repo?ref=main&a=b\"\n",
2597 )
2598 .unwrap();
2599
2600 let remotes = plain_git_remote_items(tmp.path());
2601
2602 assert_eq!(
2603 remotes.get("origin").map(String::as_str),
2604 Some("https://example.com/repo?ref=main&a=b"),
2605 );
2606 }
2607
2608 #[test]
2609 fn strips_inline_comments_from_url() {
2610 let tmp = tempfile::TempDir::new().unwrap();
2611 init_git(tmp.path());
2612 fs::write(
2613 tmp.path().join(".git").join("config"),
2614 "[remote \"origin\"]\n\turl = https://example.com/repo ; trailing comment\n",
2615 )
2616 .unwrap();
2617
2618 let remotes = plain_git_remote_items(tmp.path());
2619
2620 assert_eq!(
2621 remotes.get("origin").map(String::as_str),
2622 Some("https://example.com/repo"),
2623 );
2624 }
2625
2626 #[test]
2627 fn follows_include_directives() {
2628 let tmp = tempfile::TempDir::new().unwrap();
2629 init_git(tmp.path());
2630 let git_dir = tmp.path().join(".git");
2631 fs::write(
2632 git_dir.join("extra.config"),
2633 "[remote \"upstream\"]\n\turl = https://example.com/upstream\n",
2634 )
2635 .unwrap();
2636 fs::write(git_dir.join("config"), "[include]\n\tpath = extra.config\n").unwrap();
2637
2638 let remotes = plain_git_remote_items(tmp.path());
2639
2640 assert_eq!(
2641 remotes.get("upstream").map(String::as_str),
2642 Some("https://example.com/upstream"),
2643 );
2644 }
2645
2646 #[test]
2647 fn worktree_config_overrides_local_when_extension_enabled() {
2648 let tmp = tempfile::TempDir::new().unwrap();
2649 init_git(tmp.path());
2650 let git_dir = tmp.path().join(".git");
2651 fs::write(
2652 git_dir.join("config"),
2653 "[extensions]\n\tworktreeConfig = true\n\
2654 [remote \"origin\"]\n\turl = https://example.com/local\n",
2655 )
2656 .unwrap();
2657 fs::write(
2658 git_dir.join("config.worktree"),
2659 "[remote \"origin\"]\n\turl = https://example.com/worktree\n",
2660 )
2661 .unwrap();
2662
2663 let remotes = plain_git_remote_items(tmp.path());
2664
2665 assert_eq!(
2666 remotes.get("origin").map(String::as_str),
2667 Some("https://example.com/worktree"),
2668 );
2669 }
2670
2671 #[test]
2672 fn ignores_worktree_config_when_extension_disabled() {
2673 let tmp = tempfile::TempDir::new().unwrap();
2674 init_git(tmp.path());
2675 let git_dir = tmp.path().join(".git");
2676 fs::write(
2677 git_dir.join("config"),
2678 "[remote \"origin\"]\n\turl = https://example.com/local\n",
2679 )
2680 .unwrap();
2681 fs::write(
2682 git_dir.join("config.worktree"),
2683 "[remote \"origin\"]\n\turl = https://example.com/worktree\n",
2684 )
2685 .unwrap();
2686
2687 let remotes = plain_git_remote_items(tmp.path());
2688
2689 assert_eq!(
2690 remotes.get("origin").map(String::as_str),
2691 Some("https://example.com/local"),
2692 );
2693 }
2694
2695 #[test]
2696 fn list_plain_git_marks_origin_default() {
2697 let tmp = tempfile::TempDir::new().unwrap();
2698 init_git(tmp.path());
2699 fs::write(
2700 tmp.path().join(".git").join("config"),
2701 "[remote \"origin\"]\n\turl = https://example.com/repo\n\
2702 [remote \"upstream\"]\n\turl = https://example.com/up\n",
2703 )
2704 .unwrap();
2705
2706 let report = list_plain_git_remotes(tmp.path());
2707 assert_eq!(report.output_kind, "remote_list");
2708 assert_eq!(report.remotes.len(), 2);
2709 let origin = report.remotes.iter().find(|r| r.name == "origin").unwrap();
2710 assert!(origin.is_default);
2711 assert_eq!(origin.source, "git");
2712 let upstream = report
2713 .remotes
2714 .iter()
2715 .find(|r| r.name == "upstream")
2716 .unwrap();
2717 assert!(!upstream.is_default);
2718 }
2719
2720 #[test]
2721 fn write_file_for_rejects_external_include() {
2722 let tmp = tempfile::TempDir::new().unwrap();
2723 init_git(tmp.path());
2724 let git_dir = tmp.path().join(".git");
2725 let external = tmp.path().join("external.config");
2726 fs::write(
2727 &external,
2728 "[remote \"origin\"]\n\turl = https://example.com/external\n",
2729 )
2730 .unwrap();
2731 fs::write(
2732 git_dir.join("config"),
2733 format!("[include]\n\tpath = {}\n", external.display()),
2734 )
2735 .unwrap();
2736
2737 let ctx = GitConfigContext::discover(tmp.path()).unwrap();
2738 assert!(ctx.write_file_for("origin").is_err());
2739 assert!(ctx.remove_files_for("origin").is_err());
2740 }
2741
2742 #[test]
2743 fn defining_files_follow_include_path() {
2744 let tmp = tempfile::TempDir::new().unwrap();
2745 init_git(tmp.path());
2746 let git_dir = tmp.path().join(".git");
2747 fs::write(
2748 git_dir.join("extra.config"),
2749 "[remote \"origin\"]\n\turl = https://example.com/old\n",
2750 )
2751 .unwrap();
2752 fs::write(git_dir.join("config"), "[include]\n\tpath = extra.config\n").unwrap();
2753
2754 let ctx = GitConfigContext::discover(tmp.path()).unwrap();
2755 let target = ctx.write_file_for("origin").unwrap();
2756 assert_eq!(target, git_dir.join("extra.config"));
2757 }
2758
2759 #[test]
2762 fn git_overlay_all_threads_hosted_push_is_single_mirror() {
2763 assert!(
2764 all_threads_uses_single_mirror_push(RepositoryCapability::GitOverlay),
2765 "git-overlay --all-threads must collapse to one mirror push",
2766 );
2767 assert!(
2768 !all_threads_uses_single_mirror_push(RepositoryCapability::NativeHeddle),
2769 "native --all-threads must keep the per-thread fan-out (#838)",
2770 );
2771 }
2772
2773 #[test]
2774 fn plan_hosted_push_routes_by_capability_and_all_threads() {
2775 assert_eq!(
2776 plan_hosted_push(RepositoryCapability::NativeHeddle, true),
2777 HostedPushPlan::NativePerThreadFanout,
2778 );
2779 assert_eq!(
2780 plan_hosted_push(RepositoryCapability::GitOverlay, true),
2781 HostedPushPlan::GitOverlayMirror,
2782 );
2783 assert_eq!(
2784 plan_hosted_push(RepositoryCapability::GitOverlay, false),
2785 HostedPushPlan::GitOverlayMirror,
2786 );
2787 assert_eq!(
2788 plan_hosted_push(RepositoryCapability::NativeHeddle, false),
2789 HostedPushPlan::NativeSingleThread,
2790 );
2791 }
2792
2793 #[test]
2794 fn uses_git_overlay_mirror_rpc_only_for_overlay() {
2795 assert!(uses_git_overlay_mirror_rpc(
2796 RepositoryCapability::GitOverlay
2797 ));
2798 assert!(!uses_git_overlay_mirror_rpc(
2799 RepositoryCapability::NativeHeddle
2800 ));
2801 }
2802
2803 #[test]
2804 fn uses_local_git_overlay_transport_follows_resolved_remote() {
2805 assert!(uses_local_git_overlay_transport(
2806 RepositoryCapability::GitOverlay,
2807 false,
2808 ));
2809 assert!(!uses_local_git_overlay_transport(
2810 RepositoryCapability::GitOverlay,
2811 true,
2812 ));
2813 assert!(!uses_local_git_overlay_transport(
2814 RepositoryCapability::NativeHeddle,
2815 false,
2816 ));
2817 }
2818
2819 #[test]
2820 fn overlay_push_remote_uses_git_precedence() {
2821 let tmp = tempfile::TempDir::new().unwrap();
2822 init_git(tmp.path());
2823 fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
2824 fs::write(
2825 tmp.path().join(".git/config"),
2826 "[remote \"origin\"]\n\turl = https://example.com/origin\n\
2827 [remote \"upstream\"]\n\turl = https://example.com/upstream\n\
2828 [remote \"publish\"]\n\turl = https://example.com/publish\n\
2829 [remote]\n\tpushDefault = publish\n\
2830 [branch \"main\"]\n\tremote = origin\n\tpushRemote = upstream\n",
2831 )
2832 .unwrap();
2833 let repo = Repository::init_git_overlay_sidecar(tmp.path()).unwrap();
2834 assert_eq!(
2835 resolve_default_push_remote_name(&repo, None).unwrap(),
2836 "upstream"
2837 );
2838
2839 let config = fs::read_to_string(tmp.path().join(".git/config")).unwrap();
2840 fs::write(
2841 tmp.path().join(".git/config"),
2842 config.replace("\tpushRemote = upstream\n", ""),
2843 )
2844 .unwrap();
2845 assert_eq!(
2846 resolve_default_push_remote_name(&repo, None).unwrap(),
2847 "publish"
2848 );
2849 }
2850
2851 #[test]
2852 fn overlay_remote_resolution_does_not_invent_origin() {
2853 let tmp = tempfile::TempDir::new().unwrap();
2854 init_git(tmp.path());
2855 let repo = Repository::init_git_overlay_sidecar(tmp.path()).unwrap();
2856
2857 assert!(resolve_default_remote_name(&repo, None).is_err());
2858 assert!(resolve_default_push_remote_name(&repo, None).is_err());
2859 }
2860
2861 #[test]
2862 fn default_push_thread_prefers_explicit_then_attached_then_main() {
2863 let attached = Head::Attached {
2864 thread: objects::object::ThreadName::new("feature"),
2865 };
2866 let detached = Head::Detached {
2867 state: objects::object::StateId::from_bytes([75; 32]),
2868 };
2869
2870 assert_eq!(
2871 default_push_thread_name(Some("release"), &attached),
2872 "release"
2873 );
2874 assert_eq!(default_push_thread_name(None, &attached), "feature");
2875 assert_eq!(default_push_thread_name(None, &detached), "main");
2876 }
2877
2878 #[test]
2879 fn default_pull_thread_uses_current_git_overlay_thread() {
2880 let head = Head::Attached {
2881 thread: objects::object::ThreadName::new("master"),
2882 };
2883 assert_eq!(
2884 default_pull_thread_name(None, RepositoryCapability::GitOverlay, &head),
2885 "master"
2886 );
2887 }
2888
2889 #[test]
2890 fn default_pull_thread_keeps_native_main_default() {
2891 let head = Head::Attached {
2892 thread: objects::object::ThreadName::new("feature"),
2893 };
2894 assert_eq!(
2895 default_pull_thread_name(None, RepositoryCapability::NativeHeddle, &head),
2896 "main"
2897 );
2898 }
2899
2900 #[test]
2901 fn default_pull_thread_honors_explicit_thread() {
2902 let head = Head::Attached {
2903 thread: objects::object::ThreadName::new("master"),
2904 };
2905 assert_eq!(
2906 default_pull_thread_name(Some("release"), RepositoryCapability::GitOverlay, &head),
2907 "release"
2908 );
2909 }
2910
2911 #[test]
2912 fn git_overlay_current_thread_push_refuses_mismatched_thread() {
2913 assert!(git_overlay_current_thread_push_ok(
2914 false,
2915 None,
2916 Some("main")
2917 ));
2918 assert!(git_overlay_current_thread_push_ok(
2919 false,
2920 Some("main"),
2921 Some("main")
2922 ));
2923 assert!(!git_overlay_current_thread_push_ok(
2924 false,
2925 Some("feature"),
2926 Some("main")
2927 ));
2928 assert!(!git_overlay_current_thread_push_ok(
2929 false,
2930 Some("feature"),
2931 None
2932 ));
2933 assert!(git_overlay_current_thread_push_ok(
2934 true,
2935 Some("feature"),
2936 Some("main")
2937 ));
2938 }
2939
2940 fn attached_head(name: &str) -> Head {
2943 Head::Attached {
2944 thread: objects::object::ThreadName::new(name),
2945 }
2946 }
2947
2948 fn detached_head() -> Head {
2949 Head::Detached {
2950 state: objects::object::StateId::from_bytes([76; 32]),
2951 }
2952 }
2953
2954 fn base_push_request() -> PushPlanRequest {
2955 PushPlanRequest {
2956 capability: RepositoryCapability::NativeHeddle,
2957 uses_hosted_network: false,
2958 remote: Some("origin".to_string()),
2959 has_default_remote: true,
2960 thread: None,
2961 all_threads: false,
2962 force: false,
2963 head: attached_head("main"),
2964 native_local_heddle_target: false,
2965 transport_mismatch: false,
2966 }
2967 }
2968
2969 fn base_pull_request() -> PullPlanRequest {
2970 PullPlanRequest {
2971 capability: RepositoryCapability::NativeHeddle,
2972 uses_hosted_network: false,
2973 remote: Some("origin".to_string()),
2974 has_default_remote: true,
2975 thread: None,
2976 local_thread: None,
2977 head: attached_head("main"),
2978 transport_mismatch: false,
2979 lazy: false,
2980 }
2981 }
2982
2983 #[test]
2984 fn remote_missing_blocker_table() {
2985 assert_eq!(
2986 remote_missing_blocker(None, false),
2987 Some(RemotePreflightBlocker::MissingRemote)
2988 );
2989 assert_eq!(remote_missing_blocker(None, true), None);
2990 assert_eq!(remote_missing_blocker(Some("origin"), false), None);
2991 assert_eq!(remote_missing_blocker(Some("origin"), true), None);
2992 }
2993
2994 #[test]
2995 fn transport_mismatch_blocker_table() {
2996 assert_eq!(
2997 transport_mismatch_blocker(false, true),
2998 Some(RemotePreflightBlocker::TransportMismatch)
2999 );
3000 assert_eq!(transport_mismatch_blocker(true, true), None);
3001 assert_eq!(transport_mismatch_blocker(false, false), None);
3002 assert_eq!(transport_mismatch_blocker(true, false), None);
3003 }
3004
3005 #[test]
3006 fn pull_clean_worktree_policy_table() {
3007 let cases = [
3009 (true, true, true),
3010 (true, false, true),
3011 (false, true, true),
3012 (false, false, false),
3013 ];
3014 for (overlay, materialize, expected) in cases {
3015 assert_eq!(
3016 pull_requires_clean_worktree(overlay, materialize),
3017 expected,
3018 "overlay={overlay} materialize={materialize}"
3019 );
3020 }
3021 }
3022
3023 #[test]
3024 fn pull_will_materialize_table() {
3025 let attached = attached_head("feature");
3026 let detached = detached_head();
3027 assert!(pull_will_materialize(None, "feature", &attached));
3029 assert!(!pull_will_materialize(None, "main", &attached));
3030 assert!(pull_will_materialize(Some("feature"), "main", &attached));
3031 assert!(!pull_will_materialize(Some("other"), "feature", &attached));
3032 assert!(pull_will_materialize(None, "main", &detached));
3033 assert!(!pull_will_materialize(Some("feature"), "main", &detached));
3034 }
3035
3036 #[test]
3037 fn plan_push_missing_remote() {
3038 let mut req = base_push_request();
3039 req.remote = None;
3040 req.has_default_remote = false;
3041 assert_eq!(plan_push(&req), Err(RemotePreflightBlocker::MissingRemote));
3042 }
3043
3044 #[test]
3045 fn plan_push_transport_mismatch_on_native_path() {
3046 let mut req = base_push_request();
3047 req.transport_mismatch = true;
3048 assert_eq!(
3049 plan_push(&req),
3050 Err(RemotePreflightBlocker::TransportMismatch)
3051 );
3052 }
3053
3054 #[test]
3055 fn plan_push_ignores_transport_mismatch_on_local_overlay() {
3056 let mut req = base_push_request();
3057 req.capability = RepositoryCapability::GitOverlay;
3058 req.transport_mismatch = true;
3059 let plan = plan_push(&req).expect("overlay path skips mismatch");
3060 assert!(plan.uses_local_git_overlay);
3061 assert!(matches!(plan.path, PushPath::LocalGitOverlayRefs { .. }));
3062 }
3063
3064 #[test]
3065 fn plan_push_git_overlay_thread_mismatch() {
3066 let mut req = base_push_request();
3067 req.capability = RepositoryCapability::GitOverlay;
3068 req.thread = Some("feature".to_string());
3069 req.head = attached_head("main");
3070 assert_eq!(
3071 plan_push(&req),
3072 Err(RemotePreflightBlocker::GitOverlayThreadMismatch {
3073 requested: "feature".to_string(),
3074 attached: Some("main".to_string()),
3075 })
3076 );
3077 }
3078
3079 #[test]
3080 fn plan_push_native_local_heddle_skips_thread_mismatch() {
3081 let mut req = base_push_request();
3082 req.capability = RepositoryCapability::GitOverlay;
3083 req.thread = Some("feature".to_string());
3084 req.head = attached_head("main");
3085 req.native_local_heddle_target = true;
3086 let plan = plan_push(&req).expect("native local skips overlay thread gate");
3087 assert!(matches!(
3088 plan.path,
3089 PushPath::LocalNativeHeddle { all_threads: false }
3090 ));
3091 assert_eq!(plan.track_name, "feature");
3092 }
3093
3094 #[test]
3095 fn plan_push_hosted_and_fanout_selection_table() {
3096 let cases = [
3098 (
3099 RepositoryCapability::NativeHeddle,
3100 true,
3101 HostedPushPlan::NativePerThreadFanout,
3102 true,
3103 false,
3104 ),
3105 (
3106 RepositoryCapability::GitOverlay,
3107 true,
3108 HostedPushPlan::GitOverlayMirror,
3109 false,
3110 true,
3111 ),
3112 (
3113 RepositoryCapability::GitOverlay,
3114 false,
3115 HostedPushPlan::GitOverlayMirror,
3116 false,
3117 true,
3118 ),
3119 (
3120 RepositoryCapability::NativeHeddle,
3121 false,
3122 HostedPushPlan::NativeSingleThread,
3123 false,
3124 false,
3125 ),
3126 ];
3127 for (capability, all_threads, hosted, fanout, mirror) in cases {
3128 let mut req = base_push_request();
3129 req.capability = capability;
3130 req.all_threads = all_threads;
3131 req.uses_hosted_network = capability == RepositoryCapability::GitOverlay;
3133 let plan = plan_push(&req).expect("plan");
3134 assert_eq!(plan.hosted, hosted, "capability={capability:?}");
3135 assert_eq!(plan.native_all_threads_fanout, fanout);
3136 assert_eq!(plan.uses_git_overlay_mirror_rpc, mirror);
3137 assert!(matches!(
3138 plan.path,
3139 PushPath::NativeRemote {
3140 hosted: h,
3141 uses_mirror_rpc: m,
3142 native_all_threads_fanout: f,
3143 } if h == hosted && m == mirror && f == fanout
3144 ));
3145 }
3146 }
3147
3148 #[test]
3149 fn plan_push_local_overlay_refs_path() {
3150 let mut req = base_push_request();
3151 req.capability = RepositoryCapability::GitOverlay;
3152 req.all_threads = true;
3153 let plan = plan_push(&req).unwrap();
3154 assert!(plan.uses_local_git_overlay);
3155 assert_eq!(
3156 plan.path,
3157 PushPath::LocalGitOverlayRefs { all_threads: true }
3158 );
3159 assert_eq!(plan.track_name, "main");
3160 }
3161
3162 #[test]
3163 fn plan_push_track_name_from_head() {
3164 let mut req = base_push_request();
3165 req.remote = Some("origin".into());
3166 req.head = attached_head("feature");
3167 let plan = plan_push(&req).unwrap();
3168 assert_eq!(plan.track_name, "feature");
3169
3170 req.thread = Some("release".into());
3171 let plan = plan_push(&req).unwrap();
3172 assert_eq!(plan.track_name, "release");
3173 }
3174
3175 #[test]
3176 fn plan_pull_missing_remote() {
3177 let mut req = base_pull_request();
3178 req.remote = None;
3179 req.has_default_remote = false;
3180 assert_eq!(plan_pull(&req), Err(RemotePreflightBlocker::MissingRemote));
3181 }
3182
3183 #[test]
3184 fn plan_pull_transport_mismatch() {
3185 let mut req = base_pull_request();
3186 req.transport_mismatch = true;
3187 assert_eq!(
3188 plan_pull(&req),
3189 Err(RemotePreflightBlocker::TransportMismatch)
3190 );
3191 }
3192
3193 #[test]
3194 fn plan_pull_local_overlay_requires_clean() {
3195 let mut req = base_pull_request();
3196 req.capability = RepositoryCapability::GitOverlay;
3197 req.local_thread = Some("other".into());
3198 let plan = plan_pull(&req).unwrap();
3199 assert!(plan.uses_local_git_overlay);
3200 assert!(!plan.will_materialize);
3202 assert!(plan.requires_clean_worktree);
3203 assert_eq!(plan.remote_thread, "main");
3204 }
3205
3206 #[test]
3207 fn plan_pull_native_materialize_policy() {
3208 let mut req = base_pull_request();
3209 req.head = attached_head("feature");
3210 let plan = plan_pull(&req).unwrap();
3212 assert!(!plan.uses_local_git_overlay);
3213 assert!(!plan.will_materialize);
3214 assert!(!plan.requires_clean_worktree);
3215 assert_eq!(plan.remote_thread, "main");
3216
3217 req.thread = Some("feature".into());
3219 let plan = plan_pull(&req).unwrap();
3220 assert!(plan.will_materialize);
3221 assert!(plan.requires_clean_worktree);
3222
3223 req.local_thread = Some("scratch".into());
3224 let plan = plan_pull(&req).unwrap();
3225 assert!(!plan.will_materialize);
3226 assert!(!plan.requires_clean_worktree);
3227 }
3228
3229 #[test]
3230 fn plan_pull_thread_defaults_table() {
3231 let attached = attached_head("master");
3232 let mut req = base_pull_request();
3234 req.capability = RepositoryCapability::GitOverlay;
3235 req.head = attached.clone();
3236 let plan = plan_pull(&req).unwrap();
3237 assert_eq!(plan.remote_thread, "master");
3238
3239 req.thread = Some("release".into());
3240 let plan = plan_pull(&req).unwrap();
3241 assert_eq!(plan.remote_thread, "release");
3242
3243 req.capability = RepositoryCapability::NativeHeddle;
3245 req.thread = None;
3246 req.head = attached_head("feature");
3247 let plan = plan_pull(&req).unwrap();
3248 assert_eq!(plan.remote_thread, "main");
3249 }
3250
3251 #[test]
3254 fn build_git_overlay_push_outcome_matches_success_json_fields() {
3255 let mut req = base_push_request();
3256 req.capability = RepositoryCapability::GitOverlay;
3257 req.force = true;
3258 req.all_threads = false;
3259 let plan = plan_push(&req).unwrap();
3260 let outcome = build_push_outcome(
3261 &plan,
3262 PushExecutionFacts::GitOverlayRefs {
3263 remote_name: "origin".into(),
3264 current_thread: Some("main".into()),
3265 refs_written: vec!["refs/heads/main".into(), "refs/notes/heddle".into()],
3266 tracking: Some(GitOverlayPushTracking {
3267 remote_name: "origin".into(),
3268 configured_remote: Some(GitRemoteConfigured {
3269 name: "origin".into(),
3270 url: "https://example.com/repo.git".into(),
3271 }),
3272 upstream_branch: Some("main".into()),
3273 }),
3274 },
3275 );
3276 assert_eq!(outcome.output_kind, "push");
3277 assert_eq!(outcome.transport, "git");
3278 assert_eq!(outcome.status, "pushed");
3279 assert!(outcome.success && outcome.pushed && outcome.changed);
3280 assert_eq!(outcome.push_scope, Some("current_thread"));
3281 assert_eq!(outcome.ref_scope, Some("branch_and_heddle_notes"));
3282 assert_eq!(outcome.git_notes_ref, Some(GIT_NOTES_REF));
3283 assert_eq!(outcome.force, Some(true));
3284 assert_eq!(outcome.force_discard_warning, Some(FORCE_DISCARD_WARNING));
3285 assert_eq!(outcome.tags_included, Some(false));
3286 assert_eq!(outcome.thread.as_deref(), Some("main"));
3287 assert_eq!(
3288 outcome.git_upstream_configured,
3289 Some(GitUpstreamConfigured {
3290 branch: "main".into(),
3291 remote: "origin".into(),
3292 })
3293 );
3294 let summary = summarize_push_outcome(&outcome);
3295 assert!(summary.contains("force-pushed"), "{summary}");
3296 assert!(summary.contains("2 refs"), "{summary}");
3297 }
3298
3299 #[test]
3300 fn build_heddle_all_threads_push_outcome_partial_and_sorts_refs() {
3301 let mut req = base_push_request();
3302 req.all_threads = true;
3303 let plan = plan_push(&req).unwrap();
3304 let outcome = build_push_outcome(
3305 &plan,
3306 PushExecutionFacts::HeddleAllThreads {
3307 pushed_threads: vec!["z".into(), "a".into()],
3308 failed_threads: vec!["b".into()],
3309 objects: 4,
3310 },
3311 );
3312 assert_eq!(outcome.status, "partial");
3313 assert!(!outcome.success);
3314 assert!(!outcome.pushed);
3315 assert_eq!(outcome.push_scope, Some("all_threads"));
3316 assert_eq!(
3317 outcome.refs_written.as_deref(),
3318 Some(["a".to_string(), "z".to_string()].as_slice())
3319 );
3320 assert_eq!(outcome.objects, Some(4));
3321 let summary = summarize_push_outcome(&outcome);
3322 assert!(summary.contains("partial"), "{summary}");
3323 }
3324
3325 #[test]
3326 fn build_heddle_single_push_outcome() {
3327 let plan = plan_push(&base_push_request()).unwrap();
3328 let outcome = build_push_outcome(
3329 &plan,
3330 PushExecutionFacts::HeddleSingle {
3331 state: Some("abc123".into()),
3332 objects: Some(7),
3333 },
3334 );
3335 assert_eq!(outcome.transport, "heddle");
3336 assert_eq!(outcome.state.as_deref(), Some("abc123"));
3337 assert_eq!(outcome.objects, Some(7));
3338 assert!(outcome.refs_written.is_none());
3339 assert!(summarize_push_outcome(&outcome).contains("abc123"));
3340 }
3341
3342 #[test]
3343 fn build_git_overlay_and_heddle_pull_outcomes() {
3344 let plan = plan_pull(&base_pull_request()).unwrap();
3345 let git = build_pull_outcome(
3346 Some(&plan),
3347 PullExecutionFacts::GitOverlay {
3348 remote: "origin".into(),
3349 branch: Some("main".into()),
3350 old_git_head: Some("old".into()),
3351 new_git_head: Some("new".into()),
3352 old_state: Some("s0".into()),
3353 new_state: Some("s1".into()),
3354 changed: true,
3355 states_created: 2,
3356 commits_seen: 5,
3357 materialized_checkout: true,
3358 changed_paths: vec!["a.rs".into(), "b.rs".into()],
3359 },
3360 );
3361 assert_eq!(git.status, "updated");
3362 assert_eq!(git.transport, "git");
3363 assert_eq!(git.changed_path_count, Some(2));
3364 assert_eq!(git.commits_seen_scope, Some(COMMITS_SEEN_SCOPE));
3365 assert!(git.pulled && git.changed);
3366 assert!(summarize_pull_outcome(&git).contains("2 changed paths"));
3367
3368 let heddle = build_pull_outcome(
3369 Some(&plan),
3370 PullExecutionFacts::Heddle {
3371 changed: false,
3372 remote: "/tmp/src".into(),
3373 thread: "main".into(),
3374 state: Some("s1".into()),
3375 objects: Some(0),
3376 },
3377 );
3378 assert_eq!(heddle.status, "up_to_date");
3379 assert!(!heddle.pulled);
3380 assert_eq!(heddle.thread.as_deref(), Some("main"));
3381 assert!(summarize_pull_outcome(&heddle).contains("up to date"));
3382 }
3383
3384 #[test]
3385 fn push_and_pull_status_helpers() {
3386 assert_eq!(push_status(true), "pushed");
3387 assert_eq!(push_status(false), "partial");
3388 assert_eq!(pull_status(true), "updated");
3389 assert_eq!(pull_status(false), "up_to_date");
3390 assert_eq!(push_scope_label(true), "all_threads");
3391 assert_eq!(push_scope_label(false), "current_thread");
3392 assert_eq!(
3393 git_overlay_ref_scope(true),
3394 "all_threads_tags_and_heddle_notes"
3395 );
3396 assert_eq!(git_overlay_ref_scope(false), "branch_and_heddle_notes");
3397 }
3398
3399 #[test]
3402 fn push_failure_advice_kinds_map_to_recovery_kinds() {
3403 assert_eq!(
3404 PushFailure::Preflight(RemotePreflightBlocker::MissingRemote).advice_kind(),
3405 remote_advice_kind::REMOTE_NOT_CONFIGURED
3406 );
3407 assert_eq!(
3408 PushFailure::Preflight(RemotePreflightBlocker::TransportMismatch).advice_kind(),
3409 remote_advice_kind::REMOTE_TRANSPORT_MISMATCH
3410 );
3411 assert_eq!(
3412 PushFailure::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch {
3413 requested: "feature".into(),
3414 attached: Some("main".into()),
3415 })
3416 .advice_kind(),
3417 remote_advice_kind::GIT_OVERLAY_THREAD_MISMATCH
3418 );
3419 assert_eq!(
3420 named_thread_tip_mismatch_failure("feat", "aaa", "bbb").advice_kind(),
3421 remote_advice_kind::NAMED_THREAD_TIP_MISMATCH
3422 );
3423 assert_eq!(
3424 PushFailure::RemoteFailed {
3425 track_name: "main".into(),
3426 error: "boom".into(),
3427 }
3428 .advice_kind(),
3429 remote_advice_kind::REMOTE_PUSH_FAILED
3430 );
3431 }
3432
3433 #[test]
3434 fn pull_failure_advice_kinds_map_to_recovery_kinds() {
3435 assert_eq!(
3436 PullFailure::LocalLazyUnsupported {
3437 source_path: "/tmp/src".into(),
3438 }
3439 .advice_kind(),
3440 remote_advice_kind::LOCAL_LAZY_PULL_UNSUPPORTED
3441 );
3442 assert_eq!(
3443 PullFailure::RemoteFailed {
3444 remote_thread: "main".into(),
3445 local_thread: None,
3446 error: "no".into(),
3447 }
3448 .advice_kind(),
3449 remote_advice_kind::REMOTE_PULL_FAILED
3450 );
3451 }
3452
3453 #[test]
3454 fn named_thread_tip_overwrite_guard_table() {
3455 let cases = [
3457 (false, Some("feat"), true, true),
3458 (true, Some("feat"), true, false),
3459 (false, Some("feat"), false, false),
3460 (false, None, true, false),
3461 (false, None, false, false),
3462 ];
3463 for (force, named, differs, refuse) in cases {
3464 assert_eq!(
3465 refuse_named_thread_tip_overwrite(force, named, differs),
3466 refuse,
3467 "force={force} named={named:?} differs={differs}"
3468 );
3469 }
3470 }
3471
3472 #[test]
3473 fn first_multi_thread_push_failure_picks_first() {
3474 assert!(first_multi_thread_push_failure(&[]).is_none());
3475 let failure = first_multi_thread_push_failure(&[
3476 ("a".into(), "e1".into()),
3477 ("b".into(), "e2".into()),
3478 ])
3479 .unwrap();
3480 assert_eq!(
3481 failure,
3482 PushFailure::RemoteFailed {
3483 track_name: "a".into(),
3484 error: "e1".into(),
3485 }
3486 );
3487 }
3488
3489 #[test]
3490 fn transport_error_message_defaults_and_trims() {
3491 assert_eq!(transport_error_message(None), UNKNOWN_TRANSPORT_ERROR);
3492 assert_eq!(transport_error_message(Some("")), UNKNOWN_TRANSPORT_ERROR);
3493 assert_eq!(
3494 transport_error_message(Some(" ")),
3495 UNKNOWN_TRANSPORT_ERROR
3496 );
3497 assert_eq!(transport_error_message(Some(" boom ")), "boom");
3498 }
3499
3500 #[test]
3501 fn remote_push_and_pull_failure_from_transport_errors() {
3502 assert_eq!(
3503 remote_push_failure("main", None),
3504 PushFailure::RemoteFailed {
3505 track_name: "main".into(),
3506 error: UNKNOWN_TRANSPORT_ERROR.into(),
3507 }
3508 );
3509 assert_eq!(
3510 remote_push_failure("feat", Some("refused")),
3511 PushFailure::RemoteFailed {
3512 track_name: "feat".into(),
3513 error: "refused".into(),
3514 }
3515 );
3516 assert_eq!(
3517 remote_pull_failure("main", Some("local"), None),
3518 PullFailure::RemoteFailed {
3519 remote_thread: "main".into(),
3520 local_thread: Some("local".into()),
3521 error: UNKNOWN_TRANSPORT_ERROR.into(),
3522 }
3523 );
3524 assert_eq!(
3525 remote_pull_failure("main", None, Some("gone")),
3526 PullFailure::RemoteFailed {
3527 remote_thread: "main".into(),
3528 local_thread: None,
3529 error: "gone".into(),
3530 }
3531 );
3532 }
3533
3534 #[test]
3535 fn multi_thread_reported_refs_and_execution_facts() {
3536 let failures = [("b".into(), "e".into()), ("c".into(), "e2".into())];
3537 assert_eq!(
3538 multi_thread_failed_names(&failures),
3539 vec!["b".to_string(), "c".to_string()]
3540 );
3541 assert_eq!(
3542 multi_thread_reported_refs(&["z".into(), "a".into()]),
3543 vec!["a".to_string(), "z".to_string()]
3544 );
3545 let facts = multi_thread_push_execution_facts(vec!["z".into(), "a".into()], &failures, 3);
3546 assert_eq!(
3547 facts,
3548 PushExecutionFacts::HeddleAllThreads {
3549 pushed_threads: vec!["z".into(), "a".into()],
3550 failed_threads: vec!["b".into(), "c".into()],
3551 objects: 3,
3552 }
3553 );
3554 let mut req = base_push_request();
3555 req.all_threads = true;
3556 let plan = plan_push(&req).unwrap();
3557 let outcome = build_push_outcome(&plan, facts);
3558 assert_eq!(
3559 outcome.refs_written.as_deref(),
3560 Some(["a".to_string(), "z".to_string()].as_slice())
3561 );
3562 assert_eq!(outcome.status, "partial");
3563 }
3564
3565 #[test]
3566 fn all_threads_mirror_coverage_note_policy() {
3567 assert_eq!(
3568 all_threads_mirror_coverage_note(true),
3569 Some(ALL_THREADS_MIRROR_COVERS_NOTE)
3570 );
3571 assert_eq!(all_threads_mirror_coverage_note(false), None);
3572 }
3573
3574 #[test]
3575 fn hosted_push_result_parse_and_execution_facts() {
3576 let ok = HostedPushResultFields {
3577 success: true,
3578 new_state: Some("s1".into()),
3579 error: None,
3580 };
3581 assert_eq!(
3582 parse_hosted_push_result("main", &ok),
3583 HostedPushResult::Success {
3584 state: Some("s1".into())
3585 }
3586 );
3587 assert_eq!(
3588 heddle_single_push_execution_facts_from_hosted(&ok),
3589 PushExecutionFacts::HeddleSingle {
3590 state: Some("s1".into()),
3591 objects: None,
3592 }
3593 );
3594 let fail = HostedPushResultFields {
3595 success: false,
3596 new_state: None,
3597 error: Some(" refused ".into()),
3598 };
3599 assert_eq!(
3600 parse_hosted_push_result("feat", &fail),
3601 HostedPushResult::Failed(PushFailure::RemoteFailed {
3602 track_name: "feat".into(),
3603 error: "refused".into(),
3604 })
3605 );
3606 let local = LocalTransferSummary {
3607 state: Some("abc".into()),
3608 objects: Some(3),
3609 };
3610 assert_eq!(
3611 heddle_single_push_execution_facts_from_local(&local),
3612 PushExecutionFacts::HeddleSingle {
3613 state: Some("abc".into()),
3614 objects: Some(3),
3615 }
3616 );
3617 }
3618
3619 #[test]
3620 fn hosted_pull_result_parse_and_execution_facts() {
3621 let ok = HostedPullResultFields {
3622 success: true,
3623 final_state: Some("s9".into()),
3624 error: None,
3625 };
3626 assert_eq!(
3627 parse_hosted_pull_result("main", Some("local"), &ok),
3628 HostedPullResult::Success {
3629 final_state: Some("s9".into())
3630 }
3631 );
3632 assert_eq!(
3633 heddle_pull_execution_facts_from_hosted(true, "origin".into(), "main".into(), &ok),
3634 PullExecutionFacts::Heddle {
3635 changed: true,
3636 remote: "origin".into(),
3637 thread: "main".into(),
3638 state: Some("s9".into()),
3639 objects: None,
3640 }
3641 );
3642 let fail = HostedPullResultFields {
3643 success: false,
3644 final_state: None,
3645 error: None,
3646 };
3647 assert_eq!(
3648 parse_hosted_pull_result("main", None, &fail),
3649 HostedPullResult::Failed(PullFailure::RemoteFailed {
3650 remote_thread: "main".into(),
3651 local_thread: None,
3652 error: UNKNOWN_TRANSPORT_ERROR.into(),
3653 })
3654 );
3655 assert!(pull_tip_changed(Some("a"), Some("b")));
3656 assert!(!pull_tip_changed(Some("a"), Some("a")));
3657 assert!(!pull_tip_changed(Some("a"), None));
3658 assert!(local_pull_changed(Some("a"), "a", 1));
3659 assert!(!local_pull_changed(Some("a"), "a", 0));
3660 }
3661
3662 #[test]
3663 fn multi_ref_progress_constructors_and_ref_list() {
3664 assert_eq!(
3665 multi_ref_push_begin("file:///tmp/r"),
3666 MultiRefPushProgress::Begin {
3667 target: "file:///tmp/r".into(),
3668 }
3669 );
3670 let local = multi_ref_thread_succeeded_local("main", Some("abc".into()), Some(2));
3671 assert_eq!(
3672 format_multi_ref_push_progress(&local),
3673 "pushed abc to main (2 objects)"
3674 );
3675 let hosted_fields = HostedPushResultFields {
3676 success: true,
3677 new_state: Some("s1".into()),
3678 error: None,
3679 };
3680 assert_eq!(
3681 multi_ref_progress_from_hosted_thread("feat", &hosted_fields),
3682 multi_ref_thread_succeeded_hosted("feat", Some("s1".into()))
3683 );
3684 let fail_fields = HostedPushResultFields {
3685 success: false,
3686 new_state: None,
3687 error: Some("boom".into()),
3688 };
3689 assert_eq!(
3690 format_multi_ref_push_progress(&multi_ref_progress_from_hosted_thread(
3691 "x",
3692 &fail_fields
3693 )),
3694 "failed to push x: boom"
3695 );
3696 assert_eq!(
3697 format_ref_list(&["b".into(), "a".into()]),
3698 "b, a".to_string()
3699 );
3700 assert_eq!(
3701 format_multi_thread_refs_detail(&["z".into(), "a".into()]).as_deref(),
3702 Some("refs: a, z")
3703 );
3704 assert!(format_multi_thread_refs_detail(&[]).is_none());
3705 }
3706
3707 #[test]
3708 fn working_and_mirror_text_helpers() {
3709 assert_eq!(format_pushing_to("file:///r"), "pushing to file:///r");
3710 assert_eq!(format_pulling_from("file:///s"), "pulling from file:///s");
3711 assert_eq!(
3712 format_connected_to("127.0.0.1:1"),
3713 "connected to 127.0.0.1:1"
3714 );
3715 assert_eq!(format_remote_state_detail("s1"), "remote state: s1");
3716 assert_eq!(format_mirror_success_text("origin"), "mirrored to origin");
3717 assert!(format_mirror_failure_text("m", "e").contains("mirror push to m failed"));
3718 }
3719
3720 #[test]
3721 fn multi_ref_push_progress_formatting() {
3722 assert_eq!(
3723 format_multi_ref_push_progress(&MultiRefPushProgress::Begin {
3724 target: "file:///tmp/r".into(),
3725 }),
3726 "pushing all threads to file:///tmp/r"
3727 );
3728 assert_eq!(
3729 format_multi_ref_push_progress(&MultiRefPushProgress::ThreadSucceeded {
3730 thread: "main".into(),
3731 state_short: Some("abc".into()),
3732 objects: Some(1),
3733 remote_state: None,
3734 }),
3735 "pushed abc to main (1 object)"
3736 );
3737 assert_eq!(
3738 format_multi_ref_push_progress(&MultiRefPushProgress::ThreadSucceeded {
3739 thread: "main".into(),
3740 state_short: Some("abc".into()),
3741 objects: Some(2),
3742 remote_state: None,
3743 }),
3744 "pushed abc to main (2 objects)"
3745 );
3746 assert_eq!(
3747 format_multi_ref_push_progress(&MultiRefPushProgress::ThreadSucceeded {
3748 thread: "feat".into(),
3749 state_short: None,
3750 objects: None,
3751 remote_state: Some("s1".into()),
3752 }),
3753 "pushed to feat (remote state s1)"
3754 );
3755 assert_eq!(
3756 format_multi_ref_push_progress(&MultiRefPushProgress::ThreadFailed {
3757 thread: "x".into(),
3758 error: "nope".into(),
3759 }),
3760 "failed to push x: nope"
3761 );
3762 }
3763
3764 #[test]
3765 fn format_push_outcome_text_git_overlay_details() {
3766 let mut req = base_push_request();
3767 req.capability = RepositoryCapability::GitOverlay;
3768 req.force = true;
3769 let plan = plan_push(&req).unwrap();
3770 let outcome = build_push_outcome(
3771 &plan,
3772 PushExecutionFacts::GitOverlayRefs {
3773 remote_name: "origin".into(),
3774 current_thread: Some("main".into()),
3775 refs_written: vec!["refs/heads/main".into()],
3776 tracking: Some(GitOverlayPushTracking {
3777 remote_name: "origin".into(),
3778 configured_remote: Some(GitRemoteConfigured {
3779 name: "origin".into(),
3780 url: "https://example.com/r.git".into(),
3781 }),
3782 upstream_branch: Some("main".into()),
3783 }),
3784 },
3785 );
3786 let text = format_push_outcome_text(&outcome, None);
3787 assert!(
3788 text.headline.contains("pushed thread main to origin"),
3789 "{}",
3790 text.headline
3791 );
3792 assert!(
3793 text.detail_lines.iter().any(|l| l.starts_with("Force:")),
3794 "{:?}",
3795 text.detail_lines
3796 );
3797 assert!(
3798 text.detail_lines
3799 .iter()
3800 .any(|l| l.contains("refs/notes/heddle")),
3801 "{:?}",
3802 text.detail_lines
3803 );
3804 assert!(
3805 text.detail_lines
3806 .iter()
3807 .any(|l| l.contains("tracks origin/main")),
3808 "{:?}",
3809 text.detail_lines
3810 );
3811 }
3812
3813 #[test]
3814 fn format_pull_outcome_text_up_to_date_and_paths() {
3815 let plan = plan_pull(&base_pull_request()).unwrap();
3816 let up = build_pull_outcome(
3817 Some(&plan),
3818 PullExecutionFacts::Heddle {
3819 changed: false,
3820 remote: "origin".into(),
3821 thread: "main".into(),
3822 state: None,
3823 objects: None,
3824 },
3825 );
3826 let text = format_pull_outcome_text(&up, 8);
3827 assert!(text.headline.contains("already up to date with origin"));
3828
3829 let git = build_pull_outcome(
3830 Some(&plan),
3831 PullExecutionFacts::GitOverlay {
3832 remote: "origin".into(),
3833 branch: Some("main".into()),
3834 old_git_head: None,
3835 new_git_head: None,
3836 old_state: None,
3837 new_state: None,
3838 changed: true,
3839 states_created: 1,
3840 commits_seen: 3,
3841 materialized_checkout: false,
3842 changed_paths: vec!["a".into(), "b".into(), "c".into()],
3843 },
3844 );
3845 let text = format_pull_outcome_text(&git, 2);
3846 assert_eq!(text.headline, "pulled from origin");
3847 assert!(text.detail_lines.iter().any(|l| l == "Changed paths: 3"));
3848 assert!(text.detail_lines.iter().any(|l| l == " - ... 1 more"));
3849 }
3850
3851 #[test]
3852 fn pull_should_materialize_respects_lazy() {
3853 assert!(pull_should_materialize(true, false));
3854 assert!(!pull_should_materialize(true, true));
3855 assert!(!pull_should_materialize(false, false));
3856 assert!(!pull_should_materialize(false, true));
3857 }
3858
3859 #[test]
3860 fn pure_remote_url_and_hosted_path_helpers() {
3861 assert!(looks_like_git_remote_url("https://example.com/r.git"));
3862 assert!(looks_like_git_remote_url("git@github.com:org/r.git"));
3863 assert!(!looks_like_git_remote_url("origin"));
3864 assert!(looks_like_git_forge_remote(
3865 "https://github.com/luke/tiny-notes"
3866 ));
3867 assert!(looks_like_known_git_host(
3868 "https://github.com/luke/tiny-notes"
3869 ));
3870 assert!(looks_like_git_forge_remote(
3871 "https://gitlab.com/org/repo.git"
3872 ));
3873 assert!(looks_like_git_forge_remote("https://example.com/r.git"));
3874 assert!(!looks_like_git_forge_remote("/tmp/remote.git"));
3875 assert!(!looks_like_git_forge_remote("file:///tmp/remote.git"));
3876 assert!(!looks_like_git_forge_remote(
3877 "https://api.heddle.sh/luke/tiny-notes"
3878 ));
3879 assert!(!looks_like_known_git_host(
3880 "https://api.heddle.sh/luke/tiny-notes"
3881 ));
3882 assert!(!looks_like_git_forge_remote(
3883 "heddle://api.heddle.sh/luke/tiny-notes"
3884 ));
3885 assert!(looks_like_remote_location("/tmp/repo"));
3886 assert!(looks_like_remote_location("~/src/repo"));
3887 assert!(looks_like_remote_location("ssh://host/path"));
3888 assert!(!looks_like_remote_location("origin"));
3889 assert!(remote_urls_match("same", "same"));
3890 assert!(message_indicates_already_exists("Spool already exists"));
3891 assert!(!message_indicates_already_exists("not found"));
3892 assert!(hosted_path_contains_internal_user_namespace(
3893 "__users/abc/spool"
3894 ));
3895 assert_eq!(
3896 redact_internal_hosted_paths("fail __users/u1/x more"),
3897 "fail [user namespace] more"
3898 );
3899 assert_eq!(
3900 hosted_spool_display_path("ns", "slug", "__users/u/ns/slug"),
3901 "ns/slug"
3902 );
3903 assert_eq!(
3904 hosted_spool_display_path("ns", "slug", "ns/slug"),
3905 "ns/slug"
3906 );
3907 assert!(!is_native_transport_mismatch(
3908 RepositoryCapability::GitOverlay,
3909 true
3910 ));
3911 assert!(is_native_transport_mismatch(
3912 RepositoryCapability::NativeHeddle,
3913 true
3914 ));
3915 assert!(!is_native_transport_mismatch(
3916 RepositoryCapability::NativeHeddle,
3917 false
3918 ));
3919 }
3920}