Skip to main content

heddle_git_projection/
git_core.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Core Git Projection and Bridge Mirror operations.
3
4use std::{
5    collections::{BTreeSet, HashMap, HashSet},
6    fs::{self, OpenOptions},
7    io::Write,
8    path::{Path, PathBuf},
9    time::{SystemTime, UNIX_EPOCH},
10};
11
12use objects::{
13    error::HeddleError,
14    object::{ContentHash, FileMode, Principal, StateId, StateIdParseError, ThreadName, Tree},
15    store::ObjectStore,
16};
17use refs::Head;
18use repo::{
19    AudienceTier, GitCheckpointIntent, GitCheckpointIntentPhase, GitRefName,
20    Repository as HeddleRepository,
21};
22pub use repo::{
23    GitRefContentNamespace as RefNamespace, GitRefKind, ParsedGitRef,
24    REMOTE_NAME_FOR_LOCAL_GIT_REPO, is_reserved_git_remote_name,
25};
26use sley::{
27    BString as GitBString, DeleteRef, FullName, GitObjectType, GitTime, HeadUpdateOptions, Index,
28    IndexEntry, IndexWriteOptions, ObjectFormat, ObjectId, RefPrecondition, ReferenceTarget,
29    Repository as SleyRepository, Signature,
30    plumbing::sley_core::ByteString as GitByteString,
31    remote::{
32        CredentialProvider, FetchOptions, LsRemoteFilter, NoCredentials, ProgressSink,
33        PushActionPlan, PushCommand, PushOptions, SilentProgress,
34    },
35};
36
37use super::{
38    credential::EmbeddingSafeCredentialProvider,
39    git_export::{
40        ExportStateOptions, commit_is_byte_faithful, export_all, export_current_thread,
41        export_state,
42    },
43    git_ingest::import_git_history,
44    git_notes,
45    git_reconstruct::{commit_object_id, reconstruct_commit_bytes, write_commit_object},
46    git_residual::{ResidualStore, resolve_lossy_object},
47    git_util::ImportStats,
48};
49
50/// Errors specific to Git Projection and Bridge Mirror operations.
51#[derive(Debug, thiserror::Error)]
52pub enum GitProjectionError {
53    #[error("git error: {0}")]
54    Git(String),
55
56    #[error("store error: {0}")]
57    Store(#[from] HeddleError),
58
59    #[error("io error: {0}")]
60    Io(#[from] std::io::Error),
61
62    #[error("invalid trailer format: {0}")]
63    InvalidTrailer(String),
64
65    #[error("missing required trailer: {0}")]
66    MissingTrailer(String),
67
68    #[error("invalid mapping: {0}")]
69    InvalidMapping(String),
70
71    #[error("commit not found: {0}")]
72    CommitNotFound(String),
73
74    #[error("state not found: {0}")]
75    StateNotFound(StateId),
76
77    #[error("git repository not initialized")]
78    GitRepoNotInitialized,
79
80    #[error(
81        "shallow Git repository at {repository} cannot be imported until full ancestry is available"
82    )]
83    ShallowClone {
84        repository: PathBuf,
85        retry_command: String,
86    },
87
88    #[error("conflict during sync: {0}")]
89    Conflict(String),
90
91    #[error("Git Projection Mapping conflict: {message}")]
92    MappingConflict { message: String },
93
94    #[error("Git branch '{branch}' cannot be imported as a Heddle thread: {message}")]
95    InvalidThreadName { branch: String, message: String },
96
97    #[error(
98        "Git branch {branch} and Heddle thread {thread} diverged: thread {thread_change}, branch {branch_change}"
99    )]
100    GitHeddleThreadDiverged {
101        thread: String,
102        branch: String,
103        thread_change: StateId,
104        branch_change: StateId,
105    },
106
107    #[error(
108        "ref update would rewrite {name}: {old} -> {new}; refusing to replace a user-visible Git commit with a Heddle export commit"
109    )]
110    NonFastForwardRef {
111        name: String,
112        old: ObjectId,
113        new: ObjectId,
114        /// True when the rejected ref belongs to a push destination; false
115        /// when Heddle is updating the checkout's authoritative local `.git`.
116        remote_destination: bool,
117    },
118
119    #[error(
120        "remote branch {upstream} does not fast-forward the local Git checkpoint for {branch}: local {local}, remote {remote}"
121    )]
122    RemoteDiverged {
123        branch: String,
124        upstream: String,
125        local: ObjectId,
126        remote: ObjectId,
127    },
128
129    #[error("change id parse error: {0}")]
130    StateIdParse(#[from] StateIdParseError),
131}
132
133/// Type alias for Git Projection and Bridge Mirror results.
134pub type GitProjectionResult<T> = std::result::Result<T, GitProjectionError>;
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct RefUpdate {
138    pub name: String,
139    pub target: ObjectId,
140    pub namespace: RefNamespace,
141}
142
143/// Reject a remote name that collides with [`REMOTE_NAME_FOR_LOCAL_GIT_REPO`].
144/// Surfaced at the public fetch/pull accept boundary with an actionable
145/// message, and re-applied as an invariant net at every
146/// `refs/remotes/{name}/...` write site, so a remote named `git` can never be
147/// treated as a normal remote-tracking namespace — keeping the writers
148/// consistent with [`parse_git_ref`], which already rejects such refs.
149fn reject_reserved_git_remote_name(remote: &str) -> GitProjectionResult<()> {
150    if is_reserved_git_remote_name(remote) {
151        return Err(GitProjectionError::Git(format!(
152            "a Git remote named '{remote}' collides with heddle's reserved namespace \
153             (local refs are recorded under the '{REMOTE_NAME_FOR_LOCAL_GIT_REPO}' sentinel); \
154             configure it under another name with `heddle remote add origin <url>`, \
155             remove the reserved entry with `heddle remote remove {remote}`, and retry"
156        )));
157    }
158    Ok(())
159}
160
161fn remote_name_from_remote_ref(ref_name: &str) -> Option<&str> {
162    GitRefName::new(ref_name).remote_name()
163}
164
165fn validate_refspec_ref(ref_name: &str) -> GitProjectionResult<()> {
166    if let Some(remote) = remote_name_from_remote_ref(ref_name) {
167        reject_reserved_git_remote_name(remote)?;
168    }
169    Ok(())
170}
171
172/// Parse a fully-qualified Git ref name into its [`GitRefKind`], short name,
173/// and owning remote. Returns `None` for refs outside the
174/// branch/remote-branch/tag/notes namespaces (e.g. `HEAD`).
175///
176/// Ported from jj's `parse_git_ref` (`lib/src/git.rs`) and extended for
177/// Heddle's notes content namespace; the symbolic `HEAD` and
178/// `refs/remotes/<remote>/HEAD` entries are not treated as refs.
179pub fn parse_git_ref(ref_name: &str) -> Option<ParsedGitRef<'_>> {
180    RefSpec::new(None, ref_name, false).ok()?;
181    GitRefName::new(ref_name).git_projection_ref()
182}
183
184/// A Git refspec: an optional `source`, a `destination`, and a `forced` (`+`)
185/// marker. Ported from jj's `RefSpec` (`lib/src/git.rs`).
186mod refspec {
187    use super::{GitProjectionResult, validate_refspec_ref};
188
189    #[derive(Debug, Clone, PartialEq, Eq)]
190    pub struct RefSpec {
191        forced: bool,
192        /// `None` encodes a delete refspec (`:destination`).
193        source: Option<String>,
194        destination: String,
195    }
196
197    impl RefSpec {
198        /// Construct a refspec after enforcing reserved-remote-name invariants.
199        pub fn new(
200            source: Option<String>,
201            destination: impl Into<String>,
202            forced: bool,
203        ) -> GitProjectionResult<Self> {
204            let destination = destination.into();
205            if source.is_none() && destination.is_empty() {
206                return Err(super::GitProjectionError::InvalidMapping(
207                    "refspec source and destination cannot both be empty".to_string(),
208                ));
209            }
210            if let Some(source) = source.as_deref() {
211                validate_refspec_ref(source)?;
212            }
213            validate_refspec_ref(&destination)?;
214            Ok(Self {
215                forced,
216                source,
217                destination,
218            })
219        }
220
221        /// A forced (`+`) refspec mapping `source` onto `destination`.
222        pub fn forced(
223            source: impl Into<String>,
224            destination: impl Into<String>,
225        ) -> GitProjectionResult<Self> {
226            Self::new(Some(source.into()), destination, true)
227        }
228
229        /// A delete refspec (`:destination`). Not forced: deleting a destination
230        /// that has no source cannot lose work.
231        pub fn delete(destination: impl Into<String>) -> GitProjectionResult<Self> {
232            Self::new(None, destination, false)
233        }
234
235        /// Render in `git` refspec syntax, including the leading `+` when forced.
236        pub fn to_git_format(&self) -> String {
237            format!(
238                "{}{}",
239                if self.forced { "+" } else { "" },
240                self.to_git_format_not_forced()
241            )
242        }
243
244        /// Render in `git` refspec syntax without the leading `+`, even when forced.
245        pub fn to_git_format_not_forced(&self) -> String {
246            format!(
247                "{}:{}",
248                self.source.as_deref().unwrap_or(""),
249                self.destination
250            )
251        }
252    }
253}
254
255pub use refspec::RefSpec;
256
257/// A negative refspec (`^source`) excluding refs from a fetch or push. Ported
258/// from jj's `NegativeRefSpec` (`lib/src/git.rs`).
259mod negative_refspec {
260    use super::{GitProjectionError, GitProjectionResult, validate_refspec_ref};
261
262    #[derive(Debug, Clone, PartialEq, Eq)]
263    pub struct NegativeRefSpec {
264        source: String,
265    }
266
267    impl NegativeRefSpec {
268        /// Construct a negative refspec after validating the rendered `^source`
269        /// form Git will receive.
270        pub fn new(source: impl Into<String>) -> GitProjectionResult<Self> {
271            let source = source.into();
272            validate_refspec_ref(&source)?;
273            if source.contains('*') {
274                return Err(GitProjectionError::InvalidMapping(format!(
275                    "invalid negative refspec source '{source}': Negative glob patterns are not supported"
276                )));
277            }
278            Ok(Self { source })
279        }
280
281        /// Render in `git` refspec syntax (`^source`).
282        pub fn to_git_format(&self) -> String {
283            format!("^{}", self.source)
284        }
285    }
286}
287
288// Keep the concrete fields in a private submodule. Callers outside this module
289// cannot construct `NegativeRefSpec { ... }` directly (E0451), so all values
290// pass through `NegativeRefSpec::new`.
291pub use negative_refspec::NegativeRefSpec;
292
293/// The fetch refspecs heddle uses to mirror a remote: every branch and every
294/// heddle note, forced. Built through [`RefSpec`] so the wire format has a
295/// single typed source of truth.
296fn heddle_mirror_fetch_refspecs() -> GitProjectionResult<[String; 2]> {
297    Ok([
298        RefSpec::forced("refs/heads/*", "refs/heads/*")?.to_git_format(),
299        RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format(),
300    ])
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum GitPushScope {
305    CurrentThread,
306    AllThreads,
307}
308
309#[derive(Debug, Clone, Default)]
310pub struct GitPullOutcome {
311    pub changed: bool,
312    pub states_created: usize,
313    pub commits_seen: usize,
314    pub materialized_checkout: bool,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318enum PullPreflight {
319    UpToDate,
320    ImportRequired,
321}
322
323fn pull_outcome(stats: &ImportStats, materialized_checkout: bool) -> GitPullOutcome {
324    GitPullOutcome {
325        changed: materialized_checkout || stats.states_created > 0,
326        states_created: stats.states_created,
327        commits_seen: stats.commits_imported,
328        materialized_checkout,
329    }
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333enum GitFetchScope {
334    BranchesAndNotes,
335    AllRefs,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339enum RefreshCheckoutAfterFetch {
340    Yes,
341    No,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345enum RemoteDirection {
346    Fetch,
347    Push,
348}
349
350#[derive(Debug, Clone)]
351enum ResolvedRemote {
352    Local(PathBuf),
353    Url(String),
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum WriteThroughSkipReason {
358    MissingDotGit,
359    DetachedHead,
360    NoAttachedThread,
361    NoMappedCommit,
362    MirrorIsWorktree,
363    IndexAlreadyDirty,
364}
365
366impl std::fmt::Display for WriteThroughSkipReason {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        match self {
369            WriteThroughSkipReason::MissingDotGit => {
370                write!(f, "this checkout does not have a Git working tree")
371            }
372            WriteThroughSkipReason::DetachedHead => {
373                write!(f, "Git HEAD is detached")
374            }
375            WriteThroughSkipReason::NoAttachedThread => {
376                write!(f, "the attached Heddle thread does not resolve to a state")
377            }
378            WriteThroughSkipReason::NoMappedCommit => {
379                write!(f, "the current Heddle state has not been exported to Git")
380            }
381            WriteThroughSkipReason::MirrorIsWorktree => {
382                write!(f, "the legacy Bridge Mirror target is the checkout itself")
383            }
384            WriteThroughSkipReason::IndexAlreadyDirty => {
385                write!(f, "the Git index is already locked by another operation")
386            }
387        }
388    }
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum WriteThroughOutcome {
393    Wrote(ObjectId),
394    Skipped(WriteThroughSkipReason),
395}
396
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct LocalGitIdentity {
399    pub name: String,
400    pub email: String,
401}
402
403impl LocalGitIdentity {
404    pub fn from_principal(principal: &Principal) -> Self {
405        Self {
406            name: principal.name.clone(),
407            email: principal.email.clone(),
408        }
409    }
410
411    pub fn to_ident_line(&self, seconds: i64) -> Vec<u8> {
412        format!("{} <{}> {} +0000", self.name, self.email, seconds).into_bytes()
413    }
414
415    pub fn to_signature(&self, seconds: i64) -> Signature {
416        let ident = self.to_ident_line(seconds);
417        Signature {
418            name: GitByteString::new(self.name.as_bytes().to_vec()),
419            email: GitByteString::new(self.email.as_bytes().to_vec()),
420            time: GitTime::new(seconds, 0),
421            raw: ident,
422        }
423    }
424}
425
426impl WriteThroughOutcome {
427    pub fn object_id(self) -> Option<ObjectId> {
428        match self {
429            WriteThroughOutcome::Wrote(oid) => Some(oid),
430            WriteThroughOutcome::Skipped(_) => None,
431        }
432    }
433
434    pub fn skip_reason(self) -> Option<WriteThroughSkipReason> {
435        match self {
436            WriteThroughOutcome::Skipped(reason) => Some(reason),
437            WriteThroughOutcome::Wrote(_) => None,
438        }
439    }
440}
441
442/// Mapping between Heddle StateIds and Git commit object IDs.
443#[derive(Debug, Clone, Default, PartialEq, Eq)]
444pub struct SyncMapping {
445    /// Maps Heddle StateId -> Git object id
446    heddle_to_git: HashMap<StateId, ObjectId>,
447    /// Maps Git object id -> Heddle StateId
448    git_to_heddle: HashMap<ObjectId, StateId>,
449}
450
451impl SyncMapping {
452    /// Create a new empty mapping.
453    pub fn new() -> Self {
454        Self::default()
455    }
456
457    /// Insert a mapping.
458    pub fn insert(&mut self, state_id: StateId, git_oid: ObjectId) {
459        if let Some(previous_git) = self.heddle_to_git.remove(&state_id) {
460            self.git_to_heddle.remove(&previous_git);
461        }
462        if let Some(previous_change) = self.git_to_heddle.remove(&git_oid) {
463            self.heddle_to_git.remove(&previous_change);
464        }
465        self.heddle_to_git.insert(state_id, git_oid);
466        self.git_to_heddle.insert(git_oid, state_id);
467    }
468
469    /// Insert a mapping and detect conflicts.
470    pub fn insert_checked(
471        &mut self,
472        state_id: StateId,
473        git_oid: ObjectId,
474    ) -> GitProjectionResult<()> {
475        if let Some(existing) = self.heddle_to_git.get(&state_id)
476            && *existing != git_oid
477        {
478            return Err(GitProjectionError::MappingConflict {
479                message: format!(
480                    "change id {} mapped to {} (new {})",
481                    state_id, existing, git_oid
482                ),
483            });
484        }
485
486        if let Some(existing) = self.git_to_heddle.get(&git_oid)
487            && *existing != state_id
488        {
489            return Err(GitProjectionError::MappingConflict {
490                message: format!(
491                    "git oid {} mapped to {} (new {})",
492                    git_oid, existing, state_id
493                ),
494            });
495        }
496
497        self.insert(state_id, git_oid);
498        Ok(())
499    }
500
501    /// Get Git object id for a Heddle StateId.
502    pub fn get_git(&self, state_id: &StateId) -> Option<ObjectId> {
503        self.heddle_to_git.get(state_id).copied()
504    }
505
506    /// Get Heddle StateId for a Git object id.
507    pub fn get_heddle(&self, git_oid: ObjectId) -> Option<StateId> {
508        self.git_to_heddle.get(&git_oid).copied()
509    }
510
511    /// Check if a mapping exists for a StateId.
512    pub fn has_heddle(&self, state_id: &StateId) -> bool {
513        self.heddle_to_git.contains_key(state_id)
514    }
515
516    /// Drop the mapping for `state_id`, clearing both directions. Returns the
517    /// Git OID that was mapped, if any.
518    ///
519    /// The export visibility purge calls this to remove a state whose
520    /// effective tier is no longer served by the export audience. Without it,
521    /// a stale StateId→OID mapping (minted while the state was public, kept
522    /// alive by the notes/cache rebuild on the next export) makes the
523    /// frontier walk and the tag/note sync treat a now-embargoed commit as
524    /// served — leaking it via `refs/heads/<thread>` or a tag.
525    pub fn remove(&mut self, state_id: &StateId) -> Option<ObjectId> {
526        let git_oid = self.heddle_to_git.remove(state_id)?;
527        self.git_to_heddle.remove(&git_oid);
528        Some(git_oid)
529    }
530
531    /// Check if a mapping exists for a Git object id.
532    pub fn has_git(&self, git_oid: ObjectId) -> bool {
533        self.git_to_heddle.contains_key(&git_oid)
534    }
535
536    /// Iterate over mappings.
537    pub fn iter(&self) -> impl Iterator<Item = (&StateId, &ObjectId)> {
538        self.heddle_to_git.iter()
539    }
540
541    /// Whether the in-memory mapping holds no `StateId → git OID` entries. The
542    /// checkout-materialization path (#568 P1) uses this to decide whether it must
543    /// hydrate the mapping from disk (a standalone projection checkout) or trust
544    /// the mapping export just built in memory (a checkpoint/push).
545    pub fn is_empty(&self) -> bool {
546        self.heddle_to_git.is_empty()
547    }
548
549    pub fn retain_git_objects(&mut self, repo: &SleyRepository) {
550        let retained: Vec<(StateId, ObjectId)> = self
551            .heddle_to_git
552            .iter()
553            .filter_map(|(state_id, git_oid)| {
554                repo.read_object(git_oid)
555                    .ok()
556                    .map(|_| (*state_id, *git_oid))
557            })
558            .collect();
559
560        self.heddle_to_git.clear();
561        self.git_to_heddle.clear();
562        for (state_id, git_oid) in retained {
563            self.insert(state_id, git_oid);
564        }
565    }
566
567    #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
568    pub fn retain_git_object_set(&mut self, reachable: &HashSet<ObjectId>) -> usize {
569        let before = self.heddle_to_git.len();
570        let retained: Vec<(StateId, ObjectId)> = self
571            .heddle_to_git
572            .iter()
573            .filter(|(_, git_oid)| reachable.contains(*git_oid))
574            .map(|(state_id, git_oid)| (*state_id, *git_oid))
575            .collect();
576
577        self.heddle_to_git.clear();
578        self.git_to_heddle.clear();
579        for (state_id, git_oid) in retained {
580            self.insert(state_id, git_oid);
581        }
582        before.saturating_sub(self.heddle_to_git.len())
583    }
584}
585
586/// Legacy-named implementation for explicit Git Projection and Bridge Mirror operations.
587pub struct GitProjection<'a> {
588    pub heddle_repo: &'a HeddleRepository,
589    pub git_repo_path: Option<PathBuf>,
590    pub mapping: SyncMapping,
591    pub commit_message_overrides: HashMap<StateId, String>,
592    pub commit_parent_overrides: HashMap<StateId, Vec<ObjectId>>,
593    linearize_unmapped_tip_to_checkout: bool,
594}
595
596struct MappingFileSnapshot {
597    path: PathBuf,
598    contents: Option<Vec<u8>>,
599}
600
601impl MappingFileSnapshot {
602    fn read(path: PathBuf) -> GitProjectionResult<Self> {
603        let contents = match fs::read(&path) {
604            Ok(contents) => Some(contents),
605            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
606            Err(error) => return Err(error.into()),
607        };
608        Ok(Self { path, contents })
609    }
610
611    fn restore(self) -> GitProjectionResult<()> {
612        match self.contents {
613            Some(contents) => {
614                if let Some(parent) = self.path.parent() {
615                    fs::create_dir_all(parent)?;
616                }
617                fs::write(&self.path, contents)?;
618            }
619            None => match fs::remove_file(&self.path) {
620                Ok(()) => {}
621                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
622                Err(error) => return Err(error.into()),
623            },
624        }
625        Ok(())
626    }
627}
628
629struct CheckoutWrite {
630    checkout_repo: SleyRepository,
631    object_repo: SleyRepository,
632    git_dir: PathBuf,
633    branch_ref: String,
634    head_path: PathBuf,
635    index_path: PathBuf,
636    previous_head: Option<Vec<u8>>,
637    previous_index: Option<Vec<u8>>,
638    previous_branch: Option<ObjectId>,
639}
640
641impl CheckoutWrite {
642    fn prepare(root: &Path, thread: &str) -> GitProjectionResult<Self> {
643        let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
644        let git_dir = checkout_repo.git_dir().to_path_buf();
645        if git_dir.join("index.lock").exists() {
646            return Err(GitProjectionError::Git(
647                "Git index is locked; checkpoint write-through cannot proceed".to_string(),
648            ));
649        }
650        let object_repo = common_repo_for_worktree(&checkout_repo)?;
651        let branch_ref = format!("refs/heads/{thread}");
652        let head_path = git_dir.join("HEAD");
653        let index_path = git_dir.join("index");
654        let previous_head = read_optional_file(&head_path)?;
655        let previous_index = read_optional_file(&index_path)?;
656        let previous_branch = match object_repo.find_reference(&branch_ref).map_err(git_err)? {
657            Some(reference) => reference.peeled_oid(&object_repo).map_err(git_err)?,
658            None => None,
659        };
660        Ok(Self {
661            checkout_repo,
662            object_repo,
663            git_dir,
664            branch_ref,
665            head_path,
666            index_path,
667            previous_head,
668            previous_index,
669            previous_branch,
670        })
671    }
672
673    fn excluded_objects(&self) -> GitProjectionResult<HashSet<ObjectId>> {
674        match self.previous_branch {
675            Some(parent) => sley::plumbing::sley_odb::collect_reachable_object_ids(
676                self.object_repo.objects().as_ref(),
677                self.object_repo.object_format(),
678                [parent],
679            )
680            .map_err(git_err),
681            None => Ok(HashSet::new()),
682        }
683    }
684
685    fn publish(&self, git_oid: ObjectId) -> GitProjectionResult<()> {
686        let published_head = format!("ref: {}\n", self.branch_ref).into_bytes();
687        let mut head_written = false;
688        let mut index_written = false;
689        let mut branch_update_attempted = false;
690        let mut published_index = None;
691        let result = (|| -> GitProjectionResult<()> {
692            write_head_symref(&self.git_dir, &self.branch_ref)?;
693            head_written = true;
694            let commit = self.object_repo.read_commit(&git_oid).map_err(git_err)?;
695            let mut index = self
696                .object_repo
697                .index_from_tree(&commit.tree)
698                .map_err(git_err)?;
699            index.upgrade_version_for_flags();
700            published_index = Some(
701                index
702                    .write(self.checkout_repo.object_format())
703                    .map_err(git_err)?,
704            );
705            self.checkout_repo
706                .write_index(
707                    &index,
708                    IndexWriteOptions {
709                        fsync: true,
710                        validate_checksum: true,
711                    },
712                )
713                .map_err(git_err)?;
714            index_written = true;
715            branch_update_attempted = true;
716            let head_reflog = update_checkout_branch_ref(
717                &self.checkout_repo,
718                &self.branch_ref,
719                git_oid,
720                self.previous_branch,
721                "heddle: write-through current thread",
722            )?;
723            self.checkout_repo
724                .references()
725                .append_reflog("HEAD", &head_reflog)
726                .map_err(git_err)?;
727            fsync_path(&self.head_path)?;
728            fsync_path(&self.index_path)?;
729            fsync_path(&self.git_dir)?;
730            Ok(())
731        })();
732
733        if let Err(error) = result {
734            if branch_update_attempted {
735                rollback_reference_if_unchanged(
736                    &self.object_repo,
737                    &self.branch_ref,
738                    git_oid,
739                    self.previous_branch,
740                )?;
741            }
742            if index_written {
743                restore_file_if_unchanged(
744                    &self.index_path,
745                    published_index.as_deref().expect("written index bytes"),
746                    self.previous_index.as_deref(),
747                )?;
748            }
749            if head_written {
750                restore_file_if_unchanged(
751                    &self.head_path,
752                    &published_head,
753                    self.previous_head.as_deref(),
754                )?;
755            }
756            let _ = fsync_path(&self.head_path);
757            let _ = fsync_path(&self.index_path);
758            let _ = fsync_path(&self.git_dir);
759            return Err(error);
760        }
761        Ok(())
762    }
763}
764
765impl<'a> GitProjection<'a> {
766    /// Create a Git Projection helper for a Heddle repository.
767    pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
768        Self {
769            heddle_repo,
770            git_repo_path: None,
771            mapping: SyncMapping::new(),
772            commit_message_overrides: HashMap::new(),
773            commit_parent_overrides: HashMap::new(),
774            linearize_unmapped_tip_to_checkout: false,
775        }
776    }
777
778    /// Initialize the Bridge Mirror in the .heddle/git directory.
779    pub fn init_mirror(&mut self) -> GitProjectionResult<()> {
780        let _guard = self.init_mirror_with_guard()?;
781        _guard.commit();
782        Ok(())
783    }
784
785    /// Variant of `init_mirror` that returns a `MirrorInitGuard` so
786    /// callers performing a multi-step bring-up (init + first export)
787    /// can roll back the partially-created mirror if a later step
788    /// fails. Call `guard.commit()` once the mirror is known-good.
789    pub fn init_mirror_with_guard(&mut self) -> GitProjectionResult<MirrorInitGuard> {
790        let git_dir = self.heddle_repo.heddle_dir().join("git");
791
792        let did_create = if git_dir.exists() {
793            let _ = open_repo(&git_dir)?;
794            false
795        } else {
796            fs::create_dir_all(&git_dir)?;
797            let _ = SleyRepository::init_bare(&git_dir).map_err(git_err)?;
798            let mirror_repo = open_repo(&git_dir)?;
799            seed_checkout_note_refs_into_mirror(self.heddle_repo.root(), &mirror_repo)?;
800            true
801        };
802
803        self.git_repo_path = Some(git_dir.clone());
804        Ok(MirrorInitGuard::new_from_init(git_dir, did_create))
805    }
806
807    /// Get the path to the legacy Bridge Mirror directory.
808    pub fn mirror_path(&self) -> PathBuf {
809        self.heddle_repo.heddle_dir().join("git")
810    }
811
812    /// Check if a legacy Bridge Mirror is initialized.
813    pub fn is_initialized(&self) -> bool {
814        self.mirror_path().exists()
815    }
816
817    /// Open the Git repository (mirror or regular).
818    pub fn open_git_repo(&self) -> GitProjectionResult<SleyRepository> {
819        if let Some(ref path) = self.git_repo_path {
820            open_repo(path)
821        } else {
822            let mirror_path = self.mirror_path();
823            if mirror_path.exists() {
824                open_repo(&mirror_path)
825            } else {
826                open_repo(self.heddle_repo.root())
827            }
828        }
829    }
830
831    /// Sort states topologically (parents before children).
832    pub fn sort_states_topologically(
833        &self,
834        states: &[StateId],
835    ) -> GitProjectionResult<Vec<StateId>> {
836        let mut sorted = Vec::new();
837        let mut visited: std::collections::HashSet<StateId> = std::collections::HashSet::new();
838
839        fn visit<S: ObjectStore + ?Sized>(
840            state_id: &StateId,
841            store: &S,
842            visited: &mut std::collections::HashSet<StateId>,
843            sorted: &mut Vec<StateId>,
844        ) -> GitProjectionResult<()> {
845            if visited.contains(state_id) {
846                return Ok(());
847            }
848
849            if let Some(state) = store.get_state(state_id)? {
850                for parent in &state.parents {
851                    visit(parent, store, visited, sorted)?;
852                }
853            }
854
855            visited.insert(*state_id);
856            sorted.push(*state_id);
857
858            Ok(())
859        }
860
861        for state_id in states {
862            visit(
863                state_id,
864                self.heddle_repo.store(),
865                &mut visited,
866                &mut sorted,
867            )?;
868        }
869
870        Ok(sorted)
871    }
872
873    /// Export all Heddle states to Git commits.
874    pub fn export(&mut self) -> GitProjectionResult<super::git_util::ExportStats> {
875        export_all(self)
876    }
877
878    pub fn set_commit_message_override(&mut self, state_id: StateId, message: String) {
879        self.commit_message_overrides.insert(state_id, message);
880    }
881
882    pub fn set_commit_parent_override(&mut self, state_id: StateId, parents: Vec<ObjectId>) {
883        self.commit_parent_overrides.insert(state_id, parents);
884    }
885
886    /// Export an otherwise-unmapped tip on top of the checkout branch's
887    /// current Git commit. Multi-peer land enables this for every peer after
888    /// the first so the batch advances one linear Git branch.
889    pub fn linearize_unmapped_tip_to_checkout(&mut self) {
890        self.linearize_unmapped_tip_to_checkout = true;
891    }
892
893    pub fn with_mapping_rollback<T>(
894        &mut self,
895        operation: impl FnOnce(&mut Self) -> GitProjectionResult<T>,
896    ) -> GitProjectionResult<T> {
897        let mapping = self.mapping.clone();
898        let commit_message_overrides = self.commit_message_overrides.clone();
899        let commit_parent_overrides = self.commit_parent_overrides.clone();
900        let mapping_file = MappingFileSnapshot::read(self.mapping_path())?;
901        let mapping_tmp_file = MappingFileSnapshot::read(self.mapping_tmp_path())?;
902
903        match operation(self) {
904            Ok(value) => Ok(value),
905            Err(error) => {
906                self.mapping = mapping;
907                self.commit_message_overrides = commit_message_overrides;
908                self.commit_parent_overrides = commit_parent_overrides;
909                if let Err(rollback_error) = mapping_file
910                    .restore()
911                    .and_then(|()| mapping_tmp_file.restore())
912                {
913                    return Err(GitProjectionError::Git(format!(
914                        "operation failed ({error}); additionally failed to roll back Git Projection Mapping state ({rollback_error})"
915                    )));
916                }
917                Err(error)
918            }
919        }
920    }
921
922    /// Push to a Git remote. Returns the full names of the refs written
923    /// at the destination this invocation (see [`Self::push_with_scope_force`]).
924    pub fn push(&mut self, remote_name: &str) -> GitProjectionResult<Vec<String>> {
925        self.push_with_scope(remote_name, GitPushScope::AllThreads)
926    }
927
928    /// Push to a Git remote with an explicit ref scope. Returns the full
929    /// names of the refs written at the destination this invocation.
930    pub fn push_with_scope(
931        &mut self,
932        remote_name: &str,
933        scope: GitPushScope,
934    ) -> GitProjectionResult<Vec<String>> {
935        self.push_with_scope_force(remote_name, scope, false)
936    }
937
938    /// Push to a Git remote with an explicit ref scope and optional
939    /// non-fast-forward ref movement.
940    ///
941    /// Returns the full names (e.g. `refs/heads/<thread>`,
942    /// `refs/notes/heddle`, `refs/tags/<tag>`) of the refs WRITTEN at the
943    /// destination this invocation — creations, fast-forwards, and forced
944    /// rewinds — sorted for deterministic output. A no-op push returns an
945    /// empty list. Retraction deletes are not included.
946    pub fn push_with_scope_force(
947        &mut self,
948        remote_name: &str,
949        scope: GitPushScope,
950        force: bool,
951    ) -> GitProjectionResult<Vec<String>> {
952        self.init_mirror()?;
953        let current_branch = match scope {
954            GitPushScope::CurrentThread => Some(self.current_attached_thread_for_push()?),
955            GitPushScope::AllThreads => None,
956        };
957        match scope {
958            GitPushScope::CurrentThread => {
959                export_current_thread(self, current_branch.as_deref().expect("current branch"))?;
960            }
961            GitPushScope::AllThreads => {
962                self.export()?;
963                self.mirror_checkout_tags_for_push()?;
964            }
965        }
966        self.write_current_checkout_from_existing_mirror()?;
967
968        // The export step above (scoped or all-thread) has already reconciled the
969        // mirror to the served frontier, so a scoped export materialized only the
970        // requested thread yet still RECONCILED every out-of-scope sibling (rewound
971        // an embargoed one). Both destination paths therefore reconcile against the
972        // WHOLE-MIRROR served frontier — `collect_ref_updates(mirror)`, computed
973        // inside each path — never a scope-filtered subset; the scope lives in the
974        // mirror state, not in a second destination filter (heddle#316 r16).
975        let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
976        match self.resolve_remote(remote_name, RemoteDirection::Push)? {
977            ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
978                &target_path,
979                &log_message,
980                /* init_if_missing */ false,
981                scope,
982                current_branch.as_deref(),
983                force,
984            ),
985            ResolvedRemote::Url(url) => {
986                let mirror_repo = self.open_git_repo()?;
987                push_network_remote(
988                    &mirror_repo,
989                    self.heddle_repo.heddle_dir(),
990                    &url,
991                    scope,
992                    current_branch.as_deref(),
993                    force,
994                )
995            }
996        }
997    }
998
999    fn current_attached_thread_for_push(&self) -> GitProjectionResult<String> {
1000        let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1001            return Err(GitProjectionError::Git(
1002                "cannot push the current Git-overlay branch from a detached Heddle HEAD; use --all-threads to push all exported refs".to_string(),
1003            ));
1004        };
1005        if self.heddle_repo.refs().get_thread(&thread)?.is_none() {
1006            return Err(GitProjectionError::Git(format!(
1007                "attached thread '{thread}' has no state to push"
1008            )));
1009        }
1010        Ok(thread.to_string())
1011    }
1012
1013    /// Export current Heddle state into the internal mirror, then write it out
1014    /// as a bare git repository at `target_path`. Auto-initializes
1015    /// `target_path` as a bare repo if it does not already exist.
1016    pub fn export_to_path(
1017        &mut self,
1018        target_path: &Path,
1019    ) -> GitProjectionResult<super::git_util::ExportStats> {
1020        self.init_mirror()?;
1021        let stats = self.export()?;
1022        self.copy_mirror_to_path(
1023            target_path,
1024            &format!("heddle: export from {}", self.heddle_repo.root().display()),
1025            /* init_if_missing */ true,
1026            GitPushScope::AllThreads,
1027            /* current_branch */ None,
1028            /* force */ false,
1029        )?;
1030        Ok(stats)
1031    }
1032
1033    /// Shared helper: copy every reachable object from the internal mirror to
1034    /// `target_path`, then reconcile its branch/tag/note refs to the WHOLE-MIRROR
1035    /// served frontier. When `init_if_missing` is true, the destination is created
1036    /// as a bare repo when it does not exist. `scope`/`current_branch` gate only
1037    /// MATERIALIZATION (a scoped push never publishes a brand-new sibling); `force`
1038    /// authorizes retracting an out-of-band destination tip and forcing a true fork.
1039    ///
1040    /// Returns the sorted full names of the refs written at the destination.
1041    fn copy_mirror_to_path(
1042        &mut self,
1043        target_path: &Path,
1044        log_message: &str,
1045        init_if_missing: bool,
1046        scope: GitPushScope,
1047        current_branch: Option<&str>,
1048        force: bool,
1049    ) -> GitProjectionResult<Vec<String>> {
1050        let mirror_repo = self.open_git_repo()?;
1051        let target_repo = if target_path.exists() {
1052            open_repo(target_path)?
1053        } else if init_if_missing {
1054            fs::create_dir_all(target_path)?;
1055            SleyRepository::init_bare(target_path).map_err(git_err)?;
1056            open_repo(target_path)?
1057        } else {
1058            return Err(GitProjectionError::Git(format!(
1059                "destination '{}' does not exist",
1060                target_path.display()
1061            )));
1062        };
1063
1064        // The WHOLE-MIRROR served frontier — the SAME projection the mirror
1065        // reconcile materialized (heddle#316 r14/r16). It drives BOTH the object
1066        // transfer AND the destination ref reconcile, so a scoped push reconciles
1067        // the destination against the whole served frontier rather than a
1068        // scope-filtered subset: an out-of-scope ref the mirror rewound for
1069        // embargo propagates to the destination by construction, never kept at its
1070        // old (embargoed) tip.
1071        //
1072        // Sourced from the MANAGED-filtered ref set (heddle#316): a foreign
1073        // branch/tag heddle never wrote — even one at a heddle-minted commit —
1074        // must NOT enter the served frontier nor the destination's desired set.
1075        // Ownership is name-keyed via the mirror's managed-refs record, the
1076        // mirror-side analog of the destination's exported-refs record.
1077        let managed_record = read_mirror_managed_refs(&mirror_repo)?;
1078        let served_frontier = collect_managed_ref_updates(&mirror_repo, &managed_record)?;
1079        copy_reachable_objects(
1080            &mirror_repo,
1081            &target_repo,
1082            served_frontier.iter().map(|update| update.target),
1083        )?;
1084
1085        // The ONE served-frontier reconciliation, shared with the URL/network
1086        // push path (heddle#316 r11). It writes survivors — FORCING a deliberate
1087        // embargo rewind past the FF guard (a prior tip lagged down to its served
1088        // ancestor) while still rejecting a true fork — AND deletes the refs
1089        // heddle previously exported here that the served mirror no longer
1090        // carries (retraction), leaving foreign refs heddle never exported
1091        // untouched.
1092        let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
1093        let old_at_destination = read_destination_ref_map(&target_repo)?;
1094        let previously_exported = read_exported_refs(&target_repo)?;
1095        let plan = plan_destination_reconcile(
1096            &mirror_repo,
1097            &served_frontier,
1098            creatable.as_ref(),
1099            &old_at_destination,
1100            &previously_exported,
1101            force,
1102        )?;
1103        for write in &plan.writes {
1104            let constraint = match write.old {
1105                Some(old) => RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(old)),
1106                None => RefPrecondition::MustNotExist,
1107            };
1108            set_reference(
1109                &target_repo,
1110                &write.full_name,
1111                write.new,
1112                constraint,
1113                log_message,
1114            )?;
1115        }
1116        for delete in &plan.deletes {
1117            delete_reference_matching(&target_repo, &delete.full_name, delete.old)?;
1118        }
1119        write_exported_refs(&target_repo, &plan.new_manifest)?;
1120        Ok(planned_write_names(&plan))
1121    }
1122
1123    /// Fetch Git refs and objects into the internal mirror without moving
1124    /// Heddle thread refs or the current worktree.
1125    pub fn fetch(&mut self, remote_name: &str) -> GitProjectionResult<()> {
1126        self.fetch_with_scope(
1127            remote_name,
1128            GitFetchScope::BranchesAndNotes,
1129            RefreshCheckoutAfterFetch::Yes,
1130        )
1131    }
1132
1133    fn fetch_with_scope(
1134        &mut self,
1135        remote_name: &str,
1136        scope: GitFetchScope,
1137        refresh_checkout: RefreshCheckoutAfterFetch,
1138    ) -> GitProjectionResult<()> {
1139        reject_reserved_git_remote_name(remote_name)?;
1140        self.init_mirror()?;
1141        let current_branch = self.heddle_repo.git_overlay_current_branch()?;
1142        let tracking_remote = checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1143            .or_else(|| {
1144                (!looks_like_remote_location(remote_name)).then(|| remote_name.to_string())
1145            });
1146        // A URL/path remote can still resolve onto a configured remote literally
1147        // named `git`; reject that here too so the constructed tracking refs
1148        // never land under the reserved namespace.
1149        if let Some(tracking_remote) = tracking_remote.as_deref() {
1150            reject_reserved_git_remote_name(tracking_remote)?;
1151        }
1152
1153        let mirror_repo = self.open_git_repo()?;
1154        match self.resolve_remote(remote_name, RemoteDirection::Fetch)? {
1155            ResolvedRemote::Local(path) => {
1156                let remote_repo = open_repo(&path)?;
1157                let updates = collect_ref_updates_for_fetch(&remote_repo, scope)?;
1158                tracing::debug!(
1159                    remote = remote_name,
1160                    path = %path.display(),
1161                    refs = updates.len(),
1162                    notes = updates
1163                        .iter()
1164                        .filter(|update| update.namespace == RefNamespace::Note)
1165                        .count(),
1166                    "fetching Git refs from local remote"
1167                );
1168                copy_reachable_objects(
1169                    &remote_repo,
1170                    &mirror_repo,
1171                    updates.iter().map(|update| update.target),
1172                )?;
1173                apply_ref_updates(
1174                    &mirror_repo,
1175                    &updates,
1176                    &format!("heddle: fetch from {remote_name}"),
1177                )?;
1178                if let Some(tracking_remote) = tracking_remote.as_deref() {
1179                    apply_remote_tracking_ref_updates(
1180                        &mirror_repo,
1181                        tracking_remote,
1182                        &updates,
1183                        &format!("heddle: fetch from {remote_name}"),
1184                    )?;
1185                }
1186            }
1187            ResolvedRemote::Url(url) => {
1188                fetch_network_remote(&mirror_repo, remote_name, &url, scope)?;
1189                let updates = collect_ref_updates_for_fetch(&mirror_repo, scope)?;
1190                if let Some(tracking_remote) = tracking_remote.as_deref() {
1191                    apply_remote_tracking_ref_updates(
1192                        &mirror_repo,
1193                        tracking_remote,
1194                        &updates,
1195                        &format!("heddle: fetch from {remote_name}"),
1196                    )?;
1197                }
1198            }
1199        }
1200
1201        self.git_repo_path = Some(self.mirror_path());
1202        if matches!(refresh_checkout, RefreshCheckoutAfterFetch::Yes) {
1203            if let Some(tracking_remote) = tracking_remote.as_deref() {
1204                self.refresh_checkout_remote_tracking_refs(tracking_remote)?;
1205            }
1206            if let Some(branch) = current_branch {
1207                self.refresh_checkout_remote_tracking_ref(remote_name, &branch)?;
1208            }
1209            self.refresh_checkout_note_refs_from_mirror()?;
1210        }
1211        Ok(())
1212    }
1213
1214    /// Best-effort adoption preflight for the ingest-backed path.
1215    ///
1216    /// Plain Git clones do not fetch `refs/notes/heddle` by default, but
1217    /// Heddle-pushed overlay remotes use that ref to preserve Git commit
1218    /// -> Heddle state identity. Ingest reads directly from the checkout, so
1219    /// it only needs `refs/notes/heddle` hydrated in the checkout's own object
1220    /// database before `GitSource` opens the repository.
1221    pub fn hydrate_checkout_heddle_notes_without_mirror(root: &Path) -> bool {
1222        if checkout_note_ref_exists(root).unwrap_or(false) {
1223            return true;
1224        }
1225
1226        let mut remotes = match checkout_remote_url_items(root) {
1227            Ok(remotes) => remotes
1228                .into_iter()
1229                .map(|(name, _)| name)
1230                .collect::<Vec<_>>(),
1231            Err(error) => {
1232                tracing::debug!(
1233                    error = %error,
1234                    "skipping configured remote note hydration before ingest-backed adopt"
1235                );
1236                return false;
1237            }
1238        };
1239        remotes.sort_by(|left, right| {
1240            match (left.as_str() == "origin", right.as_str() == "origin") {
1241                (true, false) => std::cmp::Ordering::Less,
1242                (false, true) => std::cmp::Ordering::Greater,
1243                _ => left.cmp(right),
1244            }
1245        });
1246        remotes.dedup();
1247
1248        for remote in remotes {
1249            match hydrate_checkout_notes_from_remote_without_mirror(root, &remote) {
1250                Ok(()) if checkout_note_ref_exists(root).unwrap_or(false) => return true,
1251                Ok(()) => {}
1252                Err(error) => {
1253                    tracing::debug!(
1254                        remote = remote.as_str(),
1255                        error = %error,
1256                        "configured remote did not provide Heddle notes during ingest-backed adopt"
1257                    );
1258                }
1259            }
1260        }
1261
1262        false
1263    }
1264
1265    /// Pull from a Git remote.
1266    pub fn pull(&mut self, remote_name: &str) -> GitProjectionResult<GitPullOutcome> {
1267        let head_before = self.heddle_repo.refs().read_head()?;
1268        let attached_before = match &head_before {
1269            Head::Attached { thread } => self
1270                .heddle_repo
1271                .refs()
1272                .get_thread(thread)?
1273                .map(|state| (thread.to_string(), state)),
1274            Head::Detached { .. } => None,
1275        };
1276        let attached_thread = attached_before.as_ref().map(|(thread, _)| thread.clone());
1277
1278        self.fetch_with_scope(
1279            remote_name,
1280            GitFetchScope::AllRefs,
1281            RefreshCheckoutAfterFetch::No,
1282        )?;
1283        if self.preflight_attached_pull_fast_forward(remote_name, attached_before.as_ref())?
1284            == PullPreflight::UpToDate
1285        {
1286            if let Some(thread) = attached_thread {
1287                self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1288            }
1289            self.refresh_checkout_note_refs_from_mirror()?;
1290            return Ok(GitPullOutcome::default());
1291        }
1292        let mirror_path = self.mirror_path();
1293        let stats = import_git_history(self, Some(&mirror_path), &[], Default::default(), None)?;
1294
1295        let mut materialized_attached_thread = false;
1296        if let Some((thread, old_state)) = attached_before
1297            && let Some(new_state) = self
1298                .heddle_repo
1299                .refs()
1300                .get_thread(&ThreadName::new(&thread))?
1301            && new_state != old_state
1302        {
1303            self.heddle_repo
1304                .refs()
1305                .set_thread(&ThreadName::new(&thread), &old_state)?;
1306            self.heddle_repo.refs().write_head(&Head::Attached {
1307                thread: ThreadName::new(&thread),
1308            })?;
1309            self.heddle_repo
1310                .goto_verified_clean_without_record(&new_state)?;
1311            self.heddle_repo
1312                .refs()
1313                .set_thread(&ThreadName::new(&thread), &new_state)?;
1314            self.heddle_repo.refs().write_head(&Head::Attached {
1315                thread: ThreadName::new(&thread),
1316            })?;
1317            materialized_attached_thread = true;
1318        }
1319
1320        if materialized_attached_thread {
1321            self.write_current_checkout_from_existing_mirror()?;
1322        }
1323        if let Some(thread) = attached_thread {
1324            self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1325        }
1326        self.refresh_checkout_note_refs_from_mirror()?;
1327        Ok(pull_outcome(&stats, materialized_attached_thread))
1328    }
1329
1330    fn preflight_attached_pull_fast_forward(
1331        &mut self,
1332        remote_name: &str,
1333        attached_before: Option<&(String, StateId)>,
1334    ) -> GitProjectionResult<PullPreflight> {
1335        let Some((thread, state_id)) = attached_before else {
1336            return Ok(PullPreflight::ImportRequired);
1337        };
1338        self.build_existing_mapping(None)?;
1339        let Some(local_git_oid) = self.mapping.get_git(state_id) else {
1340            return Ok(PullPreflight::ImportRequired);
1341        };
1342        let mirror_repo = self.open_git_repo()?;
1343        let branch_ref = format!("refs/heads/{thread}");
1344        let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1345            return Ok(PullPreflight::ImportRequired);
1346        };
1347        let Some(remote_git_oid) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1348            return Ok(PullPreflight::ImportRequired);
1349        };
1350        if remote_git_oid == local_git_oid {
1351            return Ok(PullPreflight::UpToDate);
1352        }
1353        if commit_is_descendant_of(&mirror_repo, remote_git_oid, local_git_oid)? {
1354            return Ok(PullPreflight::ImportRequired);
1355        }
1356        Err(GitProjectionError::RemoteDiverged {
1357            branch: thread.clone(),
1358            upstream: format!("{remote_name}/{thread}"),
1359            local: local_git_oid,
1360            remote: remote_git_oid,
1361        })
1362    }
1363
1364    fn mirror_checkout_tags_for_push(&self) -> GitProjectionResult<()> {
1365        if !self.heddle_repo.root().join(".git").exists() {
1366            return Ok(());
1367        }
1368
1369        let mirror_repo = self.open_git_repo()?;
1370        let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1371        if checkout_repo.git_dir() == mirror_repo.git_dir() {
1372            return Ok(());
1373        }
1374        let object_repo = common_repo_for_worktree(&checkout_repo)?;
1375        let tag_updates = collect_ref_updates(&object_repo)?
1376            .into_iter()
1377            .filter(|update| update.namespace == RefNamespace::Tag)
1378            .collect::<Vec<_>>();
1379        if tag_updates.is_empty() {
1380            return Ok(());
1381        }
1382
1383        copy_reachable_objects(
1384            &object_repo,
1385            &mirror_repo,
1386            tag_updates.iter().map(|u| u.target),
1387        )?;
1388        apply_ref_updates(
1389            &mirror_repo,
1390            &tag_updates,
1391            "heddle: mirror checkout tags before push",
1392        )?;
1393        // Claim the raw checkout tags as heddle-managed in the mirror record so
1394        // the managed-filtered push frontier includes them — an all-threads push
1395        // publishes the user's checkout tags on their behalf. This runs AFTER the
1396        // export reconcile (which has no marker for a raw checkout tag and would
1397        // drop it), so each push re-applies + re-claims them; the net effect
1398        // matches the pre-record behavior where the push copied every mirror ref
1399        // (heddle#316).
1400        let mut record = read_mirror_managed_refs(&mirror_repo)?;
1401        for update in &tag_updates {
1402            record.insert(full_ref_name(update), update.target);
1403        }
1404        write_mirror_managed_refs(&mirror_repo, &record)?;
1405        Ok(())
1406    }
1407
1408    pub fn seed_git_checkpoint_mappings_from_checkout(
1409        &mut self,
1410        mirror_repo: &SleyRepository,
1411    ) -> GitProjectionResult<()> {
1412        if !self.heddle_repo.root().join(".git").exists() {
1413            return Ok(());
1414        }
1415
1416        let checkout_repo = match SleyRepository::discover(self.heddle_repo.root()) {
1417            Ok(repo) => repo,
1418            Err(_) => return Ok(()),
1419        };
1420        if checkout_repo.git_dir() == mirror_repo.git_dir() {
1421            return Ok(());
1422        }
1423        let object_repo = common_repo_for_worktree(&checkout_repo)?;
1424        for record in self.heddle_repo.list_git_checkpoints()? {
1425            let git_oid = record
1426                .git_commit
1427                .parse::<ObjectId>()
1428                .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?;
1429            if mirror_repo.read_object(&git_oid).is_err() {
1430                copy_reachable_objects(&object_repo, mirror_repo, [git_oid])?;
1431            }
1432        }
1433
1434        self.seed_git_checkpoint_mappings_from_repo(mirror_repo)
1435    }
1436
1437    fn seed_git_checkpoint_mappings_from_repo(
1438        &mut self,
1439        git_repo: &SleyRepository,
1440    ) -> GitProjectionResult<()> {
1441        for record in self.heddle_repo.list_git_checkpoints()? {
1442            let state_id = StateId::parse(&record.state_id)?;
1443            let git_oid = record
1444                .git_commit
1445                .parse::<ObjectId>()
1446                .map_err(|err| GitProjectionError::InvalidMapping(err.to_string()))?;
1447
1448            git_repo
1449                .read_object(&git_oid)
1450                .map_err(|_| GitProjectionError::CommitNotFound(record.git_commit.clone()))?;
1451
1452            self.mapping.insert(state_id, git_oid);
1453            // Only publish a note for a state served to the public mirror.
1454            // `collect_ref_updates` copies `refs/notes/*`, so writing a note for
1455            // a now-embargoed checkpoint here would leak that commit's metadata
1456            // even though no branch/tag serves it. `export_scoped`'s
1457            // purge+retract closes this for the all-states export, but a scoped
1458            // export never examines an out-of-thread checkpoint — so gate the
1459            // note at its source, symmetric with `export_state`'s minting gate
1460            // (heddle#316). Git projection export always publishes the public Git projection.
1461            let tier = self
1462                .heddle_repo
1463                .effective_visibility_tier(&state_id)
1464                .map_err(|e| {
1465                    GitProjectionError::Git(format!("resolve visibility for {state_id}: {e:#}"))
1466                })?;
1467            if repo::visible(&tier, &repo::AudienceTier::Public)
1468                && super::git_notes::read_note(git_repo, git_oid)?.is_none()
1469                && let Some(state) = self.heddle_repo.store().get_state(&state_id)?
1470            {
1471                let note = super::git_notes::HeddleNote::from_state(&state);
1472                super::git_notes::write_note(git_repo, git_oid, &note)?;
1473            }
1474        }
1475
1476        Ok(())
1477    }
1478
1479    pub fn stage_ingest_source_in_mirror(
1480        &mut self,
1481        source: &Path,
1482        refs: &[String],
1483    ) -> GitProjectionResult<()> {
1484        let source_repo = open_repo(source)?;
1485        let updates = collect_import_source_ref_updates(&source_repo, refs)?;
1486        if updates.is_empty() {
1487            return Ok(());
1488        }
1489
1490        self.init_mirror()?;
1491        let mirror_repo = self.open_git_repo()?;
1492        copy_reachable_objects(
1493            &source_repo,
1494            &mirror_repo,
1495            updates.iter().map(|update| update.target),
1496        )?;
1497        apply_ref_updates(
1498            &mirror_repo,
1499            &updates,
1500            &format!("heddle: stage ingest source from {}", source.display()),
1501        )?;
1502
1503        let mut record = read_or_seed_mirror_managed_refs(&mirror_repo)?;
1504        for update in &updates {
1505            record.insert(full_ref_name(update), update.target);
1506        }
1507        write_mirror_managed_refs(&mirror_repo, &record)?;
1508        Ok(())
1509    }
1510
1511    /// Make the checkout's real `.git` view agree with the current Heddle
1512    /// thread: copy exported objects from the internal mirror, advance the
1513    /// matching Git branch, attach HEAD, and rebuild the Git index from the
1514    /// exported commit tree.
1515    pub fn write_through_current_checkout(&mut self) -> GitProjectionResult<WriteThroughOutcome> {
1516        if !self.heddle_repo.root().join(".git").exists() {
1517            return Ok(WriteThroughOutcome::Skipped(
1518                WriteThroughSkipReason::MissingDotGit,
1519            ));
1520        }
1521        if checkout_git_head_is_detached(self.heddle_repo.root())? {
1522            return Ok(WriteThroughOutcome::Skipped(
1523                WriteThroughSkipReason::DetachedHead,
1524            ));
1525        }
1526        let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1527            return Ok(WriteThroughOutcome::Skipped(
1528                WriteThroughSkipReason::DetachedHead,
1529            ));
1530        };
1531        let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1532            return Ok(WriteThroughOutcome::Skipped(
1533                WriteThroughSkipReason::NoAttachedThread,
1534            ));
1535        };
1536        self.write_thread_state_checkout_direct(&thread, &state_id, None)
1537    }
1538
1539    pub fn write_through_current_checkout_with_message(
1540        &mut self,
1541        state_id: StateId,
1542        message: String,
1543    ) -> GitProjectionResult<WriteThroughOutcome> {
1544        if !self.heddle_repo.root().join(".git").exists() {
1545            return Ok(WriteThroughOutcome::Skipped(
1546                WriteThroughSkipReason::MissingDotGit,
1547            ));
1548        }
1549        if checkout_git_head_is_detached(self.heddle_repo.root())? {
1550            return Ok(WriteThroughOutcome::Skipped(
1551                WriteThroughSkipReason::DetachedHead,
1552            ));
1553        }
1554        self.set_commit_message_override(state_id, message);
1555        let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1556            return Ok(WriteThroughOutcome::Skipped(
1557                WriteThroughSkipReason::DetachedHead,
1558            ));
1559        };
1560        let summary = self
1561            .commit_message_overrides
1562            .get(&state_id)
1563            .cloned()
1564            .unwrap_or_default();
1565        self.write_thread_state_checkout_direct(&thread, &state_id, Some(&summary))
1566    }
1567
1568    /// Mark files that Heddle has captured but that Git still sees as
1569    /// untracked as `intent-to-add` in the colocated checkout's index,
1570    /// so a colocated developer's `git status` shows `AM new_file`
1571    /// ("Heddle knows about it; no Git blob committed yet") instead of
1572    /// `?? new_file` ("untracked — Git knows nothing"). The placeholder
1573    /// entry uses the empty-blob oid and a zeroed stat, so Git always
1574    /// reports the working-tree content as modified-against-index.
1575    ///
1576    /// Ported from jujutsu's `update_intent_to_add` (`lib/src/git.rs`),
1577    /// which diffs `old_tree` vs `new_tree` and flags paths present in
1578    /// the new tree but absent from the old one. Here `new_tree` is the
1579    /// just-captured Heddle state's tree and `old_tree` is whatever the
1580    /// checkout's index already tracks — paths already in the index are
1581    /// not `??`, so they are left untouched (no spurious marking of
1582    /// tracked or unchanged files).
1583    ///
1584    /// Call frequency mirrors jj: this fires at a Heddle parent/state
1585    /// change (`capture`), not on every command. A later `checkpoint`
1586    /// rebuilds the index from the committed tree via
1587    /// [`Self::write_through_current_checkout`], replacing these
1588    /// placeholder entries with real ones — so the index is never
1589    /// churned by read-only invocations.
1590    pub fn update_intent_to_add(&self, state_id: &StateId) -> GitProjectionResult<()> {
1591        let root = self.heddle_repo.root();
1592        if !root.join(".git").exists() {
1593            return Ok(());
1594        }
1595        let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1596        // Skip detached HEAD: write-through only mirrors attached
1597        // threads, and there is no branch context to reason about here.
1598        if checkout_repo
1599            .head()
1600            .map(|head| head.is_detached())
1601            .unwrap_or(false)
1602        {
1603            return Ok(());
1604        }
1605
1606        // `new_tree`: every file the just-captured state contains.
1607        let Some(state) = self.heddle_repo.store().get_state(state_id)? else {
1608            return Ok(());
1609        };
1610        let Some(tree) = self.heddle_repo.store().get_tree(&state.tree)? else {
1611            return Ok(());
1612        };
1613        let mut captured: Vec<(String, FileMode)> = Vec::new();
1614        collect_capture_paths(self.heddle_repo.store(), &tree, "", &mut captured)?;
1615        // No early return on an empty captured set: the reconcile below must
1616        // run on EVERY recapture path. When the recaptured state is empty,
1617        // `captured_paths` is empty too, so the PRUNE pass clears every prior
1618        // intent-to-add entry (all are now stale) and the ADD loop is a no-op.
1619
1620        // Reconcile the index's intent-to-add set against the captured
1621        // state. Real (committed) entries are left untouched; the
1622        // intent-to-add set must end up equal to the captured paths that
1623        // are not yet real entries. So we both ADD newly-captured paths
1624        // and PRUNE intent-to-add entries whose path left the captured
1625        // set (deleted, or now committed) — otherwise a stale entry
1626        // surfaces as a phantom ` D path` in `git status`.
1627        let mut index = checkout_repo
1628            .open_index()
1629            .map_err(git_err)?
1630            .unwrap_or_else(|| Index {
1631                version: 2,
1632                entries: Vec::new(),
1633                extensions: Vec::new(),
1634                checksum: None,
1635            });
1636
1637        // Partition existing entries: real tracked paths vs. the
1638        // intent-to-add placeholders we manage here.
1639        let mut real_tracked: HashSet<String> = HashSet::new();
1640        let mut existing_ita: HashSet<String> = HashSet::new();
1641        for entry in &index.entries {
1642            let path = String::from_utf8_lossy(entry.path.as_bytes()).into_owned();
1643            if entry.is_intent_to_add() {
1644                existing_ita.insert(path);
1645            } else {
1646                real_tracked.insert(path);
1647            }
1648        }
1649
1650        // Desired intent-to-add set: captured paths not backed by a real
1651        // (committed) index entry.
1652        let captured_paths: HashSet<&str> = captured.iter().map(|(p, _)| p.as_str()).collect();
1653
1654        // PRUNE: any intent-to-add entry whose path is no longer desired.
1655        let before_prune = index.entries.len();
1656        index.entries.retain(|entry| {
1657            !entry.is_intent_to_add()
1658                || captured_paths.contains(String::from_utf8_lossy(entry.path.as_bytes()).as_ref())
1659        });
1660        let mut changed = index.entries.len() != before_prune;
1661
1662        // ADD: newly-captured paths not already tracked or marked.
1663        for (path, mode) in &captured {
1664            if real_tracked.contains(path) || existing_ita.contains(path) {
1665                continue;
1666            }
1667            // Git's index cannot hold both a blob `foo` and a blob
1668            // `foo/bar` — a path is either a file or a directory. An
1669            // added path that file↔directory-PREFIX-conflicts with a
1670            // still-tracked real entry is not a clean "new file": the
1671            // real entry wins. Writing an intent-to-add placeholder for
1672            // it would corrupt the index into a file/dir conflict, so
1673            // skip it (checked in both directions).
1674            if real_tracked
1675                .iter()
1676                .any(|tracked| path_prefix_conflict(path, tracked))
1677            {
1678                continue;
1679            }
1680            // Native child-spool edges are not git-tracked files and have no
1681            // git index mode: skip them rather than fabricate a 160000
1682            // submodule entry.
1683            if *mode == FileMode::Spoollink {
1684                continue;
1685            }
1686            let mut entry = IndexEntry::intent_to_add(
1687                checkout_repo.object_format(),
1688                GitBString::from(path.as_str()),
1689            );
1690            entry.mode = match mode {
1691                FileMode::Executable => 0o100755,
1692                FileMode::Symlink => 0o120000,
1693                FileMode::Gitlink => 0o160000,
1694                FileMode::Normal => 0o100644,
1695                // Unreachable: spoollinks are skipped above before this map.
1696                FileMode::Spoollink => 0o100644,
1697            };
1698            changed = true;
1699            index.entries.push(entry);
1700        }
1701
1702        if changed {
1703            index
1704                .entries
1705                .sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
1706            index.upgrade_version_for_flags();
1707            checkout_repo
1708                .write_index(
1709                    &index,
1710                    IndexWriteOptions {
1711                        fsync: true,
1712                        validate_checksum: true,
1713                    },
1714                )
1715                .map_err(git_err)?;
1716        }
1717        Ok(())
1718    }
1719
1720    /// Make the checkout's real `.git` view agree with a specific Heddle
1721    /// thread. `thread switch` uses this after writing Heddle HEAD because
1722    /// resolving "current" through Git-overlay discovery can still see the
1723    /// branch that was active before the switch.
1724    pub fn write_through_thread_checkout(
1725        &mut self,
1726        thread: &str,
1727    ) -> GitProjectionResult<WriteThroughOutcome> {
1728        if !self.heddle_repo.root().join(".git").exists() {
1729            return Ok(WriteThroughOutcome::Skipped(
1730                WriteThroughSkipReason::MissingDotGit,
1731            ));
1732        }
1733
1734        let Some(state_id) = self
1735            .heddle_repo
1736            .refs()
1737            .get_thread(&ThreadName::new(thread))?
1738        else {
1739            return Ok(WriteThroughOutcome::Skipped(
1740                WriteThroughSkipReason::NoAttachedThread,
1741            ));
1742        };
1743        self.write_thread_state_checkout_direct(thread, &state_id, None)
1744    }
1745
1746    fn write_thread_state_checkout_direct(
1747        &mut self,
1748        thread: &str,
1749        state_id: &StateId,
1750        checkpoint_summary: Option<&str>,
1751    ) -> GitProjectionResult<WriteThroughOutcome> {
1752        let git = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1753        if git.git_dir().join("index.lock").exists() {
1754            return Ok(WriteThroughOutcome::Skipped(
1755                WriteThroughSkipReason::IndexAlreadyDirty,
1756            ));
1757        }
1758        let checkout = CheckoutWrite::prepare(self.heddle_repo.root(), thread)?;
1759        self.build_existing_mapping(Some(self.heddle_repo.root()))?;
1760        self.seed_git_checkpoint_mappings_from_repo(&checkout.object_repo)?;
1761        self.seed_ingest_identity_mappings_from_repo(&checkout.object_repo)?;
1762
1763        // Sequential peer lands must advance the checkout branch linearly.
1764        // A restacked Heddle state can otherwise export beside the Git commit
1765        // produced by the previous peer, making the local ref update non-FF.
1766        // Never remint a state that already has a durable Git identity.
1767        if self.linearize_unmapped_tip_to_checkout
1768            && self.mapping.get_git(state_id).is_none()
1769            && !self.commit_parent_overrides.contains_key(state_id)
1770            && let Some(previous) = checkout.previous_branch
1771        {
1772            self.set_commit_parent_override(*state_id, vec![previous]);
1773        }
1774
1775        let identity = git_config_identity_with_global_fallback(self.heddle_repo.root())?;
1776        let audience = AudienceTier::Public;
1777        for reachable in self.sort_states_topologically(&[*state_id])? {
1778            let message_override = self
1779                .commit_message_overrides
1780                .get(&reachable)
1781                .map(String::as_str);
1782            let parent_override = self
1783                .commit_parent_overrides
1784                .get(&reachable)
1785                .map(Vec::as_slice);
1786            if let Some(mapped) = self.mapping.get_git(&reachable) {
1787                let native_object_missing = checkout.object_repo.read_object(&mapped).is_err()
1788                    && self
1789                        .heddle_repo
1790                        .store()
1791                        .get_state(&reachable)?
1792                        .is_some_and(|state| state.raw_message.is_none());
1793                if !native_object_missing {
1794                    continue;
1795                }
1796            }
1797            let Some(git_oid) = export_state(
1798                &mut self.mapping,
1799                self.heddle_repo,
1800                &checkout.object_repo,
1801                &reachable,
1802                ExportStateOptions {
1803                    identity: identity.as_ref(),
1804                    message_override,
1805                    parent_override,
1806                    audience: &audience,
1807                },
1808            )?
1809            else {
1810                continue;
1811            };
1812            if let Some(mapped) = self.mapping.get_git(&reachable)
1813                && mapped != git_oid
1814            {
1815                return Err(GitProjectionError::MappingConflict {
1816                    message: format!(
1817                        "state {reachable} reminted as {git_oid}, but the durable mapping expects {mapped}"
1818                    ),
1819                });
1820            }
1821            self.mapping.insert(reachable, git_oid);
1822            if let Some(state) = self.heddle_repo.store().get_state(&reachable)? {
1823                git_notes::write_note(
1824                    &checkout.object_repo,
1825                    git_oid,
1826                    &git_notes::HeddleNote::from_state(&state),
1827                )?;
1828            }
1829        }
1830
1831        let Some(git_oid) = self.mapping.get_git(state_id) else {
1832            return Ok(WriteThroughOutcome::Skipped(
1833                WriteThroughSkipReason::NoMappedCommit,
1834            ));
1835        };
1836        materialize_active_checkout_closure(
1837            self.heddle_repo,
1838            &self.mapping,
1839            &checkout.object_repo,
1840            state_id,
1841            git_oid,
1842            &checkout.excluded_objects()?,
1843        )?;
1844        self.save_mapping_to_disk()?;
1845
1846        if let Some(summary) = checkpoint_summary {
1847            let pending = self.heddle_repo.pending_git_checkpoint_intent()?;
1848            let previous_git_oid = pending
1849                .as_ref()
1850                .filter(|intent| {
1851                    intent.state_id == state_id.to_string_full()
1852                        && intent.branch == thread
1853                        && intent.new_git_oid == git_oid.to_string()
1854                })
1855                .and_then(|intent| intent.previous_git_oid.clone())
1856                .or_else(|| checkout.previous_branch.map(|oid| oid.to_string()));
1857            let intent = GitCheckpointIntent {
1858                version: 1,
1859                state_id: state_id.to_string_full(),
1860                branch: thread.to_string(),
1861                previous_git_oid,
1862                new_git_oid: git_oid.to_string(),
1863                summary: summary.to_string(),
1864                phase: GitCheckpointIntentPhase::Prepared,
1865            };
1866            self.heddle_repo.begin_git_checkpoint_intent(&intent)?;
1867            objects::fault_inject::maybe_panic_at("git_checkpoint_after_intent_before_publish");
1868        }
1869
1870        checkout.publish(git_oid)?;
1871        if checkpoint_summary.is_some() {
1872            objects::fault_inject::maybe_panic_at("git_checkpoint_after_publish_before_phase");
1873            self.heddle_repo
1874                .mark_git_checkpoint_published(state_id, &git_oid.to_string())?;
1875        }
1876        Ok(WriteThroughOutcome::Wrote(git_oid))
1877    }
1878
1879    pub fn write_current_checkout_from_existing_mirror(
1880        &mut self,
1881    ) -> GitProjectionResult<WriteThroughOutcome> {
1882        if !self.heddle_repo.root().join(".git").exists() {
1883            return Ok(WriteThroughOutcome::Skipped(
1884                WriteThroughSkipReason::MissingDotGit,
1885            ));
1886        }
1887
1888        let (thread, state_id) = match self.heddle_repo.head_ref()? {
1889            Head::Attached { thread } => {
1890                let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1891                    return Ok(WriteThroughOutcome::Skipped(
1892                        WriteThroughSkipReason::NoAttachedThread,
1893                    ));
1894                };
1895                (thread, state_id)
1896            }
1897            Head::Detached { .. } => {
1898                return Ok(WriteThroughOutcome::Skipped(
1899                    WriteThroughSkipReason::DetachedHead,
1900                ));
1901            }
1902        };
1903        self.write_thread_state_checkout_from_existing_mirror(&thread, &state_id)
1904    }
1905
1906    fn write_thread_state_checkout_from_existing_mirror(
1907        &mut self,
1908        thread: &str,
1909        state_id: &StateId,
1910    ) -> GitProjectionResult<WriteThroughOutcome> {
1911        let mirror_repo = self.open_git_repo()?;
1912        if self.mapping.is_empty() {
1913            self.build_existing_mapping(None)?;
1914        }
1915        let git_oid = if let Some(git_oid) = self.mapping.get_git(state_id) {
1916            git_oid
1917        } else if let Some(git_commit) = self
1918            .heddle_repo
1919            .git_overlay_mapped_git_commit_for_state(state_id)
1920            .map_err(|error| GitProjectionError::Git(error.to_string()))?
1921        {
1922            ObjectId::from_hex(mirror_repo.object_format(), &git_commit)
1923                .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?
1924        } else {
1925            return Ok(WriteThroughOutcome::Skipped(
1926                WriteThroughSkipReason::NoMappedCommit,
1927            ));
1928        };
1929
1930        let git = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1931        if git.git_dir().join("index.lock").exists() {
1932            return Ok(WriteThroughOutcome::Skipped(
1933                WriteThroughSkipReason::IndexAlreadyDirty,
1934            ));
1935        }
1936        let checkout = CheckoutWrite::prepare(self.heddle_repo.root(), thread)?;
1937        if checkout.checkout_repo.git_dir() == mirror_repo.git_dir() {
1938            return Ok(WriteThroughOutcome::Skipped(
1939                WriteThroughSkipReason::MirrorIsWorktree,
1940            ));
1941        }
1942        materialize_checkout_closure_from_state(
1943            self.heddle_repo,
1944            &self.mapping,
1945            &mirror_repo,
1946            &checkout.object_repo,
1947            state_id,
1948            git_oid,
1949            &checkout.excluded_objects()?,
1950        )?;
1951        checkout.publish(git_oid)?;
1952        Ok(WriteThroughOutcome::Wrote(git_oid))
1953    }
1954
1955    fn refresh_checkout_remote_tracking_ref(
1956        &self,
1957        remote_name: &str,
1958        branch: &str,
1959    ) -> GitProjectionResult<()> {
1960        if !self.heddle_repo.root().join(".git").exists() {
1961            return Ok(());
1962        }
1963        let Some(tracking_remote) =
1964            checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1965        else {
1966            return Ok(());
1967        };
1968        reject_reserved_git_remote_name(&tracking_remote)?;
1969
1970        let mirror_repo = self.open_git_repo()?;
1971        let branch_ref = format!("refs/heads/{branch}");
1972        let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1973            return Ok(());
1974        };
1975        let Some(target) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1976            return Ok(());
1977        };
1978
1979        let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1980        if checkout_repo.git_dir() == mirror_repo.git_dir() {
1981            return Ok(());
1982        }
1983        let object_repo = common_repo_for_worktree(&checkout_repo)?;
1984        copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
1985        set_reference(
1986            &object_repo,
1987            &format!("refs/remotes/{tracking_remote}/{branch}"),
1988            target,
1989            RefPrecondition::Any,
1990            "heddle: refresh remote-tracking branch after pull",
1991        )?;
1992        Ok(())
1993    }
1994
1995    fn refresh_checkout_remote_tracking_refs(&self, remote_name: &str) -> GitProjectionResult<()> {
1996        if !self.heddle_repo.root().join(".git").exists() {
1997            return Ok(());
1998        }
1999        let Some(tracking_remote) =
2000            checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
2001        else {
2002            return Ok(());
2003        };
2004        reject_reserved_git_remote_name(&tracking_remote)?;
2005
2006        let mirror_repo = self.open_git_repo()?;
2007        let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
2008        if checkout_repo.git_dir() == mirror_repo.git_dir() {
2009            return Ok(());
2010        }
2011        let object_repo = common_repo_for_worktree(&checkout_repo)?;
2012        let prefix = format!("refs/remotes/{remote_name}/");
2013        for reference in mirror_repo.references().list_refs().map_err(git_err)? {
2014            if !reference.name.starts_with(&prefix) {
2015                continue;
2016            }
2017            let ReferenceTarget::Direct(target) = reference.target else {
2018                continue;
2019            };
2020            let full = reference.name;
2021            let Some(branch) = full.strip_prefix(&prefix) else {
2022                continue;
2023            };
2024            if branch.ends_with("/HEAD") {
2025                continue;
2026            }
2027            copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
2028            set_reference(
2029                &object_repo,
2030                &format!("refs/remotes/{tracking_remote}/{branch}"),
2031                target,
2032                RefPrecondition::Any,
2033                "heddle: refresh remote-tracking branch after fetch",
2034            )?;
2035        }
2036        Ok(())
2037    }
2038
2039    fn refresh_checkout_note_refs_from_mirror(&self) -> GitProjectionResult<()> {
2040        if !self.heddle_repo.root().join(".git").exists() {
2041            return Ok(());
2042        }
2043
2044        let mirror_repo = self.open_git_repo()?;
2045        let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
2046        if checkout_repo.git_dir() == mirror_repo.git_dir() {
2047            return Ok(());
2048        }
2049        let object_repo = common_repo_for_worktree(&checkout_repo)?;
2050        let note_updates = collect_ref_updates(&mirror_repo)?
2051            .into_iter()
2052            .filter(|update| update.namespace == RefNamespace::Note)
2053            .collect::<Vec<_>>();
2054        if note_updates.is_empty() {
2055            return Ok(());
2056        }
2057
2058        copy_reachable_objects(
2059            &mirror_repo,
2060            &object_repo,
2061            note_updates.iter().map(|u| u.target),
2062        )?;
2063        apply_ref_updates(
2064            &object_repo,
2065            &note_updates,
2066            "heddle: refresh Heddle note refs",
2067        )?;
2068        Ok(())
2069    }
2070
2071    fn resolve_remote(
2072        &self,
2073        remote_name: &str,
2074        direction: RemoteDirection,
2075    ) -> GitProjectionResult<ResolvedRemote> {
2076        let repo = self.open_git_repo()?;
2077        let url = match remote_url_from_repo(&repo, remote_name, direction)? {
2078            Some(url) => Some(url),
2079            None => self.checkout_remote_url(remote_name, direction)?,
2080        };
2081
2082        let base = repo_relative_base(&repo);
2083        let url = match url {
2084            Some(url) => url,
2085            None => parse_configured_remote_url(remote_name, &base)?,
2086        };
2087
2088        if let Some(path) = local_path_from_url(&url)? {
2089            Ok(ResolvedRemote::Local(path))
2090        } else {
2091            Ok(ResolvedRemote::Url(url))
2092        }
2093    }
2094
2095    fn checkout_remote_url(
2096        &self,
2097        remote_name: &str,
2098        direction: RemoteDirection,
2099    ) -> GitProjectionResult<Option<String>> {
2100        if direction == RemoteDirection::Fetch
2101            && let Some(url) =
2102                remote_fetch_url_from_checkout_config(self.heddle_repo.root(), remote_name)?
2103        {
2104            return Ok(Some(url));
2105        }
2106        let Ok(repo) = SleyRepository::discover(self.heddle_repo.root()) else {
2107            return Ok(None);
2108        };
2109        remote_url_from_repo(&repo, remote_name, direction)
2110    }
2111}
2112
2113fn remote_url_from_repo(
2114    repo: &SleyRepository,
2115    remote_name: &str,
2116    direction: RemoteDirection,
2117) -> GitProjectionResult<Option<String>> {
2118    let config = repo.config_snapshot().map_err(git_err)?;
2119    let push = direction == RemoteDirection::Push;
2120    let value = if push {
2121        config
2122            .get("remote", Some(remote_name), "pushurl")
2123            .or_else(|| config.get("remote", Some(remote_name), "url"))
2124    } else {
2125        config.get("remote", Some(remote_name), "url")
2126    };
2127    let Some(value) = value else {
2128        return Ok(None);
2129    };
2130    let rewritten =
2131        sley::plumbing::sley_config::remotes::rewrite_url_with_config(&config, value, push);
2132    parse_configured_remote_url(&rewritten, &repo_relative_base(repo)).map(Some)
2133}
2134
2135fn checkout_tracking_remote_name(
2136    root: &Path,
2137    requested: &str,
2138) -> GitProjectionResult<Option<String>> {
2139    let remotes = checkout_remote_url_items(root)?;
2140    if remotes.is_empty() {
2141        return Ok(None);
2142    }
2143    if let Some((name, _)) = remotes.iter().find(|(name, _)| name == requested) {
2144        return Ok(Some(name.clone()));
2145    }
2146    if let Some((name, _)) = remotes
2147        .iter()
2148        .find(|(_, url)| configured_remote_values_match(url, requested))
2149    {
2150        return Ok(Some(name.clone()));
2151    }
2152    if looks_like_remote_location(requested) && remotes.len() == 1 {
2153        return Ok(Some(remotes[0].0.clone()));
2154    }
2155    if !looks_like_remote_location(requested) {
2156        return Ok(Some(requested.to_string()));
2157    }
2158    Ok(None)
2159}
2160
2161fn checkout_remote_url_items(root: &Path) -> GitProjectionResult<Vec<(String, String)>> {
2162    let mut remotes = Vec::new();
2163    for config_path in checkout_git_config_paths(root) {
2164        parse_remote_url_items_from_config(&config_path, &mut remotes)?;
2165    }
2166    Ok(remotes)
2167}
2168
2169fn checkout_note_ref_exists(root: &Path) -> GitProjectionResult<bool> {
2170    if !root.join(".git").exists() {
2171        return Ok(false);
2172    }
2173    let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2174    let object_repo = common_repo_for_worktree(&checkout_repo)?;
2175    Ok(object_repo
2176        .find_reference(super::git_notes::NOTES_REF)
2177        .map_err(git_err)?
2178        .is_some())
2179}
2180
2181fn seed_checkout_note_refs_into_mirror(
2182    root: &Path,
2183    mirror_repo: &SleyRepository,
2184) -> GitProjectionResult<()> {
2185    if !root.join(".git").exists() {
2186        return Ok(());
2187    }
2188
2189    let checkout_repo = match SleyRepository::discover(root) {
2190        Ok(repo) => repo,
2191        Err(_) => return Ok(()),
2192    };
2193    if checkout_repo.git_dir() == mirror_repo.git_dir() {
2194        return Ok(());
2195    }
2196    let object_repo = common_repo_for_worktree(&checkout_repo)?;
2197    let note_updates = collect_ref_updates(&object_repo)?
2198        .into_iter()
2199        .filter(|update| update.namespace == RefNamespace::Note)
2200        .collect::<Vec<_>>();
2201    if note_updates.is_empty() {
2202        return Ok(());
2203    }
2204
2205    copy_reachable_objects(
2206        &object_repo,
2207        mirror_repo,
2208        note_updates.iter().map(|update| update.target),
2209    )?;
2210    apply_ref_updates(
2211        mirror_repo,
2212        &note_updates,
2213        "heddle: seed mirror note refs from checkout",
2214    )
2215}
2216
2217fn hydrate_checkout_notes_from_remote_without_mirror(
2218    root: &Path,
2219    remote_name: &str,
2220) -> GitProjectionResult<()> {
2221    reject_reserved_git_remote_name(remote_name)?;
2222    let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2223    let object_repo = common_repo_for_worktree(&checkout_repo)?;
2224    let url = remote_fetch_url_from_checkout_config(root, remote_name)?.ok_or_else(|| {
2225        GitProjectionError::Git(format!("remote '{remote_name}' has no fetch URL"))
2226    })?;
2227
2228    if let Some(path) = local_path_from_url(&url)? {
2229        let remote_repo = open_repo(&path)?;
2230        let note_updates = collect_ref_updates(&remote_repo)?
2231            .into_iter()
2232            .filter(|update| update.namespace == RefNamespace::Note)
2233            .collect::<Vec<_>>();
2234        if note_updates.is_empty() {
2235            return Ok(());
2236        }
2237        copy_reachable_objects(
2238            &remote_repo,
2239            &object_repo,
2240            note_updates.iter().map(|update| update.target),
2241        )?;
2242        apply_ref_updates(
2243            &object_repo,
2244            &note_updates,
2245            &format!("heddle: hydrate notes from {remote_name}"),
2246        )?;
2247        return Ok(());
2248    }
2249
2250    fetch_heddle_notes_into_repo(&object_repo, remote_name, &url)
2251}
2252
2253fn fetch_heddle_notes_into_repo(
2254    repo: &SleyRepository,
2255    remote_name: &str,
2256    url: &str,
2257) -> GitProjectionResult<()> {
2258    let mut credentials = NoCredentials;
2259    let mut progress = SilentProgress;
2260    let refspec = RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format();
2261    repo.fetch(
2262        url,
2263        &[refspec],
2264        FetchOptions {
2265            // sley 0.5.0 additions — heddle uses git defaults (no CLI override).
2266            filter_auto: false,
2267            progress: None,
2268            upload_pack_command: None,
2269            // sley 0.4.2 negotiation/shallow controls — heddle uses defaults
2270            // (no CLI override; use remote config), matching git's defaults.
2271            negotiation_include: None,
2272            negotiation_restrict: None,
2273            reject_shallow: false,
2274            quiet: true,
2275            auto_follow_tags: false,
2276            fetch_all_tags: false,
2277            prune: false,
2278            dry_run: false,
2279            append: false,
2280            write_fetch_head: true,
2281            force: false,
2282            tag_option_explicit: true,
2283            prune_option_explicit: true,
2284            prune_tags: false,
2285            prune_tags_option_explicit: false,
2286            refmap: None,
2287            refetch: false,
2288            record_promisor_refs: false,
2289            update_head_ok: false,
2290            ssh_options: None,
2291            atomic: false,
2292            depth: None,
2293            merge_srcs: Vec::new(),
2294            filter: None,
2295            cloning: false,
2296            update_shallow: false,
2297            deepen_relative: false,
2298            deepen_since: None,
2299            deepen_not: Vec::new(),
2300        },
2301        &mut credentials,
2302        &mut progress,
2303    )
2304    .map(|_| ())
2305    .map_err(|err| {
2306        GitProjectionError::Git(format!("failed to fetch notes from {remote_name}: {err}"))
2307    })
2308}
2309
2310fn parse_remote_url_items_from_config(
2311    path: &Path,
2312    remotes: &mut Vec<(String, String)>,
2313) -> GitProjectionResult<()> {
2314    let Ok(contents) = fs::read_to_string(path) else {
2315        return Ok(());
2316    };
2317    let mut current_remote: Option<String> = None;
2318    for raw in contents.lines() {
2319        let line = raw.trim();
2320        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2321            continue;
2322        }
2323        if line.starts_with('[') && line.ends_with(']') {
2324            current_remote = line
2325                .strip_prefix("[remote \"")
2326                .and_then(|rest| rest.strip_suffix("\"]"))
2327                .map(str::to_string);
2328            continue;
2329        }
2330        let Some(name) = current_remote.as_ref() else {
2331            continue;
2332        };
2333        let Some((key, value)) = line.split_once('=') else {
2334            continue;
2335        };
2336        if key.trim().eq_ignore_ascii_case("url") {
2337            remotes.push((name.clone(), git_config_value(value.trim())?));
2338        }
2339    }
2340    Ok(())
2341}
2342
2343fn configured_remote_values_match(left: &str, right: &str) -> bool {
2344    if left == right {
2345        return true;
2346    }
2347    let left_path = Path::new(left);
2348    let right_path = Path::new(right);
2349    if let (Ok(left), Ok(right)) = (left_path.canonicalize(), right_path.canonicalize()) {
2350        return left == right;
2351    }
2352    false
2353}
2354
2355fn materialize_active_checkout_closure(
2356    heddle_repo: &HeddleRepository,
2357    mapping: &SyncMapping,
2358    object_repo: &SleyRepository,
2359    tip_state_id: &StateId,
2360    tip_oid: ObjectId,
2361    excluded: &HashSet<ObjectId>,
2362) -> GitProjectionResult<()> {
2363    let residuals = ResidualStore::open(heddle_repo.heddle_dir());
2364    let mut stack = vec![*tip_state_id];
2365    let mut seen = HashSet::new();
2366
2367    while let Some(state_id) = stack.pop() {
2368        if !seen.insert(state_id) {
2369            continue;
2370        }
2371        let git_oid = if state_id == *tip_state_id {
2372            tip_oid
2373        } else {
2374            mapping
2375                .get_git(&state_id)
2376                .ok_or(GitProjectionError::StateNotFound(state_id))?
2377        };
2378        if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
2379            continue;
2380        }
2381
2382        let state = heddle_repo
2383            .store()
2384            .get_state(&state_id)?
2385            .ok_or(GitProjectionError::StateNotFound(state_id))?;
2386        if commit_is_byte_faithful(&state) {
2387            let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
2388            let reconstructed = commit_object_id(&content);
2389            if reconstructed != git_oid {
2390                return Err(GitProjectionError::MappingConflict {
2391                    message: format!(
2392                        "state {state_id} reconstructs as {reconstructed}, expected {git_oid}"
2393                    ),
2394                });
2395            }
2396            write_commit_object(object_repo, &content)?;
2397            stack.extend(state.parents);
2398            continue;
2399        }
2400
2401        if !residuals.install_into(object_repo, &git_oid)? {
2402            return Err(GitProjectionError::CommitNotFound(format!(
2403                "{git_oid} for state {state_id}; the authoritative checkout .git and Raw Git Object Residual store do not contain it"
2404            )));
2405        }
2406        match verify_closure_present(object_repo, &[git_oid], excluded) {
2407            Ok(()) => {}
2408            Err(ClosureCheck::Incomplete { missing }) => {
2409                return Err(GitProjectionError::CommitNotFound(format!(
2410                    "{missing} in the closure of {git_oid}; restore the original object to the authoritative checkout .git"
2411                )));
2412            }
2413            Err(ClosureCheck::Walk(error)) => return Err(error),
2414        }
2415    }
2416    Ok(())
2417}
2418
2419fn looks_like_remote_location(value: &str) -> bool {
2420    value.starts_with('/')
2421        || value.starts_with("./")
2422        || value.starts_with("../")
2423        || value.starts_with("~/")
2424        || value.contains("://")
2425        || value.contains('\\')
2426}
2427
2428fn remote_fetch_url_from_checkout_config(
2429    root: &Path,
2430    remote_name: &str,
2431) -> GitProjectionResult<Option<String>> {
2432    for config_path in checkout_git_config_paths(root) {
2433        let Some(url) = parse_remote_fetch_url_from_config(&config_path, remote_name)? else {
2434            continue;
2435        };
2436        return parse_configured_remote_url(&url, root).map(Some);
2437    }
2438    Ok(None)
2439}
2440
2441fn parse_configured_remote_url(value: &str, relative_base: &Path) -> GitProjectionResult<String> {
2442    if configured_remote_is_local_path(value) {
2443        let path = configured_remote_local_path(value, relative_base);
2444        return Ok(format!("file://{}", path.display()));
2445    }
2446    Ok(value.to_string())
2447}
2448
2449fn configured_remote_local_path(value: &str, relative_base: &Path) -> PathBuf {
2450    if value == "~"
2451        && let Some(home) = std::env::var_os("HOME")
2452    {
2453        return PathBuf::from(home);
2454    }
2455    if let Some(rest) = value.strip_prefix("~/")
2456        && let Some(home) = std::env::var_os("HOME")
2457    {
2458        return PathBuf::from(home).join(rest);
2459    }
2460
2461    let path = Path::new(value);
2462    if path.is_absolute() {
2463        path.to_path_buf()
2464    } else {
2465        relative_base.join(path)
2466    }
2467}
2468
2469fn configured_remote_is_local_path(value: &str) -> bool {
2470    value.starts_with('/')
2471        || value.starts_with("./")
2472        || value.starts_with("../")
2473        || value.starts_with('~')
2474        || value.starts_with(std::path::MAIN_SEPARATOR)
2475}
2476
2477fn checkout_git_config_paths(root: &Path) -> Vec<PathBuf> {
2478    let dot_git = root.join(".git");
2479    let mut paths = Vec::new();
2480    if dot_git.is_dir() {
2481        paths.push(dot_git.join("config"));
2482        if let Some(common_dir) = common_git_dir_from_git_dir(&dot_git) {
2483            paths.push(common_dir.join("config"));
2484        }
2485        return paths;
2486    }
2487    let Ok(contents) = fs::read_to_string(&dot_git) else {
2488        return paths;
2489    };
2490    let Some(target) = contents.trim().strip_prefix("gitdir:").map(str::trim) else {
2491        return paths;
2492    };
2493    let git_dir = {
2494        let path = Path::new(target);
2495        if path.is_absolute() {
2496            path.to_path_buf()
2497        } else {
2498            dot_git
2499                .parent()
2500                .map(|parent| parent.join(path))
2501                .unwrap_or_else(|| path.to_path_buf())
2502        }
2503    };
2504    paths.push(git_dir.join("config"));
2505    if let Some(common_dir) = common_git_dir_from_git_dir(&git_dir) {
2506        paths.push(common_dir.join("config"));
2507    }
2508    paths
2509}
2510
2511fn common_git_dir_from_git_dir(git_dir: &Path) -> Option<PathBuf> {
2512    let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
2513    let target = contents.trim();
2514    let path = Path::new(target);
2515    Some(if path.is_absolute() {
2516        path.to_path_buf()
2517    } else {
2518        git_dir.join(path)
2519    })
2520}
2521
2522fn parse_remote_fetch_url_from_config(
2523    path: &Path,
2524    remote_name: &str,
2525) -> GitProjectionResult<Option<String>> {
2526    let Ok(contents) = fs::read_to_string(path) else {
2527        return Ok(None);
2528    };
2529    let mut in_remote = false;
2530    for raw in contents.lines() {
2531        let line = raw.trim();
2532        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2533            continue;
2534        }
2535        if line.starts_with('[') && line.ends_with(']') {
2536            in_remote = line
2537                .strip_prefix("[remote \"")
2538                .and_then(|rest| rest.strip_suffix("\"]"))
2539                == Some(remote_name);
2540            continue;
2541        }
2542        if !in_remote {
2543            continue;
2544        }
2545        let Some((key, value)) = line.split_once('=') else {
2546            continue;
2547        };
2548        if key.trim().eq_ignore_ascii_case("url") {
2549            return git_config_value(value.trim()).map(Some);
2550        }
2551    }
2552    Ok(None)
2553}
2554
2555fn common_repo_for_worktree(repo: &SleyRepository) -> GitProjectionResult<SleyRepository> {
2556    let common_dir_file = repo.git_dir().join("commondir");
2557    let Ok(contents) = fs::read_to_string(&common_dir_file) else {
2558        return Ok(repo.clone());
2559    };
2560    let target = contents.trim();
2561    if target.is_empty() {
2562        return Ok(repo.clone());
2563    }
2564    let common_dir = {
2565        let path = Path::new(target);
2566        if path.is_absolute() {
2567            path.to_path_buf()
2568        } else {
2569            repo.git_dir().join(path)
2570        }
2571    };
2572    open_repo(&common_dir)
2573}
2574
2575pub fn git_err(err: impl std::fmt::Display) -> GitProjectionError {
2576    GitProjectionError::Git(err.to_string())
2577}
2578
2579fn restore_file_if_unchanged(
2580    path: &Path,
2581    expected: &[u8],
2582    previous: Option<&[u8]>,
2583) -> GitProjectionResult<()> {
2584    let file_name = path.file_name().ok_or_else(|| {
2585        GitProjectionError::Git(format!("cannot lock rollback path {}", path.display()))
2586    })?;
2587    let mut lock_name = file_name.to_os_string();
2588    lock_name.push(".lock");
2589    let lock_path = path.with_file_name(lock_name);
2590    let mut lock = OpenOptions::new()
2591        .write(true)
2592        .create_new(true)
2593        .open(&lock_path)
2594        .map_err(|error| {
2595            GitProjectionError::Git(format!(
2596                "cannot acquire Git rollback lock {}: {error}",
2597                lock_path.display()
2598            ))
2599        })?;
2600    let result = (|| {
2601        let current = fs::read(path)?;
2602        if current != expected {
2603            return Err(GitProjectionError::Git(format!(
2604                "refusing to roll back {} because another Git operation changed it",
2605                path.display()
2606            )));
2607        }
2608        if let Some(previous) = previous {
2609            lock.write_all(previous)?;
2610            lock.sync_all()?;
2611            drop(lock);
2612            fs::rename(&lock_path, path)?;
2613        } else {
2614            drop(lock);
2615            fs::remove_file(path)?;
2616            fs::remove_file(&lock_path)?;
2617        }
2618        Ok(())
2619    })();
2620    if result.is_err() {
2621        let _ = fs::remove_file(&lock_path);
2622    }
2623    result
2624}
2625
2626fn read_optional_file(path: &Path) -> GitProjectionResult<Option<Vec<u8>>> {
2627    match fs::read(path) {
2628        Ok(contents) => Ok(Some(contents)),
2629        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2630        Err(error) => Err(error.into()),
2631    }
2632}
2633
2634fn rollback_reference_if_unchanged(
2635    repo: &SleyRepository,
2636    name: &str,
2637    published: ObjectId,
2638    previous: Option<ObjectId>,
2639) -> GitProjectionResult<()> {
2640    let current = match repo.find_reference(name).map_err(git_err)? {
2641        Some(reference) => reference.peeled_oid(repo).map_err(git_err)?,
2642        None => None,
2643    };
2644    if current == previous {
2645        return Ok(());
2646    }
2647    if current != Some(published) {
2648        return Err(GitProjectionError::Git(format!(
2649            "refusing to roll back Git reference '{name}' because another Git operation changed it"
2650        )));
2651    }
2652    let rollback = match previous {
2653        Some(previous) => set_reference(
2654            repo,
2655            name,
2656            previous,
2657            RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(published)),
2658            "heddle: rollback failed write-through",
2659        ),
2660        None => delete_reference_matching(repo, name, published),
2661    };
2662    if rollback.is_ok() {
2663        return Ok(());
2664    }
2665    let current = match repo.find_reference(name).map_err(git_err)? {
2666        Some(reference) => reference.peeled_oid(repo).map_err(git_err)?,
2667        None => None,
2668    };
2669    if current == previous {
2670        Ok(())
2671    } else {
2672        rollback
2673    }
2674}
2675
2676/// `fsync` a single file by opening it read-only and calling
2677/// `sync_all`. Best-effort: missing files are not an error (a Drop
2678/// guard might have removed them between write and fsync).
2679fn fsync_path(path: &Path) -> GitProjectionResult<()> {
2680    match std::fs::File::open(path) {
2681        Ok(file) => {
2682            file.sync_all()?;
2683            Ok(())
2684        }
2685        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2686        Err(err) => Err(GitProjectionError::Io(err)),
2687    }
2688}
2689
2690/// RAII guard for `init_mirror`. When the mirror directory did not
2691/// exist at acquisition time, an early Drop (panic, error return)
2692/// removes the partially-initialized `.heddle/git/` so a future
2693/// `heddle export git ...` doesn't see a half-built bare repo. Call
2694/// `commit()` once the mirror is known-good (e.g. after a successful
2695/// first export) to disarm the guard.
2696pub struct MirrorInitGuard {
2697    path: PathBuf,
2698    /// `Some(true)` means we created the directory in this call and
2699    /// own its rollback; `Some(false)` (or `None` after commit) means
2700    /// hands off.
2701    rollback: Option<bool>,
2702}
2703
2704impl MirrorInitGuard {
2705    pub fn new_from_init(path: PathBuf, did_create: bool) -> Self {
2706        Self {
2707            path,
2708            rollback: Some(did_create),
2709        }
2710    }
2711
2712    pub fn commit(mut self) {
2713        self.rollback = None;
2714    }
2715}
2716
2717impl Drop for MirrorInitGuard {
2718    fn drop(&mut self) {
2719        if matches!(self.rollback, Some(true))
2720            && self.path.exists()
2721            && let Err(err) = std::fs::remove_dir_all(&self.path)
2722        {
2723            tracing::warn!(
2724                path = %self.path.display(),
2725                error = %err,
2726                "failed to roll back partial legacy Bridge Mirror; manual cleanup may be required"
2727            );
2728        }
2729    }
2730}
2731
2732/// Git projection policy: a thread is considered an "unclaimed bootstrap"
2733/// when it /// points at an empty-tree state with no parents. That is the exact shape of
2734/// the state produced by `Repository::seed_default_thread`, and it cannot
2735/// occur through normal user work — any snapshot advances the tip to a state
2736/// with either a non-empty tree or a non-empty parents list.
2737///
2738/// When a user runs `heddle init` followed by `heddle pull` (or
2739/// `import git`), the bootstrap `main` is unclaimed and the incoming git ref
2740/// should win. This helper lets the Git projection engine recognize that
2741/// case without silently overwriting real work.
2742pub fn thread_is_unclaimed_bootstrap(
2743    heddle_repo: &HeddleRepository,
2744    state_id: &StateId,
2745) -> GitProjectionResult<bool> {
2746    let Some(state) = heddle_repo.store().get_state(state_id)? else {
2747        return Ok(false);
2748    };
2749    if !state.parents.is_empty() {
2750        return Ok(false);
2751    }
2752    let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
2753        return Ok(false);
2754    };
2755    Ok(tree == Tree::new())
2756}
2757
2758pub fn open_repo(path: &Path) -> GitProjectionResult<SleyRepository> {
2759    match SleyRepository::discover(path) {
2760        Ok(repo) => Ok(repo),
2761        Err(_) => SleyRepository::open(path).map_err(git_err),
2762    }
2763}
2764
2765/// Delete a reference if present; missing-ref is a no-op. Used by the
2766/// write-through rollback path to drop a branch that was created by a
2767/// failed write-through but isn't reachable from any prior state. We
2768/// scope the deletion with `RefPrecondition::MustExist` so an unrelated
2769/// concurrent writer that *just* updated this ref isn't silently
2770/// clobbered — if the ref vanished underneath us between our read and
2771/// the delete, that's the rollback we wanted anyway.
2772pub fn delete_reference_if_present(repo: &SleyRepository, name: &str) -> GitProjectionResult<()> {
2773    delete_reference(repo, name, None, true)
2774}
2775
2776fn delete_reference_matching(
2777    repo: &SleyRepository,
2778    name: &str,
2779    expected_old: ObjectId,
2780) -> GitProjectionResult<()> {
2781    delete_reference(repo, name, Some(expected_old), false)
2782}
2783
2784fn delete_reference(
2785    repo: &SleyRepository,
2786    name: &str,
2787    expected_old: Option<ObjectId>,
2788    missing_ok: bool,
2789) -> GitProjectionResult<()> {
2790    let refs = repo.references();
2791    match refs.read_ref(name).map_err(git_err)? {
2792        None if missing_ok => Ok(()),
2793        None => Err(GitProjectionError::Git(format!(
2794            "failed to delete Git reference '{name}': ref is missing"
2795        ))),
2796        Some(ReferenceTarget::Direct(oid)) => repo
2797            .delete_ref(DeleteRef {
2798                name: FullName::new(name).map_err(git_err)?,
2799                expected_old: Some(expected_old.unwrap_or(oid)),
2800                expected: None,
2801                reflog: None,
2802                reflog_committer: None,
2803            })
2804            .map_err(git_err),
2805        Some(ReferenceTarget::Symbolic(_)) => {
2806            if let Some(expected_old) = expected_old {
2807                let current = repo
2808                    .find_reference(name)
2809                    .map_err(git_err)?
2810                    .and_then(|reference| reference.peeled_oid(repo).ok().flatten());
2811                if current != Some(expected_old) {
2812                    return Err(GitProjectionError::Git(format!(
2813                        "failed to delete Git reference '{name}': expected {expected_old}, found {}",
2814                        current
2815                            .map(|oid| oid.to_string())
2816                            .unwrap_or_else(|| "missing".to_string())
2817                    )));
2818                }
2819            }
2820            refs.delete_symbolic_ref(name).map(|_| ()).map_err(git_err)
2821        }
2822    }
2823}
2824
2825pub fn set_reference(
2826    repo: &SleyRepository,
2827    name: &str,
2828    target: ObjectId,
2829    constraint: RefPrecondition,
2830    log_message: &str,
2831) -> GitProjectionResult<()> {
2832    let refs = repo.references();
2833    let old_oid = match refs.read_ref(name).map_err(git_err)? {
2834        Some(ReferenceTarget::Direct(oid)) => oid,
2835        _ => ObjectId::null(repo.object_format()),
2836    };
2837    let reflog = sley::plumbing::sley_refs::ReflogEntry {
2838        old_oid,
2839        new_oid: target,
2840        committer: git_projection_signature(),
2841        message: log_message.as_bytes().to_vec(),
2842    };
2843    let mut tx = refs.transaction();
2844    tx.update_to(
2845        name.to_string(),
2846        ReferenceTarget::Direct(target),
2847        constraint,
2848        Some(reflog),
2849    );
2850    tx.commit().map_err(git_err)?;
2851    Ok(())
2852}
2853
2854/// Whether two index paths file↔directory-PREFIX-conflict: one names a
2855/// blob that is a directory prefix of the other (`foo` vs `foo/bar`, in
2856/// either order). Git's index cannot hold both, since a path is either a
2857/// file or a directory. Equal paths do NOT count here — that case is an
2858/// exact match handled separately by the caller.
2859fn path_prefix_conflict(a: &str, b: &str) -> bool {
2860    let child_of = |parent: &str, child: &str| {
2861        child
2862            .strip_prefix(parent)
2863            .is_some_and(|rest| rest.starts_with('/'))
2864    };
2865    child_of(a, b) || child_of(b, a)
2866}
2867
2868/// Recursively collect every Git-indexable leaf path in `tree`,
2869/// resolving subtrees through `store`. Missing subtree objects are
2870/// skipped rather than treated as errors, matching the repo's other
2871/// tree walks. Paths use `/` separators, the form Git's index expects.
2872fn collect_capture_paths<S: ObjectStore + ?Sized>(
2873    store: &S,
2874    tree: &Tree,
2875    prefix: &str,
2876    out: &mut Vec<(String, FileMode)>,
2877) -> GitProjectionResult<()> {
2878    for entry in tree.iter() {
2879        let path = if prefix.is_empty() {
2880            entry.name().to_string()
2881        } else {
2882            format!("{prefix}/{}", entry.name())
2883        };
2884        if entry.is_tree() {
2885            if let Some(hash) = entry.tree_hash()
2886                && let Some(subtree) = store.get_tree(&hash)?
2887            {
2888                collect_capture_paths(store, &subtree, &path, out)?;
2889            }
2890        } else {
2891            out.push((path, entry.mode()));
2892        }
2893    }
2894    Ok(())
2895}
2896
2897fn update_checkout_branch_ref(
2898    repo: &SleyRepository,
2899    branch_ref: &str,
2900    target: ObjectId,
2901    previous_branch: Option<ObjectId>,
2902    log_message: &str,
2903) -> GitProjectionResult<sley::plumbing::sley_refs::ReflogEntry> {
2904    let expected = previous_branch.map_or(RefPrecondition::MustNotExist, |oid| {
2905        RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(oid))
2906    });
2907    let old_oid = previous_branch.unwrap_or_else(|| ObjectId::null(repo.object_format()));
2908    let head_reflog = sley::plumbing::sley_refs::ReflogEntry {
2909        old_oid,
2910        new_oid: target,
2911        committer: git_projection_signature(),
2912        message: log_message.as_bytes().to_vec(),
2913    };
2914    set_reference(repo, branch_ref, target, expected, log_message)?;
2915    Ok(head_reflog)
2916}
2917
2918fn checkout_git_head_is_detached(root: &Path) -> GitProjectionResult<bool> {
2919    let repo = SleyRepository::discover(root).map_err(git_err)?;
2920    Ok(repo.head().map(|head| head.is_detached()).unwrap_or(false))
2921}
2922
2923pub fn resolve_git_commit_identity(
2924    repo_root: &Path,
2925    fallback: &Principal,
2926) -> GitProjectionResult<LocalGitIdentity> {
2927    if !principal_is_default_unknown(fallback) {
2928        return Ok(LocalGitIdentity::from_principal(fallback));
2929    }
2930    if let Some(identity) = git_config_identity_with_global_fallback(repo_root)? {
2931        return Ok(identity);
2932    }
2933
2934    Err(GitProjectionError::Git(
2935        "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(),
2936    ))
2937}
2938
2939pub fn git_config_identity_with_global_fallback(
2940    repo_root: &Path,
2941) -> GitProjectionResult<Option<LocalGitIdentity>> {
2942    let name = git_config_value_with_global_fallback(repo_root, "user.name")?;
2943    let email = git_config_value_with_global_fallback(repo_root, "user.email")?;
2944    if let (Some(name), Some(email)) = (name, email)
2945        && !name.trim().is_empty()
2946        && !email.trim().is_empty()
2947    {
2948        return Ok(Some(LocalGitIdentity { name, email }));
2949    }
2950    Ok(None)
2951}
2952
2953pub fn principal_is_default_unknown(principal: &Principal) -> bool {
2954    principal.name.trim().is_empty()
2955        || principal.email.trim().is_empty()
2956        || (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
2957}
2958
2959fn git_config_value_with_global_fallback(
2960    repo_root: &Path,
2961    key: &str,
2962) -> GitProjectionResult<Option<String>> {
2963    let Ok(repo) = SleyRepository::discover(repo_root) else {
2964        return Ok(None);
2965    };
2966    let Some((section, variable)) = key.split_once('.') else {
2967        return Ok(None);
2968    };
2969    Ok(repo
2970        .config_snapshot()
2971        .map_err(git_err)?
2972        .get(section, None, variable)
2973        .map(str::to_string))
2974}
2975
2976fn git_config_value(value: &str) -> GitProjectionResult<String> {
2977    let Some(quoted) = value
2978        .strip_prefix('"')
2979        .and_then(|rest| rest.strip_suffix('"'))
2980    else {
2981        return Ok(value.to_string());
2982    };
2983    let mut out = String::new();
2984    let mut chars = quoted.chars();
2985    while let Some(ch) = chars.next() {
2986        if ch != '\\' {
2987            out.push(ch);
2988            continue;
2989        }
2990        let Some(escaped) = chars.next() else {
2991            return Err(GitProjectionError::Git(
2992                "unterminated escape in repo-local Git config".to_string(),
2993            ));
2994        };
2995        match escaped {
2996            '"' | '\\' => out.push(escaped),
2997            'n' => out.push('\n'),
2998            't' => out.push('\t'),
2999            'b' => out.push('\u{0008}'),
3000            other => out.push(other),
3001        }
3002    }
3003    Ok(out)
3004}
3005
3006fn git_projection_signature() -> Vec<u8> {
3007    let seconds = SystemTime::now()
3008        .duration_since(UNIX_EPOCH)
3009        .map(|duration| duration.as_secs() as i64)
3010        .unwrap_or(0);
3011    format!("Heddle <heddle@local> {seconds} +0000").into_bytes()
3012}
3013
3014fn repo_relative_base(repo: &SleyRepository) -> PathBuf {
3015    repo.workdir().unwrap_or_else(|| {
3016        if repo
3017            .git_dir()
3018            .file_name()
3019            .is_some_and(|name| name == ".git")
3020        {
3021            repo.git_dir()
3022                .parent()
3023                .map(Path::to_path_buf)
3024                .unwrap_or_else(|| repo.git_dir().to_path_buf())
3025        } else {
3026            repo.git_dir().to_path_buf()
3027        }
3028    })
3029}
3030
3031fn local_path_from_url(url: &str) -> GitProjectionResult<Option<PathBuf>> {
3032    // Defense in depth (push-routing no-op): the git-overlay exporter speaks
3033    // only the local/git network transports. A `heddle://` hosted URL must
3034    // NEVER reach this classifier — the hosted-sync path
3035    // (`GrpcHostedClient`) is the only thing that can push to it. If routing
3036    // upstream is correct this is unreachable; making it a hard error here
3037    // means a `heddle://` slipping into the git exporter can never again be a
3038    // silent success (it would otherwise fall through as a generic network
3039    // URL, "reconcile" locally, and report success without contacting the
3040    // server).
3041    if url.starts_with("heddle://") {
3042        return Err(GitProjectionError::Git(format!(
3043            "remote '{url}' uses the hosted heddle:// scheme, which cannot be pushed via the git-overlay exporter; hosted pushes must go through the native hosted-sync path"
3044        )));
3045    }
3046    let Some(raw_path) = url.strip_prefix("file://") else {
3047        return Ok(None);
3048    };
3049    let path = PathBuf::from(raw_path);
3050    if path.as_os_str().is_empty() {
3051        return Err(GitProjectionError::Git(format!(
3052            "remote '{}' has no filesystem path",
3053            url
3054        )));
3055    }
3056    Ok(Some(path))
3057}
3058
3059fn collect_ref_updates(repo: &SleyRepository) -> GitProjectionResult<Vec<RefUpdate>> {
3060    let mut updates = Vec::new();
3061
3062    for reference in repo.references().list_refs().map_err(git_err)? {
3063        let ReferenceTarget::Direct(target) = reference.target else {
3064            continue;
3065        };
3066        let ref_name = GitRefName::new(&reference.name);
3067        if let Some(namespace) = ref_name.content_namespace()
3068            && let Some(name) = ref_name.short_name()
3069        {
3070            updates.push(RefUpdate {
3071                name: name.to_string(),
3072                target,
3073                namespace,
3074            });
3075        }
3076    }
3077
3078    Ok(updates)
3079}
3080
3081/// A partition of the commits that land in the destination, computed over
3082/// the SINGLE copied ref set. `total` is every unique commit reachable from
3083/// the copied branch/tag tips; `newly` is the subset minted during this
3084/// export run. `already` is the remainder. Because `newly` is a subset of
3085/// the same walk that produced `total`, `newly + already == total` holds by
3086/// construction — the summary can never report more "newly written" than
3087/// "total", and no orphan/unreferenced state (minted but reachable from no
3088/// copied ref, hence never in the walk) can inflate any count.
3089#[derive(Debug, Default, Clone, Copy)]
3090pub struct ExportedCommitCounts {
3091    pub total: usize,
3092    pub newly: usize,
3093}
3094
3095/// Count and partition the commits reachable from the branch and tag tips
3096/// that `collect_ref_updates` writes to a destination. Derived from the SAME
3097/// ref set `copy_mirror_to_path` copies, so the reported counts equal what
3098/// actually lands in the destination — including stale mirror refs left
3099/// behind by a dropped Heddle thread (export does not prune them, so the
3100/// commit is still copied and must still be counted; pruning would be a
3101/// separate behavior change). Notes refs are excluded: they carry
3102/// metadata, not history, so they don't count as exported commits.
3103///
3104/// `newly_minted` is the set of git OIDs freshly minted during this export
3105/// run; a commit in the walk that is in this set is counted as `newly`, the
3106/// rest as `already`. Routing both the total and the newly count through
3107/// this single walk guarantees they can never diverge.
3108pub fn count_exported_commits(
3109    repo: &SleyRepository,
3110    newly_minted: &HashSet<ObjectId>,
3111) -> GitProjectionResult<ExportedCommitCounts> {
3112    let tips: Vec<ObjectId> = collect_ref_updates(repo)?
3113        .into_iter()
3114        .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag))
3115        .map(|update| update.target)
3116        .collect();
3117
3118    let mut stack = tips;
3119    let mut seen = HashSet::new();
3120    let mut counts = ExportedCommitCounts::default();
3121    while let Some(oid) = stack.pop() {
3122        if !seen.insert(oid) {
3123            continue;
3124        }
3125        let object = repo.read_object(&oid).map_err(git_err)?;
3126        match object.object_type {
3127            GitObjectType::Commit => {
3128                counts.total += 1;
3129                if newly_minted.contains(&oid) {
3130                    counts.newly += 1;
3131                }
3132                let commit = repo.read_commit(&oid).map_err(git_err)?;
3133                for parent in commit.parents {
3134                    stack.push(parent);
3135                }
3136            }
3137            // An annotated tag dereferences to its target (commit, or a
3138            // blob/tree for the rare blob/tree-pointing tag). Follow it;
3139            // only a Commit at the end increments the count.
3140            GitObjectType::Tag => {
3141                let tag = repo.read_tag(&oid).map_err(git_err)?;
3142                stack.push(tag.object);
3143            }
3144            GitObjectType::Tree | GitObjectType::Blob => {}
3145        }
3146    }
3147    Ok(counts)
3148}
3149
3150fn collect_ref_updates_for_fetch(
3151    repo: &SleyRepository,
3152    scope: GitFetchScope,
3153) -> GitProjectionResult<Vec<RefUpdate>> {
3154    let updates = collect_ref_updates(repo)?;
3155    match scope {
3156        GitFetchScope::AllRefs => Ok(updates),
3157        GitFetchScope::BranchesAndNotes => Ok(updates
3158            .into_iter()
3159            .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Note))
3160            .collect()),
3161    }
3162}
3163
3164pub fn collect_import_source_ref_updates(
3165    repo: &SleyRepository,
3166    refs: &[String],
3167) -> GitProjectionResult<Vec<RefUpdate>> {
3168    let updates = collect_ref_updates(repo)?;
3169    if refs.is_empty() {
3170        return Ok(updates);
3171    }
3172
3173    let wanted: HashSet<&str> = refs.iter().map(String::as_str).collect();
3174    Ok(updates
3175        .into_iter()
3176        .filter(|update| matches_import_ref(update, &wanted))
3177        .collect())
3178}
3179
3180fn matches_import_ref(update: &RefUpdate, wanted: &HashSet<&str>) -> bool {
3181    let full = full_ref_name(update);
3182    wanted.contains(update.name.as_str()) || wanted.contains(full.as_str())
3183}
3184
3185fn full_ref_name(update: &RefUpdate) -> String {
3186    GitRefName::content_full_name(update.namespace, &update.name)
3187}
3188
3189#[cfg(test)]
3190pub fn ensure_commit_update_fast_forward(
3191    repo: &SleyRepository,
3192    name: &str,
3193    old: ObjectId,
3194    new: ObjectId,
3195) -> GitProjectionResult<()> {
3196    if old == new || old == ObjectId::null(repo.object_format()) {
3197        return Ok(());
3198    }
3199    match commit_is_descendant_of(repo, new, old) {
3200        Ok(true) => Ok(()),
3201        Ok(false) => Err(GitProjectionError::NonFastForwardRef {
3202            name: name.to_string(),
3203            old,
3204            new,
3205            remote_destination: false,
3206        }),
3207        Err(err) => Err(GitProjectionError::Git(format!(
3208            "ref update would move {name}: {old} -> {new}, but Heddle could not verify it as a fast-forward ({err}); fetch/import first or inspect the refs explicitly"
3209        ))),
3210    }
3211}
3212
3213fn commit_is_descendant_of(
3214    repo: &SleyRepository,
3215    descendant: ObjectId,
3216    ancestor: ObjectId,
3217) -> GitProjectionResult<bool> {
3218    let mut stack = vec![descendant];
3219    let mut seen = HashSet::new();
3220    while let Some(oid) = stack.pop() {
3221        if oid == ancestor {
3222            return Ok(true);
3223        }
3224        if !seen.insert(oid) {
3225            continue;
3226        }
3227        let commit = repo.read_commit(&oid).map_err(git_err)?;
3228        for parent in commit.parents {
3229            stack.push(parent);
3230        }
3231    }
3232    Ok(false)
3233}
3234
3235/// Filename, under a destination repo's git dir, of heddle's record of which
3236/// full ref names it has exported to THAT destination, AND the tip OID heddle
3237/// last published for each. A heddle-owned sidecar (git ignores unknown files in
3238/// the git dir), one `<full ref name> <published tip oid>` pair per line. Lives
3239/// WITH the destination so the delete-set can be scoped to refs heddle actually
3240/// wrote here — never the raw destination namespace (heddle#316 CLASS 2) — and
3241/// so the force decision can prove a rewind is heddle-OWNED, not an out-of-band
3242/// advance, by matching the destination tip against the recorded published tip
3243/// (heddle#316 r12).
3244const HEDDLE_EXPORTED_REFS_FILE: &str = "heddle-exported-refs";
3245
3246/// Directory, under heddle's OWN dir, holding the per-URL-remote exported-refs
3247/// records. A network remote (`git://`, `ssh://`, `https://`) has no local git
3248/// dir heddle can drop a sidecar into, so its record lives here instead — keyed
3249/// by a hash of the remote URL. This is the network sibling of
3250/// [`HEDDLE_EXPORTED_REFS_FILE`]: the SAME delete-set reconciliation, with the
3251/// only difference being WHERE the record is stored (heddle#316 r11).
3252const HEDDLE_NETWORK_EXPORTED_REFS_DIR: &str = "git-network-exported-refs";
3253
3254fn exported_refs_manifest_path(target_repo: &SleyRepository) -> PathBuf {
3255    target_repo.git_dir().join(HEDDLE_EXPORTED_REFS_FILE)
3256}
3257
3258/// On-disk location of the exported-refs record for the network remote at `url`.
3259/// Keyed by a hash of the URL string so an arbitrarily long / non-ASCII URL maps
3260/// to a fixed-length, filesystem-safe filename. Stored under heddle's own dir
3261/// (the remote is not local, so there is no destination git dir to host it).
3262fn network_exported_refs_path(heddle_dir: &Path, url: &str) -> PathBuf {
3263    let key = ContentHash::compute_typed("git-network-exported-refs", url.as_bytes()).to_hex();
3264    heddle_dir
3265        .join(HEDDLE_NETWORK_EXPORTED_REFS_DIR)
3266        .join(format!("{key}.refs"))
3267}
3268
3269/// The full ref names heddle has previously exported to the destination whose
3270/// record lives at `path`, each mapped to the tip OID heddle last published for
3271/// it. `Ok(empty)` when no record exists yet — a first export, OR a destination
3272/// heddle wrote to before this record existed. Returning empty (rather than
3273/// assuming the destination's current heddle-namespace refs were heddle's) is the
3274/// conservative choice: it can never delete a foreign ref — nor force-overwrite a
3275/// destination tip — on the first export after this code lands.
3276fn read_exported_refs_at(path: &Path) -> GitProjectionResult<HashMap<String, ObjectId>> {
3277    match fs::read_to_string(path) {
3278        Ok(text) => {
3279            let mut map = HashMap::new();
3280            for line in text.lines() {
3281                let line = line.trim();
3282                if line.is_empty() {
3283                    continue;
3284                }
3285                // `<full ref name> <published tip oid>`. The tip is the OID heddle
3286                // last published for that ref here — the ownership token the force
3287                // decision consults (heddle#316 r12). A pre-r12 legacy record
3288                // stored only the name; parse its tip when present and fall back to
3289                // null otherwise. A null tip can never equal a live `old`, so a
3290                // legacy ref is never force-rewound (the safe direction) while it
3291                // still participates in the delete-set.
3292                let mut parts = line.split_whitespace();
3293                let Some(name) = parts.next() else {
3294                    continue;
3295                };
3296                let tip = parts
3297                    .next()
3298                    .and_then(|token| token.parse::<ObjectId>().ok())
3299                    .unwrap_or_else(|| ObjectId::null(ObjectFormat::Sha1));
3300                map.insert(name.to_string(), tip);
3301            }
3302            Ok(map)
3303        }
3304        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
3305        Err(e) => Err(GitProjectionError::Io(e)),
3306    }
3307}
3308
3309/// Persist `refs` (full ref name → published tip OID) as heddle's exported-refs
3310/// record at `path`. Atomic temp+rename so a torn write can't surface a
3311/// half-record.
3312fn write_exported_refs_at(
3313    path: &Path,
3314    refs: &HashMap<String, ObjectId>,
3315) -> GitProjectionResult<()> {
3316    if let Some(parent) = path.parent() {
3317        fs::create_dir_all(parent)?;
3318    }
3319    let mut sorted: Vec<(&str, &ObjectId)> = refs
3320        .iter()
3321        .map(|(name, tip)| (name.as_str(), tip))
3322        .collect();
3323    sorted.sort_unstable_by(|a, b| a.0.cmp(b.0));
3324    let body = sorted
3325        .iter()
3326        .map(|(name, tip)| format!("{name} {tip}"))
3327        .collect::<Vec<_>>()
3328        .join("\n");
3329    let tmp = path.with_extension("tmp");
3330    fs::write(&tmp, body)?;
3331    fs::rename(&tmp, path)?;
3332    Ok(())
3333}
3334
3335/// Write `HEAD` as a symbolic ref pointing at `branch_ref` (e.g.
3336/// `refs/heads/main`) via sley's ref backend.
3337pub fn write_head_symref(git_dir: &Path, branch_ref: &str) -> GitProjectionResult<()> {
3338    let repo = repo_for_git_dir(git_dir)?;
3339    repo.set_head_symref(branch_ref, HeadUpdateOptions::new())
3340        .map_err(git_err)?;
3341    Ok(())
3342}
3343
3344fn repo_for_git_dir(git_dir: &Path) -> GitProjectionResult<SleyRepository> {
3345    if let Ok(repo) = open_repo(git_dir) {
3346        return Ok(repo);
3347    }
3348    if git_dir.file_name().is_some_and(|name| name == ".git")
3349        && let Some(parent) = git_dir.parent()
3350    {
3351        return open_repo(parent);
3352    }
3353    open_repo(git_dir)
3354}
3355
3356/// Heddle's exported-refs record for `target_repo` (full ref name → last-published
3357/// tip OID), the local-path destination record. See [`read_exported_refs_at`].
3358pub fn read_exported_refs(
3359    target_repo: &SleyRepository,
3360) -> GitProjectionResult<HashMap<String, ObjectId>> {
3361    read_exported_refs_at(&exported_refs_manifest_path(target_repo))
3362}
3363
3364/// Persist the local-path destination's exported-refs record. See
3365/// [`write_exported_refs_at`].
3366pub fn write_exported_refs(
3367    target_repo: &SleyRepository,
3368    refs: &HashMap<String, ObjectId>,
3369) -> GitProjectionResult<()> {
3370    write_exported_refs_at(&exported_refs_manifest_path(target_repo), refs)
3371}
3372
3373/// Filename, under the internal MIRROR's git dir, of heddle's record of which
3374/// full ref names it MANAGES in the mirror, each mapped to the tip it last
3375/// published for that ref. The mirror-side analog of [`HEDDLE_EXPORTED_REFS_FILE`]
3376/// (the destination's `heddle-exported-refs`): the mirror reconcile had no
3377/// persisted ownership record, so it reconstructed ownership ad-hoc from OID
3378/// membership — the bug that drove heddle#316 through 7 review rounds. A mirror
3379/// ref is MANAGED iff its full name is a key here, NEVER by OID membership: a
3380/// foreign branch/tag that happens to point at a heddle-minted commit is still
3381/// foreign because heddle never recorded WRITING it under that name. The format,
3382/// atomic-write, and parse contract are shared verbatim with the destination
3383/// record (`read_exported_refs_at`/`write_exported_refs_at`).
3384const HEDDLE_MIRROR_MANAGED_REFS_FILE: &str = "heddle-mirror-managed-refs";
3385
3386/// On-disk path of the mirror's managed-refs record.
3387fn mirror_managed_refs_path(mirror_repo: &SleyRepository) -> PathBuf {
3388    mirror_repo.git_dir().join(HEDDLE_MIRROR_MANAGED_REFS_FILE)
3389}
3390
3391/// Whether the mirror's managed-refs record exists on disk. Used to distinguish
3392/// a genuine FIRST export after this code lands (absent → seed from the current
3393/// mirror ref set so pre-existing heddle refs aren't all misread as foreign)
3394/// from a record that exists but is empty (everything was legitimately dropped —
3395/// do NOT re-seed).
3396pub fn mirror_managed_refs_recorded(mirror_repo: &SleyRepository) -> bool {
3397    mirror_managed_refs_path(mirror_repo).exists()
3398}
3399
3400/// The full ref names heddle MANAGES in the mirror (full ref name → last-published
3401/// tip OID). `Ok(empty)` when the record is absent — callers seed a first run from
3402/// the current mirror ref set; see [`mirror_managed_refs_recorded`].
3403pub fn read_mirror_managed_refs(
3404    mirror_repo: &SleyRepository,
3405) -> GitProjectionResult<HashMap<String, ObjectId>> {
3406    read_exported_refs_at(&mirror_managed_refs_path(mirror_repo))
3407}
3408
3409/// Persist the mirror's managed-refs record. Atomic temp+rename via
3410/// [`write_exported_refs_at`].
3411pub fn write_mirror_managed_refs(
3412    mirror_repo: &SleyRepository,
3413    refs: &HashMap<String, ObjectId>,
3414) -> GitProjectionResult<()> {
3415    write_exported_refs_at(&mirror_managed_refs_path(mirror_repo), refs)
3416}
3417
3418/// Read the mirror's managed-refs record, SEEDING a genuine first run (no record
3419/// on disk) from the current mirror ref set so the reconcile does not misread
3420/// every pre-existing heddle ref as foreign.
3421///
3422/// This is the #1 first-run risk (heddle#316): an absent record on the first
3423/// export after this code lands must NOT make existing refs look foreign — that
3424/// would silently stop embargo retraction (a now-embargoed thread tip would never
3425/// be rewound/deleted because its branch would be treated as a foreign ref to
3426/// spare). Every ref currently in the mirror was put there by heddle (the mint
3427/// reconcile, `import git`, or `fetch`), so claiming them all as managed on the first
3428/// run is correct. A record that EXISTS but is empty (everything was legitimately
3429/// dropped) is NOT re-seeded — only a truly-absent record triggers the seed.
3430pub fn read_or_seed_mirror_managed_refs(
3431    mirror_repo: &SleyRepository,
3432) -> GitProjectionResult<HashMap<String, ObjectId>> {
3433    if mirror_managed_refs_recorded(mirror_repo) {
3434        read_mirror_managed_refs(mirror_repo)
3435    } else {
3436        Ok(collect_ref_updates(mirror_repo)?
3437            .into_iter()
3438            .map(|update| (full_ref_name(&update), update.target))
3439            .collect())
3440    }
3441}
3442
3443/// The mirror refs heddle MANAGES, as [`RefUpdate`]s — [`collect_ref_updates`]
3444/// filtered to the names in the managed-refs `record`, PLUS every `refs/notes/*`
3445/// ref (heddle's metadata namespace, always heddle-managed and content-rebuilt
3446/// rather than target-claimed through the reconcile). The export/push frontier
3447/// MUST source from this rather than the raw [`collect_ref_updates`] so a foreign
3448/// branch/tag heddle never wrote — even one pointing at a heddle-minted commit —
3449/// never enters the served frontier nor the destination's desired set (heddle#316).
3450/// The FETCH path keeps using [`collect_ref_updates`]/[`collect_ref_updates_for_fetch`]
3451/// (it must see every ref); only the export/push frontier is managed-filtered.
3452pub fn collect_managed_ref_updates(
3453    repo: &SleyRepository,
3454    record: &HashMap<String, ObjectId>,
3455) -> GitProjectionResult<Vec<RefUpdate>> {
3456    Ok(collect_ref_updates(repo)?
3457        .into_iter()
3458        .filter(|update| {
3459            matches!(update.namespace, RefNamespace::Note)
3460                || record.contains_key(&full_ref_name(update))
3461        })
3462        .collect())
3463}
3464
3465/// How a destination ref must move from its current `old` tip to the served
3466/// `new` tip. The discriminator that lets EVERY push destination apply the SAME
3467/// served-frontier reconciliation: a deliberate backward rewind (the embargo
3468/// frontier lag) is FORCED past the fast-forward guard, while a true fork is
3469/// still caught by it (heddle#316 r11).
3470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3471enum RefMove {
3472    /// `old == new` (or both absent) — nothing to do.
3473    Unchanged,
3474    /// No resolvable `old` at the destination — a fresh ref.
3475    Create,
3476    /// `new` descends from `old` — an ordinary fast-forward.
3477    FastForward,
3478    /// `old` descends from `new` AND `old` is the tip heddle itself last
3479    /// published here — a deliberate backward rewind heddle OWNS: the served
3480    /// frontier was lagged down to an ancestor because the prior tip (or a
3481    /// descendant of `new`) was embargoed/retracted. MUST be forced through at
3482    /// every destination, exactly as the mirror-side branch rewind forces it.
3483    /// Topology alone does NOT qualify: a destination tip advanced OUT OF BAND
3484    /// past heddle's last-published tip also descends from `new`, but is
3485    /// [`Diverged`](RefMove::Diverged), never force-overwritten (heddle#316 r12).
3486    Rewind,
3487    /// `old` and `new` share no ancestor line (or `old` is unresolvable here) —
3488    /// the divergence the fast-forward guard exists to catch.
3489    Diverged,
3490}
3491
3492/// Classify how a destination ref moves from `old` to `new`, resolving the
3493/// topology in `repo` (the mirror, which holds every served object PLUS any
3494/// previously-exported-now-embargoed object the purge dropped from the mapping
3495/// but not from the object DB). The single place that distinguishes a deliberate
3496/// embargo rewind from a fork, so both push destinations force the former and
3497/// reject the latter identically.
3498///
3499/// `recorded_tip` is the tip heddle last published for this ref at THIS
3500/// destination (from its exported-refs record), or `None` when heddle has no
3501/// record of publishing it here. A backward rewind is FORCED only when heddle
3502/// owns the tip being rewound — `recorded_tip == Some(old)`. Topology alone is
3503/// insufficient: a destination tip advanced OUT OF BAND past heddle's
3504/// last-published tip (then fetched into the mirror) ALSO descends from `new`,
3505/// but heddle never published it, so it is [`RefMove::Diverged`] and must not be
3506/// force-overwritten (heddle#316 r12).
3507fn classify_ref_move(
3508    repo: &SleyRepository,
3509    old: Option<ObjectId>,
3510    new: ObjectId,
3511    recorded_tip: Option<ObjectId>,
3512) -> GitProjectionResult<RefMove> {
3513    let Some(old) = old else {
3514        return Ok(RefMove::Create);
3515    };
3516    if old == ObjectId::null(repo.object_format()) {
3517        return Ok(RefMove::Create);
3518    }
3519    if old == new {
3520        return Ok(RefMove::Unchanged);
3521    }
3522    // `new` is the served frontier we just minted/copied, so walking from it is
3523    // always safe. A fast-forward is `new` reaching `old`.
3524    if commit_is_descendant_of(repo, new, old)? {
3525        return Ok(RefMove::FastForward);
3526    }
3527    // A backward rewind is `old` reaching `new`. Forcing it past the FF guard is
3528    // authorized ONLY when heddle OWNS the rewind: `old` is exactly the tip heddle
3529    // itself last published for this ref here (per the exported-refs record). A
3530    // destination tip heddle did NOT publish — an out-of-band descendant the user
3531    // advanced and fetched into the mirror — is never force-overwritten; it falls
3532    // through to `Diverged` (FF-rejected unless the user passes `--force`), so its
3533    // newer commit survives. `old`'s objects survive in the mirror because heddle
3534    // published it (the embargo purge drops the StateId→OID mapping, never the
3535    // object); if `old` is NOT resolvable here we cannot prove a rewind anyway.
3536    if recorded_tip == Some(old)
3537        && repo.read_commit(&old).is_ok()
3538        && commit_is_descendant_of(repo, old, new)?
3539    {
3540        return Ok(RefMove::Rewind);
3541    }
3542    Ok(RefMove::Diverged)
3543}
3544
3545/// Whether a destination ref in the served set may be overwritten, and on what
3546/// terms. The verdict EVERY namespace's overwrite funnels through, so ownership
3547/// is decided in exactly one place.
3548///
3549/// The reconcile invariant (heddle#316 r17): ownership — heddle owns the tip it
3550/// overwrites (`recorded == old`, or the move is a safe forward), OR the user
3551/// passes `--force` — gates EVERY namespace's overwrite AND every delete. The
3552/// ONLY per-namespace axis is move-classification: branch/note resolve
3553/// fast-forward-vs-fork topology via [`classify_ref_move`]; a tag's target may be
3554/// an annotated-tag-object OID (not a commit) so it cannot be FF-classified and
3555/// uses the free-move [`classify_tag_move`], which bakes the SAME ownership gate
3556/// in. A new namespace that wires an overwrite without consulting a verdict here
3557/// would skip the gate — the conformance matrix exists to fail that row.
3558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3559enum WriteVerdict {
3560    /// No-op — the served target already matches the destination tip.
3561    Skip,
3562    /// Safe to land unconditionally: a create, a fast-forward, or a heddle-owned
3563    /// overwrite/rewind (the ownership token already proved `recorded == old`).
3564    Write,
3565    /// An out-of-band overwrite heddle does NOT own — error unless `--force`.
3566    RequireForce,
3567}
3568
3569/// Map a branch/note [`RefMove`] onto a [`WriteVerdict`]. `Rewind` is already
3570/// ownership-proven by [`classify_ref_move`] (`recorded == old`), so it is a
3571/// `Write`; only `Diverged` (a fork, or an out-of-band advance heddle never
3572/// published) demands `--force`.
3573fn verdict_from_move(m: RefMove) -> WriteVerdict {
3574    match m {
3575        RefMove::Unchanged => WriteVerdict::Skip,
3576        RefMove::Create | RefMove::FastForward | RefMove::Rewind => WriteVerdict::Write,
3577        RefMove::Diverged => WriteVerdict::RequireForce,
3578    }
3579}
3580
3581/// Classify a TAG overwrite. Tags are free-move (never fast-forward-guarded): a
3582/// tag's `target` can be an annotated-tag-object OID rather than a commit, so it
3583/// cannot be FF-classified — [`classify_ref_move`] would resolve `find_commit`
3584/// on the tag object and error. The ownership gate is applied directly here
3585/// instead: a create or a heddle-owned overwrite (`recorded == old`) lands; an
3586/// out-of-band tag heddle never recorded is spared (`RequireForce`) exactly as an
3587/// out-of-band branch advance is — never silently clobbered (heddle#316 r17).
3588fn classify_tag_move(
3589    old: Option<ObjectId>,
3590    target: ObjectId,
3591    recorded: Option<ObjectId>,
3592) -> WriteVerdict {
3593    match old {
3594        // No tip at the destination — a fresh tag.
3595        None => WriteVerdict::Write,
3596        // Already at the served target — nothing to do.
3597        Some(o) if o == target => WriteVerdict::Skip,
3598        // heddle owns the tip it is overwriting — its published move lands.
3599        Some(o) if recorded == Some(o) => WriteVerdict::Write,
3600        // An out-of-band tag heddle never published — spare it unless `--force`.
3601        Some(_) => WriteVerdict::RequireForce,
3602    }
3603}
3604
3605/// A served ref a push destination must write: its full name, the served `new`
3606/// tip, and whether the receive-pack command must be forced.
3607#[derive(Debug)]
3608pub struct PlannedRefWrite {
3609    pub full_name: String,
3610    pub old: Option<ObjectId>,
3611    pub new: ObjectId,
3612    pub force: bool,
3613}
3614
3615/// A previously-exported ref the served mirror no longer carries: it must be
3616/// deleted at the destination.
3617#[derive(Debug)]
3618pub struct PlannedRefDelete {
3619    pub full_name: String,
3620    pub old: ObjectId,
3621}
3622
3623/// The ONE reconciliation plan EVERY push destination applies, so its published
3624/// refs converge to the served frontier by construction.
3625#[derive(Debug)]
3626pub struct DestinationReconcilePlan {
3627    /// Survivors to write — creations, fast-forwards, and FORCED embargo rewinds.
3628    pub writes: Vec<PlannedRefWrite>,
3629    /// Previously-exported refs the mirror no longer serves AND that still exist
3630    /// at the destination — to delete. Scoped to heddle-owned refs (never foreign).
3631    pub deletes: Vec<PlannedRefDelete>,
3632    /// The exported-refs record to persist for this destination after the push:
3633    /// full ref name → the tip heddle just published, plus the previously-recorded
3634    /// tip for any ref left in place — a still-served ref out of this push's scope
3635    /// OR an out-of-band tip whose retraction was skipped (so `--force` can still
3636    /// retract it later). A deleted ref drops out; a foreign ref never enters.
3637    pub new_manifest: HashMap<String, ObjectId>,
3638}
3639
3640/// The sorted full names of the refs a destination reconcile plan WRITES —
3641/// creations, fast-forwards, and forced embargo rewinds. This is the
3642/// `refs_written` surface `heddle push` reports so a git veteran (or agent)
3643/// can verify the round-trip with `git ls-remote`. Retraction deletes are
3644/// not included. Sorted because the plan's write order derives from hash-map
3645/// iteration and the reported list must be deterministic.
3646pub fn planned_write_names(plan: &DestinationReconcilePlan) -> Vec<String> {
3647    let mut names: Vec<String> = plan
3648        .writes
3649        .iter()
3650        .map(|write| write.full_name.clone())
3651        .collect();
3652    names.sort_unstable();
3653    names
3654}
3655
3656/// The full ref names a push may MATERIALIZE (create fresh) at a destination — the
3657/// `creatable_names` gate for [`plan_destination_reconcile`]. `None` for an
3658/// all-thread push (every served ref is creatable, so the gate never fires);
3659/// `Some(set)` for a current-thread push (only the attached branch + the notes
3660/// refs). This is the destination analog of the mirror reconcile's materialization
3661/// gate (`git_export::export`'s `existing.is_none() && !in_scope` skip): a scoped
3662/// push reconciles EXISTING out-of-scope refs (the embargo rewind) but never
3663/// publishes a brand-new sibling the caller did not ask to export (heddle#316 r16).
3664fn creatable_ref_names(
3665    served_frontier: &[RefUpdate],
3666    scope: GitPushScope,
3667    current_branch: Option<&str>,
3668) -> Option<HashSet<String>> {
3669    match scope {
3670        GitPushScope::AllThreads => None,
3671        GitPushScope::CurrentThread => {
3672            let branch = current_branch.unwrap_or_default();
3673            Some(
3674                served_frontier
3675                    .iter()
3676                    .filter(|update| {
3677                        (matches!(update.namespace, RefNamespace::Branch) && update.name == branch)
3678                            || matches!(update.namespace, RefNamespace::Note)
3679                    })
3680                    .map(full_ref_name)
3681                    .collect(),
3682            )
3683        }
3684    }
3685}
3686
3687/// Build the served-frontier reconciliation plan shared by the local-path and
3688/// URL/network push destinations (heddle#316 r11/r13/r16). The destination's
3689/// published refs are a PURE PROJECTION of the served frontier, restricted to
3690/// heddle-owned refs: every op — create, fast-forward, forced embargo rewind,
3691/// retraction delete, or skip — is DERIVED from ONE pass over the desired-vs-
3692/// actual diff, and the heddle-OWNERSHIP token (`recorded_tip == old`) gates
3693/// force AND delete UNIFORMLY. There is no separate per-operation enforcement
3694/// branch to forget: a destination tip heddle never published is neither
3695/// force-rewound NOR deleted (it survives) unless the user passes `--force`.
3696///
3697/// INVARIANT (heddle#316 r16): `served_frontier` is the WHOLE-MIRROR served
3698/// frontier — every heddle-managed mirror ref at its CURRENT served target — the
3699/// SAME projection the mirror reconcile (`git_export::export`) materialized into
3700/// the mirror. The destination reconcile and the mirror reconcile are therefore
3701/// driven by ONE source of truth, so destination and mirror cannot diverge for
3702/// ANY embargo transition, in-scope OR out-of-scope: an out-of-scope ref the
3703/// mirror rewound for embargo is present here at its NEW (rewound) target, and
3704/// [`classify_ref_move`] emits the rewind to the destination by construction.
3705/// There is NO "served but out of this push's scope, leave it untouched" arm — a
3706/// scoped push reconciles the destination against the whole served frontier, not
3707/// a scope-filtered subset that could keep serving a ref the mirror already
3708/// rewound (the cross-thread-embargo destination leak this round closes).
3709///
3710/// The ONE thing scope still gates is MATERIALIZATION — exactly as the mirror
3711/// reconcile does (`git_export::export`'s `existing.is_none() && !in_scope`
3712/// skip): a scoped push REWINDS/RETRACTS an EXISTING out-of-scope ref (the embargo
3713/// fix) but must not publish a brand-new sibling the caller did not ask to export.
3714/// `creatable_names` carries that gate: a ref ABSENT from the destination whose
3715/// name is NOT creatable is skipped (never created); one that already EXISTS is
3716/// always reconciled, so no target change is ever masked.
3717///
3718/// * `mirror_repo` — resolves the rewind-vs-fork topology (see
3719///   [`classify_ref_move`]).
3720/// * `served_frontier` — the WHOLE-MIRROR served frontier: every heddle-owned
3721///   ref that should exist at the destination, at its served target. A
3722///   previously-exported ref ABSENT from this set is one the mirror no longer
3723///   serves AT ALL (a retraction), never merely out of a push's scope.
3724/// * `creatable_names` — the full ref names this push may MATERIALIZE fresh:
3725///   `None` for an all-thread push (every served ref is creatable); `Some(set)`
3726///   for a current-thread push (only the attached branch + notes). Gates ONLY
3727///   first-time creation of an absent ref; an existing ref is always reconciled.
3728/// * `old_at_destination` — the destination's current ref tips (full name → oid).
3729/// * `previously_exported` — heddle's record of what it exported to THIS
3730///   destination (full ref name → last-published tip OID): the foreign-ref
3731///   scoping AND the single ownership token for both delete and force.
3732/// * `force` — the user's explicit `--force`: additionally forces a true fork
3733///   AND authorizes retracting an out-of-band destination tip.
3734pub fn plan_destination_reconcile(
3735    mirror_repo: &SleyRepository,
3736    served_frontier: &[RefUpdate],
3737    creatable_names: Option<&HashSet<String>>,
3738    old_at_destination: &HashMap<String, ObjectId>,
3739    previously_exported: &HashMap<String, ObjectId>,
3740    force: bool,
3741) -> GitProjectionResult<DestinationReconcilePlan> {
3742    // The DESIRED ref-set indexed by full name → its `RefUpdate` (served target +
3743    // namespace). A name is in `desired` iff the WHOLE-MIRROR served frontier
3744    // wants it published now — there is no scope-filtered subset (heddle#316 r16),
3745    // so an out-of-scope ref the mirror rewound for embargo is here at its NEW
3746    // target rather than silently kept at its old (embargoed) tip.
3747    let desired: HashMap<String, &RefUpdate> = served_frontier
3748        .iter()
3749        .map(|u| (full_ref_name(u), u))
3750        .collect();
3751
3752    // ONE pass over the union of (desired ∪ previously-exported) names — the
3753    // complete desired-vs-actual diff. For each ref the op is derived from the
3754    // same three inputs: `desired` (does the served frontier want it, at what
3755    // target), `old` (the destination's current tip, out-of-band-aware), and
3756    // `recorded` (the tip heddle last published here = the OWNERSHIP token). The
3757    // ownership token gates force AND delete identically (heddle#316 r13).
3758    let mut names: BTreeSet<String> = desired.keys().cloned().collect();
3759    names.extend(previously_exported.keys().cloned());
3760
3761    let mut writes = Vec::new();
3762    let mut deletes = Vec::new();
3763    let mut new_manifest: HashMap<String, ObjectId> = HashMap::new();
3764
3765    for full in names {
3766        let old = old_at_destination.get(&full).copied();
3767        let recorded = previously_exported.get(&full).copied();
3768
3769        if let Some(update) = desired.get(&full).copied() {
3770            // MATERIALIZATION gate (the mirror reconcile's `existing.is_none() &&
3771            // !in_scope` skip, applied to the destination): an out-of-scope ref
3772            // ABSENT from the destination must not be CREATED by a scoped push —
3773            // that would publish a brand-new sibling the caller did not ask to
3774            // export. An EXISTING out-of-scope ref falls through and is reconciled
3775            // (rewind/retract), so the embargo fix is untouched; only first-time
3776            // creation is suppressed. Preserve any ownership token so a later
3777            // all-thread push can still materialize it (heddle#316 r14/r16).
3778            if old.is_none() && creatable_names.is_some_and(|names| !names.contains(&full)) {
3779                if let Some(recorded) = recorded {
3780                    new_manifest.insert(full, recorded);
3781                }
3782                continue;
3783            }
3784            // In the desired set: land it at the served target. A ref this push
3785            // publishes is heddle-owned at its new target — record it. The
3786            // overwrite funnels through ONE ownership gate ([`WriteVerdict`]): the
3787            // only per-namespace axis is move-classification — branch/note resolve
3788            // fast-forward-vs-fork topology, a tag is free-move (its target may be
3789            // an annotated-tag-object OID, not a commit) with the SAME ownership
3790            // gate baked into [`classify_tag_move`]. An out-of-band destination tip
3791            // heddle never recorded is spared at EVERY namespace unless `--force`.
3792            let (verdict, force_write) = match update.namespace {
3793                RefNamespace::Branch | RefNamespace::Note => {
3794                    let movement = classify_ref_move(mirror_repo, old, update.target, recorded)?;
3795                    (
3796                        verdict_from_move(movement),
3797                        matches!(movement, RefMove::Rewind),
3798                    )
3799                }
3800                RefNamespace::Tag => {
3801                    let verdict = classify_tag_move(old, update.target, recorded);
3802                    (
3803                        verdict,
3804                        old.is_some_and(|old| old != update.target)
3805                            && matches!(verdict, WriteVerdict::Write),
3806                    )
3807                }
3808            };
3809            let proceed = match verdict {
3810                WriteVerdict::Skip => false,
3811                WriteVerdict::Write => true,
3812                WriteVerdict::RequireForce => {
3813                    if force {
3814                        true
3815                    } else {
3816                        return Err(GitProjectionError::NonFastForwardRef {
3817                            name: full.clone(),
3818                            old: old.unwrap_or_else(|| ObjectId::null(mirror_repo.object_format())),
3819                            new: update.target,
3820                            remote_destination: true,
3821                        });
3822                    }
3823                }
3824            };
3825            if proceed {
3826                writes.push(PlannedRefWrite {
3827                    full_name: full.clone(),
3828                    old,
3829                    new: update.target,
3830                    force: force_write || matches!(verdict, WriteVerdict::RequireForce),
3831                });
3832            }
3833            // CLAIM ownership in the record ONLY for a ref heddle actually writes
3834            // this push, or one it already owned (had a record for). A pre-existing
3835            // destination ref already AT the served target that heddle never recorded
3836            // (verdict Skip, `recorded` None) is FOREIGN — recording it would let a
3837            // later export DELETE/rewind a ref heddle never created (heddle#316
3838            // destination foreign-ref over-claim). Spare it: leave it out of the
3839            // manifest so it stays unowned.
3840            if proceed || recorded.is_some() {
3841                new_manifest.insert(full, update.target);
3842            }
3843            continue;
3844        }
3845
3846        // Absent from the WHOLE-MIRROR served frontier ⇒ genuinely retracted: the
3847        // served mirror no longer carries this previously-exported ref at all (NOT
3848        // merely out of a push's scope — there is no scope subset here). Delete it,
3849        // but ONLY through the SAME ownership gate the forced
3850        // rewind uses: heddle owns the destination's current tip (`recorded ==
3851        // old`), or the user forces. An out-of-band advance heddle never published
3852        // is spared (it survives) and KEEPS its ownership token, so a later
3853        // `--force` can still retract it (heddle#316 r13).
3854        match old {
3855            Some(old) if recorded == Some(old) || force => {
3856                deletes.push(PlannedRefDelete {
3857                    full_name: full,
3858                    old,
3859                });
3860                // Deleted ⇒ no longer owned ⇒ drops from the record.
3861            }
3862            Some(_) => {
3863                // Out-of-band tip heddle never published — skip the delete; retain
3864                // ownership so `--force` remains the explicit escape hatch.
3865                if let Some(recorded) = recorded {
3866                    new_manifest.insert(full, recorded);
3867                }
3868            }
3869            None => {
3870                // Already absent at the destination — no op; drops from the record.
3871            }
3872        }
3873    }
3874
3875    Ok(DestinationReconcilePlan {
3876        writes,
3877        deletes,
3878        new_manifest,
3879    })
3880}
3881
3882/// The destination's current ref tips (full name → oid) across the namespaces
3883/// heddle manages (heads, tags, notes) — the `old_at_destination` input to
3884/// [`plan_destination_reconcile`] for a local-path destination.
3885fn read_destination_ref_map(
3886    repo: &SleyRepository,
3887) -> GitProjectionResult<HashMap<String, ObjectId>> {
3888    Ok(collect_ref_updates(repo)?
3889        .iter()
3890        .map(|update| (full_ref_name(update), update.target))
3891        .collect())
3892}
3893
3894pub fn apply_ref_updates(
3895    repo: &SleyRepository,
3896    updates: &[RefUpdate],
3897    log_message: &str,
3898) -> GitProjectionResult<()> {
3899    for update in updates {
3900        let full_name = full_ref_name(update);
3901        set_reference(
3902            repo,
3903            &full_name,
3904            update.target,
3905            RefPrecondition::Any,
3906            log_message,
3907        )?;
3908    }
3909    Ok(())
3910}
3911
3912fn apply_remote_tracking_ref_updates(
3913    repo: &SleyRepository,
3914    remote_name: &str,
3915    updates: &[RefUpdate],
3916    log_message: &str,
3917) -> GitProjectionResult<()> {
3918    reject_reserved_git_remote_name(remote_name)?;
3919    for update in updates
3920        .iter()
3921        .filter(|update| update.namespace == RefNamespace::Branch)
3922    {
3923        set_reference(
3924            repo,
3925            &format!("refs/remotes/{remote_name}/{}", update.name),
3926            update.target,
3927            RefPrecondition::Any,
3928            log_message,
3929        )?;
3930    }
3931    Ok(())
3932}
3933
3934/// Copy a local Git repository into a bare repository without invoking Git
3935/// transport helpers. This is the local-path clone fast path used by the OSS
3936/// Git-overlay workflow when the user does not have `git` installed.
3937pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitProjectionResult<()> {
3938    fs::create_dir_all(dest)?;
3939    let source = open_repo(source_path)?;
3940    let target = match SleyRepository::open(dest) {
3941        Ok(repo) => repo,
3942        Err(_) => SleyRepository::init_bare(dest).map_err(git_err)?,
3943    };
3944    let updates = collect_ref_updates(&source)?;
3945    copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
3946    apply_ref_updates(
3947        &target,
3948        &updates,
3949        &format!("heddle: clone from {}", source_path.display()),
3950    )?;
3951
3952    // Mirror the source repo's HEAD: if the source is on `master` (or
3953    // `develop`, or anything non-`main`) but happens to also have a
3954    // `main` branch, the previous logic silently moved the user to
3955    // `main` on clone. Read the source's symbolic HEAD target and
3956    // honour it whenever it points at a branch we actually copied.
3957    // Fall back to `main` (then any first branch) only when the source
3958    // HEAD is detached or points at a branch we did not import.
3959    let copied_branches: HashSet<&str> = updates
3960        .iter()
3961        .filter(|update| update.namespace == RefNamespace::Branch)
3962        .map(|update| update.name.as_str())
3963        .collect();
3964    let source_head_branch = source
3965        .head()
3966        .ok()
3967        .and_then(|head| head.branch_name().map(str::to_owned))
3968        .filter(|branch| copied_branches.contains(branch.as_str()));
3969    if let Some(branch) = source_head_branch {
3970        write_head_symref(dest, &format!("refs/heads/{branch}"))?;
3971    } else if copied_branches.contains("main") {
3972        write_head_symref(dest, "refs/heads/main")?;
3973    } else if let Some(first_branch) = updates
3974        .iter()
3975        .find(|update| update.namespace == RefNamespace::Branch)
3976    {
3977        write_head_symref(dest, &format!("refs/heads/{}", first_branch.name))?;
3978    }
3979    Ok(())
3980}
3981
3982/// Clone a remote git URL into `dest` as a bare repository, fetching all
3983/// branches and tags. Mirrors the sley remote fetch path used by
3984/// `fetch_network_remote` but starts from an empty `init_bare` rather than an
3985/// existing repo.
3986///
3987/// Used by `import git --path <URL>` (Phase F): we clone into a
3988/// scratch directory under the heddle repo's `.heddle/tmp/` and feed the
3989/// resulting bare repo into the normal import path. Also used by `clone`
3990/// for Git-overlay URLs, where `depth` carries through to a shallow clone.
3991///
3992/// * `depth` — if `Some(n)` with `n >= 1`, a shallow clone with that
3993///   many commits per ref for network transports (transport-v2
3994///   `deepen <n>` capability). `file://` URLs use the native local-copy
3995///   path so they do not spawn Git upload-pack helpers; shallow local
3996///   copies are rejected until Heddle has native shallow-object pruning.
3997/// * `filter` — currently rejected. Heddle's Git-overlay runtime is
3998///   intentionally Git-compatible but not Git-binary-dependent, and the
3999///   native transport path does not yet expose partial-clone filtering.
4000pub fn clone_url_to_bare(
4001    url: &str,
4002    dest: &Path,
4003    depth: Option<u32>,
4004    filter: Option<&str>,
4005    progress: &mut dyn ProgressSink,
4006) -> GitProjectionResult<()> {
4007    // Public Git-overlay workflows must run on machines with no Git executable
4008    // installed. Keep depth-only clones native and reject filtered clones until
4009    // the importer can tolerate missing objects.
4010    if let Some(spec) = filter {
4011        return Err(GitProjectionError::Git(format!(
4012            "partial Git clone filter `{spec}` is not supported in Heddle's native no-git runtime yet; retry without --filter/--lazy so Heddle can import a complete object graph"
4013        )));
4014    }
4015    if let Some(source_path) = local_path_from_url(url)? {
4016        if depth.is_some() {
4017            return Err(GitProjectionError::Git(
4018                "shallow file:// Git clones are not supported in Heddle's native no-git runtime yet; retry without --depth so Heddle can copy the local Git object graph without spawning Git transport helpers"
4019                    .to_string(),
4020            ));
4021        }
4022        return copy_local_repo_to_bare(&source_path, dest);
4023    }
4024    let default_branch = clone_url_to_bare_via_sley(url, dest, depth, progress)?
4025        .or_else(|| default_branch_from_file_url(url));
4026    // `init_bare` writes `.git/HEAD = ref: refs/heads/<init.defaultBranch>`
4027    // (typically "main" or "master") regardless of what the remote
4028    // advertises, and the fetch above doesn't touch HEAD. If we leave
4029    // that in place, downstream `select_clone_thread` and
4030    // `detect_git_head` will steer the user to a branch the remote may
4031    // not even have — observed: cloning ripgrep landed users on
4032    // `ag/bstr-migration` (alphabetically first imported thread) when
4033    // the remote's actual default is `master`. Honour the remote's
4034    // `HEAD` symref when we can resolve it.
4035    if let Some(branch) = default_branch
4036        && bare_branch_exists(dest, &branch)?
4037    {
4038        write_head_symref(dest, &format!("refs/heads/{branch}"))?;
4039    }
4040    Ok(())
4041}
4042
4043fn default_branch_from_file_url(url: &str) -> Option<String> {
4044    let source_path = local_path_from_url(url).ok().flatten()?;
4045    let repo = open_repo(&source_path).ok()?;
4046    let head = repo.head_state().ok()?;
4047    let branch = head.branch_name()?;
4048    (!branch.is_empty()).then(|| branch.to_string())
4049}
4050
4051fn bare_branch_exists(repo_path: &Path, branch: &str) -> GitProjectionResult<bool> {
4052    let repo = open_repo(repo_path)?;
4053    Ok(repo
4054        .find_reference(&format!("refs/heads/{branch}"))
4055        .map_err(git_err)?
4056        .is_some())
4057}
4058
4059fn clone_url_to_bare_via_sley(
4060    url: &str,
4061    dest: &Path,
4062    depth: Option<u32>,
4063    progress: &mut dyn ProgressSink,
4064) -> GitProjectionResult<Option<String>> {
4065    fs::create_dir_all(dest)?;
4066    let repo = SleyRepository::init_bare(dest).map_err(git_err)?;
4067    let mut credentials =
4068        EmbeddingSafeCredentialProvider::new(&repo.config_snapshot().map_err(git_err)?);
4069    let display_url = sley::plumbing::sley_core::redact_url_for_display(url);
4070    let outcome = repo
4071        .fetch(
4072            url,
4073            &heddle_mirror_fetch_refspecs()?,
4074            FetchOptions {
4075                // sley 0.5.0 additions — heddle uses git defaults (no CLI override).
4076                filter_auto: false,
4077                progress: None,
4078                upload_pack_command: None,
4079                negotiation_include: None,
4080                negotiation_restrict: None,
4081                reject_shallow: false,
4082                quiet: true,
4083                auto_follow_tags: true,
4084                fetch_all_tags: true,
4085                prune: false,
4086                dry_run: false,
4087                append: false,
4088                write_fetch_head: true,
4089                force: false,
4090                tag_option_explicit: true,
4091                prune_option_explicit: true,
4092                prune_tags: false,
4093                prune_tags_option_explicit: false,
4094                refmap: None,
4095                refetch: false,
4096                record_promisor_refs: false,
4097                update_head_ok: false,
4098                ssh_options: None,
4099                atomic: false,
4100                depth,
4101                merge_srcs: Vec::new(),
4102                filter: None,
4103                cloning: true,
4104                update_shallow: false,
4105                deepen_relative: false,
4106                deepen_since: None,
4107                deepen_not: Vec::new(),
4108            },
4109            &mut credentials,
4110            progress,
4111        )
4112        .map_err(|err| GitProjectionError::Git(format!("clone failed for {display_url}: {err}")))?;
4113    Ok(outcome
4114        .head_symref
4115        .and_then(|target| target.strip_prefix("refs/heads/").map(str::to_string)))
4116}
4117
4118/// Materialize the checkout `.git` object closure for the commit mapped to
4119/// `tip_state_id` (`tip_oid`) — reconstructing every byte-faithful commit from
4120/// heddle state, then resolving lossy objects from Raw Git Object Residuals
4121/// (preferred) or the Bridge Mirror backstop (#568 P1 + residual foundation).
4122///
4123/// Walks the heddle state DAG from `tip_state_id`. For each visited state:
4124///   * its mapped git OID is already in `excluded` (the prior checkout HEAD's full
4125///     closure, already on disk) ⇒ skip it AND its ancestors — that subgraph is
4126///     present;
4127///   * [`commit_is_byte_faithful`] ⇒ reconstruct the commit object (and, via
4128///     [`reconstruct_commit_bytes`]'s [`export_tree`], its whole tree/blob closure)
4129///     directly into `object_repo`, then recurse into its parents;
4130///   * otherwise (lossy: `--lossy` import or non-UTF8 identity) ⇒ install that
4131///     commit's object from residual storage when present; otherwise copy its
4132///     reachable closure from the Bridge Mirror (lazily migrating the root into
4133///     residuals) and DO NOT recurse when the mirror supplied the closure.
4134///
4135/// CRITICAL safety gate: every reconstructed commit's git OID MUST equal the
4136/// mapped `git_oid`. A mismatch means reconstruction diverged from the imported
4137/// bytes (an unmodeled fidelity gap), which would silently materialize a
4138/// wrong-OID checkout — so this HARD-ERRORS instead. This assertion is what lets
4139/// the reconstruction path be trusted as a mirror replacement.
4140///
4141/// When a lossy object has neither a residual nor a mirror copy, this hard-fails
4142/// with a clear fidelity error rather than silently omitting the object.
4143///
4144/// Output is byte-identical to prior mirror-backed materialization for objects
4145/// that remain residual- or mirror-backed: git objects are content-addressed, so
4146/// a faithful reconstruction lands the exact same OID the mirror copy would have,
4147/// and the lossy path installs verbatim bytes. The exclude set keeps it O(objects
4148/// new since the parent). `init_mirror` remains available for migration; this
4149/// path does not delete `.heddle/git`.
4150#[allow(clippy::too_many_arguments)]
4151pub fn materialize_checkout_closure_from_state(
4152    heddle_repo: &HeddleRepository,
4153    mapping: &SyncMapping,
4154    mirror_repo: &SleyRepository,
4155    object_repo: &SleyRepository,
4156    tip_state_id: &StateId,
4157    tip_oid: ObjectId,
4158    excluded: &HashSet<ObjectId>,
4159) -> GitProjectionResult<()> {
4160    // Lossy commits whose closure still needs Bridge Mirror copy after residual
4161    // install attempts. Residual-only roots are installed per-oid below; mirror
4162    // roots are batched into one excluding pack install for perf shape parity.
4163    let mut lossy_roots: Vec<ObjectId> = Vec::new();
4164    let mut stack: Vec<StateId> = vec![*tip_state_id];
4165    let mut seen: HashSet<StateId> = HashSet::new();
4166    let residual_store = ResidualStore::open(heddle_repo.heddle_dir());
4167
4168    while let Some(state_id) = stack.pop() {
4169        if !seen.insert(state_id) {
4170            continue;
4171        }
4172        let Some(git_oid) = resolve_mapped_git_oid(heddle_repo, mapping, &state_id, object_repo)?
4173        else {
4174            // No mapping for this state: it was never exported (e.g. an embargoed
4175            // ancestor withheld from the served frontier). The tip itself always
4176            // resolves (`tip_oid`), and a withheld ancestor's git object is, by
4177            // construction, absent from both store-reconstruction and the served
4178            // mirror — so there is nothing to materialize. Skip without recursing.
4179            continue;
4180        };
4181
4182        // Already on disk (this state's object is in the parent's excluded closure,
4183        // or a sibling branch already materialized it): the whole subgraph beneath
4184        // it is present too, so prune here.
4185        if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
4186            continue;
4187        }
4188
4189        let state = heddle_repo
4190            .store()
4191            .get_state(&state_id)?
4192            .ok_or(GitProjectionError::StateNotFound(state_id))?;
4193
4194        if commit_is_byte_faithful(&state) {
4195            let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
4196            // The byte-exact gate (#568 P1): a faithful reconstruction MUST hash to
4197            // the mapped OID. If it does not, refuse — never write a wrong-SHA
4198            // object into the worktree.
4199            let reconstructed = commit_object_id(&content);
4200            if reconstructed != git_oid {
4201                return Err(GitProjectionError::Git(format!(
4202                    "checkout reconstruction OID mismatch for state {state_id}: reconstructed {reconstructed}, expected mapped {git_oid}; \
4203                     refusing to materialize a wrong-OID checkout (unmodeled fidelity gap)"
4204                )));
4205            }
4206            let written = write_commit_object(object_repo, &content)?;
4207            debug_assert_eq!(written, git_oid);
4208            stack.extend(state.parents.iter().copied());
4209        } else {
4210            // Lossy residual path: prefer Raw Git Object Residual, else Bridge
4211            // Mirror. Residual install covers only this commit object (not its
4212            // full tree closure); when residual is present we still fall back to
4213            // mirror for missing dependents if the residual is commit-only, so
4214            // keep the oid in lossy_roots for mirror closure copy when residual
4215            // install alone is insufficient for the checkout.
4216            lossy_roots.push(git_oid);
4217        }
4218    }
4219
4220    // Ensure the requested tip is materialized even in the degenerate case where
4221    // the walk skipped it (e.g. an unmapped store state that nonetheless has a
4222    // residual/mirror object): fall back for it. The faithful path above already
4223    // wrote it when reconstructable, and a redundant root here is pruned by the
4224    // exclude set / idempotent install.
4225    if object_repo.read_object(&tip_oid).is_err() && !lossy_roots.contains(&tip_oid) {
4226        lossy_roots.push(tip_oid);
4227    }
4228
4229    if !lossy_roots.is_empty() {
4230        materialize_lossy_roots_from_residual_or_mirror(
4231            &residual_store,
4232            mirror_repo,
4233            object_repo,
4234            &lossy_roots,
4235            excluded,
4236        )?;
4237    }
4238
4239    Ok(())
4240}
4241
4242/// Install lossy roots into `object_repo`: residual first (with lazy mirror
4243/// migration into residual storage), then Bridge Mirror reachable copy for any
4244/// roots (and their closures) still missing. Hard-fails when a root is present
4245/// in neither residual nor mirror.
4246fn materialize_lossy_roots_from_residual_or_mirror(
4247    residual_store: &ResidualStore,
4248    mirror_repo: &SleyRepository,
4249    object_repo: &SleyRepository,
4250    lossy_roots: &[ObjectId],
4251    excluded: &HashSet<ObjectId>,
4252) -> GitProjectionResult<()> {
4253    let format = object_repo.object_format();
4254    let mut mirror_needed: Vec<ObjectId> = Vec::new();
4255
4256    for oid in lossy_roots {
4257        if excluded.contains(oid) || object_repo.read_object(oid).is_ok() {
4258            continue;
4259        }
4260        // Prefer residual. If residual is only the commit root, mirror copy may
4261        // still be required for tree/blob dependents — those are pulled via the
4262        // mirror batch below when the object still cannot be fully satisfied.
4263        // For a residual that installs successfully, still attempt mirror
4264        // closure copy for dependents that residual storage may not yet hold
4265        // (foundation: residual capture is often commit-granular first).
4266        match residual_store.install_into(object_repo, oid) {
4267            Ok(true) => {
4268                // Root installed from residual; still ask the mirror for any
4269                // missing reachable dependents when the mirror is available.
4270                mirror_needed.push(*oid);
4271            }
4272            Ok(false) => {
4273                // No residual: require mirror (and migrate the root into residual
4274                // when the mirror has it).
4275                let residual = resolve_lossy_object(
4276                    residual_store,
4277                    Some(mirror_repo),
4278                    format,
4279                    oid,
4280                    true, // lazy migrate mirror → residual
4281                )?;
4282                let written = object_repo
4283                    .write_object(sley::plumbing::sley_object::EncodedObject::new(
4284                        residual.object_type,
4285                        residual.body,
4286                    ))
4287                    .map_err(git_err)?;
4288                if written != *oid {
4289                    return Err(GitProjectionError::Git(format!(
4290                        "lossy materialize wrote {written}, expected {oid}"
4291                    )));
4292                }
4293                mirror_needed.push(*oid);
4294            }
4295            Err(error) => return Err(error),
4296        }
4297    }
4298
4299    if !mirror_needed.is_empty() {
4300        // Closure fill from the Bridge Mirror. If the mirror lacks an object
4301        // that residual already supplied as a root, copy is a no-op for present
4302        // oids; missing dependents (trees/blobs the residual root did not carry)
4303        // must be pulled from the mirror here or the checkout `.git` is silently
4304        // corrupt — commit present, closure absent.
4305        if let Err(error) = copy_reachable_objects_excluding(
4306            mirror_repo,
4307            object_repo,
4308            mirror_needed.iter().copied(),
4309            excluded,
4310        ) {
4311            // Do NOT tolerate the copy error merely because the ROOT commits are
4312            // readable — a readable commit whose tree/blob closure is missing is
4313            // exactly the silent-corruption case. Only tolerate the mirror being
4314            // absent when the residual already supplied the FULL object closure
4315            // reachable from every root. Otherwise propagate the copy error.
4316            match verify_closure_present(object_repo, &mirror_needed, excluded) {
4317                Ok(()) => {
4318                    // Full closure already materialized from residual; the mirror
4319                    // was genuinely unnecessary. Tolerate its absence.
4320                }
4321                Err(ClosureCheck::Incomplete { missing }) => {
4322                    // Partial closure: the mirror copy failed AND residual did
4323                    // not carry the full graph. Hard-error rather than emit a
4324                    // corrupt checkout; surface the copy error and the first
4325                    // missing object for diagnosis.
4326                    return Err(GitProjectionError::Git(format!(
4327                        "checkout object closure incomplete after mirror copy failure: \
4328missing reachable object {missing} (mirror error: {error})"
4329                    )));
4330                }
4331                Err(ClosureCheck::Walk(walk_error)) => {
4332                    // The closure walk itself failed (e.g. a malformed object).
4333                    // Prefer surfacing the original mirror copy error, chained.
4334                    return Err(GitProjectionError::Git(format!(
4335                        "mirror copy failed ({error}); closure verification also failed: {walk_error}"
4336                    )));
4337                }
4338            }
4339        }
4340    }
4341
4342    Ok(())
4343}
4344
4345/// Outcome of a failed closure check in [`verify_closure_present`].
4346enum ClosureCheck {
4347    /// The reachable closure is missing at least one object; `missing` is the
4348    /// first such object encountered.
4349    Incomplete { missing: ObjectId },
4350    /// The walk could not complete (a readable object failed to parse, etc.).
4351    Walk(GitProjectionError),
4352}
4353
4354/// Verify that the FULL object closure reachable from each root in `roots` is
4355/// present and readable in `object_repo`, honoring `excluded` (objects the
4356/// caller has intentionally kept out of the checkout store).
4357///
4358/// Walks commits → their trees → subtrees/blobs, and tags → their targets.
4359/// Gitlink (submodule) entries are not part of this repository's closure and
4360/// are skipped. Returns `Ok(())` only when every reachable object is present;
4361/// otherwise the first missing object (or a walk failure) is reported so the
4362/// caller can hard-error instead of emitting a corrupt checkout.
4363fn verify_closure_present(
4364    object_repo: &SleyRepository,
4365    roots: &[ObjectId],
4366    excluded: &HashSet<ObjectId>,
4367) -> Result<(), ClosureCheck> {
4368    const GITLINK_MODE: u32 = 0o160000;
4369
4370    let mut stack: Vec<ObjectId> = roots.to_vec();
4371    let mut seen: HashSet<ObjectId> = HashSet::new();
4372    while let Some(oid) = stack.pop() {
4373        if excluded.contains(&oid) || !seen.insert(oid) {
4374            continue;
4375        }
4376        let object = match object_repo.read_object(&oid) {
4377            Ok(object) => object,
4378            Err(_) => return Err(ClosureCheck::Incomplete { missing: oid }),
4379        };
4380        match object.object_type {
4381            GitObjectType::Commit => {
4382                let commit = object_repo
4383                    .read_commit(&oid)
4384                    .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4385                stack.push(commit.tree);
4386                for parent in commit.parents {
4387                    stack.push(parent);
4388                }
4389            }
4390            GitObjectType::Tree => {
4391                let tree = object_repo
4392                    .read_tree(&oid)
4393                    .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4394                for entry in tree.entries {
4395                    // Submodule pointers reference commits in a foreign repo;
4396                    // they are not part of this repo's object closure.
4397                    if entry.mode == GITLINK_MODE {
4398                        continue;
4399                    }
4400                    stack.push(entry.oid);
4401                }
4402            }
4403            GitObjectType::Tag => {
4404                let tag = object_repo
4405                    .read_tag(&oid)
4406                    .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4407                stack.push(tag.object);
4408            }
4409            GitObjectType::Blob => {}
4410        }
4411    }
4412    Ok(())
4413}
4414
4415/// Resolve the git OID a heddle state maps to, preferring the in-memory bridge
4416/// mapping and falling back to the git-overlay checkpoint mapping (the same
4417/// resolution the checkout tip uses). Returns `None` when the state has no mapped
4418/// git object at all.
4419fn resolve_mapped_git_oid(
4420    heddle_repo: &HeddleRepository,
4421    mapping: &SyncMapping,
4422    state_id: &StateId,
4423    object_repo: &SleyRepository,
4424) -> GitProjectionResult<Option<ObjectId>> {
4425    if let Some(git_oid) = mapping.get_git(state_id) {
4426        return Ok(Some(git_oid));
4427    }
4428    if let Some(git_commit) = heddle_repo
4429        .git_overlay_mapped_git_commit_for_state(state_id)
4430        .map_err(|error| GitProjectionError::Git(error.to_string()))?
4431    {
4432        let oid = ObjectId::from_hex(object_repo.object_format(), &git_commit)
4433            .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?;
4434        return Ok(Some(oid));
4435    }
4436    Ok(None)
4437}
4438
4439pub fn copy_reachable_objects(
4440    source: &SleyRepository,
4441    target: &SleyRepository,
4442    roots: impl IntoIterator<Item = ObjectId>,
4443) -> GitProjectionResult<()> {
4444    // TODO: Keep local Git-lane reachable transfer behind Sley primitives. If
4445    // this needs pack identity/stream planning, route it through the Sley
4446    // reachable-pack facade gate instead of adding a Heddle-local planner.
4447    let roots = roots.into_iter().collect::<Vec<_>>();
4448    target.copy_reachable_from(source, &roots).map_err(git_err)
4449}
4450
4451/// Incremental variant of [`copy_reachable_objects`]: copy the closure
4452/// reachable from `roots`, skipping every object in `excluded`.
4453///
4454/// INVARIANT: every OID in `excluded` MUST already be present in `target` — the
4455/// walk neither visits nor copies an excluded object (nor anything reachable only
4456/// through it), so excluding an object the target is missing would silently drop
4457/// it. Callers satisfy this by computing `excluded` as the reachable closure of
4458/// something already in `target`. Used by checkpoint write-through, which passes
4459/// the prior checkout HEAD's full closure (already entirely in the checkout's
4460/// object DB): the new commit's tree re-reaches the parent's unchanged
4461/// trees/blobs, so excluding the whole closure — not just the parent commit —
4462/// prunes them all, turning per-checkpoint object transfer from O(total history)
4463/// into O(objects new since the parent). Output is byte-identical — the same
4464/// objects end up in `target`; the pruned ones were already there.
4465pub fn copy_reachable_objects_excluding(
4466    source: &SleyRepository,
4467    target: &SleyRepository,
4468    roots: impl IntoIterator<Item = ObjectId>,
4469    excluded: &HashSet<ObjectId>,
4470) -> GitProjectionResult<()> {
4471    if excluded.is_empty() {
4472        return copy_reachable_objects(source, target, roots);
4473    }
4474    if source.object_format() != target.object_format() {
4475        // Mismatched formats can't share objects; fall back to the plain copy so
4476        // its existing format-mismatch error surfaces unchanged.
4477        return copy_reachable_objects(source, target, roots);
4478    }
4479    // TODO: This local incremental transfer already delegates pack installation
4480    // to Sley. Keep future reachable-pack planning Sley-gated here too; Heddle
4481    // should not grow its own exclusion-aware pack planner.
4482    sley::plumbing::sley_odb::install_reachable_pack_excluding(
4483        source.objects().as_ref(),
4484        target.objects().as_ref(),
4485        target.object_format(),
4486        roots,
4487        excluded,
4488    )
4489    .map_err(|error| GitProjectionError::Git(error.to_string()))?;
4490    // Make the freshly-installed pack visible to subsequent reads on `target`,
4491    // mirroring what `copy_reachable_from` does internally.
4492    target.refresh_objects();
4493    Ok(())
4494}
4495
4496fn fetch_network_remote(
4497    mirror_repo: &SleyRepository,
4498    remote_name: &str,
4499    url: &str,
4500    scope: GitFetchScope,
4501) -> GitProjectionResult<()> {
4502    let mut credentials = NoCredentials;
4503    let mut progress = SilentProgress;
4504    mirror_repo
4505        .fetch(
4506            url,
4507            &heddle_mirror_fetch_refspecs()?,
4508            FetchOptions {
4509                // sley 0.5.0 additions — heddle uses git defaults (no CLI override).
4510                filter_auto: false,
4511                progress: None,
4512                upload_pack_command: None,
4513                negotiation_include: None,
4514                negotiation_restrict: None,
4515                reject_shallow: false,
4516                quiet: true,
4517                auto_follow_tags: matches!(scope, GitFetchScope::AllRefs),
4518                fetch_all_tags: matches!(scope, GitFetchScope::AllRefs),
4519                prune: false,
4520                dry_run: false,
4521                append: false,
4522                write_fetch_head: true,
4523                force: false,
4524                tag_option_explicit: true,
4525                prune_option_explicit: true,
4526                prune_tags: false,
4527                prune_tags_option_explicit: false,
4528                refmap: None,
4529                refetch: false,
4530                record_promisor_refs: false,
4531                update_head_ok: false,
4532                ssh_options: None,
4533                atomic: false,
4534                depth: None,
4535                merge_srcs: Vec::new(),
4536                filter: None,
4537                cloning: false,
4538                update_shallow: false,
4539                deepen_relative: false,
4540                deepen_since: None,
4541                deepen_not: Vec::new(),
4542            },
4543            &mut credentials,
4544            &mut progress,
4545        )
4546        .map_err(|err| GitProjectionError::Git(format!("failed to fetch from {url}: {err}")))?;
4547    let _ = remote_name;
4548    Ok(())
4549}
4550
4551/// Push the served frontier to a URL/network remote. Returns the sorted
4552/// full names of the refs written on the wire (see [`planned_write_names`]).
4553fn push_network_remote(
4554    mirror_repo: &SleyRepository,
4555    heddle_dir: &Path,
4556    url: &str,
4557    scope: GitPushScope,
4558    current_branch: Option<&str>,
4559    force: bool,
4560) -> GitProjectionResult<Vec<String>> {
4561    // The network destination's exported-refs record lives in heddle's own dir,
4562    // keyed by the remote URL (the remote has no local git dir to host the
4563    // sidecar). Read it BEFORE the empty-frontier fast-path: a retraction lands
4564    // here with an EMPTY served set yet a non-empty record, so the delete-set —
4565    // not the served set — is what must still propagate (heddle#316 r11).
4566    let manifest_path = network_exported_refs_path(heddle_dir, url);
4567    let previously_exported = read_exported_refs_at(&manifest_path)?;
4568    // The WHOLE-MIRROR served frontier — the SAME projection the local-path
4569    // destination reconciles against and the mirror reconcile materialized
4570    // (heddle#316 r16). A scoped push reconciles the destination against this
4571    // whole frontier, so an out-of-scope ref the mirror rewound for embargo
4572    // propagates to the wire by construction, never a scope-filtered subset.
4573    //
4574    // Managed-filtered (heddle#316): the same foreign-ref exclusion the
4575    // local-path push applies — a foreign branch/tag heddle never wrote is kept
4576    // off the wire, sourced from the mirror's name-keyed managed-refs record.
4577    let managed_record = read_mirror_managed_refs(mirror_repo)?;
4578    let served_frontier = collect_managed_ref_updates(mirror_repo, &managed_record)?;
4579    if served_frontier.is_empty() && previously_exported.is_empty() {
4580        return Ok(Vec::new());
4581    }
4582
4583    let mut credentials = NoCredentials;
4584    let records = mirror_repo
4585        .ls_remote(
4586            url,
4587            LsRemoteFilter {
4588                heads: false,
4589                tags: false,
4590                refs_only: true,
4591            },
4592            &|_| true,
4593            &mut credentials,
4594        )
4595        .map_err(|err| GitProjectionError::Git(format!("failed to list refs from {url}: {err}")))?;
4596    let remote_refs = records
4597        .into_iter()
4598        .filter(|record| GitRefName::new(&record.name).content_namespace().is_some())
4599        .map(|record| (record.name, record.oid))
4600        .collect::<HashMap<_, _>>();
4601
4602    // The SAME served-frontier plan the local-path destination runs: writes
4603    // (forcing embargo rewinds, rejecting forks), the retraction delete-set
4604    // (scoped to heddle-owned refs — never foreign), and the new record to
4605    // persist — all derived from the whole-mirror `served_frontier` above.
4606    let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4607    let plan = plan_destination_reconcile(
4608        mirror_repo,
4609        &served_frontier,
4610        creatable.as_ref(),
4611        &remote_refs,
4612        &previously_exported,
4613        force,
4614    )?;
4615
4616    if plan.writes.is_empty() && plan.deletes.is_empty() {
4617        // Nothing to move on the wire, but the record may still need to drop a
4618        // ref that was already absent at the remote.
4619        write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4620        return Ok(Vec::new());
4621    }
4622
4623    let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4624    let mut pack_objects = Vec::with_capacity(plan.writes.len());
4625    let force_transport_checks = plan.writes.iter().any(|write| write.force);
4626    for write in &plan.writes {
4627        commands.push(PushCommand {
4628            src: Some(write.new),
4629            dst: write.full_name.clone(),
4630            expected_old: write.old,
4631            force: write.force,
4632        });
4633        pack_objects.push(write.new);
4634    }
4635    for delete in &plan.deletes {
4636        commands.push(PushCommand {
4637            src: None,
4638            dst: delete.full_name.clone(),
4639            expected_old: Some(delete.old),
4640            force: false,
4641        });
4642    }
4643
4644    let mut credentials = NoCredentials;
4645    let mut progress = SilentProgress;
4646    mirror_repo
4647        .push_actions(
4648            url,
4649            PushActionPlan {
4650                commands,
4651                pack_objects,
4652                options: PushOptions {
4653                    // sley 0.5.0 additions — heddle uses git defaults.
4654                    atomic: false,
4655                    push_options: Vec::new(),
4656                    quiet: true,
4657                    force: force || force_transport_checks,
4658                    thin: sley::remote::PushThinMode::Auto,
4659                },
4660            },
4661            &mut credentials,
4662            &mut progress,
4663        )
4664        .map_err(|err| GitProjectionError::Git(format!("push failed for {url}: {err}")))?;
4665    // Only persist the record once the remote has acknowledged every command, so
4666    // a failed push never leaves a ref recorded as exported that did not land.
4667    write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4668    Ok(planned_write_names(&plan))
4669}
4670
4671/// Push the authoritative checkout's Git refs directly through Sley.
4672///
4673/// Local branches and tags are intentional inputs because `.git` is authoritative
4674/// in an overlay checkout. The per-remote manifest separately prevents Heddle
4675/// from claiming, rewinding, or deleting destination refs it did not publish.
4676pub struct AuthoritativeGitPushOptions<'a> {
4677    pub heddle_dir: &'a Path,
4678    pub remote: &'a str,
4679    pub scope: GitPushScope,
4680    pub current_branch: Option<&'a str>,
4681    pub force: bool,
4682}
4683
4684pub fn push_authoritative_git_refs(
4685    source: &SleyRepository,
4686    options: AuthoritativeGitPushOptions<'_>,
4687    credentials: &mut dyn CredentialProvider,
4688    progress: &mut dyn ProgressSink,
4689) -> GitProjectionResult<Vec<String>> {
4690    let AuthoritativeGitPushOptions {
4691        heddle_dir,
4692        remote,
4693        scope,
4694        current_branch,
4695        force,
4696    } = options;
4697    let remote_url = source.remote(remote).map_err(git_err)?.push_url();
4698    let manifest_path = network_exported_refs_path(heddle_dir, &remote_url);
4699    let previously_exported = read_exported_refs_at(&manifest_path)?;
4700    let served_frontier = collect_ref_updates(source)?
4701        .into_iter()
4702        .filter(|update| {
4703            matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag)
4704                || (update.namespace == RefNamespace::Note && update.name == "heddle")
4705        })
4706        .filter(|update| match scope {
4707            GitPushScope::AllThreads => true,
4708            GitPushScope::CurrentThread => {
4709                update.namespace == RefNamespace::Note
4710                    || (update.namespace == RefNamespace::Branch
4711                        && Some(update.name.as_str()) == current_branch)
4712            }
4713        })
4714        .collect::<Vec<_>>();
4715
4716    let records = source
4717        .ls_remote(
4718            &remote_url,
4719            LsRemoteFilter {
4720                heads: false,
4721                tags: false,
4722                refs_only: true,
4723            },
4724            &|_| true,
4725            credentials,
4726        )
4727        .map_err(git_err)?;
4728    let remote_refs = records
4729        .into_iter()
4730        .filter(|record| GitRefName::new(&record.name).content_namespace().is_some())
4731        .map(|record| (record.name, record.oid))
4732        .collect::<HashMap<_, _>>();
4733    let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4734    let previously_exported_in_scope = previously_exported
4735        .iter()
4736        .filter(|(name, _)| match scope {
4737            GitPushScope::AllThreads => true,
4738            GitPushScope::CurrentThread => {
4739                name.as_str() == "refs/notes/heddle"
4740                    || current_branch
4741                        .is_some_and(|branch| name.as_str() == format!("refs/heads/{branch}"))
4742            }
4743        })
4744        .map(|(name, oid)| (name.clone(), *oid))
4745        .collect::<HashMap<_, _>>();
4746    let mut plan = plan_destination_reconcile(
4747        source,
4748        &served_frontier,
4749        creatable.as_ref(),
4750        &remote_refs,
4751        &previously_exported_in_scope,
4752        force,
4753    )?;
4754    if scope == GitPushScope::CurrentThread {
4755        for (name, oid) in previously_exported {
4756            plan.new_manifest.entry(name).or_insert(oid);
4757        }
4758    }
4759    if plan.writes.is_empty() && plan.deletes.is_empty() {
4760        write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4761        return Ok(Vec::new());
4762    }
4763
4764    let force_transport_checks = plan.writes.iter().any(|write| write.force);
4765    let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4766    let mut pack_objects = Vec::with_capacity(plan.writes.len());
4767    for write in &plan.writes {
4768        commands.push(PushCommand {
4769            src: Some(write.new),
4770            dst: write.full_name.clone(),
4771            expected_old: write.old,
4772            force: write.force,
4773        });
4774        pack_objects.push(write.new);
4775    }
4776    for delete in &plan.deletes {
4777        commands.push(PushCommand {
4778            src: None,
4779            dst: delete.full_name.clone(),
4780            expected_old: Some(delete.old),
4781            force: false,
4782        });
4783    }
4784    source
4785        .push_actions(
4786            remote,
4787            PushActionPlan {
4788                commands,
4789                pack_objects,
4790                options: PushOptions {
4791                    quiet: true,
4792                    force: force || force_transport_checks,
4793                    thin: sley::remote::PushThinMode::Auto,
4794                    atomic: false,
4795                    push_options: Vec::new(),
4796                },
4797            },
4798            credentials,
4799            progress,
4800        )
4801        .map_err(git_err)?;
4802    write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4803    Ok(planned_write_names(&plan))
4804}
4805
4806#[cfg(test)]
4807mod tests {
4808    use super::*;
4809
4810    #[test]
4811    fn parse_git_ref_local_branch() {
4812        let parsed = parse_git_ref("refs/heads/main").expect("local branch parses");
4813        assert_eq!(parsed.kind, GitRefKind::Branch);
4814        assert_eq!(parsed.name, "main");
4815        assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4816    }
4817
4818    #[test]
4819    fn parse_git_ref_remote_branch_keeps_nested_name() {
4820        let parsed = parse_git_ref("refs/remotes/origin/feature/x").expect("remote branch parses");
4821        assert_eq!(parsed.kind, GitRefKind::Branch);
4822        assert_eq!(parsed.name, "feature/x");
4823        assert_eq!(parsed.remote, "origin");
4824    }
4825
4826    #[test]
4827    fn parse_git_ref_tag() {
4828        let parsed = parse_git_ref("refs/tags/v1.0").expect("tag parses");
4829        assert_eq!(parsed.kind, GitRefKind::Tag);
4830        assert_eq!(parsed.name, "v1.0");
4831        assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4832    }
4833
4834    #[test]
4835    fn parse_git_ref_note() {
4836        let parsed = parse_git_ref("refs/notes/heddle").expect("note parses");
4837        assert_eq!(parsed.kind, GitRefKind::Note);
4838        assert_eq!(parsed.name, "heddle");
4839        assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4840    }
4841
4842    #[test]
4843    fn parse_git_ref_skips_head_symrefs() {
4844        assert_eq!(parse_git_ref("refs/heads/HEAD"), None);
4845        assert_eq!(parse_git_ref("refs/remotes/origin/HEAD"), None);
4846    }
4847
4848    #[test]
4849    fn parse_git_ref_rejects_unknown_or_malformed() {
4850        assert_eq!(parse_git_ref("HEAD"), None);
4851        // A remote ref with no branch component beneath the remote name.
4852        assert_eq!(parse_git_ref("refs/remotes/origin"), None);
4853    }
4854
4855    #[test]
4856    fn parse_git_ref_rejects_reserved_git_remote_namespace() {
4857        // A user remote literally named `git` collides with the local sentinel;
4858        // it must not be aliased onto local refs at the parse site.
4859        assert_eq!(parse_git_ref("refs/remotes/git/main"), None);
4860        assert_eq!(parse_git_ref("refs/remotes/git/feature/x"), None);
4861        assert!(is_reserved_git_remote_name(REMOTE_NAME_FOR_LOCAL_GIT_REPO));
4862        assert!(!is_reserved_git_remote_name("origin"));
4863    }
4864
4865    #[test]
4866    fn local_path_from_url_rejects_hosted_heddle_scheme() {
4867        // Regression (push-routing no-op): a `heddle://` hosted remote that
4868        // reaches the git-overlay exporter must be a HARD ERROR, never a
4869        // silent no-op success. The git network pusher cannot speak the
4870        // hosted protocol, so classifying a `heddle://` URL here must fail
4871        // loudly rather than fall through to `ResolvedRemote::Url` (which
4872        // would "reconcile" locally and report success without ever
4873        // contacting the server).
4874        let err = local_path_from_url("heddle://weft.local:8421/org/repo")
4875            .expect_err("heddle:// must be rejected by the git exporter classifier");
4876        let msg = err.to_string();
4877        assert!(
4878            msg.contains("heddle://") && msg.contains("hosted"),
4879            "error should explain the hosted scheme cannot be pushed via the git-overlay exporter, got: {msg}"
4880        );
4881    }
4882
4883    #[test]
4884    fn local_path_from_url_still_accepts_file_and_git_urls() {
4885        // The guard must not regress legitimate transports: `file://` still
4886        // resolves to a local path, and ordinary git URLs (https/ssh) still
4887        // pass through as "not local" (Ok(None)) for the network git pusher.
4888        assert!(
4889            local_path_from_url("file:///tmp/repo.git")
4890                .expect("file url ok")
4891                .is_some(),
4892            "file:// must still resolve to a local path"
4893        );
4894        assert!(
4895            local_path_from_url("https://example.com/org/repo.git")
4896                .expect("https url ok")
4897                .is_none(),
4898            "https git url must pass through as a network URL"
4899        );
4900        assert!(
4901            local_path_from_url("git@github.com:org/repo.git")
4902                .expect("ssh url ok")
4903                .is_none(),
4904            "ssh git url must pass through as a network URL"
4905        );
4906    }
4907
4908    #[test]
4909    fn refspec_forced_round_trips_git_format() {
4910        let spec =
4911            RefSpec::forced("refs/heads/main", "refs/heads/main").expect("valid forced refspec");
4912        assert_eq!(spec.to_git_format(), "+refs/heads/main:refs/heads/main");
4913        assert_eq!(
4914            spec.to_git_format_not_forced(),
4915            "refs/heads/main:refs/heads/main"
4916        );
4917    }
4918
4919    #[test]
4920    fn refspec_constructor_rejects_reserved_remote_name() {
4921        let err = RefSpec::new(
4922            Some("refs/remotes/git/main".to_string()),
4923            "refs/heads/main",
4924            false,
4925        )
4926        .expect_err("reserved remote source is rejected");
4927        assert!(err.to_string().contains("reserved namespace"));
4928
4929        let err = RefSpec::new(
4930            Some("refs/heads/main".to_string()),
4931            "refs/remotes/git/main",
4932            false,
4933        )
4934        .expect_err("reserved remote destination is rejected");
4935        assert!(err.to_string().contains("reserved namespace"));
4936    }
4937
4938    #[test]
4939    fn refspec_forced_rejects_reserved_remote_name() {
4940        assert!(RefSpec::forced("refs/remotes/git/main", "refs/heads/main").is_err());
4941        assert!(RefSpec::forced("refs/heads/main", "refs/remotes/git/main").is_err());
4942    }
4943
4944    #[test]
4945    fn refspec_delete_has_empty_source() {
4946        let spec = RefSpec::delete("refs/heads/stale").expect("valid delete refspec");
4947        assert_eq!(spec.to_git_format(), ":refs/heads/stale");
4948        assert_eq!(spec.to_git_format_not_forced(), ":refs/heads/stale");
4949    }
4950
4951    #[test]
4952    fn refspec_delete_rejects_reserved_remote_name() {
4953        assert!(RefSpec::delete("refs/remotes/git/stale").is_err());
4954    }
4955
4956    #[test]
4957    fn refspec_constructor_rejects_empty_source_and_destination() {
4958        let err = RefSpec::new(None, "", false)
4959            .expect_err("empty source plus empty destination is rejected");
4960        assert!(err.to_string().contains("cannot both be empty"));
4961    }
4962
4963    #[test]
4964    fn negative_refspec_prefixes_caret() {
4965        let spec = NegativeRefSpec::new("refs/heads/wip").expect("valid negative refspec");
4966        assert_eq!(spec.to_git_format(), "^refs/heads/wip");
4967    }
4968
4969    #[test]
4970    fn negative_refspec_constructor_rejects_unparseable_negation() {
4971        let err = NegativeRefSpec::new("refs/heads/wip/*").expect_err("negative glob is rejected");
4972        assert!(err.to_string().contains("Negative glob patterns"));
4973    }
4974
4975    #[test]
4976    fn negative_refspec_constructor_rejects_reserved_remote_name() {
4977        let err = NegativeRefSpec::new("refs/remotes/git/main")
4978            .expect_err("reserved remote negative source is rejected");
4979        assert!(err.to_string().contains("reserved namespace"));
4980    }
4981
4982    #[test]
4983    fn mirror_fetch_refspecs_cover_branches_and_notes() {
4984        assert_eq!(
4985            heddle_mirror_fetch_refspecs().expect("mirror refspecs are valid"),
4986            [
4987                "+refs/heads/*:refs/heads/*".to_string(),
4988                "+refs/notes/*:refs/notes/*".to_string(),
4989            ]
4990        );
4991    }
4992
4993    #[test]
4994    fn scoped_import_ref_updates_do_not_include_notes_implicitly() {
4995        let tmp = tempfile::TempDir::new().unwrap();
4996        let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4997        let main = seed_commit(&repo, "main");
4998        let other = seed_commit(&repo, "other");
4999        let notes = seed_commit(&repo, "notes");
5000        set_reference(
5001            &repo,
5002            "refs/heads/main",
5003            main,
5004            RefPrecondition::MustNotExist,
5005            "test: main",
5006        )
5007        .expect("write main");
5008        set_reference(
5009            &repo,
5010            "refs/heads/other",
5011            other,
5012            RefPrecondition::MustNotExist,
5013            "test: other",
5014        )
5015        .expect("write other");
5016        set_reference(
5017            &repo,
5018            "refs/notes/heddle",
5019            notes,
5020            RefPrecondition::MustNotExist,
5021            "test: notes",
5022        )
5023        .expect("write notes");
5024
5025        let updates = collect_import_source_ref_updates(&repo, &["main".to_string()])
5026            .expect("collect scoped updates");
5027        let full_names = updates.iter().map(full_ref_name).collect::<Vec<_>>();
5028
5029        assert_eq!(full_names, vec!["refs/heads/main".to_string()]);
5030    }
5031
5032    #[test]
5033    fn fast_forward_guard_reports_exact_rewrite_before_after() {
5034        let tmp = tempfile::TempDir::new().unwrap();
5035        let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
5036        let root = test_commit(&repo, "root", &[]);
5037        let old = test_commit(&repo, "old", &[root]);
5038        let new = test_commit(&repo, "new", &[root]);
5039
5040        let err = ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
5041            .expect_err("sibling commit update should be refused");
5042        let message = err.to_string();
5043        assert!(message.contains("refs/heads/main"));
5044        assert!(message.contains(&old.to_string()));
5045        assert!(message.contains(&new.to_string()));
5046        assert!(message.contains("refusing to replace"));
5047    }
5048
5049    #[test]
5050    fn fast_forward_guard_allows_descendant_update() {
5051        let tmp = tempfile::TempDir::new().unwrap();
5052        let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
5053        let old = test_commit(&repo, "old", &[]);
5054        let new = test_commit(&repo, "new", &[old]);
5055
5056        ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
5057            .expect("descendant update should be allowed");
5058    }
5059
5060    fn test_commit(repo: &SleyRepository, message: &str, parents: &[ObjectId]) -> ObjectId {
5061        let empty_tree_oid = ObjectId::empty_tree(repo.object_format());
5062        let sig = Signature {
5063            name: GitByteString::new(b"Heddle Test".to_vec()),
5064            email: GitByteString::new(b"heddle@test".to_vec()),
5065            time: GitTime::new(0, 0),
5066            raw: b"Heddle Test <heddle@test> 0 +0000".to_vec(),
5067        };
5068        let commit = sley::CommitObject {
5069            tree: empty_tree_oid,
5070            parents: parents.to_vec(),
5071            author: sig.to_ident_bytes(),
5072            committer: sig.to_ident_bytes(),
5073            encoding: None,
5074            message: message.as_bytes().to_vec(),
5075        };
5076        repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
5077            GitObjectType::Commit,
5078            commit.write(),
5079        ))
5080        .expect("write test commit")
5081    }
5082
5083    fn seed_commit(repo: &SleyRepository, message: &str) -> ObjectId {
5084        test_commit(repo, message, &[])
5085    }
5086
5087    /// heddle#141 regression: when the URL-fetch path of
5088    /// `clone_url_to_bare` runs against a bare repo whose `HEAD`
5089    /// points at a branch that is *not* alphabetically first (and
5090    /// crucially, not what sley's `init_bare` defaults to), the
5091    /// resulting dest bare must have `HEAD` pointing at the remote
5092    /// default — not sley's init-time guess.
5093    #[test]
5094    fn clone_url_to_bare_via_sley_honours_remote_head_symref() {
5095        let tmp = tempfile::TempDir::new().unwrap();
5096        let source = tmp.path().join("source.git");
5097        let dest = tmp.path().join("dest.git");
5098
5099        // Build a bare source with two branches under
5100        // deliberately-non-default names: `trunk` (will be the
5101        // remote default — neither sley's `init.defaultBranch` nor
5102        // the alphabetically-first imported ref would land here by
5103        // accident) and `abc-feature` (alphabetically first — what
5104        // the buggy fallback used to pick).
5105        let src = SleyRepository::init_bare(&source).expect("init bare source");
5106        let seed = seed_commit(&src, "seed");
5107        for name in ["refs/heads/trunk", "refs/heads/abc-feature"] {
5108            set_reference(&src, name, seed, RefPrecondition::Any, "test: seed branch")
5109                .expect("set ref");
5110        }
5111        // Make sure HEAD on the source points at trunk so
5112        // `git ls-remote --symref` reports trunk.
5113        std::fs::write(source.join("HEAD"), b"ref: refs/heads/trunk\n").unwrap();
5114
5115        let url = format!("file://{}", source.display());
5116        let mut progress = SilentProgress;
5117        clone_url_to_bare(&url, &dest, None, None, &mut progress).expect("clone url to bare");
5118
5119        let dest_head = std::fs::read_to_string(dest.join("HEAD")).expect("read dest HEAD");
5120        assert_eq!(
5121            dest_head.trim(),
5122            "ref: refs/heads/trunk",
5123            "dest HEAD must mirror the remote's symref (trunk), not sley's \
5124             init-time default and not the alphabetically-first branch \
5125             (abc-feature) — see heddle#141"
5126        );
5127    }
5128
5129    #[test]
5130    fn write_head_symref_writes_git_head_bytes() {
5131        let tmp = tempfile::TempDir::new().unwrap();
5132        let git_dir = tmp.path();
5133        SleyRepository::init_bare(git_dir).expect("init bare");
5134
5135        write_head_symref(git_dir, "refs/heads/feature/x").expect("write HEAD symref");
5136        assert_eq!(
5137            std::fs::read_to_string(git_dir.join("HEAD")).expect("read HEAD"),
5138            "ref: refs/heads/feature/x\n"
5139        );
5140
5141        write_head_symref(git_dir, "refs/heads/main").expect("rewrite HEAD symref");
5142        assert_eq!(
5143            std::fs::read_to_string(git_dir.join("HEAD")).unwrap(),
5144            "ref: refs/heads/main\n"
5145        );
5146    }
5147
5148    #[test]
5149    fn rollback_restore_uses_git_lock_and_compare_and_swap() {
5150        let tmp = tempfile::TempDir::new().unwrap();
5151        let path = tmp.path().join("HEAD");
5152        std::fs::write(&path, b"published").unwrap();
5153
5154        restore_file_if_unchanged(&path, b"published", Some(b"previous")).unwrap();
5155        assert_eq!(std::fs::read(&path).unwrap(), b"previous");
5156        assert!(!tmp.path().join("HEAD.lock").exists());
5157
5158        std::fs::write(&path, b"concurrent").unwrap();
5159        let error = restore_file_if_unchanged(&path, b"published", Some(b"previous"))
5160            .expect_err("concurrent write must block rollback");
5161        assert!(error.to_string().contains("another Git operation changed"));
5162        assert_eq!(std::fs::read(&path).unwrap(), b"concurrent");
5163        assert!(!tmp.path().join("HEAD.lock").exists());
5164    }
5165
5166    #[test]
5167    fn checkout_publish_rolls_back_branch_when_head_reflog_fails() {
5168        let tmp = tempfile::TempDir::new().unwrap();
5169        let repo = SleyRepository::init(tmp.path()).expect("init repository");
5170        repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
5171            GitObjectType::Tree,
5172            Vec::new(),
5173        ))
5174        .expect("write empty tree");
5175        let previous = test_commit(&repo, "previous", &[]);
5176        let next = test_commit(&repo, "next", &[previous]);
5177        set_reference(
5178            &repo,
5179            "refs/heads/main",
5180            previous,
5181            RefPrecondition::Any,
5182            "test: seed branch",
5183        )
5184        .unwrap();
5185        write_head_symref(repo.git_dir(), "refs/heads/main").unwrap();
5186
5187        let write = CheckoutWrite::prepare(tmp.path(), "main").unwrap();
5188        let previous_head = write.previous_head.clone();
5189        let previous_index = write.previous_index.clone();
5190        std::fs::remove_file(write.git_dir.join("logs/HEAD")).expect("remove existing HEAD reflog");
5191        std::fs::create_dir_all(write.git_dir.join("logs/HEAD"))
5192            .expect("block HEAD reflog file creation");
5193
5194        write
5195            .publish(next)
5196            .expect_err("HEAD reflog failure must fail publication");
5197
5198        let branch = repo
5199            .find_reference("refs/heads/main")
5200            .unwrap()
5201            .unwrap()
5202            .peeled_oid(&repo)
5203            .unwrap()
5204            .unwrap();
5205        assert_eq!(branch, previous);
5206        assert_eq!(read_optional_file(&write.head_path).unwrap(), previous_head);
5207        assert_eq!(
5208            read_optional_file(&write.index_path).unwrap(),
5209            previous_index
5210        );
5211        assert!(!write.git_dir.join("index.lock").exists());
5212        assert!(!write.git_dir.join("HEAD.lock").exists());
5213    }
5214
5215    /// Characterization: `head_state()` branch/detached/unborn mapping matches
5216    /// the legacy `ref: refs/heads/` text parse.
5217    #[test]
5218    fn head_state_matches_legacy_head_symref_parse() {
5219        let tmp = tempfile::TempDir::new().unwrap();
5220        let root = tmp.path();
5221        let git_dir = root.join(".git");
5222        SleyRepository::init_bare(&git_dir).expect("init bare overlay");
5223
5224        fn legacy_branch_parse(head_path: &Path) -> Option<String> {
5225            let contents = std::fs::read_to_string(head_path).ok()?;
5226            let trimmed = contents.trim();
5227            let suffix = trimmed.strip_prefix("ref: ")?;
5228            let branch = suffix.strip_prefix("refs/heads/")?;
5229            (!branch.is_empty()).then(|| branch.to_string())
5230        }
5231
5232        // Attached symref.
5233        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
5234        let repo = open_repo(root).expect("open");
5235        assert_eq!(repo.head_state().unwrap().branch_name(), Some("main"));
5236        assert_eq!(
5237            legacy_branch_parse(&git_dir.join("HEAD")),
5238            Some("main".into())
5239        );
5240
5241        // Detached HEAD.
5242        let oid = ObjectId::from_hex(
5243            ObjectFormat::Sha1,
5244            "0000000000000000000000000000000000000001",
5245        )
5246        .unwrap();
5247        std::fs::write(git_dir.join("HEAD"), format!("{oid}\n")).unwrap();
5248        let repo = open_repo(root).expect("open");
5249        let state = repo.head_state().unwrap();
5250        assert!(state.is_detached());
5251        assert_eq!(state.branch_name(), None);
5252        assert_eq!(legacy_branch_parse(&git_dir.join("HEAD")), None);
5253
5254        // Unborn branch symref (no refs/heads/feature yet).
5255        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
5256        let repo = open_repo(root).expect("open");
5257        assert_eq!(repo.head_state().unwrap().branch_name(), Some("feature"));
5258        assert_eq!(
5259            legacy_branch_parse(&git_dir.join("HEAD")),
5260            Some("feature".into())
5261        );
5262    }
5263}