Skip to main content

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, &note)?;
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, &note_target_roots)?;
614    let note_reachable: HashSet<StateId> = note_reachable_vec.iter().copied().collect();
615    let note_sorted = bridge.sort_states_topologically(&note_reachable_vec)?;
616    let note_served =
617        served_state_ids(bridge.heddle_repo, &note_sorted, &note_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, &note)?;
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, &notes_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}