Skip to main content

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