Skip to main content

cli/bridge/
git_core.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Core Git bridge types and 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;
17use repo::{GitRefName, Repository as HeddleRepository};
18pub use repo::{GitRefKind, ParsedGitRef, REMOTE_NAME_FOR_LOCAL_GIT_REPO};
19pub(crate) use repo::{GitRefContentNamespace as RefNamespace, is_reserved_git_remote_name};
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 bridge operations.
39#[derive(Debug, thiserror::Error)]
40pub enum GitBridgeError {
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-overlay 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 bridge results.
119pub type GitResult<T> = std::result::Result<T, GitBridgeError>;
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) -> GitResult<()> {
135    if is_reserved_git_remote_name(remote) {
136        return Err(GitBridgeError::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) -> GitResult<()> {
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).bridge_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::{GitResult, 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        ) -> GitResult<Self> {
188            let destination = destination.into();
189            if source.is_none() && destination.is_empty() {
190                return Err(super::GitBridgeError::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        ) -> GitResult<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>) -> GitResult<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::{GitBridgeError, GitResult, 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>) -> GitResult<Self> {
255            let source = source.into();
256            validate_refspec_ref(&source)?;
257            if source.contains('*') {
258                return Err(GitBridgeError::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() -> GitResult<[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 Git mirror is already the active checkout")
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    ) -> GitResult<()> {
459        if let Some(existing) = self.heddle_to_git.get(&change_id)
460            && *existing != git_oid
461        {
462            return Err(GitBridgeError::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(GitBridgeError::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 `bridge git 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/// Git bridge for Heddle repository.
571pub struct GitBridge<'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) -> GitResult<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) -> GitResult<()> {
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> GitBridge<'a> {
613    /// Create a new Git bridge 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 a Git mirror in the .heddle/git directory.
625    pub fn init_mirror(&mut self) -> GitResult<()> {
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) -> GitResult<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 Git mirror directory.
654    pub fn mirror_path(&self) -> PathBuf {
655        self.heddle_repo.heddle_dir().join("git")
656    }
657
658    /// Check if a Git 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) -> GitResult<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    ) -> GitResult<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        ) -> GitResult<()> {
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) -> GitResult<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) -> GitResult<T>,
739    ) -> GitResult<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(GitBridgeError::Git(format!(
757                        "operation failed ({error}); additionally failed to roll back git bridge 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) -> GitResult<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    ) -> GitResult<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    ) -> GitResult<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) -> GitResult<String> {
843        let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
844            return Err(GitBridgeError::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(GitBridgeError::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    ) -> GitResult<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    ) -> GitResult<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(GitBridgeError::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) -> GitResult<()> {
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    ) -> GitResult<()> {
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) -> GitResult<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    ) -> GitResult<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(GitBridgeError::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) -> GitResult<()> {
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    ) -> GitResult<()> {
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| GitBridgeError::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(|_| GitBridgeError::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). The Git bridge always publishes the Public mirror.
1291            let tier = self
1292                .heddle_repo
1293                .effective_visibility_tier(&change_id)
1294                .map_err(|e| {
1295                    GitBridgeError::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    ) -> GitResult<()> {
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) -> GitResult<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        // bridge export or push-all.
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    ) -> GitResult<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) -> GitResult<()> {
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    ) -> GitResult<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    ) -> GitResult<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    ) -> GitResult<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    ) -> GitResult<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 bridge 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 (`bridge git 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| GitBridgeError::Git(error.to_string()))?
1629        {
1630            ObjectId::from_hex(mirror_repo.object_format(), &git_commit)
1631                .map_err(|error| GitBridgeError::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 = (|| -> GitResult<()> {
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| GitBridgeError::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    ) -> GitResult<()> {
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) -> GitResult<()> {
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) -> GitResult<()> {
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    ) -> GitResult<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    ) -> GitResult<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) -> GitResult<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(root: &Path, requested: &str) -> GitResult<Option<String>> {
1958    let remotes = checkout_remote_url_items(root)?;
1959    if remotes.is_empty() {
1960        return Ok(None);
1961    }
1962    if let Some((name, _)) = remotes.iter().find(|(name, _)| name == requested) {
1963        return Ok(Some(name.clone()));
1964    }
1965    if let Some((name, _)) = remotes
1966        .iter()
1967        .find(|(_, url)| configured_remote_values_match(url, requested))
1968    {
1969        return Ok(Some(name.clone()));
1970    }
1971    if looks_like_remote_location(requested) && remotes.len() == 1 {
1972        return Ok(Some(remotes[0].0.clone()));
1973    }
1974    if !looks_like_remote_location(requested) {
1975        return Ok(Some(requested.to_string()));
1976    }
1977    Ok(None)
1978}
1979
1980fn checkout_remote_url_items(root: &Path) -> GitResult<Vec<(String, String)>> {
1981    let mut remotes = Vec::new();
1982    for config_path in checkout_git_config_paths(root) {
1983        parse_remote_url_items_from_config(&config_path, &mut remotes)?;
1984    }
1985    Ok(remotes)
1986}
1987
1988fn checkout_note_ref_exists(root: &Path) -> GitResult<bool> {
1989    if !root.join(".git").exists() {
1990        return Ok(false);
1991    }
1992    let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1993    let object_repo = common_repo_for_worktree(&checkout_repo)?;
1994    Ok(object_repo
1995        .find_reference(super::git_notes::NOTES_REF)
1996        .map_err(git_err)?
1997        .is_some())
1998}
1999
2000fn seed_checkout_note_refs_into_mirror(root: &Path, mirror_repo: &SleyRepository) -> GitResult<()> {
2001    if !root.join(".git").exists() {
2002        return Ok(());
2003    }
2004
2005    let checkout_repo = match SleyRepository::discover(root) {
2006        Ok(repo) => repo,
2007        Err(_) => return Ok(()),
2008    };
2009    if checkout_repo.git_dir() == mirror_repo.git_dir() {
2010        return Ok(());
2011    }
2012    let object_repo = common_repo_for_worktree(&checkout_repo)?;
2013    let note_updates = collect_ref_updates(&object_repo)?
2014        .into_iter()
2015        .filter(|update| update.namespace == RefNamespace::Note)
2016        .collect::<Vec<_>>();
2017    if note_updates.is_empty() {
2018        return Ok(());
2019    }
2020
2021    copy_reachable_objects(
2022        &object_repo,
2023        mirror_repo,
2024        note_updates.iter().map(|update| update.target),
2025    )?;
2026    apply_ref_updates(
2027        mirror_repo,
2028        &note_updates,
2029        "heddle: seed mirror note refs from checkout",
2030    )
2031}
2032
2033fn hydrate_checkout_notes_from_remote_without_mirror(
2034    root: &Path,
2035    remote_name: &str,
2036) -> GitResult<()> {
2037    reject_reserved_git_remote_name(remote_name)?;
2038    let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2039    let object_repo = common_repo_for_worktree(&checkout_repo)?;
2040    let url = remote_fetch_url_from_checkout_config(root, remote_name)?
2041        .ok_or_else(|| GitBridgeError::Git(format!("remote '{remote_name}' has no fetch URL")))?;
2042
2043    if let Some(path) = local_path_from_url(&url)? {
2044        let remote_repo = open_repo(&path)?;
2045        let note_updates = collect_ref_updates(&remote_repo)?
2046            .into_iter()
2047            .filter(|update| update.namespace == RefNamespace::Note)
2048            .collect::<Vec<_>>();
2049        if note_updates.is_empty() {
2050            return Ok(());
2051        }
2052        copy_reachable_objects(
2053            &remote_repo,
2054            &object_repo,
2055            note_updates.iter().map(|update| update.target),
2056        )?;
2057        apply_ref_updates(
2058            &object_repo,
2059            &note_updates,
2060            &format!("heddle: hydrate notes from {remote_name}"),
2061        )?;
2062        return Ok(());
2063    }
2064
2065    fetch_heddle_notes_into_repo(&object_repo, remote_name, &url)
2066}
2067
2068fn fetch_heddle_notes_into_repo(
2069    repo: &SleyRepository,
2070    remote_name: &str,
2071    url: &str,
2072) -> GitResult<()> {
2073    let mut credentials = NoCredentials;
2074    let mut progress = SilentProgress;
2075    let refspec = RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format();
2076    repo.fetch(
2077        url,
2078        &[refspec],
2079        FetchOptions {
2080            quiet: true,
2081            auto_follow_tags: false,
2082            fetch_all_tags: false,
2083            prune: false,
2084            dry_run: false,
2085            append: false,
2086            write_fetch_head: true,
2087            force: false,
2088            tag_option_explicit: true,
2089            prune_option_explicit: true,
2090            prune_tags: false,
2091            prune_tags_option_explicit: false,
2092            refmap: None,
2093            refetch: false,
2094            record_promisor_refs: false,
2095            update_head_ok: false,
2096            ssh_options: None,
2097            atomic: false,
2098            depth: None,
2099            merge_srcs: Vec::new(),
2100            filter: None,
2101            cloning: false,
2102            update_shallow: false,
2103            deepen_relative: false,
2104            deepen_since: None,
2105            deepen_not: Vec::new(),
2106        },
2107        &mut credentials,
2108        &mut progress,
2109    )
2110    .map(|_| ())
2111    .map_err(|err| GitBridgeError::Git(format!("failed to fetch notes from {remote_name}: {err}")))
2112}
2113
2114fn parse_remote_url_items_from_config(
2115    path: &Path,
2116    remotes: &mut Vec<(String, String)>,
2117) -> GitResult<()> {
2118    let Ok(contents) = fs::read_to_string(path) else {
2119        return Ok(());
2120    };
2121    let mut current_remote: Option<String> = None;
2122    for raw in contents.lines() {
2123        let line = raw.trim();
2124        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2125            continue;
2126        }
2127        if line.starts_with('[') && line.ends_with(']') {
2128            current_remote = line
2129                .strip_prefix("[remote \"")
2130                .and_then(|rest| rest.strip_suffix("\"]"))
2131                .map(str::to_string);
2132            continue;
2133        }
2134        let Some(name) = current_remote.as_ref() else {
2135            continue;
2136        };
2137        let Some((key, value)) = line.split_once('=') else {
2138            continue;
2139        };
2140        if key.trim().eq_ignore_ascii_case("url") {
2141            remotes.push((name.clone(), git_config_value(value.trim())?));
2142        }
2143    }
2144    Ok(())
2145}
2146
2147fn configured_remote_values_match(left: &str, right: &str) -> bool {
2148    if left == right {
2149        return true;
2150    }
2151    let left_path = Path::new(left);
2152    let right_path = Path::new(right);
2153    if let (Ok(left), Ok(right)) = (left_path.canonicalize(), right_path.canonicalize()) {
2154        return left == right;
2155    }
2156    false
2157}
2158
2159fn looks_like_remote_location(value: &str) -> bool {
2160    value.starts_with('/')
2161        || value.starts_with("./")
2162        || value.starts_with("../")
2163        || value.starts_with("~/")
2164        || value.contains("://")
2165        || value.contains('\\')
2166}
2167
2168fn remote_fetch_url_from_checkout_config(
2169    root: &Path,
2170    remote_name: &str,
2171) -> GitResult<Option<String>> {
2172    for config_path in checkout_git_config_paths(root) {
2173        let Some(url) = parse_remote_fetch_url_from_config(&config_path, remote_name)? else {
2174            continue;
2175        };
2176        return parse_configured_remote_url(&url, root).map(Some);
2177    }
2178    Ok(None)
2179}
2180
2181fn parse_configured_remote_url(value: &str, relative_base: &Path) -> GitResult<String> {
2182    if configured_remote_is_local_path(value) {
2183        let path = configured_remote_local_path(value, relative_base);
2184        return Ok(format!("file://{}", path.display()));
2185    }
2186    Ok(value.to_string())
2187}
2188
2189fn configured_remote_local_path(value: &str, relative_base: &Path) -> PathBuf {
2190    if value == "~"
2191        && let Some(home) = std::env::var_os("HOME")
2192    {
2193        return PathBuf::from(home);
2194    }
2195    if let Some(rest) = value.strip_prefix("~/")
2196        && let Some(home) = std::env::var_os("HOME")
2197    {
2198        return PathBuf::from(home).join(rest);
2199    }
2200
2201    let path = Path::new(value);
2202    if path.is_absolute() {
2203        path.to_path_buf()
2204    } else {
2205        relative_base.join(path)
2206    }
2207}
2208
2209fn configured_remote_is_local_path(value: &str) -> bool {
2210    value.starts_with('/')
2211        || value.starts_with("./")
2212        || value.starts_with("../")
2213        || value.starts_with('~')
2214        || value.starts_with(std::path::MAIN_SEPARATOR)
2215}
2216
2217fn checkout_git_config_paths(root: &Path) -> Vec<PathBuf> {
2218    let dot_git = root.join(".git");
2219    let mut paths = Vec::new();
2220    if dot_git.is_dir() {
2221        paths.push(dot_git.join("config"));
2222        if let Some(common_dir) = common_git_dir_from_git_dir(&dot_git) {
2223            paths.push(common_dir.join("config"));
2224        }
2225        return paths;
2226    }
2227    let Ok(contents) = fs::read_to_string(&dot_git) else {
2228        return paths;
2229    };
2230    let Some(target) = contents.trim().strip_prefix("gitdir:").map(str::trim) else {
2231        return paths;
2232    };
2233    let git_dir = {
2234        let path = Path::new(target);
2235        if path.is_absolute() {
2236            path.to_path_buf()
2237        } else {
2238            dot_git
2239                .parent()
2240                .map(|parent| parent.join(path))
2241                .unwrap_or_else(|| path.to_path_buf())
2242        }
2243    };
2244    paths.push(git_dir.join("config"));
2245    if let Some(common_dir) = common_git_dir_from_git_dir(&git_dir) {
2246        paths.push(common_dir.join("config"));
2247    }
2248    paths
2249}
2250
2251fn common_git_dir_from_git_dir(git_dir: &Path) -> Option<PathBuf> {
2252    let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
2253    let target = contents.trim();
2254    let path = Path::new(target);
2255    Some(if path.is_absolute() {
2256        path.to_path_buf()
2257    } else {
2258        git_dir.join(path)
2259    })
2260}
2261
2262fn parse_remote_fetch_url_from_config(path: &Path, remote_name: &str) -> GitResult<Option<String>> {
2263    let Ok(contents) = fs::read_to_string(path) else {
2264        return Ok(None);
2265    };
2266    let mut in_remote = false;
2267    for raw in contents.lines() {
2268        let line = raw.trim();
2269        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2270            continue;
2271        }
2272        if line.starts_with('[') && line.ends_with(']') {
2273            in_remote = line
2274                .strip_prefix("[remote \"")
2275                .and_then(|rest| rest.strip_suffix("\"]"))
2276                == Some(remote_name);
2277            continue;
2278        }
2279        if !in_remote {
2280            continue;
2281        }
2282        let Some((key, value)) = line.split_once('=') else {
2283            continue;
2284        };
2285        if key.trim().eq_ignore_ascii_case("url") {
2286            return git_config_value(value.trim()).map(Some);
2287        }
2288    }
2289    Ok(None)
2290}
2291
2292fn common_repo_for_worktree(repo: &SleyRepository) -> GitResult<SleyRepository> {
2293    let common_dir_file = repo.git_dir().join("commondir");
2294    let Ok(contents) = fs::read_to_string(&common_dir_file) else {
2295        return Ok(repo.clone());
2296    };
2297    let target = contents.trim();
2298    if target.is_empty() {
2299        return Ok(repo.clone());
2300    }
2301    let common_dir = {
2302        let path = Path::new(target);
2303        if path.is_absolute() {
2304            path.to_path_buf()
2305        } else {
2306            repo.git_dir().join(path)
2307        }
2308    };
2309    open_repo(&common_dir)
2310}
2311
2312pub(crate) fn git_err(err: impl std::fmt::Display) -> GitBridgeError {
2313    GitBridgeError::Git(err.to_string())
2314}
2315
2316fn restore_file(path: PathBuf, previous: Option<&[u8]>) -> GitResult<()> {
2317    if let Some(previous) = previous {
2318        fs::write(path, previous)?;
2319    } else if path.exists() {
2320        fs::remove_file(path)?;
2321    }
2322    Ok(())
2323}
2324
2325/// `fsync` a single file by opening it read-only and calling
2326/// `sync_all`. Best-effort: missing files are not an error (a Drop
2327/// guard might have removed them between write and fsync).
2328fn fsync_path(path: &Path) -> GitResult<()> {
2329    match std::fs::File::open(path) {
2330        Ok(file) => {
2331            file.sync_all()?;
2332            Ok(())
2333        }
2334        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2335        Err(err) => Err(GitBridgeError::Io(err)),
2336    }
2337}
2338
2339/// RAII guard for `init_mirror`. When the mirror directory did not
2340/// exist at acquisition time, an early Drop (panic, error return)
2341/// removes the partially-initialized `.heddle/git/` so a future
2342/// `heddle bridge ...` doesn't see a half-built bare repo. Call
2343/// `commit()` once the mirror is known-good (e.g. after a successful
2344/// first export) to disarm the guard.
2345pub(crate) struct MirrorInitGuard {
2346    path: PathBuf,
2347    /// `Some(true)` means we created the directory in this call and
2348    /// own its rollback; `Some(false)` (or `None` after commit) means
2349    /// hands off.
2350    rollback: Option<bool>,
2351}
2352
2353impl MirrorInitGuard {
2354    pub(crate) fn new_from_init(path: PathBuf, did_create: bool) -> Self {
2355        Self {
2356            path,
2357            rollback: Some(did_create),
2358        }
2359    }
2360
2361    pub(crate) fn commit(mut self) {
2362        self.rollback = None;
2363    }
2364}
2365
2366impl Drop for MirrorInitGuard {
2367    fn drop(&mut self) {
2368        if matches!(self.rollback, Some(true))
2369            && self.path.exists()
2370            && let Err(err) = std::fs::remove_dir_all(&self.path)
2371        {
2372            tracing::warn!(
2373                path = %self.path.display(),
2374                error = %err,
2375                "failed to roll back partial bridge mirror; manual cleanup may be required"
2376            );
2377        }
2378    }
2379}
2380
2381/// Bridge policy: a thread is considered an "unclaimed bootstrap" when it
2382/// points at an empty-tree state with no parents. That is the exact shape of
2383/// the state produced by `Repository::seed_default_thread`, and it cannot
2384/// occur through normal user work — any snapshot advances the tip to a state
2385/// with either a non-empty tree or a non-empty parents list.
2386///
2387/// When a user runs `heddle init` followed by `heddle bridge pull` (or
2388/// `import`), the bootstrap `main` is unclaimed and the incoming git ref
2389/// should win. This helper lets the bridge recognize that case without
2390/// silently overwriting real work.
2391pub(crate) fn thread_is_unclaimed_bootstrap(
2392    heddle_repo: &HeddleRepository,
2393    change_id: &ChangeId,
2394) -> GitResult<bool> {
2395    let Some(state) = heddle_repo.store().get_state(change_id)? else {
2396        return Ok(false);
2397    };
2398    if !state.parents.is_empty() {
2399        return Ok(false);
2400    }
2401    let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
2402        return Ok(false);
2403    };
2404    Ok(tree == Tree::new())
2405}
2406
2407pub(crate) fn open_repo(path: &Path) -> GitResult<SleyRepository> {
2408    match SleyRepository::discover(path) {
2409        Ok(repo) => Ok(repo),
2410        Err(_) => SleyRepository::open(path).map_err(git_err),
2411    }
2412}
2413
2414/// Delete a reference if present; missing-ref is a no-op. Used by the
2415/// write-through rollback path to drop a branch that was created by a
2416/// failed write-through but isn't reachable from any prior state. We
2417/// scope the deletion with `RefPrecondition::MustExist` so an unrelated
2418/// concurrent writer that *just* updated this ref isn't silently
2419/// clobbered — if the ref vanished underneath us between our read and
2420/// the delete, that's the rollback we wanted anyway.
2421pub(crate) fn delete_reference_if_present(repo: &SleyRepository, name: &str) -> GitResult<()> {
2422    delete_reference(repo, name, None, true)
2423}
2424
2425fn delete_reference_matching(
2426    repo: &SleyRepository,
2427    name: &str,
2428    expected_old: ObjectId,
2429) -> GitResult<()> {
2430    delete_reference(repo, name, Some(expected_old), false)
2431}
2432
2433fn delete_reference(
2434    repo: &SleyRepository,
2435    name: &str,
2436    expected_old: Option<ObjectId>,
2437    missing_ok: bool,
2438) -> GitResult<()> {
2439    let refs = repo.references();
2440    match refs.read_ref(name).map_err(git_err)? {
2441        None if missing_ok => Ok(()),
2442        None => Err(GitBridgeError::Git(format!(
2443            "failed to delete Git reference '{name}': ref is missing"
2444        ))),
2445        Some(ReferenceTarget::Direct(oid)) => repo
2446            .delete_ref(DeleteRef {
2447                name: FullName::new(name).map_err(git_err)?,
2448                expected_old: Some(expected_old.unwrap_or(oid)),
2449                expected: None,
2450                reflog: None,
2451                reflog_committer: None,
2452            })
2453            .map_err(git_err),
2454        Some(ReferenceTarget::Symbolic(_)) => {
2455            if let Some(expected_old) = expected_old {
2456                let current = repo
2457                    .find_reference(name)
2458                    .map_err(git_err)?
2459                    .and_then(|reference| reference.peeled_oid(repo).ok().flatten());
2460                if current != Some(expected_old) {
2461                    return Err(GitBridgeError::Git(format!(
2462                        "failed to delete Git reference '{name}': expected {expected_old}, found {}",
2463                        current
2464                            .map(|oid| oid.to_string())
2465                            .unwrap_or_else(|| "missing".to_string())
2466                    )));
2467                }
2468            }
2469            refs.delete_symbolic_ref(name).map(|_| ()).map_err(git_err)
2470        }
2471    }
2472}
2473
2474pub(crate) fn set_reference(
2475    repo: &SleyRepository,
2476    name: &str,
2477    target: ObjectId,
2478    constraint: RefPrecondition,
2479    log_message: &str,
2480) -> GitResult<()> {
2481    let refs = repo.references();
2482    let old_oid = match refs.read_ref(name).map_err(git_err)? {
2483        Some(ReferenceTarget::Direct(oid)) => oid,
2484        _ => ObjectId::null(repo.object_format()),
2485    };
2486    let reflog = sley::plumbing::sley_refs::ReflogEntry {
2487        old_oid,
2488        new_oid: target,
2489        committer: bridge_signature(),
2490        message: log_message.as_bytes().to_vec(),
2491    };
2492    let mut tx = refs.transaction();
2493    tx.update_to(
2494        name.to_string(),
2495        ReferenceTarget::Direct(target),
2496        constraint,
2497        Some(reflog),
2498    );
2499    tx.commit().map_err(git_err)?;
2500    Ok(())
2501}
2502
2503/// Whether two index paths file↔directory-PREFIX-conflict: one names a
2504/// blob that is a directory prefix of the other (`foo` vs `foo/bar`, in
2505/// either order). Git's index cannot hold both, since a path is either a
2506/// file or a directory. Equal paths do NOT count here — that case is an
2507/// exact match handled separately by the caller.
2508fn path_prefix_conflict(a: &str, b: &str) -> bool {
2509    let child_of = |parent: &str, child: &str| {
2510        child
2511            .strip_prefix(parent)
2512            .is_some_and(|rest| rest.starts_with('/'))
2513    };
2514    child_of(a, b) || child_of(b, a)
2515}
2516
2517/// Recursively collect every Git-indexable leaf path in `tree`,
2518/// resolving subtrees through `store`. Missing subtree objects are
2519/// skipped rather than treated as errors, matching the repo's other
2520/// tree walks. Paths use `/` separators, the form Git's index expects.
2521fn collect_capture_paths<S: ObjectStore + ?Sized>(
2522    store: &S,
2523    tree: &Tree,
2524    prefix: &str,
2525    out: &mut Vec<(String, FileMode)>,
2526) -> GitResult<()> {
2527    for entry in tree.iter() {
2528        let path = if prefix.is_empty() {
2529            entry.name().to_string()
2530        } else {
2531            format!("{prefix}/{}", entry.name())
2532        };
2533        if entry.is_tree() {
2534            if let Some(hash) = entry.tree_hash()
2535                && let Some(subtree) = store.get_tree(&hash)?
2536            {
2537                collect_capture_paths(store, &subtree, &path, out)?;
2538            }
2539        } else {
2540            out.push((path, entry.mode()));
2541        }
2542    }
2543    Ok(())
2544}
2545
2546fn update_checkout_head_ref(
2547    repo: &SleyRepository,
2548    target: ObjectId,
2549    previous_branch: Option<ObjectId>,
2550    log_message: &str,
2551) -> GitResult<()> {
2552    let expected = previous_branch.map_or(RefPrecondition::MustNotExist, |oid| {
2553        RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(oid))
2554    });
2555    let ref_name = repo
2556        .head()
2557        .ok()
2558        .and_then(|head| head.symbolic_target.map(|name| name.to_string()))
2559        .unwrap_or_else(|| "HEAD".to_string());
2560    let old_oid = previous_branch.unwrap_or_else(|| ObjectId::null(repo.object_format()));
2561    let head_reflog = sley::plumbing::sley_refs::ReflogEntry {
2562        old_oid,
2563        new_oid: target,
2564        committer: bridge_signature(),
2565        message: log_message.as_bytes().to_vec(),
2566    };
2567    set_reference(repo, &ref_name, target, expected, log_message)?;
2568    if ref_name != "HEAD" {
2569        repo.references()
2570            .append_reflog("HEAD", &head_reflog)
2571            .map_err(git_err)?;
2572    }
2573    Ok(())
2574}
2575
2576fn checkout_git_head_is_detached(root: &Path) -> GitResult<bool> {
2577    let repo = SleyRepository::discover(root).map_err(git_err)?;
2578    Ok(repo.head().map(|head| head.is_detached()).unwrap_or(false))
2579}
2580
2581pub(crate) fn resolve_git_commit_identity(
2582    repo_root: &Path,
2583    fallback: &Principal,
2584) -> GitResult<LocalGitIdentity> {
2585    if !principal_is_default_unknown(fallback) {
2586        return Ok(LocalGitIdentity::from_principal(fallback));
2587    }
2588    if let Some(identity) = git_config_identity_with_global_fallback(repo_root)? {
2589        return Ok(identity);
2590    }
2591
2592    Err(GitBridgeError::Git(
2593        "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(),
2594    ))
2595}
2596
2597pub(crate) fn git_config_identity_with_global_fallback(
2598    repo_root: &Path,
2599) -> GitResult<Option<LocalGitIdentity>> {
2600    let name = git_config_value_with_global_fallback(repo_root, "user.name")?;
2601    let email = git_config_value_with_global_fallback(repo_root, "user.email")?;
2602    if let (Some(name), Some(email)) = (name, email)
2603        && !name.trim().is_empty()
2604        && !email.trim().is_empty()
2605    {
2606        return Ok(Some(LocalGitIdentity { name, email }));
2607    }
2608    Ok(None)
2609}
2610
2611pub(crate) fn principal_is_default_unknown(principal: &Principal) -> bool {
2612    principal.name.trim().is_empty()
2613        || principal.email.trim().is_empty()
2614        || (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
2615}
2616
2617fn git_config_value_with_global_fallback(repo_root: &Path, key: &str) -> GitResult<Option<String>> {
2618    let Ok(repo) = SleyRepository::discover(repo_root) else {
2619        return Ok(None);
2620    };
2621    let Some((section, variable)) = key.split_once('.') else {
2622        return Ok(None);
2623    };
2624    Ok(repo
2625        .config_snapshot()
2626        .map_err(git_err)?
2627        .get(section, None, variable)
2628        .map(str::to_string))
2629}
2630
2631fn git_config_value(value: &str) -> GitResult<String> {
2632    let Some(quoted) = value
2633        .strip_prefix('"')
2634        .and_then(|rest| rest.strip_suffix('"'))
2635    else {
2636        return Ok(value.to_string());
2637    };
2638    let mut out = String::new();
2639    let mut chars = quoted.chars();
2640    while let Some(ch) = chars.next() {
2641        if ch != '\\' {
2642            out.push(ch);
2643            continue;
2644        }
2645        let Some(escaped) = chars.next() else {
2646            return Err(GitBridgeError::Git(
2647                "unterminated escape in repo-local Git config".to_string(),
2648            ));
2649        };
2650        match escaped {
2651            '"' | '\\' => out.push(escaped),
2652            'n' => out.push('\n'),
2653            't' => out.push('\t'),
2654            'b' => out.push('\u{0008}'),
2655            other => out.push(other),
2656        }
2657    }
2658    Ok(out)
2659}
2660
2661fn bridge_signature() -> Vec<u8> {
2662    let seconds = SystemTime::now()
2663        .duration_since(UNIX_EPOCH)
2664        .map(|duration| duration.as_secs() as i64)
2665        .unwrap_or(0);
2666    format!("Heddle <heddle@local> {seconds} +0000").into_bytes()
2667}
2668
2669fn repo_relative_base(repo: &SleyRepository) -> PathBuf {
2670    repo.workdir().unwrap_or_else(|| {
2671        if repo
2672            .git_dir()
2673            .file_name()
2674            .is_some_and(|name| name == ".git")
2675        {
2676            repo.git_dir()
2677                .parent()
2678                .map(Path::to_path_buf)
2679                .unwrap_or_else(|| repo.git_dir().to_path_buf())
2680        } else {
2681            repo.git_dir().to_path_buf()
2682        }
2683    })
2684}
2685
2686fn local_path_from_url(url: &str) -> GitResult<Option<PathBuf>> {
2687    // Defense in depth (push-routing no-op): the git-overlay exporter speaks
2688    // only the local/git network transports. A `heddle://` hosted URL must
2689    // NEVER reach this classifier — the hosted-sync path
2690    // (`GrpcHostedClient`) is the only thing that can push to it. If routing
2691    // upstream is correct this is unreachable; making it a hard error here
2692    // means a `heddle://` slipping into the git exporter can never again be a
2693    // silent success (it would otherwise fall through as a generic network
2694    // URL, "reconcile" locally, and report success without contacting the
2695    // server).
2696    if url.starts_with("heddle://") {
2697        return Err(GitBridgeError::Git(format!(
2698            "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"
2699        )));
2700    }
2701    let Some(raw_path) = url.strip_prefix("file://") else {
2702        return Ok(None);
2703    };
2704    let path = PathBuf::from(raw_path);
2705    if path.as_os_str().is_empty() {
2706        return Err(GitBridgeError::Git(format!(
2707            "remote '{}' has no filesystem path",
2708            url
2709        )));
2710    }
2711    Ok(Some(path))
2712}
2713
2714fn collect_ref_updates(repo: &SleyRepository) -> GitResult<Vec<RefUpdate>> {
2715    let mut updates = Vec::new();
2716
2717    for reference in repo.references().list_refs().map_err(git_err)? {
2718        let ReferenceTarget::Direct(target) = reference.target else {
2719            continue;
2720        };
2721        let ref_name = GitRefName::new(&reference.name);
2722        if let Some(namespace) = ref_name.content_namespace()
2723            && let Some(name) = ref_name.short_name()
2724        {
2725            updates.push(RefUpdate {
2726                name: name.to_string(),
2727                target,
2728                namespace,
2729            });
2730        }
2731    }
2732
2733    Ok(updates)
2734}
2735
2736/// A partition of the commits that land in the destination, computed over
2737/// the SINGLE copied ref set. `total` is every unique commit reachable from
2738/// the copied branch/tag tips; `newly` is the subset minted during this
2739/// export run. `already` is the remainder. Because `newly` is a subset of
2740/// the same walk that produced `total`, `newly + already == total` holds by
2741/// construction — the summary can never report more "newly written" than
2742/// "total", and no orphan/unreferenced state (minted but reachable from no
2743/// copied ref, hence never in the walk) can inflate any count.
2744#[derive(Debug, Default, Clone, Copy)]
2745pub(crate) struct ExportedCommitCounts {
2746    pub total: usize,
2747    pub newly: usize,
2748}
2749
2750/// Count and partition the commits reachable from the branch and tag tips
2751/// that `collect_ref_updates` writes to a destination. Derived from the SAME
2752/// ref set `copy_mirror_to_path` copies, so the reported counts equal what
2753/// actually lands in the destination — including stale mirror refs left
2754/// behind by a dropped Heddle thread (export does not prune them, so the
2755/// commit is still copied and must still be counted; pruning would be a
2756/// separate behavior change). Notes refs are excluded: they carry
2757/// metadata, not history, so they don't count as exported commits.
2758///
2759/// `newly_minted` is the set of git OIDs freshly minted during this export
2760/// run; a commit in the walk that is in this set is counted as `newly`, the
2761/// rest as `already`. Routing both the total and the newly count through
2762/// this single walk guarantees they can never diverge.
2763pub(crate) fn count_exported_commits(
2764    repo: &SleyRepository,
2765    newly_minted: &HashSet<ObjectId>,
2766) -> GitResult<ExportedCommitCounts> {
2767    let tips: Vec<ObjectId> = collect_ref_updates(repo)?
2768        .into_iter()
2769        .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag))
2770        .map(|update| update.target)
2771        .collect();
2772
2773    let mut stack = tips;
2774    let mut seen = HashSet::new();
2775    let mut counts = ExportedCommitCounts::default();
2776    while let Some(oid) = stack.pop() {
2777        if !seen.insert(oid) {
2778            continue;
2779        }
2780        let object = repo.read_object(&oid).map_err(git_err)?;
2781        match object.object_type {
2782            GitObjectType::Commit => {
2783                counts.total += 1;
2784                if newly_minted.contains(&oid) {
2785                    counts.newly += 1;
2786                }
2787                let commit = repo.read_commit(&oid).map_err(git_err)?;
2788                for parent in commit.parents {
2789                    stack.push(parent);
2790                }
2791            }
2792            // An annotated tag dereferences to its target (commit, or a
2793            // blob/tree for the rare blob/tree-pointing tag). Follow it;
2794            // only a Commit at the end increments the count.
2795            GitObjectType::Tag => {
2796                let tag = repo.read_tag(&oid).map_err(git_err)?;
2797                stack.push(tag.object);
2798            }
2799            GitObjectType::Tree | GitObjectType::Blob => {}
2800        }
2801    }
2802    Ok(counts)
2803}
2804
2805fn collect_ref_updates_for_fetch(
2806    repo: &SleyRepository,
2807    scope: GitFetchScope,
2808) -> GitResult<Vec<RefUpdate>> {
2809    let updates = collect_ref_updates(repo)?;
2810    match scope {
2811        GitFetchScope::AllRefs => Ok(updates),
2812        GitFetchScope::BranchesAndNotes => Ok(updates
2813            .into_iter()
2814            .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Note))
2815            .collect()),
2816    }
2817}
2818
2819pub(crate) fn collect_import_source_ref_updates(
2820    repo: &SleyRepository,
2821    refs: &[String],
2822) -> GitResult<Vec<RefUpdate>> {
2823    let updates = collect_ref_updates(repo)?;
2824    if refs.is_empty() {
2825        return Ok(updates);
2826    }
2827
2828    let wanted: HashSet<&str> = refs.iter().map(String::as_str).collect();
2829    Ok(updates
2830        .into_iter()
2831        .filter(|update| matches_import_ref(update, &wanted))
2832        .collect())
2833}
2834
2835fn matches_import_ref(update: &RefUpdate, wanted: &HashSet<&str>) -> bool {
2836    let full = full_ref_name(update);
2837    wanted.contains(update.name.as_str()) || wanted.contains(full.as_str())
2838}
2839
2840fn full_ref_name(update: &RefUpdate) -> String {
2841    GitRefName::content_full_name(update.namespace, &update.name)
2842}
2843
2844#[cfg(test)]
2845pub(crate) fn ensure_commit_update_fast_forward(
2846    repo: &SleyRepository,
2847    name: &str,
2848    old: ObjectId,
2849    new: ObjectId,
2850) -> GitResult<()> {
2851    if old == new || old == ObjectId::null(repo.object_format()) {
2852        return Ok(());
2853    }
2854    match commit_is_descendant_of(repo, new, old) {
2855        Ok(true) => Ok(()),
2856        Ok(false) => Err(GitBridgeError::NonFastForwardRef {
2857            name: name.to_string(),
2858            old,
2859            new,
2860        }),
2861        Err(err) => Err(GitBridgeError::Git(format!(
2862            "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"
2863        ))),
2864    }
2865}
2866
2867fn commit_is_descendant_of(
2868    repo: &SleyRepository,
2869    descendant: ObjectId,
2870    ancestor: ObjectId,
2871) -> GitResult<bool> {
2872    let mut stack = vec![descendant];
2873    let mut seen = HashSet::new();
2874    while let Some(oid) = stack.pop() {
2875        if oid == ancestor {
2876            return Ok(true);
2877        }
2878        if !seen.insert(oid) {
2879            continue;
2880        }
2881        let commit = repo.read_commit(&oid).map_err(git_err)?;
2882        for parent in commit.parents {
2883            stack.push(parent);
2884        }
2885    }
2886    Ok(false)
2887}
2888
2889/// Filename, under a destination repo's git dir, of heddle's record of which
2890/// full ref names it has exported to THAT destination, AND the tip OID heddle
2891/// last published for each. A heddle-owned sidecar (git ignores unknown files in
2892/// the git dir), one `<full ref name> <published tip oid>` pair per line. Lives
2893/// WITH the destination so the delete-set can be scoped to refs heddle actually
2894/// wrote here — never the raw destination namespace (heddle#316 CLASS 2) — and
2895/// so the force decision can prove a rewind is heddle-OWNED, not an out-of-band
2896/// advance, by matching the destination tip against the recorded published tip
2897/// (heddle#316 r12).
2898const HEDDLE_EXPORTED_REFS_FILE: &str = "heddle-exported-refs";
2899
2900/// Directory, under heddle's OWN dir, holding the per-URL-remote exported-refs
2901/// records. A network remote (`git://`, `ssh://`, `https://`) has no local git
2902/// dir heddle can drop a sidecar into, so its record lives here instead — keyed
2903/// by a hash of the remote URL. This is the network sibling of
2904/// [`HEDDLE_EXPORTED_REFS_FILE`]: the SAME delete-set reconciliation, with the
2905/// only difference being WHERE the record is stored (heddle#316 r11).
2906const HEDDLE_NETWORK_EXPORTED_REFS_DIR: &str = "git-network-exported-refs";
2907
2908fn exported_refs_manifest_path(target_repo: &SleyRepository) -> PathBuf {
2909    target_repo.git_dir().join(HEDDLE_EXPORTED_REFS_FILE)
2910}
2911
2912/// On-disk location of the exported-refs record for the network remote at `url`.
2913/// Keyed by a hash of the URL string so an arbitrarily long / non-ASCII URL maps
2914/// to a fixed-length, filesystem-safe filename. Stored under heddle's own dir
2915/// (the remote is not local, so there is no destination git dir to host it).
2916fn network_exported_refs_path(heddle_dir: &Path, url: &str) -> PathBuf {
2917    let key = ContentHash::compute_typed("git-network-exported-refs", url.as_bytes()).to_hex();
2918    heddle_dir
2919        .join(HEDDLE_NETWORK_EXPORTED_REFS_DIR)
2920        .join(format!("{key}.refs"))
2921}
2922
2923/// The full ref names heddle has previously exported to the destination whose
2924/// record lives at `path`, each mapped to the tip OID heddle last published for
2925/// it. `Ok(empty)` when no record exists yet — a first export, OR a destination
2926/// heddle wrote to before this record existed. Returning empty (rather than
2927/// assuming the destination's current heddle-namespace refs were heddle's) is the
2928/// conservative choice: it can never delete a foreign ref — nor force-overwrite a
2929/// destination tip — on the first export after this code lands.
2930fn read_exported_refs_at(path: &Path) -> GitResult<HashMap<String, ObjectId>> {
2931    match fs::read_to_string(path) {
2932        Ok(text) => {
2933            let mut map = HashMap::new();
2934            for line in text.lines() {
2935                let line = line.trim();
2936                if line.is_empty() {
2937                    continue;
2938                }
2939                // `<full ref name> <published tip oid>`. The tip is the OID heddle
2940                // last published for that ref here — the ownership token the force
2941                // decision consults (heddle#316 r12). A pre-r12 legacy record
2942                // stored only the name; parse its tip when present and fall back to
2943                // null otherwise. A null tip can never equal a live `old`, so a
2944                // legacy ref is never force-rewound (the safe direction) while it
2945                // still participates in the delete-set.
2946                let mut parts = line.split_whitespace();
2947                let Some(name) = parts.next() else {
2948                    continue;
2949                };
2950                let tip = parts
2951                    .next()
2952                    .and_then(|token| token.parse::<ObjectId>().ok())
2953                    .unwrap_or_else(|| ObjectId::null(ObjectFormat::Sha1));
2954                map.insert(name.to_string(), tip);
2955            }
2956            Ok(map)
2957        }
2958        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
2959        Err(e) => Err(GitBridgeError::Io(e)),
2960    }
2961}
2962
2963/// Persist `refs` (full ref name → published tip OID) as heddle's exported-refs
2964/// record at `path`. Atomic temp+rename so a torn write can't surface a
2965/// half-record.
2966fn write_exported_refs_at(path: &Path, refs: &HashMap<String, ObjectId>) -> GitResult<()> {
2967    if let Some(parent) = path.parent() {
2968        fs::create_dir_all(parent)?;
2969    }
2970    let mut sorted: Vec<(&str, &ObjectId)> = refs
2971        .iter()
2972        .map(|(name, tip)| (name.as_str(), tip))
2973        .collect();
2974    sorted.sort_unstable_by(|a, b| a.0.cmp(b.0));
2975    let body = sorted
2976        .iter()
2977        .map(|(name, tip)| format!("{name} {tip}"))
2978        .collect::<Vec<_>>()
2979        .join("\n");
2980    let tmp = path.with_extension("tmp");
2981    fs::write(&tmp, body)?;
2982    fs::rename(&tmp, path)?;
2983    Ok(())
2984}
2985
2986/// Write `HEAD` as a symbolic ref pointing at `branch_ref` (e.g.
2987/// `refs/heads/main`) via sley's ref backend.
2988pub(crate) fn write_head_symref(git_dir: &Path, branch_ref: &str) -> GitResult<()> {
2989    let repo = repo_for_git_dir(git_dir)?;
2990    repo.set_head_symref(branch_ref, HeadUpdateOptions::new())
2991        .map_err(git_err)?;
2992    Ok(())
2993}
2994
2995fn repo_for_git_dir(git_dir: &Path) -> GitResult<SleyRepository> {
2996    if let Ok(repo) = open_repo(git_dir) {
2997        return Ok(repo);
2998    }
2999    if git_dir.file_name().is_some_and(|name| name == ".git")
3000        && let Some(parent) = git_dir.parent()
3001    {
3002        return open_repo(parent);
3003    }
3004    open_repo(git_dir)
3005}
3006
3007/// Heddle's exported-refs record for `target_repo` (full ref name → last-published
3008/// tip OID), the local-path destination record. See [`read_exported_refs_at`].
3009pub(crate) fn read_exported_refs(
3010    target_repo: &SleyRepository,
3011) -> GitResult<HashMap<String, ObjectId>> {
3012    read_exported_refs_at(&exported_refs_manifest_path(target_repo))
3013}
3014
3015/// Persist the local-path destination's exported-refs record. See
3016/// [`write_exported_refs_at`].
3017pub(crate) fn write_exported_refs(
3018    target_repo: &SleyRepository,
3019    refs: &HashMap<String, ObjectId>,
3020) -> GitResult<()> {
3021    write_exported_refs_at(&exported_refs_manifest_path(target_repo), refs)
3022}
3023
3024/// Filename, under the internal MIRROR's git dir, of heddle's record of which
3025/// full ref names it MANAGES in the mirror, each mapped to the tip it last
3026/// published for that ref. The mirror-side analog of [`HEDDLE_EXPORTED_REFS_FILE`]
3027/// (the destination's `heddle-exported-refs`): the mirror reconcile had no
3028/// persisted ownership record, so it reconstructed ownership ad-hoc from OID
3029/// membership — the bug that drove heddle#316 through 7 review rounds. A mirror
3030/// ref is MANAGED iff its full name is a key here, NEVER by OID membership: a
3031/// foreign branch/tag that happens to point at a heddle-minted commit is still
3032/// foreign because heddle never recorded WRITING it under that name. The format,
3033/// atomic-write, and parse contract are shared verbatim with the destination
3034/// record (`read_exported_refs_at`/`write_exported_refs_at`).
3035const HEDDLE_MIRROR_MANAGED_REFS_FILE: &str = "heddle-mirror-managed-refs";
3036
3037/// On-disk path of the mirror's managed-refs record.
3038fn mirror_managed_refs_path(mirror_repo: &SleyRepository) -> PathBuf {
3039    mirror_repo.git_dir().join(HEDDLE_MIRROR_MANAGED_REFS_FILE)
3040}
3041
3042/// Whether the mirror's managed-refs record exists on disk. Used to distinguish
3043/// a genuine FIRST export after this code lands (absent → seed from the current
3044/// mirror ref set so pre-existing heddle refs aren't all misread as foreign)
3045/// from a record that exists but is empty (everything was legitimately dropped —
3046/// do NOT re-seed).
3047pub(crate) fn mirror_managed_refs_recorded(mirror_repo: &SleyRepository) -> bool {
3048    mirror_managed_refs_path(mirror_repo).exists()
3049}
3050
3051/// The full ref names heddle MANAGES in the mirror (full ref name → last-published
3052/// tip OID). `Ok(empty)` when the record is absent — callers seed a first run from
3053/// the current mirror ref set; see [`mirror_managed_refs_recorded`].
3054pub(crate) fn read_mirror_managed_refs(
3055    mirror_repo: &SleyRepository,
3056) -> GitResult<HashMap<String, ObjectId>> {
3057    read_exported_refs_at(&mirror_managed_refs_path(mirror_repo))
3058}
3059
3060/// Persist the mirror's managed-refs record. Atomic temp+rename via
3061/// [`write_exported_refs_at`].
3062pub(crate) fn write_mirror_managed_refs(
3063    mirror_repo: &SleyRepository,
3064    refs: &HashMap<String, ObjectId>,
3065) -> GitResult<()> {
3066    write_exported_refs_at(&mirror_managed_refs_path(mirror_repo), refs)
3067}
3068
3069/// Read the mirror's managed-refs record, SEEDING a genuine first run (no record
3070/// on disk) from the current mirror ref set so the reconcile does not misread
3071/// every pre-existing heddle ref as foreign.
3072///
3073/// This is the #1 first-run risk (heddle#316): an absent record on the first
3074/// export after this code lands must NOT make existing refs look foreign — that
3075/// would silently stop embargo retraction (a now-embargoed thread tip would never
3076/// be rewound/deleted because its branch would be treated as a foreign ref to
3077/// spare). Every ref currently in the mirror was put there by heddle (the mint
3078/// reconcile, `import`, or `fetch`), so claiming them all as managed on the first
3079/// run is correct. A record that EXISTS but is empty (everything was legitimately
3080/// dropped) is NOT re-seeded — only a truly-absent record triggers the seed.
3081pub(crate) fn read_or_seed_mirror_managed_refs(
3082    mirror_repo: &SleyRepository,
3083) -> GitResult<HashMap<String, ObjectId>> {
3084    if mirror_managed_refs_recorded(mirror_repo) {
3085        read_mirror_managed_refs(mirror_repo)
3086    } else {
3087        Ok(collect_ref_updates(mirror_repo)?
3088            .into_iter()
3089            .map(|update| (full_ref_name(&update), update.target))
3090            .collect())
3091    }
3092}
3093
3094/// The mirror refs heddle MANAGES, as [`RefUpdate`]s — [`collect_ref_updates`]
3095/// filtered to the names in the managed-refs `record`, PLUS every `refs/notes/*`
3096/// ref (heddle's metadata namespace, always heddle-managed and content-rebuilt
3097/// rather than target-claimed through the reconcile). The export/push frontier
3098/// MUST source from this rather than the raw [`collect_ref_updates`] so a foreign
3099/// branch/tag heddle never wrote — even one pointing at a heddle-minted commit —
3100/// never enters the served frontier nor the destination's desired set (heddle#316).
3101/// The FETCH path keeps using [`collect_ref_updates`]/[`collect_ref_updates_for_fetch`]
3102/// (it must see every ref); only the export/push frontier is managed-filtered.
3103pub(crate) fn collect_managed_ref_updates(
3104    repo: &SleyRepository,
3105    record: &HashMap<String, ObjectId>,
3106) -> GitResult<Vec<RefUpdate>> {
3107    Ok(collect_ref_updates(repo)?
3108        .into_iter()
3109        .filter(|update| {
3110            matches!(update.namespace, RefNamespace::Note)
3111                || record.contains_key(&full_ref_name(update))
3112        })
3113        .collect())
3114}
3115
3116/// How a destination ref must move from its current `old` tip to the served
3117/// `new` tip. The discriminator that lets EVERY push destination apply the SAME
3118/// served-frontier reconciliation: a deliberate backward rewind (the embargo
3119/// frontier lag) is FORCED past the fast-forward guard, while a true fork is
3120/// still caught by it (heddle#316 r11).
3121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3122enum RefMove {
3123    /// `old == new` (or both absent) — nothing to do.
3124    Unchanged,
3125    /// No resolvable `old` at the destination — a fresh ref.
3126    Create,
3127    /// `new` descends from `old` — an ordinary fast-forward.
3128    FastForward,
3129    /// `old` descends from `new` AND `old` is the tip heddle itself last
3130    /// published here — a deliberate backward rewind heddle OWNS: the served
3131    /// frontier was lagged down to an ancestor because the prior tip (or a
3132    /// descendant of `new`) was embargoed/retracted. MUST be forced through at
3133    /// every destination, exactly as the mirror-side branch rewind forces it.
3134    /// Topology alone does NOT qualify: a destination tip advanced OUT OF BAND
3135    /// past heddle's last-published tip also descends from `new`, but is
3136    /// [`Diverged`](RefMove::Diverged), never force-overwritten (heddle#316 r12).
3137    Rewind,
3138    /// `old` and `new` share no ancestor line (or `old` is unresolvable here) —
3139    /// the divergence the fast-forward guard exists to catch.
3140    Diverged,
3141}
3142
3143/// Classify how a destination ref moves from `old` to `new`, resolving the
3144/// topology in `repo` (the mirror, which holds every served object PLUS any
3145/// previously-exported-now-embargoed object the purge dropped from the mapping
3146/// but not from the object DB). The single place that distinguishes a deliberate
3147/// embargo rewind from a fork, so both push destinations force the former and
3148/// reject the latter identically.
3149///
3150/// `recorded_tip` is the tip heddle last published for this ref at THIS
3151/// destination (from its exported-refs record), or `None` when heddle has no
3152/// record of publishing it here. A backward rewind is FORCED only when heddle
3153/// owns the tip being rewound — `recorded_tip == Some(old)`. Topology alone is
3154/// insufficient: a destination tip advanced OUT OF BAND past heddle's
3155/// last-published tip (then fetched into the mirror) ALSO descends from `new`,
3156/// but heddle never published it, so it is [`RefMove::Diverged`] and must not be
3157/// force-overwritten (heddle#316 r12).
3158fn classify_ref_move(
3159    repo: &SleyRepository,
3160    old: Option<ObjectId>,
3161    new: ObjectId,
3162    recorded_tip: Option<ObjectId>,
3163) -> GitResult<RefMove> {
3164    let Some(old) = old else {
3165        return Ok(RefMove::Create);
3166    };
3167    if old == ObjectId::null(repo.object_format()) {
3168        return Ok(RefMove::Create);
3169    }
3170    if old == new {
3171        return Ok(RefMove::Unchanged);
3172    }
3173    // `new` is the served frontier we just minted/copied, so walking from it is
3174    // always safe. A fast-forward is `new` reaching `old`.
3175    if commit_is_descendant_of(repo, new, old)? {
3176        return Ok(RefMove::FastForward);
3177    }
3178    // A backward rewind is `old` reaching `new`. Forcing it past the FF guard is
3179    // authorized ONLY when heddle OWNS the rewind: `old` is exactly the tip heddle
3180    // itself last published for this ref here (per the exported-refs record). A
3181    // destination tip heddle did NOT publish — an out-of-band descendant the user
3182    // advanced and fetched into the mirror — is never force-overwritten; it falls
3183    // through to `Diverged` (FF-rejected unless the user passes `--force`), so its
3184    // newer commit survives. `old`'s objects survive in the mirror because heddle
3185    // published it (the embargo purge drops the ChangeId→OID mapping, never the
3186    // object); if `old` is NOT resolvable here we cannot prove a rewind anyway.
3187    if recorded_tip == Some(old)
3188        && repo.read_commit(&old).is_ok()
3189        && commit_is_descendant_of(repo, old, new)?
3190    {
3191        return Ok(RefMove::Rewind);
3192    }
3193    Ok(RefMove::Diverged)
3194}
3195
3196/// Whether a destination ref in the served set may be overwritten, and on what
3197/// terms. The verdict EVERY namespace's overwrite funnels through, so ownership
3198/// is decided in exactly one place.
3199///
3200/// The reconcile invariant (heddle#316 r17): ownership — heddle owns the tip it
3201/// overwrites (`recorded == old`, or the move is a safe forward), OR the user
3202/// passes `--force` — gates EVERY namespace's overwrite AND every delete. The
3203/// ONLY per-namespace axis is move-classification: branch/note resolve
3204/// fast-forward-vs-fork topology via [`classify_ref_move`]; a tag's target may be
3205/// an annotated-tag-object OID (not a commit) so it cannot be FF-classified and
3206/// uses the free-move [`classify_tag_move`], which bakes the SAME ownership gate
3207/// in. A new namespace that wires an overwrite without consulting a verdict here
3208/// would skip the gate — the conformance matrix exists to fail that row.
3209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3210enum WriteVerdict {
3211    /// No-op — the served target already matches the destination tip.
3212    Skip,
3213    /// Safe to land unconditionally: a create, a fast-forward, or a heddle-owned
3214    /// overwrite/rewind (the ownership token already proved `recorded == old`).
3215    Write,
3216    /// An out-of-band overwrite heddle does NOT own — error unless `--force`.
3217    RequireForce,
3218}
3219
3220/// Map a branch/note [`RefMove`] onto a [`WriteVerdict`]. `Rewind` is already
3221/// ownership-proven by [`classify_ref_move`] (`recorded == old`), so it is a
3222/// `Write`; only `Diverged` (a fork, or an out-of-band advance heddle never
3223/// published) demands `--force`.
3224fn verdict_from_move(m: RefMove) -> WriteVerdict {
3225    match m {
3226        RefMove::Unchanged => WriteVerdict::Skip,
3227        RefMove::Create | RefMove::FastForward | RefMove::Rewind => WriteVerdict::Write,
3228        RefMove::Diverged => WriteVerdict::RequireForce,
3229    }
3230}
3231
3232/// Classify a TAG overwrite. Tags are free-move (never fast-forward-guarded): a
3233/// tag's `target` can be an annotated-tag-object OID rather than a commit, so it
3234/// cannot be FF-classified — [`classify_ref_move`] would resolve `find_commit`
3235/// on the tag object and error. The ownership gate is applied directly here
3236/// instead: a create or a heddle-owned overwrite (`recorded == old`) lands; an
3237/// out-of-band tag heddle never recorded is spared (`RequireForce`) exactly as an
3238/// out-of-band branch advance is — never silently clobbered (heddle#316 r17).
3239fn classify_tag_move(
3240    old: Option<ObjectId>,
3241    target: ObjectId,
3242    recorded: Option<ObjectId>,
3243) -> WriteVerdict {
3244    match old {
3245        // No tip at the destination — a fresh tag.
3246        None => WriteVerdict::Write,
3247        // Already at the served target — nothing to do.
3248        Some(o) if o == target => WriteVerdict::Skip,
3249        // heddle owns the tip it is overwriting — its published move lands.
3250        Some(o) if recorded == Some(o) => WriteVerdict::Write,
3251        // An out-of-band tag heddle never published — spare it unless `--force`.
3252        Some(_) => WriteVerdict::RequireForce,
3253    }
3254}
3255
3256/// A served ref a push destination must write: its full name, the served `new`
3257/// tip, and whether the receive-pack command must be forced.
3258#[derive(Debug)]
3259pub(crate) struct PlannedRefWrite {
3260    pub(crate) full_name: String,
3261    pub(crate) old: Option<ObjectId>,
3262    pub(crate) new: ObjectId,
3263    pub(crate) force: bool,
3264}
3265
3266/// A previously-exported ref the served mirror no longer carries: it must be
3267/// deleted at the destination.
3268#[derive(Debug)]
3269pub(crate) struct PlannedRefDelete {
3270    pub(crate) full_name: String,
3271    pub(crate) old: ObjectId,
3272}
3273
3274/// The ONE reconciliation plan EVERY push destination applies, so its published
3275/// refs converge to the served frontier by construction.
3276#[derive(Debug)]
3277pub(crate) struct DestinationReconcilePlan {
3278    /// Survivors to write — creations, fast-forwards, and FORCED embargo rewinds.
3279    pub(crate) writes: Vec<PlannedRefWrite>,
3280    /// Previously-exported refs the mirror no longer serves AND that still exist
3281    /// at the destination — to delete. Scoped to heddle-owned refs (never foreign).
3282    pub(crate) deletes: Vec<PlannedRefDelete>,
3283    /// The exported-refs record to persist for this destination after the push:
3284    /// full ref name → the tip heddle just published, plus the previously-recorded
3285    /// tip for any ref left in place — a still-served ref out of this push's scope
3286    /// OR an out-of-band tip whose retraction was skipped (so `--force` can still
3287    /// retract it later). A deleted ref drops out; a foreign ref never enters.
3288    pub(crate) new_manifest: HashMap<String, ObjectId>,
3289}
3290
3291/// The sorted full names of the refs a destination reconcile plan WRITES —
3292/// creations, fast-forwards, and forced embargo rewinds. This is the
3293/// `refs_written` surface `heddle push` reports so a git veteran (or agent)
3294/// can verify the round-trip with `git ls-remote`. Retraction deletes are
3295/// not included. Sorted because the plan's write order derives from hash-map
3296/// iteration and the reported list must be deterministic.
3297pub(crate) fn planned_write_names(plan: &DestinationReconcilePlan) -> Vec<String> {
3298    let mut names: Vec<String> = plan
3299        .writes
3300        .iter()
3301        .map(|write| write.full_name.clone())
3302        .collect();
3303    names.sort_unstable();
3304    names
3305}
3306
3307/// The full ref names a push may MATERIALIZE (create fresh) at a destination — the
3308/// `creatable_names` gate for [`plan_destination_reconcile`]. `None` for an
3309/// all-thread push (every served ref is creatable, so the gate never fires);
3310/// `Some(set)` for a current-thread push (only the attached branch + the notes
3311/// refs). This is the destination analog of the mirror reconcile's materialization
3312/// gate (`git_export::export`'s `existing.is_none() && !in_scope` skip): a scoped
3313/// push reconciles EXISTING out-of-scope refs (the embargo rewind) but never
3314/// publishes a brand-new sibling the caller did not ask to export (heddle#316 r16).
3315fn creatable_ref_names(
3316    served_frontier: &[RefUpdate],
3317    scope: GitPushScope,
3318    current_branch: Option<&str>,
3319) -> Option<HashSet<String>> {
3320    match scope {
3321        GitPushScope::AllThreads => None,
3322        GitPushScope::CurrentThread => {
3323            let branch = current_branch.unwrap_or_default();
3324            Some(
3325                served_frontier
3326                    .iter()
3327                    .filter(|update| {
3328                        (matches!(update.namespace, RefNamespace::Branch) && update.name == branch)
3329                            || matches!(update.namespace, RefNamespace::Note)
3330                    })
3331                    .map(full_ref_name)
3332                    .collect(),
3333            )
3334        }
3335    }
3336}
3337
3338/// Build the served-frontier reconciliation plan shared by the local-path and
3339/// URL/network push destinations (heddle#316 r11/r13/r16). The destination's
3340/// published refs are a PURE PROJECTION of the served frontier, restricted to
3341/// heddle-owned refs: every op — create, fast-forward, forced embargo rewind,
3342/// retraction delete, or skip — is DERIVED from ONE pass over the desired-vs-
3343/// actual diff, and the heddle-OWNERSHIP token (`recorded_tip == old`) gates
3344/// force AND delete UNIFORMLY. There is no separate per-operation enforcement
3345/// branch to forget: a destination tip heddle never published is neither
3346/// force-rewound NOR deleted (it survives) unless the user passes `--force`.
3347///
3348/// INVARIANT (heddle#316 r16): `served_frontier` is the WHOLE-MIRROR served
3349/// frontier — every heddle-managed mirror ref at its CURRENT served target — the
3350/// SAME projection the mirror reconcile (`git_export::export`) materialized into
3351/// the mirror. The destination reconcile and the mirror reconcile are therefore
3352/// driven by ONE source of truth, so destination and mirror cannot diverge for
3353/// ANY embargo transition, in-scope OR out-of-scope: an out-of-scope ref the
3354/// mirror rewound for embargo is present here at its NEW (rewound) target, and
3355/// [`classify_ref_move`] emits the rewind to the destination by construction.
3356/// There is NO "served but out of this push's scope, leave it untouched" arm — a
3357/// scoped push reconciles the destination against the whole served frontier, not
3358/// a scope-filtered subset that could keep serving a ref the mirror already
3359/// rewound (the cross-thread-embargo destination leak this round closes).
3360///
3361/// The ONE thing scope still gates is MATERIALIZATION — exactly as the mirror
3362/// reconcile does (`git_export::export`'s `existing.is_none() && !in_scope`
3363/// skip): a scoped push REWINDS/RETRACTS an EXISTING out-of-scope ref (the embargo
3364/// fix) but must not publish a brand-new sibling the caller did not ask to export.
3365/// `creatable_names` carries that gate: a ref ABSENT from the destination whose
3366/// name is NOT creatable is skipped (never created); one that already EXISTS is
3367/// always reconciled, so no target change is ever masked.
3368///
3369/// * `mirror_repo` — resolves the rewind-vs-fork topology (see
3370///   [`classify_ref_move`]).
3371/// * `served_frontier` — the WHOLE-MIRROR served frontier: every heddle-owned
3372///   ref that should exist at the destination, at its served target. A
3373///   previously-exported ref ABSENT from this set is one the mirror no longer
3374///   serves AT ALL (a retraction), never merely out of a push's scope.
3375/// * `creatable_names` — the full ref names this push may MATERIALIZE fresh:
3376///   `None` for an all-thread push (every served ref is creatable); `Some(set)`
3377///   for a current-thread push (only the attached branch + notes). Gates ONLY
3378///   first-time creation of an absent ref; an existing ref is always reconciled.
3379/// * `old_at_destination` — the destination's current ref tips (full name → oid).
3380/// * `previously_exported` — heddle's record of what it exported to THIS
3381///   destination (full ref name → last-published tip OID): the foreign-ref
3382///   scoping AND the single ownership token for both delete and force.
3383/// * `force` — the user's explicit `--force`: additionally forces a true fork
3384///   AND authorizes retracting an out-of-band destination tip.
3385pub(crate) fn plan_destination_reconcile(
3386    mirror_repo: &SleyRepository,
3387    served_frontier: &[RefUpdate],
3388    creatable_names: Option<&HashSet<String>>,
3389    old_at_destination: &HashMap<String, ObjectId>,
3390    previously_exported: &HashMap<String, ObjectId>,
3391    force: bool,
3392) -> GitResult<DestinationReconcilePlan> {
3393    // The DESIRED ref-set indexed by full name → its `RefUpdate` (served target +
3394    // namespace). A name is in `desired` iff the WHOLE-MIRROR served frontier
3395    // wants it published now — there is no scope-filtered subset (heddle#316 r16),
3396    // so an out-of-scope ref the mirror rewound for embargo is here at its NEW
3397    // target rather than silently kept at its old (embargoed) tip.
3398    let desired: HashMap<String, &RefUpdate> = served_frontier
3399        .iter()
3400        .map(|u| (full_ref_name(u), u))
3401        .collect();
3402
3403    // ONE pass over the union of (desired ∪ previously-exported) names — the
3404    // complete desired-vs-actual diff. For each ref the op is derived from the
3405    // same three inputs: `desired` (does the served frontier want it, at what
3406    // target), `old` (the destination's current tip, out-of-band-aware), and
3407    // `recorded` (the tip heddle last published here = the OWNERSHIP token). The
3408    // ownership token gates force AND delete identically (heddle#316 r13).
3409    let mut names: BTreeSet<String> = desired.keys().cloned().collect();
3410    names.extend(previously_exported.keys().cloned());
3411
3412    let mut writes = Vec::new();
3413    let mut deletes = Vec::new();
3414    let mut new_manifest: HashMap<String, ObjectId> = HashMap::new();
3415
3416    for full in names {
3417        let old = old_at_destination.get(&full).copied();
3418        let recorded = previously_exported.get(&full).copied();
3419
3420        if let Some(update) = desired.get(&full).copied() {
3421            // MATERIALIZATION gate (the mirror reconcile's `existing.is_none() &&
3422            // !in_scope` skip, applied to the destination): an out-of-scope ref
3423            // ABSENT from the destination must not be CREATED by a scoped push —
3424            // that would publish a brand-new sibling the caller did not ask to
3425            // export. An EXISTING out-of-scope ref falls through and is reconciled
3426            // (rewind/retract), so the embargo fix is untouched; only first-time
3427            // creation is suppressed. Preserve any ownership token so a later
3428            // all-thread push can still materialize it (heddle#316 r14/r16).
3429            if old.is_none() && creatable_names.is_some_and(|names| !names.contains(&full)) {
3430                if let Some(recorded) = recorded {
3431                    new_manifest.insert(full, recorded);
3432                }
3433                continue;
3434            }
3435            // In the desired set: land it at the served target. A ref this push
3436            // publishes is heddle-owned at its new target — record it. The
3437            // overwrite funnels through ONE ownership gate ([`WriteVerdict`]): the
3438            // only per-namespace axis is move-classification — branch/note resolve
3439            // fast-forward-vs-fork topology, a tag is free-move (its target may be
3440            // an annotated-tag-object OID, not a commit) with the SAME ownership
3441            // gate baked into [`classify_tag_move`]. An out-of-band destination tip
3442            // heddle never recorded is spared at EVERY namespace unless `--force`.
3443            let (verdict, force_write) = match update.namespace {
3444                RefNamespace::Branch | RefNamespace::Note => {
3445                    let movement = classify_ref_move(mirror_repo, old, update.target, recorded)?;
3446                    (
3447                        verdict_from_move(movement),
3448                        matches!(movement, RefMove::Rewind),
3449                    )
3450                }
3451                RefNamespace::Tag => {
3452                    let verdict = classify_tag_move(old, update.target, recorded);
3453                    (
3454                        verdict,
3455                        old.is_some_and(|old| old != update.target)
3456                            && matches!(verdict, WriteVerdict::Write),
3457                    )
3458                }
3459            };
3460            let proceed = match verdict {
3461                WriteVerdict::Skip => false,
3462                WriteVerdict::Write => true,
3463                WriteVerdict::RequireForce => {
3464                    if force {
3465                        true
3466                    } else {
3467                        return Err(GitBridgeError::NonFastForwardRef {
3468                            name: full.clone(),
3469                            old: old.unwrap_or_else(|| ObjectId::null(mirror_repo.object_format())),
3470                            new: update.target,
3471                        });
3472                    }
3473                }
3474            };
3475            if proceed {
3476                writes.push(PlannedRefWrite {
3477                    full_name: full.clone(),
3478                    old,
3479                    new: update.target,
3480                    force: force_write || matches!(verdict, WriteVerdict::RequireForce),
3481                });
3482            }
3483            // CLAIM ownership in the record ONLY for a ref heddle actually writes
3484            // this push, or one it already owned (had a record for). A pre-existing
3485            // destination ref already AT the served target that heddle never recorded
3486            // (verdict Skip, `recorded` None) is FOREIGN — recording it would let a
3487            // later export DELETE/rewind a ref heddle never created (heddle#316
3488            // destination foreign-ref over-claim). Spare it: leave it out of the
3489            // manifest so it stays unowned.
3490            if proceed || recorded.is_some() {
3491                new_manifest.insert(full, update.target);
3492            }
3493            continue;
3494        }
3495
3496        // Absent from the WHOLE-MIRROR served frontier ⇒ genuinely retracted: the
3497        // served mirror no longer carries this previously-exported ref at all (NOT
3498        // merely out of a push's scope — there is no scope subset here). Delete it,
3499        // but ONLY through the SAME ownership gate the forced
3500        // rewind uses: heddle owns the destination's current tip (`recorded ==
3501        // old`), or the user forces. An out-of-band advance heddle never published
3502        // is spared (it survives) and KEEPS its ownership token, so a later
3503        // `--force` can still retract it (heddle#316 r13).
3504        match old {
3505            Some(old) if recorded == Some(old) || force => {
3506                deletes.push(PlannedRefDelete {
3507                    full_name: full,
3508                    old,
3509                });
3510                // Deleted ⇒ no longer owned ⇒ drops from the record.
3511            }
3512            Some(_) => {
3513                // Out-of-band tip heddle never published — skip the delete; retain
3514                // ownership so `--force` remains the explicit escape hatch.
3515                if let Some(recorded) = recorded {
3516                    new_manifest.insert(full, recorded);
3517                }
3518            }
3519            None => {
3520                // Already absent at the destination — no op; drops from the record.
3521            }
3522        }
3523    }
3524
3525    Ok(DestinationReconcilePlan {
3526        writes,
3527        deletes,
3528        new_manifest,
3529    })
3530}
3531
3532/// The destination's current ref tips (full name → oid) across the namespaces
3533/// heddle manages (heads, tags, notes) — the `old_at_destination` input to
3534/// [`plan_destination_reconcile`] for a local-path destination.
3535fn read_destination_ref_map(repo: &SleyRepository) -> GitResult<HashMap<String, ObjectId>> {
3536    Ok(collect_ref_updates(repo)?
3537        .iter()
3538        .map(|update| (full_ref_name(update), update.target))
3539        .collect())
3540}
3541
3542pub(crate) fn apply_ref_updates(
3543    repo: &SleyRepository,
3544    updates: &[RefUpdate],
3545    log_message: &str,
3546) -> GitResult<()> {
3547    for update in updates {
3548        let full_name = full_ref_name(update);
3549        set_reference(
3550            repo,
3551            &full_name,
3552            update.target,
3553            RefPrecondition::Any,
3554            log_message,
3555        )?;
3556    }
3557    Ok(())
3558}
3559
3560fn apply_remote_tracking_ref_updates(
3561    repo: &SleyRepository,
3562    remote_name: &str,
3563    updates: &[RefUpdate],
3564    log_message: &str,
3565) -> GitResult<()> {
3566    reject_reserved_git_remote_name(remote_name)?;
3567    for update in updates
3568        .iter()
3569        .filter(|update| update.namespace == RefNamespace::Branch)
3570    {
3571        set_reference(
3572            repo,
3573            &format!("refs/remotes/{remote_name}/{}", update.name),
3574            update.target,
3575            RefPrecondition::Any,
3576            log_message,
3577        )?;
3578    }
3579    Ok(())
3580}
3581
3582/// Copy a local Git repository into a bare repository without invoking Git
3583/// transport helpers. This is the local-path clone fast path used by the OSS
3584/// Git-overlay workflow when the user does not have `git` installed.
3585pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitResult<()> {
3586    fs::create_dir_all(dest)?;
3587    let source = open_repo(source_path)?;
3588    let target = match SleyRepository::open(dest) {
3589        Ok(repo) => repo,
3590        Err(_) => SleyRepository::init_bare(dest).map_err(git_err)?,
3591    };
3592    let updates = collect_ref_updates(&source)?;
3593    copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
3594    apply_ref_updates(
3595        &target,
3596        &updates,
3597        &format!("heddle: clone from {}", source_path.display()),
3598    )?;
3599
3600    // Mirror the source repo's HEAD: if the source is on `master` (or
3601    // `develop`, or anything non-`main`) but happens to also have a
3602    // `main` branch, the previous logic silently moved the user to
3603    // `main` on clone. Read the source's symbolic HEAD target and
3604    // honour it whenever it points at a branch we actually copied.
3605    // Fall back to `main` (then any first branch) only when the source
3606    // HEAD is detached or points at a branch we did not import.
3607    let copied_branches: HashSet<&str> = updates
3608        .iter()
3609        .filter(|update| update.namespace == RefNamespace::Branch)
3610        .map(|update| update.name.as_str())
3611        .collect();
3612    let source_head_branch = source
3613        .head()
3614        .ok()
3615        .and_then(|head| head.branch_name().map(str::to_owned))
3616        .filter(|branch| copied_branches.contains(branch.as_str()));
3617    if let Some(branch) = source_head_branch {
3618        write_head_symref(dest, &format!("refs/heads/{branch}"))?;
3619    } else if copied_branches.contains("main") {
3620        write_head_symref(dest, "refs/heads/main")?;
3621    } else if let Some(first_branch) = updates
3622        .iter()
3623        .find(|update| update.namespace == RefNamespace::Branch)
3624    {
3625        write_head_symref(dest, &format!("refs/heads/{}", first_branch.name))?;
3626    }
3627    Ok(())
3628}
3629
3630/// Clone a remote git URL into `dest` as a bare repository, fetching all
3631/// branches and tags. Mirrors the sley remote fetch path used by
3632/// `fetch_network_remote` but starts from an empty `init_bare` rather than an
3633/// existing repo.
3634///
3635/// Used by `bridge import --path <URL>` (Phase F): we clone into a
3636/// scratch directory under the heddle repo's `.heddle/tmp/` and feed the
3637/// resulting bare repo into the normal import path. Also used by `clone`
3638/// for Git-overlay URLs, where `depth` carries through to a shallow clone.
3639///
3640/// * `depth` — if `Some(n)` with `n >= 1`, a shallow clone with that
3641///   many commits per ref for network transports (transport-v2
3642///   `deepen <n>` capability). `file://` URLs use the native local-copy
3643///   path so they do not spawn Git upload-pack helpers; shallow local
3644///   copies are rejected until Heddle has native shallow-object pruning.
3645/// * `filter` — currently rejected. Heddle's Git-overlay runtime is
3646///   intentionally Git-compatible but not Git-binary-dependent, and the
3647///   native transport path does not yet expose partial-clone filtering.
3648pub fn clone_url_to_bare(
3649    url: &str,
3650    dest: &Path,
3651    depth: Option<u32>,
3652    filter: Option<&str>,
3653) -> GitResult<()> {
3654    // Public Git-overlay workflows must run on machines with no Git executable
3655    // installed. Keep depth-only clones native and reject filtered clones until
3656    // the importer can tolerate missing objects.
3657    if let Some(spec) = filter {
3658        return Err(GitBridgeError::Git(format!(
3659            "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"
3660        )));
3661    }
3662    if let Some(source_path) = local_path_from_url(url)? {
3663        if depth.is_some() {
3664            return Err(GitBridgeError::Git(
3665                "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"
3666                    .to_string(),
3667            ));
3668        }
3669        return copy_local_repo_to_bare(&source_path, dest);
3670    }
3671    let default_branch =
3672        clone_url_to_bare_via_sley(url, dest, depth)?.or_else(|| default_branch_from_file_url(url));
3673    // `init_bare` writes `.git/HEAD = ref: refs/heads/<init.defaultBranch>`
3674    // (typically "main" or "master") regardless of what the remote
3675    // advertises, and the fetch above doesn't touch HEAD. If we leave
3676    // that in place, downstream `select_clone_thread` and
3677    // `detect_git_head` will steer the user to a branch the remote may
3678    // not even have — observed: cloning ripgrep landed users on
3679    // `ag/bstr-migration` (alphabetically first imported thread) when
3680    // the remote's actual default is `master`. Honour the remote's
3681    // `HEAD` symref when we can resolve it.
3682    if let Some(branch) = default_branch
3683        && bare_branch_exists(dest, &branch)?
3684    {
3685        write_head_symref(dest, &format!("refs/heads/{branch}"))?;
3686    }
3687    Ok(())
3688}
3689
3690fn default_branch_from_file_url(url: &str) -> Option<String> {
3691    let source_path = local_path_from_url(url).ok().flatten()?;
3692    let repo = open_repo(&source_path).ok()?;
3693    let head = repo.head_state().ok()?;
3694    let branch = head.branch_name()?;
3695    (!branch.is_empty()).then(|| branch.to_string())
3696}
3697
3698fn bare_branch_exists(repo_path: &Path, branch: &str) -> GitResult<bool> {
3699    let repo = open_repo(repo_path)?;
3700    Ok(repo
3701        .find_reference(&format!("refs/heads/{branch}"))
3702        .map_err(git_err)?
3703        .is_some())
3704}
3705
3706fn clone_url_to_bare_via_sley(
3707    url: &str,
3708    dest: &Path,
3709    depth: Option<u32>,
3710) -> GitResult<Option<String>> {
3711    fs::create_dir_all(dest)?;
3712    let repo = SleyRepository::init_bare(dest).map_err(git_err)?;
3713    let mut credentials = NoCredentials;
3714    let mut progress = SilentProgress;
3715    let outcome = repo
3716        .fetch(
3717            url,
3718            &heddle_mirror_fetch_refspecs()?,
3719            FetchOptions {
3720                quiet: true,
3721                auto_follow_tags: true,
3722                fetch_all_tags: true,
3723                prune: false,
3724                dry_run: false,
3725                append: false,
3726                write_fetch_head: true,
3727                force: false,
3728                tag_option_explicit: true,
3729                prune_option_explicit: true,
3730                prune_tags: false,
3731                prune_tags_option_explicit: false,
3732                refmap: None,
3733                refetch: false,
3734                record_promisor_refs: false,
3735                update_head_ok: false,
3736                ssh_options: None,
3737                atomic: false,
3738                depth,
3739                merge_srcs: Vec::new(),
3740                filter: None,
3741                cloning: true,
3742                update_shallow: false,
3743                deepen_relative: false,
3744                deepen_since: None,
3745                deepen_not: Vec::new(),
3746            },
3747            &mut credentials,
3748            &mut progress,
3749        )
3750        .map_err(|err| GitBridgeError::Git(format!("clone failed for {url}: {err}")))?;
3751    Ok(outcome
3752        .head_symref
3753        .and_then(|target| target.strip_prefix("refs/heads/").map(str::to_string)))
3754}
3755
3756/// Materialize the checkout `.git` object closure for the commit mapped to
3757/// `tip_state_id` (`tip_oid`) — reconstructing every byte-faithful commit from
3758/// heddle state, and copying only the lossy residual from the eager `.heddle/git`
3759/// mirror (#568 P1).
3760///
3761/// Walks the heddle state DAG from `tip_state_id`. For each visited state:
3762///   * its mapped git OID is already in `excluded` (the prior checkout HEAD's full
3763///     closure, already on disk) ⇒ skip it AND its ancestors — that subgraph is
3764///     present;
3765///   * [`commit_is_byte_faithful`] ⇒ reconstruct the commit object (and, via
3766///     [`reconstruct_commit_bytes`]'s [`export_tree`], its whole tree/blob closure)
3767///     directly into `object_repo`, then recurse into its parents;
3768///   * otherwise (lossy: `--lossy` import or non-UTF8 identity — the residual the
3769///     mirror exclusively holds) ⇒ copy that commit's full reachable closure from
3770///     `mirror_repo` and DO NOT recurse (the copy already brought its ancestry).
3771///
3772/// CRITICAL safety gate: every reconstructed commit's git OID MUST equal the
3773/// mapped `git_oid`. A mismatch means reconstruction diverged from the imported
3774/// bytes (an unmodeled fidelity gap), which would silently materialize a
3775/// wrong-OID checkout — so this HARD-ERRORS instead. This assertion is what lets
3776/// the reconstruction path be trusted as a mirror replacement.
3777///
3778/// Output is byte-identical to the prior `copy_reachable_objects_excluding(mirror
3779/// → checkout)`: git objects are content-addressed, so a faithful reconstruction
3780/// lands the exact same OID the mirror copy would have, and the lossy path copies
3781/// verbatim. The exclude set keeps it O(objects new since the parent).
3782#[allow(clippy::too_many_arguments)]
3783pub(crate) fn materialize_checkout_closure_from_state(
3784    heddle_repo: &HeddleRepository,
3785    mapping: &SyncMapping,
3786    mirror_repo: &SleyRepository,
3787    object_repo: &SleyRepository,
3788    tip_state_id: &ChangeId,
3789    tip_oid: ObjectId,
3790    excluded: &HashSet<ObjectId>,
3791) -> GitResult<()> {
3792    // Lossy commits whose closure is copied verbatim from the mirror. Their roots
3793    // are batched and copied once at the end (a single excluding pack install,
3794    // matching the prior single-copy perf shape) rather than per-commit.
3795    let mut lossy_roots: Vec<ObjectId> = Vec::new();
3796    let mut stack: Vec<ChangeId> = vec![*tip_state_id];
3797    let mut seen: HashSet<ChangeId> = HashSet::new();
3798
3799    while let Some(state_id) = stack.pop() {
3800        if !seen.insert(state_id) {
3801            continue;
3802        }
3803        let Some(git_oid) = resolve_mapped_git_oid(heddle_repo, mapping, &state_id, object_repo)?
3804        else {
3805            // No mapping for this state: it was never exported (e.g. an embargoed
3806            // ancestor withheld from the served frontier). The tip itself always
3807            // resolves (`tip_oid`), and a withheld ancestor's git object is, by
3808            // construction, absent from both store-reconstruction and the served
3809            // mirror — so there is nothing to materialize. Skip without recursing.
3810            continue;
3811        };
3812
3813        // Already on disk (this state's object is in the parent's excluded closure,
3814        // or a sibling branch already materialized it): the whole subgraph beneath
3815        // it is present too, so prune here.
3816        if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
3817            continue;
3818        }
3819
3820        let state = heddle_repo
3821            .store()
3822            .get_state(&state_id)?
3823            .ok_or(GitBridgeError::StateNotFound(state_id))?;
3824
3825        if commit_is_byte_faithful(&state) {
3826            let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
3827            // The byte-exact gate (#568 P1): a faithful reconstruction MUST hash to
3828            // the mapped OID. If it does not, refuse — never write a wrong-SHA
3829            // object into the worktree.
3830            let reconstructed = commit_object_id(&content);
3831            if reconstructed != git_oid {
3832                return Err(GitBridgeError::Git(format!(
3833                    "checkout reconstruction OID mismatch for state {state_id}: reconstructed {reconstructed}, expected mapped {git_oid}; \
3834                     refusing to materialize a wrong-OID checkout (unmodeled fidelity gap)"
3835                )));
3836            }
3837            let written = write_commit_object(object_repo, &content)?;
3838            debug_assert_eq!(written, git_oid);
3839            stack.extend(state.parents.iter().copied());
3840        } else {
3841            // Lossy residual: the verbatim bytes live only in the mirror. Copy this
3842            // commit's full closure from there and stop — the copy carries its
3843            // ancestry, so we don't reconstruct (or re-copy) beneath it.
3844            lossy_roots.push(git_oid);
3845        }
3846    }
3847
3848    // Ensure the requested tip is materialized even in the degenerate case where
3849    // the walk skipped it (e.g. an unmapped store state that nonetheless has a
3850    // mirror object): fall back to the mirror copy for it. The faithful path above
3851    // already wrote it when reconstructable, and a redundant root here is pruned
3852    // by the exclude set / idempotent install.
3853    if object_repo.read_object(&tip_oid).is_err() && !lossy_roots.contains(&tip_oid) {
3854        lossy_roots.push(tip_oid);
3855    }
3856
3857    if !lossy_roots.is_empty() {
3858        copy_reachable_objects_excluding(mirror_repo, object_repo, lossy_roots, excluded)?;
3859    }
3860
3861    Ok(())
3862}
3863
3864/// Resolve the git OID a heddle state maps to, preferring the in-memory bridge
3865/// mapping and falling back to the git-overlay checkpoint mapping (the same
3866/// resolution the checkout tip uses). Returns `None` when the state has no mapped
3867/// git object at all.
3868fn resolve_mapped_git_oid(
3869    heddle_repo: &HeddleRepository,
3870    mapping: &SyncMapping,
3871    state_id: &ChangeId,
3872    object_repo: &SleyRepository,
3873) -> GitResult<Option<ObjectId>> {
3874    if let Some(git_oid) = mapping.get_git(state_id) {
3875        return Ok(Some(git_oid));
3876    }
3877    if let Some(git_commit) = heddle_repo
3878        .git_overlay_mapped_git_commit_for_change(state_id)
3879        .map_err(|error| GitBridgeError::Git(error.to_string()))?
3880    {
3881        let oid = ObjectId::from_hex(object_repo.object_format(), &git_commit)
3882            .map_err(|error| GitBridgeError::InvalidMapping(error.to_string()))?;
3883        return Ok(Some(oid));
3884    }
3885    Ok(None)
3886}
3887
3888pub(crate) fn copy_reachable_objects(
3889    source: &SleyRepository,
3890    target: &SleyRepository,
3891    roots: impl IntoIterator<Item = ObjectId>,
3892) -> GitResult<()> {
3893    // TODO: Keep local Git-lane reachable transfer behind Sley primitives. If
3894    // this needs pack identity/stream planning, route it through the Sley
3895    // reachable-pack facade gate instead of adding a Heddle-local planner.
3896    let roots = roots.into_iter().collect::<Vec<_>>();
3897    target.copy_reachable_from(source, &roots).map_err(git_err)
3898}
3899
3900/// Incremental variant of [`copy_reachable_objects`]: copy the closure
3901/// reachable from `roots`, skipping every object in `excluded`.
3902///
3903/// INVARIANT: every OID in `excluded` MUST already be present in `target` — the
3904/// walk neither visits nor copies an excluded object (nor anything reachable only
3905/// through it), so excluding an object the target is missing would silently drop
3906/// it. Callers satisfy this by computing `excluded` as the reachable closure of
3907/// something already in `target`. Used by checkpoint write-through, which passes
3908/// the prior checkout HEAD's full closure (already entirely in the checkout's
3909/// object DB): the new commit's tree re-reaches the parent's unchanged
3910/// trees/blobs, so excluding the whole closure — not just the parent commit —
3911/// prunes them all, turning per-checkpoint object transfer from O(total history)
3912/// into O(objects new since the parent). Output is byte-identical — the same
3913/// objects end up in `target`; the pruned ones were already there.
3914pub(crate) fn copy_reachable_objects_excluding(
3915    source: &SleyRepository,
3916    target: &SleyRepository,
3917    roots: impl IntoIterator<Item = ObjectId>,
3918    excluded: &HashSet<ObjectId>,
3919) -> GitResult<()> {
3920    if excluded.is_empty() {
3921        return copy_reachable_objects(source, target, roots);
3922    }
3923    if source.object_format() != target.object_format() {
3924        // Mismatched formats can't share objects; fall back to the plain copy so
3925        // its existing format-mismatch error surfaces unchanged.
3926        return copy_reachable_objects(source, target, roots);
3927    }
3928    // TODO: This local incremental transfer already delegates pack installation
3929    // to Sley. Keep future reachable-pack planning Sley-gated here too; Heddle
3930    // should not grow its own exclusion-aware pack planner.
3931    sley::plumbing::sley_odb::install_reachable_pack_excluding(
3932        source.objects().as_ref(),
3933        target.objects().as_ref(),
3934        target.object_format(),
3935        roots,
3936        excluded,
3937    )
3938    .map_err(|error| GitBridgeError::Git(error.to_string()))?;
3939    // Make the freshly-installed pack visible to subsequent reads on `target`,
3940    // mirroring what `copy_reachable_from` does internally.
3941    target.refresh_objects();
3942    Ok(())
3943}
3944
3945fn fetch_network_remote(
3946    mirror_repo: &SleyRepository,
3947    remote_name: &str,
3948    url: &str,
3949    scope: GitFetchScope,
3950) -> GitResult<()> {
3951    let mut credentials = NoCredentials;
3952    let mut progress = SilentProgress;
3953    mirror_repo
3954        .fetch(
3955            url,
3956            &heddle_mirror_fetch_refspecs()?,
3957            FetchOptions {
3958                quiet: true,
3959                auto_follow_tags: matches!(scope, GitFetchScope::AllRefs),
3960                fetch_all_tags: matches!(scope, GitFetchScope::AllRefs),
3961                prune: false,
3962                dry_run: false,
3963                append: false,
3964                write_fetch_head: true,
3965                force: false,
3966                tag_option_explicit: true,
3967                prune_option_explicit: true,
3968                prune_tags: false,
3969                prune_tags_option_explicit: false,
3970                refmap: None,
3971                refetch: false,
3972                record_promisor_refs: false,
3973                update_head_ok: false,
3974                ssh_options: None,
3975                atomic: false,
3976                depth: None,
3977                merge_srcs: Vec::new(),
3978                filter: None,
3979                cloning: false,
3980                update_shallow: false,
3981                deepen_relative: false,
3982                deepen_since: None,
3983                deepen_not: Vec::new(),
3984            },
3985            &mut credentials,
3986            &mut progress,
3987        )
3988        .map_err(|err| GitBridgeError::Git(format!("failed to fetch from {url}: {err}")))?;
3989    let _ = remote_name;
3990    Ok(())
3991}
3992
3993/// Push the served frontier to a URL/network remote. Returns the sorted
3994/// full names of the refs written on the wire (see [`planned_write_names`]).
3995fn push_network_remote(
3996    mirror_repo: &SleyRepository,
3997    heddle_dir: &Path,
3998    url: &str,
3999    scope: GitPushScope,
4000    current_branch: Option<&str>,
4001    force: bool,
4002) -> GitResult<Vec<String>> {
4003    // The network destination's exported-refs record lives in heddle's own dir,
4004    // keyed by the remote URL (the remote has no local git dir to host the
4005    // sidecar). Read it BEFORE the empty-frontier fast-path: a retraction lands
4006    // here with an EMPTY served set yet a non-empty record, so the delete-set —
4007    // not the served set — is what must still propagate (heddle#316 r11).
4008    let manifest_path = network_exported_refs_path(heddle_dir, url);
4009    let previously_exported = read_exported_refs_at(&manifest_path)?;
4010    // The WHOLE-MIRROR served frontier — the SAME projection the local-path
4011    // destination reconciles against and the mirror reconcile materialized
4012    // (heddle#316 r16). A scoped push reconciles the destination against this
4013    // whole frontier, so an out-of-scope ref the mirror rewound for embargo
4014    // propagates to the wire by construction, never a scope-filtered subset.
4015    //
4016    // Managed-filtered (heddle#316): the same foreign-ref exclusion the
4017    // local-path push applies — a foreign branch/tag heddle never wrote is kept
4018    // off the wire, sourced from the mirror's name-keyed managed-refs record.
4019    let managed_record = read_mirror_managed_refs(mirror_repo)?;
4020    let served_frontier = collect_managed_ref_updates(mirror_repo, &managed_record)?;
4021    if served_frontier.is_empty() && previously_exported.is_empty() {
4022        return Ok(Vec::new());
4023    }
4024
4025    let mut credentials = NoCredentials;
4026    let records = mirror_repo
4027        .ls_remote(
4028            url,
4029            LsRemoteFilter {
4030                heads: false,
4031                tags: false,
4032                refs_only: true,
4033            },
4034            &|_| true,
4035            &mut credentials,
4036        )
4037        .map_err(|err| GitBridgeError::Git(format!("failed to list refs from {url}: {err}")))?;
4038    let remote_refs = records
4039        .into_iter()
4040        .filter(|record| GitRefName::new(&record.name).content_namespace().is_some())
4041        .map(|record| (record.name, record.oid))
4042        .collect::<HashMap<_, _>>();
4043
4044    // The SAME served-frontier plan the local-path destination runs: writes
4045    // (forcing embargo rewinds, rejecting forks), the retraction delete-set
4046    // (scoped to heddle-owned refs — never foreign), and the new record to
4047    // persist — all derived from the whole-mirror `served_frontier` above.
4048    let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4049    let plan = plan_destination_reconcile(
4050        mirror_repo,
4051        &served_frontier,
4052        creatable.as_ref(),
4053        &remote_refs,
4054        &previously_exported,
4055        force,
4056    )?;
4057
4058    if plan.writes.is_empty() && plan.deletes.is_empty() {
4059        // Nothing to move on the wire, but the record may still need to drop a
4060        // ref that was already absent at the remote.
4061        write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4062        return Ok(Vec::new());
4063    }
4064
4065    let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4066    let mut pack_objects = Vec::with_capacity(plan.writes.len());
4067    let force_transport_checks = plan.writes.iter().any(|write| write.force);
4068    for write in &plan.writes {
4069        commands.push(PushCommand {
4070            src: Some(write.new),
4071            dst: write.full_name.clone(),
4072            expected_old: write.old,
4073            force: write.force,
4074        });
4075        pack_objects.push(write.new);
4076    }
4077    for delete in &plan.deletes {
4078        commands.push(PushCommand {
4079            src: None,
4080            dst: delete.full_name.clone(),
4081            expected_old: Some(delete.old),
4082            force: false,
4083        });
4084    }
4085
4086    let mut credentials = NoCredentials;
4087    let mut progress = SilentProgress;
4088    mirror_repo
4089        .push_actions(
4090            url,
4091            PushActionPlan {
4092                commands,
4093                pack_objects,
4094                options: PushOptions {
4095                    quiet: true,
4096                    force: force || force_transport_checks,
4097                    thin: sley::remote::PushThinMode::Auto,
4098                },
4099            },
4100            &mut credentials,
4101            &mut progress,
4102        )
4103        .map_err(|err| GitBridgeError::Git(format!("push failed for {url}: {err}")))?;
4104    // Only persist the record once the remote has acknowledged every command, so
4105    // a failed push never leaves a ref recorded as exported that did not land.
4106    write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4107    Ok(planned_write_names(&plan))
4108}
4109
4110#[cfg(test)]
4111mod tests {
4112    use super::*;
4113
4114    #[test]
4115    fn parse_git_ref_local_branch() {
4116        let parsed = parse_git_ref("refs/heads/main").expect("local branch parses");
4117        assert_eq!(parsed.kind, GitRefKind::Branch);
4118        assert_eq!(parsed.name, "main");
4119        assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4120    }
4121
4122    #[test]
4123    fn parse_git_ref_remote_branch_keeps_nested_name() {
4124        let parsed = parse_git_ref("refs/remotes/origin/feature/x").expect("remote branch parses");
4125        assert_eq!(parsed.kind, GitRefKind::Branch);
4126        assert_eq!(parsed.name, "feature/x");
4127        assert_eq!(parsed.remote, "origin");
4128    }
4129
4130    #[test]
4131    fn parse_git_ref_tag() {
4132        let parsed = parse_git_ref("refs/tags/v1.0").expect("tag parses");
4133        assert_eq!(parsed.kind, GitRefKind::Tag);
4134        assert_eq!(parsed.name, "v1.0");
4135        assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4136    }
4137
4138    #[test]
4139    fn parse_git_ref_note() {
4140        let parsed = parse_git_ref("refs/notes/heddle").expect("note parses");
4141        assert_eq!(parsed.kind, GitRefKind::Note);
4142        assert_eq!(parsed.name, "heddle");
4143        assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4144    }
4145
4146    #[test]
4147    fn parse_git_ref_skips_head_symrefs() {
4148        assert_eq!(parse_git_ref("refs/heads/HEAD"), None);
4149        assert_eq!(parse_git_ref("refs/remotes/origin/HEAD"), None);
4150    }
4151
4152    #[test]
4153    fn parse_git_ref_rejects_unknown_or_malformed() {
4154        assert_eq!(parse_git_ref("HEAD"), None);
4155        // A remote ref with no branch component beneath the remote name.
4156        assert_eq!(parse_git_ref("refs/remotes/origin"), None);
4157    }
4158
4159    #[test]
4160    fn parse_git_ref_rejects_reserved_git_remote_namespace() {
4161        // A user remote literally named `git` collides with the local sentinel;
4162        // it must not be aliased onto local refs at the parse site.
4163        assert_eq!(parse_git_ref("refs/remotes/git/main"), None);
4164        assert_eq!(parse_git_ref("refs/remotes/git/feature/x"), None);
4165        assert!(is_reserved_git_remote_name(REMOTE_NAME_FOR_LOCAL_GIT_REPO));
4166        assert!(!is_reserved_git_remote_name("origin"));
4167    }
4168
4169    #[test]
4170    fn local_path_from_url_rejects_hosted_heddle_scheme() {
4171        // Regression (push-routing no-op): a `heddle://` hosted remote that
4172        // reaches the git-overlay exporter must be a HARD ERROR, never a
4173        // silent no-op success. The git network pusher cannot speak the
4174        // hosted protocol, so classifying a `heddle://` URL here must fail
4175        // loudly rather than fall through to `ResolvedRemote::Url` (which
4176        // would "reconcile" locally and report success without ever
4177        // contacting the server).
4178        let err = local_path_from_url("heddle://weft.local:8421/org/repo")
4179            .expect_err("heddle:// must be rejected by the git exporter classifier");
4180        let msg = err.to_string();
4181        assert!(
4182            msg.contains("heddle://") && msg.contains("hosted"),
4183            "error should explain the hosted scheme cannot be pushed via the git-overlay exporter, got: {msg}"
4184        );
4185    }
4186
4187    #[test]
4188    fn local_path_from_url_still_accepts_file_and_git_urls() {
4189        // The guard must not regress legitimate transports: `file://` still
4190        // resolves to a local path, and ordinary git URLs (https/ssh) still
4191        // pass through as "not local" (Ok(None)) for the network git pusher.
4192        assert!(
4193            local_path_from_url("file:///tmp/repo.git")
4194                .expect("file url ok")
4195                .is_some(),
4196            "file:// must still resolve to a local path"
4197        );
4198        assert!(
4199            local_path_from_url("https://example.com/org/repo.git")
4200                .expect("https url ok")
4201                .is_none(),
4202            "https git url must pass through as a network URL"
4203        );
4204        assert!(
4205            local_path_from_url("git@github.com:org/repo.git")
4206                .expect("ssh url ok")
4207                .is_none(),
4208            "ssh git url must pass through as a network URL"
4209        );
4210    }
4211
4212    #[test]
4213    fn refspec_forced_round_trips_git_format() {
4214        let spec =
4215            RefSpec::forced("refs/heads/main", "refs/heads/main").expect("valid forced refspec");
4216        assert_eq!(spec.to_git_format(), "+refs/heads/main:refs/heads/main");
4217        assert_eq!(
4218            spec.to_git_format_not_forced(),
4219            "refs/heads/main:refs/heads/main"
4220        );
4221    }
4222
4223    #[test]
4224    fn refspec_constructor_rejects_reserved_remote_name() {
4225        let err = RefSpec::new(
4226            Some("refs/remotes/git/main".to_string()),
4227            "refs/heads/main",
4228            false,
4229        )
4230        .expect_err("reserved remote source is rejected");
4231        assert!(err.to_string().contains("reserved namespace"));
4232
4233        let err = RefSpec::new(
4234            Some("refs/heads/main".to_string()),
4235            "refs/remotes/git/main",
4236            false,
4237        )
4238        .expect_err("reserved remote destination is rejected");
4239        assert!(err.to_string().contains("reserved namespace"));
4240    }
4241
4242    #[test]
4243    fn refspec_forced_rejects_reserved_remote_name() {
4244        assert!(RefSpec::forced("refs/remotes/git/main", "refs/heads/main").is_err());
4245        assert!(RefSpec::forced("refs/heads/main", "refs/remotes/git/main").is_err());
4246    }
4247
4248    #[test]
4249    fn refspec_delete_has_empty_source() {
4250        let spec = RefSpec::delete("refs/heads/stale").expect("valid delete refspec");
4251        assert_eq!(spec.to_git_format(), ":refs/heads/stale");
4252        assert_eq!(spec.to_git_format_not_forced(), ":refs/heads/stale");
4253    }
4254
4255    #[test]
4256    fn refspec_delete_rejects_reserved_remote_name() {
4257        assert!(RefSpec::delete("refs/remotes/git/stale").is_err());
4258    }
4259
4260    #[test]
4261    fn refspec_constructor_rejects_empty_source_and_destination() {
4262        let err = RefSpec::new(None, "", false)
4263            .expect_err("empty source plus empty destination is rejected");
4264        assert!(err.to_string().contains("cannot both be empty"));
4265    }
4266
4267    #[test]
4268    fn negative_refspec_prefixes_caret() {
4269        let spec = NegativeRefSpec::new("refs/heads/wip").expect("valid negative refspec");
4270        assert_eq!(spec.to_git_format(), "^refs/heads/wip");
4271    }
4272
4273    #[test]
4274    fn negative_refspec_constructor_rejects_unparseable_negation() {
4275        let err = NegativeRefSpec::new("refs/heads/wip/*").expect_err("negative glob is rejected");
4276        assert!(err.to_string().contains("Negative glob patterns"));
4277    }
4278
4279    #[test]
4280    fn negative_refspec_constructor_rejects_reserved_remote_name() {
4281        let err = NegativeRefSpec::new("refs/remotes/git/main")
4282            .expect_err("reserved remote negative source is rejected");
4283        assert!(err.to_string().contains("reserved namespace"));
4284    }
4285
4286    #[test]
4287    fn mirror_fetch_refspecs_cover_branches_and_notes() {
4288        assert_eq!(
4289            heddle_mirror_fetch_refspecs().expect("mirror refspecs are valid"),
4290            [
4291                "+refs/heads/*:refs/heads/*".to_string(),
4292                "+refs/notes/*:refs/notes/*".to_string(),
4293            ]
4294        );
4295    }
4296
4297    #[test]
4298    fn scoped_import_ref_updates_do_not_include_notes_implicitly() {
4299        let tmp = tempfile::TempDir::new().unwrap();
4300        let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4301        let main = seed_commit(&repo, "main");
4302        let other = seed_commit(&repo, "other");
4303        let notes = seed_commit(&repo, "notes");
4304        set_reference(
4305            &repo,
4306            "refs/heads/main",
4307            main,
4308            RefPrecondition::MustNotExist,
4309            "test: main",
4310        )
4311        .expect("write main");
4312        set_reference(
4313            &repo,
4314            "refs/heads/other",
4315            other,
4316            RefPrecondition::MustNotExist,
4317            "test: other",
4318        )
4319        .expect("write other");
4320        set_reference(
4321            &repo,
4322            "refs/notes/heddle",
4323            notes,
4324            RefPrecondition::MustNotExist,
4325            "test: notes",
4326        )
4327        .expect("write notes");
4328
4329        let updates = collect_import_source_ref_updates(&repo, &["main".to_string()])
4330            .expect("collect scoped updates");
4331        let full_names = updates.iter().map(full_ref_name).collect::<Vec<_>>();
4332
4333        assert_eq!(full_names, vec!["refs/heads/main".to_string()]);
4334    }
4335
4336    #[test]
4337    fn fast_forward_guard_reports_exact_rewrite_before_after() {
4338        let tmp = tempfile::TempDir::new().unwrap();
4339        let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4340        let root = test_commit(&repo, "root", &[]);
4341        let old = test_commit(&repo, "old", &[root]);
4342        let new = test_commit(&repo, "new", &[root]);
4343
4344        let err = ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
4345            .expect_err("sibling commit update should be refused");
4346        let message = err.to_string();
4347        assert!(message.contains("refs/heads/main"));
4348        assert!(message.contains(&old.to_string()));
4349        assert!(message.contains(&new.to_string()));
4350        assert!(message.contains("refusing to replace"));
4351    }
4352
4353    #[test]
4354    fn fast_forward_guard_allows_descendant_update() {
4355        let tmp = tempfile::TempDir::new().unwrap();
4356        let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4357        let old = test_commit(&repo, "old", &[]);
4358        let new = test_commit(&repo, "new", &[old]);
4359
4360        ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
4361            .expect("descendant update should be allowed");
4362    }
4363
4364    fn test_commit(repo: &SleyRepository, message: &str, parents: &[ObjectId]) -> ObjectId {
4365        let empty_tree_oid = ObjectId::empty_tree(repo.object_format());
4366        let sig = Signature {
4367            name: GitByteString::new(b"Heddle Test".to_vec()),
4368            email: GitByteString::new(b"heddle@test".to_vec()),
4369            time: GitTime::new(0, 0),
4370            raw: b"Heddle Test <heddle@test> 0 +0000".to_vec(),
4371        };
4372        let commit = sley::CommitObject {
4373            tree: empty_tree_oid,
4374            parents: parents.to_vec(),
4375            author: sig.to_ident_bytes(),
4376            committer: sig.to_ident_bytes(),
4377            encoding: None,
4378            message: message.as_bytes().to_vec(),
4379        };
4380        repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
4381            GitObjectType::Commit,
4382            commit.write(),
4383        ))
4384        .expect("write test commit")
4385    }
4386
4387    fn seed_commit(repo: &SleyRepository, message: &str) -> ObjectId {
4388        test_commit(repo, message, &[])
4389    }
4390
4391    /// heddle#141 regression: when the URL-fetch path of
4392    /// `clone_url_to_bare` runs against a bare repo whose `HEAD`
4393    /// points at a branch that is *not* alphabetically first (and
4394    /// crucially, not what sley's `init_bare` defaults to), the
4395    /// resulting dest bare must have `HEAD` pointing at the remote
4396    /// default — not sley's init-time guess.
4397    #[test]
4398    fn clone_url_to_bare_via_sley_honours_remote_head_symref() {
4399        let tmp = tempfile::TempDir::new().unwrap();
4400        let source = tmp.path().join("source.git");
4401        let dest = tmp.path().join("dest.git");
4402
4403        // Build a bare source with two branches under
4404        // deliberately-non-default names: `trunk` (will be the
4405        // remote default — neither sley's `init.defaultBranch` nor
4406        // the alphabetically-first imported ref would land here by
4407        // accident) and `abc-feature` (alphabetically first — what
4408        // the buggy fallback used to pick).
4409        let src = SleyRepository::init_bare(&source).expect("init bare source");
4410        let seed = seed_commit(&src, "seed");
4411        for name in ["refs/heads/trunk", "refs/heads/abc-feature"] {
4412            set_reference(&src, name, seed, RefPrecondition::Any, "test: seed branch")
4413                .expect("set ref");
4414        }
4415        // Make sure HEAD on the source points at trunk so
4416        // `git ls-remote --symref` reports trunk.
4417        std::fs::write(source.join("HEAD"), b"ref: refs/heads/trunk\n").unwrap();
4418
4419        let url = format!("file://{}", source.display());
4420        clone_url_to_bare(&url, &dest, None, None).expect("clone url to bare");
4421
4422        let dest_head = std::fs::read_to_string(dest.join("HEAD")).expect("read dest HEAD");
4423        assert_eq!(
4424            dest_head.trim(),
4425            "ref: refs/heads/trunk",
4426            "dest HEAD must mirror the remote's symref (trunk), not sley's \
4427             init-time default and not the alphabetically-first branch \
4428             (abc-feature) — see heddle#141"
4429        );
4430    }
4431
4432    #[test]
4433    fn write_head_symref_writes_git_head_bytes() {
4434        let tmp = tempfile::TempDir::new().unwrap();
4435        let git_dir = tmp.path();
4436        SleyRepository::init_bare(git_dir).expect("init bare");
4437
4438        write_head_symref(git_dir, "refs/heads/feature/x").expect("write HEAD symref");
4439        assert_eq!(
4440            std::fs::read_to_string(git_dir.join("HEAD")).expect("read HEAD"),
4441            "ref: refs/heads/feature/x\n"
4442        );
4443
4444        write_head_symref(git_dir, "refs/heads/main").expect("rewrite HEAD symref");
4445        assert_eq!(
4446            std::fs::read_to_string(git_dir.join("HEAD")).unwrap(),
4447            "ref: refs/heads/main\n"
4448        );
4449    }
4450
4451    /// Characterization: `head_state()` branch/detached/unborn mapping matches
4452    /// the legacy `ref: refs/heads/` text parse.
4453    #[test]
4454    fn head_state_matches_legacy_head_symref_parse() {
4455        let tmp = tempfile::TempDir::new().unwrap();
4456        let root = tmp.path();
4457        let git_dir = root.join(".git");
4458        SleyRepository::init_bare(&git_dir).expect("init bare overlay");
4459
4460        fn legacy_branch_parse(head_path: &Path) -> Option<String> {
4461            let contents = std::fs::read_to_string(head_path).ok()?;
4462            let trimmed = contents.trim();
4463            let suffix = trimmed.strip_prefix("ref: ")?;
4464            let branch = suffix.strip_prefix("refs/heads/")?;
4465            (!branch.is_empty()).then(|| branch.to_string())
4466        }
4467
4468        // Attached symref.
4469        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
4470        let repo = open_repo(root).expect("open");
4471        assert_eq!(repo.head_state().unwrap().branch_name(), Some("main"));
4472        assert_eq!(legacy_branch_parse(&git_dir.join("HEAD")), Some("main".into()));
4473
4474        // Detached HEAD.
4475        let oid =
4476            ObjectId::from_hex(ObjectFormat::Sha1, "0000000000000000000000000000000000000001")
4477                .unwrap();
4478        std::fs::write(git_dir.join("HEAD"), format!("{oid}\n")).unwrap();
4479        let repo = open_repo(root).expect("open");
4480        let state = repo.head_state().unwrap();
4481        assert!(state.is_detached());
4482        assert_eq!(state.branch_name(), None);
4483        assert_eq!(legacy_branch_parse(&git_dir.join("HEAD")), None);
4484
4485        // Unborn branch symref (no refs/heads/feature yet).
4486        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
4487        let repo = open_repo(root).expect("open");
4488        assert_eq!(
4489            repo.head_state().unwrap().branch_name(),
4490            Some("feature")
4491        );
4492        assert_eq!(
4493            legacy_branch_parse(&git_dir.join("HEAD")),
4494            Some("feature".into())
4495        );
4496    }
4497}