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::{HashMap, HashSet},
6    fs,
7    io::Write,
8    path::{Path, PathBuf},
9    sync::atomic::AtomicBool,
10    time::{SystemTime, UNIX_EPOCH},
11};
12
13use gix::{
14    bstr::ByteSlice,
15    hash::{Kind as ObjectHashKind, ObjectId},
16    refs::{
17        Target,
18        transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog},
19    },
20};
21use gix_transport::{
22    Protocol, Service,
23    client::{MessageKind, WriteMode, blocking_io::Transport},
24};
25use objects::{
26    error::HeddleError,
27    object::{ChangeId, ChangeIdParseError, Tree},
28    store::ObjectStore,
29};
30use refs::Head;
31use repo::Repository as HeddleRepository;
32
33use super::{git_export::export_all, git_import::import_all};
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("conflict during sync: {0}")]
66    Conflict(String),
67
68    #[error("change id parse error: {0}")]
69    ChangeIdParse(#[from] ChangeIdParseError),
70}
71
72/// Type alias for Git bridge results.
73pub type GitResult<T> = std::result::Result<T, GitBridgeError>;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub(crate) enum RefNamespace {
77    Branch,
78    Tag,
79    /// `refs/notes/<name>` — heddle uses `refs/notes/heddle` to carry
80    /// per-commit metadata (change_id) without disturbing commit SHAs.
81    Note,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub(crate) struct RefUpdate {
86    pub name: String,
87    pub target: ObjectId,
88    pub namespace: RefNamespace,
89}
90
91#[derive(Debug, Clone)]
92enum ResolvedRemote {
93    Local(PathBuf),
94    Url(gix::Url),
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum WriteThroughSkipReason {
99    MissingDotGit,
100    DetachedHead,
101    NoAttachedThread,
102    NoMappedCommit,
103    MirrorIsWorktree,
104    IndexAlreadyDirty,
105}
106
107impl std::fmt::Display for WriteThroughSkipReason {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            WriteThroughSkipReason::MissingDotGit => {
111                write!(f, "this checkout does not have a Git working tree")
112            }
113            WriteThroughSkipReason::DetachedHead => {
114                write!(f, "the current Heddle head is detached")
115            }
116            WriteThroughSkipReason::NoAttachedThread => {
117                write!(f, "the attached Heddle thread does not resolve to a state")
118            }
119            WriteThroughSkipReason::NoMappedCommit => {
120                write!(f, "the current Heddle state has not been exported to Git")
121            }
122            WriteThroughSkipReason::MirrorIsWorktree => {
123                write!(f, "the Git mirror is already the active checkout")
124            }
125            WriteThroughSkipReason::IndexAlreadyDirty => {
126                write!(f, "the Git index is already locked by another operation")
127            }
128        }
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum WriteThroughOutcome {
134    Wrote(ObjectId),
135    Skipped(WriteThroughSkipReason),
136}
137
138impl WriteThroughOutcome {
139    pub fn object_id(self) -> Option<ObjectId> {
140        match self {
141            WriteThroughOutcome::Wrote(oid) => Some(oid),
142            WriteThroughOutcome::Skipped(_) => None,
143        }
144    }
145
146    pub fn skip_reason(self) -> Option<WriteThroughSkipReason> {
147        match self {
148            WriteThroughOutcome::Skipped(reason) => Some(reason),
149            WriteThroughOutcome::Wrote(_) => None,
150        }
151    }
152}
153
154/// Mapping between Heddle ChangeIds and Git commit object IDs.
155#[derive(Debug, Default)]
156pub struct SyncMapping {
157    /// Maps Heddle ChangeId -> Git object id
158    heddle_to_git: HashMap<ChangeId, ObjectId>,
159    /// Maps Git object id -> Heddle ChangeId
160    git_to_heddle: HashMap<ObjectId, ChangeId>,
161}
162
163impl SyncMapping {
164    /// Create a new empty mapping.
165    pub fn new() -> Self {
166        Self::default()
167    }
168
169    /// Insert a mapping.
170    pub fn insert(&mut self, change_id: ChangeId, git_oid: ObjectId) {
171        self.heddle_to_git.insert(change_id, git_oid);
172        self.git_to_heddle.insert(git_oid, change_id);
173    }
174
175    /// Insert a mapping and detect conflicts.
176    pub(crate) fn insert_checked(
177        &mut self,
178        change_id: ChangeId,
179        git_oid: ObjectId,
180    ) -> GitResult<()> {
181        if let Some(existing) = self.heddle_to_git.get(&change_id)
182            && *existing != git_oid
183        {
184            return Err(GitBridgeError::Conflict(format!(
185                "change id {} mapped to {} (new {})",
186                change_id, existing, git_oid
187            )));
188        }
189
190        if let Some(existing) = self.git_to_heddle.get(&git_oid)
191            && *existing != change_id
192        {
193            return Err(GitBridgeError::Conflict(format!(
194                "git oid {} mapped to {} (new {})",
195                git_oid, existing, change_id
196            )));
197        }
198
199        self.insert(change_id, git_oid);
200        Ok(())
201    }
202
203    /// Get Git object id for a Heddle ChangeId.
204    pub fn get_git(&self, change_id: &ChangeId) -> Option<ObjectId> {
205        self.heddle_to_git.get(change_id).copied()
206    }
207
208    /// Get Heddle ChangeId for a Git object id.
209    pub fn get_heddle(&self, git_oid: ObjectId) -> Option<ChangeId> {
210        self.git_to_heddle.get(&git_oid).copied()
211    }
212
213    /// Check if a mapping exists for a ChangeId.
214    pub fn has_heddle(&self, change_id: &ChangeId) -> bool {
215        self.heddle_to_git.contains_key(change_id)
216    }
217
218    /// Check if a mapping exists for a Git object id.
219    pub fn has_git(&self, git_oid: ObjectId) -> bool {
220        self.git_to_heddle.contains_key(&git_oid)
221    }
222
223    /// Iterate over mappings.
224    pub(crate) fn iter(&self) -> impl Iterator<Item = (&ChangeId, &ObjectId)> {
225        self.heddle_to_git.iter()
226    }
227
228    pub(crate) fn retain_git_objects(&mut self, repo: &gix::Repository) {
229        let retained: Vec<(ChangeId, ObjectId)> = self
230            .heddle_to_git
231            .iter()
232            .filter_map(|(change_id, git_oid)| {
233                repo.find_object(*git_oid)
234                    .ok()
235                    .map(|_| (*change_id, *git_oid))
236            })
237            .collect();
238
239        self.heddle_to_git.clear();
240        self.git_to_heddle.clear();
241        for (change_id, git_oid) in retained {
242            self.insert(change_id, git_oid);
243        }
244    }
245
246    #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
247    pub(crate) fn retain_git_object_set(&mut self, reachable: &HashSet<ObjectId>) -> usize {
248        let before = self.heddle_to_git.len();
249        let retained: Vec<(ChangeId, ObjectId)> = self
250            .heddle_to_git
251            .iter()
252            .filter_map(|(change_id, git_oid)| {
253                reachable
254                    .contains(git_oid)
255                    .then_some((*change_id, *git_oid))
256            })
257            .collect();
258
259        self.heddle_to_git.clear();
260        self.git_to_heddle.clear();
261        for (change_id, git_oid) in retained {
262            self.insert(change_id, git_oid);
263        }
264        before.saturating_sub(self.heddle_to_git.len())
265    }
266}
267
268/// Git bridge for Heddle repository.
269pub struct GitBridge<'a> {
270    pub(crate) heddle_repo: &'a HeddleRepository,
271    pub(crate) git_repo_path: Option<PathBuf>,
272    pub(crate) mapping: SyncMapping,
273}
274
275impl<'a> GitBridge<'a> {
276    /// Trailer keys used in Git commit messages for Heddle metadata.
277    pub(crate) const TRAILER_CHANGE_ID: &'static str = "Heddle-Change-Id";
278    pub(crate) const TRAILER_AGENT: &'static str = "Heddle-Agent";
279    pub(crate) const TRAILER_CONFIDENCE: &'static str = "Heddle-Confidence";
280    pub(crate) const TRAILER_STATUS: &'static str = "Heddle-Status";
281
282    /// Create a new Git bridge for a Heddle repository.
283    pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
284        Self {
285            heddle_repo,
286            git_repo_path: None,
287            mapping: SyncMapping::new(),
288        }
289    }
290
291    /// Initialize a Git mirror in the .heddle/git directory.
292    pub fn init_mirror(&mut self) -> GitResult<()> {
293        let _guard = self.init_mirror_with_guard()?;
294        _guard.commit();
295        Ok(())
296    }
297
298    /// Variant of `init_mirror` that returns a `MirrorInitGuard` so
299    /// callers performing a multi-step bring-up (init + first export)
300    /// can roll back the partially-created mirror if a later step
301    /// fails. Call `guard.commit()` once the mirror is known-good.
302    pub(crate) fn init_mirror_with_guard(&mut self) -> GitResult<MirrorInitGuard> {
303        let git_dir = self.heddle_repo.heddle_dir().join("git");
304
305        let did_create = if git_dir.exists() {
306            let _ = open_repo(&git_dir)?;
307            false
308        } else {
309            fs::create_dir_all(&git_dir)?;
310            let _ = gix::init_bare(&git_dir).map_err(git_err)?;
311            true
312        };
313
314        self.git_repo_path = Some(git_dir.clone());
315        Ok(MirrorInitGuard::new_from_init(git_dir, did_create))
316    }
317
318    /// Get the path to the Git mirror directory.
319    pub fn mirror_path(&self) -> PathBuf {
320        self.heddle_repo.heddle_dir().join("git")
321    }
322
323    /// Check if a Git mirror is initialized.
324    pub fn is_initialized(&self) -> bool {
325        self.mirror_path().exists()
326    }
327
328    /// Open the Git repository (mirror or regular).
329    pub(crate) fn open_git_repo(&self) -> GitResult<gix::Repository> {
330        if let Some(ref path) = self.git_repo_path {
331            open_repo(path)
332        } else {
333            let mirror_path = self.mirror_path();
334            if mirror_path.exists() {
335                open_repo(&mirror_path)
336            } else {
337                open_repo(self.heddle_repo.root())
338            }
339        }
340    }
341
342    /// Sort states topologically (parents before children).
343    pub(crate) fn sort_states_topologically(
344        &self,
345        states: &[ChangeId],
346    ) -> GitResult<Vec<ChangeId>> {
347        let mut sorted = Vec::new();
348        let mut visited: std::collections::HashSet<ChangeId> = std::collections::HashSet::new();
349
350        fn visit<S: ObjectStore + ?Sized>(
351            state_id: &ChangeId,
352            store: &S,
353            visited: &mut std::collections::HashSet<ChangeId>,
354            sorted: &mut Vec<ChangeId>,
355        ) -> GitResult<()> {
356            if visited.contains(state_id) {
357                return Ok(());
358            }
359
360            if let Some(state) = store.get_state(state_id)? {
361                for parent in &state.parents {
362                    visit(parent, store, visited, sorted)?;
363                }
364            }
365
366            visited.insert(*state_id);
367            sorted.push(*state_id);
368
369            Ok(())
370        }
371
372        for state_id in states {
373            visit(
374                state_id,
375                self.heddle_repo.store(),
376                &mut visited,
377                &mut sorted,
378            )?;
379        }
380
381        Ok(sorted)
382    }
383
384    /// Export all Heddle states to Git commits.
385    pub fn export(&mut self) -> GitResult<super::git_util::ExportStats> {
386        export_all(self)
387    }
388
389    /// Import Git commits into Heddle states.
390    pub fn import(&mut self, git_path: Option<&Path>) -> GitResult<super::git_util::ImportStats> {
391        import_all(self, git_path)
392    }
393
394    /// Push to a Git remote.
395    pub fn push(&mut self, remote_name: &str) -> GitResult<()> {
396        self.init_mirror()?;
397        self.export()?;
398        self.write_through_current_checkout()?;
399
400        let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
401        match self.resolve_remote(remote_name, gix::remote::Direction::Push)? {
402            ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
403                &target_path,
404                &log_message,
405                /* init_if_missing */ false,
406            ),
407            ResolvedRemote::Url(url) => {
408                let mirror_repo = self.open_git_repo()?;
409                push_network_remote(&mirror_repo, &url)
410            }
411        }
412    }
413
414    /// Export current Heddle state into the internal mirror, then write it out
415    /// as a bare git repository at `target_path`. Auto-initializes
416    /// `target_path` as a bare repo if it does not already exist.
417    pub fn export_to_path(
418        &mut self,
419        target_path: &Path,
420    ) -> GitResult<super::git_util::ExportStats> {
421        self.init_mirror()?;
422        let stats = self.export()?;
423        self.copy_mirror_to_path(
424            target_path,
425            &format!("heddle: export from {}", self.heddle_repo.root().display()),
426            /* init_if_missing */ true,
427        )?;
428        Ok(stats)
429    }
430
431    /// Shared helper: copy every reachable object from the internal mirror to
432    /// `target_path`, then mirror branch/tag refs onto it. When
433    /// `init_if_missing` is true, the destination is created as a bare repo
434    /// when it does not exist.
435    fn copy_mirror_to_path(
436        &mut self,
437        target_path: &Path,
438        log_message: &str,
439        init_if_missing: bool,
440    ) -> GitResult<()> {
441        let mirror_repo = self.open_git_repo()?;
442        let target_repo = if target_path.exists() {
443            open_repo(target_path)?
444        } else if init_if_missing {
445            fs::create_dir_all(target_path)?;
446            gix::init_bare(target_path).map_err(git_err)?;
447            open_repo(target_path)?
448        } else {
449            return Err(GitBridgeError::Git(format!(
450                "destination '{}' does not exist",
451                target_path.display()
452            )));
453        };
454        let updates = collect_ref_updates(&mirror_repo)?;
455
456        copy_reachable_objects(
457            &mirror_repo,
458            &target_repo,
459            updates.iter().map(|update| update.target),
460        )?;
461        apply_ref_updates(&target_repo, &updates, log_message)?;
462        Ok(())
463    }
464
465    /// Fetch Git refs and objects into the internal mirror without moving
466    /// Heddle thread refs or the current worktree.
467    pub fn fetch(&mut self, remote_name: &str) -> GitResult<()> {
468        self.init_mirror()?;
469
470        let mirror_repo = self.open_git_repo()?;
471        match self.resolve_remote(remote_name, gix::remote::Direction::Fetch)? {
472            ResolvedRemote::Local(path) => {
473                let remote_repo = open_repo(&path)?;
474                let updates = collect_ref_updates(&remote_repo)?;
475                copy_reachable_objects(
476                    &remote_repo,
477                    &mirror_repo,
478                    updates.iter().map(|update| update.target),
479                )?;
480                apply_ref_updates(
481                    &mirror_repo,
482                    &updates,
483                    &format!("heddle: fetch from {remote_name}"),
484                )?;
485            }
486            ResolvedRemote::Url(url) => {
487                fetch_network_remote(&mirror_repo, remote_name, &url)?;
488            }
489        }
490
491        self.git_repo_path = Some(self.mirror_path());
492        Ok(())
493    }
494
495    /// Pull from a Git remote.
496    pub fn pull(&mut self, remote_name: &str) -> GitResult<()> {
497        let head_before = self.heddle_repo.refs().read_head()?;
498        let attached_before = match &head_before {
499            Head::Attached { thread } => self
500                .heddle_repo
501                .refs()
502                .get_thread(thread)?
503                .map(|state| (thread.clone(), state)),
504            Head::Detached { .. } => None,
505        };
506
507        self.fetch(remote_name)?;
508        self.import(None)?;
509
510        if let Some((thread, old_state)) = attached_before
511            && let Some(new_state) = self.heddle_repo.refs().get_thread(&thread)?
512            && new_state != old_state
513        {
514            self.heddle_repo.refs().set_thread(&thread, &old_state)?;
515            self.heddle_repo.refs().write_head(&Head::Attached {
516                thread: thread.clone(),
517            })?;
518            self.heddle_repo
519                .goto_verified_clean_without_record(&new_state)?;
520            self.heddle_repo.refs().set_thread(&thread, &new_state)?;
521            self.heddle_repo
522                .refs()
523                .write_head(&Head::Attached { thread })?;
524        }
525        Ok(())
526    }
527
528    /// Make the checkout's real `.git` view agree with the current Heddle
529    /// thread: copy exported objects from the internal mirror, advance the
530    /// matching Git branch, attach HEAD, and rebuild the Git index from the
531    /// exported commit tree.
532    pub fn write_through_current_checkout(&mut self) -> GitResult<WriteThroughOutcome> {
533        if !self.heddle_repo.root().join(".git").exists() {
534            return Ok(WriteThroughOutcome::Skipped(
535                WriteThroughSkipReason::MissingDotGit,
536            ));
537        }
538
539        let mirror_guard = self.init_mirror_with_guard()?;
540        // First export against a freshly-initialized mirror runs while
541        // the guard is still armed; if export fails we want the
542        // half-built `.heddle/git/` cleared so the next caller doesn't
543        // see a corrupt bare repo.
544        self.export()?;
545        // Mirror is committed to disk (objects + refs) in a known-good
546        // shape; remaining failures only affect the user's checkout
547        // and have their own per-file rollback below.
548        mirror_guard.commit();
549
550        let (thread, state_id) = match self.heddle_repo.head_ref()? {
551            Head::Attached { thread } => {
552                let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
553                    return Ok(WriteThroughOutcome::Skipped(
554                        WriteThroughSkipReason::NoAttachedThread,
555                    ));
556                };
557                (thread, state_id)
558            }
559            Head::Detached { .. } => {
560                return Ok(WriteThroughOutcome::Skipped(
561                    WriteThroughSkipReason::DetachedHead,
562                ));
563            }
564        };
565        let Some(git_oid) = self.mapping.get_git(&state_id) else {
566            return Ok(WriteThroughOutcome::Skipped(
567                WriteThroughSkipReason::NoMappedCommit,
568            ));
569        };
570
571        let mirror_repo = self.open_git_repo()?;
572        let checkout_repo = gix::discover(self.heddle_repo.root()).map_err(git_err)?;
573        if checkout_repo.git_dir() == mirror_repo.git_dir() {
574            return Ok(WriteThroughOutcome::Skipped(
575                WriteThroughSkipReason::MirrorIsWorktree,
576            ));
577        }
578        let git_dir = checkout_repo.git_dir().to_path_buf();
579        // gix-index manages its own `index.lock` (atomic `O_CREAT |
580        // O_EXCL`) inside `index.write`, so we don't create a parallel
581        // lock here — that would deadlock with gix's writer. The
582        // existence check below is a UX nicety so a stale or
583        // concurrent lock surfaces as a structured `IndexAlreadyDirty`
584        // skip rather than a raw "Could not acquire lock" error from
585        // gix.
586        if git_dir.join("index.lock").exists() {
587            return Ok(WriteThroughOutcome::Skipped(
588                WriteThroughSkipReason::IndexAlreadyDirty,
589            ));
590        }
591
592        let object_repo = common_repo_for_worktree(&checkout_repo)?;
593        let branch_ref = format!("refs/heads/{thread}");
594        let head_path = git_dir.join("HEAD");
595        let index_path = git_dir.join("index");
596        let previous_head = fs::read(&head_path).ok();
597        let previous_index = fs::read(&index_path).ok();
598        let previous_branch = object_repo
599            .find_reference(&branch_ref)
600            .ok()
601            .and_then(|mut reference| reference.peel_to_id().ok())
602            .map(|id| id.detach());
603
604        let write_result = (|| -> GitResult<()> {
605            copy_reachable_objects(&mirror_repo, &object_repo, [git_oid])?;
606            fs::write(&head_path, format!("ref: {branch_ref}\n"))?;
607
608            let commit = checkout_repo.find_commit(git_oid).map_err(git_err)?;
609            let tree_id = commit.tree_id().map_err(git_err)?;
610            let mut index = checkout_repo.index_from_tree(&tree_id).map_err(git_err)?;
611            index
612                .write(gix_index::write::Options::default())
613                .map_err(git_err)?;
614
615            set_reference(
616                &object_repo,
617                &branch_ref,
618                git_oid,
619                PreviousValue::Any,
620                "heddle: write-through current thread",
621            )?;
622
623            // Mirror the bridge's `refs/notes/heddle` ref into the
624            // user's `.git/`. Without this, `git notes show <commit>`
625            // from the working tree fails because the user's repo
626            // has no notes ref — orchestrators have to know to poke
627            // inside `.heddle/git/` with `--git-dir`. The notes ref
628            // is a normal commit pointing at a tree of
629            // `<commit-sha>` → `<change-id-text>` blobs, so the
630            // standard reachability copy works.
631            mirror_notes_ref(&mirror_repo, &object_repo)?;
632
633            // fsync after every durable write so a power loss between
634            // `fs::write(HEAD)` and `index.write` doesn't leave the
635            // checkout in a self-inconsistent state. Sync the parent
636            // dir too — file-level fsync on its own doesn't durably
637            // commit the dirent on most filesystems.
638            fsync_path(&head_path)?;
639            fsync_path(&index_path)?;
640            fsync_path(&git_dir)?;
641            Ok(())
642        })();
643
644        if let Err(err) = write_result {
645            restore_file(head_path.clone(), previous_head.as_deref())?;
646            restore_file(index_path.clone(), previous_index.as_deref())?;
647            if let Some(previous_branch) = previous_branch {
648                set_reference(
649                    &object_repo,
650                    &branch_ref,
651                    previous_branch,
652                    PreviousValue::Any,
653                    "heddle: rollback failed write-through",
654                )?;
655            } else {
656                // The branch did not exist before write-through. If
657                // `set_reference` (or anything after it — notes mirror,
658                // fsync) created the new branch and *then* the write
659                // failed, the rollback used to leave that branch
660                // behind, so callers saw an error but Git still showed
661                // the new ref. Delete it so the failure is actually
662                // reverted. Best-effort: a missing ref here means the
663                // failure happened before set_reference ran, which is
664                // already the correct rolled-back state.
665                let _ = delete_reference_if_present(&object_repo, &branch_ref);
666            }
667            // fsync the rollback so the recovered files are durable
668            // even if the caller crashes immediately after.
669            let _ = fsync_path(&head_path);
670            let _ = fsync_path(&index_path);
671            let _ = fsync_path(&git_dir);
672            return Err(err);
673        }
674
675        Ok(WriteThroughOutcome::Wrote(git_oid))
676    }
677
678    fn resolve_remote(
679        &self,
680        remote_name: &str,
681        direction: gix::remote::Direction,
682    ) -> GitResult<ResolvedRemote> {
683        let repo = self.open_git_repo()?;
684        let url = match remote_url_from_repo(&repo, remote_name, direction)? {
685            Some(url) => Some(url),
686            None => self.checkout_remote_url(remote_name, direction)?,
687        };
688
689        let url = match url {
690            Some(url) => url,
691            None => gix::url::parse(remote_name.as_bytes().as_bstr()).map_err(git_err)?,
692        };
693
694        match url.scheme {
695            gix::url::Scheme::File => Ok(ResolvedRemote::Local(local_path_from_url(&url)?)),
696            _ => Ok(ResolvedRemote::Url(url)),
697        }
698    }
699
700    fn checkout_remote_url(
701        &self,
702        remote_name: &str,
703        direction: gix::remote::Direction,
704    ) -> GitResult<Option<gix::Url>> {
705        let Ok(repo) = gix::discover(self.heddle_repo.root()) else {
706            return Ok(None);
707        };
708        remote_url_from_repo(&repo, remote_name, direction)
709    }
710}
711
712fn remote_url_from_repo(
713    repo: &gix::Repository,
714    remote_name: &str,
715    direction: gix::remote::Direction,
716) -> GitResult<Option<gix::Url>> {
717    if direction == gix::remote::Direction::Fetch {
718        repo.find_fetch_remote(Some(remote_name.as_bytes().as_bstr()))
719            .map(|remote| remote.url(direction).cloned())
720            .map_err(git_err)
721    } else if let Ok(remote) = repo.find_remote(remote_name.as_bytes().as_bstr()) {
722        Ok(remote.url(direction).cloned())
723    } else {
724        Ok(None)
725    }
726}
727
728fn common_repo_for_worktree(repo: &gix::Repository) -> GitResult<gix::Repository> {
729    let common_dir_file = repo.git_dir().join("commondir");
730    let Ok(contents) = fs::read_to_string(&common_dir_file) else {
731        return Ok(repo.clone());
732    };
733    let target = contents.trim();
734    if target.is_empty() {
735        return Ok(repo.clone());
736    }
737    let common_dir = {
738        let path = Path::new(target);
739        if path.is_absolute() {
740            path.to_path_buf()
741        } else {
742            repo.git_dir().join(path)
743        }
744    };
745    open_repo(&common_dir)
746}
747
748pub(crate) fn git_err(err: impl std::fmt::Display) -> GitBridgeError {
749    GitBridgeError::Git(err.to_string())
750}
751
752fn restore_file(path: PathBuf, previous: Option<&[u8]>) -> GitResult<()> {
753    if let Some(previous) = previous {
754        fs::write(path, previous)?;
755    } else if path.exists() {
756        fs::remove_file(path)?;
757    }
758    Ok(())
759}
760
761/// `fsync` a single file by opening it read-only and calling
762/// `sync_all`. Best-effort: missing files are not an error (a Drop
763/// guard might have removed them between write and fsync).
764fn fsync_path(path: &Path) -> GitResult<()> {
765    match std::fs::File::open(path) {
766        Ok(file) => {
767            file.sync_all()?;
768            Ok(())
769        }
770        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
771        Err(err) => Err(GitBridgeError::Io(err)),
772    }
773}
774
775/// Copy the bridge mirror's `refs/notes/heddle` ref into the user's
776/// `.git/`. The notes ref is a regular Git commit pointing at a
777/// tree of `<commit-sha>` → `<change-id-text>` files, so reachability
778/// copy + a normal ref update is enough — no special notes-format
779/// awareness needed.
780///
781/// Best-effort: if the mirror has no notes ref yet (e.g. a fresh
782/// import that hasn't recorded any change_ids), we silently skip.
783/// The user-visible contract is "notes ref is at-least-as-fresh-as
784/// the mirror" — never "always present."
785fn mirror_notes_ref(mirror_repo: &gix::Repository, object_repo: &gix::Repository) -> GitResult<()> {
786    const NOTES_REF: &str = "refs/notes/heddle";
787    let Ok(mut notes_ref) = mirror_repo.find_reference(NOTES_REF) else {
788        // No notes in the mirror yet — nothing to mirror.
789        return Ok(());
790    };
791    let notes_oid = notes_ref.peel_to_id().map_err(git_err)?.detach();
792    copy_reachable_objects(mirror_repo, object_repo, [notes_oid])?;
793    set_reference(
794        object_repo,
795        NOTES_REF,
796        notes_oid,
797        PreviousValue::Any,
798        "heddle: mirror notes/heddle from bridge",
799    )?;
800    Ok(())
801}
802
803/// RAII guard for `init_mirror`. When the mirror directory did not
804/// exist at acquisition time, an early Drop (panic, error return)
805/// removes the partially-initialized `.heddle/git/` so a future
806/// `heddle bridge ...` doesn't see a half-built bare repo. Call
807/// `commit()` once the mirror is known-good (e.g. after a successful
808/// first export) to disarm the guard.
809pub(crate) struct MirrorInitGuard {
810    path: PathBuf,
811    /// `Some(true)` means we created the directory in this call and
812    /// own its rollback; `Some(false)` (or `None` after commit) means
813    /// hands off.
814    rollback: Option<bool>,
815}
816
817impl MirrorInitGuard {
818    pub(crate) fn new_from_init(path: PathBuf, did_create: bool) -> Self {
819        Self {
820            path,
821            rollback: Some(did_create),
822        }
823    }
824
825    pub(crate) fn commit(mut self) {
826        self.rollback = None;
827    }
828}
829
830impl Drop for MirrorInitGuard {
831    fn drop(&mut self) {
832        if matches!(self.rollback, Some(true))
833            && self.path.exists()
834            && let Err(err) = std::fs::remove_dir_all(&self.path)
835        {
836            tracing::warn!(
837                path = %self.path.display(),
838                error = %err,
839                "failed to roll back partial bridge mirror; manual cleanup may be required"
840            );
841        }
842    }
843}
844
845/// Bridge policy: a thread is considered an "unclaimed bootstrap" when it
846/// points at an empty-tree state with no parents. That is the exact shape of
847/// the state produced by `Repository::seed_default_thread`, and it cannot
848/// occur through normal user work — any snapshot advances the tip to a state
849/// with either a non-empty tree or a non-empty parents list.
850///
851/// When a user runs `heddle init` followed by `heddle bridge pull` (or
852/// `import`), the bootstrap `main` is unclaimed and the incoming git ref
853/// should win. This helper lets the bridge recognize that case without
854/// silently overwriting real work.
855pub(crate) fn thread_is_unclaimed_bootstrap(
856    heddle_repo: &HeddleRepository,
857    change_id: &ChangeId,
858) -> GitResult<bool> {
859    let Some(state) = heddle_repo.store().get_state(change_id)? else {
860        return Ok(false);
861    };
862    if !state.parents.is_empty() {
863        return Ok(false);
864    }
865    let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
866        return Ok(false);
867    };
868    Ok(tree == Tree::new())
869}
870
871pub(crate) fn open_repo(path: &Path) -> GitResult<gix::Repository> {
872    match gix::discover(path) {
873        Ok(repo) => Ok(repo),
874        Err(_) => gix::open(path).map_err(git_err),
875    }
876}
877
878/// Delete a reference if present; missing-ref is a no-op. Used by the
879/// write-through rollback path to drop a branch that was created by a
880/// failed write-through but isn't reachable from any prior state. We
881/// scope the deletion with `PreviousValue::MustExist` so an unrelated
882/// concurrent writer that *just* updated this ref isn't silently
883/// clobbered — if the ref vanished underneath us between our read and
884/// the delete, that's the rollback we wanted anyway.
885pub(crate) fn delete_reference_if_present(repo: &gix::Repository, name: &str) -> GitResult<()> {
886    let signature = bridge_signature();
887    let mut time_buf = gix::date::parse::TimeBuf::default();
888    let edit = RefEdit {
889        change: Change::Delete {
890            log: RefLog::AndReference,
891            expected: PreviousValue::MustExist,
892        },
893        name: name
894            .try_into()
895            .map_err(|err| GitBridgeError::Git(format!("invalid ref {name}: {err}")))?,
896        deref: false,
897    };
898    match repo.edit_references_as([edit], Some(signature.to_ref(&mut time_buf))) {
899        Ok(_) => Ok(()),
900        // Missing ref → already rolled back; treat as success. gix's
901        // error message on an absent ref reads "for deletion did not
902        // exist or could not be parsed".
903        Err(err) if err.to_string().contains("did not exist") => Ok(()),
904        Err(err) => Err(git_err(err)),
905    }
906}
907
908pub(crate) fn set_reference(
909    repo: &gix::Repository,
910    name: &str,
911    target: ObjectId,
912    constraint: PreviousValue,
913    log_message: &str,
914) -> GitResult<()> {
915    let signature = bridge_signature();
916    let mut time_buf = gix::date::parse::TimeBuf::default();
917    let edit = RefEdit {
918        change: Change::Update {
919            log: LogChange {
920                mode: RefLog::AndReference,
921                force_create_reflog: false,
922                message: log_message.into(),
923            },
924            expected: constraint,
925            new: Target::Object(target),
926        },
927        name: name
928            .try_into()
929            .map_err(|err| GitBridgeError::Git(format!("invalid ref {name}: {err}")))?,
930        deref: false,
931    };
932    repo.edit_references_as([edit], Some(signature.to_ref(&mut time_buf)))
933        .map_err(git_err)?;
934    Ok(())
935}
936
937fn bridge_signature() -> gix::actor::Signature {
938    let seconds = SystemTime::now()
939        .duration_since(UNIX_EPOCH)
940        .map(|duration| duration.as_secs() as i64)
941        .unwrap_or(0);
942    gix::actor::Signature {
943        name: "Heddle".into(),
944        email: "heddle@local".into(),
945        time: gix::date::Time { seconds, offset: 0 },
946    }
947}
948
949fn local_path_from_url(url: &gix::Url) -> GitResult<PathBuf> {
950    if url.scheme != gix::url::Scheme::File {
951        return Err(GitBridgeError::Git(format!(
952            "remote '{}' uses unsupported scheme {:?}; only local path and file:// remotes are supported",
953            url, url.scheme
954        )));
955    }
956
957    let path = PathBuf::from(String::from_utf8_lossy(url.path.as_ref()).into_owned());
958    if path.as_os_str().is_empty() {
959        return Err(GitBridgeError::Git(format!(
960            "remote '{}' has no filesystem path",
961            url
962        )));
963    }
964    Ok(path)
965}
966
967fn collect_ref_updates(repo: &gix::Repository) -> GitResult<Vec<RefUpdate>> {
968    let mut updates = Vec::new();
969
970    for branch in repo
971        .references()
972        .map_err(git_err)?
973        .local_branches()
974        .map_err(git_err)?
975    {
976        let branch = branch.map_err(git_err)?;
977        let Some(target) = branch.try_id() else {
978            continue;
979        };
980        updates.push(RefUpdate {
981            name: branch.name().shorten().to_string(),
982            target: target.detach(),
983            namespace: RefNamespace::Branch,
984        });
985    }
986
987    for tag in repo
988        .references()
989        .map_err(git_err)?
990        .tags()
991        .map_err(git_err)?
992    {
993        let tag = tag.map_err(git_err)?;
994        let Some(target) = tag.try_id() else {
995            continue;
996        };
997        updates.push(RefUpdate {
998            name: tag.name().shorten().to_string(),
999            target: target.detach(),
1000            namespace: RefNamespace::Tag,
1001        });
1002    }
1003
1004    // Pick up refs/notes/* (currently just refs/notes/heddle) so the
1005    // change_id metadata travels alongside branches/tags on every push.
1006    for note_ref in repo
1007        .references()
1008        .map_err(git_err)?
1009        .prefixed("refs/notes/")
1010        .map_err(git_err)?
1011    {
1012        let note_ref = note_ref.map_err(git_err)?;
1013        let Some(target) = note_ref.try_id() else {
1014            continue;
1015        };
1016        // shorten() on refs/notes/<n> returns "<n>" (the path beneath the
1017        // notes/ prefix). We want to round-trip "<n>", not "notes/<n>",
1018        // since apply_ref_updates rebuilds the full name.
1019        let full = note_ref.name().as_bstr().to_string();
1020        let short = full
1021            .strip_prefix("refs/notes/")
1022            .unwrap_or(&full)
1023            .to_string();
1024        updates.push(RefUpdate {
1025            name: short,
1026            target: target.detach(),
1027            namespace: RefNamespace::Note,
1028        });
1029    }
1030
1031    Ok(updates)
1032}
1033
1034fn full_ref_name(update: &RefUpdate) -> String {
1035    match update.namespace {
1036        RefNamespace::Branch => format!("refs/heads/{}", update.name),
1037        RefNamespace::Tag => format!("refs/tags/{}", update.name),
1038        RefNamespace::Note => format!("refs/notes/{}", update.name),
1039    }
1040}
1041
1042pub(crate) fn apply_ref_updates(
1043    repo: &gix::Repository,
1044    updates: &[RefUpdate],
1045    log_message: &str,
1046) -> GitResult<()> {
1047    for update in updates {
1048        let full_name = full_ref_name(update);
1049        set_reference(
1050            repo,
1051            &full_name,
1052            update.target,
1053            PreviousValue::Any,
1054            log_message,
1055        )?;
1056    }
1057    Ok(())
1058}
1059
1060/// Copy a local Git repository into a bare repository without invoking Git
1061/// transport helpers. This is the local-path clone fast path used by the OSS
1062/// Git-overlay workflow when the user does not have `git` installed.
1063pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitResult<()> {
1064    fs::create_dir_all(dest)?;
1065    let source = open_repo(source_path)?;
1066    let target = match open_repo(dest) {
1067        Ok(repo) => repo,
1068        Err(_) => gix::init_bare(dest).map_err(git_err)?,
1069    };
1070    let updates = collect_ref_updates(&source)?;
1071    copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
1072    apply_ref_updates(
1073        &target,
1074        &updates,
1075        &format!("heddle: clone from {}", source_path.display()),
1076    )?;
1077
1078    // Mirror the source repo's HEAD: if the source is on `master` (or
1079    // `develop`, or anything non-`main`) but happens to also have a
1080    // `main` branch, the previous logic silently moved the user to
1081    // `main` on clone. Read the source's symbolic HEAD target and
1082    // honour it whenever it points at a branch we actually copied.
1083    // Fall back to `main` (then any first branch) only when the source
1084    // HEAD is detached or points at a branch we did not import.
1085    let copied_branches: HashSet<&str> = updates
1086        .iter()
1087        .filter(|update| update.namespace == RefNamespace::Branch)
1088        .map(|update| update.name.as_str())
1089        .collect();
1090    let source_head_branch = source
1091        .head_name()
1092        .ok()
1093        .flatten()
1094        .and_then(|full_name| {
1095            full_name
1096                .as_bstr()
1097                .to_str()
1098                .ok()
1099                .and_then(|s| s.strip_prefix("refs/heads/").map(str::to_owned))
1100        })
1101        .filter(|branch| copied_branches.contains(branch.as_str()));
1102    if let Some(branch) = source_head_branch {
1103        fs::write(dest.join("HEAD"), format!("ref: refs/heads/{branch}\n"))?;
1104    } else if copied_branches.contains("main") {
1105        fs::write(dest.join("HEAD"), b"ref: refs/heads/main\n")?;
1106    } else if let Some(first_branch) = updates
1107        .iter()
1108        .find(|update| update.namespace == RefNamespace::Branch)
1109    {
1110        fs::write(
1111            dest.join("HEAD"),
1112            format!("ref: refs/heads/{}\n", first_branch.name),
1113        )?;
1114    }
1115    Ok(())
1116}
1117
1118/// Clone a remote git URL into `dest` as a bare repository, fetching all
1119/// branches and tags. Mirrors the gix recipe used by `fetch_network_remote`
1120/// but starts from an empty `init_bare` rather than an existing repo.
1121///
1122/// Used by `bridge import --path <URL>` (Phase F): we clone into a
1123/// scratch directory under the heddle repo's `.heddle/tmp/` and feed the
1124/// resulting bare repo into the normal import path.
1125pub fn clone_url_to_bare(url: &gix::Url, dest: &Path) -> GitResult<()> {
1126    fs::create_dir_all(dest)?;
1127    let repo = gix::init_bare(dest).map_err(git_err)?;
1128    let mut remote = repo.remote_at(url.clone()).map_err(git_err)?;
1129    remote
1130        .replace_refspecs(
1131            ["+refs/heads/*:refs/heads/*"],
1132            gix::remote::Direction::Fetch,
1133        )
1134        .map_err(git_err)?;
1135    remote = remote.with_fetch_tags(gix::remote::fetch::Tags::All);
1136    let connection = remote
1137        .connect(gix::remote::Direction::Fetch)
1138        .map_err(git_err)?;
1139    let prepare = connection
1140        .prepare_fetch(
1141            gix::progress::Discard,
1142            gix::remote::ref_map::Options::default(),
1143        )
1144        .map_err(git_err)?;
1145    prepare
1146        .with_reflog_message(gix::remote::fetch::RefLogMessage::Override {
1147            message: format!("heddle: clone from {url}").into(),
1148        })
1149        .receive(gix::progress::Discard, &AtomicBool::new(false))
1150        .map_err(|err| GitBridgeError::Git(format!("clone failed for {url}: {err}")))?;
1151    Ok(())
1152}
1153
1154pub(crate) fn copy_reachable_objects(
1155    source: &gix::Repository,
1156    target: &gix::Repository,
1157    roots: impl IntoIterator<Item = ObjectId>,
1158) -> GitResult<()> {
1159    if source.object_hash() != target.object_hash() {
1160        return Err(GitBridgeError::Git(format!(
1161            "object hash mismatch: {:?} vs {:?}",
1162            source.object_hash(),
1163            target.object_hash()
1164        )));
1165    }
1166
1167    for oid in collect_reachable_object_ids(source, roots)? {
1168        let object = source.find_object(oid).map_err(git_err)?;
1169        let object_ref =
1170            gix::objs::ObjectRef::from_bytes(object.kind, &object.data).map_err(git_err)?;
1171        target.write_object(object_ref).map_err(git_err)?;
1172    }
1173
1174    Ok(())
1175}
1176
1177fn collect_reachable_object_ids(
1178    source: &gix::Repository,
1179    roots: impl IntoIterator<Item = ObjectId>,
1180) -> GitResult<Vec<ObjectId>> {
1181    let mut stack: Vec<ObjectId> = roots.into_iter().collect();
1182    let mut seen = HashSet::new();
1183    let mut ordered = Vec::new();
1184
1185    while let Some(oid) = stack.pop() {
1186        if !seen.insert(oid) {
1187            continue;
1188        }
1189        ordered.push(oid);
1190
1191        let object = source.find_object(oid).map_err(git_err)?;
1192        match object.kind {
1193            gix::objs::Kind::Commit => {
1194                let commit = source.find_commit(oid).map_err(git_err)?;
1195                stack.push(commit.tree_id().map_err(git_err)?.detach());
1196                for parent in commit.parent_ids() {
1197                    stack.push(parent.detach());
1198                }
1199            }
1200            gix::objs::Kind::Tree => {
1201                let tree = source.find_tree(oid).map_err(git_err)?;
1202                for entry in tree.iter() {
1203                    let entry = entry.map_err(git_err)?;
1204                    // Gitlink (mode 160000) entries point at a commit
1205                    // in the *submodule's* repository, not this one —
1206                    // by Git's design, that commit is never stored
1207                    // locally. Pushing its OID onto the walk would
1208                    // make the next `find_object` fail with
1209                    // "object … could not be found", which is what
1210                    // happens on a normal clone of any repo with
1211                    // submodules (e.g. git/git's
1212                    // sha1collisiondetection). The bridge import
1213                    // path (`import_gitlink`) records the foreign OID
1214                    // as a `heddle-submodule:` blob, which is what
1215                    // round-trips on export — so skipping it here is
1216                    // safe: we still emit the parent tree, just
1217                    // without trying to resolve a foreign-repo
1218                    // commit we cannot read.
1219                    if entry.mode().kind() == gix::object::tree::EntryKind::Commit {
1220                        continue;
1221                    }
1222                    stack.push(entry.object_id());
1223                }
1224            }
1225            gix::objs::Kind::Tag => {
1226                let tag = source.find_tag(oid).map_err(git_err)?;
1227                stack.push(tag.target_id().map_err(git_err)?.detach());
1228            }
1229            gix::objs::Kind::Blob => {}
1230        }
1231    }
1232
1233    Ok(ordered)
1234}
1235
1236fn fetch_network_remote(
1237    mirror_repo: &gix::Repository,
1238    remote_name: &str,
1239    url: &gix::Url,
1240) -> GitResult<()> {
1241    let mut remote = mirror_repo.remote_at(url.clone()).map_err(git_err)?;
1242    remote
1243        .replace_refspecs(
1244            ["+refs/heads/*:refs/heads/*"],
1245            gix::remote::Direction::Fetch,
1246        )
1247        .map_err(git_err)?;
1248    remote = remote.with_fetch_tags(gix::remote::fetch::Tags::All);
1249
1250    let connection = remote
1251        .connect(gix::remote::Direction::Fetch)
1252        .map_err(git_err)?;
1253    let progress = gix::progress::Discard;
1254    let prepare = connection
1255        .prepare_fetch(progress, gix::remote::ref_map::Options::default())
1256        .map_err(git_err)?;
1257    let progress = gix::progress::Discard;
1258    prepare
1259        .with_reflog_message(gix::remote::fetch::RefLogMessage::Override {
1260            message: format!("heddle: fetch from {remote_name}").into(),
1261        })
1262        .receive(progress, &AtomicBool::new(false))
1263        .map_err(|err| GitBridgeError::Git(format!("failed to fetch from {url}: {err}")))?;
1264    Ok(())
1265}
1266
1267fn push_network_remote(mirror_repo: &gix::Repository, url: &gix::Url) -> GitResult<()> {
1268    let updates = collect_ref_updates(mirror_repo)?;
1269    if updates.is_empty() {
1270        return Ok(());
1271    }
1272
1273    let mut transport = gix_transport::client::blocking_io::connect::connect(
1274        url.clone(),
1275        gix_transport::client::blocking_io::connect::Options {
1276            version: Protocol::V1,
1277            ..Default::default()
1278        },
1279    )
1280    .map_err(|err| GitBridgeError::Git(format!("failed to connect to {url}: {err}")))?;
1281
1282    let remote_refs = {
1283        let mut handshake = transport
1284            .handshake(Service::ReceivePack, &[])
1285            .map_err(|err| {
1286                GitBridgeError::Git(format!("receive-pack handshake failed for {url}: {err}"))
1287            })?;
1288        if !handshake.capabilities.contains("report-status") {
1289            return Err(GitBridgeError::Git(format!(
1290                "remote {url} does not support report-status; refusing to push without server acknowledgement"
1291            )));
1292        }
1293        remote_refs_from_receive_pack_handshake(&mut handshake)?
1294    };
1295    let mut commands = Vec::new();
1296    for update in &updates {
1297        let full_name = full_ref_name(update);
1298        let old = remote_refs
1299            .get(&full_name)
1300            .copied()
1301            .unwrap_or_else(|| ObjectHashKind::Sha1.null());
1302        if old == update.target {
1303            continue;
1304        }
1305        commands.push((full_name, old, update.target));
1306    }
1307
1308    if commands.is_empty() {
1309        return Ok(());
1310    }
1311
1312    let pack =
1313        pack_reachable_objects(mirror_repo, commands.iter().map(|(_, _, new_oid)| *new_oid))?;
1314    let mut request = transport
1315        .request(
1316            WriteMode::OneLfTerminatedLinePerWriteCall,
1317            MessageKind::Flush,
1318            false,
1319        )
1320        .map_err(git_err)?;
1321    for (idx, (name, old, new_oid)) in commands.iter().enumerate() {
1322        let mut line = format!("{old} {new_oid} {name}");
1323        if idx == 0 {
1324            line.push('\0');
1325            line.push_str("report-status");
1326        }
1327        request.write_all(line.as_bytes()).map_err(git_err)?;
1328    }
1329    request.write_message(MessageKind::Flush).map_err(git_err)?;
1330
1331    let (mut raw_writer, mut reader) = request.into_parts();
1332    raw_writer.write_all(&pack).map_err(git_err)?;
1333    raw_writer.flush().map_err(git_err)?;
1334    drop(raw_writer);
1335
1336    read_receive_pack_status(&mut reader, &commands, url)
1337}
1338
1339fn remote_refs_from_receive_pack_handshake(
1340    handshake: &mut gix_transport::client::blocking_io::SetServiceResponse<'_>,
1341) -> GitResult<HashMap<String, ObjectId>> {
1342    let mut remote_refs = HashMap::new();
1343    let Some(refs) = handshake.refs.as_mut() else {
1344        return Ok(remote_refs);
1345    };
1346    let (parsed, _) =
1347        gix_protocol::handshake::refs::from_v1_refs_received_as_part_of_handshake_and_capabilities(
1348            refs,
1349            handshake.capabilities.iter(),
1350        )
1351        .map_err(git_err)?;
1352
1353    for remote_ref in parsed {
1354        let (name, target, _) = remote_ref.unpack();
1355        let Some(target) = target else {
1356            continue;
1357        };
1358        remote_refs.insert(name.to_string(), target.to_owned());
1359    }
1360    Ok(remote_refs)
1361}
1362
1363fn pack_reachable_objects(
1364    repo: &gix::Repository,
1365    roots: impl IntoIterator<Item = ObjectId>,
1366) -> GitResult<Vec<u8>> {
1367    let oids = collect_reachable_object_ids(repo, roots)?;
1368    let mut entries = Vec::with_capacity(oids.len());
1369    for oid in &oids {
1370        let object = repo.find_object(*oid).map_err(git_err)?;
1371        let data = gix::objs::Data {
1372            kind: object.kind,
1373            data: &object.data,
1374        };
1375        let count = gix_pack::data::output::Count::from_data(*oid, None);
1376        let entry = gix_pack::data::output::Entry::from_data(&count, &data).map_err(git_err)?;
1377        entries.push(entry);
1378    }
1379
1380    let mut pack = Vec::new();
1381    let input = std::iter::once(Ok::<_, GitBridgeError>(entries));
1382    let mut writer = gix_pack::data::output::bytes::FromEntriesIter::new(
1383        input,
1384        &mut pack,
1385        oids.len().try_into().map_err(|_| {
1386            GitBridgeError::Git(format!(
1387                "push pack has too many objects to encode: {}",
1388                oids.len()
1389            ))
1390        })?,
1391        gix_pack::data::Version::V2,
1392        ObjectHashKind::Sha1,
1393    );
1394    for result in writer.by_ref() {
1395        result.map_err(git_err)?;
1396    }
1397    drop(writer);
1398    Ok(pack)
1399}
1400
1401fn read_receive_pack_status(
1402    reader: &mut (dyn gix_transport::client::blocking_io::ExtendedBufRead<'_> + Unpin),
1403    commands: &[(String, ObjectId, ObjectId)],
1404    url: &gix::Url,
1405) -> GitResult<()> {
1406    let mut line = String::new();
1407    let mut saw_unpack_ok = false;
1408    let mut acknowledged = HashSet::new();
1409
1410    loop {
1411        line.clear();
1412        let read = reader.readline_str(&mut line).map_err(git_err)?;
1413        if read == 0 {
1414            break;
1415        }
1416        let status = line.trim_end_matches(['\r', '\n']);
1417        if status == "unpack ok" {
1418            saw_unpack_ok = true;
1419            continue;
1420        }
1421        if let Some(name) = status.strip_prefix("ok ") {
1422            acknowledged.insert(name.to_string());
1423            continue;
1424        }
1425        if let Some(rest) = status.strip_prefix("ng ") {
1426            return Err(GitBridgeError::Git(format!(
1427                "push rejected by {url}: {rest}"
1428            )));
1429        }
1430        if let Some(rest) = status.strip_prefix("unpack ") {
1431            return Err(GitBridgeError::Git(format!(
1432                "push pack rejected by {url}: {rest}"
1433            )));
1434        }
1435    }
1436
1437    if !saw_unpack_ok {
1438        return Err(GitBridgeError::Git(format!(
1439            "push to {url} did not return an unpack acknowledgement"
1440        )));
1441    }
1442    for (name, _, _) in commands {
1443        if !acknowledged.contains(name) {
1444            return Err(GitBridgeError::Git(format!(
1445                "push to {url} did not acknowledge ref {name}"
1446            )));
1447        }
1448    }
1449    Ok(())
1450}