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