heddle_git_projection/git_export.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Export Heddle states to Git commits functionality.
3
4use std::collections::HashSet;
5
6use objects::{
7 error::HeddleError,
8 object::{ContentHash, MarkerName, Principal, State, StateId, ThreadName, TreeEntryTarget},
9 store::ObjectStore,
10};
11use repo::{AudienceTier, Repository as HeddleRepository, visible};
12use sley::{
13 CommitObject, EntryKind, GitObjectType, ObjectId, RefPrecondition, ReferenceTarget,
14 Repository as SleyRepository, Signature, plumbing::sley_object::EncodedObject,
15};
16use tracing::debug;
17
18use crate::{
19 git_core::{
20 GitProjection, GitProjectionError, GitProjectionResult, LocalGitIdentity, SyncMapping,
21 copy_reachable_objects, count_exported_commits, delete_reference_if_present,
22 git_config_identity_with_global_fallback, git_err, principal_is_default_unknown,
23 read_or_seed_mirror_managed_refs, set_reference, write_mirror_managed_refs,
24 },
25 git_notes,
26 git_reconstruct::{commit_object_id, reconstruct_commit_bytes, write_commit_object},
27 git_residual::ResidualStore,
28 git_sync::{sync_marker_to_tag, sync_track_to_branch},
29 git_util::{ExportStats, ExportedRef},
30};
31
32/// Whether `state` carries a captured original git commit to reconstruct
33/// byte-exactly (the #565 de-lossy fidelity fields). When true, export
34/// regenerates the commit object from state via [`reconstruct_commit_bytes`]
35/// with NO W2 footer and NO `"No intent specified"` placeholder — any injected
36/// byte would push the minted object off the original SHA (#567). When false
37/// (a native heddle commit, no original to preserve), export mints with the
38/// footer/placeholder as before.
39///
40/// `raw_message` is the load-bearing signal: the git importer always records it
41/// (even as an empty body for an empty-message commit) for an imported commit,
42/// and never for a native one.
43fn has_git_fidelity(state: &State) -> bool {
44 state.raw_message.is_some()
45}
46
47/// Whether `who`'s name/email round-trip byte-exactly through reconstruction.
48/// `Principal.name/email` are `String`, so the git importer replaced any non-UTF8
49/// identity byte with U+FFFD when it called `to_string()` on the raw actor bytes
50/// (the #565-deferred gap; `Principal` is still `String`, see #564). Those
51/// replaced bytes can't be regenerated, so reconstruction would hash off the
52/// original SHA. A literal U+FFFD that was itself valid UTF-8 in the original
53/// survives fine — so this can only FALSE-POSITIVE into the safe verbatim
54/// fallback, never a wrong-SHA mint.
55fn identity_is_byte_faithful(who: &Principal) -> bool {
56 !who.name.contains('\u{FFFD}') && !who.email.contains('\u{FFFD}')
57}
58
59/// Whether reconstructing `state`'s commit object from Heddle state alone is
60/// guaranteed byte-exact to the original commit — the precondition for the #567
61/// reconstruct-from-state path. False for the two #564 lossy gaps:
62/// 1. non-UTF8 author/committer identity bytes (see [`identity_is_byte_faithful`]);
63/// 2. lossy imports, where unrepresentable tree entries were dropped/converted
64/// so the rebuilt tree — hence commit — OID diverges.
65///
66/// (2) is read off ONE canonical signal — [`State::git_lossy`] — that lossy
67/// import population paths set, rather than enumerating import surfaces or
68/// relying on Git Projection Mapping sidecar state. The state flag closes the whole
69/// class, including any future lossy entry point.
70///
71/// When false the caller MUST keep the verbatim residual / Bridge Mirror bytes
72/// / preserved mapped OID (or fall through to the native mint) rather than mint
73/// a wrong-SHA reconstructed object.
74///
75/// `pub` so the checkout write-through path (#568 P1,
76/// `git_core::write_thread_state_checkout_from_existing_mirror`) reads the SAME
77/// single faithful-or-lossy discriminator the export path does — reconstruct
78/// faithful commits from state, residual-then-mirror backstop the lossy residual.
79/// Keeping ONE chokepoint for the decision means a new consumer cannot drift to a
80/// different (wrong-SHA) rule.
81pub fn commit_is_byte_faithful(state: &State) -> bool {
82 has_git_fidelity(state)
83 && !state.git_lossy
84 && identity_is_byte_faithful(&state.attribution.principal)
85 && state
86 .committer
87 .as_ref()
88 .map(identity_is_byte_faithful)
89 .unwrap_or(true)
90}
91
92pub struct ExportStateOptions<'a> {
93 pub identity: Option<&'a LocalGitIdentity>,
94 pub message_override: Option<&'a str>,
95 pub parent_override: Option<&'a [ObjectId]>,
96 pub audience: &'a AudienceTier,
97}
98
99/// Export a single state to Git for `audience`.
100///
101/// Returns `Ok(None)` — **absence** — when the state's effective visibility
102/// tier is not visible to `audience`: the public mirror never mints a Git
103/// commit (no stub, no partial tree) for an embargoed state (spike §5.0/§5.3).
104/// The caller realizes downward-closure by also withholding any state whose
105/// parent was withheld, so an embargoed commit *and its descendants* stay
106/// absent from the mirror.
107pub fn export_state(
108 mapping: &mut SyncMapping,
109 heddle_repo: &HeddleRepository,
110 repo: &SleyRepository,
111 state_id: &StateId,
112 options: ExportStateOptions<'_>,
113) -> GitProjectionResult<Option<ObjectId>> {
114 let state = heddle_repo
115 .store()
116 .get_state(state_id)?
117 .ok_or(GitProjectionError::StateNotFound(*state_id))?;
118
119 // Audience-aware minting. The visibility decision lives here, at the state
120 // walk where the `StateId` is in scope — never in the blob-keyed
121 // `export_tree` (no `StateId`/audience).
122 let tier = heddle_repo
123 .effective_visibility_tier(state_id)
124 .map_err(|e| {
125 GitProjectionError::Git(format!("resolve visibility for {state_id}: {e:#}"))
126 })?;
127 if !visible(&tier, options.audience) {
128 return Ok(None);
129 }
130
131 // Fidelity mint (#567): the state carries a captured original git commit
132 // (#565 fields — `raw_message` is the load-bearing signal). MINT the commit
133 // object from that raw metadata via `reconstruct_commit_bytes` — NO footer,
134 // NO placeholder, NO message override — so the minted bytes preserve the
135 // original message/identities/headers rather than the native intent+footer.
136 // This is the path that lets the git mirror be dropped (#568): a correct
137 // export no longer depends on the mirror holding the verbatim imported bytes.
138 //
139 // Routing (#567 round 3): export keys off (is byte-faithful?) AND (does a
140 // Git Projection Mapping exist?). The verbatim / mapped-OID fallback for a lossy
141 // commit applies ONLY when a Git Projection Mapping holds a TRACKED original OID to
142 // preserve — and that branch lives in `export_scoped`'s already-mapped path.
143 // `export_state` is only ever reached for an UNMAPPED state (the caller's
144 // `has_heddle` guard), so there is NO original OID to match and NO verbatim
145 // mirror bytes to fall back to. Every unmapped fidelity state therefore MINTS
146 // from its own raw metadata — a `--lossy` one is NOT rejected into a
147 // nonexistent verbatim source (the r2 over-correction, #567 round 3):
148 // * byte-faithful (a clean ingest-backed import, native heddle commit with
149 // fidelity, ...) -> the derived OID coincides with the original commit SHA;
150 // * lossy / non-UTF8 (ingest-backed import with lossy tree conversion) -> a
151 // DERIVED OID that still preserves raw_message/identities/headers. With
152 // no original to match this is correct, not the wrong-SHA bug the r2
153 // `git_lossy` guard (rightly) blocks ONLY for a MAPPED commit.
154 if has_git_fidelity(&state) {
155 let content = reconstruct_commit_bytes(heddle_repo, repo, mapping, &state)?;
156 return Ok(Some(write_commit_object(repo, &content)?));
157 }
158
159 // Native heddle commit: no original to preserve. Mint a raw commit object
160 // and inject the durable W2 footer (and the "No intent specified"
161 // placeholder for an empty intent) — these ride ONLY native commits.
162 let git_tree_oid = export_tree(heddle_repo, repo, &state.tree)?;
163 // R6: emit the W2 footer on every exported commit. The footer is
164 // durable across remotes; per-scope breakdowns ride on the opt-in
165 // git note. For first-pass we audit nothing about the state's
166 // annotation set (the audience defaults to "public"); a follow-up
167 // landed with `export git --audience` threads the count
168 // through here. See `git_util::build_commit_message_with_footer`.
169 let hosted_url = heddle_repo
170 .config()
171 .hosted
172 .upstream_url
173 .as_deref()
174 .filter(|s| !s.is_empty());
175 let message = match options.message_override {
176 Some(message) => GitProjection::build_commit_message_with_footer_with_body(
177 &state, message, hosted_url, /*omitted=*/ 0,
178 ),
179 None => {
180 GitProjection::build_commit_message_with_footer(&state, hosted_url, /*omitted=*/ 0)
181 }
182 };
183 let parent_oids: Vec<ObjectId> = if let Some(parents) = options.parent_override {
184 parents.to_vec()
185 } else {
186 state
187 .parents
188 .iter()
189 .map(|parent_id| {
190 mapping
191 .get_git(parent_id)
192 .ok_or(GitProjectionError::StateNotFound(*parent_id))
193 })
194 .collect::<GitProjectionResult<Vec<_>>>()?
195 };
196
197 let sig = if principal_is_default_unknown(&state.attribution.principal) {
198 let Some(identity) = options.identity else {
199 return Err(GitProjectionError::Git(
200 "refusing to write a Git commit with Unknown <unknown@example.com>; configure user.name/user.email, HEDDLE_PRINCIPAL_NAME/HEDDLE_PRINCIPAL_EMAIL, or .heddle principal".to_string(),
201 ));
202 };
203 identity.to_signature(state.created_at.timestamp())
204 } else {
205 state_to_signature(&state)
206 };
207 let commit = CommitObject {
208 tree: git_tree_oid,
209 parents: parent_oids,
210 author: sig.to_ident_bytes(),
211 committer: sig.to_ident_bytes(),
212 encoding: None,
213 message: message.into_bytes(),
214 };
215 Ok(Some(
216 repo.write_object(EncodedObject::new(GitObjectType::Commit, commit.write()))
217 .map_err(git_err)?,
218 ))
219}
220
221/// Export a Heddle tree to Git.
222pub fn export_tree(
223 heddle_repo: &HeddleRepository,
224 repo: &SleyRepository,
225 tree_hash: &ContentHash,
226) -> GitProjectionResult<ObjectId> {
227 let tree = heddle_repo
228 .store()
229 .get_tree(tree_hash)?
230 .ok_or_else(|| HeddleError::NotFound(format!("tree {}", tree_hash)))?;
231
232 let empty_tree = ObjectId::empty_tree(repo.object_format());
233 let mut editor = repo.edit_tree(&empty_tree).map_err(git_err)?;
234
235 for entry in tree.entries() {
236 let write_blob_entry = |hash: &ContentHash| -> GitProjectionResult<ObjectId> {
237 // Redaction safety: if the blob carries an active redaction
238 // record, export the stub instead of the bytes. This is the
239 // single chokepoint between Heddle-side redactions and any
240 // downstream Git remote (GitHub, internal mirrors, ...).
241 // Bytes that escape via Git projection are bytes that escape,
242 // full stop — we cannot retroactively scrub them from
243 // outside repos. The check sits *here*, not in
244 // `materialize_blob`, because export reads `blob.content()`
245 // directly (we never touch the materialize path) and writes
246 // the raw bytes through `repo.write_blob`.
247 let stub = heddle_repo
248 .redaction_stub_for_blob(hash)
249 .map_err(|err| HeddleError::Config(format!("redaction lookup failed: {err}")))?;
250
251 if let Some(stub_text) = stub {
252 // Stubs are text-only and ASCII safe across newline/BOM quirks.
253 return repo.write_blob(stub_text.as_bytes()).map_err(git_err);
254 }
255
256 let blob = heddle_repo
257 .store()
258 .get_blob(hash)?
259 .ok_or_else(|| HeddleError::NotFound(format!("blob {}", hash)))?;
260 repo.write_blob(blob.content()).map_err(git_err)
261 };
262 let (kind, id) = match entry.target() {
263 TreeEntryTarget::Tree { hash } => {
264 (EntryKind::Tree, export_tree(heddle_repo, repo, hash)?)
265 }
266 TreeEntryTarget::Blob { hash, executable } => {
267 let kind = if *executable {
268 EntryKind::BlobExecutable
269 } else {
270 EntryKind::Blob
271 };
272 (kind, write_blob_entry(hash)?)
273 }
274 TreeEntryTarget::Symlink { hash } => (EntryKind::Symlink, write_blob_entry(hash)?),
275 TreeEntryTarget::Gitlink { target } => (EntryKind::Commit, *target),
276 // A native child-spool edge points at a spool-id + state-id, NOT a
277 // git commit OID, so it has no valid git submodule (mode 160000)
278 // representation. Emitting a `Commit` entry here would fabricate a
279 // bogus submodule pointer, so we deliberately SKIP the entry on
280 // git-export. (Git-import never produces spoollinks — only native
281 // spool operations do — so nothing round-trips back through here.)
282 TreeEntryTarget::Spoollink { spool_id, state_id } => {
283 debug!(
284 name = entry.name(),
285 %spool_id,
286 %state_id,
287 "skipping SPOOLLINK entry on git-export: no valid git submodule representation"
288 );
289 continue;
290 }
291 };
292
293 editor.upsert(entry.name(), kind, id);
294 }
295
296 repo.write_tree(editor).map_err(git_err)
297}
298
299/// Export all Heddle states to Git commits.
300pub fn export_all(bridge: &mut GitProjection) -> GitProjectionResult<ExportStats> {
301 bridge.with_mapping_rollback(|bridge| export_scoped(bridge, None))
302}
303
304/// Export one Heddle thread to its matching Git branch.
305pub fn export_current_thread(
306 bridge: &mut GitProjection,
307 thread: &str,
308) -> GitProjectionResult<ExportStats> {
309 bridge.with_mapping_rollback(|bridge| export_scoped(bridge, Some(thread)))
310}
311
312fn export_scoped(
313 bridge: &mut GitProjection,
314 thread: Option<&str>,
315) -> GitProjectionResult<ExportStats> {
316 bridge.init_mirror()?;
317
318 let states = match thread {
319 Some(thread) => {
320 let Some(state_id) = bridge
321 .heddle_repo
322 .refs()
323 .get_thread(&ThreadName::new(thread))?
324 else {
325 return Err(GitProjectionError::Git(format!(
326 "thread '{thread}' has no state to export"
327 )));
328 };
329 reachable_states(bridge.heddle_repo, &[state_id])?
330 }
331 None => bridge.heddle_repo.store().list_states()?,
332 };
333 let mut stats = ExportStats::default();
334
335 bridge.build_existing_mapping(None)?;
336 let identity = git_config_identity_with_global_fallback(bridge.heddle_repo.root())?;
337
338 // Git projection export publishes the public projection — the export audience is
339 // always `Public`. Per-commit visibility is enforced here, in the OSS
340 // bridge, by emitting absence (the authoritative wire serve gate is weft's
341 // job, spike §10 #4).
342 let audience = AudienceTier::Public;
343
344 let sorted_states = bridge.sort_states_topologically(&states)?;
345 // Reachable set, used to tell a withheld parent (absent from the mapping
346 // but present in this export) apart from a genuinely-missing shallow
347 // boundary (absent from both).
348 let reachable: HashSet<StateId> = sorted_states.iter().copied().collect();
349 let repo = bridge.open_git_repo()?;
350 bridge.mapping.retain_git_objects(&repo);
351 bridge.seed_git_checkpoint_mappings_from_checkout(&repo)?;
352 bridge.seed_ingest_identity_mappings_from_repo(&repo)?;
353
354 // The desired/actual ref sets span the WHOLE mirror, not just this export's
355 // scoped thread: a prior all-thread export can leave `refs/heads`/`refs/tags`
356 // for OTHER threads/markers whose commits — or their ancestors — were later
357 // marked Private. Reconciling only the scoped thread would keep serving those
358 // now-embargoed commits via the other thread's branch (heddle#316 cross-thread
359 // embargo leak). So purge + project + reconcile over every heddle-managed
360 // thread/marker regardless of scope; the mint loop below stays scoped (only the
361 // requested thread's new commits are minted), so widening changes WHICH refs
362 // are reconciled, never what gets created.
363 let remote_names = git_remote_names(bridge.heddle_repo);
364 let threads: Vec<String> = {
365 let mut all: Vec<String> = bridge
366 .heddle_repo
367 .refs()
368 .list_threads()?
369 .into_iter()
370 .filter(|thread| !is_remote_tracking_thread_name(thread, &remote_names))
371 .map(|t| t.to_string())
372 .collect();
373 // A scoped export's own thread may be a remote-tracking name the filter
374 // drops; keep it so the requested thread is always reconciled.
375 if let Some(t) = thread
376 && !all.iter().any(|x| x == t)
377 {
378 all.push(t.to_string());
379 }
380 all
381 };
382 let markers: Vec<MarkerName> = bridge.heddle_repo.refs().list_markers()?;
383
384 // Roots of the whole-mirror served frontier: every reconciled thread's tip and
385 // every marker's state. Purging over their reachable closure (below) drops any
386 // out-of-scope commit whose tier — or an ancestor's — is now unserved, so
387 // `project_desired_refs` lags those branches/tags correctly even on a scoped
388 // export (heddle#316).
389 let mut frontier_roots: Vec<StateId> = Vec::new();
390 for track_name in &threads {
391 if let Some(tip) = bridge
392 .heddle_repo
393 .refs()
394 .get_thread(&ThreadName::new(track_name))?
395 {
396 frontier_roots.push(tip);
397 }
398 }
399 for marker_name in &markers {
400 if let Some(state_id) = bridge.heddle_repo.refs().get_marker(marker_name)? {
401 frontier_roots.push(state_id);
402 }
403 }
404 let frontier_reachable = reachable_states(bridge.heddle_repo, &frontier_roots)?;
405
406 // Re-validate the served set against CURRENT visibility before anything treats
407 // a mapping as "already served". A state minted while public in a prior export
408 // can be marked under-tier later; `build_existing_mapping` rebuilds its stale
409 // StateId→OID mapping from the notes/sidecar every run, so without this purge
410 // the frontier walk, the note re-write, and the tag sync would all keep serving
411 // the now-embargoed commit. Purging is downward-closed: a still-visible state
412 // whose ancestor is embargoed is withheld too (its Git commit chains to the
413 // embargoed one). The purge spans the mint set UNION the whole-mirror frontier,
414 // so a scoped export still drops an out-of-scope thread's now-embargoed tip; for
415 // an all-thread export the frontier ⊆ the mint set and this reduces to the prior
416 // behavior. After this, `mapping` == the served set across every reconciled ref,
417 // exactly what `frontier_git_oid` assumes.
418 // Snapshot EVERY mapped target before the purge mutates the mapping: these are
419 // exactly the commits that may already carry a `refs/notes/*` entry in the
420 // mirror, so the notes-ref retraction below must consider all of them —
421 // including the states the purge is about to drop AND any orphaned mapping a
422 // deleted thread left behind, which no current-ref frontier reaches (heddle#316).
423 let pre_purge_targets: Vec<(StateId, ObjectId)> =
424 bridge.mapping.iter().map(|(c, o)| (*c, *o)).collect();
425
426 let purge_reachable: HashSet<StateId> = sorted_states
427 .iter()
428 .copied()
429 .chain(frontier_reachable.iter().copied())
430 .collect();
431 let purge_sorted =
432 bridge.sort_states_topologically(&purge_reachable.iter().copied().collect::<Vec<_>>())?;
433 // The purge MUTATES the mapping down to the served set. Its returned drop-set
434 // (the OIDs THIS run withheld) is deliberately NOT used to classify EXISTING
435 // mirror tips: a scoped run's purge omits a tip embargoed in a PRIOR run, or
436 // out of this run's purge reach, so classifying by it misreads such a tip as
437 // served and keeps serving it. Existing-tip served classification (heads + tags
438 // below) uses the whole-mirror served-OID set (`served_oids`) instead
439 // (heddle#316).
440 purge_unserved_mappings(
441 bridge.heddle_repo,
442 &mut bridge.mapping,
443 &purge_sorted,
444 &purge_reachable,
445 &audience,
446 )?;
447
448 // Git OIDs minted during this run. Used below to partition the copied
449 // ref set into newly-written vs already-mapped — so the "newly" count
450 // is a subset of the same walk that produces the total, never a
451 // parallel tally over `list_states()` that could include an orphan
452 // state reachable from no copied ref.
453 let mut newly_minted: HashSet<ObjectId> = HashSet::new();
454
455 for state_id in sorted_states {
456 // Already mapped to a git object — the common case for git-imported
457 // states (the import populated the StateId→OID mapping) and for
458 // native commits a prior export already minted. Not re-counted as
459 // "newly minted" (the total is decided below by ref-reachability).
460 if bridge.mapping.has_heddle(&state_id) {
461 // For an IMPORTED commit (#565 fidelity fields present),
462 // REGENERATE the object from state into the mirror rather than
463 // leaning on the verbatim imported bytes still being there (#567).
464 // Byte-identical, so the OID is unchanged and the write is
465 // idempotent today; what changes is that a correct export no
466 // longer DEPENDS on the mirror's verbatim copy — the step that
467 // lets the mirror be dropped (#568). Native already-mapped commits
468 // have no original to reconstruct (raw_message is None), so they
469 // are left to their prior mint; re-minting those is out of scope.
470 if let Some(state) = bridge.heddle_repo.store().get_state(&state_id)?
471 && has_git_fidelity(&state)
472 {
473 let mapped = bridge.mapping.get_git(&state_id);
474 // Incremental-export fast path (perf, latent O(history) fix): the
475 // regenerate-from-state step below is purely a #567 idempotent
476 // re-write — it rebuilds the commit object from state and writes it
477 // so a correct export no longer DEPENDS on the mirror's verbatim
478 // bytes. But when the mapped commit object is ALREADY in the mirror,
479 // that re-write is a no-op (sley hashes-then-skips), preceded by
480 // `reconstruct_commit_bytes`'s FULL recursive tree re-walk + re-hash.
481 // On a deep imported history every already-mapped commit hits this
482 // branch on every export, so that re-walk is paid once per historical
483 // commit per export — O(total history). Skipping when the object is
484 // present makes it O(commits whose object is missing), with
485 // byte-identical output: the served object, its OID, and the mapping
486 // are all unchanged. The reconstruct still runs (and the safety net
487 // still guards the write) for any mapped commit whose object is NOT
488 // yet in the mirror — the case #567/#568 actually need it for.
489 if mapped.is_some_and(|oid| repo.read_object(&oid).is_ok()) {
490 continue;
491 }
492 // mirror still required for non-byte-faithful commits (non-UTF8
493 // identities, --lossy); #568 mirror elimination must account for
494 // these, and full de-lossy needs byte-preserving identities (#564
495 // follow-up).
496 // Fidelity guard (#567): regenerate from state ONLY when the
497 // state is fully byte-faithful to the original import. A
498 // non-byte-faithful commit (non-UTF8 identity, or a `--lossy`
499 // import — both import-lossy and ingest-lossy carry the canonical
500 // `git_lossy` flag) would reconstruct to a WRONG SHA, so leave it
501 // on the preserved mapped OID — Raw Git Object Residual bytes
502 // (preferred) or Bridge Mirror verbatim bytes stay the served
503 // object.
504 if commit_is_byte_faithful(&state) {
505 let content = reconstruct_commit_bytes(
506 bridge.heddle_repo,
507 &repo,
508 &bridge.mapping,
509 &state,
510 )?;
511 // Safety net: the regenerated object MUST hash to the mapped
512 // OID. A mismatch means reconstruction diverged from the
513 // imported bytes (an undetected fidelity gap), so fall back to
514 // residual / Bridge Mirror / mapped OID rather than write a
515 // wrong-SHA object.
516 let reconstructed = commit_object_id(&content);
517 if mapped.map(|m| m == reconstructed).unwrap_or(true) {
518 write_commit_object(&repo, &content)?;
519 }
520 } else if let Some(mapped_oid) = mapped {
521 // Lossy path: prefer residual, else leave mirror bytes as the
522 // served object (pre-#567). Foundation: install residual when
523 // present so export no longer *requires* the mirror for that
524 // root when residual capture has already run.
525 let residual_store = ResidualStore::open(bridge.heddle_repo.heddle_dir());
526 let _ = residual_store.install_into(&repo, &mapped_oid)?;
527 }
528 }
529 continue;
530 }
531
532 // Downward-closure (spike §5.0): withhold a state whose parent was
533 // itself withheld for this audience. Processed in topo order, so a
534 // parent's mapped-ness is already decided. A parent absent from the
535 // mapping but present in `reachable` was withheld → withhold this
536 // child too (and, transitively, all its descendants). A parent absent
537 // from both is a shallow boundary (public-by-absence) — let the mint
538 // proceed exactly as before.
539 let parent_withheld = bridge
540 .heddle_repo
541 .store()
542 .get_state(&state_id)?
543 .map(|state| {
544 state
545 .parents
546 .iter()
547 .any(|p| reachable.contains(p) && bridge.mapping.get_git(p).is_none())
548 })
549 .unwrap_or(false);
550 if parent_withheld {
551 continue;
552 }
553
554 let message_override = bridge
555 .commit_message_overrides
556 .get(&state_id)
557 .map(String::as_str);
558 let parent_override = bridge
559 .commit_parent_overrides
560 .get(&state_id)
561 .map(Vec::as_slice);
562 if let Some(parents) = parent_override
563 && !parents.is_empty()
564 {
565 let checkout_repo =
566 SleyRepository::discover(bridge.heddle_repo.root()).map_err(git_err)?;
567 copy_reachable_objects(&checkout_repo, &repo, parents.iter().copied())?;
568 }
569 let Some(git_oid) = export_state(
570 &mut bridge.mapping,
571 bridge.heddle_repo,
572 &repo,
573 &state_id,
574 ExportStateOptions {
575 identity: identity.as_ref(),
576 message_override,
577 parent_override,
578 audience: &audience,
579 },
580 )?
581 else {
582 // Embargoed for this audience — emit absence (no commit minted).
583 continue;
584 };
585 bridge.mapping.insert(state_id, git_oid);
586 newly_minted.insert(git_oid);
587
588 // Attach a heddle note to the freshly-created commit so the
589 // state_id survives a fresh `git clone` of the destination
590 // (when only the git side travels, without our sidecar).
591 if let Some(state) = bridge.heddle_repo.store().get_state(&state_id)? {
592 let note = git_notes::HeddleNote::from_state(&state);
593 git_notes::write_note(&repo, git_oid, ¬e)?;
594 }
595 }
596
597 // The downward-closure served set across EVERY note target — the pre-purge
598 // mapping (commits that may already carry a note in the mirror) UNION the
599 // current post-mint mapping (served states + freshly minted commits),
600 // computed over the FULL ancestry of all of them. The branch purge is
601 // ref-rooted (it walks the whole-mirror frontier of current thread tips +
602 // markers), so it never examines an ORPHANED mapping a deleted thread left
603 // behind; without this closure such a commit's note — public-tier but with a
604 // now-Private ancestor — would slip past both the backfill gate and the
605 // retraction below. This is the SAME served rule the branch frontier uses,
606 // applied to notes (heddle#316). For an all-states export it reduces to the
607 // post-purge served set, so behavior there is unchanged.
608 let note_target_roots: Vec<StateId> = pre_purge_targets
609 .iter()
610 .map(|(c, _)| *c)
611 .chain(bridge.mapping.iter().map(|(c, _)| *c))
612 .collect();
613 let note_reachable_vec = reachable_states(bridge.heddle_repo, ¬e_target_roots)?;
614 let note_reachable: HashSet<StateId> = note_reachable_vec.iter().copied().collect();
615 let note_sorted = bridge.sort_states_topologically(¬e_reachable_vec)?;
616 let note_served =
617 served_state_ids(bridge.heddle_repo, ¬e_sorted, ¬e_reachable, &audience)?;
618
619 // For states whose git_oid was already in the mapping (the SHA-stable
620 // path above), make sure the note is present too. This covers two
621 // cases: (a) the state was imported from a non-heddle git source and
622 // never had a note, and (b) the note was deleted from the mirror.
623 let note_targets: Vec<(StateId, ObjectId)> =
624 bridge.mapping.iter().map(|(c, o)| (*c, *o)).collect();
625 for (state_id, git_oid) in note_targets {
626 // Gate the backfill on the downward-closure served set, not the commit's
627 // DIRECT tier. The mapping can carry orphaned entries (a deleted thread's
628 // commits) the ref-rooted purge never examined; gating on direct
629 // visibility alone would re-publish a note for a public commit whose
630 // ancestor became Private — a commit the branch downward-closure
631 // withholds. `note_served` is the same served notion the branch frontier
632 // uses, so no note-write site can emit metadata for an unserved commit
633 // (heddle#316).
634 if note_served.contains(&state_id)
635 && git_notes::read_note(&repo, git_oid)?.is_none()
636 && let Some(state) = bridge.heddle_repo.store().get_state(&state_id)?
637 {
638 let note = git_notes::HeddleNote::from_state(&state);
639 git_notes::write_note(&repo, git_oid, ¬e)?;
640 }
641 }
642
643 // Retract the notes for every mapped target that is NOT served under the
644 // downward-closure rule. The mirror copies `refs/notes/*`
645 // (`collect_ref_updates`) alongside branches and tags, so a note left for an
646 // unserved commit keeps leaking its metadata even after its branch/tag were
647 // retracted. This is the notes-ref sibling of the branch/tag retraction
648 // above (heddle#316). Considering EVERY pre-purge target — not just the
649 // `embargoed_oids` the ref-rooted purge dropped — catches an orphaned note an
650 // ancestor embargo stranded on a deleted thread's commit. Guard the
651 // degenerate case where a still-served state maps to the same git OID by
652 // keeping any OID a served target maps to.
653 let served_note_oids: HashSet<ObjectId> = pre_purge_targets
654 .iter()
655 .copied()
656 .chain(bridge.mapping.iter().map(|(c, o)| (*c, *o)))
657 .filter(|(c, _)| note_served.contains(c))
658 .map(|(_, oid)| oid)
659 .collect();
660 let notes_to_retract: HashSet<ObjectId> = pre_purge_targets
661 .iter()
662 .filter(|(c, _)| !note_served.contains(c))
663 .map(|(_, oid)| *oid)
664 .filter(|oid| !served_note_oids.contains(oid))
665 .collect();
666 git_notes::remove_notes(&repo, ¬es_to_retract)?;
667
668 // THE PROJECTION (heddle#316 r13): the desired heddle-owned ref-set for this
669 // audience — heads lagged to the served frontier, tags at served markers — as
670 // a pure function of the post-purge served `mapping` + audience + ownership.
671 // Every mirror ref op below (set / forced embargo retract / delete) is DERIVED
672 // from this ONE map, so a ref surface can never drift out of one enforcement
673 // pass while another keeps serving it. The mirror MATERIALIZES this desired
674 // set; downstream `plan_destination_reconcile` then reconciles each
675 // destination against it — one projection, one reconcile, all destinations.
676 let desired = project_desired_refs(bridge.heddle_repo, &bridge.mapping, &threads, &markers)?;
677
678 // The downward-closure served set over the WHOLE-MIRROR frontier — the SAME
679 // closure the purge ran over (every thread tip + every marker state). A state is
680 // served iff visible to this audience AND every reachable ancestor is served.
681 // Drives BOTH the served-OID set just below AND (further down) the tag
682 // classifier's served-but-unminted axis.
683 let frontier_served = {
684 let reachable_set: HashSet<StateId> = frontier_reachable.iter().copied().collect();
685 let sorted = bridge.sort_states_topologically(&frontier_reachable)?;
686 served_state_ids(bridge.heddle_repo, &sorted, &reachable_set, &audience)?
687 };
688
689 // The whole-mirror SERVED-OID set: the git OID of every served frontier state.
690 // An EXISTING mirror tip (head or tag) is "served" iff it is one of these — an
691 // actually-served commit RIGHT NOW — independent of whether THIS run's purge
692 // happened to drop it. `frontier_served` is downward-closed at the StateId
693 // level (served ⟹ every reachable ancestor served) and every minted commit's
694 // parents are themselves mapped, so the mapped OIDs of `frontier_served` already
695 // form the downward-closed git-ancestry set — no separate git walk is needed
696 // (heddle#316). Replaces the prior `embargoed_oids` (this-run-only purge
697 // drop-set) classification that leaked a prior-run / out-of-scope embargo.
698 let served_oids: HashSet<ObjectId> = frontier_served
699 .iter()
700 .filter_map(|state| bridge.mapping.get_git(state))
701 .collect();
702
703 // The mirror's NAME-KEYED ownership record (heddle#316): a mirror ref is
704 // MANAGED iff heddle recorded WRITING it under that full name — NEVER by OID
705 // membership (the r20c bug that classified a foreign ref at a heddle OID as
706 // heddle's). The mirror analog of the destination's `heddle-exported-refs`
707 // record. Read BEFORE the head/tag loops mutate any ref so a genuine first run
708 // (absent record) seeds from the prior-run ref set rather than misreading every
709 // pre-existing ref as foreign — which would silently stop embargo retraction.
710 let mut managed_record = read_or_seed_mirror_managed_refs(&repo)?;
711
712 // Reconcile the mirror's HEADS via the shared `reconcile_ref` decision. Iterate
713 // the CURRENT threads: a dropped thread's stale branch is intentionally NOT
714 // pruned (the #289 dropped-thread contract) — it is never iterated, survives in
715 // the mirror, and stays in the managed record so the push still copies it. The
716 // desired head target is the maximal served ancestor-or-self of the thread tip
717 // (`frontier_git_oid`, via `project_desired_refs`). The existing tip is
718 // classified against the whole-mirror served-OID set, so a still-served tip
719 // fast-forwards, an embargoed tip force-rewinds to its served ancestor, and a
720 // whole-line-embargoed head is deleted. A scoped export reconciles every current
721 // thread but MATERIALIZES (creates) only the one it was scoped to.
722 for track_name in &threads {
723 if bridge
724 .heddle_repo
725 .refs()
726 .get_thread(&ThreadName::new(track_name))?
727 .is_none()
728 {
729 // A listed thread name with no tip is neither synced nor pruned.
730 continue;
731 }
732 let branch_ref = format!("refs/heads/{track_name}");
733 let in_scope = thread.is_none() || thread == Some(track_name.as_str());
734 let desired_oid = desired.get(&branch_ref).copied();
735 let existing_oid = branch_tip_oid(&repo, &branch_ref);
736 match reconcile_ref(
737 ReconcileNs::Head,
738 desired_oid,
739 existing_oid,
740 in_scope,
741 /* marker_served_unminted */ false,
742 &served_oids,
743 ) {
744 ReconcileOp::Write => {
745 let git_oid = desired_oid.expect("Write implies a desired target");
746 sync_track_to_branch(&repo, track_name, git_oid)?;
747 managed_record.insert(branch_ref.clone(), git_oid);
748 stats.threads_synced += 1;
749 stats.branches.push(ExportedRef {
750 name: track_name.clone(),
751 tip: git_oid,
752 });
753 }
754 ReconcileOp::ForceRewind => {
755 let git_oid = desired_oid.expect("ForceRewind implies a desired target");
756 set_reference(
757 &repo,
758 &branch_ref,
759 git_oid,
760 RefPrecondition::Any,
761 "heddle: retract embargoed thread frontier",
762 )?;
763 managed_record.insert(branch_ref.clone(), git_oid);
764 stats.threads_synced += 1;
765 stats.branches.push(ExportedRef {
766 name: track_name.clone(),
767 tip: git_oid,
768 });
769 }
770 ReconcileOp::Delete => {
771 delete_reference_if_present(&repo, &branch_ref)?;
772 managed_record.remove(&branch_ref);
773 }
774 // A head has no preserve path — `frontier_git_oid` recomputes the
775 // target every run, so a head is always rewound/deleted, never kept at
776 // a stale tip (Preserve is unreachable for `ReconcileNs::Head`).
777 ReconcileOp::Skip | ReconcileOp::Preserve => {}
778 }
779 }
780
781 // Reconcile the mirror's TAGS via the SAME `reconcile_ref` decision as heads.
782 // Iterate the UNION of current markers AND the managed-record tag names: a
783 // DELETED marker drops out of `markers`, so its stale managed mirror tag is
784 // reachable only via the managed-record side (heddle#316 S3 — a deleted marker
785 // must delete its tag). A FOREIGN tag heddle never wrote is in NEITHER set, so
786 // it is never visited: it survives untouched and stays out of the push frontier
787 // (`collect_managed_ref_updates`). The desired tag target comes from the
788 // projection (a marker minted this run); the served-but-unminted vs embargoed
789 // split (r18 PRESERVE vs r19 DELETE) is the existing tag's served-ness combined
790 // with `marker_served_unminted`.
791 let mut tag_names: std::collections::BTreeSet<String> =
792 markers.iter().map(|m| m.to_string()).collect();
793 for full_name in managed_record.keys() {
794 if let Some(tag) = full_name.strip_prefix("refs/tags/") {
795 tag_names.insert(tag.to_string());
796 }
797 }
798
799 for name in &tag_names {
800 let tag_ref = format!("refs/tags/{name}");
801 let existing_raw_oid = direct_ref_oid(&repo, &tag_ref);
802 let existing_oid = existing_raw_oid.and_then(|oid| peel_to_commit_oid(&repo, oid));
803 let desired_oid = desired.get(&tag_ref).copied();
804 let in_scope = thread.is_none();
805 // A live marker whose served target was NOT minted into the mapping this
806 // run (a scoped export that didn't reach it). The desired projection omits
807 // such a tag (it only publishes minted markers), so the reconcile sees
808 // `desired_oid == None`; this flag plus the existing tag's served-ness is
809 // the sole axis splitting r18-PRESERVE from r19-DELETE.
810 let marker_served_unminted = match bridge
811 .heddle_repo
812 .refs()
813 .get_marker(&MarkerName::new(name.as_str()))?
814 {
815 Some(state) => {
816 bridge.mapping.get_git(&state).is_none() && frontier_served.contains(&state)
817 }
818 None => false,
819 };
820 if let (Some(desired), Some(raw), Some(peeled)) =
821 (desired_oid, existing_raw_oid, existing_oid)
822 && raw != desired
823 && peeled == desired
824 {
825 managed_record.insert(tag_ref.clone(), raw);
826 stats.markers_synced += 1;
827 stats.tags.push(ExportedRef {
828 name: name.clone(),
829 tip: raw,
830 });
831 continue;
832 }
833 match reconcile_ref(
834 ReconcileNs::Tag,
835 desired_oid,
836 existing_oid,
837 in_scope,
838 marker_served_unminted,
839 &served_oids,
840 ) {
841 ReconcileOp::Write => {
842 let git_oid = desired_oid.expect("Write implies a desired target");
843 sync_marker_to_tag(&repo, name, git_oid)?;
844 managed_record.insert(tag_ref.clone(), git_oid);
845 stats.markers_synced += 1;
846 stats.tags.push(ExportedRef {
847 name: name.clone(),
848 tip: git_oid,
849 });
850 }
851 ReconcileOp::Delete => {
852 delete_reference_if_present(&repo, &tag_ref)?;
853 managed_record.remove(&tag_ref);
854 }
855 // PRESERVE keeps the existing served tag (still managed → stays in the
856 // record); SKIP is a no-op. A tag is free-move and never force-rewinds
857 // (ForceRewind is unreachable for `ReconcileNs::Tag`).
858 ReconcileOp::Preserve | ReconcileOp::Skip | ReconcileOp::ForceRewind => {}
859 }
860 }
861
862 // Persist the updated ownership record so the next reconcile — and the push
863 // frontier (`collect_managed_ref_updates`) — read heddle's managed set by name.
864 write_mirror_managed_refs(&repo, &managed_record)?;
865
866 // Every count in the summary is a partition of the SINGLE copied ref
867 // set: `total` is unique commits reachable from the mirror's branch/tag
868 // tips (the exact ref set `copy_mirror_to_path` writes via
869 // `collect_ref_updates`), and `states_exported` ("newly") is the subset
870 // of THAT walk minted this run. Deriving both from one walk — rather
871 // than tallying `states_exported` inline over `list_states()` — makes
872 // `newly + already == total` hold by construction: a state minted into
873 // the mirror but reachable from no copied ref (e.g. a dropped thread's
874 // orphan history) is in neither count, so the impossible
875 // "1 total (2 newly written)" summary cannot occur.
876 let counts = count_exported_commits(&repo, &newly_minted)?;
877 stats.commits_total = counts.total;
878 stats.states_exported = counts.newly;
879
880 bridge.save_mapping_to_disk()?;
881
882 Ok(stats)
883}
884
885/// Which namespace a reconciled mirror ref lives in. The reconcile DECISION is
886/// one shape for both; the only namespace-specific axis is how "write the desired
887/// target" lands — a head is fast-forward-guarded (and force-rewound for an
888/// embargo retract), a tag is free-move.
889#[derive(Debug, Clone, Copy, PartialEq, Eq)]
890enum ReconcileNs {
891 Head,
892 Tag,
893}
894
895/// The op the mirror reconcile applies to a single ref. The SINGLE decision the
896/// head and tag reconciles share (heddle#316): a foreign ref never reaches here
897/// (the iteration set is current threads/markers ∪ heddle-managed names), so every
898/// arm acts on a ref heddle owns.
899#[derive(Debug, Clone, Copy, PartialEq, Eq)]
900enum ReconcileOp {
901 /// Nothing to do — a scoped export declining to materialize an out-of-scope
902 /// ref, or a genuine no-op (no desired target and nothing to retract).
903 Skip,
904 /// Write the desired target through the namespace's guarded path: a head
905 /// fast-forwards (or creates); a tag force-retargets (or creates).
906 Write,
907 /// Force-set a head to the desired target past the fast-forward guard — the
908 /// embargo retract that rewinds an embargoed tip to its served ancestor.
909 ForceRewind,
910 /// Keep an existing served tag whose marker target is served-but-unminted this
911 /// run (r18). A later all-thread export re-mints and advances it.
912 Preserve,
913 /// Delete the ref — its line/marker has no served frontier (whole-line embargo,
914 /// r19 embargoed-existing tag, or a deleted marker's stale tag).
915 Delete,
916}
917
918/// The mirror reconcile decision — IDENTICAL in shape for heads and tags
919/// (heddle#316). `desired_oid` is the served target the projection wants published
920/// (`None` ⇒ nothing served for this ref this run); `existing_oid` is the mirror
921/// ref's CURRENT tip, already PEELED to a commit by [`branch_tip_oid`] (so an
922/// annotated foreign tag colliding with a marker name is tested by its commit, not
923/// its tag-object OID — heddle#316 risk #2). `in_scope` gates only
924/// MATERIALIZATION: a scoped export reconciles existing refs but never CREATES a
925/// brand-new one the caller did not ask for. `marker_served_unminted` is set only
926/// for a tag whose live marker target is served but was not minted this run — the
927/// sole axis that, combined with `existing_served`, splits r18-PRESERVE from
928/// r19-DELETE. `served_oids` is the whole-mirror served-OID set classifying the
929/// existing tip (NOT this run's purge drop-set, which omits a prior-run /
930/// out-of-scope embargo).
931fn reconcile_ref(
932 ns: ReconcileNs,
933 desired_oid: Option<ObjectId>,
934 existing_oid: Option<ObjectId>,
935 in_scope: bool,
936 marker_served_unminted: bool,
937 served_oids: &HashSet<ObjectId>,
938) -> ReconcileOp {
939 // `existing_oid` is already the peeled commit OID (`branch_tip_oid`), so this
940 // membership test compares commit-against-commit (risk #2).
941 let existing_served = existing_oid
942 .map(|oid| served_oids.contains(&oid))
943 .unwrap_or(false);
944 match (desired_oid, existing_oid) {
945 // Scoped export, would-create: never materialize a ref the caller did not
946 // ask to export.
947 (Some(_), None) if !in_scope => ReconcileOp::Skip,
948 // Create a fresh ref at the served target.
949 (Some(_), None) => ReconcileOp::Write,
950 // Head with an existing tip: a still-served tip fast-forwards (r17 FF guard
951 // applies); an embargoed tip is force-rewound to its served ancestor.
952 (Some(_), Some(_)) if ns == ReconcileNs::Head => {
953 if existing_served {
954 ReconcileOp::Write
955 } else {
956 ReconcileOp::ForceRewind
957 }
958 }
959 // Tag with an existing tip: free-move force-retarget to the served target.
960 (Some(_), Some(_)) => ReconcileOp::Write,
961 // Nothing served, nothing present.
962 (None, None) => ReconcileOp::Skip,
963 // Nothing served, but a tag exists whose marker target is served-but-
964 // unminted AND the existing tag is itself served: PRESERVE (r18).
965 (None, Some(_)) if marker_served_unminted && existing_served => ReconcileOp::Preserve,
966 // Nothing served, an existing ref remains: DELETE (whole-line embargo, r19
967 // embargoed existing tag, or a deleted marker's stale tag).
968 (None, Some(_)) => ReconcileOp::Delete,
969 }
970}
971
972pub fn git_remote_names(heddle_repo: &HeddleRepository) -> HashSet<String> {
973 let Ok(repo) = SleyRepository::discover(heddle_repo.root()) else {
974 return HashSet::new();
975 };
976 repo.remote_names()
977 .unwrap_or_default()
978 .into_iter()
979 .filter(|name| !name.trim().is_empty())
980 .collect()
981}
982
983pub fn is_remote_tracking_thread_name(thread: &str, remote_names: &HashSet<String>) -> bool {
984 let Some((remote, branch)) = thread.split_once('/') else {
985 return false;
986 };
987 !branch.is_empty() && remote_names.contains(remote)
988}
989
990/// Purge from `mapping` every reachable state whose effective visibility is no
991/// longer served by `audience`, and return the Git OIDs that were dropped so
992/// the caller can retract any ref still pointing at them.
993///
994/// A state can be minted while public and only later marked under-tier; its
995/// stale StateId→OID mapping is rebuilt from the notes/sidecar on every
996/// export, so the served set must be re-derived against CURRENT visibility
997/// here rather than trusted from the mapping. The purge is downward-closed: a
998/// still-visible state is unserved if any reachable ancestor is unserved,
999/// because its minted Git commit chains to the ancestor's (now-embargoed)
1000/// commit. `sorted_states` is topological (parents before children), so a
1001/// parent's served-ness is decided before its child is examined.
1002fn purge_unserved_mappings(
1003 heddle_repo: &HeddleRepository,
1004 mapping: &mut SyncMapping,
1005 sorted_states: &[StateId],
1006 reachable: &HashSet<StateId>,
1007 audience: &AudienceTier,
1008) -> GitProjectionResult<HashSet<ObjectId>> {
1009 let served = served_state_ids(heddle_repo, sorted_states, reachable, audience)?;
1010 let mut purged: HashSet<ObjectId> = HashSet::new();
1011 for state_id in sorted_states {
1012 if !served.contains(state_id)
1013 && let Some(oid) = mapping.remove(state_id)
1014 {
1015 purged.insert(oid);
1016 }
1017 }
1018 Ok(purged)
1019}
1020
1021/// The downward-closure served set (spike §5.0): a state is served iff it is
1022/// visible to `audience` AND every *reachable* parent is itself served. The
1023/// topo order of `sorted_states` guarantees a parent's servedness is already
1024/// decided when its child is visited. A parent outside `reachable` is a shallow
1025/// boundary (public-by-absence, treated as served).
1026///
1027/// The single notion of "served" shared by the branch-frontier purge and the
1028/// notes-ref retraction — so a note can never be published for a commit whose
1029/// branch the same rule would withhold (heddle#316).
1030fn served_state_ids(
1031 heddle_repo: &HeddleRepository,
1032 sorted_states: &[StateId],
1033 reachable: &HashSet<StateId>,
1034 audience: &AudienceTier,
1035) -> GitProjectionResult<HashSet<StateId>> {
1036 let mut served: HashSet<StateId> = HashSet::new();
1037 for state_id in sorted_states {
1038 let tier = heddle_repo
1039 .effective_visibility_tier(state_id)
1040 .map_err(|e| {
1041 GitProjectionError::Git(format!("resolve visibility for {state_id}: {e:#}"))
1042 })?;
1043 let parents_served = match heddle_repo.store().get_state(state_id)? {
1044 Some(state) => state
1045 .parents
1046 .iter()
1047 .all(|p| !reachable.contains(p) || served.contains(p)),
1048 None => true,
1049 };
1050 if visible(&tier, audience) && parents_served {
1051 served.insert(*state_id);
1052 }
1053 }
1054 Ok(served)
1055}
1056
1057/// Resolve `ref_name` to its tip commit OID in the mirror, or `None` when the
1058/// ref is absent or unpeelable.
1059fn branch_tip_oid(repo: &SleyRepository, ref_name: &str) -> Option<ObjectId> {
1060 let oid = repo
1061 .find_reference(ref_name)
1062 .ok()
1063 .flatten()?
1064 .peeled_oid(repo)
1065 .ok()
1066 .flatten()?;
1067 peel_to_commit_oid(repo, oid)
1068}
1069
1070fn direct_ref_oid(repo: &SleyRepository, ref_name: &str) -> Option<ObjectId> {
1071 match repo.find_reference(ref_name).ok()??.target {
1072 ReferenceTarget::Direct(oid) => Some(oid),
1073 ReferenceTarget::Symbolic(_) => None,
1074 }
1075}
1076
1077fn peel_to_commit_oid(repo: &SleyRepository, mut oid: ObjectId) -> Option<ObjectId> {
1078 loop {
1079 let object = repo.read_object(&oid).ok()?;
1080 match object.object_type {
1081 GitObjectType::Commit => return Some(oid),
1082 GitObjectType::Tag => {
1083 oid = repo.read_tag(&oid).ok()?.object;
1084 }
1085 _ => return None,
1086 }
1087 }
1088}
1089
1090/// Project the DESIRED heddle-owned ref-set for an export: full ref name → its
1091/// served target OID. A ref appears iff heddle should publish it now; a ref the
1092/// projection omits is one the mirror reconcile must DELETE (its prior export is
1093/// stale). This is the single place that decides WHICH refs exist and at WHAT
1094/// target — the mirror reconcile, and downstream every destination reconcile,
1095/// derive their ops (create / fast-forward / forced rewind / delete / skip) from
1096/// this set, so a surface can never silently drop out of one enforcement pass
1097/// while another keeps serving it (heddle#316 r13).
1098///
1099/// * heads — `refs/heads/<thread>` at the maximal SERVED ancestor-or-self of the
1100/// thread tip ([`frontier_git_oid`]); a thread whose whole line is unserved is
1101/// ABSENT (downward-closed: an embargoed commit and its descendants stay off
1102/// the public branch).
1103/// * tags — `refs/tags/<marker>` at the marker's served state; a marker whose
1104/// state is not served (embargoed, withheld for a withheld ancestor, or
1105/// retargeted to a never-minted Private state) is ABSENT.
1106///
1107/// Notes (`refs/notes/heddle`) are the history-bearing member of the desired set
1108/// and are projected by content rebuild (backfill + [`git_notes::remove_notes`])
1109/// upstream rather than a target swap, so they are not enumerated here.
1110fn project_desired_refs(
1111 heddle_repo: &HeddleRepository,
1112 mapping: &SyncMapping,
1113 threads: &[String],
1114 markers: &[MarkerName],
1115) -> GitProjectionResult<std::collections::HashMap<String, ObjectId>> {
1116 let mut desired = std::collections::HashMap::new();
1117 for track_name in threads {
1118 let Some(tip) = heddle_repo
1119 .refs()
1120 .get_thread(&ThreadName::new(track_name))?
1121 else {
1122 continue;
1123 };
1124 if let Some(git_oid) = frontier_git_oid(heddle_repo, mapping, tip)? {
1125 desired.insert(format!("refs/heads/{track_name}"), git_oid);
1126 }
1127 }
1128 for marker_name in markers {
1129 let Some(state_id) = heddle_repo.refs().get_marker(marker_name)? else {
1130 continue;
1131 };
1132 if let Some(git_oid) = mapping.get_git(&state_id) {
1133 desired.insert(format!("refs/tags/{marker_name}"), git_oid);
1134 }
1135 }
1136 Ok(desired)
1137}
1138
1139/// The Git OID the public branch should lag to for a thread whose raw tip is
1140/// `tip`: the maximal **served** ancestor-or-self of `tip`. A state is served
1141/// iff it is present in the mapping — `purge_unserved_mappings` runs first to
1142/// drop any mapped-but-now-embargoed state (and its descendants), so the mapped
1143/// set is exactly the served set. Returns `None` when no ancestor of `tip` is
1144/// served (the whole line is embargoed to its root → absence).
1145fn frontier_git_oid(
1146 heddle_repo: &HeddleRepository,
1147 mapping: &SyncMapping,
1148 tip: StateId,
1149) -> GitProjectionResult<Option<ObjectId>> {
1150 let mut visited = HashSet::new();
1151 let mut stack = vec![tip];
1152 let mut frontier: Vec<StateId> = Vec::new();
1153 while let Some(id) = stack.pop() {
1154 if !visited.insert(id) {
1155 continue;
1156 }
1157 // Stop at the first served (mapped) state on each downward path: that
1158 // is a maximal served ancestor — its own served ancestors are
1159 // dominated by it, so we do not descend past it.
1160 if mapping.get_git(&id).is_some() {
1161 frontier.push(id);
1162 continue;
1163 }
1164 if let Some(state) = heddle_repo.store().get_state(&id)? {
1165 stack.extend(state.parents.iter().copied());
1166 }
1167 }
1168 // A linear thread yields exactly one maximal served state. A merge whose
1169 // embargo splits the DAG can leave an antichain of ≥2 maximal served
1170 // states; advertising each sibling line under its own ref is the
1171 // multi-root work deferred to issues #4/#5. Until then the branch lags
1172 // deterministically (lowest StateId) — never published from a raw
1173 // embargoed tip — and the other lines are absent from this branch.
1174 let chosen = frontier.into_iter().min_by_key(|c| c.to_string_full());
1175 Ok(chosen.and_then(|c| mapping.get_git(&c)))
1176}
1177
1178fn reachable_states(
1179 heddle_repo: &HeddleRepository,
1180 roots: &[StateId],
1181) -> GitProjectionResult<Vec<StateId>> {
1182 let mut stack = roots.to_vec();
1183 let mut seen = HashSet::new();
1184 let mut states = Vec::new();
1185 while let Some(state_id) = stack.pop() {
1186 if !seen.insert(state_id) {
1187 continue;
1188 }
1189 states.push(state_id);
1190 if let Some(state) = heddle_repo.store().get_state(&state_id)? {
1191 stack.extend(state.parents.iter().copied());
1192 }
1193 }
1194 Ok(states)
1195}
1196
1197fn state_to_signature(state: &objects::object::State) -> Signature {
1198 let seconds = state.created_at.timestamp();
1199 let raw = format!(
1200 "{} <{}> {} +0000",
1201 state.attribution.principal.name, state.attribution.principal.email, seconds
1202 )
1203 .into_bytes();
1204 Signature {
1205 name: sley::plumbing::sley_core::ByteString::new(
1206 state.attribution.principal.name.as_bytes().to_vec(),
1207 ),
1208 email: sley::plumbing::sley_core::ByteString::new(
1209 state.attribution.principal.email.as_bytes().to_vec(),
1210 ),
1211 time: sley::GitTime::new(seconds, 0),
1212 raw,
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use objects::object::{Attribution, ContentHash, Principal, State};
1219
1220 use super::*;
1221
1222 fn fidelity_state() -> State {
1223 State::new(
1224 ContentHash::from_bytes([7u8; 32]),
1225 vec![],
1226 Attribution::human(Principal::new("Alice", "alice@example.com")),
1227 )
1228 .with_raw_message("an imported commit\n")
1229 }
1230
1231 /// The fidelity guard reconstructs a byte-faithful imported commit.
1232 #[test]
1233 fn byte_faithful_when_fidelity_present_and_not_lossy() {
1234 assert!(commit_is_byte_faithful(&fidelity_state()));
1235 }
1236
1237 /// The canonical `git_lossy` marker — set by BOTH `import --lossy` and
1238 /// `ingest --lossy` — routes the commit OFF the reconstruct path regardless
1239 /// of which import surface produced it. A lossy import drops/converts tree
1240 /// entries, so reconstructing from state would mint a wrong SHA.
1241 #[test]
1242 fn lossy_marker_blocks_reconstruction() {
1243 let lossy = fidelity_state().with_git_lossy(true);
1244 assert!(
1245 !commit_is_byte_faithful(&lossy),
1246 "a state carrying the canonical git_lossy marker must NOT be \
1247 reconstructed from state, regardless of import surface"
1248 );
1249 }
1250}