1use std::{
5 collections::{BTreeSet, HashMap, HashSet},
6 fs,
7 path::{Path, PathBuf},
8 time::{SystemTime, UNIX_EPOCH},
9};
10
11use objects::{
12 error::HeddleError,
13 object::{ChangeId, ChangeIdParseError, ContentHash, FileMode, Principal, ThreadName, Tree},
14 store::ObjectStore,
15};
16use refs::Head;
17use repo::Repository as HeddleRepository;
18use sley::{
19 BString as GitBString, DeleteRef, FullName, GitObjectType, GitTime, Index, IndexEntry,
20 IndexWriteOptions, ObjectFormat, ObjectId, RefPrecondition, ReferenceTarget,
21 Repository as SleyRepository, Signature,
22 plumbing::sley_core::ByteString as GitByteString,
23 remote::{
24 FetchOptions, LsRemoteFilter, NoCredentials, PushActionPlan, PushCommand, PushOptions,
25 SilentProgress,
26 },
27};
28
29use super::{
30 git_export::{commit_is_byte_faithful, export_all, export_current_thread},
31 git_ingest::import_git_history,
32 git_reconstruct::{commit_object_id, reconstruct_commit_bytes, write_commit_object},
33 git_util::ImportStats,
34};
35
36#[derive(Debug, thiserror::Error)]
38pub enum GitBridgeError {
39 #[error("git error: {0}")]
40 Git(String),
41
42 #[error("store error: {0}")]
43 Store(#[from] HeddleError),
44
45 #[error("io error: {0}")]
46 Io(#[from] std::io::Error),
47
48 #[error("invalid trailer format: {0}")]
49 InvalidTrailer(String),
50
51 #[error("missing required trailer: {0}")]
52 MissingTrailer(String),
53
54 #[error("invalid mapping: {0}")]
55 InvalidMapping(String),
56
57 #[error("commit not found: {0}")]
58 CommitNotFound(String),
59
60 #[error("state not found: {0}")]
61 StateNotFound(ChangeId),
62
63 #[error("git repository not initialized")]
64 GitRepoNotInitialized,
65
66 #[error(
67 "shallow Git repository at {repository} cannot be imported until full ancestry is available"
68 )]
69 ShallowClone {
70 repository: PathBuf,
71 retry_command: String,
72 },
73
74 #[error("conflict during sync: {0}")]
75 Conflict(String),
76
77 #[error("Git branch '{branch}' cannot be imported as a Heddle thread: {message}")]
78 InvalidThreadName { branch: String, message: String },
79
80 #[error(
81 "Git branch {branch} and Heddle thread {thread} diverged: thread {thread_change}, branch {branch_change}"
82 )]
83 GitHeddleThreadDiverged {
84 thread: String,
85 branch: String,
86 thread_change: ChangeId,
87 branch_change: ChangeId,
88 },
89
90 #[error(
91 "ref update would rewrite {name}: {old} -> {new}; refusing to replace a user-visible Git commit with a Heddle export commit"
92 )]
93 NonFastForwardRef {
94 name: String,
95 old: ObjectId,
96 new: ObjectId,
97 },
98
99 #[error(
100 "remote branch {upstream} does not fast-forward the local Git checkpoint for {branch}: local {local}, remote {remote}"
101 )]
102 RemoteDiverged {
103 branch: String,
104 upstream: String,
105 local: ObjectId,
106 remote: ObjectId,
107 },
108
109 #[error("change id parse error: {0}")]
110 ChangeIdParse(#[from] ChangeIdParseError),
111}
112
113pub type GitResult<T> = std::result::Result<T, GitBridgeError>;
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub(crate) enum RefNamespace {
118 Branch,
119 Tag,
120 Note,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub(crate) struct RefUpdate {
127 pub name: String,
128 pub target: ObjectId,
129 pub namespace: RefNamespace,
130}
131
132pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git";
138
139pub(crate) fn is_reserved_git_remote_name(remote: &str) -> bool {
145 remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
146}
147
148fn reject_reserved_git_remote_name(remote: &str) -> GitResult<()> {
155 if is_reserved_git_remote_name(remote) {
156 return Err(GitBridgeError::Git(format!(
157 "a Git remote named '{remote}' collides with heddle's reserved namespace \
158 (local refs are recorded under the '{REMOTE_NAME_FOR_LOCAL_GIT_REPO}' sentinel); \
159 rename the remote (e.g. `git remote rename {remote} origin`) and retry"
160 )));
161 }
162 Ok(())
163}
164
165fn remote_name_from_remote_ref(ref_name: &str) -> Option<&str> {
166 let remote_and_name = ref_name.strip_prefix("refs/remotes/")?;
167 let remote = remote_and_name
168 .split_once('/')
169 .map_or(remote_and_name, |(remote, _)| remote);
170 (!remote.is_empty()).then_some(remote)
171}
172
173fn validate_refspec_ref(ref_name: &str) -> GitResult<()> {
174 if let Some(remote) = remote_name_from_remote_ref(ref_name) {
175 reject_reserved_git_remote_name(remote)?;
176 }
177 Ok(())
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum GitRefKind {
184 Branch,
186 Tag,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct ParsedGitRef<'a> {
195 pub kind: GitRefKind,
196 pub name: &'a str,
199 pub remote: &'a str,
202}
203
204pub fn parse_git_ref(ref_name: &str) -> Option<ParsedGitRef<'_>> {
211 RefSpec::new(None, ref_name, false).ok()?;
212
213 if let Some(name) = ref_name.strip_prefix("refs/heads/") {
214 (name != "HEAD").then_some(ParsedGitRef {
216 kind: GitRefKind::Branch,
217 name,
218 remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
219 })
220 } else if let Some(remote_and_name) = ref_name.strip_prefix("refs/remotes/") {
221 let (remote, name) = remote_and_name.split_once('/')?;
222 (name != "HEAD").then_some(ParsedGitRef {
232 kind: GitRefKind::Branch,
233 name,
234 remote,
235 })
236 } else {
237 ref_name
238 .strip_prefix("refs/tags/")
239 .map(|name| ParsedGitRef {
240 kind: GitRefKind::Tag,
241 name,
242 remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
243 })
244 }
245}
246
247mod refspec {
250 use super::{GitResult, validate_refspec_ref};
251
252 #[derive(Debug, Clone, PartialEq, Eq)]
253 pub struct RefSpec {
254 forced: bool,
255 source: Option<String>,
257 destination: String,
258 }
259
260 impl RefSpec {
261 pub fn new(
263 source: Option<String>,
264 destination: impl Into<String>,
265 forced: bool,
266 ) -> GitResult<Self> {
267 let destination = destination.into();
268 if source.is_none() && destination.is_empty() {
269 return Err(super::GitBridgeError::InvalidMapping(
270 "refspec source and destination cannot both be empty".to_string(),
271 ));
272 }
273 if let Some(source) = source.as_deref() {
274 validate_refspec_ref(source)?;
275 }
276 validate_refspec_ref(&destination)?;
277 Ok(Self {
278 forced,
279 source,
280 destination,
281 })
282 }
283
284 pub fn forced(
286 source: impl Into<String>,
287 destination: impl Into<String>,
288 ) -> GitResult<Self> {
289 Self::new(Some(source.into()), destination, true)
290 }
291
292 pub fn delete(destination: impl Into<String>) -> GitResult<Self> {
295 Self::new(None, destination, false)
296 }
297
298 pub fn to_git_format(&self) -> String {
300 format!(
301 "{}{}",
302 if self.forced { "+" } else { "" },
303 self.to_git_format_not_forced()
304 )
305 }
306
307 pub fn to_git_format_not_forced(&self) -> String {
309 format!(
310 "{}:{}",
311 self.source.as_deref().unwrap_or(""),
312 self.destination
313 )
314 }
315 }
316}
317
318pub use refspec::RefSpec;
319
320mod negative_refspec {
323 use super::{GitBridgeError, GitResult, validate_refspec_ref};
324
325 #[derive(Debug, Clone, PartialEq, Eq)]
326 pub struct NegativeRefSpec {
327 source: String,
328 }
329
330 impl NegativeRefSpec {
331 pub fn new(source: impl Into<String>) -> GitResult<Self> {
334 let source = source.into();
335 validate_refspec_ref(&source)?;
336 if source.contains('*') {
337 return Err(GitBridgeError::InvalidMapping(format!(
338 "invalid negative refspec source '{source}': Negative glob patterns are not supported"
339 )));
340 }
341 Ok(Self { source })
342 }
343
344 pub fn to_git_format(&self) -> String {
346 format!("^{}", self.source)
347 }
348 }
349}
350
351pub use negative_refspec::NegativeRefSpec;
355
356fn heddle_mirror_fetch_refspecs() -> GitResult<[String; 2]> {
360 Ok([
361 RefSpec::forced("refs/heads/*", "refs/heads/*")?.to_git_format(),
362 RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format(),
363 ])
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum GitPushScope {
368 CurrentThread,
369 AllThreads,
370}
371
372#[derive(Debug, Clone, Default)]
373pub struct GitPullOutcome {
374 pub changed: bool,
375 pub states_created: usize,
376 pub commits_seen: usize,
377 pub materialized_checkout: bool,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381enum PullPreflight {
382 UpToDate,
383 ImportRequired,
384}
385
386fn pull_outcome(stats: &ImportStats, materialized_checkout: bool) -> GitPullOutcome {
387 GitPullOutcome {
388 changed: materialized_checkout || stats.states_created > 0,
389 states_created: stats.states_created,
390 commits_seen: stats.commits_imported,
391 materialized_checkout,
392 }
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396enum GitFetchScope {
397 BranchesAndNotes,
398 AllRefs,
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402enum RefreshCheckoutAfterFetch {
403 Yes,
404 No,
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408enum RemoteDirection {
409 Fetch,
410 Push,
411}
412
413#[derive(Debug, Clone)]
414enum ResolvedRemote {
415 Local(PathBuf),
416 Url(String),
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum WriteThroughSkipReason {
421 MissingDotGit,
422 DetachedHead,
423 NoAttachedThread,
424 NoMappedCommit,
425 MirrorIsWorktree,
426 IndexAlreadyDirty,
427}
428
429impl std::fmt::Display for WriteThroughSkipReason {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 match self {
432 WriteThroughSkipReason::MissingDotGit => {
433 write!(f, "this checkout does not have a Git working tree")
434 }
435 WriteThroughSkipReason::DetachedHead => {
436 write!(f, "Git HEAD is detached")
437 }
438 WriteThroughSkipReason::NoAttachedThread => {
439 write!(f, "the attached Heddle thread does not resolve to a state")
440 }
441 WriteThroughSkipReason::NoMappedCommit => {
442 write!(f, "the current Heddle state has not been exported to Git")
443 }
444 WriteThroughSkipReason::MirrorIsWorktree => {
445 write!(f, "the Git mirror is already the active checkout")
446 }
447 WriteThroughSkipReason::IndexAlreadyDirty => {
448 write!(f, "the Git index is already locked by another operation")
449 }
450 }
451 }
452}
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub enum WriteThroughOutcome {
456 Wrote(ObjectId),
457 Skipped(WriteThroughSkipReason),
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub(crate) struct LocalGitIdentity {
462 pub(crate) name: String,
463 pub(crate) email: String,
464}
465
466impl LocalGitIdentity {
467 pub(crate) fn from_principal(principal: &Principal) -> Self {
468 Self {
469 name: principal.name.clone(),
470 email: principal.email.clone(),
471 }
472 }
473
474 pub(crate) fn to_ident_line(&self, seconds: i64) -> Vec<u8> {
475 format!("{} <{}> {} +0000", self.name, self.email, seconds).into_bytes()
476 }
477
478 pub(crate) fn to_signature(&self, seconds: i64) -> Signature {
479 let ident = self.to_ident_line(seconds);
480 Signature {
481 name: GitByteString::new(self.name.as_bytes().to_vec()),
482 email: GitByteString::new(self.email.as_bytes().to_vec()),
483 time: GitTime::new(seconds, 0),
484 raw: ident,
485 }
486 }
487}
488
489impl WriteThroughOutcome {
490 pub fn object_id(self) -> Option<ObjectId> {
491 match self {
492 WriteThroughOutcome::Wrote(oid) => Some(oid),
493 WriteThroughOutcome::Skipped(_) => None,
494 }
495 }
496
497 pub fn skip_reason(self) -> Option<WriteThroughSkipReason> {
498 match self {
499 WriteThroughOutcome::Skipped(reason) => Some(reason),
500 WriteThroughOutcome::Wrote(_) => None,
501 }
502 }
503}
504
505#[derive(Debug, Clone, Default, PartialEq, Eq)]
507pub struct SyncMapping {
508 heddle_to_git: HashMap<ChangeId, ObjectId>,
510 git_to_heddle: HashMap<ObjectId, ChangeId>,
512}
513
514impl SyncMapping {
515 pub fn new() -> Self {
517 Self::default()
518 }
519
520 pub fn insert(&mut self, change_id: ChangeId, git_oid: ObjectId) {
522 if let Some(previous_git) = self.heddle_to_git.remove(&change_id) {
523 self.git_to_heddle.remove(&previous_git);
524 }
525 if let Some(previous_change) = self.git_to_heddle.remove(&git_oid) {
526 self.heddle_to_git.remove(&previous_change);
527 }
528 self.heddle_to_git.insert(change_id, git_oid);
529 self.git_to_heddle.insert(git_oid, change_id);
530 }
531
532 pub(crate) fn insert_checked(
534 &mut self,
535 change_id: ChangeId,
536 git_oid: ObjectId,
537 ) -> GitResult<()> {
538 if let Some(existing) = self.heddle_to_git.get(&change_id)
539 && *existing != git_oid
540 {
541 return Err(GitBridgeError::Conflict(format!(
542 "change id {} mapped to {} (new {})",
543 change_id, existing, git_oid
544 )));
545 }
546
547 if let Some(existing) = self.git_to_heddle.get(&git_oid)
548 && *existing != change_id
549 {
550 return Err(GitBridgeError::Conflict(format!(
551 "git oid {} mapped to {} (new {})",
552 git_oid, existing, change_id
553 )));
554 }
555
556 self.insert(change_id, git_oid);
557 Ok(())
558 }
559
560 pub fn get_git(&self, change_id: &ChangeId) -> Option<ObjectId> {
562 self.heddle_to_git.get(change_id).copied()
563 }
564
565 pub fn get_heddle(&self, git_oid: ObjectId) -> Option<ChangeId> {
567 self.git_to_heddle.get(&git_oid).copied()
568 }
569
570 pub fn has_heddle(&self, change_id: &ChangeId) -> bool {
572 self.heddle_to_git.contains_key(change_id)
573 }
574
575 pub(crate) fn remove(&mut self, change_id: &ChangeId) -> Option<ObjectId> {
585 let git_oid = self.heddle_to_git.remove(change_id)?;
586 self.git_to_heddle.remove(&git_oid);
587 Some(git_oid)
588 }
589
590 pub fn has_git(&self, git_oid: ObjectId) -> bool {
592 self.git_to_heddle.contains_key(&git_oid)
593 }
594
595 pub(crate) fn iter(&self) -> impl Iterator<Item = (&ChangeId, &ObjectId)> {
597 self.heddle_to_git.iter()
598 }
599
600 pub(crate) fn is_empty(&self) -> bool {
605 self.heddle_to_git.is_empty()
606 }
607
608 pub(crate) fn retain_git_objects(&mut self, repo: &SleyRepository) {
609 let retained: Vec<(ChangeId, ObjectId)> = self
610 .heddle_to_git
611 .iter()
612 .filter_map(|(change_id, git_oid)| {
613 repo.read_object(git_oid)
614 .ok()
615 .map(|_| (*change_id, *git_oid))
616 })
617 .collect();
618
619 self.heddle_to_git.clear();
620 self.git_to_heddle.clear();
621 for (change_id, git_oid) in retained {
622 self.insert(change_id, git_oid);
623 }
624 }
625
626 #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
627 pub(crate) fn retain_git_object_set(&mut self, reachable: &HashSet<ObjectId>) -> usize {
628 let before = self.heddle_to_git.len();
629 let retained: Vec<(ChangeId, ObjectId)> = self
630 .heddle_to_git
631 .iter()
632 .filter(|(_, git_oid)| reachable.contains(*git_oid))
633 .map(|(change_id, git_oid)| (*change_id, *git_oid))
634 .collect();
635
636 self.heddle_to_git.clear();
637 self.git_to_heddle.clear();
638 for (change_id, git_oid) in retained {
639 self.insert(change_id, git_oid);
640 }
641 before.saturating_sub(self.heddle_to_git.len())
642 }
643}
644
645pub struct GitBridge<'a> {
647 pub(crate) heddle_repo: &'a HeddleRepository,
648 pub(crate) git_repo_path: Option<PathBuf>,
649 pub(crate) mapping: SyncMapping,
650 pub(crate) commit_message_overrides: HashMap<ChangeId, String>,
651 pub(crate) commit_parent_overrides: HashMap<ChangeId, Vec<ObjectId>>,
652}
653
654struct MappingFileSnapshot {
655 path: PathBuf,
656 contents: Option<Vec<u8>>,
657}
658
659impl MappingFileSnapshot {
660 fn read(path: PathBuf) -> GitResult<Self> {
661 let contents = match fs::read(&path) {
662 Ok(contents) => Some(contents),
663 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
664 Err(error) => return Err(error.into()),
665 };
666 Ok(Self { path, contents })
667 }
668
669 fn restore(self) -> GitResult<()> {
670 match self.contents {
671 Some(contents) => {
672 if let Some(parent) = self.path.parent() {
673 fs::create_dir_all(parent)?;
674 }
675 fs::write(&self.path, contents)?;
676 }
677 None => match fs::remove_file(&self.path) {
678 Ok(()) => {}
679 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
680 Err(error) => return Err(error.into()),
681 },
682 }
683 Ok(())
684 }
685}
686
687impl<'a> GitBridge<'a> {
688 pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
690 Self {
691 heddle_repo,
692 git_repo_path: None,
693 mapping: SyncMapping::new(),
694 commit_message_overrides: HashMap::new(),
695 commit_parent_overrides: HashMap::new(),
696 }
697 }
698
699 pub fn init_mirror(&mut self) -> GitResult<()> {
701 let _guard = self.init_mirror_with_guard()?;
702 _guard.commit();
703 Ok(())
704 }
705
706 pub(crate) fn init_mirror_with_guard(&mut self) -> GitResult<MirrorInitGuard> {
711 let git_dir = self.heddle_repo.heddle_dir().join("git");
712
713 let did_create = if git_dir.exists() {
714 let _ = open_repo(&git_dir)?;
715 false
716 } else {
717 fs::create_dir_all(&git_dir)?;
718 let _ = SleyRepository::init_bare(&git_dir).map_err(git_err)?;
719 let mirror_repo = open_repo(&git_dir)?;
720 seed_checkout_note_refs_into_mirror(self.heddle_repo.root(), &mirror_repo)?;
721 true
722 };
723
724 self.git_repo_path = Some(git_dir.clone());
725 Ok(MirrorInitGuard::new_from_init(git_dir, did_create))
726 }
727
728 pub fn mirror_path(&self) -> PathBuf {
730 self.heddle_repo.heddle_dir().join("git")
731 }
732
733 pub fn is_initialized(&self) -> bool {
735 self.mirror_path().exists()
736 }
737
738 pub(crate) fn open_git_repo(&self) -> GitResult<SleyRepository> {
740 if let Some(ref path) = self.git_repo_path {
741 open_repo(path)
742 } else {
743 let mirror_path = self.mirror_path();
744 if mirror_path.exists() {
745 open_repo(&mirror_path)
746 } else {
747 open_repo(self.heddle_repo.root())
748 }
749 }
750 }
751
752 pub(crate) fn sort_states_topologically(
754 &self,
755 states: &[ChangeId],
756 ) -> GitResult<Vec<ChangeId>> {
757 let mut sorted = Vec::new();
758 let mut visited: std::collections::HashSet<ChangeId> = std::collections::HashSet::new();
759
760 fn visit<S: ObjectStore + ?Sized>(
761 state_id: &ChangeId,
762 store: &S,
763 visited: &mut std::collections::HashSet<ChangeId>,
764 sorted: &mut Vec<ChangeId>,
765 ) -> GitResult<()> {
766 if visited.contains(state_id) {
767 return Ok(());
768 }
769
770 if let Some(state) = store.get_state(state_id)? {
771 for parent in &state.parents {
772 visit(parent, store, visited, sorted)?;
773 }
774 }
775
776 visited.insert(*state_id);
777 sorted.push(*state_id);
778
779 Ok(())
780 }
781
782 for state_id in states {
783 visit(
784 state_id,
785 self.heddle_repo.store(),
786 &mut visited,
787 &mut sorted,
788 )?;
789 }
790
791 Ok(sorted)
792 }
793
794 pub fn export(&mut self) -> GitResult<super::git_util::ExportStats> {
796 export_all(self)
797 }
798
799 pub(crate) fn set_commit_message_override(&mut self, state_id: ChangeId, message: String) {
800 self.commit_message_overrides.insert(state_id, message);
801 }
802
803 pub(crate) fn set_commit_parent_override(
804 &mut self,
805 state_id: ChangeId,
806 parents: Vec<ObjectId>,
807 ) {
808 self.commit_parent_overrides.insert(state_id, parents);
809 }
810
811 pub(crate) fn with_mapping_rollback<T>(
812 &mut self,
813 operation: impl FnOnce(&mut Self) -> GitResult<T>,
814 ) -> GitResult<T> {
815 let mapping = self.mapping.clone();
816 let commit_message_overrides = self.commit_message_overrides.clone();
817 let commit_parent_overrides = self.commit_parent_overrides.clone();
818 let mapping_file = MappingFileSnapshot::read(self.mapping_path())?;
819 let mapping_tmp_file = MappingFileSnapshot::read(self.mapping_tmp_path())?;
820
821 match operation(self) {
822 Ok(value) => Ok(value),
823 Err(error) => {
824 self.mapping = mapping;
825 self.commit_message_overrides = commit_message_overrides;
826 self.commit_parent_overrides = commit_parent_overrides;
827 if let Err(rollback_error) = mapping_file
828 .restore()
829 .and_then(|()| mapping_tmp_file.restore())
830 {
831 return Err(GitBridgeError::Git(format!(
832 "operation failed ({error}); additionally failed to roll back git bridge mapping state ({rollback_error})"
833 )));
834 }
835 Err(error)
836 }
837 }
838 }
839
840 pub fn push(&mut self, remote_name: &str) -> GitResult<Vec<String>> {
843 self.push_with_scope(remote_name, GitPushScope::AllThreads)
844 }
845
846 pub fn push_with_scope(
849 &mut self,
850 remote_name: &str,
851 scope: GitPushScope,
852 ) -> GitResult<Vec<String>> {
853 self.push_with_scope_force(remote_name, scope, false)
854 }
855
856 pub fn push_with_scope_force(
865 &mut self,
866 remote_name: &str,
867 scope: GitPushScope,
868 force: bool,
869 ) -> GitResult<Vec<String>> {
870 self.init_mirror()?;
871 let current_branch = match scope {
872 GitPushScope::CurrentThread => Some(self.current_attached_thread_for_push()?),
873 GitPushScope::AllThreads => None,
874 };
875 match scope {
876 GitPushScope::CurrentThread => {
877 export_current_thread(self, current_branch.as_deref().expect("current branch"))?;
878 }
879 GitPushScope::AllThreads => {
880 self.export()?;
881 self.mirror_checkout_tags_for_push()?;
882 }
883 }
884 self.write_current_checkout_from_existing_mirror()?;
885
886 let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
894 match self.resolve_remote(remote_name, RemoteDirection::Push)? {
895 ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
896 &target_path,
897 &log_message,
898 false,
899 scope,
900 current_branch.as_deref(),
901 force,
902 ),
903 ResolvedRemote::Url(url) => {
904 let mirror_repo = self.open_git_repo()?;
905 push_network_remote(
906 &mirror_repo,
907 self.heddle_repo.heddle_dir(),
908 &url,
909 scope,
910 current_branch.as_deref(),
911 force,
912 )
913 }
914 }
915 }
916
917 fn current_attached_thread_for_push(&self) -> GitResult<String> {
918 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
919 return Err(GitBridgeError::Git(
920 "cannot push the current Git-overlay branch from a detached Heddle HEAD; use --all-threads to push all exported refs".to_string(),
921 ));
922 };
923 if self.heddle_repo.refs().get_thread(&thread)?.is_none() {
924 return Err(GitBridgeError::Git(format!(
925 "attached thread '{thread}' has no state to push"
926 )));
927 }
928 Ok(thread.to_string())
929 }
930
931 pub fn export_to_path(
935 &mut self,
936 target_path: &Path,
937 ) -> GitResult<super::git_util::ExportStats> {
938 self.init_mirror()?;
939 let stats = self.export()?;
940 self.copy_mirror_to_path(
941 target_path,
942 &format!("heddle: export from {}", self.heddle_repo.root().display()),
943 true,
944 GitPushScope::AllThreads,
945 None,
946 false,
947 )?;
948 Ok(stats)
949 }
950
951 fn copy_mirror_to_path(
960 &mut self,
961 target_path: &Path,
962 log_message: &str,
963 init_if_missing: bool,
964 scope: GitPushScope,
965 current_branch: Option<&str>,
966 force: bool,
967 ) -> GitResult<Vec<String>> {
968 let mirror_repo = self.open_git_repo()?;
969 let target_repo = if target_path.exists() {
970 open_repo(target_path)?
971 } else if init_if_missing {
972 fs::create_dir_all(target_path)?;
973 SleyRepository::init_bare(target_path).map_err(git_err)?;
974 open_repo(target_path)?
975 } else {
976 return Err(GitBridgeError::Git(format!(
977 "destination '{}' does not exist",
978 target_path.display()
979 )));
980 };
981
982 let managed_record = read_mirror_managed_refs(&mirror_repo)?;
996 let served_frontier = collect_managed_ref_updates(&mirror_repo, &managed_record)?;
997 copy_reachable_objects(
998 &mirror_repo,
999 &target_repo,
1000 served_frontier.iter().map(|update| update.target),
1001 )?;
1002
1003 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
1011 let old_at_destination = read_destination_ref_map(&target_repo)?;
1012 let previously_exported = read_exported_refs(&target_repo)?;
1013 let plan = plan_destination_reconcile(
1014 &mirror_repo,
1015 &served_frontier,
1016 creatable.as_ref(),
1017 &old_at_destination,
1018 &previously_exported,
1019 force,
1020 )?;
1021 for write in &plan.writes {
1022 let constraint = match write.old {
1023 Some(old) => RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(old)),
1024 None => RefPrecondition::MustNotExist,
1025 };
1026 set_reference(
1027 &target_repo,
1028 &write.full_name,
1029 write.new,
1030 constraint,
1031 log_message,
1032 )?;
1033 }
1034 for delete in &plan.deletes {
1035 delete_reference_matching(&target_repo, &delete.full_name, delete.old)?;
1036 }
1037 write_exported_refs(&target_repo, &plan.new_manifest)?;
1038 Ok(planned_write_names(&plan))
1039 }
1040
1041 pub fn fetch(&mut self, remote_name: &str) -> GitResult<()> {
1044 self.fetch_with_scope(
1045 remote_name,
1046 GitFetchScope::BranchesAndNotes,
1047 RefreshCheckoutAfterFetch::Yes,
1048 )
1049 }
1050
1051 fn fetch_with_scope(
1052 &mut self,
1053 remote_name: &str,
1054 scope: GitFetchScope,
1055 refresh_checkout: RefreshCheckoutAfterFetch,
1056 ) -> GitResult<()> {
1057 reject_reserved_git_remote_name(remote_name)?;
1058 self.init_mirror()?;
1059 let current_branch = self.heddle_repo.git_overlay_current_branch()?;
1060 let tracking_remote = checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1061 .or_else(|| {
1062 (!looks_like_remote_location(remote_name)).then(|| remote_name.to_string())
1063 });
1064 if let Some(tracking_remote) = tracking_remote.as_deref() {
1068 reject_reserved_git_remote_name(tracking_remote)?;
1069 }
1070
1071 let mirror_repo = self.open_git_repo()?;
1072 match self.resolve_remote(remote_name, RemoteDirection::Fetch)? {
1073 ResolvedRemote::Local(path) => {
1074 let remote_repo = open_repo(&path)?;
1075 let updates = collect_ref_updates_for_fetch(&remote_repo, scope)?;
1076 tracing::debug!(
1077 remote = remote_name,
1078 path = %path.display(),
1079 refs = updates.len(),
1080 notes = updates
1081 .iter()
1082 .filter(|update| update.namespace == RefNamespace::Note)
1083 .count(),
1084 "fetching Git refs from local remote"
1085 );
1086 copy_reachable_objects(
1087 &remote_repo,
1088 &mirror_repo,
1089 updates.iter().map(|update| update.target),
1090 )?;
1091 apply_ref_updates(
1092 &mirror_repo,
1093 &updates,
1094 &format!("heddle: fetch from {remote_name}"),
1095 )?;
1096 if let Some(tracking_remote) = tracking_remote.as_deref() {
1097 apply_remote_tracking_ref_updates(
1098 &mirror_repo,
1099 tracking_remote,
1100 &updates,
1101 &format!("heddle: fetch from {remote_name}"),
1102 )?;
1103 }
1104 }
1105 ResolvedRemote::Url(url) => {
1106 fetch_network_remote(&mirror_repo, remote_name, &url, scope)?;
1107 let updates = collect_ref_updates_for_fetch(&mirror_repo, scope)?;
1108 if let Some(tracking_remote) = tracking_remote.as_deref() {
1109 apply_remote_tracking_ref_updates(
1110 &mirror_repo,
1111 tracking_remote,
1112 &updates,
1113 &format!("heddle: fetch from {remote_name}"),
1114 )?;
1115 }
1116 }
1117 }
1118
1119 self.git_repo_path = Some(self.mirror_path());
1120 if matches!(refresh_checkout, RefreshCheckoutAfterFetch::Yes) {
1121 if let Some(tracking_remote) = tracking_remote.as_deref() {
1122 self.refresh_checkout_remote_tracking_refs(tracking_remote)?;
1123 }
1124 if let Some(branch) = current_branch {
1125 self.refresh_checkout_remote_tracking_ref(remote_name, &branch)?;
1126 }
1127 self.refresh_checkout_note_refs_from_mirror()?;
1128 }
1129 Ok(())
1130 }
1131
1132 pub(crate) fn hydrate_checkout_heddle_notes_without_mirror(root: &Path) -> bool {
1140 if checkout_note_ref_exists(root).unwrap_or(false) {
1141 return true;
1142 }
1143
1144 let mut remotes = match checkout_remote_url_items(root) {
1145 Ok(remotes) => remotes
1146 .into_iter()
1147 .map(|(name, _)| name)
1148 .collect::<Vec<_>>(),
1149 Err(error) => {
1150 tracing::debug!(
1151 error = %error,
1152 "skipping configured remote note hydration before ingest-backed adopt"
1153 );
1154 return false;
1155 }
1156 };
1157 remotes.sort_by(|left, right| {
1158 match (left.as_str() == "origin", right.as_str() == "origin") {
1159 (true, false) => std::cmp::Ordering::Less,
1160 (false, true) => std::cmp::Ordering::Greater,
1161 _ => left.cmp(right),
1162 }
1163 });
1164 remotes.dedup();
1165
1166 for remote in remotes {
1167 match hydrate_checkout_notes_from_remote_without_mirror(root, &remote) {
1168 Ok(()) if checkout_note_ref_exists(root).unwrap_or(false) => return true,
1169 Ok(()) => {}
1170 Err(error) => {
1171 tracing::debug!(
1172 remote = remote.as_str(),
1173 error = %error,
1174 "configured remote did not provide Heddle notes during ingest-backed adopt"
1175 );
1176 }
1177 }
1178 }
1179
1180 false
1181 }
1182
1183 pub fn pull(&mut self, remote_name: &str) -> GitResult<GitPullOutcome> {
1185 let head_before = self.heddle_repo.refs().read_head()?;
1186 let attached_before = match &head_before {
1187 Head::Attached { thread } => self
1188 .heddle_repo
1189 .refs()
1190 .get_thread(thread)?
1191 .map(|state| (thread.to_string(), state)),
1192 Head::Detached { .. } => None,
1193 };
1194 let attached_thread = attached_before.as_ref().map(|(thread, _)| thread.clone());
1195
1196 self.fetch_with_scope(
1197 remote_name,
1198 GitFetchScope::AllRefs,
1199 RefreshCheckoutAfterFetch::No,
1200 )?;
1201 if self.preflight_attached_pull_fast_forward(remote_name, attached_before.as_ref())?
1202 == PullPreflight::UpToDate
1203 {
1204 if let Some(thread) = attached_thread {
1205 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1206 }
1207 self.refresh_checkout_note_refs_from_mirror()?;
1208 return Ok(GitPullOutcome::default());
1209 }
1210 let mirror_path = self.mirror_path();
1211 let stats = import_git_history(self, Some(&mirror_path), &[], Default::default(), None)?;
1212
1213 let mut materialized_attached_thread = false;
1214 if let Some((thread, old_state)) = attached_before
1215 && let Some(new_state) = self
1216 .heddle_repo
1217 .refs()
1218 .get_thread(&ThreadName::new(&thread))?
1219 && new_state != old_state
1220 {
1221 self.heddle_repo
1222 .refs()
1223 .set_thread(&ThreadName::new(&thread), &old_state)?;
1224 self.heddle_repo.refs().write_head(&Head::Attached {
1225 thread: ThreadName::new(&thread),
1226 })?;
1227 self.heddle_repo
1228 .goto_verified_clean_without_record(&new_state)?;
1229 self.heddle_repo
1230 .refs()
1231 .set_thread(&ThreadName::new(&thread), &new_state)?;
1232 self.heddle_repo.refs().write_head(&Head::Attached {
1233 thread: ThreadName::new(&thread),
1234 })?;
1235 materialized_attached_thread = true;
1236 }
1237
1238 if materialized_attached_thread {
1239 self.write_current_checkout_from_existing_mirror()?;
1240 }
1241 if let Some(thread) = attached_thread {
1242 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1243 }
1244 self.refresh_checkout_note_refs_from_mirror()?;
1245 Ok(pull_outcome(&stats, materialized_attached_thread))
1246 }
1247
1248 fn preflight_attached_pull_fast_forward(
1249 &mut self,
1250 remote_name: &str,
1251 attached_before: Option<&(String, ChangeId)>,
1252 ) -> GitResult<PullPreflight> {
1253 let Some((thread, state_id)) = attached_before else {
1254 return Ok(PullPreflight::ImportRequired);
1255 };
1256 self.build_existing_mapping(None)?;
1257 let Some(local_git_oid) = self.mapping.get_git(state_id) else {
1258 return Ok(PullPreflight::ImportRequired);
1259 };
1260 let mirror_repo = self.open_git_repo()?;
1261 let branch_ref = format!("refs/heads/{thread}");
1262 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1263 return Ok(PullPreflight::ImportRequired);
1264 };
1265 let Some(remote_git_oid) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1266 return Ok(PullPreflight::ImportRequired);
1267 };
1268 if remote_git_oid == local_git_oid {
1269 return Ok(PullPreflight::UpToDate);
1270 }
1271 if commit_is_descendant_of(&mirror_repo, remote_git_oid, local_git_oid)? {
1272 return Ok(PullPreflight::ImportRequired);
1273 }
1274 Err(GitBridgeError::RemoteDiverged {
1275 branch: thread.clone(),
1276 upstream: format!("{remote_name}/{thread}"),
1277 local: local_git_oid,
1278 remote: remote_git_oid,
1279 })
1280 }
1281
1282 fn mirror_checkout_tags_for_push(&self) -> GitResult<()> {
1283 if !self.heddle_repo.root().join(".git").exists() {
1284 return Ok(());
1285 }
1286
1287 let mirror_repo = self.open_git_repo()?;
1288 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1289 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1290 return Ok(());
1291 }
1292 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1293 let tag_updates = collect_ref_updates(&object_repo)?
1294 .into_iter()
1295 .filter(|update| update.namespace == RefNamespace::Tag)
1296 .collect::<Vec<_>>();
1297 if tag_updates.is_empty() {
1298 return Ok(());
1299 }
1300
1301 copy_reachable_objects(
1302 &object_repo,
1303 &mirror_repo,
1304 tag_updates.iter().map(|u| u.target),
1305 )?;
1306 apply_ref_updates(
1307 &mirror_repo,
1308 &tag_updates,
1309 "heddle: mirror checkout tags before push",
1310 )?;
1311 let mut record = read_mirror_managed_refs(&mirror_repo)?;
1319 for update in &tag_updates {
1320 record.insert(full_ref_name(update), update.target);
1321 }
1322 write_mirror_managed_refs(&mirror_repo, &record)?;
1323 Ok(())
1324 }
1325
1326 pub(crate) fn seed_git_checkpoint_mappings_from_checkout(
1327 &mut self,
1328 mirror_repo: &SleyRepository,
1329 ) -> GitResult<()> {
1330 if !self.heddle_repo.root().join(".git").exists() {
1331 return Ok(());
1332 }
1333
1334 let checkout_repo = match SleyRepository::discover(self.heddle_repo.root()) {
1335 Ok(repo) => repo,
1336 Err(_) => return Ok(()),
1337 };
1338 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1339 return Ok(());
1340 }
1341 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1342
1343 for record in self.heddle_repo.list_git_checkpoints()? {
1344 let change_id = ChangeId::parse(&record.change_id)?;
1345 let git_oid = record
1346 .git_commit
1347 .parse::<ObjectId>()
1348 .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))?;
1349
1350 if mirror_repo.read_object(&git_oid).is_err() {
1351 copy_reachable_objects(&object_repo, mirror_repo, [git_oid])?;
1352 }
1353 mirror_repo
1354 .read_object(&git_oid)
1355 .map_err(|_| GitBridgeError::CommitNotFound(record.git_commit.clone()))?;
1356
1357 self.mapping.insert(change_id, git_oid);
1358 let tier = self
1367 .heddle_repo
1368 .effective_visibility_tier(&change_id)
1369 .map_err(|e| {
1370 GitBridgeError::Git(format!("resolve visibility for {change_id}: {e:#}"))
1371 })?;
1372 if repo::visible(&tier, &repo::AudienceTier::Public)
1373 && super::git_notes::read_note(mirror_repo, git_oid)?.is_none()
1374 && let Some(state) = self.heddle_repo.store().get_state(&change_id)?
1375 {
1376 let note = super::git_notes::HeddleNote::from_state(&state);
1377 super::git_notes::write_note(mirror_repo, git_oid, ¬e)?;
1378 }
1379 }
1380
1381 Ok(())
1382 }
1383
1384 pub(crate) fn stage_ingest_source_in_mirror(
1385 &mut self,
1386 source: &Path,
1387 refs: &[String],
1388 ) -> GitResult<()> {
1389 let source_repo = open_repo(source)?;
1390 let updates = collect_import_source_ref_updates(&source_repo, refs)?;
1391 if updates.is_empty() {
1392 return Ok(());
1393 }
1394
1395 self.init_mirror()?;
1396 let mirror_repo = self.open_git_repo()?;
1397 copy_reachable_objects(
1398 &source_repo,
1399 &mirror_repo,
1400 updates.iter().map(|update| update.target),
1401 )?;
1402 apply_ref_updates(
1403 &mirror_repo,
1404 &updates,
1405 &format!("heddle: stage ingest source from {}", source.display()),
1406 )?;
1407
1408 let mut record = read_or_seed_mirror_managed_refs(&mirror_repo)?;
1409 for update in &updates {
1410 record.insert(full_ref_name(update), update.target);
1411 }
1412 write_mirror_managed_refs(&mirror_repo, &record)?;
1413 Ok(())
1414 }
1415
1416 pub fn write_through_current_checkout(&mut self) -> GitResult<WriteThroughOutcome> {
1421 if !self.heddle_repo.root().join(".git").exists() {
1422 return Ok(WriteThroughOutcome::Skipped(
1423 WriteThroughSkipReason::MissingDotGit,
1424 ));
1425 }
1426 if checkout_git_head_is_detached(self.heddle_repo.root())? {
1427 return Ok(WriteThroughOutcome::Skipped(
1428 WriteThroughSkipReason::DetachedHead,
1429 ));
1430 }
1431 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1432 return Ok(WriteThroughOutcome::Skipped(
1433 WriteThroughSkipReason::DetachedHead,
1434 ));
1435 };
1436
1437 let mirror_guard = self.init_mirror_with_guard()?;
1438 export_current_thread(self, &thread)?;
1449 mirror_guard.commit();
1453 self.write_thread_checkout_from_existing_mirror(&thread)
1454 }
1455
1456 pub fn write_through_current_checkout_with_message(
1457 &mut self,
1458 state_id: ChangeId,
1459 message: String,
1460 ) -> GitResult<WriteThroughOutcome> {
1461 self.set_commit_message_override(state_id, message);
1462 self.write_through_current_checkout()
1463 }
1464
1465 pub fn update_intent_to_add(&self, state_id: &ChangeId) -> GitResult<()> {
1488 let root = self.heddle_repo.root();
1489 if !root.join(".git").exists() {
1490 return Ok(());
1491 }
1492 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1493 if checkout_repo
1496 .head()
1497 .map(|head| head.is_detached())
1498 .unwrap_or(false)
1499 {
1500 return Ok(());
1501 }
1502
1503 let Some(state) = self.heddle_repo.store().get_state(state_id)? else {
1505 return Ok(());
1506 };
1507 let Some(tree) = self.heddle_repo.store().get_tree(&state.tree)? else {
1508 return Ok(());
1509 };
1510 let mut captured: Vec<(String, FileMode)> = Vec::new();
1511 collect_capture_paths(self.heddle_repo.store(), &tree, "", &mut captured)?;
1512 let mut index = checkout_repo
1525 .open_index()
1526 .map_err(git_err)?
1527 .unwrap_or_else(|| Index {
1528 version: 2,
1529 entries: Vec::new(),
1530 extensions: Vec::new(),
1531 checksum: None,
1532 });
1533
1534 let mut real_tracked: HashSet<String> = HashSet::new();
1537 let mut existing_ita: HashSet<String> = HashSet::new();
1538 for entry in &index.entries {
1539 let path = String::from_utf8_lossy(entry.path.as_bytes()).into_owned();
1540 if entry.is_intent_to_add() {
1541 existing_ita.insert(path);
1542 } else {
1543 real_tracked.insert(path);
1544 }
1545 }
1546
1547 let captured_paths: HashSet<&str> = captured.iter().map(|(p, _)| p.as_str()).collect();
1550
1551 let before_prune = index.entries.len();
1553 index.entries.retain(|entry| {
1554 !entry.is_intent_to_add()
1555 || captured_paths.contains(String::from_utf8_lossy(entry.path.as_bytes()).as_ref())
1556 });
1557 let mut changed = index.entries.len() != before_prune;
1558
1559 for (path, mode) in &captured {
1561 if real_tracked.contains(path) || existing_ita.contains(path) {
1562 continue;
1563 }
1564 if real_tracked
1572 .iter()
1573 .any(|tracked| path_prefix_conflict(path, tracked))
1574 {
1575 continue;
1576 }
1577 let mut entry = IndexEntry::intent_to_add(
1578 checkout_repo.object_format(),
1579 GitBString::from(path.as_str()),
1580 );
1581 entry.mode = match mode {
1582 FileMode::Executable => 0o100755,
1583 FileMode::Symlink => 0o120000,
1584 FileMode::Normal => 0o100644,
1585 };
1586 changed = true;
1587 index.entries.push(entry);
1588 }
1589
1590 if changed {
1591 index
1592 .entries
1593 .sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
1594 index.upgrade_version_for_flags();
1595 checkout_repo
1596 .write_index(
1597 &index,
1598 IndexWriteOptions {
1599 fsync: true,
1600 validate_checksum: true,
1601 },
1602 )
1603 .map_err(git_err)?;
1604 }
1605 Ok(())
1606 }
1607
1608 pub fn write_through_thread_checkout(
1613 &mut self,
1614 thread: &str,
1615 ) -> GitResult<WriteThroughOutcome> {
1616 if !self.heddle_repo.root().join(".git").exists() {
1617 return Ok(WriteThroughOutcome::Skipped(
1618 WriteThroughSkipReason::MissingDotGit,
1619 ));
1620 }
1621
1622 let mirror_guard = self.init_mirror_with_guard()?;
1623 export_current_thread(self, thread)?;
1624 mirror_guard.commit();
1625 self.write_thread_checkout_from_existing_mirror(thread)
1626 }
1627
1628 pub(crate) fn write_current_checkout_from_existing_mirror(
1629 &mut self,
1630 ) -> GitResult<WriteThroughOutcome> {
1631 if !self.heddle_repo.root().join(".git").exists() {
1632 return Ok(WriteThroughOutcome::Skipped(
1633 WriteThroughSkipReason::MissingDotGit,
1634 ));
1635 }
1636
1637 let (thread, state_id) = match self.heddle_repo.head_ref()? {
1638 Head::Attached { thread } => {
1639 let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1640 return Ok(WriteThroughOutcome::Skipped(
1641 WriteThroughSkipReason::NoAttachedThread,
1642 ));
1643 };
1644 (thread, state_id)
1645 }
1646 Head::Detached { .. } => {
1647 return Ok(WriteThroughOutcome::Skipped(
1648 WriteThroughSkipReason::DetachedHead,
1649 ));
1650 }
1651 };
1652 self.write_thread_state_checkout_from_existing_mirror(&thread, &state_id)
1653 }
1654
1655 fn write_thread_checkout_from_existing_mirror(
1656 &mut self,
1657 thread: &str,
1658 ) -> GitResult<WriteThroughOutcome> {
1659 let Some(state_id) = self
1660 .heddle_repo
1661 .refs()
1662 .get_thread(&ThreadName::new(thread))?
1663 else {
1664 return Ok(WriteThroughOutcome::Skipped(
1665 WriteThroughSkipReason::NoAttachedThread,
1666 ));
1667 };
1668 self.write_thread_state_checkout_from_existing_mirror(thread, &state_id)
1669 }
1670
1671 fn write_thread_state_checkout_from_existing_mirror(
1672 &mut self,
1673 thread: &str,
1674 state_id: &ChangeId,
1675 ) -> GitResult<WriteThroughOutcome> {
1676 let mirror_repo = self.open_git_repo()?;
1677 if self.mapping.is_empty() {
1687 self.build_existing_mapping(None)?;
1688 }
1689 let git_oid = if let Some(git_oid) = self.mapping.get_git(state_id) {
1690 git_oid
1691 } else if let Some(git_commit) = self
1692 .heddle_repo
1693 .git_overlay_mapped_git_commit_for_change(state_id)
1694 .map_err(|error| GitBridgeError::Git(error.to_string()))?
1695 {
1696 ObjectId::from_hex(mirror_repo.object_format(), &git_commit)
1697 .map_err(|error| GitBridgeError::InvalidMapping(error.to_string()))?
1698 } else {
1699 return Ok(WriteThroughOutcome::Skipped(
1700 WriteThroughSkipReason::NoMappedCommit,
1701 ));
1702 };
1703
1704 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1705 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1706 return Ok(WriteThroughOutcome::Skipped(
1707 WriteThroughSkipReason::MirrorIsWorktree,
1708 ));
1709 }
1710 let git_dir = checkout_repo.git_dir().to_path_buf();
1711 if git_dir.join("index.lock").exists() {
1714 return Ok(WriteThroughOutcome::Skipped(
1715 WriteThroughSkipReason::IndexAlreadyDirty,
1716 ));
1717 }
1718
1719 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1720 let branch_ref = format!("refs/heads/{thread}");
1721 let head_path = git_dir.join("HEAD");
1722 let index_path = git_dir.join("index");
1723 let previous_head = fs::read(&head_path).ok();
1724 let previous_index = fs::read(&index_path).ok();
1725 let previous_branch = object_repo
1726 .find_reference(&branch_ref)
1727 .ok()
1728 .flatten()
1729 .and_then(|reference| reference.peeled_oid(&object_repo).ok().flatten());
1730
1731 let heddle_repo = self.heddle_repo;
1732 let mapping = &self.mapping;
1733 let write_result = (|| -> GitResult<()> {
1734 let excluded: HashSet<ObjectId> = match previous_branch {
1749 Some(parent) => sley::plumbing::sley_odb::collect_reachable_object_ids(
1750 object_repo.objects().as_ref(),
1751 object_repo.object_format(),
1752 [parent],
1753 )
1754 .map_err(|error| GitBridgeError::Git(error.to_string()))?,
1755 None => HashSet::new(),
1756 };
1757 materialize_checkout_closure_from_state(
1765 heddle_repo,
1766 mapping,
1767 &mirror_repo,
1768 &object_repo,
1769 state_id,
1770 git_oid,
1771 &excluded,
1772 )?;
1773 write_head_symref(&git_dir, &branch_ref)?;
1777
1778 let commit = object_repo.read_commit(&git_oid).map_err(git_err)?;
1779 let mut index = object_repo.index_from_tree(&commit.tree).map_err(git_err)?;
1780 index.upgrade_version_for_flags();
1781 checkout_repo
1782 .write_index(
1783 &index,
1784 IndexWriteOptions {
1785 fsync: true,
1786 validate_checksum: true,
1787 },
1788 )
1789 .map_err(git_err)?;
1790
1791 update_checkout_head_ref(
1792 &checkout_repo,
1793 git_oid,
1794 previous_branch,
1795 "heddle: write-through current thread",
1796 )?;
1797
1798 fsync_path(&head_path)?;
1804 fsync_path(&index_path)?;
1805 fsync_path(&git_dir)?;
1806 Ok(())
1807 })();
1808
1809 if let Err(err) = write_result {
1810 restore_file(head_path.clone(), previous_head.as_deref())?;
1811 restore_file(index_path.clone(), previous_index.as_deref())?;
1812 if let Some(previous_branch) = previous_branch {
1813 set_reference(
1814 &object_repo,
1815 &branch_ref,
1816 previous_branch,
1817 RefPrecondition::Any,
1818 "heddle: rollback failed write-through",
1819 )?;
1820 } else {
1821 let _ = delete_reference_if_present(&object_repo, &branch_ref);
1831 }
1832 let _ = fsync_path(&head_path);
1835 let _ = fsync_path(&index_path);
1836 let _ = fsync_path(&git_dir);
1837 return Err(err);
1838 }
1839
1840 Ok(WriteThroughOutcome::Wrote(git_oid))
1841 }
1842
1843 fn refresh_checkout_remote_tracking_ref(
1844 &self,
1845 remote_name: &str,
1846 branch: &str,
1847 ) -> GitResult<()> {
1848 if !self.heddle_repo.root().join(".git").exists() {
1849 return Ok(());
1850 }
1851 let Some(tracking_remote) =
1852 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1853 else {
1854 return Ok(());
1855 };
1856 reject_reserved_git_remote_name(&tracking_remote)?;
1857
1858 let mirror_repo = self.open_git_repo()?;
1859 let branch_ref = format!("refs/heads/{branch}");
1860 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1861 return Ok(());
1862 };
1863 let Some(target) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1864 return Ok(());
1865 };
1866
1867 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1868 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1869 return Ok(());
1870 }
1871 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1872 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
1873 set_reference(
1874 &object_repo,
1875 &format!("refs/remotes/{tracking_remote}/{branch}"),
1876 target,
1877 RefPrecondition::Any,
1878 "heddle: refresh remote-tracking branch after pull",
1879 )?;
1880 Ok(())
1881 }
1882
1883 fn refresh_checkout_remote_tracking_refs(&self, remote_name: &str) -> GitResult<()> {
1884 if !self.heddle_repo.root().join(".git").exists() {
1885 return Ok(());
1886 }
1887 let Some(tracking_remote) =
1888 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1889 else {
1890 return Ok(());
1891 };
1892 reject_reserved_git_remote_name(&tracking_remote)?;
1893
1894 let mirror_repo = self.open_git_repo()?;
1895 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1896 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1897 return Ok(());
1898 }
1899 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1900 let prefix = format!("refs/remotes/{remote_name}/");
1901 for reference in mirror_repo.references().list_refs().map_err(git_err)? {
1902 if !reference.name.starts_with(&prefix) {
1903 continue;
1904 }
1905 let ReferenceTarget::Direct(target) = reference.target else {
1906 continue;
1907 };
1908 let full = reference.name;
1909 let Some(branch) = full.strip_prefix(&prefix) else {
1910 continue;
1911 };
1912 if branch.ends_with("/HEAD") {
1913 continue;
1914 }
1915 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
1916 set_reference(
1917 &object_repo,
1918 &format!("refs/remotes/{tracking_remote}/{branch}"),
1919 target,
1920 RefPrecondition::Any,
1921 "heddle: refresh remote-tracking branch after fetch",
1922 )?;
1923 }
1924 Ok(())
1925 }
1926
1927 fn refresh_checkout_note_refs_from_mirror(&self) -> GitResult<()> {
1928 if !self.heddle_repo.root().join(".git").exists() {
1929 return Ok(());
1930 }
1931
1932 let mirror_repo = self.open_git_repo()?;
1933 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1934 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1935 return Ok(());
1936 }
1937 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1938 let note_updates = collect_ref_updates(&mirror_repo)?
1939 .into_iter()
1940 .filter(|update| update.namespace == RefNamespace::Note)
1941 .collect::<Vec<_>>();
1942 if note_updates.is_empty() {
1943 return Ok(());
1944 }
1945
1946 copy_reachable_objects(
1947 &mirror_repo,
1948 &object_repo,
1949 note_updates.iter().map(|u| u.target),
1950 )?;
1951 apply_ref_updates(
1952 &object_repo,
1953 ¬e_updates,
1954 "heddle: refresh Heddle note refs",
1955 )?;
1956 Ok(())
1957 }
1958
1959 fn resolve_remote(
1960 &self,
1961 remote_name: &str,
1962 direction: RemoteDirection,
1963 ) -> GitResult<ResolvedRemote> {
1964 let repo = self.open_git_repo()?;
1965 let url = match remote_url_from_repo(&repo, remote_name, direction)? {
1966 Some(url) => Some(url),
1967 None => self.checkout_remote_url(remote_name, direction)?,
1968 };
1969
1970 let base = repo_relative_base(&repo);
1971 let url = match url {
1972 Some(url) => url,
1973 None => parse_configured_remote_url(remote_name, &base)?,
1974 };
1975
1976 if let Some(path) = local_path_from_url(&url)? {
1977 Ok(ResolvedRemote::Local(path))
1978 } else {
1979 Ok(ResolvedRemote::Url(url))
1980 }
1981 }
1982
1983 fn checkout_remote_url(
1984 &self,
1985 remote_name: &str,
1986 direction: RemoteDirection,
1987 ) -> GitResult<Option<String>> {
1988 if direction == RemoteDirection::Fetch
1989 && let Some(url) =
1990 remote_fetch_url_from_checkout_config(self.heddle_repo.root(), remote_name)?
1991 {
1992 return Ok(Some(url));
1993 }
1994 let Ok(repo) = SleyRepository::discover(self.heddle_repo.root()) else {
1995 return Ok(None);
1996 };
1997 remote_url_from_repo(&repo, remote_name, direction)
1998 }
1999}
2000
2001fn remote_url_from_repo(
2002 repo: &SleyRepository,
2003 remote_name: &str,
2004 direction: RemoteDirection,
2005) -> GitResult<Option<String>> {
2006 let config = repo.config_snapshot().map_err(git_err)?;
2007 let push = direction == RemoteDirection::Push;
2008 let value = if push {
2009 config
2010 .get("remote", Some(remote_name), "pushurl")
2011 .or_else(|| config.get("remote", Some(remote_name), "url"))
2012 } else {
2013 config.get("remote", Some(remote_name), "url")
2014 };
2015 let Some(value) = value else {
2016 return Ok(None);
2017 };
2018 let rewritten =
2019 sley::plumbing::sley_config::remotes::rewrite_url_with_config(&config, value, push);
2020 parse_configured_remote_url(&rewritten, &repo_relative_base(repo)).map(Some)
2021}
2022
2023fn checkout_tracking_remote_name(root: &Path, requested: &str) -> GitResult<Option<String>> {
2024 let remotes = checkout_remote_url_items(root)?;
2025 if remotes.is_empty() {
2026 return Ok(None);
2027 }
2028 if let Some((name, _)) = remotes.iter().find(|(name, _)| name == requested) {
2029 return Ok(Some(name.clone()));
2030 }
2031 if let Some((name, _)) = remotes
2032 .iter()
2033 .find(|(_, url)| configured_remote_values_match(url, requested))
2034 {
2035 return Ok(Some(name.clone()));
2036 }
2037 if looks_like_remote_location(requested) && remotes.len() == 1 {
2038 return Ok(Some(remotes[0].0.clone()));
2039 }
2040 if !looks_like_remote_location(requested) {
2041 return Ok(Some(requested.to_string()));
2042 }
2043 Ok(None)
2044}
2045
2046fn checkout_remote_url_items(root: &Path) -> GitResult<Vec<(String, String)>> {
2047 let mut remotes = Vec::new();
2048 for config_path in checkout_git_config_paths(root) {
2049 parse_remote_url_items_from_config(&config_path, &mut remotes)?;
2050 }
2051 Ok(remotes)
2052}
2053
2054fn checkout_note_ref_exists(root: &Path) -> GitResult<bool> {
2055 if !root.join(".git").exists() {
2056 return Ok(false);
2057 }
2058 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2059 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2060 Ok(object_repo
2061 .find_reference(super::git_notes::NOTES_REF)
2062 .map_err(git_err)?
2063 .is_some())
2064}
2065
2066fn seed_checkout_note_refs_into_mirror(root: &Path, mirror_repo: &SleyRepository) -> GitResult<()> {
2067 if !root.join(".git").exists() {
2068 return Ok(());
2069 }
2070
2071 let checkout_repo = match SleyRepository::discover(root) {
2072 Ok(repo) => repo,
2073 Err(_) => return Ok(()),
2074 };
2075 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2076 return Ok(());
2077 }
2078 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2079 let note_updates = collect_ref_updates(&object_repo)?
2080 .into_iter()
2081 .filter(|update| update.namespace == RefNamespace::Note)
2082 .collect::<Vec<_>>();
2083 if note_updates.is_empty() {
2084 return Ok(());
2085 }
2086
2087 copy_reachable_objects(
2088 &object_repo,
2089 mirror_repo,
2090 note_updates.iter().map(|update| update.target),
2091 )?;
2092 apply_ref_updates(
2093 mirror_repo,
2094 ¬e_updates,
2095 "heddle: seed mirror note refs from checkout",
2096 )
2097}
2098
2099fn hydrate_checkout_notes_from_remote_without_mirror(
2100 root: &Path,
2101 remote_name: &str,
2102) -> GitResult<()> {
2103 reject_reserved_git_remote_name(remote_name)?;
2104 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2105 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2106 let url = remote_fetch_url_from_checkout_config(root, remote_name)?
2107 .ok_or_else(|| GitBridgeError::Git(format!("remote '{remote_name}' has no fetch URL")))?;
2108
2109 if let Some(path) = local_path_from_url(&url)? {
2110 let remote_repo = open_repo(&path)?;
2111 let note_updates = collect_ref_updates(&remote_repo)?
2112 .into_iter()
2113 .filter(|update| update.namespace == RefNamespace::Note)
2114 .collect::<Vec<_>>();
2115 if note_updates.is_empty() {
2116 return Ok(());
2117 }
2118 copy_reachable_objects(
2119 &remote_repo,
2120 &object_repo,
2121 note_updates.iter().map(|update| update.target),
2122 )?;
2123 apply_ref_updates(
2124 &object_repo,
2125 ¬e_updates,
2126 &format!("heddle: hydrate notes from {remote_name}"),
2127 )?;
2128 return Ok(());
2129 }
2130
2131 fetch_heddle_notes_into_repo(&object_repo, remote_name, &url)
2132}
2133
2134fn fetch_heddle_notes_into_repo(
2135 repo: &SleyRepository,
2136 remote_name: &str,
2137 url: &str,
2138) -> GitResult<()> {
2139 let mut credentials = NoCredentials;
2140 let mut progress = SilentProgress;
2141 let refspec = RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format();
2142 repo.fetch(
2143 url,
2144 &[refspec],
2145 FetchOptions {
2146 quiet: true,
2147 auto_follow_tags: false,
2148 fetch_all_tags: false,
2149 prune: false,
2150 dry_run: false,
2151 append: false,
2152 write_fetch_head: true,
2153 tag_option_explicit: true,
2154 prune_option_explicit: true,
2155 prune_tags: false,
2156 prune_tags_option_explicit: false,
2157 refmap: None,
2158 refetch: false,
2159 record_promisor_refs: false,
2160 update_head_ok: false,
2161 ssh_options: None,
2162 atomic: false,
2163 depth: None,
2164 merge_srcs: Vec::new(),
2165 filter: None,
2166 cloning: false,
2167 update_shallow: false,
2168 deepen_relative: false,
2169 deepen_since: None,
2170 deepen_not: Vec::new(),
2171 },
2172 &mut credentials,
2173 &mut progress,
2174 )
2175 .map(|_| ())
2176 .map_err(|err| GitBridgeError::Git(format!("failed to fetch notes from {remote_name}: {err}")))
2177}
2178
2179fn parse_remote_url_items_from_config(
2180 path: &Path,
2181 remotes: &mut Vec<(String, String)>,
2182) -> GitResult<()> {
2183 let Ok(contents) = fs::read_to_string(path) else {
2184 return Ok(());
2185 };
2186 let mut current_remote: Option<String> = None;
2187 for raw in contents.lines() {
2188 let line = raw.trim();
2189 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2190 continue;
2191 }
2192 if line.starts_with('[') && line.ends_with(']') {
2193 current_remote = line
2194 .strip_prefix("[remote \"")
2195 .and_then(|rest| rest.strip_suffix("\"]"))
2196 .map(str::to_string);
2197 continue;
2198 }
2199 let Some(name) = current_remote.as_ref() else {
2200 continue;
2201 };
2202 let Some((key, value)) = line.split_once('=') else {
2203 continue;
2204 };
2205 if key.trim().eq_ignore_ascii_case("url") {
2206 remotes.push((name.clone(), git_config_value(value.trim())?));
2207 }
2208 }
2209 Ok(())
2210}
2211
2212fn configured_remote_values_match(left: &str, right: &str) -> bool {
2213 if left == right {
2214 return true;
2215 }
2216 let left_path = Path::new(left);
2217 let right_path = Path::new(right);
2218 if let (Ok(left), Ok(right)) = (left_path.canonicalize(), right_path.canonicalize()) {
2219 return left == right;
2220 }
2221 false
2222}
2223
2224fn looks_like_remote_location(value: &str) -> bool {
2225 value.starts_with('/')
2226 || value.starts_with("./")
2227 || value.starts_with("../")
2228 || value.starts_with("~/")
2229 || value.contains("://")
2230 || value.contains('\\')
2231}
2232
2233fn remote_fetch_url_from_checkout_config(
2234 root: &Path,
2235 remote_name: &str,
2236) -> GitResult<Option<String>> {
2237 for config_path in checkout_git_config_paths(root) {
2238 let Some(url) = parse_remote_fetch_url_from_config(&config_path, remote_name)? else {
2239 continue;
2240 };
2241 return parse_configured_remote_url(&url, root).map(Some);
2242 }
2243 Ok(None)
2244}
2245
2246fn parse_configured_remote_url(value: &str, relative_base: &Path) -> GitResult<String> {
2247 if configured_remote_is_local_path(value) {
2248 let path = configured_remote_local_path(value, relative_base);
2249 return Ok(format!("file://{}", path.display()));
2250 }
2251 Ok(value.to_string())
2252}
2253
2254fn configured_remote_local_path(value: &str, relative_base: &Path) -> PathBuf {
2255 if value == "~"
2256 && let Some(home) = std::env::var_os("HOME")
2257 {
2258 return PathBuf::from(home);
2259 }
2260 if let Some(rest) = value.strip_prefix("~/")
2261 && let Some(home) = std::env::var_os("HOME")
2262 {
2263 return PathBuf::from(home).join(rest);
2264 }
2265
2266 let path = Path::new(value);
2267 if path.is_absolute() {
2268 path.to_path_buf()
2269 } else {
2270 relative_base.join(path)
2271 }
2272}
2273
2274fn configured_remote_is_local_path(value: &str) -> bool {
2275 value.starts_with('/')
2276 || value.starts_with("./")
2277 || value.starts_with("../")
2278 || value.starts_with('~')
2279 || value.starts_with(std::path::MAIN_SEPARATOR)
2280}
2281
2282fn checkout_git_config_paths(root: &Path) -> Vec<PathBuf> {
2283 let dot_git = root.join(".git");
2284 let mut paths = Vec::new();
2285 if dot_git.is_dir() {
2286 paths.push(dot_git.join("config"));
2287 if let Some(common_dir) = common_git_dir_from_git_dir(&dot_git) {
2288 paths.push(common_dir.join("config"));
2289 }
2290 return paths;
2291 }
2292 let Ok(contents) = fs::read_to_string(&dot_git) else {
2293 return paths;
2294 };
2295 let Some(target) = contents.trim().strip_prefix("gitdir:").map(str::trim) else {
2296 return paths;
2297 };
2298 let git_dir = {
2299 let path = Path::new(target);
2300 if path.is_absolute() {
2301 path.to_path_buf()
2302 } else {
2303 dot_git
2304 .parent()
2305 .map(|parent| parent.join(path))
2306 .unwrap_or_else(|| path.to_path_buf())
2307 }
2308 };
2309 paths.push(git_dir.join("config"));
2310 if let Some(common_dir) = common_git_dir_from_git_dir(&git_dir) {
2311 paths.push(common_dir.join("config"));
2312 }
2313 paths
2314}
2315
2316fn common_git_dir_from_git_dir(git_dir: &Path) -> Option<PathBuf> {
2317 let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
2318 let target = contents.trim();
2319 let path = Path::new(target);
2320 Some(if path.is_absolute() {
2321 path.to_path_buf()
2322 } else {
2323 git_dir.join(path)
2324 })
2325}
2326
2327fn parse_remote_fetch_url_from_config(path: &Path, remote_name: &str) -> GitResult<Option<String>> {
2328 let Ok(contents) = fs::read_to_string(path) else {
2329 return Ok(None);
2330 };
2331 let mut in_remote = false;
2332 for raw in contents.lines() {
2333 let line = raw.trim();
2334 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2335 continue;
2336 }
2337 if line.starts_with('[') && line.ends_with(']') {
2338 in_remote = line
2339 .strip_prefix("[remote \"")
2340 .and_then(|rest| rest.strip_suffix("\"]"))
2341 == Some(remote_name);
2342 continue;
2343 }
2344 if !in_remote {
2345 continue;
2346 }
2347 let Some((key, value)) = line.split_once('=') else {
2348 continue;
2349 };
2350 if key.trim().eq_ignore_ascii_case("url") {
2351 return git_config_value(value.trim()).map(Some);
2352 }
2353 }
2354 Ok(None)
2355}
2356
2357fn common_repo_for_worktree(repo: &SleyRepository) -> GitResult<SleyRepository> {
2358 let common_dir_file = repo.git_dir().join("commondir");
2359 let Ok(contents) = fs::read_to_string(&common_dir_file) else {
2360 return Ok(repo.clone());
2361 };
2362 let target = contents.trim();
2363 if target.is_empty() {
2364 return Ok(repo.clone());
2365 }
2366 let common_dir = {
2367 let path = Path::new(target);
2368 if path.is_absolute() {
2369 path.to_path_buf()
2370 } else {
2371 repo.git_dir().join(path)
2372 }
2373 };
2374 open_repo(&common_dir)
2375}
2376
2377pub(crate) fn git_err(err: impl std::fmt::Display) -> GitBridgeError {
2378 GitBridgeError::Git(err.to_string())
2379}
2380
2381fn restore_file(path: PathBuf, previous: Option<&[u8]>) -> GitResult<()> {
2382 if let Some(previous) = previous {
2383 fs::write(path, previous)?;
2384 } else if path.exists() {
2385 fs::remove_file(path)?;
2386 }
2387 Ok(())
2388}
2389
2390fn fsync_path(path: &Path) -> GitResult<()> {
2394 match std::fs::File::open(path) {
2395 Ok(file) => {
2396 file.sync_all()?;
2397 Ok(())
2398 }
2399 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2400 Err(err) => Err(GitBridgeError::Io(err)),
2401 }
2402}
2403
2404pub(crate) struct MirrorInitGuard {
2411 path: PathBuf,
2412 rollback: Option<bool>,
2416}
2417
2418impl MirrorInitGuard {
2419 pub(crate) fn new_from_init(path: PathBuf, did_create: bool) -> Self {
2420 Self {
2421 path,
2422 rollback: Some(did_create),
2423 }
2424 }
2425
2426 pub(crate) fn commit(mut self) {
2427 self.rollback = None;
2428 }
2429}
2430
2431impl Drop for MirrorInitGuard {
2432 fn drop(&mut self) {
2433 if matches!(self.rollback, Some(true))
2434 && self.path.exists()
2435 && let Err(err) = std::fs::remove_dir_all(&self.path)
2436 {
2437 tracing::warn!(
2438 path = %self.path.display(),
2439 error = %err,
2440 "failed to roll back partial bridge mirror; manual cleanup may be required"
2441 );
2442 }
2443 }
2444}
2445
2446pub(crate) fn thread_is_unclaimed_bootstrap(
2457 heddle_repo: &HeddleRepository,
2458 change_id: &ChangeId,
2459) -> GitResult<bool> {
2460 let Some(state) = heddle_repo.store().get_state(change_id)? else {
2461 return Ok(false);
2462 };
2463 if !state.parents.is_empty() {
2464 return Ok(false);
2465 }
2466 let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
2467 return Ok(false);
2468 };
2469 Ok(tree == Tree::new())
2470}
2471
2472pub(crate) fn open_repo(path: &Path) -> GitResult<SleyRepository> {
2473 match SleyRepository::discover(path) {
2474 Ok(repo) => Ok(repo),
2475 Err(_) => SleyRepository::open(path).map_err(git_err),
2476 }
2477}
2478
2479pub(crate) fn delete_reference_if_present(repo: &SleyRepository, name: &str) -> GitResult<()> {
2487 delete_reference(repo, name, None, true)
2488}
2489
2490fn delete_reference_matching(
2491 repo: &SleyRepository,
2492 name: &str,
2493 expected_old: ObjectId,
2494) -> GitResult<()> {
2495 delete_reference(repo, name, Some(expected_old), false)
2496}
2497
2498fn delete_reference(
2499 repo: &SleyRepository,
2500 name: &str,
2501 expected_old: Option<ObjectId>,
2502 missing_ok: bool,
2503) -> GitResult<()> {
2504 let refs = repo.references();
2505 match refs.read_ref(name).map_err(git_err)? {
2506 None if missing_ok => Ok(()),
2507 None => Err(GitBridgeError::Git(format!(
2508 "failed to delete Git reference '{name}': ref is missing"
2509 ))),
2510 Some(ReferenceTarget::Direct(oid)) => repo
2511 .delete_ref(DeleteRef {
2512 name: FullName::new(name).map_err(git_err)?,
2513 expected_old: Some(expected_old.unwrap_or(oid)),
2514 expected: None,
2515 reflog: None,
2516 reflog_committer: None,
2517 })
2518 .map_err(git_err),
2519 Some(ReferenceTarget::Symbolic(_)) => {
2520 if let Some(expected_old) = expected_old {
2521 let current = repo
2522 .find_reference(name)
2523 .map_err(git_err)?
2524 .and_then(|reference| reference.peeled_oid(repo).ok().flatten());
2525 if current != Some(expected_old) {
2526 return Err(GitBridgeError::Git(format!(
2527 "failed to delete Git reference '{name}': expected {expected_old}, found {}",
2528 current
2529 .map(|oid| oid.to_string())
2530 .unwrap_or_else(|| "missing".to_string())
2531 )));
2532 }
2533 }
2534 refs.delete_symbolic_ref(name).map(|_| ()).map_err(git_err)
2535 }
2536 }
2537}
2538
2539pub(crate) fn set_reference(
2540 repo: &SleyRepository,
2541 name: &str,
2542 target: ObjectId,
2543 constraint: RefPrecondition,
2544 log_message: &str,
2545) -> GitResult<()> {
2546 let refs = repo.references();
2547 let old_oid = match refs.read_ref(name).map_err(git_err)? {
2548 Some(ReferenceTarget::Direct(oid)) => oid,
2549 _ => ObjectId::null(repo.object_format()),
2550 };
2551 let reflog = sley::plumbing::sley_refs::ReflogEntry {
2552 old_oid,
2553 new_oid: target,
2554 committer: bridge_signature(),
2555 message: log_message.as_bytes().to_vec(),
2556 };
2557 let mut tx = refs.transaction();
2558 tx.update_to(
2559 name.to_string(),
2560 ReferenceTarget::Direct(target),
2561 constraint,
2562 Some(reflog),
2563 );
2564 tx.commit().map_err(git_err)?;
2565 Ok(())
2566}
2567
2568fn path_prefix_conflict(a: &str, b: &str) -> bool {
2574 let child_of = |parent: &str, child: &str| {
2575 child
2576 .strip_prefix(parent)
2577 .is_some_and(|rest| rest.starts_with('/'))
2578 };
2579 child_of(a, b) || child_of(b, a)
2580}
2581
2582fn collect_capture_paths<S: ObjectStore + ?Sized>(
2587 store: &S,
2588 tree: &Tree,
2589 prefix: &str,
2590 out: &mut Vec<(String, FileMode)>,
2591) -> GitResult<()> {
2592 for entry in tree.iter() {
2593 let path = if prefix.is_empty() {
2594 entry.name.clone()
2595 } else {
2596 format!("{prefix}/{}", entry.name)
2597 };
2598 if entry.is_tree() {
2599 if let Some(subtree) = store.get_tree(&entry.hash)? {
2600 collect_capture_paths(store, &subtree, &path, out)?;
2601 }
2602 } else {
2603 out.push((path, entry.mode));
2604 }
2605 }
2606 Ok(())
2607}
2608
2609fn update_checkout_head_ref(
2610 repo: &SleyRepository,
2611 target: ObjectId,
2612 previous_branch: Option<ObjectId>,
2613 log_message: &str,
2614) -> GitResult<()> {
2615 let expected = previous_branch.map_or(RefPrecondition::MustNotExist, |oid| {
2616 RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(oid))
2617 });
2618 let ref_name = repo
2619 .head()
2620 .ok()
2621 .and_then(|head| head.symbolic_target.map(|name| name.to_string()))
2622 .unwrap_or_else(|| "HEAD".to_string());
2623 let old_oid = previous_branch.unwrap_or_else(|| ObjectId::null(repo.object_format()));
2624 let head_reflog = sley::plumbing::sley_refs::ReflogEntry {
2625 old_oid,
2626 new_oid: target,
2627 committer: bridge_signature(),
2628 message: log_message.as_bytes().to_vec(),
2629 };
2630 set_reference(repo, &ref_name, target, expected, log_message)?;
2631 if ref_name != "HEAD" {
2632 repo.references()
2633 .append_reflog("HEAD", &head_reflog)
2634 .map_err(git_err)?;
2635 }
2636 Ok(())
2637}
2638
2639fn checkout_git_head_is_detached(root: &Path) -> GitResult<bool> {
2640 let repo = SleyRepository::discover(root).map_err(git_err)?;
2641 Ok(repo.head().map(|head| head.is_detached()).unwrap_or(false))
2642}
2643
2644pub(crate) fn resolve_git_commit_identity(
2645 repo_root: &Path,
2646 fallback: &Principal,
2647) -> GitResult<LocalGitIdentity> {
2648 if !principal_is_default_unknown(fallback) {
2649 return Ok(LocalGitIdentity::from_principal(fallback));
2650 }
2651 if let Some(identity) = git_config_identity_with_global_fallback(repo_root)? {
2652 return Ok(identity);
2653 }
2654
2655 Err(GitBridgeError::Git(
2656 "refusing to write a Git commit with Unknown <unknown@example.com>; configure user.name/user.email, HEDDLE_PRINCIPAL_NAME/HEDDLE_PRINCIPAL_EMAIL, or .heddle principal".to_string(),
2657 ))
2658}
2659
2660pub(crate) fn git_config_identity_with_global_fallback(
2661 repo_root: &Path,
2662) -> GitResult<Option<LocalGitIdentity>> {
2663 let name = git_config_value_with_global_fallback(repo_root, "user.name")?;
2664 let email = git_config_value_with_global_fallback(repo_root, "user.email")?;
2665 if let (Some(name), Some(email)) = (name, email)
2666 && !name.trim().is_empty()
2667 && !email.trim().is_empty()
2668 {
2669 return Ok(Some(LocalGitIdentity { name, email }));
2670 }
2671 Ok(None)
2672}
2673
2674pub(crate) fn principal_is_default_unknown(principal: &Principal) -> bool {
2675 principal.name.trim().is_empty()
2676 || principal.email.trim().is_empty()
2677 || (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
2678}
2679
2680fn git_config_value_with_global_fallback(repo_root: &Path, key: &str) -> GitResult<Option<String>> {
2681 let Ok(repo) = SleyRepository::discover(repo_root) else {
2682 return Ok(None);
2683 };
2684 let Some((section, variable)) = key.split_once('.') else {
2685 return Ok(None);
2686 };
2687 Ok(repo
2688 .config_snapshot()
2689 .map_err(git_err)?
2690 .get(section, None, variable)
2691 .map(str::to_string))
2692}
2693
2694fn git_config_value(value: &str) -> GitResult<String> {
2695 let Some(quoted) = value
2696 .strip_prefix('"')
2697 .and_then(|rest| rest.strip_suffix('"'))
2698 else {
2699 return Ok(value.to_string());
2700 };
2701 let mut out = String::new();
2702 let mut chars = quoted.chars();
2703 while let Some(ch) = chars.next() {
2704 if ch != '\\' {
2705 out.push(ch);
2706 continue;
2707 }
2708 let Some(escaped) = chars.next() else {
2709 return Err(GitBridgeError::Git(
2710 "unterminated escape in repo-local Git config".to_string(),
2711 ));
2712 };
2713 match escaped {
2714 '"' | '\\' => out.push(escaped),
2715 'n' => out.push('\n'),
2716 't' => out.push('\t'),
2717 'b' => out.push('\u{0008}'),
2718 other => out.push(other),
2719 }
2720 }
2721 Ok(out)
2722}
2723
2724fn bridge_signature() -> Vec<u8> {
2725 let seconds = SystemTime::now()
2726 .duration_since(UNIX_EPOCH)
2727 .map(|duration| duration.as_secs() as i64)
2728 .unwrap_or(0);
2729 format!("Heddle <heddle@local> {seconds} +0000").into_bytes()
2730}
2731
2732fn repo_relative_base(repo: &SleyRepository) -> PathBuf {
2733 repo.workdir().unwrap_or_else(|| {
2734 if repo
2735 .git_dir()
2736 .file_name()
2737 .is_some_and(|name| name == ".git")
2738 {
2739 repo.git_dir()
2740 .parent()
2741 .map(Path::to_path_buf)
2742 .unwrap_or_else(|| repo.git_dir().to_path_buf())
2743 } else {
2744 repo.git_dir().to_path_buf()
2745 }
2746 })
2747}
2748
2749fn local_path_from_url(url: &str) -> GitResult<Option<PathBuf>> {
2750 if url.starts_with("heddle://") {
2760 return Err(GitBridgeError::Git(format!(
2761 "remote '{url}' uses the hosted heddle:// scheme, which cannot be pushed via the git-overlay exporter; hosted pushes must go through the native hosted-sync path"
2762 )));
2763 }
2764 let Some(raw_path) = url.strip_prefix("file://") else {
2765 return Ok(None);
2766 };
2767 let path = PathBuf::from(raw_path);
2768 if path.as_os_str().is_empty() {
2769 return Err(GitBridgeError::Git(format!(
2770 "remote '{}' has no filesystem path",
2771 url
2772 )));
2773 }
2774 Ok(Some(path))
2775}
2776
2777fn collect_ref_updates(repo: &SleyRepository) -> GitResult<Vec<RefUpdate>> {
2778 let mut updates = Vec::new();
2779
2780 for reference in repo.references().list_refs().map_err(git_err)? {
2781 let ReferenceTarget::Direct(target) = reference.target else {
2782 continue;
2783 };
2784 if let Some(name) = reference.name.strip_prefix("refs/heads/") {
2785 updates.push(RefUpdate {
2786 name: name.to_string(),
2787 target,
2788 namespace: RefNamespace::Branch,
2789 });
2790 } else if let Some(name) = reference.name.strip_prefix("refs/tags/") {
2791 updates.push(RefUpdate {
2792 name: name.to_string(),
2793 target,
2794 namespace: RefNamespace::Tag,
2795 });
2796 } else if let Some(name) = reference.name.strip_prefix("refs/notes/") {
2797 updates.push(RefUpdate {
2798 name: name.to_string(),
2799 target,
2800 namespace: RefNamespace::Note,
2801 });
2802 }
2803 }
2804
2805 Ok(updates)
2806}
2807
2808#[derive(Debug, Default, Clone, Copy)]
2817pub(crate) struct ExportedCommitCounts {
2818 pub total: usize,
2819 pub newly: usize,
2820}
2821
2822pub(crate) fn count_exported_commits(
2836 repo: &SleyRepository,
2837 newly_minted: &HashSet<ObjectId>,
2838) -> GitResult<ExportedCommitCounts> {
2839 let tips: Vec<ObjectId> = collect_ref_updates(repo)?
2840 .into_iter()
2841 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag))
2842 .map(|update| update.target)
2843 .collect();
2844
2845 let mut stack = tips;
2846 let mut seen = HashSet::new();
2847 let mut counts = ExportedCommitCounts::default();
2848 while let Some(oid) = stack.pop() {
2849 if !seen.insert(oid) {
2850 continue;
2851 }
2852 let object = repo.read_object(&oid).map_err(git_err)?;
2853 match object.object_type {
2854 GitObjectType::Commit => {
2855 counts.total += 1;
2856 if newly_minted.contains(&oid) {
2857 counts.newly += 1;
2858 }
2859 let commit = repo.read_commit(&oid).map_err(git_err)?;
2860 for parent in commit.parents {
2861 stack.push(parent);
2862 }
2863 }
2864 GitObjectType::Tag => {
2868 let tag = repo.read_tag(&oid).map_err(git_err)?;
2869 stack.push(tag.object);
2870 }
2871 GitObjectType::Tree | GitObjectType::Blob => {}
2872 }
2873 }
2874 Ok(counts)
2875}
2876
2877fn collect_ref_updates_for_fetch(
2878 repo: &SleyRepository,
2879 scope: GitFetchScope,
2880) -> GitResult<Vec<RefUpdate>> {
2881 let updates = collect_ref_updates(repo)?;
2882 match scope {
2883 GitFetchScope::AllRefs => Ok(updates),
2884 GitFetchScope::BranchesAndNotes => Ok(updates
2885 .into_iter()
2886 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Note))
2887 .collect()),
2888 }
2889}
2890
2891pub(crate) fn collect_import_source_ref_updates(
2892 repo: &SleyRepository,
2893 refs: &[String],
2894) -> GitResult<Vec<RefUpdate>> {
2895 let updates = collect_ref_updates(repo)?;
2896 if refs.is_empty() {
2897 return Ok(updates);
2898 }
2899
2900 let wanted: HashSet<&str> = refs.iter().map(String::as_str).collect();
2901 Ok(updates
2902 .into_iter()
2903 .filter(|update| matches_import_ref(update, &wanted))
2904 .collect())
2905}
2906
2907fn matches_import_ref(update: &RefUpdate, wanted: &HashSet<&str>) -> bool {
2908 let full = full_ref_name(update);
2909 wanted.contains(update.name.as_str()) || wanted.contains(full.as_str())
2910}
2911
2912fn full_ref_name(update: &RefUpdate) -> String {
2913 match update.namespace {
2914 RefNamespace::Branch => format!("refs/heads/{}", update.name),
2915 RefNamespace::Tag => format!("refs/tags/{}", update.name),
2916 RefNamespace::Note => format!("refs/notes/{}", update.name),
2917 }
2918}
2919
2920#[cfg(test)]
2921pub(crate) fn ensure_commit_update_fast_forward(
2922 repo: &SleyRepository,
2923 name: &str,
2924 old: ObjectId,
2925 new: ObjectId,
2926) -> GitResult<()> {
2927 if old == new || old == ObjectId::null(repo.object_format()) {
2928 return Ok(());
2929 }
2930 match commit_is_descendant_of(repo, new, old) {
2931 Ok(true) => Ok(()),
2932 Ok(false) => Err(GitBridgeError::NonFastForwardRef {
2933 name: name.to_string(),
2934 old,
2935 new,
2936 }),
2937 Err(err) => Err(GitBridgeError::Git(format!(
2938 "ref update would move {name}: {old} -> {new}, but Heddle could not verify it as a fast-forward ({err}); fetch/import first or inspect the refs explicitly"
2939 ))),
2940 }
2941}
2942
2943fn commit_is_descendant_of(
2944 repo: &SleyRepository,
2945 descendant: ObjectId,
2946 ancestor: ObjectId,
2947) -> GitResult<bool> {
2948 let mut stack = vec![descendant];
2949 let mut seen = HashSet::new();
2950 while let Some(oid) = stack.pop() {
2951 if oid == ancestor {
2952 return Ok(true);
2953 }
2954 if !seen.insert(oid) {
2955 continue;
2956 }
2957 let commit = repo.read_commit(&oid).map_err(git_err)?;
2958 for parent in commit.parents {
2959 stack.push(parent);
2960 }
2961 }
2962 Ok(false)
2963}
2964
2965const HEDDLE_EXPORTED_REFS_FILE: &str = "heddle-exported-refs";
2975
2976const HEDDLE_NETWORK_EXPORTED_REFS_DIR: &str = "git-network-exported-refs";
2983
2984fn exported_refs_manifest_path(target_repo: &SleyRepository) -> PathBuf {
2985 target_repo.git_dir().join(HEDDLE_EXPORTED_REFS_FILE)
2986}
2987
2988fn network_exported_refs_path(heddle_dir: &Path, url: &str) -> PathBuf {
2993 let key = ContentHash::compute_typed("git-network-exported-refs", url.as_bytes()).to_hex();
2994 heddle_dir
2995 .join(HEDDLE_NETWORK_EXPORTED_REFS_DIR)
2996 .join(format!("{key}.refs"))
2997}
2998
2999fn read_exported_refs_at(path: &Path) -> GitResult<HashMap<String, ObjectId>> {
3007 match fs::read_to_string(path) {
3008 Ok(text) => {
3009 let mut map = HashMap::new();
3010 for line in text.lines() {
3011 let line = line.trim();
3012 if line.is_empty() {
3013 continue;
3014 }
3015 let mut parts = line.split_whitespace();
3023 let Some(name) = parts.next() else {
3024 continue;
3025 };
3026 let tip = parts
3027 .next()
3028 .and_then(|token| token.parse::<ObjectId>().ok())
3029 .unwrap_or_else(|| ObjectId::null(ObjectFormat::Sha1));
3030 map.insert(name.to_string(), tip);
3031 }
3032 Ok(map)
3033 }
3034 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
3035 Err(e) => Err(GitBridgeError::Io(e)),
3036 }
3037}
3038
3039fn write_exported_refs_at(path: &Path, refs: &HashMap<String, ObjectId>) -> GitResult<()> {
3043 if let Some(parent) = path.parent() {
3044 fs::create_dir_all(parent)?;
3045 }
3046 let mut sorted: Vec<(&str, &ObjectId)> = refs
3047 .iter()
3048 .map(|(name, tip)| (name.as_str(), tip))
3049 .collect();
3050 sorted.sort_unstable_by(|a, b| a.0.cmp(b.0));
3051 let body = sorted
3052 .iter()
3053 .map(|(name, tip)| format!("{name} {tip}"))
3054 .collect::<Vec<_>>()
3055 .join("\n");
3056 let tmp = path.with_extension("tmp");
3057 fs::write(&tmp, body)?;
3058 fs::rename(&tmp, path)?;
3059 Ok(())
3060}
3061
3062pub(crate) fn write_head_symref(git_dir: &Path, branch_ref: &str) -> GitResult<()> {
3075 let head_path = git_dir.join("HEAD");
3076 let tmp = head_path.with_extension("tmp");
3077 fs::write(&tmp, format!("ref: {branch_ref}\n"))?;
3078 fsync_path(&tmp)?;
3079 fs::rename(&tmp, &head_path)?;
3080 fsync_path(&head_path)?;
3081 fsync_path(git_dir)?;
3082 Ok(())
3083}
3084
3085pub(crate) fn read_exported_refs(
3088 target_repo: &SleyRepository,
3089) -> GitResult<HashMap<String, ObjectId>> {
3090 read_exported_refs_at(&exported_refs_manifest_path(target_repo))
3091}
3092
3093pub(crate) fn write_exported_refs(
3096 target_repo: &SleyRepository,
3097 refs: &HashMap<String, ObjectId>,
3098) -> GitResult<()> {
3099 write_exported_refs_at(&exported_refs_manifest_path(target_repo), refs)
3100}
3101
3102const HEDDLE_MIRROR_MANAGED_REFS_FILE: &str = "heddle-mirror-managed-refs";
3114
3115fn mirror_managed_refs_path(mirror_repo: &SleyRepository) -> PathBuf {
3117 mirror_repo.git_dir().join(HEDDLE_MIRROR_MANAGED_REFS_FILE)
3118}
3119
3120pub(crate) fn mirror_managed_refs_recorded(mirror_repo: &SleyRepository) -> bool {
3126 mirror_managed_refs_path(mirror_repo).exists()
3127}
3128
3129pub(crate) fn read_mirror_managed_refs(
3133 mirror_repo: &SleyRepository,
3134) -> GitResult<HashMap<String, ObjectId>> {
3135 read_exported_refs_at(&mirror_managed_refs_path(mirror_repo))
3136}
3137
3138pub(crate) fn write_mirror_managed_refs(
3141 mirror_repo: &SleyRepository,
3142 refs: &HashMap<String, ObjectId>,
3143) -> GitResult<()> {
3144 write_exported_refs_at(&mirror_managed_refs_path(mirror_repo), refs)
3145}
3146
3147pub(crate) fn read_or_seed_mirror_managed_refs(
3160 mirror_repo: &SleyRepository,
3161) -> GitResult<HashMap<String, ObjectId>> {
3162 if mirror_managed_refs_recorded(mirror_repo) {
3163 read_mirror_managed_refs(mirror_repo)
3164 } else {
3165 Ok(collect_ref_updates(mirror_repo)?
3166 .into_iter()
3167 .map(|update| (full_ref_name(&update), update.target))
3168 .collect())
3169 }
3170}
3171
3172pub(crate) fn collect_managed_ref_updates(
3182 repo: &SleyRepository,
3183 record: &HashMap<String, ObjectId>,
3184) -> GitResult<Vec<RefUpdate>> {
3185 Ok(collect_ref_updates(repo)?
3186 .into_iter()
3187 .filter(|update| {
3188 matches!(update.namespace, RefNamespace::Note)
3189 || record.contains_key(&full_ref_name(update))
3190 })
3191 .collect())
3192}
3193
3194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3200enum RefMove {
3201 Unchanged,
3203 Create,
3205 FastForward,
3207 Rewind,
3216 Diverged,
3219}
3220
3221fn classify_ref_move(
3237 repo: &SleyRepository,
3238 old: Option<ObjectId>,
3239 new: ObjectId,
3240 recorded_tip: Option<ObjectId>,
3241) -> GitResult<RefMove> {
3242 let Some(old) = old else {
3243 return Ok(RefMove::Create);
3244 };
3245 if old == ObjectId::null(repo.object_format()) {
3246 return Ok(RefMove::Create);
3247 }
3248 if old == new {
3249 return Ok(RefMove::Unchanged);
3250 }
3251 if commit_is_descendant_of(repo, new, old)? {
3254 return Ok(RefMove::FastForward);
3255 }
3256 if recorded_tip == Some(old)
3266 && repo.read_commit(&old).is_ok()
3267 && commit_is_descendant_of(repo, old, new)?
3268 {
3269 return Ok(RefMove::Rewind);
3270 }
3271 Ok(RefMove::Diverged)
3272}
3273
3274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3288enum WriteVerdict {
3289 Skip,
3291 Write,
3294 RequireForce,
3296}
3297
3298fn verdict_from_move(m: RefMove) -> WriteVerdict {
3303 match m {
3304 RefMove::Unchanged => WriteVerdict::Skip,
3305 RefMove::Create | RefMove::FastForward | RefMove::Rewind => WriteVerdict::Write,
3306 RefMove::Diverged => WriteVerdict::RequireForce,
3307 }
3308}
3309
3310fn classify_tag_move(
3318 old: Option<ObjectId>,
3319 target: ObjectId,
3320 recorded: Option<ObjectId>,
3321) -> WriteVerdict {
3322 match old {
3323 None => WriteVerdict::Write,
3325 Some(o) if o == target => WriteVerdict::Skip,
3327 Some(o) if recorded == Some(o) => WriteVerdict::Write,
3329 Some(_) => WriteVerdict::RequireForce,
3331 }
3332}
3333
3334#[derive(Debug)]
3337pub(crate) struct PlannedRefWrite {
3338 pub(crate) full_name: String,
3339 pub(crate) old: Option<ObjectId>,
3340 pub(crate) new: ObjectId,
3341 pub(crate) force: bool,
3342}
3343
3344#[derive(Debug)]
3347pub(crate) struct PlannedRefDelete {
3348 pub(crate) full_name: String,
3349 pub(crate) old: ObjectId,
3350}
3351
3352#[derive(Debug)]
3355pub(crate) struct DestinationReconcilePlan {
3356 pub(crate) writes: Vec<PlannedRefWrite>,
3358 pub(crate) deletes: Vec<PlannedRefDelete>,
3361 pub(crate) new_manifest: HashMap<String, ObjectId>,
3367}
3368
3369pub(crate) fn planned_write_names(plan: &DestinationReconcilePlan) -> Vec<String> {
3376 let mut names: Vec<String> = plan
3377 .writes
3378 .iter()
3379 .map(|write| write.full_name.clone())
3380 .collect();
3381 names.sort_unstable();
3382 names
3383}
3384
3385fn creatable_ref_names(
3394 served_frontier: &[RefUpdate],
3395 scope: GitPushScope,
3396 current_branch: Option<&str>,
3397) -> Option<HashSet<String>> {
3398 match scope {
3399 GitPushScope::AllThreads => None,
3400 GitPushScope::CurrentThread => {
3401 let branch = current_branch.unwrap_or_default();
3402 Some(
3403 served_frontier
3404 .iter()
3405 .filter(|update| {
3406 (matches!(update.namespace, RefNamespace::Branch) && update.name == branch)
3407 || matches!(update.namespace, RefNamespace::Note)
3408 })
3409 .map(full_ref_name)
3410 .collect(),
3411 )
3412 }
3413 }
3414}
3415
3416pub(crate) fn plan_destination_reconcile(
3464 mirror_repo: &SleyRepository,
3465 served_frontier: &[RefUpdate],
3466 creatable_names: Option<&HashSet<String>>,
3467 old_at_destination: &HashMap<String, ObjectId>,
3468 previously_exported: &HashMap<String, ObjectId>,
3469 force: bool,
3470) -> GitResult<DestinationReconcilePlan> {
3471 let desired: HashMap<String, &RefUpdate> = served_frontier
3477 .iter()
3478 .map(|u| (full_ref_name(u), u))
3479 .collect();
3480
3481 let mut names: BTreeSet<String> = desired.keys().cloned().collect();
3488 names.extend(previously_exported.keys().cloned());
3489
3490 let mut writes = Vec::new();
3491 let mut deletes = Vec::new();
3492 let mut new_manifest: HashMap<String, ObjectId> = HashMap::new();
3493
3494 for full in names {
3495 let old = old_at_destination.get(&full).copied();
3496 let recorded = previously_exported.get(&full).copied();
3497
3498 if let Some(update) = desired.get(&full).copied() {
3499 if old.is_none() && creatable_names.is_some_and(|names| !names.contains(&full)) {
3508 if let Some(recorded) = recorded {
3509 new_manifest.insert(full, recorded);
3510 }
3511 continue;
3512 }
3513 let (verdict, force_write) = match update.namespace {
3522 RefNamespace::Branch | RefNamespace::Note => {
3523 let movement = classify_ref_move(mirror_repo, old, update.target, recorded)?;
3524 (
3525 verdict_from_move(movement),
3526 matches!(movement, RefMove::Rewind),
3527 )
3528 }
3529 RefNamespace::Tag => {
3530 let verdict = classify_tag_move(old, update.target, recorded);
3531 (
3532 verdict,
3533 old.is_some_and(|old| old != update.target)
3534 && matches!(verdict, WriteVerdict::Write),
3535 )
3536 }
3537 };
3538 let proceed = match verdict {
3539 WriteVerdict::Skip => false,
3540 WriteVerdict::Write => true,
3541 WriteVerdict::RequireForce => {
3542 if force {
3543 true
3544 } else {
3545 return Err(GitBridgeError::NonFastForwardRef {
3546 name: full.clone(),
3547 old: old.unwrap_or_else(|| ObjectId::null(mirror_repo.object_format())),
3548 new: update.target,
3549 });
3550 }
3551 }
3552 };
3553 if proceed {
3554 writes.push(PlannedRefWrite {
3555 full_name: full.clone(),
3556 old,
3557 new: update.target,
3558 force: force_write || matches!(verdict, WriteVerdict::RequireForce),
3559 });
3560 }
3561 if proceed || recorded.is_some() {
3569 new_manifest.insert(full, update.target);
3570 }
3571 continue;
3572 }
3573
3574 match old {
3583 Some(old) if recorded == Some(old) || force => {
3584 deletes.push(PlannedRefDelete {
3585 full_name: full,
3586 old,
3587 });
3588 }
3590 Some(_) => {
3591 if let Some(recorded) = recorded {
3594 new_manifest.insert(full, recorded);
3595 }
3596 }
3597 None => {
3598 }
3600 }
3601 }
3602
3603 Ok(DestinationReconcilePlan {
3604 writes,
3605 deletes,
3606 new_manifest,
3607 })
3608}
3609
3610fn read_destination_ref_map(repo: &SleyRepository) -> GitResult<HashMap<String, ObjectId>> {
3614 Ok(collect_ref_updates(repo)?
3615 .iter()
3616 .map(|update| (full_ref_name(update), update.target))
3617 .collect())
3618}
3619
3620pub(crate) fn apply_ref_updates(
3621 repo: &SleyRepository,
3622 updates: &[RefUpdate],
3623 log_message: &str,
3624) -> GitResult<()> {
3625 for update in updates {
3626 let full_name = full_ref_name(update);
3627 set_reference(
3628 repo,
3629 &full_name,
3630 update.target,
3631 RefPrecondition::Any,
3632 log_message,
3633 )?;
3634 }
3635 Ok(())
3636}
3637
3638fn apply_remote_tracking_ref_updates(
3639 repo: &SleyRepository,
3640 remote_name: &str,
3641 updates: &[RefUpdate],
3642 log_message: &str,
3643) -> GitResult<()> {
3644 reject_reserved_git_remote_name(remote_name)?;
3645 for update in updates
3646 .iter()
3647 .filter(|update| update.namespace == RefNamespace::Branch)
3648 {
3649 set_reference(
3650 repo,
3651 &format!("refs/remotes/{remote_name}/{}", update.name),
3652 update.target,
3653 RefPrecondition::Any,
3654 log_message,
3655 )?;
3656 }
3657 Ok(())
3658}
3659
3660pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitResult<()> {
3664 fs::create_dir_all(dest)?;
3665 let source = open_repo(source_path)?;
3666 let target = match SleyRepository::open(dest) {
3667 Ok(repo) => repo,
3668 Err(_) => SleyRepository::init_bare(dest).map_err(git_err)?,
3669 };
3670 let updates = collect_ref_updates(&source)?;
3671 copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
3672 apply_ref_updates(
3673 &target,
3674 &updates,
3675 &format!("heddle: clone from {}", source_path.display()),
3676 )?;
3677
3678 let copied_branches: HashSet<&str> = updates
3686 .iter()
3687 .filter(|update| update.namespace == RefNamespace::Branch)
3688 .map(|update| update.name.as_str())
3689 .collect();
3690 let source_head_branch = source
3691 .head()
3692 .ok()
3693 .and_then(|head| head.branch_name().map(str::to_owned))
3694 .filter(|branch| copied_branches.contains(branch.as_str()));
3695 if let Some(branch) = source_head_branch {
3696 write_head_symref(dest, &format!("refs/heads/{branch}"))?;
3697 } else if copied_branches.contains("main") {
3698 write_head_symref(dest, "refs/heads/main")?;
3699 } else if let Some(first_branch) = updates
3700 .iter()
3701 .find(|update| update.namespace == RefNamespace::Branch)
3702 {
3703 write_head_symref(dest, &format!("refs/heads/{}", first_branch.name))?;
3704 }
3705 Ok(())
3706}
3707
3708pub fn clone_url_to_bare(
3727 url: &str,
3728 dest: &Path,
3729 depth: Option<u32>,
3730 filter: Option<&str>,
3731) -> GitResult<()> {
3732 if let Some(spec) = filter {
3736 return Err(GitBridgeError::Git(format!(
3737 "partial Git clone filter `{spec}` is not supported in Heddle's native no-git runtime yet; retry without --filter/--lazy so Heddle can import a complete object graph"
3738 )));
3739 }
3740 if let Some(source_path) = local_path_from_url(url)? {
3741 if depth.is_some() {
3742 return Err(GitBridgeError::Git(
3743 "shallow file:// Git clones are not supported in Heddle's native no-git runtime yet; retry without --depth so Heddle can copy the local Git object graph without spawning Git transport helpers"
3744 .to_string(),
3745 ));
3746 }
3747 return copy_local_repo_to_bare(&source_path, dest);
3748 }
3749 let default_branch =
3750 clone_url_to_bare_via_sley(url, dest, depth)?.or_else(|| default_branch_from_file_url(url));
3751 if let Some(branch) = default_branch
3761 && bare_branch_exists(dest, &branch)?
3762 {
3763 write_head_symref(dest, &format!("refs/heads/{branch}"))?;
3764 }
3765 Ok(())
3766}
3767
3768fn default_branch_from_file_url(url: &str) -> Option<String> {
3769 let source_path = local_path_from_url(url).ok().flatten()?;
3770 let head_path = if source_path.join("HEAD").is_file() {
3771 source_path.join("HEAD")
3772 } else {
3773 source_path.join(".git").join("HEAD")
3774 };
3775 let head = fs::read_to_string(head_path).ok()?;
3776 let branch = head.trim().strip_prefix("ref: refs/heads/")?;
3777 (!branch.is_empty()).then(|| branch.to_string())
3778}
3779
3780fn bare_branch_exists(repo_path: &Path, branch: &str) -> GitResult<bool> {
3781 let repo = open_repo(repo_path)?;
3782 Ok(repo
3783 .find_reference(&format!("refs/heads/{branch}"))
3784 .map_err(git_err)?
3785 .is_some())
3786}
3787
3788fn clone_url_to_bare_via_sley(
3789 url: &str,
3790 dest: &Path,
3791 depth: Option<u32>,
3792) -> GitResult<Option<String>> {
3793 fs::create_dir_all(dest)?;
3794 let repo = SleyRepository::init_bare(dest).map_err(git_err)?;
3795 let mut credentials = NoCredentials;
3796 let mut progress = SilentProgress;
3797 let outcome = repo
3798 .fetch(
3799 url,
3800 &heddle_mirror_fetch_refspecs()?,
3801 FetchOptions {
3802 quiet: true,
3803 auto_follow_tags: true,
3804 fetch_all_tags: true,
3805 prune: false,
3806 dry_run: false,
3807 append: false,
3808 write_fetch_head: true,
3809 tag_option_explicit: true,
3810 prune_option_explicit: true,
3811 prune_tags: false,
3812 prune_tags_option_explicit: false,
3813 refmap: None,
3814 refetch: false,
3815 record_promisor_refs: false,
3816 update_head_ok: false,
3817 ssh_options: None,
3818 atomic: false,
3819 depth,
3820 merge_srcs: Vec::new(),
3821 filter: None,
3822 cloning: true,
3823 update_shallow: false,
3824 deepen_relative: false,
3825 deepen_since: None,
3826 deepen_not: Vec::new(),
3827 },
3828 &mut credentials,
3829 &mut progress,
3830 )
3831 .map_err(|err| GitBridgeError::Git(format!("clone failed for {url}: {err}")))?;
3832 Ok(outcome
3833 .head_symref
3834 .and_then(|target| target.strip_prefix("refs/heads/").map(str::to_string)))
3835}
3836
3837#[allow(clippy::too_many_arguments)]
3864pub(crate) fn materialize_checkout_closure_from_state(
3865 heddle_repo: &HeddleRepository,
3866 mapping: &SyncMapping,
3867 mirror_repo: &SleyRepository,
3868 object_repo: &SleyRepository,
3869 tip_state_id: &ChangeId,
3870 tip_oid: ObjectId,
3871 excluded: &HashSet<ObjectId>,
3872) -> GitResult<()> {
3873 let mut lossy_roots: Vec<ObjectId> = Vec::new();
3877 let mut stack: Vec<ChangeId> = vec![*tip_state_id];
3878 let mut seen: HashSet<ChangeId> = HashSet::new();
3879
3880 while let Some(state_id) = stack.pop() {
3881 if !seen.insert(state_id) {
3882 continue;
3883 }
3884 let Some(git_oid) = resolve_mapped_git_oid(heddle_repo, mapping, &state_id, object_repo)?
3885 else {
3886 continue;
3892 };
3893
3894 if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
3898 continue;
3899 }
3900
3901 let state = heddle_repo
3902 .store()
3903 .get_state(&state_id)?
3904 .ok_or(GitBridgeError::StateNotFound(state_id))?;
3905
3906 if commit_is_byte_faithful(&state) {
3907 let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
3908 let reconstructed = commit_object_id(&content);
3912 if reconstructed != git_oid {
3913 return Err(GitBridgeError::Git(format!(
3914 "checkout reconstruction OID mismatch for state {state_id}: reconstructed {reconstructed}, expected mapped {git_oid}; \
3915 refusing to materialize a wrong-OID checkout (unmodeled fidelity gap)"
3916 )));
3917 }
3918 let written = write_commit_object(object_repo, &content)?;
3919 debug_assert_eq!(written, git_oid);
3920 stack.extend(state.parents.iter().copied());
3921 } else {
3922 lossy_roots.push(git_oid);
3926 }
3927 }
3928
3929 if object_repo.read_object(&tip_oid).is_err() && !lossy_roots.contains(&tip_oid) {
3935 lossy_roots.push(tip_oid);
3936 }
3937
3938 if !lossy_roots.is_empty() {
3939 copy_reachable_objects_excluding(mirror_repo, object_repo, lossy_roots, excluded)?;
3940 }
3941
3942 Ok(())
3943}
3944
3945fn resolve_mapped_git_oid(
3950 heddle_repo: &HeddleRepository,
3951 mapping: &SyncMapping,
3952 state_id: &ChangeId,
3953 object_repo: &SleyRepository,
3954) -> GitResult<Option<ObjectId>> {
3955 if let Some(git_oid) = mapping.get_git(state_id) {
3956 return Ok(Some(git_oid));
3957 }
3958 if let Some(git_commit) = heddle_repo
3959 .git_overlay_mapped_git_commit_for_change(state_id)
3960 .map_err(|error| GitBridgeError::Git(error.to_string()))?
3961 {
3962 let oid = ObjectId::from_hex(object_repo.object_format(), &git_commit)
3963 .map_err(|error| GitBridgeError::InvalidMapping(error.to_string()))?;
3964 return Ok(Some(oid));
3965 }
3966 Ok(None)
3967}
3968
3969pub(crate) fn copy_reachable_objects(
3970 source: &SleyRepository,
3971 target: &SleyRepository,
3972 roots: impl IntoIterator<Item = ObjectId>,
3973) -> GitResult<()> {
3974 let roots = roots.into_iter().collect::<Vec<_>>();
3975 target.copy_reachable_from(source, &roots).map_err(git_err)
3976}
3977
3978pub(crate) fn copy_reachable_objects_excluding(
3993 source: &SleyRepository,
3994 target: &SleyRepository,
3995 roots: impl IntoIterator<Item = ObjectId>,
3996 excluded: &HashSet<ObjectId>,
3997) -> GitResult<()> {
3998 if excluded.is_empty() {
3999 return copy_reachable_objects(source, target, roots);
4000 }
4001 if source.object_format() != target.object_format() {
4002 return copy_reachable_objects(source, target, roots);
4005 }
4006 sley::plumbing::sley_odb::install_reachable_pack_excluding(
4007 source.objects().as_ref(),
4008 target.objects().as_ref(),
4009 target.object_format(),
4010 roots,
4011 excluded,
4012 )
4013 .map_err(|error| GitBridgeError::Git(error.to_string()))?;
4014 target.refresh_objects();
4017 Ok(())
4018}
4019
4020fn fetch_network_remote(
4021 mirror_repo: &SleyRepository,
4022 remote_name: &str,
4023 url: &str,
4024 scope: GitFetchScope,
4025) -> GitResult<()> {
4026 let mut credentials = NoCredentials;
4027 let mut progress = SilentProgress;
4028 mirror_repo
4029 .fetch(
4030 url,
4031 &heddle_mirror_fetch_refspecs()?,
4032 FetchOptions {
4033 quiet: true,
4034 auto_follow_tags: matches!(scope, GitFetchScope::AllRefs),
4035 fetch_all_tags: matches!(scope, GitFetchScope::AllRefs),
4036 prune: false,
4037 dry_run: false,
4038 append: false,
4039 write_fetch_head: true,
4040 tag_option_explicit: true,
4041 prune_option_explicit: true,
4042 prune_tags: false,
4043 prune_tags_option_explicit: false,
4044 refmap: None,
4045 refetch: false,
4046 record_promisor_refs: false,
4047 update_head_ok: false,
4048 ssh_options: None,
4049 atomic: false,
4050 depth: None,
4051 merge_srcs: Vec::new(),
4052 filter: None,
4053 cloning: false,
4054 update_shallow: false,
4055 deepen_relative: false,
4056 deepen_since: None,
4057 deepen_not: Vec::new(),
4058 },
4059 &mut credentials,
4060 &mut progress,
4061 )
4062 .map_err(|err| GitBridgeError::Git(format!("failed to fetch from {url}: {err}")))?;
4063 let _ = remote_name;
4064 Ok(())
4065}
4066
4067fn push_network_remote(
4070 mirror_repo: &SleyRepository,
4071 heddle_dir: &Path,
4072 url: &str,
4073 scope: GitPushScope,
4074 current_branch: Option<&str>,
4075 force: bool,
4076) -> GitResult<Vec<String>> {
4077 let manifest_path = network_exported_refs_path(heddle_dir, url);
4083 let previously_exported = read_exported_refs_at(&manifest_path)?;
4084 let managed_record = read_mirror_managed_refs(mirror_repo)?;
4094 let served_frontier = collect_managed_ref_updates(mirror_repo, &managed_record)?;
4095 if served_frontier.is_empty() && previously_exported.is_empty() {
4096 return Ok(Vec::new());
4097 }
4098
4099 let mut credentials = NoCredentials;
4100 let records = mirror_repo
4101 .ls_remote(
4102 url,
4103 LsRemoteFilter {
4104 heads: false,
4105 tags: false,
4106 refs_only: true,
4107 },
4108 &|_| true,
4109 &mut credentials,
4110 )
4111 .map_err(|err| GitBridgeError::Git(format!("failed to list refs from {url}: {err}")))?;
4112 let remote_refs = records
4113 .into_iter()
4114 .filter(|record| {
4115 record.name.starts_with("refs/heads/")
4116 || record.name.starts_with("refs/tags/")
4117 || record.name.starts_with("refs/notes/")
4118 })
4119 .map(|record| (record.name, record.oid))
4120 .collect::<HashMap<_, _>>();
4121
4122 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4127 let plan = plan_destination_reconcile(
4128 mirror_repo,
4129 &served_frontier,
4130 creatable.as_ref(),
4131 &remote_refs,
4132 &previously_exported,
4133 force,
4134 )?;
4135
4136 if plan.writes.is_empty() && plan.deletes.is_empty() {
4137 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4140 return Ok(Vec::new());
4141 }
4142
4143 let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4144 let mut pack_objects = Vec::with_capacity(plan.writes.len());
4145 let force_transport_checks = plan.writes.iter().any(|write| write.force);
4146 for write in &plan.writes {
4147 commands.push(PushCommand {
4148 src: Some(write.new),
4149 dst: write.full_name.clone(),
4150 expected_old: write.old,
4151 force: write.force,
4152 });
4153 pack_objects.push(write.new);
4154 }
4155 for delete in &plan.deletes {
4156 commands.push(PushCommand {
4157 src: None,
4158 dst: delete.full_name.clone(),
4159 expected_old: Some(delete.old),
4160 force: false,
4161 });
4162 }
4163
4164 let mut credentials = NoCredentials;
4165 let mut progress = SilentProgress;
4166 mirror_repo
4167 .push_actions(
4168 url,
4169 PushActionPlan {
4170 commands,
4171 pack_objects,
4172 options: PushOptions {
4173 quiet: true,
4174 force: force || force_transport_checks,
4175 },
4176 },
4177 &mut credentials,
4178 &mut progress,
4179 )
4180 .map_err(|err| GitBridgeError::Git(format!("push failed for {url}: {err}")))?;
4181 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4184 Ok(planned_write_names(&plan))
4185}
4186
4187#[cfg(test)]
4188mod tests {
4189 use super::*;
4190
4191 #[test]
4192 fn parse_git_ref_local_branch() {
4193 let parsed = parse_git_ref("refs/heads/main").expect("local branch parses");
4194 assert_eq!(parsed.kind, GitRefKind::Branch);
4195 assert_eq!(parsed.name, "main");
4196 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4197 }
4198
4199 #[test]
4200 fn parse_git_ref_remote_branch_keeps_nested_name() {
4201 let parsed = parse_git_ref("refs/remotes/origin/feature/x").expect("remote branch parses");
4202 assert_eq!(parsed.kind, GitRefKind::Branch);
4203 assert_eq!(parsed.name, "feature/x");
4204 assert_eq!(parsed.remote, "origin");
4205 }
4206
4207 #[test]
4208 fn parse_git_ref_tag() {
4209 let parsed = parse_git_ref("refs/tags/v1.0").expect("tag parses");
4210 assert_eq!(parsed.kind, GitRefKind::Tag);
4211 assert_eq!(parsed.name, "v1.0");
4212 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4213 }
4214
4215 #[test]
4216 fn parse_git_ref_skips_head_symrefs() {
4217 assert_eq!(parse_git_ref("refs/heads/HEAD"), None);
4218 assert_eq!(parse_git_ref("refs/remotes/origin/HEAD"), None);
4219 }
4220
4221 #[test]
4222 fn parse_git_ref_rejects_unknown_or_malformed() {
4223 assert_eq!(parse_git_ref("refs/notes/heddle"), None);
4224 assert_eq!(parse_git_ref("HEAD"), None);
4225 assert_eq!(parse_git_ref("refs/remotes/origin"), None);
4227 }
4228
4229 #[test]
4230 fn parse_git_ref_rejects_reserved_git_remote_namespace() {
4231 assert_eq!(parse_git_ref("refs/remotes/git/main"), None);
4234 assert_eq!(parse_git_ref("refs/remotes/git/feature/x"), None);
4235 assert!(is_reserved_git_remote_name(REMOTE_NAME_FOR_LOCAL_GIT_REPO));
4236 assert!(!is_reserved_git_remote_name("origin"));
4237 }
4238
4239 #[test]
4240 fn local_path_from_url_rejects_hosted_heddle_scheme() {
4241 let err = local_path_from_url("heddle://weft.local:8421/org/repo")
4249 .expect_err("heddle:// must be rejected by the git exporter classifier");
4250 let msg = err.to_string();
4251 assert!(
4252 msg.contains("heddle://") && msg.contains("hosted"),
4253 "error should explain the hosted scheme cannot be pushed via the git-overlay exporter, got: {msg}"
4254 );
4255 }
4256
4257 #[test]
4258 fn local_path_from_url_still_accepts_file_and_git_urls() {
4259 assert!(
4263 local_path_from_url("file:///tmp/repo.git")
4264 .expect("file url ok")
4265 .is_some(),
4266 "file:// must still resolve to a local path"
4267 );
4268 assert!(
4269 local_path_from_url("https://example.com/org/repo.git")
4270 .expect("https url ok")
4271 .is_none(),
4272 "https git url must pass through as a network URL"
4273 );
4274 assert!(
4275 local_path_from_url("git@github.com:org/repo.git")
4276 .expect("ssh url ok")
4277 .is_none(),
4278 "ssh git url must pass through as a network URL"
4279 );
4280 }
4281
4282 #[test]
4283 fn refspec_forced_round_trips_git_format() {
4284 let spec =
4285 RefSpec::forced("refs/heads/main", "refs/heads/main").expect("valid forced refspec");
4286 assert_eq!(spec.to_git_format(), "+refs/heads/main:refs/heads/main");
4287 assert_eq!(
4288 spec.to_git_format_not_forced(),
4289 "refs/heads/main:refs/heads/main"
4290 );
4291 }
4292
4293 #[test]
4294 fn refspec_constructor_rejects_reserved_remote_name() {
4295 let err = RefSpec::new(
4296 Some("refs/remotes/git/main".to_string()),
4297 "refs/heads/main",
4298 false,
4299 )
4300 .expect_err("reserved remote source is rejected");
4301 assert!(err.to_string().contains("reserved namespace"));
4302
4303 let err = RefSpec::new(
4304 Some("refs/heads/main".to_string()),
4305 "refs/remotes/git/main",
4306 false,
4307 )
4308 .expect_err("reserved remote destination is rejected");
4309 assert!(err.to_string().contains("reserved namespace"));
4310 }
4311
4312 #[test]
4313 fn refspec_forced_rejects_reserved_remote_name() {
4314 assert!(RefSpec::forced("refs/remotes/git/main", "refs/heads/main").is_err());
4315 assert!(RefSpec::forced("refs/heads/main", "refs/remotes/git/main").is_err());
4316 }
4317
4318 #[test]
4319 fn refspec_delete_has_empty_source() {
4320 let spec = RefSpec::delete("refs/heads/stale").expect("valid delete refspec");
4321 assert_eq!(spec.to_git_format(), ":refs/heads/stale");
4322 assert_eq!(spec.to_git_format_not_forced(), ":refs/heads/stale");
4323 }
4324
4325 #[test]
4326 fn refspec_delete_rejects_reserved_remote_name() {
4327 assert!(RefSpec::delete("refs/remotes/git/stale").is_err());
4328 }
4329
4330 #[test]
4331 fn refspec_constructor_rejects_empty_source_and_destination() {
4332 let err = RefSpec::new(None, "", false)
4333 .expect_err("empty source plus empty destination is rejected");
4334 assert!(err.to_string().contains("cannot both be empty"));
4335 }
4336
4337 #[test]
4338 fn negative_refspec_prefixes_caret() {
4339 let spec = NegativeRefSpec::new("refs/heads/wip").expect("valid negative refspec");
4340 assert_eq!(spec.to_git_format(), "^refs/heads/wip");
4341 }
4342
4343 #[test]
4344 fn negative_refspec_constructor_rejects_unparseable_negation() {
4345 let err = NegativeRefSpec::new("refs/heads/wip/*").expect_err("negative glob is rejected");
4346 assert!(err.to_string().contains("Negative glob patterns"));
4347 }
4348
4349 #[test]
4350 fn negative_refspec_constructor_rejects_reserved_remote_name() {
4351 let err = NegativeRefSpec::new("refs/remotes/git/main")
4352 .expect_err("reserved remote negative source is rejected");
4353 assert!(err.to_string().contains("reserved namespace"));
4354 }
4355
4356 #[test]
4357 fn mirror_fetch_refspecs_cover_branches_and_notes() {
4358 assert_eq!(
4359 heddle_mirror_fetch_refspecs().expect("mirror refspecs are valid"),
4360 [
4361 "+refs/heads/*:refs/heads/*".to_string(),
4362 "+refs/notes/*:refs/notes/*".to_string(),
4363 ]
4364 );
4365 }
4366
4367 #[test]
4368 fn scoped_import_ref_updates_do_not_include_notes_implicitly() {
4369 let tmp = tempfile::TempDir::new().unwrap();
4370 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4371 let main = seed_commit(&repo, "main");
4372 let other = seed_commit(&repo, "other");
4373 let notes = seed_commit(&repo, "notes");
4374 set_reference(
4375 &repo,
4376 "refs/heads/main",
4377 main,
4378 RefPrecondition::MustNotExist,
4379 "test: main",
4380 )
4381 .expect("write main");
4382 set_reference(
4383 &repo,
4384 "refs/heads/other",
4385 other,
4386 RefPrecondition::MustNotExist,
4387 "test: other",
4388 )
4389 .expect("write other");
4390 set_reference(
4391 &repo,
4392 "refs/notes/heddle",
4393 notes,
4394 RefPrecondition::MustNotExist,
4395 "test: notes",
4396 )
4397 .expect("write notes");
4398
4399 let updates = collect_import_source_ref_updates(&repo, &["main".to_string()])
4400 .expect("collect scoped updates");
4401 let full_names = updates.iter().map(full_ref_name).collect::<Vec<_>>();
4402
4403 assert_eq!(full_names, vec!["refs/heads/main".to_string()]);
4404 }
4405
4406 #[test]
4407 fn fast_forward_guard_reports_exact_rewrite_before_after() {
4408 let tmp = tempfile::TempDir::new().unwrap();
4409 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4410 let root = test_commit(&repo, "root", &[]);
4411 let old = test_commit(&repo, "old", &[root]);
4412 let new = test_commit(&repo, "new", &[root]);
4413
4414 let err = ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
4415 .expect_err("sibling commit update should be refused");
4416 let message = err.to_string();
4417 assert!(message.contains("refs/heads/main"));
4418 assert!(message.contains(&old.to_string()));
4419 assert!(message.contains(&new.to_string()));
4420 assert!(message.contains("refusing to replace"));
4421 }
4422
4423 #[test]
4424 fn fast_forward_guard_allows_descendant_update() {
4425 let tmp = tempfile::TempDir::new().unwrap();
4426 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4427 let old = test_commit(&repo, "old", &[]);
4428 let new = test_commit(&repo, "new", &[old]);
4429
4430 ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
4431 .expect("descendant update should be allowed");
4432 }
4433
4434 fn test_commit(repo: &SleyRepository, message: &str, parents: &[ObjectId]) -> ObjectId {
4435 let empty_tree_oid = ObjectId::empty_tree(repo.object_format());
4436 let sig = Signature {
4437 name: GitByteString::new(b"Heddle Test".to_vec()),
4438 email: GitByteString::new(b"heddle@test".to_vec()),
4439 time: GitTime::new(0, 0),
4440 raw: b"Heddle Test <heddle@test> 0 +0000".to_vec(),
4441 };
4442 let commit = sley::CommitObject {
4443 tree: empty_tree_oid,
4444 parents: parents.to_vec(),
4445 author: sig.to_ident_bytes(),
4446 committer: sig.to_ident_bytes(),
4447 encoding: None,
4448 message: message.as_bytes().to_vec(),
4449 };
4450 repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
4451 GitObjectType::Commit,
4452 commit.write(),
4453 ))
4454 .expect("write test commit")
4455 }
4456
4457 fn seed_commit(repo: &SleyRepository, message: &str) -> ObjectId {
4458 test_commit(repo, message, &[])
4459 }
4460
4461 #[test]
4468 fn clone_url_to_bare_via_sley_honours_remote_head_symref() {
4469 let tmp = tempfile::TempDir::new().unwrap();
4470 let source = tmp.path().join("source.git");
4471 let dest = tmp.path().join("dest.git");
4472
4473 let src = SleyRepository::init_bare(&source).expect("init bare source");
4480 let seed = seed_commit(&src, "seed");
4481 for name in ["refs/heads/trunk", "refs/heads/abc-feature"] {
4482 set_reference(&src, name, seed, RefPrecondition::Any, "test: seed branch")
4483 .expect("set ref");
4484 }
4485 std::fs::write(source.join("HEAD"), b"ref: refs/heads/trunk\n").unwrap();
4488
4489 let url = format!("file://{}", source.display());
4490 clone_url_to_bare(&url, &dest, None, None).expect("clone url to bare");
4491
4492 let dest_head = std::fs::read_to_string(dest.join("HEAD")).expect("read dest HEAD");
4493 assert_eq!(
4494 dest_head.trim(),
4495 "ref: refs/heads/trunk",
4496 "dest HEAD must mirror the remote's symref (trunk), not sley's \
4497 init-time default and not the alphabetically-first branch \
4498 (abc-feature) — see heddle#141"
4499 );
4500 }
4501
4502 #[test]
4503 fn write_head_symref_is_atomic_and_round_trips() {
4504 let tmp = tempfile::TempDir::new().unwrap();
4505 let git_dir = tmp.path();
4506
4507 write_head_symref(git_dir, "refs/heads/feature/x").expect("write HEAD symref");
4508
4509 assert!(
4511 !git_dir.join("HEAD.tmp").exists(),
4512 "atomic writer must not leave HEAD.tmp behind"
4513 );
4514
4515 let contents = std::fs::read_to_string(git_dir.join("HEAD")).expect("read HEAD");
4517 assert_eq!(contents, "ref: refs/heads/feature/x\n");
4518
4519 let branch = contents
4522 .trim()
4523 .strip_prefix("ref: ")
4524 .and_then(|s| s.strip_prefix("refs/heads/"))
4525 .expect("HEAD parses as a branch symref");
4526 assert_eq!(branch, "feature/x");
4527
4528 write_head_symref(git_dir, "refs/heads/main").expect("rewrite HEAD symref");
4530 assert!(!git_dir.join("HEAD.tmp").exists());
4531 assert_eq!(
4532 std::fs::read_to_string(git_dir.join("HEAD")).unwrap(),
4533 "ref: refs/heads/main\n"
4534 );
4535 }
4536}