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