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