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}