Skip to main content

heddle_core/
remote.rs

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