Skip to main content

mkit_cli/remote_dispatch/
mod.rs

1//! URL-scheme → `Transport` dispatch for `mkit push` / `mkit pull`.
2//!
3//! The Rust binary wires all five shipping schemes here: `mkit+file://`,
4//! `mkit+https://` (and `mkit+http://` for local dev), `mkit+s3://`, and
5//! `mkit+ssh://`. The memory transport is in-process only, so it is
6//! reached via [`push_all`] / [`pull_all`] with an `Arc<MemoryTransport>`
7//! constructed in-process rather than URL-based construction. Integration
8//! tests in the `mkit-cli` crate exercise the memory path directly.
9//!
10//! Credentials / environment sources:
11//! - HTTP(S): optional `MKIT_API_TOKEN` bearer.
12//! - S3/R2: `MKIT_R2_ACCESS_KEY_ID` + `MKIT_R2_SECRET_ACCESS_KEY` (plus
13//!   optional `MKIT_R2_REGION`, default `auto`). Missing creds do NOT
14//!   fail at connect time; the first signed request returns
15//!   `TransportError::AccessDenied`.
16//! - SSH: spawns `ssh(1)` subprocess — inherits the user's agent / keys /
17//!   `~/.ssh/config`. Per-repo `.mkit/config` SSH options (host-key
18//!   checking, known-hosts path, identity file) are wired through via
19//!   `SshTransport::connect_with_options` when config is loaded.
20
21// `pub(crate)` so the `remote remove`/`rename` command handlers can drive
22// the record's lifecycle ops (#545); everything else stays module-private.
23pub(crate) mod applied_packs;
24mod envelope_signer;
25mod packmap;
26
27use mkit_core::layout::RepoLayout;
28use std::path::Path;
29use std::sync::Arc;
30
31use applied_packs::AppliedPacks;
32
33use mkit_core::hash::{HASH_LEN, Hash};
34use mkit_core::object::Object;
35use mkit_core::ops::merge::is_ancestor;
36use mkit_core::ops::restore;
37use mkit_core::pack::{self, PackError, PackWriter};
38use mkit_core::protocol::{PackKey, Transport, TransportError};
39use mkit_core::refs::{self, Head};
40use mkit_core::store::{ObjectStore, StoreError};
41use mkit_core::transfer::{self, PackListError};
42use mkit_transport_connect::ConnectTransport;
43use mkit_transport_file::FileTransport;
44use mkit_transport_s3::S3Transport;
45use mkit_transport_ssh::{SshInitError, SshOptions, SshTransport, parse_mkit_ssh_url};
46
47use packmap::{
48    ChainAction, advance_packmap, apply_fetched_chain, commit_head, packmap_ref, probe_chain,
49    rebaseline_depth, resolve_and_download_chain,
50};
51
52const DEFAULT_REMOTE: &str = "default";
53
54/// Errors returned by the push / pull helpers. Mapped to exit codes by
55/// the commands themselves.
56#[derive(Debug, thiserror::Error)]
57pub enum DispatchError {
58    #[error("unsupported URL scheme: {0}")]
59    UnsupportedScheme(String),
60    #[error("malformed URL: {0}")]
61    MalformedUrl(String),
62    #[error("no HEAD branch to push")]
63    NoHead,
64    /// A poll-loop checkpoint observed `signal::is_shutdown() == true`
65    /// and aborted partway through. Callers should map this to
66    /// `exit::TEMPFAIL` (75) so retries are safe — the transfer is
67    /// half-finished but the remote is unmodified for any ref we
68    /// hadn't reached yet.
69    #[error("interrupted")]
70    Interrupted,
71    #[error("transport: {0}")]
72    Transport(#[from] TransportError),
73    #[error("refs: {0}")]
74    Refs(#[from] refs::RefError),
75    #[error("repo lock: {0}")]
76    RepoLock(#[from] mkit_core::repo_lock::LockError),
77    #[error("worktree discovery: {0}")]
78    Discover(#[from] mkit_core::layout::DiscoverError),
79    #[error("io: {0}")]
80    Io(#[from] std::io::Error),
81    #[error("store: {0}")]
82    Store(#[from] StoreError),
83    #[error("pack: {0}")]
84    Pack(#[from] PackError),
85    #[error("packlist: {0}")]
86    PackList(#[from] PackListError),
87    #[error("ssh init: {0}")]
88    SshInit(#[from] SshInitError),
89    #[error("pull requires HEAD to point at a branch")]
90    DetachedHead,
91    #[error("remote branch '{0}' not found")]
92    RemoteBranchMissing(String),
93    #[error("pull would not fast-forward branch '{branch}'; merge or rebase first")]
94    NonFastForwardPull { branch: String },
95    #[error("restore safety: {0}")]
96    RestoreSafety(String),
97    #[error("object is not a commit")]
98    NotCommit,
99    #[error("restore: {0}")]
100    Restore(#[from] restore::RestoreError),
101    /// The per-endpoint credential-trust gate (#97) refused to build a
102    /// credential-bearing transport for a repo-chosen endpoint the user
103    /// has not explicitly trusted. The wrapped string is the actionable
104    /// message produced by [`crate::config::endpoint_credential_trust`].
105    #[error("{0}")]
106    UntrustedRemote(String),
107    /// A CAS ref write was rejected because the remote moved under us
108    /// (non-fast-forward). Callers map this to an actionable
109    /// fetch-then-retry / `--force-with-lease` hint.
110    #[error(
111        "updates were rejected for branch '{branch}' (non-fast-forward); fetch and merge first, or re-run with --force-with-lease / --force"
112    )]
113    NonFastForwardPush { branch: String },
114    /// The branch's packmap pointer could not be durably advanced under
115    /// sustained concurrent pushes. The branch ref was NOT moved, so the
116    /// remote stays consistent; the push is safe to retry.
117    #[error(
118        "could not establish the pack map for branch '{branch}' under concurrent pushes; retry"
119    )]
120    PackmapContended { branch: String },
121    /// A packmap chain was malformed — it exceeded the depth cap, contained
122    /// a cycle, or a node could not be downloaded/decoded. Indicates a
123    /// corrupt or hostile remote.
124    #[error("pack map chain for branch '{branch}' is malformed (too deep, cyclic, or unreadable)")]
125    PackChainInvalid { branch: String },
126    /// The remote advertised a branch ref but no packmap
127    /// (`refs/mkit/packmap/<branch>`) for it. mkit speaks a single,
128    /// packmap-only transfer dialect: the push path ALWAYS advertises a
129    /// packmap before moving the branch ref, so a branch with a tip but no
130    /// packmap is a corrupt/incomplete remote, not a format we degrade to.
131    /// We refuse to fetch rather than silently materialise a partial ref.
132    #[error("remote advertised branch '{0}' but no pack map to reconstruct it")]
133    PackmapMissing(String),
134    /// The packmap advertised a pack the remote does not hold. The branch's
135    /// closure cannot be reconstructed, so the fetch is aborted rather than
136    /// publishing a ref to an incomplete history.
137    #[error("remote advertised pack {pack} for branch '{branch}' but does not hold it")]
138    AdvertisedPackMissing { branch: String, pack: String },
139    /// After unpacking the branch's whole packmap chain, an object
140    /// reachable from the fetched tip is still absent from the local store —
141    /// the chain did not deliver the full closure. This is a pure integrity
142    /// assertion (no recovery download is attempted): the remote's packmap
143    /// is incomplete, so fetch aborts before publishing the ref.
144    #[error("remote is missing object {0} needed to reconstruct the ref")]
145    RemoteMissingObject(String),
146    /// The fetched tip's object closure exceeds the
147    /// [`mkit_core::ops::graph::MAX_REACHABLE`] verification cap, so
148    /// completeness could not be confirmed. On the applied-pack skip path
149    /// (#409) this closure walk is the sole guarantee the local store is
150    /// whole; a truncated walk could silently pass over missing objects, so
151    /// we fail closed rather than publish a ref we can't fully verify. This is
152    /// NOT a self-heal trigger.
153    #[error(
154        "fetched history is too large to verify (closure exceeds the {0}-object cap); refusing to publish an unverified ref"
155    )]
156    ClosureTooLarge(usize),
157    /// A commit/remix/tag newly introduced by this fetch failed Ed25519
158    /// signature verification via [`mkit_core::sign::verify_commit`] /
159    /// `verify_remix` / `verify_tag` — the exact check `mkit verify <rev>`
160    /// runs manually (issue #692). A hostile remote (THREAT-MODEL §3.1) can
161    /// otherwise push an unsigned or forged history that `clone`/`pull`/
162    /// `fetch` would silently materialise. Deliberately distinct from
163    /// [`RemoteMissingObject`](Self::RemoteMissingObject) so it does NOT
164    /// feed the applied-pack self-heal retry (#409): an invalid signature
165    /// is not evidence of local staleness, and clearing the applied-packs
166    /// record would not make a hostile remote's history valid. Fails
167    /// closed by default; opt out with `--no-verify-signatures` or the
168    /// user-scoped `pull.require_signed = false` config (never settable
169    /// from repo-scoped config — see [`crate::config::REPO_FORBIDDEN_KEYS`]).
170    #[error("object {hash} failed signature verification: {reason}")]
171    UnsignedOrInvalidObject { hash: String, reason: String },
172    /// A remote name passed to the applied-packs record (#409) is not a legal
173    /// ref name (per [`mkit_core::refs::validate_ref_name`]). A remote name
174    /// *is* a ref name, so this should never occur for a config-registered
175    /// remote; it is defence-in-depth against a malformed name being used as
176    /// a raw path component under `.mkit/applied-packs/`.
177    #[error("invalid remote name for applied-packs record: '{0}'")]
178    InvalidRemoteName(String),
179}
180
181/// Open a transport for `endpoint` only after the per-endpoint
182/// credential-trust gate (#97) approves it.
183///
184/// This is the single choke point through which push / fetch / pull
185/// (and named-remote callers in #175) MUST build a transport: it runs
186/// [`crate::config::endpoint_credential_trust`] — keyed on the resolved
187/// ENDPOINT and its `repo_chosen` provenance — *before* constructing
188/// the transport, so a credential-bearing HTTP/S3 transport is never
189/// instantiated for a repo-chosen endpoint the user hasn't trusted.
190///
191/// `repo_chosen` is `true` when the endpoint came from repo-scoped
192/// config (the flat `remote_endpoint` or a `remote.<name>.url`),
193/// `false` when it came from the user / an explicit CLI argument. Trust
194/// is per ENDPOINT, never per remote name.
195///
196/// `layout` is needed only to resolve a repo-key-file envelope signer
197/// when `cfg.merged.transport_auth == "envelope"` (see
198/// `envelope_signer_from_config`) — every caller already has it at
199/// hand (it discovered the repo before building `cfg`).
200pub fn open_trusted(
201    endpoint: &str,
202    repo_chosen: bool,
203    cfg: &crate::config::LayeredConfig,
204    layout: &RepoLayout,
205) -> Result<Arc<dyn Transport>, DispatchError> {
206    crate::config::endpoint_credential_trust(cfg, endpoint, repo_chosen)
207        .map_err(DispatchError::UntrustedRemote)?;
208    open_with_config(endpoint, &cfg.merged, layout)
209}
210
211/// The single chokepoint that resolves SSH trust-pinning (issue #389) and
212/// `mkit+https://` envelope-signing config from `cfg` and opens a
213/// transport. Every config-bearing caller — [`open_trusted`] (push /
214/// fetch / pull) and `clone` — routes through here, so both are resolved
215/// and threaded in exactly ONE place. A new remote command physically
216/// cannot forget them as long as it opens through config; the only
217/// un-pinned path is the config-less [`open`], which production never
218/// uses for `ssh` or envelope auth.
219pub(crate) fn open_with_config(
220    url: &str,
221    cfg: &crate::config::Config,
222    layout: &RepoLayout,
223) -> Result<Arc<dyn Transport>, DispatchError> {
224    let envelope_signer = if url.starts_with("mkit+https://") || url.starts_with("mkit+http://") {
225        envelope_signer_from_config(cfg, layout)?
226    } else {
227        None
228    };
229    open_with_ssh_options(url, &ssh_options_from_config(cfg), envelope_signer)
230}
231
232/// Resolve an [`mkit_transport_connect::EnvelopeSigner`] from `cfg`, when
233/// `cfg.transport_auth_envelope()` is set — `Ok(None)` otherwise (the
234/// default: bearer-token-only, unchanged from #700/#701).
235///
236/// Reuses EXACTLY the same signer resolution as `mkit commit`'s
237/// [`crate::commands::commit::load_commit_signer`] (`cfg.signer` ==
238/// `""`/`"legacy"` -> the repo key file at `cfg.signing_key`; `"keystore"`
239/// -> `cfg.key.ed25519_ref_or_fallback()` via `mkit-keystore`) rather than
240/// inventing a parallel key path — the write envelope authenticates with
241/// the SAME Ed25519 identity that already signs the user's commits.
242///
243/// Both signer kinds sign the raw envelope digest directly (no
244/// SPEC-SIGNING commit/remix/tag domain prefix): the legacy path delegates
245/// to the EXISTING `mkit_attest::RepoKeySigner` (its `sign` already signs
246/// the given bytes directly — "the PAE's own `\"DSSEv1 \"` prefix is the
247/// domain separator" per its own doc comment — so no new raw-Ed25519 call
248/// site is needed here), the keystore path via `KeySigner::sign`, whose
249/// own contract already documents "Ed25519 signers return the 64-byte
250/// RFC 8032 signature over `msg`" — i.e. no domain digest applied, exactly
251/// what the envelope needs. See `envelope_signer.rs` for both adapters.
252pub(crate) fn envelope_signer_from_config(
253    cfg: &crate::config::Config,
254    layout: &RepoLayout,
255) -> Result<Option<Arc<dyn mkit_transport_connect::EnvelopeSigner>>, DispatchError> {
256    if !cfg.transport_auth_envelope() {
257        return Ok(None);
258    }
259    let remote_error = |msg: String| DispatchError::Transport(TransportError::RemoteError(msg));
260    match cfg.signer.as_str() {
261        "" | "legacy" => {
262            let key_path =
263                crate::config::resolve_key_path(layout, &cfg.signing_key).map_err(|e| {
264                    remote_error(format!("transport_auth = envelope: signing_key: {e}"))
265                })?;
266            if !key_path.exists() {
267                return Err(remote_error(format!(
268                    "transport_auth = envelope requires a signing key at {} — run `mkit keygen` first",
269                    key_path.display()
270                )));
271            }
272            let kp = mkit_core::sign::load_key(&key_path)
273                .map_err(|e| remote_error(format!("transport_auth = envelope: load key: {e}")))?;
274            Ok(Some(
275                Arc::new(envelope_signer::RepoKeyEnvelopeSigner::new(kp))
276                    as Arc<dyn mkit_transport_connect::EnvelopeSigner>,
277            ))
278        }
279        "keystore" => {
280            let signer = envelope_signer::KeystoreEnvelopeSigner::open(cfg)
281                .map_err(|e| remote_error(format!("transport_auth = envelope: {e}")))?;
282            Ok(Some(
283                Arc::new(signer) as Arc<dyn mkit_transport_connect::EnvelopeSigner>
284            ))
285        }
286        other => Err(remote_error(format!(
287            "transport_auth = envelope: unknown signer `{other}` — expected `legacy` or `keystore`"
288        ))),
289    }
290}
291
292/// Map the three `ssh.*` trust-pinning keys from a merged [`Config`] into
293/// the [`SshOptions`] carried to the spawned `ssh(1)` child. An empty
294/// string means "unset" — `build_ssh_command` emits no flag for it, so
295/// the user's `ssh(1)` defaults are inherited. The producer half of
296/// issue #389 (the consumer half, `build_ssh_command`, wires the fields
297/// into argv). Sole caller is [`open_with_config`].
298fn ssh_options_from_config(cfg: &crate::config::Config) -> SshOptions {
299    SshOptions {
300        strict_host_key_checking: cfg.ssh_strict_host_key_checking.clone(),
301        user_known_hosts_file: cfg.ssh_user_known_hosts_file.clone(),
302        identity_file: cfg.ssh_identity_file.clone(),
303    }
304}
305
306/// Open a transport for the given URL with **no** SSH trust-pinning.
307/// Returns a type-erased `Arc` so callers can treat all schemes
308/// uniformly.
309///
310/// Low-level scheme dispatch only — it neither enforces the credential
311/// gate nor threads `ssh.*` config. Any caller that has a [`Config`]
312/// must use `open_with_config` (directly, or via `open_trusted`) so
313/// the trust-pinning keys reach the spawned `ssh(1)`; `open` stays
314/// public only for file/memory integration tests that have no ambient
315/// config to resolve.
316///
317/// [`Config`]: crate::config::Config
318pub fn open(url: &str) -> Result<Arc<dyn Transport>, DispatchError> {
319    open_with_ssh_options(url, &SshOptions::default(), None)
320}
321
322/// Scheme dispatch with explicit SSH options and an optional `mkit+https://`
323/// / `mkit+http://` envelope signer. Identical to [`open`] for every
324/// non-SSH, non-Connect scheme; the `mkit+ssh://` branch threads
325/// `ssh_options` (issue #389) into the spawned `ssh(1)` child via
326/// [`SshTransport::connect_with_options`], and the `mkit+https://`/
327/// `mkit+http://` branch threads `envelope_signer` (issue #699 follow-up)
328/// into [`ConnectTransport::connect_with_signer`]. Reached only via
329/// [`open`] (no config — both `None`/default) and [`open_with_config`]
330/// (config-derived).
331fn open_with_ssh_options(
332    url: &str,
333    ssh_options: &SshOptions,
334    envelope_signer: Option<Arc<dyn mkit_transport_connect::EnvelopeSigner>>,
335) -> Result<Arc<dyn Transport>, DispatchError> {
336    if url.starts_with("git+") {
337        return Err(DispatchError::UnsupportedScheme(format!(
338            "'{url}' is a git-bridge remote — native push/pull/fetch/clone do not \
339             speak git transports; use `mkit git export` / `mkit git import` / \
340             `mkit git pull` (feature git-bridge)"
341        )));
342    }
343    if let Some(rest) = url.strip_prefix("mkit+file://") {
344        // mkit+file:///abs/path -> /abs/path
345        let path = Path::new(rest);
346        return Ok(Arc::new(FileTransport::new(path)));
347    }
348    if url.starts_with("mkit+memory://") {
349        // Memory transport is in-process; the URL-based path is not
350        // useful on its own but we accept it so `mkit remote add`
351        // round-trips cleanly.
352        return Err(DispatchError::UnsupportedScheme(
353            "mkit+memory:// must be driven via in-process harness (see tests)".to_string(),
354        ));
355    }
356    if url.starts_with("mkit+https://") || url.starts_with("mkit+http://") {
357        // ConnectTransport::connect_with_signer strips the `mkit+` prefix
358        // itself and reads MKIT_API_TOKEN from the environment (mkit#701 —
359        // the native mkit.transport.v1 ConnectRPC client, replacing the
360        // retired mkit-transport-http JSON dialect as of
361        // SPEC-TRANSPORT-CONNECT verb parity). `envelope_signer` is `None`
362        // unless the caller resolved one via `open_with_config` (mkit#699
363        // follow-up: `transport_auth = envelope`) — bearer token and
364        // envelope signing are independent, additive auth modes.
365        let tx = ConnectTransport::connect_with_signer(url, envelope_signer)?;
366        return Ok(Arc::new(tx));
367    }
368    if url.starts_with("mkit+s3://") {
369        // S3Transport::connect reads MKIT_R2_ACCESS_KEY_ID /
370        // MKIT_R2_SECRET_ACCESS_KEY from the environment. Missing
371        // credentials surface as AccessDenied on the first signed call,
372        // not at connect time.
373        let tx = S3Transport::connect(url)?;
374        return Ok(Arc::new(tx));
375    }
376    if url.starts_with("mkit+ssh://") {
377        // Parse the URL, then spawn `ssh(1)` with the caller-supplied
378        // trust-pinning options (issue #389). `connect_with_options`
379        // performs the `Hello` / `HelloResponse` handshake. Any failure
380        // here tears the child down before returning, so callers never
381        // see a half-initialised transport.
382        let target = parse_mkit_ssh_url(url).map_err(SshInitError::from)?;
383        let tx = SshTransport::connect_with_options(&target, ssh_options)?;
384        return Ok(Arc::new(tx));
385    }
386    #[cfg(feature = "enc-transport")]
387    if url.starts_with("mkit+enc://") {
388        return open_enc(url);
389    }
390    Err(DispatchError::MalformedUrl(url.to_string()))
391}
392
393/// `mkit+enc://` dispatch (issue #156).
394///
395/// Parses the URL, derives an ephemeral dialer keypair (keystore
396/// integration is SPEC-TRANSPORT-ENC §6 item 5, still deferred), and
397/// runs the encrypted-stream handshake against the URL-advertised
398/// server public key.
399///
400/// Client identity (issue #178): an allowlisting server pins the
401/// dialer's static ed25519 key. To survive across restarts the client
402/// can supply a STABLE raw-32 key file via the `MKIT_ENC_CLIENT_KEY`
403/// environment variable (a user-scoped / CLI-supplied path — never
404/// repo-local `.mkit/config`, which `open_enc` has no access to anyway).
405/// When the variable is unset we fall back to a fresh ephemeral key per
406/// process, which still works against `--unsafe-allow-any-enc-peer`
407/// servers.
408#[cfg(feature = "enc-transport")]
409const ENC_CLIENT_KEY_ENV: &str = "MKIT_ENC_CLIENT_KEY";
410
411#[cfg(feature = "enc-transport")]
412fn open_enc(url: &str) -> Result<Arc<dyn Transport>, DispatchError> {
413    use mkit_transport_enc::url::parse_enc_url;
414
415    let target = parse_enc_url(url).map_err(DispatchError::Transport)?;
416    let sk = load_or_ephemeral_client_key()?;
417    let tx = mkit_transport_enc::connect_tcp(&target.host, target.port, &target.server_pubkey, sk)
418        .map_err(|e| DispatchError::Transport(TransportError::RemoteError(e.to_string())))?;
419    Ok(Arc::new(tx))
420}
421
422/// Resolve the dialer's static signing key.
423///
424/// If `MKIT_ENC_CLIENT_KEY` points at a raw 32-byte key file, load it
425/// (with the standard `load_raw_32` 0600/owner hardening) so the
426/// client's public key is stable — letting an allowlisting server pin
427/// it across restarts. Otherwise draw a fresh ephemeral key from the
428/// system RNG (≥256 bits) for back-compat with allow-any servers.
429#[cfg(feature = "enc-transport")]
430fn load_or_ephemeral_client_key()
431-> Result<commonware_cryptography::ed25519::PrivateKey, DispatchError> {
432    use commonware_codec::DecodeExt as _;
433    use commonware_cryptography::ed25519::PrivateKey;
434    use zeroize::Zeroizing;
435
436    let map_err = |e: String| DispatchError::Transport(TransportError::RemoteError(e));
437
438    if let Some(path) = std::env::var_os(ENC_CLIENT_KEY_ENV).filter(|s| !s.is_empty()) {
439        let seed = mkit_core::sign::load_raw_32(std::path::Path::new(&path))
440            .map_err(|e| map_err(format!("load {ENC_CLIENT_KEY_ENV}: {e}")))?;
441        return PrivateKey::decode(seed.as_ref())
442            .map_err(|e| map_err(format!("client key construction failed: {e}")));
443    }
444
445    // Ephemeral fallback. Draw 32 bytes from `getrandom`, wrapped in
446    // `Zeroizing` so the stack copy is scrubbed on drop; the resulting
447    // `PrivateKey` carries its own `Secret`-based zeroization.
448    let mut secret = Zeroizing::new([0u8; 32]);
449    getrandom::fill(secret.as_mut()).map_err(|e| map_err(e.to_string()))?;
450    PrivateKey::decode(secret.as_ref()).map_err(|e| map_err(e.to_string()))
451}
452
453/// Push every ref under `refs/heads/` to the remote. Returns the count of
454/// refs pushed. Each branch is published with [`push_branch`], which sends
455/// one delta-compressed pack of the objects the remote lacks, advertises it
456/// via the `refs/mkit/packmap/<branch>` ref, then moves the branch ref.
457pub fn push_all(cwd: &Path, tx: &dyn Transport) -> Result<usize, DispatchError> {
458    push_all_with(cwd, tx, None, false)
459}
460
461/// CAS-aware mirror push (`mkit push --all`). Pushes every local
462/// `refs/heads/*` to the remote, using the remote-tracking ref under
463/// `refs/remotes/<remote>/<branch>` as the CAS lease (Missing when no
464/// tracking ref exists, Match otherwise). `force` upgrades every write
465/// to an unconditional `Any`. On success each pushed branch's
466/// remote-tracking ref is advanced to the pushed tip.
467///
468/// `remote` is the remote NAME used for the local tracking-ref
469/// namespace; `None` means the legacy `default`.
470pub fn push_all_with(
471    cwd: &Path,
472    tx: &dyn Transport,
473    remote: Option<&str>,
474    force: bool,
475) -> Result<usize, DispatchError> {
476    let layout = mkit_core::layout::discover(cwd)?;
477    let store = crate::commands::open_store_configured(&layout)?;
478    let refs_list = refs::list_refs(&layout)?;
479    let remote = remote.unwrap_or(DEFAULT_REMOTE);
480    let mut n = 0;
481    // Batch every pushed branch's remote-tracking-ref write (#645):
482    // publishing lands each ref as soon as its branch's `push_branch`
483    // succeeds (same visibility as before), but the directory fsync that
484    // makes those renames crash-durable is deferred to one pass over the
485    // distinct directories touched, below — instead of once per branch.
486    let mut tracking = refs::RemoteRefBatch::new(&layout, remote)?;
487    let result: Result<(), DispatchError> = (|| {
488        for r in refs_list {
489            if crate::signal::is_shutdown() {
490                return Err(DispatchError::Interrupted);
491            }
492            let Some(h) = r.hash else { continue };
493            let condition = if force {
494                refs::RefWriteCondition::Any
495            } else {
496                match refs::read_remote_ref(&layout, remote, &r.name)? {
497                    Some(tracked) => refs::RefWriteCondition::Match(tracked),
498                    None => refs::RefWriteCondition::Missing,
499                }
500            };
501            push_branch(tx, &store, &r.name, h, condition)?;
502            tracking.write(&r.name, &h)?;
503            n += 1;
504        }
505        Ok(())
506    })();
507    // Commit whatever tracking-ref writes succeeded regardless of how the
508    // loop above ended, so a mid-loop failure still durably publishes the
509    // prefix that already pushed successfully — matching the old
510    // per-branch loop, where each completed ref write was independently
511    // durable before the loop moved to the next branch.
512    tracking.commit()?;
513    result?;
514    Ok(n)
515}
516
517/// True iff advancing a ref from `old` to `new` is a fast-forward (i.e.
518/// `old` is an ancestor of `new`). A missing `old` (brand-new ref) and an
519/// unchanged ref both count as fast-forwards. Used by `push`/`fetch` to
520/// pick the git-style summary symbol (`..` vs `...`/`(forced update)`).
521pub fn is_fast_forward(cwd: &Path, old: Option<Hash>, new: Hash) -> Result<bool, DispatchError> {
522    match old {
523        None => Ok(true),
524        Some(o) if o == new => Ok(true),
525        Some(o) => {
526            let layout = mkit_core::layout::discover(cwd)?;
527            let store = crate::commands::open_store_configured(&layout)?;
528            Ok(is_ancestor(&store, o, new)?)
529        }
530    }
531}
532
533/// CAS lease policy for a default (current-branch → upstream) push.
534#[derive(Debug, Clone, Copy)]
535pub enum PushLease {
536    /// Force — unconditional `Any`.
537    Force,
538    /// `--force-with-lease` — require the remote tip to equal the local
539    /// remote-tracking ref (Match), or Missing when there is none.
540    /// Identical mechanism to the default safe push; semantically it is
541    /// the explicit, opt-in form that overwrites a fast-forward-failing
542    /// branch *only* if the remote hasn't moved past what we last saw.
543    WithLease,
544    /// Default safe push: Match the local remote-tracking ref, or
545    /// Missing when absent (first push of this branch).
546    FastForward,
547}
548
549/// Resolve the CAS condition for a single-branch push from the local
550/// remote-tracking ref `refs/remotes/<remote>/<branch>` and the lease
551/// policy.
552pub fn lease_condition(
553    cwd: &Path,
554    remote: &str,
555    branch: &str,
556    lease: PushLease,
557) -> Result<refs::RefWriteCondition, DispatchError> {
558    if matches!(lease, PushLease::Force) {
559        return Ok(refs::RefWriteCondition::Any);
560    }
561    let layout = mkit_core::layout::discover(cwd)?;
562    Ok(match refs::read_remote_ref(&layout, remote, branch)? {
563        Some(tracked) => refs::RefWriteCondition::Match(tracked),
564        None => refs::RefWriteCondition::Missing,
565    })
566}
567
568/// Push the current branch to its upstream and, on success, advance the
569/// local remote-tracking ref `refs/remotes/<remote>/<branch>` to the
570/// pushed tip.
571///
572/// `remote` is the upstream remote NAME (for the tracking-ref
573/// namespace); `branch` is the local branch name; `remote_branch` is the
574/// branch name on the remote (`refs/heads/<remote_branch>`).
575pub fn push_branch_tracked(
576    cwd: &Path,
577    tx: &dyn Transport,
578    remote: &str,
579    branch: &str,
580    remote_branch: &str,
581    lease: PushLease,
582) -> Result<Hash, DispatchError> {
583    let layout = mkit_core::layout::discover(cwd)?;
584    let store = crate::commands::open_store_configured(&layout)?;
585    let tip = refs::read_ref(&layout, branch)?
586        .ok_or_else(|| DispatchError::RemoteBranchMissing(branch.to_owned()))?;
587    // Default safe push requires a TRUE fast-forward: the new tip must
588    // descend from the last-seen remote-tracking ref. The CAS `Match`
589    // lease alone only proves the remote hasn't moved since we last
590    // fetched — on its own it would still let a divergent local tip
591    // (e.g. after a local `reset` to an unrelated commit) overwrite the
592    // remote, which Git rejects as non-fast-forward. `--force-with-lease`
593    // (`WithLease`) intentionally skips this check (overwrite as long as
594    // the remote matches what we last saw); `Force` skips everything.
595    if matches!(lease, PushLease::FastForward)
596        && let Some(tracked) = refs::read_remote_ref(&layout, remote, remote_branch)?
597        && !is_ancestor(&store, tracked, tip)?
598    {
599        return Err(DispatchError::NonFastForwardPush {
600            branch: remote_branch.to_owned(),
601        });
602    }
603    let condition = lease_condition(cwd, remote, remote_branch, lease)?;
604    push_branch(tx, &store, remote_branch, tip, condition)?;
605    refs::write_remote_ref(&layout, remote, remote_branch, &tip)?;
606    Ok(tip)
607}
608
609/// Push one branch: upload one or more delta-compressed packs carrying
610/// every object reachable from `tip` that the remote lacks — split
611/// across multiple packs when the plan's payload exceeds a single
612/// pack's cap (issue #831) — durably advertise them as one node on the
613/// `refs/mkit/packmap/<branch>` metadata ref, then CAS-write
614/// `refs/heads/<branch>` under `condition`.
615///
616/// Objects already present at the remote's current tip are never re-sent
617/// (identical-object dedup), and changed `FastCDC` chunks are delta-encoded
618/// against the prior version the remote already holds when that saves
619/// bytes (see [`mkit_core::transfer::plan_pack`]). The pack is keyed by its
620/// own BLAKE3 digest (SPEC-PACKFILE §7) — required because the digest-
621/// checking storage server rejects a delta stored under the reconstructed
622/// object's hash.
623///
624/// The packmap is advanced *and confirmed* before the branch ref moves: if
625/// the packmap can't be durably established the push aborts without
626/// touching the head, so the head never points past a packmap that fails to
627/// reconstruct it (even under concurrent pushers to the same branch).
628///
629/// Plans the pack FIRST (diffing against the remote's current tip). A no-op
630/// push (empty plan — the remote already holds this closure) takes the cheap
631/// head-only path and walks NO packmap chain (mkit #521 perf). Only when the
632/// plan is non-empty does it resolve the branch's current packmap chain depth
633/// (walking it exactly once, see `packmap::probe_chain`) and, if the chain
634/// would grow past the re-baseline threshold (#406, see
635/// `packmap::rebaseline_depth`) AND the transport's `advance_refs` is
636/// transactional ([`Transport::supports_atomic_advance`], mkit #521) AND the
637/// head write is CAS-conditioned (a force push's `Any` head condition takes
638/// the safe append path — an `Any` condition makes even an atomic transport
639/// fall back to the ordered two-PUT `advance_refs`, so a reset there is not
640/// safe), re-plans as a full closure (diffs against no remote tip) and
641/// carries that decision down to `advance_packmap` as
642/// `ChainAction::ResetSelfContained` so it resets the chain to a single
643/// fresh node instead of appending to it — bounding clone cost, which
644/// otherwise grows with chain length.
645///
646/// On a transport WITHOUT transactional `advance_refs` (the default used by
647/// file/S3/SSH/memory), crossing the threshold never triggers a reset: the
648/// default `advance_refs` commits the packmap write before the head CAS, and
649/// a reset (unlike an append) is not a superset of the prior chain, so a
650/// lost head-CAS race after a committed reset would strand the (unmoved)
651/// head pointing at a commit the packmap can no longer reconstruct. Such a
652/// transport keeps appending — `ChainAction::Append` — past the
653/// threshold; `packmap::MAX_PACK_CHAIN_DEPTH` (the pure runaway/cycle guard)
654/// remains the only bound on chain growth there, unchanged by this gate.
655///
656/// A chain read that fails with [`DispatchError::PackChainInvalid`], or a
657/// missing prior packmap (first push), is left alone here — depth is only
658/// defined for a resolvable chain, and a broken chain already has its own
659/// reset path in `advance_packmap` (the broken-chain escape hatch, gated on
660/// `self_contained` alone, independent of this transactional-advance gate —
661/// see `ChainAction::Append`'s doc comment).
662///
663/// The already-resolved chain from this probe (when not discarded by a
664/// re-baseline decision) is threaded into `advance_packmap` so its first
665/// CAS attempt does not have to walk the chain a second time (#521 perf
666/// fix).
667///
668/// On a CAS failure ([`TransportError::RefConflict`]) this returns
669/// [`DispatchError::NonFastForwardPush`] so callers can render an
670/// actionable fetch-then-retry hint. Does NOT touch local
671/// remote-tracking refs — the caller decides when to advance them.
672pub fn push_branch(
673    tx: &dyn Transport,
674    store: &ObjectStore,
675    branch: &str,
676    tip: Hash,
677    condition: refs::RefWriteCondition,
678) -> Result<(), DispatchError> {
679    push_branch_with_depth(tx, store, branch, tip, condition, rebaseline_depth())
680}
681
682/// [`push_branch`] with an explicit re-baseline threshold in place of the
683/// configured one (`packmap::rebaseline_depth`: the
684/// `MKIT_PACK_REBASELINE_DEPTH` env var, default 64). `0` disables
685/// re-baselining. Semantics are otherwise identical — see [`push_branch`].
686///
687/// This is the depth-policy seam (#547): integration tests inject a small
688/// threshold (e.g. 3) to exercise the re-baseline path in-process with a
689/// handful of pushes, where reaching the default threshold would take ~64
690/// real pushes and the env-var override cannot be set on the test's own
691/// process (`std::env::set_var` is banned by `clippy::disallowed_methods` —
692/// it races other threads on POSIX).
693pub fn push_branch_with_depth(
694    tx: &dyn Transport,
695    store: &ObjectStore,
696    branch: &str,
697    tip: Hash,
698    condition: refs::RefWriteCondition,
699    rebaseline_threshold: usize,
700) -> Result<(), DispatchError> {
701    push_branch_with_limits(
702        tx,
703        store,
704        branch,
705        tip,
706        condition,
707        rebaseline_threshold,
708        pack::MAX_TOTAL_PAYLOAD,
709    )
710}
711
712/// [`push_branch_with_depth`] with an explicit per-pack payload cap in
713/// place of the format's hardcoded [`pack::MAX_TOTAL_PAYLOAD`] (issue
714/// #831). Semantics are otherwise identical — see [`push_branch`].
715///
716/// This is the payload-cap test seam, mirroring the #547
717/// `rebaseline_threshold` pattern: integration tests inject a tiny cap
718/// (a few KiB) to exercise multi-pack splitting in-process without
719/// moving a multi-GiB plan through it.
720pub fn push_branch_with_limits(
721    tx: &dyn Transport,
722    store: &ObjectStore,
723    branch: &str,
724    tip: Hash,
725    condition: refs::RefWriteCondition,
726    rebaseline_threshold: usize,
727    pack_payload_cap: u64,
728) -> Result<(), DispatchError> {
729    // Diff against the remote's CURRENT tip so we send only what it lacks
730    // and can delta against bases it already holds. Planning is an
731    // optimization; the head CAS below remains authoritative.
732    let remote_tip = tx.read_ref(&format!("refs/heads/{branch}"))?;
733
734    // Plan FIRST, against the remote's current tip (#521 perf): a no-op push
735    // (the remote already holds this closure) yields an empty plan and takes
736    // the cheap head-only path below WITHOUT walking the packmap chain. Only
737    // a push that actually has objects to send pays the O(depth) chain probe.
738    let mut plan = transfer::plan_pack(store, tip, remote_tip)?;
739
740    if plan.is_empty() {
741        // Nothing to send — the remote already holds the closure; just move
742        // the head (no packmap change needed, so no chain walk and no atomic
743        // two-ref advance).
744        return commit_head(tx, &format!("refs/heads/{branch}"), condition, &tip, branch);
745    }
746
747    if crate::signal::is_shutdown() {
748        return Err(DispatchError::Interrupted);
749    }
750
751    // Re-baseline decision (#406/#521), made only now that we know there IS
752    // something to send: walk the current chain exactly once and decide
753    // whether this push should reset the chain to a single self-contained
754    // node instead of appending. When NOT re-baselining, the walk is cached
755    // (`resolved_chain`) so `advance_packmap`'s append path can reuse it
756    // instead of re-walking.
757    //
758    // A reset is committed together with the head; the ordered (non-atomic)
759    // `advance_refs` fallback (packmap PUT then head PUT) would strand the
760    // head at the old tip on a torn write, and a reset — unlike an append —
761    // is not a superset of the prior chain, so it can't rebuild that stranded
762    // closure. We therefore re-baseline ONLY when BOTH hold:
763    //   * the transport advances both refs transactionally
764    //     (`supports_atomic_advance`), AND
765    //   * the head write is CAS-conditioned (not `Any`). A force push's `Any`
766    //     head condition makes even an atomic transport fall back to the
767    //     ordered two-PUT path (`Any` is not expressible on the atomic
768    //     endpoint — see `HttpTransport::supports_atomic_advance`), so a
769    //     force push MUST take the safe append path instead of resetting.
770    let mut rebaseline = false;
771    let mut resolved_chain = None;
772    if rebaseline_threshold > 0
773        && let Some(pm) = tx.read_ref(&packmap_ref(branch))?
774    {
775        match probe_chain(tx, branch, pm) {
776            Ok(chain)
777                if chain.depth + 1 > rebaseline_threshold
778                    && tx.supports_atomic_advance()
779                    && !matches!(condition, refs::RefWriteCondition::Any) =>
780            {
781                rebaseline = true;
782            }
783            Ok(chain) => resolved_chain = Some(chain),
784            Err(DispatchError::PackChainInvalid { .. }) => {}
785            Err(e) => return Err(e),
786        }
787    }
788
789    if rebaseline {
790        // Force a full-closure plan: no external bases, so the pack is
791        // self-contained and safe to reset the chain onto.
792        plan = transfer::plan_pack(store, tip, None)?;
793    }
794
795    // Build the plan into one or more payload-bounded packs (splitting
796    // when the plan exceeds `pack_payload_cap`, issue #831) and upload
797    // each as it's sealed. Raws first (non-blobs before blobs), then
798    // deltas (their bases are external — resolved from the fetcher's
799    // store via earlier packs, never a base introduced earlier in THIS
800    // push — so no in-pack ordering is required across the split,
801    // SPEC-PACKFILE §4).
802    let pack_keys = build_and_upload_packs(tx, store, &plan, pack_payload_cap)?;
803
804    // Chain the pack(s) onto the packmap AND move the head together
805    // (#408): a transactional transport applies both atomically, the
806    // default does packmap-then-head. Either way the head never lands
807    // past a packmap that can't reconstruct it. `Append`'s
808    // `self_contained` lets a full-closure push reset a broken chain
809    // (unconditionally, on any transport); `ResetSelfContained`
810    // proactively resets a healthy chain that has grown too deep, and
811    // is only ever chosen above when the transport is atomic-capable
812    // AND the head write is CAS-conditioned. A failed advance leaves
813    // the head untouched.
814    let action = if rebaseline {
815        ChainAction::ResetSelfContained
816    } else {
817        ChainAction::Append {
818            self_contained: plan.self_contained,
819        }
820    };
821    advance_packmap(
822        tx,
823        branch,
824        &pack_keys,
825        action,
826        resolved_chain,
827        condition,
828        tip,
829    )
830}
831
832/// Build `plan`'s entries into one or more packs, each staying under
833/// `payload_cap` bytes of wire payload, uploading each pack to `tx` as
834/// soon as it's sealed. Returns the ordered pack keys — build order is
835/// apply order, threaded straight into [`advance_packmap`].
836///
837/// A single linear left-to-right pass over the plan's already-ordered
838/// `raw ++ deltas` sequence is enough: [`transfer::plan_pack`] never
839/// deltas an entry against a base introduced earlier in THIS push
840/// (every delta's base is already on the remote from a prior push), so
841/// packs can be sealed independently the instant one would exceed the
842/// cap — no intra-push base-ordering hazard to preserve across the
843/// split.
844///
845/// Sizing uses a conservative *uncompressed* upper bound per entry
846/// (`bytes.len()` for a raw, `HASH_LEN + stream.len()` for a delta)
847/// checked against [`PackWriter::total_payload`]'s real (compressed)
848/// running total, so a sealed pack never exceeds `payload_cap` — it may
849/// under-fill when compression bites, yielding more packs than the
850/// theoretical minimum, never fewer. Peak memory is one pack buffer
851/// (bounded by `payload_cap`), not every pack held at once.
852///
853/// The caller only reaches this with a non-empty `plan` (an empty plan
854/// takes the head-only fast path before this is called), so the final
855/// seal always has at least one entry and this always returns at least
856/// one key.
857fn build_and_upload_packs(
858    tx: &dyn Transport,
859    store: &ObjectStore,
860    plan: &transfer::PackPlan,
861    payload_cap: u64,
862) -> Result<Vec<Hash>, DispatchError> {
863    let mut pack_keys = Vec::new();
864    let mut w = PackWriter::new();
865
866    for h in &plan.raw {
867        let bytes = store.read(h)?;
868        if should_seal(&w, bytes.len() as u64, payload_cap) {
869            seal_pack(tx, &mut w, &mut pack_keys)?;
870        }
871        w.push_raw(*h, &bytes)?;
872        // Honest progress (#711): one real object just got staged into
873        // the outgoing pack. Never git's fabricated
874        // Enumerating/Counting/Compressing lines — see `crate::progress`.
875        crate::progress::report(crate::progress::Event::ObjectsPacked(1));
876    }
877    for d in &plan.deltas {
878        let bound = (HASH_LEN + d.stream.len()) as u64;
879        if should_seal(&w, bound, payload_cap) {
880            seal_pack(tx, &mut w, &mut pack_keys)?;
881        }
882        w.push_delta(&d.base, &d.stream)?;
883        crate::progress::report(crate::progress::Event::ObjectsPacked(1));
884    }
885
886    seal_pack(tx, &mut w, &mut pack_keys)?;
887    Ok(pack_keys)
888}
889
890/// Would pushing an entry of (conservative, uncompressed) size
891/// `add_bound` into `w` exceed `payload_cap`? Never true for an empty
892/// writer — a single entry over the cap (only reachable with a
893/// test-injected tiny cap; production entries are bounded well under
894/// [`pack::MAX_TOTAL_PAYLOAD`] by [`mkit_core::store::MAX_RAW_OBJECT_SIZE`])
895/// lands alone in its own pack rather than looping forever.
896fn should_seal(w: &PackWriter, add_bound: u64, payload_cap: u64) -> bool {
897    w.entry_count() > 0 && w.total_payload().saturating_add(add_bound) > payload_cap
898}
899
900/// Finish `w`, upload it, record its key, and replace `w` with a fresh
901/// empty writer so the caller can keep pushing entries into the next
902/// pack.
903fn seal_pack(
904    tx: &dyn Transport,
905    w: &mut PackWriter,
906    pack_keys: &mut Vec<Hash>,
907) -> Result<(), DispatchError> {
908    if crate::signal::is_shutdown() {
909        return Err(DispatchError::Interrupted);
910    }
911    let sealed = std::mem::replace(w, PackWriter::new());
912    let pack = sealed.finish()?;
913    let pack_key = pack::pack_key(&pack);
914    tx.upload_pack(&pack, &PackKey::from_hash(pack_key))?;
915    // Upload is complete — report the real byte count handed to the
916    // transport, not an estimate.
917    crate::progress::report(crate::progress::Event::PackUploaded(pack.len() as u64));
918    pack_keys.push(pack_key);
919    Ok(())
920}
921
922/// [`pull_all_with`] with signature verification on — the CLI's default
923/// (issue #692). Existing in-process callers (the integration-test suite)
924/// that construct only validly-signed histories are unaffected.
925pub fn pull_all(
926    cwd: &Path,
927    tx: &dyn Transport,
928    remote: &str,
929    target_branch: Option<&str>,
930) -> Result<usize, DispatchError> {
931    pull_all_with(cwd, tx, remote, target_branch, true)
932}
933
934/// Fetch remote refs, then fast-forward the current local branch from
935/// `refs/remotes/default/<branch>`. Fresh repos with no local branch tip
936/// initialise from the current branch's remote-tracking ref, or the first
937/// advertised remote branch when the current default branch is absent.
938///
939/// `target_branch`, when `Some`, overrides which remote branch to land
940/// on (used by `mkit clone -b <branch>`): the branch MUST exist among
941/// the remote's advertised refs or the call fails with
942/// [`DispatchError::RemoteBranchMissing`] rather than silently falling
943/// back to another branch. `None` preserves the historical HEAD-driven
944/// selection used by plain `pull`.
945///
946/// `require_signed` gates the post-fetch commit/remix/tag signature check
947/// (issue #692) — `true` (the CLI's default, see [`pull_all`]) verifies
948/// every newly-fetched object and fails closed; `false` is the explicit
949/// `--no-verify-signatures` / `pull.require_signed = false` opt-out.
950pub fn pull_all_with(
951    cwd: &Path,
952    tx: &dyn Transport,
953    remote: &str,
954    target_branch: Option<&str>,
955    require_signed: bool,
956) -> Result<usize, DispatchError> {
957    let layout = mkit_core::layout::discover(cwd)?;
958    let store = crate::commands::open_store_configured(&layout)?;
959    // Fetch phase: `fetch_objects` takes the repo lock itself, narrowly and
960    // per branch, around only the local unpack + remote-ref-publish window
961    // (#642 — see `packmap::apply_fetched_chain`). No lock is held here
962    // across the network transfer.
963    let n = fetch_objects(&store, &layout, tx, remote, require_signed)?;
964    let remote_refs = refs::list_remote_refs(&layout, remote)?
965        .into_iter()
966        .filter_map(|r| r.hash.map(|hash| (r.name, hash)))
967        .collect::<Vec<_>>();
968    if remote_refs.is_empty() {
969        return Ok(n);
970    }
971
972    // Fast-forward phase (#642): branch ref + HEAD + worktree, narrowly
973    // locked around just this window rather than bundled with the fetch
974    // above. The objects `remote_tip` points at are already reachable via
975    // the remote-tracking ref published during the fetch phase, so this
976    // lock's job here is worktree-mutation exclusivity against concurrent
977    // commands (e.g. a racing `commit`/`checkout`/`reset`), not GC
978    // protection — that hazard was already closed before this lock was
979    // taken.
980    let _lock = mkit_core::repo_lock::acquire_default(
981        layout.worktree_state_dir(),
982        crate::commands::WORKTREE_LOCK,
983    )?;
984    let original_head = refs::read_head(&layout).ok();
985    let (branch, local_tip, remote_tip) = match &original_head {
986        Some(Head::Branch(head_branch)) => {
987            let want_branch = target_branch.unwrap_or(head_branch.as_str());
988            let local_tip = refs::read_ref(&layout, want_branch)?;
989            let selected = if local_tip.is_some() || target_branch.is_some() {
990                // An explicit `-b <branch>` (or an already-committed local
991                // branch of that name) must match exactly — no silent
992                // fallback to a different branch.
993                remote_refs
994                    .iter()
995                    .find(|(name, _)| name == want_branch)
996                    .ok_or_else(|| DispatchError::RemoteBranchMissing(want_branch.to_owned()))?
997            } else {
998                remote_refs
999                    .iter()
1000                    .find(|(name, _)| name == want_branch)
1001                    .unwrap_or(&remote_refs[0])
1002            };
1003            (selected.0.clone(), local_tip, selected.1)
1004        }
1005        Some(Head::Detached(_)) => return Err(DispatchError::DetachedHead),
1006        None => (remote_refs[0].0.clone(), None, remote_refs[0].1),
1007    };
1008
1009    let ref_condition = if let Some(local_tip) = local_tip {
1010        if local_tip == remote_tip {
1011            return Ok(n);
1012        }
1013        if !is_ancestor(&store, local_tip, remote_tip)? {
1014            return Err(DispatchError::NonFastForwardPull { branch });
1015        }
1016        refs::RefWriteCondition::Match(local_tip)
1017    } else {
1018        refs::RefWriteCondition::Missing
1019    };
1020
1021    let tree = load_tree_hash(&store, remote_tip)?;
1022    crate::commands::ensure_restore_safe(&layout, &store, tree)
1023        .map_err(DispatchError::RestoreSafety)?;
1024    crate::commands::write_ref_recording_history(&layout, &branch, ref_condition, &remote_tip)?;
1025    if let Err(e) = refs::write_head_branch(&layout, &branch) {
1026        rollback_pull_ref(&layout, &branch, local_tip, remote_tip)?;
1027        return Err(e.into());
1028    }
1029    if let Err(e) = crate::commands::restore_worktree_and_index(&layout, &store, tree) {
1030        if let Err(rollback) =
1031            rollback_pull_ref_and_head(&layout, &branch, local_tip, remote_tip, original_head)
1032        {
1033            return Err(DispatchError::RestoreSafety(format!(
1034                "{e}; additionally failed to roll back ref: {rollback}"
1035            )));
1036        }
1037        return Err(DispatchError::RestoreSafety(e));
1038    }
1039    Ok(n)
1040}
1041
1042fn rollback_pull_ref_and_head(
1043    layout: &RepoLayout,
1044    branch: &str,
1045    local_tip: Option<Hash>,
1046    remote_tip: Hash,
1047    original_head: Option<Head>,
1048) -> Result<(), String> {
1049    rollback_pull_ref(layout, branch, local_tip, remote_tip).map_err(|e| e.to_string())?;
1050    match original_head {
1051        Some(Head::Branch(name)) => refs::write_head_branch(layout, &name),
1052        Some(Head::Detached(hash)) => refs::write_head_detached(layout, &hash),
1053        None => Ok(()),
1054    }
1055    .map_err(|e| e.to_string())
1056}
1057
1058fn rollback_pull_ref(
1059    layout: &RepoLayout,
1060    branch: &str,
1061    local_tip: Option<Hash>,
1062    remote_tip: Hash,
1063) -> Result<(), refs::RefError> {
1064    if let Some(local_tip) = local_tip {
1065        crate::commands::write_ref_recording_history(
1066            layout,
1067            branch,
1068            refs::RefWriteCondition::Match(remote_tip),
1069            &local_tip,
1070        )
1071    } else if refs::read_ref(layout, branch)? == Some(remote_tip) {
1072        refs::delete_ref(layout, branch)
1073    } else {
1074        Ok(())
1075    }
1076}
1077
1078/// [`fetch_all_with`] with signature verification on — the CLI's default
1079/// (issue #692). Existing in-process callers (the integration-test suite)
1080/// that construct only validly-signed histories are unaffected.
1081pub fn fetch_all(cwd: &Path, tx: &dyn Transport, remote: &str) -> Result<usize, DispatchError> {
1082    fetch_all_with(cwd, tx, remote, true)
1083}
1084
1085/// `fetch` — `pull_all` without the HEAD update. Downloads every object
1086/// reachable from each remote ref (via [`Transport::download_pack`] on
1087/// the object's own digest) and writes the ref into
1088/// `refs/remotes/default/<branch>`.
1089///
1090/// See [`pull_all_with`] for the `require_signed` contract (issue #692).
1091pub fn fetch_all_with(
1092    cwd: &Path,
1093    tx: &dyn Transport,
1094    remote: &str,
1095    require_signed: bool,
1096) -> Result<usize, DispatchError> {
1097    let layout = mkit_core::layout::discover(cwd)?;
1098    // No outer lock here (#642): `fetch_objects` takes the repo lock
1099    // itself, narrowly and per branch, around only the local unpack +
1100    // remote-ref-publish window for that branch — never across the
1101    // network transfer. See `packmap::resolve_and_download_chain` /
1102    // `apply_fetched_chain` and `fetch_objects_inner` below.
1103    let store = crate::commands::open_store_configured(&layout)?;
1104    fetch_objects(&store, &layout, tx, remote, require_signed)
1105}
1106
1107/// Reconstruct every remote `refs/heads/*` from its packmap chain and
1108/// publish the remote-tracking refs. Each branch's local object writes and
1109/// its ref publish happen under a repo lock acquired fresh for that branch
1110/// (#642) — see [`fetch_objects_inner`] — so the caller does NOT need to
1111/// hold the repo lock around this call.
1112///
1113/// mkit speaks a single, packmap-only transfer dialect (the legacy
1114/// per-object download path was removed): for every advertised branch the
1115/// flow is exactly
1116///
1117/// 1. read the branch's packmap ref (`refs/mkit/packmap/<branch>`),
1118/// 2. walk its chain oldest-first and download any pack the local
1119///    applied-pack record doesn't already have
1120///    ([`packmap::resolve_and_download_chain`]) — no repo lock held,
1121/// 3. acquire the repo lock, unpack the downloaded packs
1122///    ([`packmap::apply_fetched_chain`]), then
1123/// 4. assert the tip's closure is fully present
1124///    ([`verify_closure_present`]) — a pure integrity check that downloads
1125///    nothing (still under the lock from step 3), then publish the
1126///    branch's remote-tracking ref and release the lock (#642).
1127///
1128/// Steps 3 and 4 are both performed *inside* [`packmap::apply_fetched_chain`]
1129/// (rather than sequenced here) so a closure-completeness failure counts
1130/// toward that function's applied-pack self-heal retry (#409): if the
1131/// local record wrongly claims every pack in the chain is already applied
1132/// (e.g. `.mkit/objects` was wiped out-of-band while `applied-packs/`
1133/// survived), the very first symptom is exactly this closure check
1134/// failing, not a download/unpack error — the retry has to cover both.
1135///
1136/// Both ends fail loudly: an absent packmap is [`DispatchError::PackmapMissing`]
1137/// and a present-but-incomplete packmap (even after the self-heal retry) is
1138/// [`DispatchError::RemoteMissingObject`]. We never publish a
1139/// remote-tracking ref to a closure we couldn't fully materialise locally.
1140///
1141/// # Concurrent re-baseline (mkit #521)
1142///
1143/// We list branch tips (`list_refs`) BEFORE reading each branch's packmap,
1144/// so a concurrent push that re-baselines (resets the packmap to a fresh
1145/// single node, #406) between those two reads can leave us verifying an
1146/// OLD tip `h` against a packmap whose closure only covers the NEW tip —
1147/// surfacing as [`DispatchError::RemoteMissingObject`] (the reset chain
1148/// isn't a superset of the prior one, and the applied-pack self-heal can't
1149/// rescue a genuinely stale tip). This is transient — no bad ref was
1150/// published — so on that specific error we re-read the branch's CURRENT tip
1151/// and packmap and retry the chain once with the fresh pair, publishing the
1152/// fresh tip. A second failure (or a vanished branch) propagates unchanged.
1153///
1154/// # Applied-packs record: load once, persist once (mkit #546)
1155///
1156/// The applied-pack record (`<common dir>/applied-packs/<remote>`, #409) is
1157/// keyed by remote, not by branch, so this function — not
1158/// [`packmap::resolve_and_download_chain`] / [`packmap::apply_fetched_chain`]
1159/// — owns its lifecycle for the WHOLE fetch: loaded once before the branch
1160/// loop, persisted once after, however many branches are fetched. Those two
1161/// functions only mutate the record in memory (inserting applied digests,
1162/// or clearing on self-heal); neither touches disk. The final persist is
1163/// unconditional and best-effort — it runs on every outcome so a fetch that
1164/// applied packs before failing never loses that progress, and the record
1165/// is a pure performance cache whose own I/O must never fail a fetch whose
1166/// objects durably landed.
1167///
1168/// # Repo-lock scope (mkit #642)
1169///
1170/// Each branch acquires the repo lock fresh, right before
1171/// [`packmap::apply_fetched_chain`] unpacks that branch's downloaded packs,
1172/// and releases it right after the branch's remote-tracking ref is
1173/// published — see [`fetch_objects_inner`]. Nothing here holds the lock
1174/// during [`packmap::resolve_and_download_chain`]'s network I/O, and the
1175/// lock is released between branches, so only the local write + ref-publish
1176/// window for one branch at a time is ever locked. This still closes the
1177/// #267 GC-prune race: `gc` takes the very same lock before computing its
1178/// live set, so it can never observe a branch's objects on disk without
1179/// that branch's ref already published.
1180fn fetch_objects(
1181    store: &ObjectStore,
1182    layout: &RepoLayout,
1183    tx: &dyn Transport,
1184    remote: &str,
1185    require_signed: bool,
1186) -> Result<usize, DispatchError> {
1187    let mut applied = AppliedPacks::load_or_empty(layout, remote);
1188    let result = fetch_objects_inner(store, layout, tx, remote, &mut applied, require_signed);
1189    persist_record(&mut applied, remote);
1190    result
1191}
1192
1193/// The branch loop proper — see [`fetch_objects`] for the load-once /
1194/// persist-once applied-packs contract this is called under.
1195fn fetch_objects_inner(
1196    store: &ObjectStore,
1197    layout: &RepoLayout,
1198    tx: &dyn Transport,
1199    remote: &str,
1200    applied: &mut AppliedPacks,
1201    require_signed: bool,
1202) -> Result<usize, DispatchError> {
1203    let remote_refs = tx.list_refs("refs/heads/")?;
1204    let mut n = 0;
1205    // Batch every fetched branch's remote-tracking-ref write (#645): see
1206    // `push_all_with` for the same pattern and its rationale. `tracking.write`
1207    // still runs inside this branch's `_lock` scope below, so ref visibility
1208    // timing is unchanged from the per-branch-publish-then-unlock model
1209    // (#642) — only the parent-directory fsync is deferred to one pass after
1210    // the loop. GC's #267 protection is unaffected: it depends on the
1211    // repo lock covering the object-write-to-ref-publish window (still true
1212    // per branch below), not on when the ref directory itself is fsynced.
1213    let mut tracking = refs::RemoteRefBatch::new(layout, remote)?;
1214    let result: Result<(), DispatchError> = (|| {
1215        for r in remote_refs {
1216            if crate::signal::is_shutdown() {
1217                return Err(DispatchError::Interrupted);
1218            }
1219            let Some(h) = r.hash else { continue };
1220            // A branch tip without a packmap is a corrupt/incomplete remote, not
1221            // a format we degrade to: the push path ALWAYS advertises a packmap
1222            // before moving the branch ref. A real transport error (network blip,
1223            // auth) propagates unchanged — only `Ok(None)` is the explicit
1224            // "no packmap" verdict, and it is now an error.
1225            let Some(chain_head) = tx.read_ref(&packmap_ref(&r.name))? else {
1226                return Err(DispatchError::PackmapMissing(r.name.clone()));
1227            };
1228
1229            // Phase 1 (#642): resolve this branch's chain and download its
1230            // packs — pure network I/O, no repo lock held (see
1231            // `packmap::resolve_and_download_chain`).
1232            let fetched = resolve_and_download_chain(tx, &r.name, chain_head, applied)?;
1233
1234            // Phase 2 (#642): unpack + verify + publish this branch's ref,
1235            // under a repo lock acquired fresh for this branch and released
1236            // once this loop iteration ends — never held across another
1237            // branch's download. See `packmap::apply_fetched_chain`'s doc
1238            // comment for the safety contract (closing the #267 GC-prune
1239            // race) this depends on.
1240            let lock = mkit_core::repo_lock::acquire_default(
1241                layout.worktree_state_dir(),
1242                crate::commands::WORKTREE_LOCK,
1243            )?;
1244            // The tip we publish: normally the listed `h`, but if the chain fails
1245            // because a concurrent re-baseline moved the branch under us, the
1246            // freshly re-read tip (see this fn's doc comment). The match also
1247            // carries the lock guard out, so whichever branch runs, the
1248            // correct (possibly re-acquired) guard is what's held at
1249            // `tracking.write` below — see the retry branch's comment for why
1250            // there are two guards, not one held across the whole match.
1251            let (published_tip, _lock) = match apply_fetched_chain(
1252                store,
1253                tx,
1254                remote,
1255                &r.name,
1256                fetched,
1257                h,
1258                applied,
1259                require_signed,
1260            ) {
1261                Ok(()) => (h, lock),
1262                Err(e @ DispatchError::RemoteMissingObject(_)) => {
1263                    // Re-read the branch's CURRENT tip + packmap. If either
1264                    // is gone (branch deleted mid-fetch) the original error
1265                    // stands. Otherwise retry the chain ONCE with the fresh
1266                    // pair; a second failure propagates via `?`.
1267                    let (Some(fresh_h), Some(fresh_head)) = (
1268                        tx.read_ref(&format!("refs/heads/{}", r.name))?,
1269                        tx.read_ref(&packmap_ref(&r.name))?,
1270                    ) else {
1271                        return Err(e);
1272                    };
1273                    // Release the lock for the retry's network
1274                    // re-download too — mirrors phase 1's unlocked
1275                    // download exactly, rather than the previously
1276                    // "accepted trade" of holding the lock across a
1277                    // second network round-trip on this rare
1278                    // race-recovery path. Re-acquire before the
1279                    // retry's local unpack + publish, which still
1280                    // needs the same #267 protection phase 2 always
1281                    // has.
1282                    drop(lock);
1283                    let fresh_fetched =
1284                        resolve_and_download_chain(tx, &r.name, fresh_head, applied)?;
1285                    let lock = mkit_core::repo_lock::acquire_default(
1286                        layout.worktree_state_dir(),
1287                        crate::commands::WORKTREE_LOCK,
1288                    )?;
1289                    apply_fetched_chain(
1290                        store,
1291                        tx,
1292                        remote,
1293                        &r.name,
1294                        fresh_fetched,
1295                        fresh_h,
1296                        applied,
1297                        require_signed,
1298                    )?;
1299                    (fresh_h, lock)
1300                }
1301                Err(e) => return Err(e),
1302            };
1303            // Still inside `_lock`'s scope (the original guard, or the
1304            // retry's re-acquired one — see above).
1305            tracking.write(&r.name, &published_tip)?;
1306            n += 1;
1307        }
1308        Ok(())
1309    })();
1310    // Commit whatever tracking-ref writes succeeded regardless of how the
1311    // loop above ended — a mid-loop failure still durably publishes the
1312    // prefix that already verified successfully, matching the old
1313    // per-branch loop's per-write durability.
1314    tracking.commit()?;
1315    result?;
1316    Ok(n)
1317}
1318
1319/// Best-effort persist of `applied` (a write failure is logged and
1320/// swallowed), called exactly once per fetch — see [`fetch_objects`] for
1321/// the load-once / persist-once contract.
1322fn persist_record(applied: &mut AppliedPacks, remote: &str) {
1323    if let Err(e) = applied.persist() {
1324        eprintln!(
1325            "warning: could not persist applied-packs record for remote '{remote}' ({e}); it will be rebuilt on the next fetch"
1326        );
1327    }
1328}
1329
1330/// Assert that every object reachable from `tip` is already present in the
1331/// local store after the packmap chain has been unpacked. This is a pure
1332/// integrity check — it walks the closure via
1333/// [`mkit_core::ops::reachable_closure_checked`] (which reads each object and
1334/// re-verifies its digest) and performs NO network access. A reachable object
1335/// that the chain failed to deliver surfaces as [`StoreError::ObjectNotFound`],
1336/// which we re-tag as [`DispatchError::RemoteMissingObject`] so the fetch
1337/// aborts loudly rather than publishing a ref to a closure we can't
1338/// reconstruct.
1339///
1340/// When packs were skipped (the applied-pack fast path, #409) this walk is
1341/// the *sole* guarantee the store is complete, so it must not silently pass
1342/// on an unverified frontier: a closure exceeding the
1343/// [`mkit_core::ops::graph::MAX_REACHABLE`] cap leaves objects past the cap
1344/// unchecked, which over a partially-wiped store could hide missing objects.
1345/// We therefore surface truncation as a hard [`DispatchError::ClosureTooLarge`]
1346/// rather than dropping the flag. `ClosureTooLarge` is deliberately distinct
1347/// from `RemoteMissingObject` so it does NOT feed the self-heal retry — a
1348/// too-large history is not evidence of local staleness.
1349///
1350/// Called from [`packmap::fetch_pack_chain`] (not sequenced after it) so its
1351/// `RemoteMissingObject` result participates in that function's applied-pack
1352/// self-heal retry — see [`fetch_objects`]'s doc comment.
1353pub(crate) fn verify_closure_present(store: &ObjectStore, tip: &Hash) -> Result<(), DispatchError> {
1354    match mkit_core::ops::reachable_closure_checked(store, std::iter::once(tip)) {
1355        Ok((_, false)) => Ok(()),
1356        Ok((_, true)) => Err(DispatchError::ClosureTooLarge(
1357            mkit_core::ops::graph::MAX_REACHABLE,
1358        )),
1359        Err(StoreError::ObjectNotFound(hex)) => Err(DispatchError::RemoteMissingObject(hex)),
1360        Err(e) => Err(e.into()),
1361    }
1362}
1363
1364fn load_tree_hash(store: &ObjectStore, commit_hash: Hash) -> Result<Hash, DispatchError> {
1365    match store.read_object(&commit_hash)? {
1366        Object::Commit(c) => Ok(c.tree_hash),
1367        Object::Remix(r) => Ok(r.tree_hash),
1368        _ => Err(DispatchError::NotCommit),
1369    }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::ssh_options_from_config;
1375    use crate::config::Config;
1376
1377    /// The three `ssh.*` trust-pinning keys, when set in `Config`, must
1378    /// map 1:1 into the `SshOptions` carried to the spawned `ssh(1)`
1379    /// child. This is the producer half of issue #389: without it the
1380    /// keys are parsed but never reach the subprocess.
1381    #[test]
1382    fn populated_config_maps_to_ssh_options() {
1383        let cfg = Config {
1384            ssh_strict_host_key_checking: "yes".to_string(),
1385            ssh_user_known_hosts_file: "/path/to/project.known_hosts".to_string(),
1386            ssh_identity_file: "/path/to/id_ed25519".to_string(),
1387            ..Config::default()
1388        };
1389        let opts = ssh_options_from_config(&cfg);
1390        assert_eq!(opts.strict_host_key_checking, "yes");
1391        assert_eq!(opts.user_known_hosts_file, "/path/to/project.known_hosts");
1392        assert_eq!(opts.identity_file, "/path/to/id_ed25519");
1393    }
1394
1395    /// Empty `ssh.*` fields must map to empty `SshOptions` fields so
1396    /// `build_ssh_command` emits NO `-o`/`-i` flags and the user's
1397    /// `ssh(1)` defaults are inherited unchanged.
1398    #[test]
1399    fn empty_config_maps_to_empty_ssh_options() {
1400        let opts = ssh_options_from_config(&Config::default());
1401        assert!(opts.strict_host_key_checking.is_empty());
1402        assert!(opts.user_known_hosts_file.is_empty());
1403        assert!(opts.identity_file.is_empty());
1404    }
1405}