Skip to main content

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