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