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::{GitRefName, Repository as HeddleRepository};
18pub use repo::{GitRefKind, ParsedGitRef, REMOTE_NAME_FOR_LOCAL_GIT_REPO};
19pub(crate) use repo::{GitRefContentNamespace as RefNamespace, is_reserved_git_remote_name};
20use sley::{
21 BString as GitBString, DeleteRef, FullName, GitObjectType, GitTime, HeadUpdateOptions, Index,
22 IndexEntry, IndexWriteOptions, ObjectFormat, ObjectId, RefPrecondition, ReferenceTarget,
23 Repository as SleyRepository, Signature,
24 plumbing::sley_core::ByteString as GitByteString,
25 remote::{
26 FetchOptions, LsRemoteFilter, NoCredentials, PushActionPlan, PushCommand, PushOptions,
27 SilentProgress,
28 },
29};
30
31use super::{
32 git_export::{commit_is_byte_faithful, export_all, export_current_thread},
33 git_ingest::import_git_history,
34 git_reconstruct::{commit_object_id, reconstruct_commit_bytes, write_commit_object},
35 git_util::ImportStats,
36};
37
38#[derive(Debug, thiserror::Error)]
40pub enum GitBridgeError {
41 #[error("git error: {0}")]
42 Git(String),
43
44 #[error("store error: {0}")]
45 Store(#[from] HeddleError),
46
47 #[error("io error: {0}")]
48 Io(#[from] std::io::Error),
49
50 #[error("invalid trailer format: {0}")]
51 InvalidTrailer(String),
52
53 #[error("missing required trailer: {0}")]
54 MissingTrailer(String),
55
56 #[error("invalid mapping: {0}")]
57 InvalidMapping(String),
58
59 #[error("commit not found: {0}")]
60 CommitNotFound(String),
61
62 #[error("state not found: {0}")]
63 StateNotFound(ChangeId),
64
65 #[error("git repository not initialized")]
66 GitRepoNotInitialized,
67
68 #[error(
69 "shallow Git repository at {repository} cannot be imported until full ancestry is available"
70 )]
71 ShallowClone {
72 repository: PathBuf,
73 retry_command: String,
74 },
75
76 #[error("conflict during sync: {0}")]
77 Conflict(String),
78
79 #[error("Git-overlay mapping conflict: {message}")]
80 MappingConflict { message: String },
81
82 #[error("Git branch '{branch}' cannot be imported as a Heddle thread: {message}")]
83 InvalidThreadName { branch: String, message: String },
84
85 #[error(
86 "Git branch {branch} and Heddle thread {thread} diverged: thread {thread_change}, branch {branch_change}"
87 )]
88 GitHeddleThreadDiverged {
89 thread: String,
90 branch: String,
91 thread_change: ChangeId,
92 branch_change: ChangeId,
93 },
94
95 #[error(
96 "ref update would rewrite {name}: {old} -> {new}; refusing to replace a user-visible Git commit with a Heddle export commit"
97 )]
98 NonFastForwardRef {
99 name: String,
100 old: ObjectId,
101 new: ObjectId,
102 },
103
104 #[error(
105 "remote branch {upstream} does not fast-forward the local Git checkpoint for {branch}: local {local}, remote {remote}"
106 )]
107 RemoteDiverged {
108 branch: String,
109 upstream: String,
110 local: ObjectId,
111 remote: ObjectId,
112 },
113
114 #[error("change id parse error: {0}")]
115 ChangeIdParse(#[from] ChangeIdParseError),
116}
117
118pub type GitResult<T> = std::result::Result<T, GitBridgeError>;
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub(crate) struct RefUpdate {
123 pub name: String,
124 pub target: ObjectId,
125 pub namespace: RefNamespace,
126}
127
128fn reject_reserved_git_remote_name(remote: &str) -> GitResult<()> {
135 if is_reserved_git_remote_name(remote) {
136 return Err(GitBridgeError::Git(format!(
137 "a Git remote named '{remote}' collides with heddle's reserved namespace \
138 (local refs are recorded under the '{REMOTE_NAME_FOR_LOCAL_GIT_REPO}' sentinel); \
139 rename the remote (e.g. `git remote rename {remote} origin`) and retry"
140 )));
141 }
142 Ok(())
143}
144
145fn remote_name_from_remote_ref(ref_name: &str) -> Option<&str> {
146 GitRefName::new(ref_name).remote_name()
147}
148
149fn validate_refspec_ref(ref_name: &str) -> GitResult<()> {
150 if let Some(remote) = remote_name_from_remote_ref(ref_name) {
151 reject_reserved_git_remote_name(remote)?;
152 }
153 Ok(())
154}
155
156pub fn parse_git_ref(ref_name: &str) -> Option<ParsedGitRef<'_>> {
164 RefSpec::new(None, ref_name, false).ok()?;
165 GitRefName::new(ref_name).bridge_ref()
166}
167
168mod refspec {
171 use super::{GitResult, validate_refspec_ref};
172
173 #[derive(Debug, Clone, PartialEq, Eq)]
174 pub struct RefSpec {
175 forced: bool,
176 source: Option<String>,
178 destination: String,
179 }
180
181 impl RefSpec {
182 pub fn new(
184 source: Option<String>,
185 destination: impl Into<String>,
186 forced: bool,
187 ) -> GitResult<Self> {
188 let destination = destination.into();
189 if source.is_none() && destination.is_empty() {
190 return Err(super::GitBridgeError::InvalidMapping(
191 "refspec source and destination cannot both be empty".to_string(),
192 ));
193 }
194 if let Some(source) = source.as_deref() {
195 validate_refspec_ref(source)?;
196 }
197 validate_refspec_ref(&destination)?;
198 Ok(Self {
199 forced,
200 source,
201 destination,
202 })
203 }
204
205 pub fn forced(
207 source: impl Into<String>,
208 destination: impl Into<String>,
209 ) -> GitResult<Self> {
210 Self::new(Some(source.into()), destination, true)
211 }
212
213 pub fn delete(destination: impl Into<String>) -> GitResult<Self> {
216 Self::new(None, destination, false)
217 }
218
219 pub fn to_git_format(&self) -> String {
221 format!(
222 "{}{}",
223 if self.forced { "+" } else { "" },
224 self.to_git_format_not_forced()
225 )
226 }
227
228 pub fn to_git_format_not_forced(&self) -> String {
230 format!(
231 "{}:{}",
232 self.source.as_deref().unwrap_or(""),
233 self.destination
234 )
235 }
236 }
237}
238
239pub use refspec::RefSpec;
240
241mod negative_refspec {
244 use super::{GitBridgeError, GitResult, validate_refspec_ref};
245
246 #[derive(Debug, Clone, PartialEq, Eq)]
247 pub struct NegativeRefSpec {
248 source: String,
249 }
250
251 impl NegativeRefSpec {
252 pub fn new(source: impl Into<String>) -> GitResult<Self> {
255 let source = source.into();
256 validate_refspec_ref(&source)?;
257 if source.contains('*') {
258 return Err(GitBridgeError::InvalidMapping(format!(
259 "invalid negative refspec source '{source}': Negative glob patterns are not supported"
260 )));
261 }
262 Ok(Self { source })
263 }
264
265 pub fn to_git_format(&self) -> String {
267 format!("^{}", self.source)
268 }
269 }
270}
271
272pub use negative_refspec::NegativeRefSpec;
276
277fn heddle_mirror_fetch_refspecs() -> GitResult<[String; 2]> {
281 Ok([
282 RefSpec::forced("refs/heads/*", "refs/heads/*")?.to_git_format(),
283 RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format(),
284 ])
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum GitPushScope {
289 CurrentThread,
290 AllThreads,
291}
292
293#[derive(Debug, Clone, Default)]
294pub struct GitPullOutcome {
295 pub changed: bool,
296 pub states_created: usize,
297 pub commits_seen: usize,
298 pub materialized_checkout: bool,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302enum PullPreflight {
303 UpToDate,
304 ImportRequired,
305}
306
307fn pull_outcome(stats: &ImportStats, materialized_checkout: bool) -> GitPullOutcome {
308 GitPullOutcome {
309 changed: materialized_checkout || stats.states_created > 0,
310 states_created: stats.states_created,
311 commits_seen: stats.commits_imported,
312 materialized_checkout,
313 }
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317enum GitFetchScope {
318 BranchesAndNotes,
319 AllRefs,
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323enum RefreshCheckoutAfterFetch {
324 Yes,
325 No,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329enum RemoteDirection {
330 Fetch,
331 Push,
332}
333
334#[derive(Debug, Clone)]
335enum ResolvedRemote {
336 Local(PathBuf),
337 Url(String),
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum WriteThroughSkipReason {
342 MissingDotGit,
343 DetachedHead,
344 NoAttachedThread,
345 NoMappedCommit,
346 MirrorIsWorktree,
347 IndexAlreadyDirty,
348}
349
350impl std::fmt::Display for WriteThroughSkipReason {
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 match self {
353 WriteThroughSkipReason::MissingDotGit => {
354 write!(f, "this checkout does not have a Git working tree")
355 }
356 WriteThroughSkipReason::DetachedHead => {
357 write!(f, "Git HEAD is detached")
358 }
359 WriteThroughSkipReason::NoAttachedThread => {
360 write!(f, "the attached Heddle thread does not resolve to a state")
361 }
362 WriteThroughSkipReason::NoMappedCommit => {
363 write!(f, "the current Heddle state has not been exported to Git")
364 }
365 WriteThroughSkipReason::MirrorIsWorktree => {
366 write!(f, "the Git mirror is already the active checkout")
367 }
368 WriteThroughSkipReason::IndexAlreadyDirty => {
369 write!(f, "the Git index is already locked by another operation")
370 }
371 }
372 }
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum WriteThroughOutcome {
377 Wrote(ObjectId),
378 Skipped(WriteThroughSkipReason),
379}
380
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub(crate) struct LocalGitIdentity {
383 pub(crate) name: String,
384 pub(crate) email: String,
385}
386
387impl LocalGitIdentity {
388 pub(crate) fn from_principal(principal: &Principal) -> Self {
389 Self {
390 name: principal.name.clone(),
391 email: principal.email.clone(),
392 }
393 }
394
395 pub(crate) fn to_ident_line(&self, seconds: i64) -> Vec<u8> {
396 format!("{} <{}> {} +0000", self.name, self.email, seconds).into_bytes()
397 }
398
399 pub(crate) fn to_signature(&self, seconds: i64) -> Signature {
400 let ident = self.to_ident_line(seconds);
401 Signature {
402 name: GitByteString::new(self.name.as_bytes().to_vec()),
403 email: GitByteString::new(self.email.as_bytes().to_vec()),
404 time: GitTime::new(seconds, 0),
405 raw: ident,
406 }
407 }
408}
409
410impl WriteThroughOutcome {
411 pub fn object_id(self) -> Option<ObjectId> {
412 match self {
413 WriteThroughOutcome::Wrote(oid) => Some(oid),
414 WriteThroughOutcome::Skipped(_) => None,
415 }
416 }
417
418 pub fn skip_reason(self) -> Option<WriteThroughSkipReason> {
419 match self {
420 WriteThroughOutcome::Skipped(reason) => Some(reason),
421 WriteThroughOutcome::Wrote(_) => None,
422 }
423 }
424}
425
426#[derive(Debug, Clone, Default, PartialEq, Eq)]
428pub struct SyncMapping {
429 heddle_to_git: HashMap<ChangeId, ObjectId>,
431 git_to_heddle: HashMap<ObjectId, ChangeId>,
433}
434
435impl SyncMapping {
436 pub fn new() -> Self {
438 Self::default()
439 }
440
441 pub fn insert(&mut self, change_id: ChangeId, git_oid: ObjectId) {
443 if let Some(previous_git) = self.heddle_to_git.remove(&change_id) {
444 self.git_to_heddle.remove(&previous_git);
445 }
446 if let Some(previous_change) = self.git_to_heddle.remove(&git_oid) {
447 self.heddle_to_git.remove(&previous_change);
448 }
449 self.heddle_to_git.insert(change_id, git_oid);
450 self.git_to_heddle.insert(git_oid, change_id);
451 }
452
453 pub(crate) fn insert_checked(
455 &mut self,
456 change_id: ChangeId,
457 git_oid: ObjectId,
458 ) -> GitResult<()> {
459 if let Some(existing) = self.heddle_to_git.get(&change_id)
460 && *existing != git_oid
461 {
462 return Err(GitBridgeError::MappingConflict {
463 message: format!(
464 "change id {} mapped to {} (new {})",
465 change_id, existing, git_oid
466 ),
467 });
468 }
469
470 if let Some(existing) = self.git_to_heddle.get(&git_oid)
471 && *existing != change_id
472 {
473 return Err(GitBridgeError::MappingConflict {
474 message: format!(
475 "git oid {} mapped to {} (new {})",
476 git_oid, existing, change_id
477 ),
478 });
479 }
480
481 self.insert(change_id, git_oid);
482 Ok(())
483 }
484
485 pub fn get_git(&self, change_id: &ChangeId) -> Option<ObjectId> {
487 self.heddle_to_git.get(change_id).copied()
488 }
489
490 pub fn get_heddle(&self, git_oid: ObjectId) -> Option<ChangeId> {
492 self.git_to_heddle.get(&git_oid).copied()
493 }
494
495 pub fn has_heddle(&self, change_id: &ChangeId) -> bool {
497 self.heddle_to_git.contains_key(change_id)
498 }
499
500 pub(crate) fn remove(&mut self, change_id: &ChangeId) -> Option<ObjectId> {
510 let git_oid = self.heddle_to_git.remove(change_id)?;
511 self.git_to_heddle.remove(&git_oid);
512 Some(git_oid)
513 }
514
515 pub fn has_git(&self, git_oid: ObjectId) -> bool {
517 self.git_to_heddle.contains_key(&git_oid)
518 }
519
520 pub(crate) fn iter(&self) -> impl Iterator<Item = (&ChangeId, &ObjectId)> {
522 self.heddle_to_git.iter()
523 }
524
525 pub(crate) fn is_empty(&self) -> bool {
530 self.heddle_to_git.is_empty()
531 }
532
533 pub(crate) fn retain_git_objects(&mut self, repo: &SleyRepository) {
534 let retained: Vec<(ChangeId, ObjectId)> = self
535 .heddle_to_git
536 .iter()
537 .filter_map(|(change_id, git_oid)| {
538 repo.read_object(git_oid)
539 .ok()
540 .map(|_| (*change_id, *git_oid))
541 })
542 .collect();
543
544 self.heddle_to_git.clear();
545 self.git_to_heddle.clear();
546 for (change_id, git_oid) in retained {
547 self.insert(change_id, git_oid);
548 }
549 }
550
551 #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
552 pub(crate) fn retain_git_object_set(&mut self, reachable: &HashSet<ObjectId>) -> usize {
553 let before = self.heddle_to_git.len();
554 let retained: Vec<(ChangeId, ObjectId)> = self
555 .heddle_to_git
556 .iter()
557 .filter(|(_, git_oid)| reachable.contains(*git_oid))
558 .map(|(change_id, git_oid)| (*change_id, *git_oid))
559 .collect();
560
561 self.heddle_to_git.clear();
562 self.git_to_heddle.clear();
563 for (change_id, git_oid) in retained {
564 self.insert(change_id, git_oid);
565 }
566 before.saturating_sub(self.heddle_to_git.len())
567 }
568}
569
570pub struct GitBridge<'a> {
572 pub(crate) heddle_repo: &'a HeddleRepository,
573 pub(crate) git_repo_path: Option<PathBuf>,
574 pub(crate) mapping: SyncMapping,
575 pub(crate) commit_message_overrides: HashMap<ChangeId, String>,
576 pub(crate) commit_parent_overrides: HashMap<ChangeId, Vec<ObjectId>>,
577}
578
579struct MappingFileSnapshot {
580 path: PathBuf,
581 contents: Option<Vec<u8>>,
582}
583
584impl MappingFileSnapshot {
585 fn read(path: PathBuf) -> GitResult<Self> {
586 let contents = match fs::read(&path) {
587 Ok(contents) => Some(contents),
588 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
589 Err(error) => return Err(error.into()),
590 };
591 Ok(Self { path, contents })
592 }
593
594 fn restore(self) -> GitResult<()> {
595 match self.contents {
596 Some(contents) => {
597 if let Some(parent) = self.path.parent() {
598 fs::create_dir_all(parent)?;
599 }
600 fs::write(&self.path, contents)?;
601 }
602 None => match fs::remove_file(&self.path) {
603 Ok(()) => {}
604 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
605 Err(error) => return Err(error.into()),
606 },
607 }
608 Ok(())
609 }
610}
611
612impl<'a> GitBridge<'a> {
613 pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
615 Self {
616 heddle_repo,
617 git_repo_path: None,
618 mapping: SyncMapping::new(),
619 commit_message_overrides: HashMap::new(),
620 commit_parent_overrides: HashMap::new(),
621 }
622 }
623
624 pub fn init_mirror(&mut self) -> GitResult<()> {
626 let _guard = self.init_mirror_with_guard()?;
627 _guard.commit();
628 Ok(())
629 }
630
631 pub(crate) fn init_mirror_with_guard(&mut self) -> GitResult<MirrorInitGuard> {
636 let git_dir = self.heddle_repo.heddle_dir().join("git");
637
638 let did_create = if git_dir.exists() {
639 let _ = open_repo(&git_dir)?;
640 false
641 } else {
642 fs::create_dir_all(&git_dir)?;
643 let _ = SleyRepository::init_bare(&git_dir).map_err(git_err)?;
644 let mirror_repo = open_repo(&git_dir)?;
645 seed_checkout_note_refs_into_mirror(self.heddle_repo.root(), &mirror_repo)?;
646 true
647 };
648
649 self.git_repo_path = Some(git_dir.clone());
650 Ok(MirrorInitGuard::new_from_init(git_dir, did_create))
651 }
652
653 pub fn mirror_path(&self) -> PathBuf {
655 self.heddle_repo.heddle_dir().join("git")
656 }
657
658 pub fn is_initialized(&self) -> bool {
660 self.mirror_path().exists()
661 }
662
663 pub(crate) fn open_git_repo(&self) -> GitResult<SleyRepository> {
665 if let Some(ref path) = self.git_repo_path {
666 open_repo(path)
667 } else {
668 let mirror_path = self.mirror_path();
669 if mirror_path.exists() {
670 open_repo(&mirror_path)
671 } else {
672 open_repo(self.heddle_repo.root())
673 }
674 }
675 }
676
677 pub(crate) fn sort_states_topologically(
679 &self,
680 states: &[ChangeId],
681 ) -> GitResult<Vec<ChangeId>> {
682 let mut sorted = Vec::new();
683 let mut visited: std::collections::HashSet<ChangeId> = std::collections::HashSet::new();
684
685 fn visit<S: ObjectStore + ?Sized>(
686 state_id: &ChangeId,
687 store: &S,
688 visited: &mut std::collections::HashSet<ChangeId>,
689 sorted: &mut Vec<ChangeId>,
690 ) -> GitResult<()> {
691 if visited.contains(state_id) {
692 return Ok(());
693 }
694
695 if let Some(state) = store.get_state(state_id)? {
696 for parent in &state.parents {
697 visit(parent, store, visited, sorted)?;
698 }
699 }
700
701 visited.insert(*state_id);
702 sorted.push(*state_id);
703
704 Ok(())
705 }
706
707 for state_id in states {
708 visit(
709 state_id,
710 self.heddle_repo.store(),
711 &mut visited,
712 &mut sorted,
713 )?;
714 }
715
716 Ok(sorted)
717 }
718
719 pub fn export(&mut self) -> GitResult<super::git_util::ExportStats> {
721 export_all(self)
722 }
723
724 pub(crate) fn set_commit_message_override(&mut self, state_id: ChangeId, message: String) {
725 self.commit_message_overrides.insert(state_id, message);
726 }
727
728 pub(crate) fn set_commit_parent_override(
729 &mut self,
730 state_id: ChangeId,
731 parents: Vec<ObjectId>,
732 ) {
733 self.commit_parent_overrides.insert(state_id, parents);
734 }
735
736 pub(crate) fn with_mapping_rollback<T>(
737 &mut self,
738 operation: impl FnOnce(&mut Self) -> GitResult<T>,
739 ) -> GitResult<T> {
740 let mapping = self.mapping.clone();
741 let commit_message_overrides = self.commit_message_overrides.clone();
742 let commit_parent_overrides = self.commit_parent_overrides.clone();
743 let mapping_file = MappingFileSnapshot::read(self.mapping_path())?;
744 let mapping_tmp_file = MappingFileSnapshot::read(self.mapping_tmp_path())?;
745
746 match operation(self) {
747 Ok(value) => Ok(value),
748 Err(error) => {
749 self.mapping = mapping;
750 self.commit_message_overrides = commit_message_overrides;
751 self.commit_parent_overrides = commit_parent_overrides;
752 if let Err(rollback_error) = mapping_file
753 .restore()
754 .and_then(|()| mapping_tmp_file.restore())
755 {
756 return Err(GitBridgeError::Git(format!(
757 "operation failed ({error}); additionally failed to roll back git bridge mapping state ({rollback_error})"
758 )));
759 }
760 Err(error)
761 }
762 }
763 }
764
765 pub fn push(&mut self, remote_name: &str) -> GitResult<Vec<String>> {
768 self.push_with_scope(remote_name, GitPushScope::AllThreads)
769 }
770
771 pub fn push_with_scope(
774 &mut self,
775 remote_name: &str,
776 scope: GitPushScope,
777 ) -> GitResult<Vec<String>> {
778 self.push_with_scope_force(remote_name, scope, false)
779 }
780
781 pub fn push_with_scope_force(
790 &mut self,
791 remote_name: &str,
792 scope: GitPushScope,
793 force: bool,
794 ) -> GitResult<Vec<String>> {
795 self.init_mirror()?;
796 let current_branch = match scope {
797 GitPushScope::CurrentThread => Some(self.current_attached_thread_for_push()?),
798 GitPushScope::AllThreads => None,
799 };
800 match scope {
801 GitPushScope::CurrentThread => {
802 export_current_thread(self, current_branch.as_deref().expect("current branch"))?;
803 }
804 GitPushScope::AllThreads => {
805 self.export()?;
806 self.mirror_checkout_tags_for_push()?;
807 }
808 }
809 self.write_current_checkout_from_existing_mirror()?;
810
811 let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
819 match self.resolve_remote(remote_name, RemoteDirection::Push)? {
820 ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
821 &target_path,
822 &log_message,
823 false,
824 scope,
825 current_branch.as_deref(),
826 force,
827 ),
828 ResolvedRemote::Url(url) => {
829 let mirror_repo = self.open_git_repo()?;
830 push_network_remote(
831 &mirror_repo,
832 self.heddle_repo.heddle_dir(),
833 &url,
834 scope,
835 current_branch.as_deref(),
836 force,
837 )
838 }
839 }
840 }
841
842 fn current_attached_thread_for_push(&self) -> GitResult<String> {
843 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
844 return Err(GitBridgeError::Git(
845 "cannot push the current Git-overlay branch from a detached Heddle HEAD; use --all-threads to push all exported refs".to_string(),
846 ));
847 };
848 if self.heddle_repo.refs().get_thread(&thread)?.is_none() {
849 return Err(GitBridgeError::Git(format!(
850 "attached thread '{thread}' has no state to push"
851 )));
852 }
853 Ok(thread.to_string())
854 }
855
856 pub fn export_to_path(
860 &mut self,
861 target_path: &Path,
862 ) -> GitResult<super::git_util::ExportStats> {
863 self.init_mirror()?;
864 let stats = self.export()?;
865 self.copy_mirror_to_path(
866 target_path,
867 &format!("heddle: export from {}", self.heddle_repo.root().display()),
868 true,
869 GitPushScope::AllThreads,
870 None,
871 false,
872 )?;
873 Ok(stats)
874 }
875
876 fn copy_mirror_to_path(
885 &mut self,
886 target_path: &Path,
887 log_message: &str,
888 init_if_missing: bool,
889 scope: GitPushScope,
890 current_branch: Option<&str>,
891 force: bool,
892 ) -> GitResult<Vec<String>> {
893 let mirror_repo = self.open_git_repo()?;
894 let target_repo = if target_path.exists() {
895 open_repo(target_path)?
896 } else if init_if_missing {
897 fs::create_dir_all(target_path)?;
898 SleyRepository::init_bare(target_path).map_err(git_err)?;
899 open_repo(target_path)?
900 } else {
901 return Err(GitBridgeError::Git(format!(
902 "destination '{}' does not exist",
903 target_path.display()
904 )));
905 };
906
907 let managed_record = read_mirror_managed_refs(&mirror_repo)?;
921 let served_frontier = collect_managed_ref_updates(&mirror_repo, &managed_record)?;
922 copy_reachable_objects(
923 &mirror_repo,
924 &target_repo,
925 served_frontier.iter().map(|update| update.target),
926 )?;
927
928 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
936 let old_at_destination = read_destination_ref_map(&target_repo)?;
937 let previously_exported = read_exported_refs(&target_repo)?;
938 let plan = plan_destination_reconcile(
939 &mirror_repo,
940 &served_frontier,
941 creatable.as_ref(),
942 &old_at_destination,
943 &previously_exported,
944 force,
945 )?;
946 for write in &plan.writes {
947 let constraint = match write.old {
948 Some(old) => RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(old)),
949 None => RefPrecondition::MustNotExist,
950 };
951 set_reference(
952 &target_repo,
953 &write.full_name,
954 write.new,
955 constraint,
956 log_message,
957 )?;
958 }
959 for delete in &plan.deletes {
960 delete_reference_matching(&target_repo, &delete.full_name, delete.old)?;
961 }
962 write_exported_refs(&target_repo, &plan.new_manifest)?;
963 Ok(planned_write_names(&plan))
964 }
965
966 pub fn fetch(&mut self, remote_name: &str) -> GitResult<()> {
969 self.fetch_with_scope(
970 remote_name,
971 GitFetchScope::BranchesAndNotes,
972 RefreshCheckoutAfterFetch::Yes,
973 )
974 }
975
976 fn fetch_with_scope(
977 &mut self,
978 remote_name: &str,
979 scope: GitFetchScope,
980 refresh_checkout: RefreshCheckoutAfterFetch,
981 ) -> GitResult<()> {
982 reject_reserved_git_remote_name(remote_name)?;
983 self.init_mirror()?;
984 let current_branch = self.heddle_repo.git_overlay_current_branch()?;
985 let tracking_remote = checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
986 .or_else(|| {
987 (!looks_like_remote_location(remote_name)).then(|| remote_name.to_string())
988 });
989 if let Some(tracking_remote) = tracking_remote.as_deref() {
993 reject_reserved_git_remote_name(tracking_remote)?;
994 }
995
996 let mirror_repo = self.open_git_repo()?;
997 match self.resolve_remote(remote_name, RemoteDirection::Fetch)? {
998 ResolvedRemote::Local(path) => {
999 let remote_repo = open_repo(&path)?;
1000 let updates = collect_ref_updates_for_fetch(&remote_repo, scope)?;
1001 tracing::debug!(
1002 remote = remote_name,
1003 path = %path.display(),
1004 refs = updates.len(),
1005 notes = updates
1006 .iter()
1007 .filter(|update| update.namespace == RefNamespace::Note)
1008 .count(),
1009 "fetching Git refs from local remote"
1010 );
1011 copy_reachable_objects(
1012 &remote_repo,
1013 &mirror_repo,
1014 updates.iter().map(|update| update.target),
1015 )?;
1016 apply_ref_updates(
1017 &mirror_repo,
1018 &updates,
1019 &format!("heddle: fetch from {remote_name}"),
1020 )?;
1021 if let Some(tracking_remote) = tracking_remote.as_deref() {
1022 apply_remote_tracking_ref_updates(
1023 &mirror_repo,
1024 tracking_remote,
1025 &updates,
1026 &format!("heddle: fetch from {remote_name}"),
1027 )?;
1028 }
1029 }
1030 ResolvedRemote::Url(url) => {
1031 fetch_network_remote(&mirror_repo, remote_name, &url, scope)?;
1032 let updates = collect_ref_updates_for_fetch(&mirror_repo, scope)?;
1033 if let Some(tracking_remote) = tracking_remote.as_deref() {
1034 apply_remote_tracking_ref_updates(
1035 &mirror_repo,
1036 tracking_remote,
1037 &updates,
1038 &format!("heddle: fetch from {remote_name}"),
1039 )?;
1040 }
1041 }
1042 }
1043
1044 self.git_repo_path = Some(self.mirror_path());
1045 if matches!(refresh_checkout, RefreshCheckoutAfterFetch::Yes) {
1046 if let Some(tracking_remote) = tracking_remote.as_deref() {
1047 self.refresh_checkout_remote_tracking_refs(tracking_remote)?;
1048 }
1049 if let Some(branch) = current_branch {
1050 self.refresh_checkout_remote_tracking_ref(remote_name, &branch)?;
1051 }
1052 self.refresh_checkout_note_refs_from_mirror()?;
1053 }
1054 Ok(())
1055 }
1056
1057 pub(crate) fn hydrate_checkout_heddle_notes_without_mirror(root: &Path) -> bool {
1065 if checkout_note_ref_exists(root).unwrap_or(false) {
1066 return true;
1067 }
1068
1069 let mut remotes = match checkout_remote_url_items(root) {
1070 Ok(remotes) => remotes
1071 .into_iter()
1072 .map(|(name, _)| name)
1073 .collect::<Vec<_>>(),
1074 Err(error) => {
1075 tracing::debug!(
1076 error = %error,
1077 "skipping configured remote note hydration before ingest-backed adopt"
1078 );
1079 return false;
1080 }
1081 };
1082 remotes.sort_by(|left, right| {
1083 match (left.as_str() == "origin", right.as_str() == "origin") {
1084 (true, false) => std::cmp::Ordering::Less,
1085 (false, true) => std::cmp::Ordering::Greater,
1086 _ => left.cmp(right),
1087 }
1088 });
1089 remotes.dedup();
1090
1091 for remote in remotes {
1092 match hydrate_checkout_notes_from_remote_without_mirror(root, &remote) {
1093 Ok(()) if checkout_note_ref_exists(root).unwrap_or(false) => return true,
1094 Ok(()) => {}
1095 Err(error) => {
1096 tracing::debug!(
1097 remote = remote.as_str(),
1098 error = %error,
1099 "configured remote did not provide Heddle notes during ingest-backed adopt"
1100 );
1101 }
1102 }
1103 }
1104
1105 false
1106 }
1107
1108 pub fn pull(&mut self, remote_name: &str) -> GitResult<GitPullOutcome> {
1110 let head_before = self.heddle_repo.refs().read_head()?;
1111 let attached_before = match &head_before {
1112 Head::Attached { thread } => self
1113 .heddle_repo
1114 .refs()
1115 .get_thread(thread)?
1116 .map(|state| (thread.to_string(), state)),
1117 Head::Detached { .. } => None,
1118 };
1119 let attached_thread = attached_before.as_ref().map(|(thread, _)| thread.clone());
1120
1121 self.fetch_with_scope(
1122 remote_name,
1123 GitFetchScope::AllRefs,
1124 RefreshCheckoutAfterFetch::No,
1125 )?;
1126 if self.preflight_attached_pull_fast_forward(remote_name, attached_before.as_ref())?
1127 == PullPreflight::UpToDate
1128 {
1129 if let Some(thread) = attached_thread {
1130 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1131 }
1132 self.refresh_checkout_note_refs_from_mirror()?;
1133 return Ok(GitPullOutcome::default());
1134 }
1135 let mirror_path = self.mirror_path();
1136 let stats = import_git_history(self, Some(&mirror_path), &[], Default::default(), None)?;
1137
1138 let mut materialized_attached_thread = false;
1139 if let Some((thread, old_state)) = attached_before
1140 && let Some(new_state) = self
1141 .heddle_repo
1142 .refs()
1143 .get_thread(&ThreadName::new(&thread))?
1144 && new_state != old_state
1145 {
1146 self.heddle_repo
1147 .refs()
1148 .set_thread(&ThreadName::new(&thread), &old_state)?;
1149 self.heddle_repo.refs().write_head(&Head::Attached {
1150 thread: ThreadName::new(&thread),
1151 })?;
1152 self.heddle_repo
1153 .goto_verified_clean_without_record(&new_state)?;
1154 self.heddle_repo
1155 .refs()
1156 .set_thread(&ThreadName::new(&thread), &new_state)?;
1157 self.heddle_repo.refs().write_head(&Head::Attached {
1158 thread: ThreadName::new(&thread),
1159 })?;
1160 materialized_attached_thread = true;
1161 }
1162
1163 if materialized_attached_thread {
1164 self.write_current_checkout_from_existing_mirror()?;
1165 }
1166 if let Some(thread) = attached_thread {
1167 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1168 }
1169 self.refresh_checkout_note_refs_from_mirror()?;
1170 Ok(pull_outcome(&stats, materialized_attached_thread))
1171 }
1172
1173 fn preflight_attached_pull_fast_forward(
1174 &mut self,
1175 remote_name: &str,
1176 attached_before: Option<&(String, ChangeId)>,
1177 ) -> GitResult<PullPreflight> {
1178 let Some((thread, state_id)) = attached_before else {
1179 return Ok(PullPreflight::ImportRequired);
1180 };
1181 self.build_existing_mapping(None)?;
1182 let Some(local_git_oid) = self.mapping.get_git(state_id) else {
1183 return Ok(PullPreflight::ImportRequired);
1184 };
1185 let mirror_repo = self.open_git_repo()?;
1186 let branch_ref = format!("refs/heads/{thread}");
1187 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1188 return Ok(PullPreflight::ImportRequired);
1189 };
1190 let Some(remote_git_oid) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1191 return Ok(PullPreflight::ImportRequired);
1192 };
1193 if remote_git_oid == local_git_oid {
1194 return Ok(PullPreflight::UpToDate);
1195 }
1196 if commit_is_descendant_of(&mirror_repo, remote_git_oid, local_git_oid)? {
1197 return Ok(PullPreflight::ImportRequired);
1198 }
1199 Err(GitBridgeError::RemoteDiverged {
1200 branch: thread.clone(),
1201 upstream: format!("{remote_name}/{thread}"),
1202 local: local_git_oid,
1203 remote: remote_git_oid,
1204 })
1205 }
1206
1207 fn mirror_checkout_tags_for_push(&self) -> GitResult<()> {
1208 if !self.heddle_repo.root().join(".git").exists() {
1209 return Ok(());
1210 }
1211
1212 let mirror_repo = self.open_git_repo()?;
1213 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1214 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1215 return Ok(());
1216 }
1217 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1218 let tag_updates = collect_ref_updates(&object_repo)?
1219 .into_iter()
1220 .filter(|update| update.namespace == RefNamespace::Tag)
1221 .collect::<Vec<_>>();
1222 if tag_updates.is_empty() {
1223 return Ok(());
1224 }
1225
1226 copy_reachable_objects(
1227 &object_repo,
1228 &mirror_repo,
1229 tag_updates.iter().map(|u| u.target),
1230 )?;
1231 apply_ref_updates(
1232 &mirror_repo,
1233 &tag_updates,
1234 "heddle: mirror checkout tags before push",
1235 )?;
1236 let mut record = read_mirror_managed_refs(&mirror_repo)?;
1244 for update in &tag_updates {
1245 record.insert(full_ref_name(update), update.target);
1246 }
1247 write_mirror_managed_refs(&mirror_repo, &record)?;
1248 Ok(())
1249 }
1250
1251 pub(crate) fn seed_git_checkpoint_mappings_from_checkout(
1252 &mut self,
1253 mirror_repo: &SleyRepository,
1254 ) -> GitResult<()> {
1255 if !self.heddle_repo.root().join(".git").exists() {
1256 return Ok(());
1257 }
1258
1259 let checkout_repo = match SleyRepository::discover(self.heddle_repo.root()) {
1260 Ok(repo) => repo,
1261 Err(_) => return Ok(()),
1262 };
1263 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1264 return Ok(());
1265 }
1266 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1267
1268 for record in self.heddle_repo.list_git_checkpoints()? {
1269 let change_id = ChangeId::parse(&record.change_id)?;
1270 let git_oid = record
1271 .git_commit
1272 .parse::<ObjectId>()
1273 .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))?;
1274
1275 if mirror_repo.read_object(&git_oid).is_err() {
1276 copy_reachable_objects(&object_repo, mirror_repo, [git_oid])?;
1277 }
1278 mirror_repo
1279 .read_object(&git_oid)
1280 .map_err(|_| GitBridgeError::CommitNotFound(record.git_commit.clone()))?;
1281
1282 self.mapping.insert(change_id, git_oid);
1283 let tier = self
1292 .heddle_repo
1293 .effective_visibility_tier(&change_id)
1294 .map_err(|e| {
1295 GitBridgeError::Git(format!("resolve visibility for {change_id}: {e:#}"))
1296 })?;
1297 if repo::visible(&tier, &repo::AudienceTier::Public)
1298 && super::git_notes::read_note(mirror_repo, git_oid)?.is_none()
1299 && let Some(state) = self.heddle_repo.store().get_state(&change_id)?
1300 {
1301 let note = super::git_notes::HeddleNote::from_state(&state);
1302 super::git_notes::write_note(mirror_repo, git_oid, ¬e)?;
1303 }
1304 }
1305
1306 Ok(())
1307 }
1308
1309 pub(crate) fn stage_ingest_source_in_mirror(
1310 &mut self,
1311 source: &Path,
1312 refs: &[String],
1313 ) -> GitResult<()> {
1314 let source_repo = open_repo(source)?;
1315 let updates = collect_import_source_ref_updates(&source_repo, refs)?;
1316 if updates.is_empty() {
1317 return Ok(());
1318 }
1319
1320 self.init_mirror()?;
1321 let mirror_repo = self.open_git_repo()?;
1322 copy_reachable_objects(
1323 &source_repo,
1324 &mirror_repo,
1325 updates.iter().map(|update| update.target),
1326 )?;
1327 apply_ref_updates(
1328 &mirror_repo,
1329 &updates,
1330 &format!("heddle: stage ingest source from {}", source.display()),
1331 )?;
1332
1333 let mut record = read_or_seed_mirror_managed_refs(&mirror_repo)?;
1334 for update in &updates {
1335 record.insert(full_ref_name(update), update.target);
1336 }
1337 write_mirror_managed_refs(&mirror_repo, &record)?;
1338 Ok(())
1339 }
1340
1341 pub fn write_through_current_checkout(&mut self) -> GitResult<WriteThroughOutcome> {
1346 if !self.heddle_repo.root().join(".git").exists() {
1347 return Ok(WriteThroughOutcome::Skipped(
1348 WriteThroughSkipReason::MissingDotGit,
1349 ));
1350 }
1351 if checkout_git_head_is_detached(self.heddle_repo.root())? {
1352 return Ok(WriteThroughOutcome::Skipped(
1353 WriteThroughSkipReason::DetachedHead,
1354 ));
1355 }
1356 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1357 return Ok(WriteThroughOutcome::Skipped(
1358 WriteThroughSkipReason::DetachedHead,
1359 ));
1360 };
1361
1362 let mirror_guard = self.init_mirror_with_guard()?;
1363 export_current_thread(self, &thread)?;
1374 mirror_guard.commit();
1378 self.write_thread_checkout_from_existing_mirror(&thread)
1379 }
1380
1381 pub fn write_through_current_checkout_with_message(
1382 &mut self,
1383 state_id: ChangeId,
1384 message: String,
1385 ) -> GitResult<WriteThroughOutcome> {
1386 self.set_commit_message_override(state_id, message);
1387 self.write_through_current_checkout()
1388 }
1389
1390 pub fn update_intent_to_add(&self, state_id: &ChangeId) -> GitResult<()> {
1413 let root = self.heddle_repo.root();
1414 if !root.join(".git").exists() {
1415 return Ok(());
1416 }
1417 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1418 if checkout_repo
1421 .head()
1422 .map(|head| head.is_detached())
1423 .unwrap_or(false)
1424 {
1425 return Ok(());
1426 }
1427
1428 let Some(state) = self.heddle_repo.store().get_state(state_id)? else {
1430 return Ok(());
1431 };
1432 let Some(tree) = self.heddle_repo.store().get_tree(&state.tree)? else {
1433 return Ok(());
1434 };
1435 let mut captured: Vec<(String, FileMode)> = Vec::new();
1436 collect_capture_paths(self.heddle_repo.store(), &tree, "", &mut captured)?;
1437 let mut index = checkout_repo
1450 .open_index()
1451 .map_err(git_err)?
1452 .unwrap_or_else(|| Index {
1453 version: 2,
1454 entries: Vec::new(),
1455 extensions: Vec::new(),
1456 checksum: None,
1457 });
1458
1459 let mut real_tracked: HashSet<String> = HashSet::new();
1462 let mut existing_ita: HashSet<String> = HashSet::new();
1463 for entry in &index.entries {
1464 let path = String::from_utf8_lossy(entry.path.as_bytes()).into_owned();
1465 if entry.is_intent_to_add() {
1466 existing_ita.insert(path);
1467 } else {
1468 real_tracked.insert(path);
1469 }
1470 }
1471
1472 let captured_paths: HashSet<&str> = captured.iter().map(|(p, _)| p.as_str()).collect();
1475
1476 let before_prune = index.entries.len();
1478 index.entries.retain(|entry| {
1479 !entry.is_intent_to_add()
1480 || captured_paths.contains(String::from_utf8_lossy(entry.path.as_bytes()).as_ref())
1481 });
1482 let mut changed = index.entries.len() != before_prune;
1483
1484 for (path, mode) in &captured {
1486 if real_tracked.contains(path) || existing_ita.contains(path) {
1487 continue;
1488 }
1489 if real_tracked
1497 .iter()
1498 .any(|tracked| path_prefix_conflict(path, tracked))
1499 {
1500 continue;
1501 }
1502 if *mode == FileMode::Spoollink {
1506 continue;
1507 }
1508 let mut entry = IndexEntry::intent_to_add(
1509 checkout_repo.object_format(),
1510 GitBString::from(path.as_str()),
1511 );
1512 entry.mode = match mode {
1513 FileMode::Executable => 0o100755,
1514 FileMode::Symlink => 0o120000,
1515 FileMode::Gitlink => 0o160000,
1516 FileMode::Normal => 0o100644,
1517 FileMode::Spoollink => 0o100644,
1519 };
1520 changed = true;
1521 index.entries.push(entry);
1522 }
1523
1524 if changed {
1525 index
1526 .entries
1527 .sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
1528 index.upgrade_version_for_flags();
1529 checkout_repo
1530 .write_index(
1531 &index,
1532 IndexWriteOptions {
1533 fsync: true,
1534 validate_checksum: true,
1535 },
1536 )
1537 .map_err(git_err)?;
1538 }
1539 Ok(())
1540 }
1541
1542 pub fn write_through_thread_checkout(
1547 &mut self,
1548 thread: &str,
1549 ) -> GitResult<WriteThroughOutcome> {
1550 if !self.heddle_repo.root().join(".git").exists() {
1551 return Ok(WriteThroughOutcome::Skipped(
1552 WriteThroughSkipReason::MissingDotGit,
1553 ));
1554 }
1555
1556 let mirror_guard = self.init_mirror_with_guard()?;
1557 export_current_thread(self, thread)?;
1558 mirror_guard.commit();
1559 self.write_thread_checkout_from_existing_mirror(thread)
1560 }
1561
1562 pub(crate) fn write_current_checkout_from_existing_mirror(
1563 &mut self,
1564 ) -> GitResult<WriteThroughOutcome> {
1565 if !self.heddle_repo.root().join(".git").exists() {
1566 return Ok(WriteThroughOutcome::Skipped(
1567 WriteThroughSkipReason::MissingDotGit,
1568 ));
1569 }
1570
1571 let (thread, state_id) = match self.heddle_repo.head_ref()? {
1572 Head::Attached { thread } => {
1573 let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1574 return Ok(WriteThroughOutcome::Skipped(
1575 WriteThroughSkipReason::NoAttachedThread,
1576 ));
1577 };
1578 (thread, state_id)
1579 }
1580 Head::Detached { .. } => {
1581 return Ok(WriteThroughOutcome::Skipped(
1582 WriteThroughSkipReason::DetachedHead,
1583 ));
1584 }
1585 };
1586 self.write_thread_state_checkout_from_existing_mirror(&thread, &state_id)
1587 }
1588
1589 fn write_thread_checkout_from_existing_mirror(
1590 &mut self,
1591 thread: &str,
1592 ) -> GitResult<WriteThroughOutcome> {
1593 let Some(state_id) = self
1594 .heddle_repo
1595 .refs()
1596 .get_thread(&ThreadName::new(thread))?
1597 else {
1598 return Ok(WriteThroughOutcome::Skipped(
1599 WriteThroughSkipReason::NoAttachedThread,
1600 ));
1601 };
1602 self.write_thread_state_checkout_from_existing_mirror(thread, &state_id)
1603 }
1604
1605 fn write_thread_state_checkout_from_existing_mirror(
1606 &mut self,
1607 thread: &str,
1608 state_id: &ChangeId,
1609 ) -> GitResult<WriteThroughOutcome> {
1610 let mirror_repo = self.open_git_repo()?;
1611 if self.mapping.is_empty() {
1621 self.build_existing_mapping(None)?;
1622 }
1623 let git_oid = if let Some(git_oid) = self.mapping.get_git(state_id) {
1624 git_oid
1625 } else if let Some(git_commit) = self
1626 .heddle_repo
1627 .git_overlay_mapped_git_commit_for_change(state_id)
1628 .map_err(|error| GitBridgeError::Git(error.to_string()))?
1629 {
1630 ObjectId::from_hex(mirror_repo.object_format(), &git_commit)
1631 .map_err(|error| GitBridgeError::InvalidMapping(error.to_string()))?
1632 } else {
1633 return Ok(WriteThroughOutcome::Skipped(
1634 WriteThroughSkipReason::NoMappedCommit,
1635 ));
1636 };
1637
1638 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1639 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1640 return Ok(WriteThroughOutcome::Skipped(
1641 WriteThroughSkipReason::MirrorIsWorktree,
1642 ));
1643 }
1644 let git_dir = checkout_repo.git_dir().to_path_buf();
1645 if git_dir.join("index.lock").exists() {
1648 return Ok(WriteThroughOutcome::Skipped(
1649 WriteThroughSkipReason::IndexAlreadyDirty,
1650 ));
1651 }
1652
1653 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1654 let branch_ref = format!("refs/heads/{thread}");
1655 let head_path = git_dir.join("HEAD");
1656 let index_path = git_dir.join("index");
1657 let previous_head = fs::read(&head_path).ok();
1658 let previous_index = fs::read(&index_path).ok();
1659 let previous_branch = object_repo
1660 .find_reference(&branch_ref)
1661 .ok()
1662 .flatten()
1663 .and_then(|reference| reference.peeled_oid(&object_repo).ok().flatten());
1664
1665 let heddle_repo = self.heddle_repo;
1666 let mapping = &self.mapping;
1667 let write_result = (|| -> GitResult<()> {
1668 let excluded: HashSet<ObjectId> = match previous_branch {
1683 Some(parent) => sley::plumbing::sley_odb::collect_reachable_object_ids(
1684 object_repo.objects().as_ref(),
1685 object_repo.object_format(),
1686 [parent],
1687 )
1688 .map_err(|error| GitBridgeError::Git(error.to_string()))?,
1689 None => HashSet::new(),
1690 };
1691 materialize_checkout_closure_from_state(
1699 heddle_repo,
1700 mapping,
1701 &mirror_repo,
1702 &object_repo,
1703 state_id,
1704 git_oid,
1705 &excluded,
1706 )?;
1707 write_head_symref(&git_dir, &branch_ref)?;
1711
1712 let commit = object_repo.read_commit(&git_oid).map_err(git_err)?;
1713 let mut index = object_repo.index_from_tree(&commit.tree).map_err(git_err)?;
1714 index.upgrade_version_for_flags();
1715 checkout_repo
1716 .write_index(
1717 &index,
1718 IndexWriteOptions {
1719 fsync: true,
1720 validate_checksum: true,
1721 },
1722 )
1723 .map_err(git_err)?;
1724
1725 update_checkout_head_ref(
1726 &checkout_repo,
1727 git_oid,
1728 previous_branch,
1729 "heddle: write-through current thread",
1730 )?;
1731
1732 fsync_path(&head_path)?;
1738 fsync_path(&index_path)?;
1739 fsync_path(&git_dir)?;
1740 Ok(())
1741 })();
1742
1743 if let Err(err) = write_result {
1744 restore_file(head_path.clone(), previous_head.as_deref())?;
1745 restore_file(index_path.clone(), previous_index.as_deref())?;
1746 if let Some(previous_branch) = previous_branch {
1747 set_reference(
1748 &object_repo,
1749 &branch_ref,
1750 previous_branch,
1751 RefPrecondition::Any,
1752 "heddle: rollback failed write-through",
1753 )?;
1754 } else {
1755 let _ = delete_reference_if_present(&object_repo, &branch_ref);
1765 }
1766 let _ = fsync_path(&head_path);
1769 let _ = fsync_path(&index_path);
1770 let _ = fsync_path(&git_dir);
1771 return Err(err);
1772 }
1773
1774 Ok(WriteThroughOutcome::Wrote(git_oid))
1775 }
1776
1777 fn refresh_checkout_remote_tracking_ref(
1778 &self,
1779 remote_name: &str,
1780 branch: &str,
1781 ) -> GitResult<()> {
1782 if !self.heddle_repo.root().join(".git").exists() {
1783 return Ok(());
1784 }
1785 let Some(tracking_remote) =
1786 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1787 else {
1788 return Ok(());
1789 };
1790 reject_reserved_git_remote_name(&tracking_remote)?;
1791
1792 let mirror_repo = self.open_git_repo()?;
1793 let branch_ref = format!("refs/heads/{branch}");
1794 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1795 return Ok(());
1796 };
1797 let Some(target) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1798 return Ok(());
1799 };
1800
1801 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1802 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1803 return Ok(());
1804 }
1805 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1806 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
1807 set_reference(
1808 &object_repo,
1809 &format!("refs/remotes/{tracking_remote}/{branch}"),
1810 target,
1811 RefPrecondition::Any,
1812 "heddle: refresh remote-tracking branch after pull",
1813 )?;
1814 Ok(())
1815 }
1816
1817 fn refresh_checkout_remote_tracking_refs(&self, remote_name: &str) -> GitResult<()> {
1818 if !self.heddle_repo.root().join(".git").exists() {
1819 return Ok(());
1820 }
1821 let Some(tracking_remote) =
1822 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1823 else {
1824 return Ok(());
1825 };
1826 reject_reserved_git_remote_name(&tracking_remote)?;
1827
1828 let mirror_repo = self.open_git_repo()?;
1829 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1830 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1831 return Ok(());
1832 }
1833 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1834 let prefix = format!("refs/remotes/{remote_name}/");
1835 for reference in mirror_repo.references().list_refs().map_err(git_err)? {
1836 if !reference.name.starts_with(&prefix) {
1837 continue;
1838 }
1839 let ReferenceTarget::Direct(target) = reference.target else {
1840 continue;
1841 };
1842 let full = reference.name;
1843 let Some(branch) = full.strip_prefix(&prefix) else {
1844 continue;
1845 };
1846 if branch.ends_with("/HEAD") {
1847 continue;
1848 }
1849 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
1850 set_reference(
1851 &object_repo,
1852 &format!("refs/remotes/{tracking_remote}/{branch}"),
1853 target,
1854 RefPrecondition::Any,
1855 "heddle: refresh remote-tracking branch after fetch",
1856 )?;
1857 }
1858 Ok(())
1859 }
1860
1861 fn refresh_checkout_note_refs_from_mirror(&self) -> GitResult<()> {
1862 if !self.heddle_repo.root().join(".git").exists() {
1863 return Ok(());
1864 }
1865
1866 let mirror_repo = self.open_git_repo()?;
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 let note_updates = collect_ref_updates(&mirror_repo)?
1873 .into_iter()
1874 .filter(|update| update.namespace == RefNamespace::Note)
1875 .collect::<Vec<_>>();
1876 if note_updates.is_empty() {
1877 return Ok(());
1878 }
1879
1880 copy_reachable_objects(
1881 &mirror_repo,
1882 &object_repo,
1883 note_updates.iter().map(|u| u.target),
1884 )?;
1885 apply_ref_updates(
1886 &object_repo,
1887 ¬e_updates,
1888 "heddle: refresh Heddle note refs",
1889 )?;
1890 Ok(())
1891 }
1892
1893 fn resolve_remote(
1894 &self,
1895 remote_name: &str,
1896 direction: RemoteDirection,
1897 ) -> GitResult<ResolvedRemote> {
1898 let repo = self.open_git_repo()?;
1899 let url = match remote_url_from_repo(&repo, remote_name, direction)? {
1900 Some(url) => Some(url),
1901 None => self.checkout_remote_url(remote_name, direction)?,
1902 };
1903
1904 let base = repo_relative_base(&repo);
1905 let url = match url {
1906 Some(url) => url,
1907 None => parse_configured_remote_url(remote_name, &base)?,
1908 };
1909
1910 if let Some(path) = local_path_from_url(&url)? {
1911 Ok(ResolvedRemote::Local(path))
1912 } else {
1913 Ok(ResolvedRemote::Url(url))
1914 }
1915 }
1916
1917 fn checkout_remote_url(
1918 &self,
1919 remote_name: &str,
1920 direction: RemoteDirection,
1921 ) -> GitResult<Option<String>> {
1922 if direction == RemoteDirection::Fetch
1923 && let Some(url) =
1924 remote_fetch_url_from_checkout_config(self.heddle_repo.root(), remote_name)?
1925 {
1926 return Ok(Some(url));
1927 }
1928 let Ok(repo) = SleyRepository::discover(self.heddle_repo.root()) else {
1929 return Ok(None);
1930 };
1931 remote_url_from_repo(&repo, remote_name, direction)
1932 }
1933}
1934
1935fn remote_url_from_repo(
1936 repo: &SleyRepository,
1937 remote_name: &str,
1938 direction: RemoteDirection,
1939) -> GitResult<Option<String>> {
1940 let config = repo.config_snapshot().map_err(git_err)?;
1941 let push = direction == RemoteDirection::Push;
1942 let value = if push {
1943 config
1944 .get("remote", Some(remote_name), "pushurl")
1945 .or_else(|| config.get("remote", Some(remote_name), "url"))
1946 } else {
1947 config.get("remote", Some(remote_name), "url")
1948 };
1949 let Some(value) = value else {
1950 return Ok(None);
1951 };
1952 let rewritten =
1953 sley::plumbing::sley_config::remotes::rewrite_url_with_config(&config, value, push);
1954 parse_configured_remote_url(&rewritten, &repo_relative_base(repo)).map(Some)
1955}
1956
1957fn checkout_tracking_remote_name(root: &Path, requested: &str) -> GitResult<Option<String>> {
1958 let remotes = checkout_remote_url_items(root)?;
1959 if remotes.is_empty() {
1960 return Ok(None);
1961 }
1962 if let Some((name, _)) = remotes.iter().find(|(name, _)| name == requested) {
1963 return Ok(Some(name.clone()));
1964 }
1965 if let Some((name, _)) = remotes
1966 .iter()
1967 .find(|(_, url)| configured_remote_values_match(url, requested))
1968 {
1969 return Ok(Some(name.clone()));
1970 }
1971 if looks_like_remote_location(requested) && remotes.len() == 1 {
1972 return Ok(Some(remotes[0].0.clone()));
1973 }
1974 if !looks_like_remote_location(requested) {
1975 return Ok(Some(requested.to_string()));
1976 }
1977 Ok(None)
1978}
1979
1980fn checkout_remote_url_items(root: &Path) -> GitResult<Vec<(String, String)>> {
1981 let mut remotes = Vec::new();
1982 for config_path in checkout_git_config_paths(root) {
1983 parse_remote_url_items_from_config(&config_path, &mut remotes)?;
1984 }
1985 Ok(remotes)
1986}
1987
1988fn checkout_note_ref_exists(root: &Path) -> GitResult<bool> {
1989 if !root.join(".git").exists() {
1990 return Ok(false);
1991 }
1992 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1993 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1994 Ok(object_repo
1995 .find_reference(super::git_notes::NOTES_REF)
1996 .map_err(git_err)?
1997 .is_some())
1998}
1999
2000fn seed_checkout_note_refs_into_mirror(root: &Path, mirror_repo: &SleyRepository) -> GitResult<()> {
2001 if !root.join(".git").exists() {
2002 return Ok(());
2003 }
2004
2005 let checkout_repo = match SleyRepository::discover(root) {
2006 Ok(repo) => repo,
2007 Err(_) => return Ok(()),
2008 };
2009 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2010 return Ok(());
2011 }
2012 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2013 let note_updates = collect_ref_updates(&object_repo)?
2014 .into_iter()
2015 .filter(|update| update.namespace == RefNamespace::Note)
2016 .collect::<Vec<_>>();
2017 if note_updates.is_empty() {
2018 return Ok(());
2019 }
2020
2021 copy_reachable_objects(
2022 &object_repo,
2023 mirror_repo,
2024 note_updates.iter().map(|update| update.target),
2025 )?;
2026 apply_ref_updates(
2027 mirror_repo,
2028 ¬e_updates,
2029 "heddle: seed mirror note refs from checkout",
2030 )
2031}
2032
2033fn hydrate_checkout_notes_from_remote_without_mirror(
2034 root: &Path,
2035 remote_name: &str,
2036) -> GitResult<()> {
2037 reject_reserved_git_remote_name(remote_name)?;
2038 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2039 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2040 let url = remote_fetch_url_from_checkout_config(root, remote_name)?
2041 .ok_or_else(|| GitBridgeError::Git(format!("remote '{remote_name}' has no fetch URL")))?;
2042
2043 if let Some(path) = local_path_from_url(&url)? {
2044 let remote_repo = open_repo(&path)?;
2045 let note_updates = collect_ref_updates(&remote_repo)?
2046 .into_iter()
2047 .filter(|update| update.namespace == RefNamespace::Note)
2048 .collect::<Vec<_>>();
2049 if note_updates.is_empty() {
2050 return Ok(());
2051 }
2052 copy_reachable_objects(
2053 &remote_repo,
2054 &object_repo,
2055 note_updates.iter().map(|update| update.target),
2056 )?;
2057 apply_ref_updates(
2058 &object_repo,
2059 ¬e_updates,
2060 &format!("heddle: hydrate notes from {remote_name}"),
2061 )?;
2062 return Ok(());
2063 }
2064
2065 fetch_heddle_notes_into_repo(&object_repo, remote_name, &url)
2066}
2067
2068fn fetch_heddle_notes_into_repo(
2069 repo: &SleyRepository,
2070 remote_name: &str,
2071 url: &str,
2072) -> GitResult<()> {
2073 let mut credentials = NoCredentials;
2074 let mut progress = SilentProgress;
2075 let refspec = RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format();
2076 repo.fetch(
2077 url,
2078 &[refspec],
2079 FetchOptions {
2080 quiet: true,
2081 auto_follow_tags: false,
2082 fetch_all_tags: false,
2083 prune: false,
2084 dry_run: false,
2085 append: false,
2086 write_fetch_head: true,
2087 force: false,
2088 tag_option_explicit: true,
2089 prune_option_explicit: true,
2090 prune_tags: false,
2091 prune_tags_option_explicit: false,
2092 refmap: None,
2093 refetch: false,
2094 record_promisor_refs: false,
2095 update_head_ok: false,
2096 ssh_options: None,
2097 atomic: false,
2098 depth: None,
2099 merge_srcs: Vec::new(),
2100 filter: None,
2101 cloning: false,
2102 update_shallow: false,
2103 deepen_relative: false,
2104 deepen_since: None,
2105 deepen_not: Vec::new(),
2106 },
2107 &mut credentials,
2108 &mut progress,
2109 )
2110 .map(|_| ())
2111 .map_err(|err| GitBridgeError::Git(format!("failed to fetch notes from {remote_name}: {err}")))
2112}
2113
2114fn parse_remote_url_items_from_config(
2115 path: &Path,
2116 remotes: &mut Vec<(String, String)>,
2117) -> GitResult<()> {
2118 let Ok(contents) = fs::read_to_string(path) else {
2119 return Ok(());
2120 };
2121 let mut current_remote: Option<String> = None;
2122 for raw in contents.lines() {
2123 let line = raw.trim();
2124 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2125 continue;
2126 }
2127 if line.starts_with('[') && line.ends_with(']') {
2128 current_remote = line
2129 .strip_prefix("[remote \"")
2130 .and_then(|rest| rest.strip_suffix("\"]"))
2131 .map(str::to_string);
2132 continue;
2133 }
2134 let Some(name) = current_remote.as_ref() else {
2135 continue;
2136 };
2137 let Some((key, value)) = line.split_once('=') else {
2138 continue;
2139 };
2140 if key.trim().eq_ignore_ascii_case("url") {
2141 remotes.push((name.clone(), git_config_value(value.trim())?));
2142 }
2143 }
2144 Ok(())
2145}
2146
2147fn configured_remote_values_match(left: &str, right: &str) -> bool {
2148 if left == right {
2149 return true;
2150 }
2151 let left_path = Path::new(left);
2152 let right_path = Path::new(right);
2153 if let (Ok(left), Ok(right)) = (left_path.canonicalize(), right_path.canonicalize()) {
2154 return left == right;
2155 }
2156 false
2157}
2158
2159fn looks_like_remote_location(value: &str) -> bool {
2160 value.starts_with('/')
2161 || value.starts_with("./")
2162 || value.starts_with("../")
2163 || value.starts_with("~/")
2164 || value.contains("://")
2165 || value.contains('\\')
2166}
2167
2168fn remote_fetch_url_from_checkout_config(
2169 root: &Path,
2170 remote_name: &str,
2171) -> GitResult<Option<String>> {
2172 for config_path in checkout_git_config_paths(root) {
2173 let Some(url) = parse_remote_fetch_url_from_config(&config_path, remote_name)? else {
2174 continue;
2175 };
2176 return parse_configured_remote_url(&url, root).map(Some);
2177 }
2178 Ok(None)
2179}
2180
2181fn parse_configured_remote_url(value: &str, relative_base: &Path) -> GitResult<String> {
2182 if configured_remote_is_local_path(value) {
2183 let path = configured_remote_local_path(value, relative_base);
2184 return Ok(format!("file://{}", path.display()));
2185 }
2186 Ok(value.to_string())
2187}
2188
2189fn configured_remote_local_path(value: &str, relative_base: &Path) -> PathBuf {
2190 if value == "~"
2191 && let Some(home) = std::env::var_os("HOME")
2192 {
2193 return PathBuf::from(home);
2194 }
2195 if let Some(rest) = value.strip_prefix("~/")
2196 && let Some(home) = std::env::var_os("HOME")
2197 {
2198 return PathBuf::from(home).join(rest);
2199 }
2200
2201 let path = Path::new(value);
2202 if path.is_absolute() {
2203 path.to_path_buf()
2204 } else {
2205 relative_base.join(path)
2206 }
2207}
2208
2209fn configured_remote_is_local_path(value: &str) -> bool {
2210 value.starts_with('/')
2211 || value.starts_with("./")
2212 || value.starts_with("../")
2213 || value.starts_with('~')
2214 || value.starts_with(std::path::MAIN_SEPARATOR)
2215}
2216
2217fn checkout_git_config_paths(root: &Path) -> Vec<PathBuf> {
2218 let dot_git = root.join(".git");
2219 let mut paths = Vec::new();
2220 if dot_git.is_dir() {
2221 paths.push(dot_git.join("config"));
2222 if let Some(common_dir) = common_git_dir_from_git_dir(&dot_git) {
2223 paths.push(common_dir.join("config"));
2224 }
2225 return paths;
2226 }
2227 let Ok(contents) = fs::read_to_string(&dot_git) else {
2228 return paths;
2229 };
2230 let Some(target) = contents.trim().strip_prefix("gitdir:").map(str::trim) else {
2231 return paths;
2232 };
2233 let git_dir = {
2234 let path = Path::new(target);
2235 if path.is_absolute() {
2236 path.to_path_buf()
2237 } else {
2238 dot_git
2239 .parent()
2240 .map(|parent| parent.join(path))
2241 .unwrap_or_else(|| path.to_path_buf())
2242 }
2243 };
2244 paths.push(git_dir.join("config"));
2245 if let Some(common_dir) = common_git_dir_from_git_dir(&git_dir) {
2246 paths.push(common_dir.join("config"));
2247 }
2248 paths
2249}
2250
2251fn common_git_dir_from_git_dir(git_dir: &Path) -> Option<PathBuf> {
2252 let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
2253 let target = contents.trim();
2254 let path = Path::new(target);
2255 Some(if path.is_absolute() {
2256 path.to_path_buf()
2257 } else {
2258 git_dir.join(path)
2259 })
2260}
2261
2262fn parse_remote_fetch_url_from_config(path: &Path, remote_name: &str) -> GitResult<Option<String>> {
2263 let Ok(contents) = fs::read_to_string(path) else {
2264 return Ok(None);
2265 };
2266 let mut in_remote = false;
2267 for raw in contents.lines() {
2268 let line = raw.trim();
2269 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2270 continue;
2271 }
2272 if line.starts_with('[') && line.ends_with(']') {
2273 in_remote = line
2274 .strip_prefix("[remote \"")
2275 .and_then(|rest| rest.strip_suffix("\"]"))
2276 == Some(remote_name);
2277 continue;
2278 }
2279 if !in_remote {
2280 continue;
2281 }
2282 let Some((key, value)) = line.split_once('=') else {
2283 continue;
2284 };
2285 if key.trim().eq_ignore_ascii_case("url") {
2286 return git_config_value(value.trim()).map(Some);
2287 }
2288 }
2289 Ok(None)
2290}
2291
2292fn common_repo_for_worktree(repo: &SleyRepository) -> GitResult<SleyRepository> {
2293 let common_dir_file = repo.git_dir().join("commondir");
2294 let Ok(contents) = fs::read_to_string(&common_dir_file) else {
2295 return Ok(repo.clone());
2296 };
2297 let target = contents.trim();
2298 if target.is_empty() {
2299 return Ok(repo.clone());
2300 }
2301 let common_dir = {
2302 let path = Path::new(target);
2303 if path.is_absolute() {
2304 path.to_path_buf()
2305 } else {
2306 repo.git_dir().join(path)
2307 }
2308 };
2309 open_repo(&common_dir)
2310}
2311
2312pub(crate) fn git_err(err: impl std::fmt::Display) -> GitBridgeError {
2313 GitBridgeError::Git(err.to_string())
2314}
2315
2316fn restore_file(path: PathBuf, previous: Option<&[u8]>) -> GitResult<()> {
2317 if let Some(previous) = previous {
2318 fs::write(path, previous)?;
2319 } else if path.exists() {
2320 fs::remove_file(path)?;
2321 }
2322 Ok(())
2323}
2324
2325fn fsync_path(path: &Path) -> GitResult<()> {
2329 match std::fs::File::open(path) {
2330 Ok(file) => {
2331 file.sync_all()?;
2332 Ok(())
2333 }
2334 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2335 Err(err) => Err(GitBridgeError::Io(err)),
2336 }
2337}
2338
2339pub(crate) struct MirrorInitGuard {
2346 path: PathBuf,
2347 rollback: Option<bool>,
2351}
2352
2353impl MirrorInitGuard {
2354 pub(crate) fn new_from_init(path: PathBuf, did_create: bool) -> Self {
2355 Self {
2356 path,
2357 rollback: Some(did_create),
2358 }
2359 }
2360
2361 pub(crate) fn commit(mut self) {
2362 self.rollback = None;
2363 }
2364}
2365
2366impl Drop for MirrorInitGuard {
2367 fn drop(&mut self) {
2368 if matches!(self.rollback, Some(true))
2369 && self.path.exists()
2370 && let Err(err) = std::fs::remove_dir_all(&self.path)
2371 {
2372 tracing::warn!(
2373 path = %self.path.display(),
2374 error = %err,
2375 "failed to roll back partial bridge mirror; manual cleanup may be required"
2376 );
2377 }
2378 }
2379}
2380
2381pub(crate) fn thread_is_unclaimed_bootstrap(
2392 heddle_repo: &HeddleRepository,
2393 change_id: &ChangeId,
2394) -> GitResult<bool> {
2395 let Some(state) = heddle_repo.store().get_state(change_id)? else {
2396 return Ok(false);
2397 };
2398 if !state.parents.is_empty() {
2399 return Ok(false);
2400 }
2401 let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
2402 return Ok(false);
2403 };
2404 Ok(tree == Tree::new())
2405}
2406
2407pub(crate) fn open_repo(path: &Path) -> GitResult<SleyRepository> {
2408 match SleyRepository::discover(path) {
2409 Ok(repo) => Ok(repo),
2410 Err(_) => SleyRepository::open(path).map_err(git_err),
2411 }
2412}
2413
2414pub(crate) fn delete_reference_if_present(repo: &SleyRepository, name: &str) -> GitResult<()> {
2422 delete_reference(repo, name, None, true)
2423}
2424
2425fn delete_reference_matching(
2426 repo: &SleyRepository,
2427 name: &str,
2428 expected_old: ObjectId,
2429) -> GitResult<()> {
2430 delete_reference(repo, name, Some(expected_old), false)
2431}
2432
2433fn delete_reference(
2434 repo: &SleyRepository,
2435 name: &str,
2436 expected_old: Option<ObjectId>,
2437 missing_ok: bool,
2438) -> GitResult<()> {
2439 let refs = repo.references();
2440 match refs.read_ref(name).map_err(git_err)? {
2441 None if missing_ok => Ok(()),
2442 None => Err(GitBridgeError::Git(format!(
2443 "failed to delete Git reference '{name}': ref is missing"
2444 ))),
2445 Some(ReferenceTarget::Direct(oid)) => repo
2446 .delete_ref(DeleteRef {
2447 name: FullName::new(name).map_err(git_err)?,
2448 expected_old: Some(expected_old.unwrap_or(oid)),
2449 expected: None,
2450 reflog: None,
2451 reflog_committer: None,
2452 })
2453 .map_err(git_err),
2454 Some(ReferenceTarget::Symbolic(_)) => {
2455 if let Some(expected_old) = expected_old {
2456 let current = repo
2457 .find_reference(name)
2458 .map_err(git_err)?
2459 .and_then(|reference| reference.peeled_oid(repo).ok().flatten());
2460 if current != Some(expected_old) {
2461 return Err(GitBridgeError::Git(format!(
2462 "failed to delete Git reference '{name}': expected {expected_old}, found {}",
2463 current
2464 .map(|oid| oid.to_string())
2465 .unwrap_or_else(|| "missing".to_string())
2466 )));
2467 }
2468 }
2469 refs.delete_symbolic_ref(name).map(|_| ()).map_err(git_err)
2470 }
2471 }
2472}
2473
2474pub(crate) fn set_reference(
2475 repo: &SleyRepository,
2476 name: &str,
2477 target: ObjectId,
2478 constraint: RefPrecondition,
2479 log_message: &str,
2480) -> GitResult<()> {
2481 let refs = repo.references();
2482 let old_oid = match refs.read_ref(name).map_err(git_err)? {
2483 Some(ReferenceTarget::Direct(oid)) => oid,
2484 _ => ObjectId::null(repo.object_format()),
2485 };
2486 let reflog = sley::plumbing::sley_refs::ReflogEntry {
2487 old_oid,
2488 new_oid: target,
2489 committer: bridge_signature(),
2490 message: log_message.as_bytes().to_vec(),
2491 };
2492 let mut tx = refs.transaction();
2493 tx.update_to(
2494 name.to_string(),
2495 ReferenceTarget::Direct(target),
2496 constraint,
2497 Some(reflog),
2498 );
2499 tx.commit().map_err(git_err)?;
2500 Ok(())
2501}
2502
2503fn path_prefix_conflict(a: &str, b: &str) -> bool {
2509 let child_of = |parent: &str, child: &str| {
2510 child
2511 .strip_prefix(parent)
2512 .is_some_and(|rest| rest.starts_with('/'))
2513 };
2514 child_of(a, b) || child_of(b, a)
2515}
2516
2517fn collect_capture_paths<S: ObjectStore + ?Sized>(
2522 store: &S,
2523 tree: &Tree,
2524 prefix: &str,
2525 out: &mut Vec<(String, FileMode)>,
2526) -> GitResult<()> {
2527 for entry in tree.iter() {
2528 let path = if prefix.is_empty() {
2529 entry.name().to_string()
2530 } else {
2531 format!("{prefix}/{}", entry.name())
2532 };
2533 if entry.is_tree() {
2534 if let Some(hash) = entry.tree_hash()
2535 && let Some(subtree) = store.get_tree(&hash)?
2536 {
2537 collect_capture_paths(store, &subtree, &path, out)?;
2538 }
2539 } else {
2540 out.push((path, entry.mode()));
2541 }
2542 }
2543 Ok(())
2544}
2545
2546fn update_checkout_head_ref(
2547 repo: &SleyRepository,
2548 target: ObjectId,
2549 previous_branch: Option<ObjectId>,
2550 log_message: &str,
2551) -> GitResult<()> {
2552 let expected = previous_branch.map_or(RefPrecondition::MustNotExist, |oid| {
2553 RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(oid))
2554 });
2555 let ref_name = repo
2556 .head()
2557 .ok()
2558 .and_then(|head| head.symbolic_target.map(|name| name.to_string()))
2559 .unwrap_or_else(|| "HEAD".to_string());
2560 let old_oid = previous_branch.unwrap_or_else(|| ObjectId::null(repo.object_format()));
2561 let head_reflog = sley::plumbing::sley_refs::ReflogEntry {
2562 old_oid,
2563 new_oid: target,
2564 committer: bridge_signature(),
2565 message: log_message.as_bytes().to_vec(),
2566 };
2567 set_reference(repo, &ref_name, target, expected, log_message)?;
2568 if ref_name != "HEAD" {
2569 repo.references()
2570 .append_reflog("HEAD", &head_reflog)
2571 .map_err(git_err)?;
2572 }
2573 Ok(())
2574}
2575
2576fn checkout_git_head_is_detached(root: &Path) -> GitResult<bool> {
2577 let repo = SleyRepository::discover(root).map_err(git_err)?;
2578 Ok(repo.head().map(|head| head.is_detached()).unwrap_or(false))
2579}
2580
2581pub(crate) fn resolve_git_commit_identity(
2582 repo_root: &Path,
2583 fallback: &Principal,
2584) -> GitResult<LocalGitIdentity> {
2585 if !principal_is_default_unknown(fallback) {
2586 return Ok(LocalGitIdentity::from_principal(fallback));
2587 }
2588 if let Some(identity) = git_config_identity_with_global_fallback(repo_root)? {
2589 return Ok(identity);
2590 }
2591
2592 Err(GitBridgeError::Git(
2593 "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(),
2594 ))
2595}
2596
2597pub(crate) fn git_config_identity_with_global_fallback(
2598 repo_root: &Path,
2599) -> GitResult<Option<LocalGitIdentity>> {
2600 let name = git_config_value_with_global_fallback(repo_root, "user.name")?;
2601 let email = git_config_value_with_global_fallback(repo_root, "user.email")?;
2602 if let (Some(name), Some(email)) = (name, email)
2603 && !name.trim().is_empty()
2604 && !email.trim().is_empty()
2605 {
2606 return Ok(Some(LocalGitIdentity { name, email }));
2607 }
2608 Ok(None)
2609}
2610
2611pub(crate) fn principal_is_default_unknown(principal: &Principal) -> bool {
2612 principal.name.trim().is_empty()
2613 || principal.email.trim().is_empty()
2614 || (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
2615}
2616
2617fn git_config_value_with_global_fallback(repo_root: &Path, key: &str) -> GitResult<Option<String>> {
2618 let Ok(repo) = SleyRepository::discover(repo_root) else {
2619 return Ok(None);
2620 };
2621 let Some((section, variable)) = key.split_once('.') else {
2622 return Ok(None);
2623 };
2624 Ok(repo
2625 .config_snapshot()
2626 .map_err(git_err)?
2627 .get(section, None, variable)
2628 .map(str::to_string))
2629}
2630
2631fn git_config_value(value: &str) -> GitResult<String> {
2632 let Some(quoted) = value
2633 .strip_prefix('"')
2634 .and_then(|rest| rest.strip_suffix('"'))
2635 else {
2636 return Ok(value.to_string());
2637 };
2638 let mut out = String::new();
2639 let mut chars = quoted.chars();
2640 while let Some(ch) = chars.next() {
2641 if ch != '\\' {
2642 out.push(ch);
2643 continue;
2644 }
2645 let Some(escaped) = chars.next() else {
2646 return Err(GitBridgeError::Git(
2647 "unterminated escape in repo-local Git config".to_string(),
2648 ));
2649 };
2650 match escaped {
2651 '"' | '\\' => out.push(escaped),
2652 'n' => out.push('\n'),
2653 't' => out.push('\t'),
2654 'b' => out.push('\u{0008}'),
2655 other => out.push(other),
2656 }
2657 }
2658 Ok(out)
2659}
2660
2661fn bridge_signature() -> Vec<u8> {
2662 let seconds = SystemTime::now()
2663 .duration_since(UNIX_EPOCH)
2664 .map(|duration| duration.as_secs() as i64)
2665 .unwrap_or(0);
2666 format!("Heddle <heddle@local> {seconds} +0000").into_bytes()
2667}
2668
2669fn repo_relative_base(repo: &SleyRepository) -> PathBuf {
2670 repo.workdir().unwrap_or_else(|| {
2671 if repo
2672 .git_dir()
2673 .file_name()
2674 .is_some_and(|name| name == ".git")
2675 {
2676 repo.git_dir()
2677 .parent()
2678 .map(Path::to_path_buf)
2679 .unwrap_or_else(|| repo.git_dir().to_path_buf())
2680 } else {
2681 repo.git_dir().to_path_buf()
2682 }
2683 })
2684}
2685
2686fn local_path_from_url(url: &str) -> GitResult<Option<PathBuf>> {
2687 if url.starts_with("heddle://") {
2697 return Err(GitBridgeError::Git(format!(
2698 "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"
2699 )));
2700 }
2701 let Some(raw_path) = url.strip_prefix("file://") else {
2702 return Ok(None);
2703 };
2704 let path = PathBuf::from(raw_path);
2705 if path.as_os_str().is_empty() {
2706 return Err(GitBridgeError::Git(format!(
2707 "remote '{}' has no filesystem path",
2708 url
2709 )));
2710 }
2711 Ok(Some(path))
2712}
2713
2714fn collect_ref_updates(repo: &SleyRepository) -> GitResult<Vec<RefUpdate>> {
2715 let mut updates = Vec::new();
2716
2717 for reference in repo.references().list_refs().map_err(git_err)? {
2718 let ReferenceTarget::Direct(target) = reference.target else {
2719 continue;
2720 };
2721 let ref_name = GitRefName::new(&reference.name);
2722 if let Some(namespace) = ref_name.content_namespace()
2723 && let Some(name) = ref_name.short_name()
2724 {
2725 updates.push(RefUpdate {
2726 name: name.to_string(),
2727 target,
2728 namespace,
2729 });
2730 }
2731 }
2732
2733 Ok(updates)
2734}
2735
2736#[derive(Debug, Default, Clone, Copy)]
2745pub(crate) struct ExportedCommitCounts {
2746 pub total: usize,
2747 pub newly: usize,
2748}
2749
2750pub(crate) fn count_exported_commits(
2764 repo: &SleyRepository,
2765 newly_minted: &HashSet<ObjectId>,
2766) -> GitResult<ExportedCommitCounts> {
2767 let tips: Vec<ObjectId> = collect_ref_updates(repo)?
2768 .into_iter()
2769 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag))
2770 .map(|update| update.target)
2771 .collect();
2772
2773 let mut stack = tips;
2774 let mut seen = HashSet::new();
2775 let mut counts = ExportedCommitCounts::default();
2776 while let Some(oid) = stack.pop() {
2777 if !seen.insert(oid) {
2778 continue;
2779 }
2780 let object = repo.read_object(&oid).map_err(git_err)?;
2781 match object.object_type {
2782 GitObjectType::Commit => {
2783 counts.total += 1;
2784 if newly_minted.contains(&oid) {
2785 counts.newly += 1;
2786 }
2787 let commit = repo.read_commit(&oid).map_err(git_err)?;
2788 for parent in commit.parents {
2789 stack.push(parent);
2790 }
2791 }
2792 GitObjectType::Tag => {
2796 let tag = repo.read_tag(&oid).map_err(git_err)?;
2797 stack.push(tag.object);
2798 }
2799 GitObjectType::Tree | GitObjectType::Blob => {}
2800 }
2801 }
2802 Ok(counts)
2803}
2804
2805fn collect_ref_updates_for_fetch(
2806 repo: &SleyRepository,
2807 scope: GitFetchScope,
2808) -> GitResult<Vec<RefUpdate>> {
2809 let updates = collect_ref_updates(repo)?;
2810 match scope {
2811 GitFetchScope::AllRefs => Ok(updates),
2812 GitFetchScope::BranchesAndNotes => Ok(updates
2813 .into_iter()
2814 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Note))
2815 .collect()),
2816 }
2817}
2818
2819pub(crate) fn collect_import_source_ref_updates(
2820 repo: &SleyRepository,
2821 refs: &[String],
2822) -> GitResult<Vec<RefUpdate>> {
2823 let updates = collect_ref_updates(repo)?;
2824 if refs.is_empty() {
2825 return Ok(updates);
2826 }
2827
2828 let wanted: HashSet<&str> = refs.iter().map(String::as_str).collect();
2829 Ok(updates
2830 .into_iter()
2831 .filter(|update| matches_import_ref(update, &wanted))
2832 .collect())
2833}
2834
2835fn matches_import_ref(update: &RefUpdate, wanted: &HashSet<&str>) -> bool {
2836 let full = full_ref_name(update);
2837 wanted.contains(update.name.as_str()) || wanted.contains(full.as_str())
2838}
2839
2840fn full_ref_name(update: &RefUpdate) -> String {
2841 GitRefName::content_full_name(update.namespace, &update.name)
2842}
2843
2844#[cfg(test)]
2845pub(crate) fn ensure_commit_update_fast_forward(
2846 repo: &SleyRepository,
2847 name: &str,
2848 old: ObjectId,
2849 new: ObjectId,
2850) -> GitResult<()> {
2851 if old == new || old == ObjectId::null(repo.object_format()) {
2852 return Ok(());
2853 }
2854 match commit_is_descendant_of(repo, new, old) {
2855 Ok(true) => Ok(()),
2856 Ok(false) => Err(GitBridgeError::NonFastForwardRef {
2857 name: name.to_string(),
2858 old,
2859 new,
2860 }),
2861 Err(err) => Err(GitBridgeError::Git(format!(
2862 "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"
2863 ))),
2864 }
2865}
2866
2867fn commit_is_descendant_of(
2868 repo: &SleyRepository,
2869 descendant: ObjectId,
2870 ancestor: ObjectId,
2871) -> GitResult<bool> {
2872 let mut stack = vec![descendant];
2873 let mut seen = HashSet::new();
2874 while let Some(oid) = stack.pop() {
2875 if oid == ancestor {
2876 return Ok(true);
2877 }
2878 if !seen.insert(oid) {
2879 continue;
2880 }
2881 let commit = repo.read_commit(&oid).map_err(git_err)?;
2882 for parent in commit.parents {
2883 stack.push(parent);
2884 }
2885 }
2886 Ok(false)
2887}
2888
2889const HEDDLE_EXPORTED_REFS_FILE: &str = "heddle-exported-refs";
2899
2900const HEDDLE_NETWORK_EXPORTED_REFS_DIR: &str = "git-network-exported-refs";
2907
2908fn exported_refs_manifest_path(target_repo: &SleyRepository) -> PathBuf {
2909 target_repo.git_dir().join(HEDDLE_EXPORTED_REFS_FILE)
2910}
2911
2912fn network_exported_refs_path(heddle_dir: &Path, url: &str) -> PathBuf {
2917 let key = ContentHash::compute_typed("git-network-exported-refs", url.as_bytes()).to_hex();
2918 heddle_dir
2919 .join(HEDDLE_NETWORK_EXPORTED_REFS_DIR)
2920 .join(format!("{key}.refs"))
2921}
2922
2923fn read_exported_refs_at(path: &Path) -> GitResult<HashMap<String, ObjectId>> {
2931 match fs::read_to_string(path) {
2932 Ok(text) => {
2933 let mut map = HashMap::new();
2934 for line in text.lines() {
2935 let line = line.trim();
2936 if line.is_empty() {
2937 continue;
2938 }
2939 let mut parts = line.split_whitespace();
2947 let Some(name) = parts.next() else {
2948 continue;
2949 };
2950 let tip = parts
2951 .next()
2952 .and_then(|token| token.parse::<ObjectId>().ok())
2953 .unwrap_or_else(|| ObjectId::null(ObjectFormat::Sha1));
2954 map.insert(name.to_string(), tip);
2955 }
2956 Ok(map)
2957 }
2958 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
2959 Err(e) => Err(GitBridgeError::Io(e)),
2960 }
2961}
2962
2963fn write_exported_refs_at(path: &Path, refs: &HashMap<String, ObjectId>) -> GitResult<()> {
2967 if let Some(parent) = path.parent() {
2968 fs::create_dir_all(parent)?;
2969 }
2970 let mut sorted: Vec<(&str, &ObjectId)> = refs
2971 .iter()
2972 .map(|(name, tip)| (name.as_str(), tip))
2973 .collect();
2974 sorted.sort_unstable_by(|a, b| a.0.cmp(b.0));
2975 let body = sorted
2976 .iter()
2977 .map(|(name, tip)| format!("{name} {tip}"))
2978 .collect::<Vec<_>>()
2979 .join("\n");
2980 let tmp = path.with_extension("tmp");
2981 fs::write(&tmp, body)?;
2982 fs::rename(&tmp, path)?;
2983 Ok(())
2984}
2985
2986pub(crate) fn write_head_symref(git_dir: &Path, branch_ref: &str) -> GitResult<()> {
2989 let repo = repo_for_git_dir(git_dir)?;
2990 repo.set_head_symref(branch_ref, HeadUpdateOptions::new())
2991 .map_err(git_err)?;
2992 Ok(())
2993}
2994
2995fn repo_for_git_dir(git_dir: &Path) -> GitResult<SleyRepository> {
2996 if let Ok(repo) = open_repo(git_dir) {
2997 return Ok(repo);
2998 }
2999 if git_dir.file_name().is_some_and(|name| name == ".git")
3000 && let Some(parent) = git_dir.parent()
3001 {
3002 return open_repo(parent);
3003 }
3004 open_repo(git_dir)
3005}
3006
3007pub(crate) fn read_exported_refs(
3010 target_repo: &SleyRepository,
3011) -> GitResult<HashMap<String, ObjectId>> {
3012 read_exported_refs_at(&exported_refs_manifest_path(target_repo))
3013}
3014
3015pub(crate) fn write_exported_refs(
3018 target_repo: &SleyRepository,
3019 refs: &HashMap<String, ObjectId>,
3020) -> GitResult<()> {
3021 write_exported_refs_at(&exported_refs_manifest_path(target_repo), refs)
3022}
3023
3024const HEDDLE_MIRROR_MANAGED_REFS_FILE: &str = "heddle-mirror-managed-refs";
3036
3037fn mirror_managed_refs_path(mirror_repo: &SleyRepository) -> PathBuf {
3039 mirror_repo.git_dir().join(HEDDLE_MIRROR_MANAGED_REFS_FILE)
3040}
3041
3042pub(crate) fn mirror_managed_refs_recorded(mirror_repo: &SleyRepository) -> bool {
3048 mirror_managed_refs_path(mirror_repo).exists()
3049}
3050
3051pub(crate) fn read_mirror_managed_refs(
3055 mirror_repo: &SleyRepository,
3056) -> GitResult<HashMap<String, ObjectId>> {
3057 read_exported_refs_at(&mirror_managed_refs_path(mirror_repo))
3058}
3059
3060pub(crate) fn write_mirror_managed_refs(
3063 mirror_repo: &SleyRepository,
3064 refs: &HashMap<String, ObjectId>,
3065) -> GitResult<()> {
3066 write_exported_refs_at(&mirror_managed_refs_path(mirror_repo), refs)
3067}
3068
3069pub(crate) fn read_or_seed_mirror_managed_refs(
3082 mirror_repo: &SleyRepository,
3083) -> GitResult<HashMap<String, ObjectId>> {
3084 if mirror_managed_refs_recorded(mirror_repo) {
3085 read_mirror_managed_refs(mirror_repo)
3086 } else {
3087 Ok(collect_ref_updates(mirror_repo)?
3088 .into_iter()
3089 .map(|update| (full_ref_name(&update), update.target))
3090 .collect())
3091 }
3092}
3093
3094pub(crate) fn collect_managed_ref_updates(
3104 repo: &SleyRepository,
3105 record: &HashMap<String, ObjectId>,
3106) -> GitResult<Vec<RefUpdate>> {
3107 Ok(collect_ref_updates(repo)?
3108 .into_iter()
3109 .filter(|update| {
3110 matches!(update.namespace, RefNamespace::Note)
3111 || record.contains_key(&full_ref_name(update))
3112 })
3113 .collect())
3114}
3115
3116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3122enum RefMove {
3123 Unchanged,
3125 Create,
3127 FastForward,
3129 Rewind,
3138 Diverged,
3141}
3142
3143fn classify_ref_move(
3159 repo: &SleyRepository,
3160 old: Option<ObjectId>,
3161 new: ObjectId,
3162 recorded_tip: Option<ObjectId>,
3163) -> GitResult<RefMove> {
3164 let Some(old) = old else {
3165 return Ok(RefMove::Create);
3166 };
3167 if old == ObjectId::null(repo.object_format()) {
3168 return Ok(RefMove::Create);
3169 }
3170 if old == new {
3171 return Ok(RefMove::Unchanged);
3172 }
3173 if commit_is_descendant_of(repo, new, old)? {
3176 return Ok(RefMove::FastForward);
3177 }
3178 if recorded_tip == Some(old)
3188 && repo.read_commit(&old).is_ok()
3189 && commit_is_descendant_of(repo, old, new)?
3190 {
3191 return Ok(RefMove::Rewind);
3192 }
3193 Ok(RefMove::Diverged)
3194}
3195
3196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3210enum WriteVerdict {
3211 Skip,
3213 Write,
3216 RequireForce,
3218}
3219
3220fn verdict_from_move(m: RefMove) -> WriteVerdict {
3225 match m {
3226 RefMove::Unchanged => WriteVerdict::Skip,
3227 RefMove::Create | RefMove::FastForward | RefMove::Rewind => WriteVerdict::Write,
3228 RefMove::Diverged => WriteVerdict::RequireForce,
3229 }
3230}
3231
3232fn classify_tag_move(
3240 old: Option<ObjectId>,
3241 target: ObjectId,
3242 recorded: Option<ObjectId>,
3243) -> WriteVerdict {
3244 match old {
3245 None => WriteVerdict::Write,
3247 Some(o) if o == target => WriteVerdict::Skip,
3249 Some(o) if recorded == Some(o) => WriteVerdict::Write,
3251 Some(_) => WriteVerdict::RequireForce,
3253 }
3254}
3255
3256#[derive(Debug)]
3259pub(crate) struct PlannedRefWrite {
3260 pub(crate) full_name: String,
3261 pub(crate) old: Option<ObjectId>,
3262 pub(crate) new: ObjectId,
3263 pub(crate) force: bool,
3264}
3265
3266#[derive(Debug)]
3269pub(crate) struct PlannedRefDelete {
3270 pub(crate) full_name: String,
3271 pub(crate) old: ObjectId,
3272}
3273
3274#[derive(Debug)]
3277pub(crate) struct DestinationReconcilePlan {
3278 pub(crate) writes: Vec<PlannedRefWrite>,
3280 pub(crate) deletes: Vec<PlannedRefDelete>,
3283 pub(crate) new_manifest: HashMap<String, ObjectId>,
3289}
3290
3291pub(crate) fn planned_write_names(plan: &DestinationReconcilePlan) -> Vec<String> {
3298 let mut names: Vec<String> = plan
3299 .writes
3300 .iter()
3301 .map(|write| write.full_name.clone())
3302 .collect();
3303 names.sort_unstable();
3304 names
3305}
3306
3307fn creatable_ref_names(
3316 served_frontier: &[RefUpdate],
3317 scope: GitPushScope,
3318 current_branch: Option<&str>,
3319) -> Option<HashSet<String>> {
3320 match scope {
3321 GitPushScope::AllThreads => None,
3322 GitPushScope::CurrentThread => {
3323 let branch = current_branch.unwrap_or_default();
3324 Some(
3325 served_frontier
3326 .iter()
3327 .filter(|update| {
3328 (matches!(update.namespace, RefNamespace::Branch) && update.name == branch)
3329 || matches!(update.namespace, RefNamespace::Note)
3330 })
3331 .map(full_ref_name)
3332 .collect(),
3333 )
3334 }
3335 }
3336}
3337
3338pub(crate) fn plan_destination_reconcile(
3386 mirror_repo: &SleyRepository,
3387 served_frontier: &[RefUpdate],
3388 creatable_names: Option<&HashSet<String>>,
3389 old_at_destination: &HashMap<String, ObjectId>,
3390 previously_exported: &HashMap<String, ObjectId>,
3391 force: bool,
3392) -> GitResult<DestinationReconcilePlan> {
3393 let desired: HashMap<String, &RefUpdate> = served_frontier
3399 .iter()
3400 .map(|u| (full_ref_name(u), u))
3401 .collect();
3402
3403 let mut names: BTreeSet<String> = desired.keys().cloned().collect();
3410 names.extend(previously_exported.keys().cloned());
3411
3412 let mut writes = Vec::new();
3413 let mut deletes = Vec::new();
3414 let mut new_manifest: HashMap<String, ObjectId> = HashMap::new();
3415
3416 for full in names {
3417 let old = old_at_destination.get(&full).copied();
3418 let recorded = previously_exported.get(&full).copied();
3419
3420 if let Some(update) = desired.get(&full).copied() {
3421 if old.is_none() && creatable_names.is_some_and(|names| !names.contains(&full)) {
3430 if let Some(recorded) = recorded {
3431 new_manifest.insert(full, recorded);
3432 }
3433 continue;
3434 }
3435 let (verdict, force_write) = match update.namespace {
3444 RefNamespace::Branch | RefNamespace::Note => {
3445 let movement = classify_ref_move(mirror_repo, old, update.target, recorded)?;
3446 (
3447 verdict_from_move(movement),
3448 matches!(movement, RefMove::Rewind),
3449 )
3450 }
3451 RefNamespace::Tag => {
3452 let verdict = classify_tag_move(old, update.target, recorded);
3453 (
3454 verdict,
3455 old.is_some_and(|old| old != update.target)
3456 && matches!(verdict, WriteVerdict::Write),
3457 )
3458 }
3459 };
3460 let proceed = match verdict {
3461 WriteVerdict::Skip => false,
3462 WriteVerdict::Write => true,
3463 WriteVerdict::RequireForce => {
3464 if force {
3465 true
3466 } else {
3467 return Err(GitBridgeError::NonFastForwardRef {
3468 name: full.clone(),
3469 old: old.unwrap_or_else(|| ObjectId::null(mirror_repo.object_format())),
3470 new: update.target,
3471 });
3472 }
3473 }
3474 };
3475 if proceed {
3476 writes.push(PlannedRefWrite {
3477 full_name: full.clone(),
3478 old,
3479 new: update.target,
3480 force: force_write || matches!(verdict, WriteVerdict::RequireForce),
3481 });
3482 }
3483 if proceed || recorded.is_some() {
3491 new_manifest.insert(full, update.target);
3492 }
3493 continue;
3494 }
3495
3496 match old {
3505 Some(old) if recorded == Some(old) || force => {
3506 deletes.push(PlannedRefDelete {
3507 full_name: full,
3508 old,
3509 });
3510 }
3512 Some(_) => {
3513 if let Some(recorded) = recorded {
3516 new_manifest.insert(full, recorded);
3517 }
3518 }
3519 None => {
3520 }
3522 }
3523 }
3524
3525 Ok(DestinationReconcilePlan {
3526 writes,
3527 deletes,
3528 new_manifest,
3529 })
3530}
3531
3532fn read_destination_ref_map(repo: &SleyRepository) -> GitResult<HashMap<String, ObjectId>> {
3536 Ok(collect_ref_updates(repo)?
3537 .iter()
3538 .map(|update| (full_ref_name(update), update.target))
3539 .collect())
3540}
3541
3542pub(crate) fn apply_ref_updates(
3543 repo: &SleyRepository,
3544 updates: &[RefUpdate],
3545 log_message: &str,
3546) -> GitResult<()> {
3547 for update in updates {
3548 let full_name = full_ref_name(update);
3549 set_reference(
3550 repo,
3551 &full_name,
3552 update.target,
3553 RefPrecondition::Any,
3554 log_message,
3555 )?;
3556 }
3557 Ok(())
3558}
3559
3560fn apply_remote_tracking_ref_updates(
3561 repo: &SleyRepository,
3562 remote_name: &str,
3563 updates: &[RefUpdate],
3564 log_message: &str,
3565) -> GitResult<()> {
3566 reject_reserved_git_remote_name(remote_name)?;
3567 for update in updates
3568 .iter()
3569 .filter(|update| update.namespace == RefNamespace::Branch)
3570 {
3571 set_reference(
3572 repo,
3573 &format!("refs/remotes/{remote_name}/{}", update.name),
3574 update.target,
3575 RefPrecondition::Any,
3576 log_message,
3577 )?;
3578 }
3579 Ok(())
3580}
3581
3582pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitResult<()> {
3586 fs::create_dir_all(dest)?;
3587 let source = open_repo(source_path)?;
3588 let target = match SleyRepository::open(dest) {
3589 Ok(repo) => repo,
3590 Err(_) => SleyRepository::init_bare(dest).map_err(git_err)?,
3591 };
3592 let updates = collect_ref_updates(&source)?;
3593 copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
3594 apply_ref_updates(
3595 &target,
3596 &updates,
3597 &format!("heddle: clone from {}", source_path.display()),
3598 )?;
3599
3600 let copied_branches: HashSet<&str> = updates
3608 .iter()
3609 .filter(|update| update.namespace == RefNamespace::Branch)
3610 .map(|update| update.name.as_str())
3611 .collect();
3612 let source_head_branch = source
3613 .head()
3614 .ok()
3615 .and_then(|head| head.branch_name().map(str::to_owned))
3616 .filter(|branch| copied_branches.contains(branch.as_str()));
3617 if let Some(branch) = source_head_branch {
3618 write_head_symref(dest, &format!("refs/heads/{branch}"))?;
3619 } else if copied_branches.contains("main") {
3620 write_head_symref(dest, "refs/heads/main")?;
3621 } else if let Some(first_branch) = updates
3622 .iter()
3623 .find(|update| update.namespace == RefNamespace::Branch)
3624 {
3625 write_head_symref(dest, &format!("refs/heads/{}", first_branch.name))?;
3626 }
3627 Ok(())
3628}
3629
3630pub fn clone_url_to_bare(
3649 url: &str,
3650 dest: &Path,
3651 depth: Option<u32>,
3652 filter: Option<&str>,
3653) -> GitResult<()> {
3654 if let Some(spec) = filter {
3658 return Err(GitBridgeError::Git(format!(
3659 "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"
3660 )));
3661 }
3662 if let Some(source_path) = local_path_from_url(url)? {
3663 if depth.is_some() {
3664 return Err(GitBridgeError::Git(
3665 "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"
3666 .to_string(),
3667 ));
3668 }
3669 return copy_local_repo_to_bare(&source_path, dest);
3670 }
3671 let default_branch =
3672 clone_url_to_bare_via_sley(url, dest, depth)?.or_else(|| default_branch_from_file_url(url));
3673 if let Some(branch) = default_branch
3683 && bare_branch_exists(dest, &branch)?
3684 {
3685 write_head_symref(dest, &format!("refs/heads/{branch}"))?;
3686 }
3687 Ok(())
3688}
3689
3690fn default_branch_from_file_url(url: &str) -> Option<String> {
3691 let source_path = local_path_from_url(url).ok().flatten()?;
3692 let repo = open_repo(&source_path).ok()?;
3693 let head = repo.head_state().ok()?;
3694 let branch = head.branch_name()?;
3695 (!branch.is_empty()).then(|| branch.to_string())
3696}
3697
3698fn bare_branch_exists(repo_path: &Path, branch: &str) -> GitResult<bool> {
3699 let repo = open_repo(repo_path)?;
3700 Ok(repo
3701 .find_reference(&format!("refs/heads/{branch}"))
3702 .map_err(git_err)?
3703 .is_some())
3704}
3705
3706fn clone_url_to_bare_via_sley(
3707 url: &str,
3708 dest: &Path,
3709 depth: Option<u32>,
3710) -> GitResult<Option<String>> {
3711 fs::create_dir_all(dest)?;
3712 let repo = SleyRepository::init_bare(dest).map_err(git_err)?;
3713 let mut credentials = NoCredentials;
3714 let mut progress = SilentProgress;
3715 let outcome = repo
3716 .fetch(
3717 url,
3718 &heddle_mirror_fetch_refspecs()?,
3719 FetchOptions {
3720 quiet: true,
3721 auto_follow_tags: true,
3722 fetch_all_tags: true,
3723 prune: false,
3724 dry_run: false,
3725 append: false,
3726 write_fetch_head: true,
3727 force: false,
3728 tag_option_explicit: true,
3729 prune_option_explicit: true,
3730 prune_tags: false,
3731 prune_tags_option_explicit: false,
3732 refmap: None,
3733 refetch: false,
3734 record_promisor_refs: false,
3735 update_head_ok: false,
3736 ssh_options: None,
3737 atomic: false,
3738 depth,
3739 merge_srcs: Vec::new(),
3740 filter: None,
3741 cloning: true,
3742 update_shallow: false,
3743 deepen_relative: false,
3744 deepen_since: None,
3745 deepen_not: Vec::new(),
3746 },
3747 &mut credentials,
3748 &mut progress,
3749 )
3750 .map_err(|err| GitBridgeError::Git(format!("clone failed for {url}: {err}")))?;
3751 Ok(outcome
3752 .head_symref
3753 .and_then(|target| target.strip_prefix("refs/heads/").map(str::to_string)))
3754}
3755
3756#[allow(clippy::too_many_arguments)]
3783pub(crate) fn materialize_checkout_closure_from_state(
3784 heddle_repo: &HeddleRepository,
3785 mapping: &SyncMapping,
3786 mirror_repo: &SleyRepository,
3787 object_repo: &SleyRepository,
3788 tip_state_id: &ChangeId,
3789 tip_oid: ObjectId,
3790 excluded: &HashSet<ObjectId>,
3791) -> GitResult<()> {
3792 let mut lossy_roots: Vec<ObjectId> = Vec::new();
3796 let mut stack: Vec<ChangeId> = vec![*tip_state_id];
3797 let mut seen: HashSet<ChangeId> = HashSet::new();
3798
3799 while let Some(state_id) = stack.pop() {
3800 if !seen.insert(state_id) {
3801 continue;
3802 }
3803 let Some(git_oid) = resolve_mapped_git_oid(heddle_repo, mapping, &state_id, object_repo)?
3804 else {
3805 continue;
3811 };
3812
3813 if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
3817 continue;
3818 }
3819
3820 let state = heddle_repo
3821 .store()
3822 .get_state(&state_id)?
3823 .ok_or(GitBridgeError::StateNotFound(state_id))?;
3824
3825 if commit_is_byte_faithful(&state) {
3826 let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
3827 let reconstructed = commit_object_id(&content);
3831 if reconstructed != git_oid {
3832 return Err(GitBridgeError::Git(format!(
3833 "checkout reconstruction OID mismatch for state {state_id}: reconstructed {reconstructed}, expected mapped {git_oid}; \
3834 refusing to materialize a wrong-OID checkout (unmodeled fidelity gap)"
3835 )));
3836 }
3837 let written = write_commit_object(object_repo, &content)?;
3838 debug_assert_eq!(written, git_oid);
3839 stack.extend(state.parents.iter().copied());
3840 } else {
3841 lossy_roots.push(git_oid);
3845 }
3846 }
3847
3848 if object_repo.read_object(&tip_oid).is_err() && !lossy_roots.contains(&tip_oid) {
3854 lossy_roots.push(tip_oid);
3855 }
3856
3857 if !lossy_roots.is_empty() {
3858 copy_reachable_objects_excluding(mirror_repo, object_repo, lossy_roots, excluded)?;
3859 }
3860
3861 Ok(())
3862}
3863
3864fn resolve_mapped_git_oid(
3869 heddle_repo: &HeddleRepository,
3870 mapping: &SyncMapping,
3871 state_id: &ChangeId,
3872 object_repo: &SleyRepository,
3873) -> GitResult<Option<ObjectId>> {
3874 if let Some(git_oid) = mapping.get_git(state_id) {
3875 return Ok(Some(git_oid));
3876 }
3877 if let Some(git_commit) = heddle_repo
3878 .git_overlay_mapped_git_commit_for_change(state_id)
3879 .map_err(|error| GitBridgeError::Git(error.to_string()))?
3880 {
3881 let oid = ObjectId::from_hex(object_repo.object_format(), &git_commit)
3882 .map_err(|error| GitBridgeError::InvalidMapping(error.to_string()))?;
3883 return Ok(Some(oid));
3884 }
3885 Ok(None)
3886}
3887
3888pub(crate) fn copy_reachable_objects(
3889 source: &SleyRepository,
3890 target: &SleyRepository,
3891 roots: impl IntoIterator<Item = ObjectId>,
3892) -> GitResult<()> {
3893 let roots = roots.into_iter().collect::<Vec<_>>();
3897 target.copy_reachable_from(source, &roots).map_err(git_err)
3898}
3899
3900pub(crate) fn copy_reachable_objects_excluding(
3915 source: &SleyRepository,
3916 target: &SleyRepository,
3917 roots: impl IntoIterator<Item = ObjectId>,
3918 excluded: &HashSet<ObjectId>,
3919) -> GitResult<()> {
3920 if excluded.is_empty() {
3921 return copy_reachable_objects(source, target, roots);
3922 }
3923 if source.object_format() != target.object_format() {
3924 return copy_reachable_objects(source, target, roots);
3927 }
3928 sley::plumbing::sley_odb::install_reachable_pack_excluding(
3932 source.objects().as_ref(),
3933 target.objects().as_ref(),
3934 target.object_format(),
3935 roots,
3936 excluded,
3937 )
3938 .map_err(|error| GitBridgeError::Git(error.to_string()))?;
3939 target.refresh_objects();
3942 Ok(())
3943}
3944
3945fn fetch_network_remote(
3946 mirror_repo: &SleyRepository,
3947 remote_name: &str,
3948 url: &str,
3949 scope: GitFetchScope,
3950) -> GitResult<()> {
3951 let mut credentials = NoCredentials;
3952 let mut progress = SilentProgress;
3953 mirror_repo
3954 .fetch(
3955 url,
3956 &heddle_mirror_fetch_refspecs()?,
3957 FetchOptions {
3958 quiet: true,
3959 auto_follow_tags: matches!(scope, GitFetchScope::AllRefs),
3960 fetch_all_tags: matches!(scope, GitFetchScope::AllRefs),
3961 prune: false,
3962 dry_run: false,
3963 append: false,
3964 write_fetch_head: true,
3965 force: false,
3966 tag_option_explicit: true,
3967 prune_option_explicit: true,
3968 prune_tags: false,
3969 prune_tags_option_explicit: false,
3970 refmap: None,
3971 refetch: false,
3972 record_promisor_refs: false,
3973 update_head_ok: false,
3974 ssh_options: None,
3975 atomic: false,
3976 depth: None,
3977 merge_srcs: Vec::new(),
3978 filter: None,
3979 cloning: false,
3980 update_shallow: false,
3981 deepen_relative: false,
3982 deepen_since: None,
3983 deepen_not: Vec::new(),
3984 },
3985 &mut credentials,
3986 &mut progress,
3987 )
3988 .map_err(|err| GitBridgeError::Git(format!("failed to fetch from {url}: {err}")))?;
3989 let _ = remote_name;
3990 Ok(())
3991}
3992
3993fn push_network_remote(
3996 mirror_repo: &SleyRepository,
3997 heddle_dir: &Path,
3998 url: &str,
3999 scope: GitPushScope,
4000 current_branch: Option<&str>,
4001 force: bool,
4002) -> GitResult<Vec<String>> {
4003 let manifest_path = network_exported_refs_path(heddle_dir, url);
4009 let previously_exported = read_exported_refs_at(&manifest_path)?;
4010 let managed_record = read_mirror_managed_refs(mirror_repo)?;
4020 let served_frontier = collect_managed_ref_updates(mirror_repo, &managed_record)?;
4021 if served_frontier.is_empty() && previously_exported.is_empty() {
4022 return Ok(Vec::new());
4023 }
4024
4025 let mut credentials = NoCredentials;
4026 let records = mirror_repo
4027 .ls_remote(
4028 url,
4029 LsRemoteFilter {
4030 heads: false,
4031 tags: false,
4032 refs_only: true,
4033 },
4034 &|_| true,
4035 &mut credentials,
4036 )
4037 .map_err(|err| GitBridgeError::Git(format!("failed to list refs from {url}: {err}")))?;
4038 let remote_refs = records
4039 .into_iter()
4040 .filter(|record| GitRefName::new(&record.name).content_namespace().is_some())
4041 .map(|record| (record.name, record.oid))
4042 .collect::<HashMap<_, _>>();
4043
4044 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4049 let plan = plan_destination_reconcile(
4050 mirror_repo,
4051 &served_frontier,
4052 creatable.as_ref(),
4053 &remote_refs,
4054 &previously_exported,
4055 force,
4056 )?;
4057
4058 if plan.writes.is_empty() && plan.deletes.is_empty() {
4059 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4062 return Ok(Vec::new());
4063 }
4064
4065 let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4066 let mut pack_objects = Vec::with_capacity(plan.writes.len());
4067 let force_transport_checks = plan.writes.iter().any(|write| write.force);
4068 for write in &plan.writes {
4069 commands.push(PushCommand {
4070 src: Some(write.new),
4071 dst: write.full_name.clone(),
4072 expected_old: write.old,
4073 force: write.force,
4074 });
4075 pack_objects.push(write.new);
4076 }
4077 for delete in &plan.deletes {
4078 commands.push(PushCommand {
4079 src: None,
4080 dst: delete.full_name.clone(),
4081 expected_old: Some(delete.old),
4082 force: false,
4083 });
4084 }
4085
4086 let mut credentials = NoCredentials;
4087 let mut progress = SilentProgress;
4088 mirror_repo
4089 .push_actions(
4090 url,
4091 PushActionPlan {
4092 commands,
4093 pack_objects,
4094 options: PushOptions {
4095 quiet: true,
4096 force: force || force_transport_checks,
4097 thin: sley::remote::PushThinMode::Auto,
4098 },
4099 },
4100 &mut credentials,
4101 &mut progress,
4102 )
4103 .map_err(|err| GitBridgeError::Git(format!("push failed for {url}: {err}")))?;
4104 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4107 Ok(planned_write_names(&plan))
4108}
4109
4110#[cfg(test)]
4111mod tests {
4112 use super::*;
4113
4114 #[test]
4115 fn parse_git_ref_local_branch() {
4116 let parsed = parse_git_ref("refs/heads/main").expect("local branch parses");
4117 assert_eq!(parsed.kind, GitRefKind::Branch);
4118 assert_eq!(parsed.name, "main");
4119 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4120 }
4121
4122 #[test]
4123 fn parse_git_ref_remote_branch_keeps_nested_name() {
4124 let parsed = parse_git_ref("refs/remotes/origin/feature/x").expect("remote branch parses");
4125 assert_eq!(parsed.kind, GitRefKind::Branch);
4126 assert_eq!(parsed.name, "feature/x");
4127 assert_eq!(parsed.remote, "origin");
4128 }
4129
4130 #[test]
4131 fn parse_git_ref_tag() {
4132 let parsed = parse_git_ref("refs/tags/v1.0").expect("tag parses");
4133 assert_eq!(parsed.kind, GitRefKind::Tag);
4134 assert_eq!(parsed.name, "v1.0");
4135 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4136 }
4137
4138 #[test]
4139 fn parse_git_ref_note() {
4140 let parsed = parse_git_ref("refs/notes/heddle").expect("note parses");
4141 assert_eq!(parsed.kind, GitRefKind::Note);
4142 assert_eq!(parsed.name, "heddle");
4143 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4144 }
4145
4146 #[test]
4147 fn parse_git_ref_skips_head_symrefs() {
4148 assert_eq!(parse_git_ref("refs/heads/HEAD"), None);
4149 assert_eq!(parse_git_ref("refs/remotes/origin/HEAD"), None);
4150 }
4151
4152 #[test]
4153 fn parse_git_ref_rejects_unknown_or_malformed() {
4154 assert_eq!(parse_git_ref("HEAD"), None);
4155 assert_eq!(parse_git_ref("refs/remotes/origin"), None);
4157 }
4158
4159 #[test]
4160 fn parse_git_ref_rejects_reserved_git_remote_namespace() {
4161 assert_eq!(parse_git_ref("refs/remotes/git/main"), None);
4164 assert_eq!(parse_git_ref("refs/remotes/git/feature/x"), None);
4165 assert!(is_reserved_git_remote_name(REMOTE_NAME_FOR_LOCAL_GIT_REPO));
4166 assert!(!is_reserved_git_remote_name("origin"));
4167 }
4168
4169 #[test]
4170 fn local_path_from_url_rejects_hosted_heddle_scheme() {
4171 let err = local_path_from_url("heddle://weft.local:8421/org/repo")
4179 .expect_err("heddle:// must be rejected by the git exporter classifier");
4180 let msg = err.to_string();
4181 assert!(
4182 msg.contains("heddle://") && msg.contains("hosted"),
4183 "error should explain the hosted scheme cannot be pushed via the git-overlay exporter, got: {msg}"
4184 );
4185 }
4186
4187 #[test]
4188 fn local_path_from_url_still_accepts_file_and_git_urls() {
4189 assert!(
4193 local_path_from_url("file:///tmp/repo.git")
4194 .expect("file url ok")
4195 .is_some(),
4196 "file:// must still resolve to a local path"
4197 );
4198 assert!(
4199 local_path_from_url("https://example.com/org/repo.git")
4200 .expect("https url ok")
4201 .is_none(),
4202 "https git url must pass through as a network URL"
4203 );
4204 assert!(
4205 local_path_from_url("git@github.com:org/repo.git")
4206 .expect("ssh url ok")
4207 .is_none(),
4208 "ssh git url must pass through as a network URL"
4209 );
4210 }
4211
4212 #[test]
4213 fn refspec_forced_round_trips_git_format() {
4214 let spec =
4215 RefSpec::forced("refs/heads/main", "refs/heads/main").expect("valid forced refspec");
4216 assert_eq!(spec.to_git_format(), "+refs/heads/main:refs/heads/main");
4217 assert_eq!(
4218 spec.to_git_format_not_forced(),
4219 "refs/heads/main:refs/heads/main"
4220 );
4221 }
4222
4223 #[test]
4224 fn refspec_constructor_rejects_reserved_remote_name() {
4225 let err = RefSpec::new(
4226 Some("refs/remotes/git/main".to_string()),
4227 "refs/heads/main",
4228 false,
4229 )
4230 .expect_err("reserved remote source is rejected");
4231 assert!(err.to_string().contains("reserved namespace"));
4232
4233 let err = RefSpec::new(
4234 Some("refs/heads/main".to_string()),
4235 "refs/remotes/git/main",
4236 false,
4237 )
4238 .expect_err("reserved remote destination is rejected");
4239 assert!(err.to_string().contains("reserved namespace"));
4240 }
4241
4242 #[test]
4243 fn refspec_forced_rejects_reserved_remote_name() {
4244 assert!(RefSpec::forced("refs/remotes/git/main", "refs/heads/main").is_err());
4245 assert!(RefSpec::forced("refs/heads/main", "refs/remotes/git/main").is_err());
4246 }
4247
4248 #[test]
4249 fn refspec_delete_has_empty_source() {
4250 let spec = RefSpec::delete("refs/heads/stale").expect("valid delete refspec");
4251 assert_eq!(spec.to_git_format(), ":refs/heads/stale");
4252 assert_eq!(spec.to_git_format_not_forced(), ":refs/heads/stale");
4253 }
4254
4255 #[test]
4256 fn refspec_delete_rejects_reserved_remote_name() {
4257 assert!(RefSpec::delete("refs/remotes/git/stale").is_err());
4258 }
4259
4260 #[test]
4261 fn refspec_constructor_rejects_empty_source_and_destination() {
4262 let err = RefSpec::new(None, "", false)
4263 .expect_err("empty source plus empty destination is rejected");
4264 assert!(err.to_string().contains("cannot both be empty"));
4265 }
4266
4267 #[test]
4268 fn negative_refspec_prefixes_caret() {
4269 let spec = NegativeRefSpec::new("refs/heads/wip").expect("valid negative refspec");
4270 assert_eq!(spec.to_git_format(), "^refs/heads/wip");
4271 }
4272
4273 #[test]
4274 fn negative_refspec_constructor_rejects_unparseable_negation() {
4275 let err = NegativeRefSpec::new("refs/heads/wip/*").expect_err("negative glob is rejected");
4276 assert!(err.to_string().contains("Negative glob patterns"));
4277 }
4278
4279 #[test]
4280 fn negative_refspec_constructor_rejects_reserved_remote_name() {
4281 let err = NegativeRefSpec::new("refs/remotes/git/main")
4282 .expect_err("reserved remote negative source is rejected");
4283 assert!(err.to_string().contains("reserved namespace"));
4284 }
4285
4286 #[test]
4287 fn mirror_fetch_refspecs_cover_branches_and_notes() {
4288 assert_eq!(
4289 heddle_mirror_fetch_refspecs().expect("mirror refspecs are valid"),
4290 [
4291 "+refs/heads/*:refs/heads/*".to_string(),
4292 "+refs/notes/*:refs/notes/*".to_string(),
4293 ]
4294 );
4295 }
4296
4297 #[test]
4298 fn scoped_import_ref_updates_do_not_include_notes_implicitly() {
4299 let tmp = tempfile::TempDir::new().unwrap();
4300 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4301 let main = seed_commit(&repo, "main");
4302 let other = seed_commit(&repo, "other");
4303 let notes = seed_commit(&repo, "notes");
4304 set_reference(
4305 &repo,
4306 "refs/heads/main",
4307 main,
4308 RefPrecondition::MustNotExist,
4309 "test: main",
4310 )
4311 .expect("write main");
4312 set_reference(
4313 &repo,
4314 "refs/heads/other",
4315 other,
4316 RefPrecondition::MustNotExist,
4317 "test: other",
4318 )
4319 .expect("write other");
4320 set_reference(
4321 &repo,
4322 "refs/notes/heddle",
4323 notes,
4324 RefPrecondition::MustNotExist,
4325 "test: notes",
4326 )
4327 .expect("write notes");
4328
4329 let updates = collect_import_source_ref_updates(&repo, &["main".to_string()])
4330 .expect("collect scoped updates");
4331 let full_names = updates.iter().map(full_ref_name).collect::<Vec<_>>();
4332
4333 assert_eq!(full_names, vec!["refs/heads/main".to_string()]);
4334 }
4335
4336 #[test]
4337 fn fast_forward_guard_reports_exact_rewrite_before_after() {
4338 let tmp = tempfile::TempDir::new().unwrap();
4339 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4340 let root = test_commit(&repo, "root", &[]);
4341 let old = test_commit(&repo, "old", &[root]);
4342 let new = test_commit(&repo, "new", &[root]);
4343
4344 let err = ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
4345 .expect_err("sibling commit update should be refused");
4346 let message = err.to_string();
4347 assert!(message.contains("refs/heads/main"));
4348 assert!(message.contains(&old.to_string()));
4349 assert!(message.contains(&new.to_string()));
4350 assert!(message.contains("refusing to replace"));
4351 }
4352
4353 #[test]
4354 fn fast_forward_guard_allows_descendant_update() {
4355 let tmp = tempfile::TempDir::new().unwrap();
4356 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4357 let old = test_commit(&repo, "old", &[]);
4358 let new = test_commit(&repo, "new", &[old]);
4359
4360 ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
4361 .expect("descendant update should be allowed");
4362 }
4363
4364 fn test_commit(repo: &SleyRepository, message: &str, parents: &[ObjectId]) -> ObjectId {
4365 let empty_tree_oid = ObjectId::empty_tree(repo.object_format());
4366 let sig = Signature {
4367 name: GitByteString::new(b"Heddle Test".to_vec()),
4368 email: GitByteString::new(b"heddle@test".to_vec()),
4369 time: GitTime::new(0, 0),
4370 raw: b"Heddle Test <heddle@test> 0 +0000".to_vec(),
4371 };
4372 let commit = sley::CommitObject {
4373 tree: empty_tree_oid,
4374 parents: parents.to_vec(),
4375 author: sig.to_ident_bytes(),
4376 committer: sig.to_ident_bytes(),
4377 encoding: None,
4378 message: message.as_bytes().to_vec(),
4379 };
4380 repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
4381 GitObjectType::Commit,
4382 commit.write(),
4383 ))
4384 .expect("write test commit")
4385 }
4386
4387 fn seed_commit(repo: &SleyRepository, message: &str) -> ObjectId {
4388 test_commit(repo, message, &[])
4389 }
4390
4391 #[test]
4398 fn clone_url_to_bare_via_sley_honours_remote_head_symref() {
4399 let tmp = tempfile::TempDir::new().unwrap();
4400 let source = tmp.path().join("source.git");
4401 let dest = tmp.path().join("dest.git");
4402
4403 let src = SleyRepository::init_bare(&source).expect("init bare source");
4410 let seed = seed_commit(&src, "seed");
4411 for name in ["refs/heads/trunk", "refs/heads/abc-feature"] {
4412 set_reference(&src, name, seed, RefPrecondition::Any, "test: seed branch")
4413 .expect("set ref");
4414 }
4415 std::fs::write(source.join("HEAD"), b"ref: refs/heads/trunk\n").unwrap();
4418
4419 let url = format!("file://{}", source.display());
4420 clone_url_to_bare(&url, &dest, None, None).expect("clone url to bare");
4421
4422 let dest_head = std::fs::read_to_string(dest.join("HEAD")).expect("read dest HEAD");
4423 assert_eq!(
4424 dest_head.trim(),
4425 "ref: refs/heads/trunk",
4426 "dest HEAD must mirror the remote's symref (trunk), not sley's \
4427 init-time default and not the alphabetically-first branch \
4428 (abc-feature) — see heddle#141"
4429 );
4430 }
4431
4432 #[test]
4433 fn write_head_symref_writes_git_head_bytes() {
4434 let tmp = tempfile::TempDir::new().unwrap();
4435 let git_dir = tmp.path();
4436 SleyRepository::init_bare(git_dir).expect("init bare");
4437
4438 write_head_symref(git_dir, "refs/heads/feature/x").expect("write HEAD symref");
4439 assert_eq!(
4440 std::fs::read_to_string(git_dir.join("HEAD")).expect("read HEAD"),
4441 "ref: refs/heads/feature/x\n"
4442 );
4443
4444 write_head_symref(git_dir, "refs/heads/main").expect("rewrite HEAD symref");
4445 assert_eq!(
4446 std::fs::read_to_string(git_dir.join("HEAD")).unwrap(),
4447 "ref: refs/heads/main\n"
4448 );
4449 }
4450
4451 #[test]
4454 fn head_state_matches_legacy_head_symref_parse() {
4455 let tmp = tempfile::TempDir::new().unwrap();
4456 let root = tmp.path();
4457 let git_dir = root.join(".git");
4458 SleyRepository::init_bare(&git_dir).expect("init bare overlay");
4459
4460 fn legacy_branch_parse(head_path: &Path) -> Option<String> {
4461 let contents = std::fs::read_to_string(head_path).ok()?;
4462 let trimmed = contents.trim();
4463 let suffix = trimmed.strip_prefix("ref: ")?;
4464 let branch = suffix.strip_prefix("refs/heads/")?;
4465 (!branch.is_empty()).then(|| branch.to_string())
4466 }
4467
4468 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
4470 let repo = open_repo(root).expect("open");
4471 assert_eq!(repo.head_state().unwrap().branch_name(), Some("main"));
4472 assert_eq!(legacy_branch_parse(&git_dir.join("HEAD")), Some("main".into()));
4473
4474 let oid =
4476 ObjectId::from_hex(ObjectFormat::Sha1, "0000000000000000000000000000000000000001")
4477 .unwrap();
4478 std::fs::write(git_dir.join("HEAD"), format!("{oid}\n")).unwrap();
4479 let repo = open_repo(root).expect("open");
4480 let state = repo.head_state().unwrap();
4481 assert!(state.is_detached());
4482 assert_eq!(state.branch_name(), None);
4483 assert_eq!(legacy_branch_parse(&git_dir.join("HEAD")), None);
4484
4485 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
4487 let repo = open_repo(root).expect("open");
4488 assert_eq!(
4489 repo.head_state().unwrap().branch_name(),
4490 Some("feature")
4491 );
4492 assert_eq!(
4493 legacy_branch_parse(&git_dir.join("HEAD")),
4494 Some("feature".into())
4495 );
4496 }
4497}