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