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