1use 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#[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
72pub 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 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#[derive(Debug, Default)]
156pub struct SyncMapping {
157 heddle_to_git: HashMap<ChangeId, ObjectId>,
159 git_to_heddle: HashMap<ObjectId, ChangeId>,
161}
162
163impl SyncMapping {
164 pub fn new() -> Self {
166 Self::default()
167 }
168
169 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 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 pub fn get_git(&self, change_id: &ChangeId) -> Option<ObjectId> {
205 self.heddle_to_git.get(change_id).copied()
206 }
207
208 pub fn get_heddle(&self, git_oid: ObjectId) -> Option<ChangeId> {
210 self.git_to_heddle.get(&git_oid).copied()
211 }
212
213 pub fn has_heddle(&self, change_id: &ChangeId) -> bool {
215 self.heddle_to_git.contains_key(change_id)
216 }
217
218 pub fn has_git(&self, git_oid: ObjectId) -> bool {
220 self.git_to_heddle.contains_key(&git_oid)
221 }
222
223 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
268pub 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 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 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 pub fn init_mirror(&mut self) -> GitResult<()> {
293 let _guard = self.init_mirror_with_guard()?;
294 _guard.commit();
295 Ok(())
296 }
297
298 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 pub fn mirror_path(&self) -> PathBuf {
320 self.heddle_repo.heddle_dir().join("git")
321 }
322
323 pub fn is_initialized(&self) -> bool {
325 self.mirror_path().exists()
326 }
327
328 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 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 pub fn export(&mut self) -> GitResult<super::git_util::ExportStats> {
386 export_all(self)
387 }
388
389 pub fn import(&mut self, git_path: Option<&Path>) -> GitResult<super::git_util::ImportStats> {
391 import_all(self, git_path)
392 }
393
394 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 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 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 true,
427 )?;
428 Ok(stats)
429 }
430
431 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 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 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 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 self.export()?;
545 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 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_notes_ref(&mirror_repo, &object_repo)?;
632
633 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 let _ = delete_reference_if_present(&object_repo, &branch_ref);
666 }
667 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
761fn 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
775fn 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 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
803pub(crate) struct MirrorInitGuard {
810 path: PathBuf,
811 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
845pub(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
878pub(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 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 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 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
1060pub 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 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
1118pub 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 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}