1use std::{
5 collections::{BTreeSet, HashMap, HashSet},
6 fs,
7 path::{Path, PathBuf},
8 time::{SystemTime, UNIX_EPOCH},
9};
10
11use objects::{
12 error::HeddleError,
13 object::{ChangeId, ChangeIdParseError, ContentHash, FileMode, Principal, ThreadName, Tree},
14 store::ObjectStore,
15};
16use refs::Head;
17use repo::Repository as HeddleRepository;
18use sley::{
19 BString as GitBString, DeleteRef, FullName, GitObjectType, GitTime, Index, IndexEntry,
20 IndexWriteOptions, ObjectFormat, ObjectId, RefPrecondition, ReferenceTarget,
21 Repository as SleyRepository, Signature,
22 plumbing::sley_core::ByteString as GitByteString,
23 remote::{
24 FetchOptions, LsRemoteFilter, NoCredentials, PushActionPlan, PushCommand, PushOptions,
25 SilentProgress,
26 },
27};
28
29use super::{
30 git_export::{export_all, export_current_thread},
31 git_ingest::import_git_history,
32 git_util::ImportStats,
33};
34
35#[derive(Debug, thiserror::Error)]
37pub enum GitBridgeError {
38 #[error("git error: {0}")]
39 Git(String),
40
41 #[error("store error: {0}")]
42 Store(#[from] HeddleError),
43
44 #[error("io error: {0}")]
45 Io(#[from] std::io::Error),
46
47 #[error("invalid trailer format: {0}")]
48 InvalidTrailer(String),
49
50 #[error("missing required trailer: {0}")]
51 MissingTrailer(String),
52
53 #[error("invalid mapping: {0}")]
54 InvalidMapping(String),
55
56 #[error("commit not found: {0}")]
57 CommitNotFound(String),
58
59 #[error("state not found: {0}")]
60 StateNotFound(ChangeId),
61
62 #[error("git repository not initialized")]
63 GitRepoNotInitialized,
64
65 #[error(
66 "shallow Git repository at {repository} cannot be imported until full ancestry is available"
67 )]
68 ShallowClone {
69 repository: PathBuf,
70 retry_command: String,
71 },
72
73 #[error("conflict during sync: {0}")]
74 Conflict(String),
75
76 #[error("Git branch '{branch}' cannot be imported as a Heddle thread: {message}")]
77 InvalidThreadName { branch: String, message: String },
78
79 #[error(
80 "Git branch {branch} and Heddle thread {thread} diverged: thread {thread_change}, branch {branch_change}"
81 )]
82 GitHeddleThreadDiverged {
83 thread: String,
84 branch: String,
85 thread_change: ChangeId,
86 branch_change: ChangeId,
87 },
88
89 #[error(
90 "ref update would rewrite {name}: {old} -> {new}; refusing to replace a user-visible Git commit with a Heddle export commit"
91 )]
92 NonFastForwardRef {
93 name: String,
94 old: ObjectId,
95 new: ObjectId,
96 },
97
98 #[error(
99 "remote branch {upstream} does not fast-forward the local Git checkpoint for {branch}: local {local}, remote {remote}"
100 )]
101 RemoteDiverged {
102 branch: String,
103 upstream: String,
104 local: ObjectId,
105 remote: ObjectId,
106 },
107
108 #[error("change id parse error: {0}")]
109 ChangeIdParse(#[from] ChangeIdParseError),
110}
111
112pub type GitResult<T> = std::result::Result<T, GitBridgeError>;
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub(crate) enum RefNamespace {
117 Branch,
118 Tag,
119 Note,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub(crate) struct RefUpdate {
126 pub name: String,
127 pub target: ObjectId,
128 pub namespace: RefNamespace,
129}
130
131pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git";
137
138pub(crate) fn is_reserved_git_remote_name(remote: &str) -> bool {
144 remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
145}
146
147fn reject_reserved_git_remote_name(remote: &str) -> GitResult<()> {
154 if is_reserved_git_remote_name(remote) {
155 return Err(GitBridgeError::Git(format!(
156 "a Git remote named '{remote}' collides with heddle's reserved namespace \
157 (local refs are recorded under the '{REMOTE_NAME_FOR_LOCAL_GIT_REPO}' sentinel); \
158 rename the remote (e.g. `git remote rename {remote} origin`) and retry"
159 )));
160 }
161 Ok(())
162}
163
164fn remote_name_from_remote_ref(ref_name: &str) -> Option<&str> {
165 let remote_and_name = ref_name.strip_prefix("refs/remotes/")?;
166 let remote = remote_and_name
167 .split_once('/')
168 .map_or(remote_and_name, |(remote, _)| remote);
169 (!remote.is_empty()).then_some(remote)
170}
171
172fn validate_refspec_ref(ref_name: &str) -> GitResult<()> {
173 if let Some(remote) = remote_name_from_remote_ref(ref_name) {
174 reject_reserved_git_remote_name(remote)?;
175 }
176 Ok(())
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum GitRefKind {
183 Branch,
185 Tag,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub struct ParsedGitRef<'a> {
194 pub kind: GitRefKind,
195 pub name: &'a str,
198 pub remote: &'a str,
201}
202
203pub fn parse_git_ref(ref_name: &str) -> Option<ParsedGitRef<'_>> {
210 RefSpec::new(None, ref_name, false).ok()?;
211
212 if let Some(name) = ref_name.strip_prefix("refs/heads/") {
213 (name != "HEAD").then_some(ParsedGitRef {
215 kind: GitRefKind::Branch,
216 name,
217 remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
218 })
219 } else if let Some(remote_and_name) = ref_name.strip_prefix("refs/remotes/") {
220 let (remote, name) = remote_and_name.split_once('/')?;
221 (name != "HEAD").then_some(ParsedGitRef {
231 kind: GitRefKind::Branch,
232 name,
233 remote,
234 })
235 } else {
236 ref_name
237 .strip_prefix("refs/tags/")
238 .map(|name| ParsedGitRef {
239 kind: GitRefKind::Tag,
240 name,
241 remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
242 })
243 }
244}
245
246mod refspec {
249 use super::{GitResult, validate_refspec_ref};
250
251 #[derive(Debug, Clone, PartialEq, Eq)]
252 pub struct RefSpec {
253 forced: bool,
254 source: Option<String>,
256 destination: String,
257 }
258
259 impl RefSpec {
260 pub fn new(
262 source: Option<String>,
263 destination: impl Into<String>,
264 forced: bool,
265 ) -> GitResult<Self> {
266 let destination = destination.into();
267 if source.is_none() && destination.is_empty() {
268 return Err(super::GitBridgeError::InvalidMapping(
269 "refspec source and destination cannot both be empty".to_string(),
270 ));
271 }
272 if let Some(source) = source.as_deref() {
273 validate_refspec_ref(source)?;
274 }
275 validate_refspec_ref(&destination)?;
276 Ok(Self {
277 forced,
278 source,
279 destination,
280 })
281 }
282
283 pub fn forced(
285 source: impl Into<String>,
286 destination: impl Into<String>,
287 ) -> GitResult<Self> {
288 Self::new(Some(source.into()), destination, true)
289 }
290
291 pub fn delete(destination: impl Into<String>) -> GitResult<Self> {
294 Self::new(None, destination, false)
295 }
296
297 pub fn to_git_format(&self) -> String {
299 format!(
300 "{}{}",
301 if self.forced { "+" } else { "" },
302 self.to_git_format_not_forced()
303 )
304 }
305
306 pub fn to_git_format_not_forced(&self) -> String {
308 format!(
309 "{}:{}",
310 self.source.as_deref().unwrap_or(""),
311 self.destination
312 )
313 }
314 }
315}
316
317pub use refspec::RefSpec;
318
319mod negative_refspec {
322 use super::{GitBridgeError, GitResult, validate_refspec_ref};
323
324 #[derive(Debug, Clone, PartialEq, Eq)]
325 pub struct NegativeRefSpec {
326 source: String,
327 }
328
329 impl NegativeRefSpec {
330 pub fn new(source: impl Into<String>) -> GitResult<Self> {
333 let source = source.into();
334 validate_refspec_ref(&source)?;
335 if source.contains('*') {
336 return Err(GitBridgeError::InvalidMapping(format!(
337 "invalid negative refspec source '{source}': Negative glob patterns are not supported"
338 )));
339 }
340 Ok(Self { source })
341 }
342
343 pub fn to_git_format(&self) -> String {
345 format!("^{}", self.source)
346 }
347 }
348}
349
350pub use negative_refspec::NegativeRefSpec;
354
355fn heddle_mirror_fetch_refspecs() -> GitResult<[String; 2]> {
359 Ok([
360 RefSpec::forced("refs/heads/*", "refs/heads/*")?.to_git_format(),
361 RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format(),
362 ])
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub enum GitPushScope {
367 CurrentThread,
368 AllThreads,
369}
370
371#[derive(Debug, Clone, Default)]
372pub struct GitPullOutcome {
373 pub changed: bool,
374 pub states_created: usize,
375 pub commits_seen: usize,
376 pub materialized_checkout: bool,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380enum PullPreflight {
381 UpToDate,
382 ImportRequired,
383}
384
385fn pull_outcome(stats: &ImportStats, materialized_checkout: bool) -> GitPullOutcome {
386 GitPullOutcome {
387 changed: materialized_checkout || stats.states_created > 0,
388 states_created: stats.states_created,
389 commits_seen: stats.commits_imported,
390 materialized_checkout,
391 }
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395enum GitFetchScope {
396 BranchesAndNotes,
397 AllRefs,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401enum RefreshCheckoutAfterFetch {
402 Yes,
403 No,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407enum RemoteDirection {
408 Fetch,
409 Push,
410}
411
412#[derive(Debug, Clone)]
413enum ResolvedRemote {
414 Local(PathBuf),
415 Url(String),
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum WriteThroughSkipReason {
420 MissingDotGit,
421 DetachedHead,
422 NoAttachedThread,
423 NoMappedCommit,
424 MirrorIsWorktree,
425 IndexAlreadyDirty,
426}
427
428impl std::fmt::Display for WriteThroughSkipReason {
429 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430 match self {
431 WriteThroughSkipReason::MissingDotGit => {
432 write!(f, "this checkout does not have a Git working tree")
433 }
434 WriteThroughSkipReason::DetachedHead => {
435 write!(f, "Git HEAD is detached")
436 }
437 WriteThroughSkipReason::NoAttachedThread => {
438 write!(f, "the attached Heddle thread does not resolve to a state")
439 }
440 WriteThroughSkipReason::NoMappedCommit => {
441 write!(f, "the current Heddle state has not been exported to Git")
442 }
443 WriteThroughSkipReason::MirrorIsWorktree => {
444 write!(f, "the Git mirror is already the active checkout")
445 }
446 WriteThroughSkipReason::IndexAlreadyDirty => {
447 write!(f, "the Git index is already locked by another operation")
448 }
449 }
450 }
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454pub enum WriteThroughOutcome {
455 Wrote(ObjectId),
456 Skipped(WriteThroughSkipReason),
457}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub(crate) struct LocalGitIdentity {
461 pub(crate) name: String,
462 pub(crate) email: String,
463}
464
465impl LocalGitIdentity {
466 pub(crate) fn from_principal(principal: &Principal) -> Self {
467 Self {
468 name: principal.name.clone(),
469 email: principal.email.clone(),
470 }
471 }
472
473 pub(crate) fn to_ident_line(&self, seconds: i64) -> Vec<u8> {
474 format!("{} <{}> {} +0000", self.name, self.email, seconds).into_bytes()
475 }
476
477 pub(crate) fn to_signature(&self, seconds: i64) -> Signature {
478 let ident = self.to_ident_line(seconds);
479 Signature {
480 name: GitByteString::new(self.name.as_bytes().to_vec()),
481 email: GitByteString::new(self.email.as_bytes().to_vec()),
482 time: GitTime::new(seconds, 0),
483 raw: ident,
484 }
485 }
486}
487
488impl WriteThroughOutcome {
489 pub fn object_id(self) -> Option<ObjectId> {
490 match self {
491 WriteThroughOutcome::Wrote(oid) => Some(oid),
492 WriteThroughOutcome::Skipped(_) => None,
493 }
494 }
495
496 pub fn skip_reason(self) -> Option<WriteThroughSkipReason> {
497 match self {
498 WriteThroughOutcome::Skipped(reason) => Some(reason),
499 WriteThroughOutcome::Wrote(_) => None,
500 }
501 }
502}
503
504#[derive(Debug, Clone, Default, PartialEq, Eq)]
506pub struct SyncMapping {
507 heddle_to_git: HashMap<ChangeId, ObjectId>,
509 git_to_heddle: HashMap<ObjectId, ChangeId>,
511}
512
513impl SyncMapping {
514 pub fn new() -> Self {
516 Self::default()
517 }
518
519 pub fn insert(&mut self, change_id: ChangeId, git_oid: ObjectId) {
521 if let Some(previous_git) = self.heddle_to_git.remove(&change_id) {
522 self.git_to_heddle.remove(&previous_git);
523 }
524 if let Some(previous_change) = self.git_to_heddle.remove(&git_oid) {
525 self.heddle_to_git.remove(&previous_change);
526 }
527 self.heddle_to_git.insert(change_id, git_oid);
528 self.git_to_heddle.insert(git_oid, change_id);
529 }
530
531 pub(crate) fn insert_checked(
533 &mut self,
534 change_id: ChangeId,
535 git_oid: ObjectId,
536 ) -> GitResult<()> {
537 if let Some(existing) = self.heddle_to_git.get(&change_id)
538 && *existing != git_oid
539 {
540 return Err(GitBridgeError::Conflict(format!(
541 "change id {} mapped to {} (new {})",
542 change_id, existing, git_oid
543 )));
544 }
545
546 if let Some(existing) = self.git_to_heddle.get(&git_oid)
547 && *existing != change_id
548 {
549 return Err(GitBridgeError::Conflict(format!(
550 "git oid {} mapped to {} (new {})",
551 git_oid, existing, change_id
552 )));
553 }
554
555 self.insert(change_id, git_oid);
556 Ok(())
557 }
558
559 pub fn get_git(&self, change_id: &ChangeId) -> Option<ObjectId> {
561 self.heddle_to_git.get(change_id).copied()
562 }
563
564 pub fn get_heddle(&self, git_oid: ObjectId) -> Option<ChangeId> {
566 self.git_to_heddle.get(&git_oid).copied()
567 }
568
569 pub fn has_heddle(&self, change_id: &ChangeId) -> bool {
571 self.heddle_to_git.contains_key(change_id)
572 }
573
574 pub(crate) fn remove(&mut self, change_id: &ChangeId) -> Option<ObjectId> {
584 let git_oid = self.heddle_to_git.remove(change_id)?;
585 self.git_to_heddle.remove(&git_oid);
586 Some(git_oid)
587 }
588
589 pub fn has_git(&self, git_oid: ObjectId) -> bool {
591 self.git_to_heddle.contains_key(&git_oid)
592 }
593
594 pub(crate) fn iter(&self) -> impl Iterator<Item = (&ChangeId, &ObjectId)> {
596 self.heddle_to_git.iter()
597 }
598
599 pub(crate) fn retain_git_objects(&mut self, repo: &SleyRepository) {
600 let retained: Vec<(ChangeId, ObjectId)> = self
601 .heddle_to_git
602 .iter()
603 .filter_map(|(change_id, git_oid)| {
604 repo.read_object(git_oid)
605 .ok()
606 .map(|_| (*change_id, *git_oid))
607 })
608 .collect();
609
610 self.heddle_to_git.clear();
611 self.git_to_heddle.clear();
612 for (change_id, git_oid) in retained {
613 self.insert(change_id, git_oid);
614 }
615 }
616
617 #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
618 pub(crate) fn retain_git_object_set(&mut self, reachable: &HashSet<ObjectId>) -> usize {
619 let before = self.heddle_to_git.len();
620 let retained: Vec<(ChangeId, ObjectId)> = self
621 .heddle_to_git
622 .iter()
623 .filter(|(_, git_oid)| reachable.contains(*git_oid))
624 .map(|(change_id, git_oid)| (*change_id, *git_oid))
625 .collect();
626
627 self.heddle_to_git.clear();
628 self.git_to_heddle.clear();
629 for (change_id, git_oid) in retained {
630 self.insert(change_id, git_oid);
631 }
632 before.saturating_sub(self.heddle_to_git.len())
633 }
634}
635
636pub struct GitBridge<'a> {
638 pub(crate) heddle_repo: &'a HeddleRepository,
639 pub(crate) git_repo_path: Option<PathBuf>,
640 pub(crate) mapping: SyncMapping,
641 pub(crate) commit_message_overrides: HashMap<ChangeId, String>,
642}
643
644struct MappingFileSnapshot {
645 path: PathBuf,
646 contents: Option<Vec<u8>>,
647}
648
649impl MappingFileSnapshot {
650 fn read(path: PathBuf) -> GitResult<Self> {
651 let contents = match fs::read(&path) {
652 Ok(contents) => Some(contents),
653 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
654 Err(error) => return Err(error.into()),
655 };
656 Ok(Self { path, contents })
657 }
658
659 fn restore(self) -> GitResult<()> {
660 match self.contents {
661 Some(contents) => {
662 if let Some(parent) = self.path.parent() {
663 fs::create_dir_all(parent)?;
664 }
665 fs::write(&self.path, contents)?;
666 }
667 None => match fs::remove_file(&self.path) {
668 Ok(()) => {}
669 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
670 Err(error) => return Err(error.into()),
671 },
672 }
673 Ok(())
674 }
675}
676
677impl<'a> GitBridge<'a> {
678 pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
680 Self {
681 heddle_repo,
682 git_repo_path: None,
683 mapping: SyncMapping::new(),
684 commit_message_overrides: HashMap::new(),
685 }
686 }
687
688 pub fn init_mirror(&mut self) -> GitResult<()> {
690 let _guard = self.init_mirror_with_guard()?;
691 _guard.commit();
692 Ok(())
693 }
694
695 pub(crate) fn init_mirror_with_guard(&mut self) -> GitResult<MirrorInitGuard> {
700 let git_dir = self.heddle_repo.heddle_dir().join("git");
701
702 let did_create = if git_dir.exists() {
703 let _ = open_repo(&git_dir)?;
704 false
705 } else {
706 fs::create_dir_all(&git_dir)?;
707 let _ = SleyRepository::init_bare(&git_dir).map_err(git_err)?;
708 let mirror_repo = open_repo(&git_dir)?;
709 seed_checkout_note_refs_into_mirror(self.heddle_repo.root(), &mirror_repo)?;
710 true
711 };
712
713 self.git_repo_path = Some(git_dir.clone());
714 Ok(MirrorInitGuard::new_from_init(git_dir, did_create))
715 }
716
717 pub fn mirror_path(&self) -> PathBuf {
719 self.heddle_repo.heddle_dir().join("git")
720 }
721
722 pub fn is_initialized(&self) -> bool {
724 self.mirror_path().exists()
725 }
726
727 pub(crate) fn open_git_repo(&self) -> GitResult<SleyRepository> {
729 if let Some(ref path) = self.git_repo_path {
730 open_repo(path)
731 } else {
732 let mirror_path = self.mirror_path();
733 if mirror_path.exists() {
734 open_repo(&mirror_path)
735 } else {
736 open_repo(self.heddle_repo.root())
737 }
738 }
739 }
740
741 pub(crate) fn sort_states_topologically(
743 &self,
744 states: &[ChangeId],
745 ) -> GitResult<Vec<ChangeId>> {
746 let mut sorted = Vec::new();
747 let mut visited: std::collections::HashSet<ChangeId> = std::collections::HashSet::new();
748
749 fn visit<S: ObjectStore + ?Sized>(
750 state_id: &ChangeId,
751 store: &S,
752 visited: &mut std::collections::HashSet<ChangeId>,
753 sorted: &mut Vec<ChangeId>,
754 ) -> GitResult<()> {
755 if visited.contains(state_id) {
756 return Ok(());
757 }
758
759 if let Some(state) = store.get_state(state_id)? {
760 for parent in &state.parents {
761 visit(parent, store, visited, sorted)?;
762 }
763 }
764
765 visited.insert(*state_id);
766 sorted.push(*state_id);
767
768 Ok(())
769 }
770
771 for state_id in states {
772 visit(
773 state_id,
774 self.heddle_repo.store(),
775 &mut visited,
776 &mut sorted,
777 )?;
778 }
779
780 Ok(sorted)
781 }
782
783 pub fn export(&mut self) -> GitResult<super::git_util::ExportStats> {
785 export_all(self)
786 }
787
788 pub(crate) fn set_commit_message_override(&mut self, state_id: ChangeId, message: String) {
789 self.commit_message_overrides.insert(state_id, message);
790 }
791
792 pub(crate) fn with_mapping_rollback<T>(
793 &mut self,
794 operation: impl FnOnce(&mut Self) -> GitResult<T>,
795 ) -> GitResult<T> {
796 let mapping = self.mapping.clone();
797 let commit_message_overrides = self.commit_message_overrides.clone();
798 let mapping_file = MappingFileSnapshot::read(self.mapping_path())?;
799 let mapping_tmp_file = MappingFileSnapshot::read(self.mapping_tmp_path())?;
800
801 match operation(self) {
802 Ok(value) => Ok(value),
803 Err(error) => {
804 self.mapping = mapping;
805 self.commit_message_overrides = commit_message_overrides;
806 if let Err(rollback_error) = mapping_file
807 .restore()
808 .and_then(|()| mapping_tmp_file.restore())
809 {
810 return Err(GitBridgeError::Git(format!(
811 "operation failed ({error}); additionally failed to roll back git bridge mapping state ({rollback_error})"
812 )));
813 }
814 Err(error)
815 }
816 }
817 }
818
819 pub fn push(&mut self, remote_name: &str) -> GitResult<Vec<String>> {
822 self.push_with_scope(remote_name, GitPushScope::AllThreads)
823 }
824
825 pub fn push_with_scope(
828 &mut self,
829 remote_name: &str,
830 scope: GitPushScope,
831 ) -> GitResult<Vec<String>> {
832 self.push_with_scope_force(remote_name, scope, false)
833 }
834
835 pub fn push_with_scope_force(
844 &mut self,
845 remote_name: &str,
846 scope: GitPushScope,
847 force: bool,
848 ) -> GitResult<Vec<String>> {
849 self.init_mirror()?;
850 let current_branch = match scope {
851 GitPushScope::CurrentThread => Some(self.current_attached_thread_for_push()?),
852 GitPushScope::AllThreads => None,
853 };
854 match scope {
855 GitPushScope::CurrentThread => {
856 export_current_thread(self, current_branch.as_deref().expect("current branch"))?;
857 }
858 GitPushScope::AllThreads => {
859 self.export()?;
860 self.mirror_checkout_tags_for_push()?;
861 }
862 }
863 self.write_current_checkout_from_existing_mirror()?;
864
865 let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
873 match self.resolve_remote(remote_name, RemoteDirection::Push)? {
874 ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
875 &target_path,
876 &log_message,
877 false,
878 scope,
879 current_branch.as_deref(),
880 force,
881 ),
882 ResolvedRemote::Url(url) => {
883 let mirror_repo = self.open_git_repo()?;
884 push_network_remote(
885 &mirror_repo,
886 self.heddle_repo.heddle_dir(),
887 &url,
888 scope,
889 current_branch.as_deref(),
890 force,
891 )
892 }
893 }
894 }
895
896 fn current_attached_thread_for_push(&self) -> GitResult<String> {
897 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
898 return Err(GitBridgeError::Git(
899 "cannot push the current Git-overlay branch from a detached Heddle HEAD; use --all-threads to push all exported refs".to_string(),
900 ));
901 };
902 if self.heddle_repo.refs().get_thread(&thread)?.is_none() {
903 return Err(GitBridgeError::Git(format!(
904 "attached thread '{thread}' has no state to push"
905 )));
906 }
907 Ok(thread.to_string())
908 }
909
910 pub fn export_to_path(
914 &mut self,
915 target_path: &Path,
916 ) -> GitResult<super::git_util::ExportStats> {
917 self.init_mirror()?;
918 let stats = self.export()?;
919 self.copy_mirror_to_path(
920 target_path,
921 &format!("heddle: export from {}", self.heddle_repo.root().display()),
922 true,
923 GitPushScope::AllThreads,
924 None,
925 false,
926 )?;
927 Ok(stats)
928 }
929
930 fn copy_mirror_to_path(
939 &mut self,
940 target_path: &Path,
941 log_message: &str,
942 init_if_missing: bool,
943 scope: GitPushScope,
944 current_branch: Option<&str>,
945 force: bool,
946 ) -> GitResult<Vec<String>> {
947 let mirror_repo = self.open_git_repo()?;
948 let target_repo = if target_path.exists() {
949 open_repo(target_path)?
950 } else if init_if_missing {
951 fs::create_dir_all(target_path)?;
952 SleyRepository::init_bare(target_path).map_err(git_err)?;
953 open_repo(target_path)?
954 } else {
955 return Err(GitBridgeError::Git(format!(
956 "destination '{}' does not exist",
957 target_path.display()
958 )));
959 };
960
961 let managed_record = read_mirror_managed_refs(&mirror_repo)?;
975 let served_frontier = collect_managed_ref_updates(&mirror_repo, &managed_record)?;
976 copy_reachable_objects(
977 &mirror_repo,
978 &target_repo,
979 served_frontier.iter().map(|update| update.target),
980 )?;
981
982 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
990 let old_at_destination = read_destination_ref_map(&target_repo)?;
991 let previously_exported = read_exported_refs(&target_repo)?;
992 let plan = plan_destination_reconcile(
993 &mirror_repo,
994 &served_frontier,
995 creatable.as_ref(),
996 &old_at_destination,
997 &previously_exported,
998 force,
999 )?;
1000 for write in &plan.writes {
1001 let constraint = match write.old {
1002 Some(old) => RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(old)),
1003 None => RefPrecondition::MustNotExist,
1004 };
1005 set_reference(
1006 &target_repo,
1007 &write.full_name,
1008 write.new,
1009 constraint,
1010 log_message,
1011 )?;
1012 }
1013 for delete in &plan.deletes {
1014 delete_reference_matching(&target_repo, &delete.full_name, delete.old)?;
1015 }
1016 write_exported_refs(&target_repo, &plan.new_manifest)?;
1017 Ok(planned_write_names(&plan))
1018 }
1019
1020 pub fn fetch(&mut self, remote_name: &str) -> GitResult<()> {
1023 self.fetch_with_scope(
1024 remote_name,
1025 GitFetchScope::BranchesAndNotes,
1026 RefreshCheckoutAfterFetch::Yes,
1027 )
1028 }
1029
1030 fn fetch_with_scope(
1031 &mut self,
1032 remote_name: &str,
1033 scope: GitFetchScope,
1034 refresh_checkout: RefreshCheckoutAfterFetch,
1035 ) -> GitResult<()> {
1036 reject_reserved_git_remote_name(remote_name)?;
1037 self.init_mirror()?;
1038 let current_branch = self.heddle_repo.git_overlay_current_branch()?;
1039 let tracking_remote = checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1040 .or_else(|| {
1041 (!looks_like_remote_location(remote_name)).then(|| remote_name.to_string())
1042 });
1043 if let Some(tracking_remote) = tracking_remote.as_deref() {
1047 reject_reserved_git_remote_name(tracking_remote)?;
1048 }
1049
1050 let mirror_repo = self.open_git_repo()?;
1051 match self.resolve_remote(remote_name, RemoteDirection::Fetch)? {
1052 ResolvedRemote::Local(path) => {
1053 let remote_repo = open_repo(&path)?;
1054 let updates = collect_ref_updates_for_fetch(&remote_repo, scope)?;
1055 tracing::debug!(
1056 remote = remote_name,
1057 path = %path.display(),
1058 refs = updates.len(),
1059 notes = updates
1060 .iter()
1061 .filter(|update| update.namespace == RefNamespace::Note)
1062 .count(),
1063 "fetching Git refs from local remote"
1064 );
1065 copy_reachable_objects(
1066 &remote_repo,
1067 &mirror_repo,
1068 updates.iter().map(|update| update.target),
1069 )?;
1070 apply_ref_updates(
1071 &mirror_repo,
1072 &updates,
1073 &format!("heddle: fetch from {remote_name}"),
1074 )?;
1075 if let Some(tracking_remote) = tracking_remote.as_deref() {
1076 apply_remote_tracking_ref_updates(
1077 &mirror_repo,
1078 tracking_remote,
1079 &updates,
1080 &format!("heddle: fetch from {remote_name}"),
1081 )?;
1082 }
1083 }
1084 ResolvedRemote::Url(url) => {
1085 fetch_network_remote(&mirror_repo, remote_name, &url, scope)?;
1086 let updates = collect_ref_updates_for_fetch(&mirror_repo, scope)?;
1087 if let Some(tracking_remote) = tracking_remote.as_deref() {
1088 apply_remote_tracking_ref_updates(
1089 &mirror_repo,
1090 tracking_remote,
1091 &updates,
1092 &format!("heddle: fetch from {remote_name}"),
1093 )?;
1094 }
1095 }
1096 }
1097
1098 self.git_repo_path = Some(self.mirror_path());
1099 if matches!(refresh_checkout, RefreshCheckoutAfterFetch::Yes) {
1100 if let Some(tracking_remote) = tracking_remote.as_deref() {
1101 self.refresh_checkout_remote_tracking_refs(tracking_remote)?;
1102 }
1103 if let Some(branch) = current_branch {
1104 self.refresh_checkout_remote_tracking_ref(remote_name, &branch)?;
1105 }
1106 self.refresh_checkout_note_refs_from_mirror()?;
1107 }
1108 Ok(())
1109 }
1110
1111 pub(crate) fn hydrate_checkout_heddle_notes_without_mirror(root: &Path) -> bool {
1119 if checkout_note_ref_exists(root).unwrap_or(false) {
1120 return true;
1121 }
1122
1123 let mut remotes = match checkout_remote_url_items(root) {
1124 Ok(remotes) => remotes
1125 .into_iter()
1126 .map(|(name, _)| name)
1127 .collect::<Vec<_>>(),
1128 Err(error) => {
1129 tracing::debug!(
1130 error = %error,
1131 "skipping configured remote note hydration before ingest-backed adopt"
1132 );
1133 return false;
1134 }
1135 };
1136 remotes.sort_by(|left, right| {
1137 match (left.as_str() == "origin", right.as_str() == "origin") {
1138 (true, false) => std::cmp::Ordering::Less,
1139 (false, true) => std::cmp::Ordering::Greater,
1140 _ => left.cmp(right),
1141 }
1142 });
1143 remotes.dedup();
1144
1145 for remote in remotes {
1146 match hydrate_checkout_notes_from_remote_without_mirror(root, &remote) {
1147 Ok(()) if checkout_note_ref_exists(root).unwrap_or(false) => return true,
1148 Ok(()) => {}
1149 Err(error) => {
1150 tracing::debug!(
1151 remote = remote.as_str(),
1152 error = %error,
1153 "configured remote did not provide Heddle notes during ingest-backed adopt"
1154 );
1155 }
1156 }
1157 }
1158
1159 false
1160 }
1161
1162 pub fn pull(&mut self, remote_name: &str) -> GitResult<GitPullOutcome> {
1164 let head_before = self.heddle_repo.refs().read_head()?;
1165 let attached_before = match &head_before {
1166 Head::Attached { thread } => self
1167 .heddle_repo
1168 .refs()
1169 .get_thread(thread)?
1170 .map(|state| (thread.to_string(), state)),
1171 Head::Detached { .. } => None,
1172 };
1173 let attached_thread = attached_before.as_ref().map(|(thread, _)| thread.clone());
1174
1175 self.fetch_with_scope(
1176 remote_name,
1177 GitFetchScope::AllRefs,
1178 RefreshCheckoutAfterFetch::No,
1179 )?;
1180 if self.preflight_attached_pull_fast_forward(remote_name, attached_before.as_ref())?
1181 == PullPreflight::UpToDate
1182 {
1183 if let Some(thread) = attached_thread {
1184 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1185 }
1186 self.refresh_checkout_note_refs_from_mirror()?;
1187 return Ok(GitPullOutcome::default());
1188 }
1189 let mirror_path = self.mirror_path();
1190 let stats = import_git_history(self, Some(&mirror_path), &[], Default::default(), None)?;
1191
1192 let mut materialized_attached_thread = false;
1193 if let Some((thread, old_state)) = attached_before
1194 && let Some(new_state) = self
1195 .heddle_repo
1196 .refs()
1197 .get_thread(&ThreadName::new(&thread))?
1198 && new_state != old_state
1199 {
1200 self.heddle_repo
1201 .refs()
1202 .set_thread(&ThreadName::new(&thread), &old_state)?;
1203 self.heddle_repo.refs().write_head(&Head::Attached {
1204 thread: ThreadName::new(&thread),
1205 })?;
1206 self.heddle_repo
1207 .goto_verified_clean_without_record(&new_state)?;
1208 self.heddle_repo
1209 .refs()
1210 .set_thread(&ThreadName::new(&thread), &new_state)?;
1211 self.heddle_repo.refs().write_head(&Head::Attached {
1212 thread: ThreadName::new(&thread),
1213 })?;
1214 materialized_attached_thread = true;
1215 }
1216
1217 if materialized_attached_thread {
1218 self.write_current_checkout_from_existing_mirror()?;
1219 }
1220 if let Some(thread) = attached_thread {
1221 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1222 }
1223 self.refresh_checkout_note_refs_from_mirror()?;
1224 Ok(pull_outcome(&stats, materialized_attached_thread))
1225 }
1226
1227 fn preflight_attached_pull_fast_forward(
1228 &mut self,
1229 remote_name: &str,
1230 attached_before: Option<&(String, ChangeId)>,
1231 ) -> GitResult<PullPreflight> {
1232 let Some((thread, state_id)) = attached_before else {
1233 return Ok(PullPreflight::ImportRequired);
1234 };
1235 self.build_existing_mapping(None)?;
1236 let Some(local_git_oid) = self.mapping.get_git(state_id) else {
1237 return Ok(PullPreflight::ImportRequired);
1238 };
1239 let mirror_repo = self.open_git_repo()?;
1240 let branch_ref = format!("refs/heads/{thread}");
1241 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1242 return Ok(PullPreflight::ImportRequired);
1243 };
1244 let Some(remote_git_oid) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1245 return Ok(PullPreflight::ImportRequired);
1246 };
1247 if remote_git_oid == local_git_oid {
1248 return Ok(PullPreflight::UpToDate);
1249 }
1250 if commit_is_descendant_of(&mirror_repo, remote_git_oid, local_git_oid)? {
1251 return Ok(PullPreflight::ImportRequired);
1252 }
1253 Err(GitBridgeError::RemoteDiverged {
1254 branch: thread.clone(),
1255 upstream: format!("{remote_name}/{thread}"),
1256 local: local_git_oid,
1257 remote: remote_git_oid,
1258 })
1259 }
1260
1261 fn mirror_checkout_tags_for_push(&self) -> GitResult<()> {
1262 if !self.heddle_repo.root().join(".git").exists() {
1263 return Ok(());
1264 }
1265
1266 let mirror_repo = self.open_git_repo()?;
1267 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1268 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1269 return Ok(());
1270 }
1271 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1272 let tag_updates = collect_ref_updates(&object_repo)?
1273 .into_iter()
1274 .filter(|update| update.namespace == RefNamespace::Tag)
1275 .collect::<Vec<_>>();
1276 if tag_updates.is_empty() {
1277 return Ok(());
1278 }
1279
1280 copy_reachable_objects(
1281 &object_repo,
1282 &mirror_repo,
1283 tag_updates.iter().map(|u| u.target),
1284 )?;
1285 apply_ref_updates(
1286 &mirror_repo,
1287 &tag_updates,
1288 "heddle: mirror checkout tags before push",
1289 )?;
1290 let mut record = read_mirror_managed_refs(&mirror_repo)?;
1298 for update in &tag_updates {
1299 record.insert(full_ref_name(update), update.target);
1300 }
1301 write_mirror_managed_refs(&mirror_repo, &record)?;
1302 Ok(())
1303 }
1304
1305 pub(crate) fn seed_git_checkpoint_mappings_from_checkout(
1306 &mut self,
1307 mirror_repo: &SleyRepository,
1308 ) -> GitResult<()> {
1309 if !self.heddle_repo.root().join(".git").exists() {
1310 return Ok(());
1311 }
1312
1313 let checkout_repo = match SleyRepository::discover(self.heddle_repo.root()) {
1314 Ok(repo) => repo,
1315 Err(_) => return Ok(()),
1316 };
1317 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1318 return Ok(());
1319 }
1320 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1321
1322 for record in self.heddle_repo.list_git_checkpoints()? {
1323 let change_id = ChangeId::parse(&record.change_id)?;
1324 let git_oid = record
1325 .git_commit
1326 .parse::<ObjectId>()
1327 .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))?;
1328
1329 if mirror_repo.read_object(&git_oid).is_err() {
1330 copy_reachable_objects(&object_repo, mirror_repo, [git_oid])?;
1331 }
1332 mirror_repo
1333 .read_object(&git_oid)
1334 .map_err(|_| GitBridgeError::CommitNotFound(record.git_commit.clone()))?;
1335
1336 self.mapping.insert(change_id, git_oid);
1337 let tier = self
1346 .heddle_repo
1347 .effective_visibility_tier(&change_id)
1348 .map_err(|e| {
1349 GitBridgeError::Git(format!("resolve visibility for {change_id}: {e:#}"))
1350 })?;
1351 if repo::visible(&tier, &repo::AudienceTier::Public)
1352 && super::git_notes::read_note(mirror_repo, git_oid)?.is_none()
1353 && let Some(state) = self.heddle_repo.store().get_state(&change_id)?
1354 {
1355 let note = super::git_notes::HeddleNote::from_state(&state);
1356 super::git_notes::write_note(mirror_repo, git_oid, ¬e)?;
1357 }
1358 }
1359
1360 Ok(())
1361 }
1362
1363 pub(crate) fn stage_ingest_source_in_mirror(
1364 &mut self,
1365 source: &Path,
1366 refs: &[String],
1367 ) -> GitResult<()> {
1368 let source_repo = open_repo(source)?;
1369 let updates = collect_import_source_ref_updates(&source_repo, refs)?;
1370 if updates.is_empty() {
1371 return Ok(());
1372 }
1373
1374 self.init_mirror()?;
1375 let mirror_repo = self.open_git_repo()?;
1376 copy_reachable_objects(
1377 &source_repo,
1378 &mirror_repo,
1379 updates.iter().map(|update| update.target),
1380 )?;
1381 apply_ref_updates(
1382 &mirror_repo,
1383 &updates,
1384 &format!("heddle: stage ingest source from {}", source.display()),
1385 )?;
1386
1387 let mut record = read_or_seed_mirror_managed_refs(&mirror_repo)?;
1388 for update in &updates {
1389 record.insert(full_ref_name(update), update.target);
1390 }
1391 write_mirror_managed_refs(&mirror_repo, &record)?;
1392 Ok(())
1393 }
1394
1395 pub fn write_through_current_checkout(&mut self) -> GitResult<WriteThroughOutcome> {
1400 if !self.heddle_repo.root().join(".git").exists() {
1401 return Ok(WriteThroughOutcome::Skipped(
1402 WriteThroughSkipReason::MissingDotGit,
1403 ));
1404 }
1405 if checkout_git_head_is_detached(self.heddle_repo.root())? {
1406 return Ok(WriteThroughOutcome::Skipped(
1407 WriteThroughSkipReason::DetachedHead,
1408 ));
1409 }
1410 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1411 return Ok(WriteThroughOutcome::Skipped(
1412 WriteThroughSkipReason::DetachedHead,
1413 ));
1414 };
1415
1416 let mirror_guard = self.init_mirror_with_guard()?;
1417 export_current_thread(self, &thread)?;
1428 mirror_guard.commit();
1432 self.write_thread_checkout_from_existing_mirror(&thread)
1433 }
1434
1435 pub fn write_through_current_checkout_with_message(
1436 &mut self,
1437 state_id: ChangeId,
1438 message: String,
1439 ) -> GitResult<WriteThroughOutcome> {
1440 self.set_commit_message_override(state_id, message);
1441 self.write_through_current_checkout()
1442 }
1443
1444 pub fn update_intent_to_add(&self, state_id: &ChangeId) -> GitResult<()> {
1467 let root = self.heddle_repo.root();
1468 if !root.join(".git").exists() {
1469 return Ok(());
1470 }
1471 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1472 if checkout_repo
1475 .head()
1476 .map(|head| head.is_detached())
1477 .unwrap_or(false)
1478 {
1479 return Ok(());
1480 }
1481
1482 let Some(state) = self.heddle_repo.store().get_state(state_id)? else {
1484 return Ok(());
1485 };
1486 let Some(tree) = self.heddle_repo.store().get_tree(&state.tree)? else {
1487 return Ok(());
1488 };
1489 let mut captured: Vec<(String, FileMode)> = Vec::new();
1490 collect_capture_paths(self.heddle_repo.store(), &tree, "", &mut captured)?;
1491 let mut index = checkout_repo
1504 .open_index()
1505 .map_err(git_err)?
1506 .unwrap_or_else(|| Index {
1507 version: 2,
1508 entries: Vec::new(),
1509 extensions: Vec::new(),
1510 checksum: None,
1511 });
1512
1513 let mut real_tracked: HashSet<String> = HashSet::new();
1516 let mut existing_ita: HashSet<String> = HashSet::new();
1517 for entry in &index.entries {
1518 let path = String::from_utf8_lossy(entry.path.as_bytes()).into_owned();
1519 if entry.is_intent_to_add() {
1520 existing_ita.insert(path);
1521 } else {
1522 real_tracked.insert(path);
1523 }
1524 }
1525
1526 let captured_paths: HashSet<&str> = captured.iter().map(|(p, _)| p.as_str()).collect();
1529
1530 let before_prune = index.entries.len();
1532 index.entries.retain(|entry| {
1533 !entry.is_intent_to_add()
1534 || captured_paths.contains(String::from_utf8_lossy(entry.path.as_bytes()).as_ref())
1535 });
1536 let mut changed = index.entries.len() != before_prune;
1537
1538 for (path, mode) in &captured {
1540 if real_tracked.contains(path) || existing_ita.contains(path) {
1541 continue;
1542 }
1543 if real_tracked
1551 .iter()
1552 .any(|tracked| path_prefix_conflict(path, tracked))
1553 {
1554 continue;
1555 }
1556 let mut entry = IndexEntry::intent_to_add(
1557 checkout_repo.object_format(),
1558 GitBString::from(path.as_str()),
1559 );
1560 entry.mode = match mode {
1561 FileMode::Executable => 0o100755,
1562 FileMode::Symlink => 0o120000,
1563 FileMode::Normal => 0o100644,
1564 };
1565 changed = true;
1566 index.entries.push(entry);
1567 }
1568
1569 if changed {
1570 index
1571 .entries
1572 .sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
1573 index.upgrade_version_for_flags();
1574 checkout_repo
1575 .write_index(
1576 &index,
1577 IndexWriteOptions {
1578 fsync: true,
1579 validate_checksum: true,
1580 },
1581 )
1582 .map_err(git_err)?;
1583 }
1584 Ok(())
1585 }
1586
1587 pub fn write_through_thread_checkout(
1592 &mut self,
1593 thread: &str,
1594 ) -> GitResult<WriteThroughOutcome> {
1595 if !self.heddle_repo.root().join(".git").exists() {
1596 return Ok(WriteThroughOutcome::Skipped(
1597 WriteThroughSkipReason::MissingDotGit,
1598 ));
1599 }
1600
1601 let mirror_guard = self.init_mirror_with_guard()?;
1602 export_current_thread(self, thread)?;
1603 mirror_guard.commit();
1604 self.write_thread_checkout_from_existing_mirror(thread)
1605 }
1606
1607 pub(crate) fn write_current_checkout_from_existing_mirror(
1608 &mut self,
1609 ) -> GitResult<WriteThroughOutcome> {
1610 if !self.heddle_repo.root().join(".git").exists() {
1611 return Ok(WriteThroughOutcome::Skipped(
1612 WriteThroughSkipReason::MissingDotGit,
1613 ));
1614 }
1615
1616 let (thread, state_id) = match self.heddle_repo.head_ref()? {
1617 Head::Attached { thread } => {
1618 let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1619 return Ok(WriteThroughOutcome::Skipped(
1620 WriteThroughSkipReason::NoAttachedThread,
1621 ));
1622 };
1623 (thread, state_id)
1624 }
1625 Head::Detached { .. } => {
1626 return Ok(WriteThroughOutcome::Skipped(
1627 WriteThroughSkipReason::DetachedHead,
1628 ));
1629 }
1630 };
1631 self.write_thread_state_checkout_from_existing_mirror(&thread, &state_id)
1632 }
1633
1634 fn write_thread_checkout_from_existing_mirror(
1635 &mut self,
1636 thread: &str,
1637 ) -> GitResult<WriteThroughOutcome> {
1638 let Some(state_id) = self
1639 .heddle_repo
1640 .refs()
1641 .get_thread(&ThreadName::new(thread))?
1642 else {
1643 return Ok(WriteThroughOutcome::Skipped(
1644 WriteThroughSkipReason::NoAttachedThread,
1645 ));
1646 };
1647 self.write_thread_state_checkout_from_existing_mirror(thread, &state_id)
1648 }
1649
1650 fn write_thread_state_checkout_from_existing_mirror(
1651 &mut self,
1652 thread: &str,
1653 state_id: &ChangeId,
1654 ) -> GitResult<WriteThroughOutcome> {
1655 let mirror_repo = self.open_git_repo()?;
1656 let git_oid = if let Some(git_oid) = self.mapping.get_git(state_id) {
1657 git_oid
1658 } else if let Some(git_commit) = self
1659 .heddle_repo
1660 .git_overlay_mapped_git_commit_for_change(state_id)
1661 .map_err(|error| GitBridgeError::Git(error.to_string()))?
1662 {
1663 ObjectId::from_hex(mirror_repo.object_format(), &git_commit)
1664 .map_err(|error| GitBridgeError::InvalidMapping(error.to_string()))?
1665 } else {
1666 return Ok(WriteThroughOutcome::Skipped(
1667 WriteThroughSkipReason::NoMappedCommit,
1668 ));
1669 };
1670
1671 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1672 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1673 return Ok(WriteThroughOutcome::Skipped(
1674 WriteThroughSkipReason::MirrorIsWorktree,
1675 ));
1676 }
1677 let git_dir = checkout_repo.git_dir().to_path_buf();
1678 if git_dir.join("index.lock").exists() {
1681 return Ok(WriteThroughOutcome::Skipped(
1682 WriteThroughSkipReason::IndexAlreadyDirty,
1683 ));
1684 }
1685
1686 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1687 let branch_ref = format!("refs/heads/{thread}");
1688 let head_path = git_dir.join("HEAD");
1689 let index_path = git_dir.join("index");
1690 let previous_head = fs::read(&head_path).ok();
1691 let previous_index = fs::read(&index_path).ok();
1692 let previous_branch = object_repo
1693 .find_reference(&branch_ref)
1694 .ok()
1695 .flatten()
1696 .and_then(|reference| reference.peeled_oid(&object_repo).ok().flatten());
1697
1698 let write_result = (|| -> GitResult<()> {
1699 copy_reachable_objects(&mirror_repo, &object_repo, [git_oid])?;
1700 fs::write(&head_path, format!("ref: {branch_ref}\n"))?;
1701
1702 let commit = checkout_repo.read_commit(&git_oid).map_err(git_err)?;
1703 let mut index = checkout_repo
1704 .index_from_tree(&commit.tree)
1705 .map_err(git_err)?;
1706 index.upgrade_version_for_flags();
1707 checkout_repo
1708 .write_index(
1709 &index,
1710 IndexWriteOptions {
1711 fsync: true,
1712 validate_checksum: true,
1713 },
1714 )
1715 .map_err(git_err)?;
1716
1717 update_checkout_head_ref(
1718 &checkout_repo,
1719 git_oid,
1720 previous_branch,
1721 "heddle: write-through current thread",
1722 )?;
1723
1724 fsync_path(&head_path)?;
1730 fsync_path(&index_path)?;
1731 fsync_path(&git_dir)?;
1732 Ok(())
1733 })();
1734
1735 if let Err(err) = write_result {
1736 restore_file(head_path.clone(), previous_head.as_deref())?;
1737 restore_file(index_path.clone(), previous_index.as_deref())?;
1738 if let Some(previous_branch) = previous_branch {
1739 set_reference(
1740 &object_repo,
1741 &branch_ref,
1742 previous_branch,
1743 RefPrecondition::Any,
1744 "heddle: rollback failed write-through",
1745 )?;
1746 } else {
1747 let _ = delete_reference_if_present(&object_repo, &branch_ref);
1757 }
1758 let _ = fsync_path(&head_path);
1761 let _ = fsync_path(&index_path);
1762 let _ = fsync_path(&git_dir);
1763 return Err(err);
1764 }
1765
1766 Ok(WriteThroughOutcome::Wrote(git_oid))
1767 }
1768
1769 fn refresh_checkout_remote_tracking_ref(
1770 &self,
1771 remote_name: &str,
1772 branch: &str,
1773 ) -> GitResult<()> {
1774 if !self.heddle_repo.root().join(".git").exists() {
1775 return Ok(());
1776 }
1777 let Some(tracking_remote) =
1778 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1779 else {
1780 return Ok(());
1781 };
1782 reject_reserved_git_remote_name(&tracking_remote)?;
1783
1784 let mirror_repo = self.open_git_repo()?;
1785 let branch_ref = format!("refs/heads/{branch}");
1786 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1787 return Ok(());
1788 };
1789 let Some(target) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1790 return Ok(());
1791 };
1792
1793 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1794 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1795 return Ok(());
1796 }
1797 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1798 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
1799 set_reference(
1800 &object_repo,
1801 &format!("refs/remotes/{tracking_remote}/{branch}"),
1802 target,
1803 RefPrecondition::Any,
1804 "heddle: refresh remote-tracking branch after pull",
1805 )?;
1806 Ok(())
1807 }
1808
1809 fn refresh_checkout_remote_tracking_refs(&self, remote_name: &str) -> GitResult<()> {
1810 if !self.heddle_repo.root().join(".git").exists() {
1811 return Ok(());
1812 }
1813 let Some(tracking_remote) =
1814 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1815 else {
1816 return Ok(());
1817 };
1818 reject_reserved_git_remote_name(&tracking_remote)?;
1819
1820 let mirror_repo = self.open_git_repo()?;
1821 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1822 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1823 return Ok(());
1824 }
1825 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1826 let prefix = format!("refs/remotes/{remote_name}/");
1827 for reference in mirror_repo.references().list_refs().map_err(git_err)? {
1828 if !reference.name.starts_with(&prefix) {
1829 continue;
1830 }
1831 let ReferenceTarget::Direct(target) = reference.target else {
1832 continue;
1833 };
1834 let full = reference.name;
1835 let Some(branch) = full.strip_prefix(&prefix) else {
1836 continue;
1837 };
1838 if branch.ends_with("/HEAD") {
1839 continue;
1840 }
1841 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
1842 set_reference(
1843 &object_repo,
1844 &format!("refs/remotes/{tracking_remote}/{branch}"),
1845 target,
1846 RefPrecondition::Any,
1847 "heddle: refresh remote-tracking branch after fetch",
1848 )?;
1849 }
1850 Ok(())
1851 }
1852
1853 fn refresh_checkout_note_refs_from_mirror(&self) -> GitResult<()> {
1854 if !self.heddle_repo.root().join(".git").exists() {
1855 return Ok(());
1856 }
1857
1858 let mirror_repo = self.open_git_repo()?;
1859 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1860 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1861 return Ok(());
1862 }
1863 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1864 let note_updates = collect_ref_updates(&mirror_repo)?
1865 .into_iter()
1866 .filter(|update| update.namespace == RefNamespace::Note)
1867 .collect::<Vec<_>>();
1868 if note_updates.is_empty() {
1869 return Ok(());
1870 }
1871
1872 copy_reachable_objects(
1873 &mirror_repo,
1874 &object_repo,
1875 note_updates.iter().map(|u| u.target),
1876 )?;
1877 apply_ref_updates(
1878 &object_repo,
1879 ¬e_updates,
1880 "heddle: refresh Heddle note refs",
1881 )?;
1882 Ok(())
1883 }
1884
1885 fn resolve_remote(
1886 &self,
1887 remote_name: &str,
1888 direction: RemoteDirection,
1889 ) -> GitResult<ResolvedRemote> {
1890 let repo = self.open_git_repo()?;
1891 let url = match remote_url_from_repo(&repo, remote_name, direction)? {
1892 Some(url) => Some(url),
1893 None => self.checkout_remote_url(remote_name, direction)?,
1894 };
1895
1896 let base = repo_relative_base(&repo);
1897 let url = match url {
1898 Some(url) => url,
1899 None => parse_configured_remote_url(remote_name, &base)?,
1900 };
1901
1902 if let Some(path) = local_path_from_url(&url)? {
1903 Ok(ResolvedRemote::Local(path))
1904 } else {
1905 Ok(ResolvedRemote::Url(url))
1906 }
1907 }
1908
1909 fn checkout_remote_url(
1910 &self,
1911 remote_name: &str,
1912 direction: RemoteDirection,
1913 ) -> GitResult<Option<String>> {
1914 if direction == RemoteDirection::Fetch
1915 && let Some(url) =
1916 remote_fetch_url_from_checkout_config(self.heddle_repo.root(), remote_name)?
1917 {
1918 return Ok(Some(url));
1919 }
1920 let Ok(repo) = SleyRepository::discover(self.heddle_repo.root()) else {
1921 return Ok(None);
1922 };
1923 remote_url_from_repo(&repo, remote_name, direction)
1924 }
1925}
1926
1927fn remote_url_from_repo(
1928 repo: &SleyRepository,
1929 remote_name: &str,
1930 direction: RemoteDirection,
1931) -> GitResult<Option<String>> {
1932 let config = repo.config_snapshot().map_err(git_err)?;
1933 let push = direction == RemoteDirection::Push;
1934 let value = if push {
1935 config
1936 .get("remote", Some(remote_name), "pushurl")
1937 .or_else(|| config.get("remote", Some(remote_name), "url"))
1938 } else {
1939 config.get("remote", Some(remote_name), "url")
1940 };
1941 let Some(value) = value else {
1942 return Ok(None);
1943 };
1944 let rewritten =
1945 sley::plumbing::sley_config::remotes::rewrite_url_with_config(&config, value, push);
1946 parse_configured_remote_url(&rewritten, &repo_relative_base(repo)).map(Some)
1947}
1948
1949fn checkout_tracking_remote_name(root: &Path, requested: &str) -> GitResult<Option<String>> {
1950 let remotes = checkout_remote_url_items(root)?;
1951 if remotes.is_empty() {
1952 return Ok(None);
1953 }
1954 if let Some((name, _)) = remotes.iter().find(|(name, _)| name == requested) {
1955 return Ok(Some(name.clone()));
1956 }
1957 if let Some((name, _)) = remotes
1958 .iter()
1959 .find(|(_, url)| configured_remote_values_match(url, requested))
1960 {
1961 return Ok(Some(name.clone()));
1962 }
1963 if looks_like_remote_location(requested) && remotes.len() == 1 {
1964 return Ok(Some(remotes[0].0.clone()));
1965 }
1966 if !looks_like_remote_location(requested) {
1967 return Ok(Some(requested.to_string()));
1968 }
1969 Ok(None)
1970}
1971
1972fn checkout_remote_url_items(root: &Path) -> GitResult<Vec<(String, String)>> {
1973 let mut remotes = Vec::new();
1974 for config_path in checkout_git_config_paths(root) {
1975 parse_remote_url_items_from_config(&config_path, &mut remotes)?;
1976 }
1977 Ok(remotes)
1978}
1979
1980fn checkout_note_ref_exists(root: &Path) -> GitResult<bool> {
1981 if !root.join(".git").exists() {
1982 return Ok(false);
1983 }
1984 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1985 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1986 Ok(object_repo
1987 .find_reference(super::git_notes::NOTES_REF)
1988 .map_err(git_err)?
1989 .is_some())
1990}
1991
1992fn seed_checkout_note_refs_into_mirror(root: &Path, mirror_repo: &SleyRepository) -> GitResult<()> {
1993 if !root.join(".git").exists() {
1994 return Ok(());
1995 }
1996
1997 let checkout_repo = match SleyRepository::discover(root) {
1998 Ok(repo) => repo,
1999 Err(_) => return Ok(()),
2000 };
2001 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2002 return Ok(());
2003 }
2004 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2005 let note_updates = collect_ref_updates(&object_repo)?
2006 .into_iter()
2007 .filter(|update| update.namespace == RefNamespace::Note)
2008 .collect::<Vec<_>>();
2009 if note_updates.is_empty() {
2010 return Ok(());
2011 }
2012
2013 copy_reachable_objects(
2014 &object_repo,
2015 mirror_repo,
2016 note_updates.iter().map(|update| update.target),
2017 )?;
2018 apply_ref_updates(
2019 mirror_repo,
2020 ¬e_updates,
2021 "heddle: seed mirror note refs from checkout",
2022 )
2023}
2024
2025fn hydrate_checkout_notes_from_remote_without_mirror(
2026 root: &Path,
2027 remote_name: &str,
2028) -> GitResult<()> {
2029 reject_reserved_git_remote_name(remote_name)?;
2030 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2031 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2032 let url = remote_fetch_url_from_checkout_config(root, remote_name)?
2033 .ok_or_else(|| GitBridgeError::Git(format!("remote '{remote_name}' has no fetch URL")))?;
2034
2035 if let Some(path) = local_path_from_url(&url)? {
2036 let remote_repo = open_repo(&path)?;
2037 let note_updates = collect_ref_updates(&remote_repo)?
2038 .into_iter()
2039 .filter(|update| update.namespace == RefNamespace::Note)
2040 .collect::<Vec<_>>();
2041 if note_updates.is_empty() {
2042 return Ok(());
2043 }
2044 copy_reachable_objects(
2045 &remote_repo,
2046 &object_repo,
2047 note_updates.iter().map(|update| update.target),
2048 )?;
2049 apply_ref_updates(
2050 &object_repo,
2051 ¬e_updates,
2052 &format!("heddle: hydrate notes from {remote_name}"),
2053 )?;
2054 return Ok(());
2055 }
2056
2057 fetch_heddle_notes_into_repo(&object_repo, remote_name, &url)
2058}
2059
2060fn fetch_heddle_notes_into_repo(
2061 repo: &SleyRepository,
2062 remote_name: &str,
2063 url: &str,
2064) -> GitResult<()> {
2065 let mut credentials = NoCredentials;
2066 let mut progress = SilentProgress;
2067 let refspec = RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format();
2068 repo.fetch(
2069 url,
2070 &[refspec],
2071 FetchOptions {
2072 quiet: true,
2073 auto_follow_tags: false,
2074 fetch_all_tags: false,
2075 prune: false,
2076 dry_run: false,
2077 append: false,
2078 write_fetch_head: true,
2079 tag_option_explicit: true,
2080 prune_option_explicit: true,
2081 depth: None,
2082 merge_srcs: Vec::new(),
2083 filter: None,
2084 cloning: false,
2085 update_shallow: false,
2086 deepen_relative: false,
2087 deepen_since: None,
2088 deepen_not: Vec::new(),
2089 },
2090 &mut credentials,
2091 &mut progress,
2092 )
2093 .map(|_| ())
2094 .map_err(|err| GitBridgeError::Git(format!("failed to fetch notes from {remote_name}: {err}")))
2095}
2096
2097fn parse_remote_url_items_from_config(
2098 path: &Path,
2099 remotes: &mut Vec<(String, String)>,
2100) -> GitResult<()> {
2101 let Ok(contents) = fs::read_to_string(path) else {
2102 return Ok(());
2103 };
2104 let mut current_remote: Option<String> = None;
2105 for raw in contents.lines() {
2106 let line = raw.trim();
2107 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2108 continue;
2109 }
2110 if line.starts_with('[') && line.ends_with(']') {
2111 current_remote = line
2112 .strip_prefix("[remote \"")
2113 .and_then(|rest| rest.strip_suffix("\"]"))
2114 .map(str::to_string);
2115 continue;
2116 }
2117 let Some(name) = current_remote.as_ref() else {
2118 continue;
2119 };
2120 let Some((key, value)) = line.split_once('=') else {
2121 continue;
2122 };
2123 if key.trim().eq_ignore_ascii_case("url") {
2124 remotes.push((name.clone(), git_config_value(value.trim())?));
2125 }
2126 }
2127 Ok(())
2128}
2129
2130fn configured_remote_values_match(left: &str, right: &str) -> bool {
2131 if left == right {
2132 return true;
2133 }
2134 let left_path = Path::new(left);
2135 let right_path = Path::new(right);
2136 if let (Ok(left), Ok(right)) = (left_path.canonicalize(), right_path.canonicalize()) {
2137 return left == right;
2138 }
2139 false
2140}
2141
2142fn looks_like_remote_location(value: &str) -> bool {
2143 value.starts_with('/')
2144 || value.starts_with("./")
2145 || value.starts_with("../")
2146 || value.starts_with("~/")
2147 || value.contains("://")
2148 || value.contains('\\')
2149}
2150
2151fn remote_fetch_url_from_checkout_config(
2152 root: &Path,
2153 remote_name: &str,
2154) -> GitResult<Option<String>> {
2155 for config_path in checkout_git_config_paths(root) {
2156 let Some(url) = parse_remote_fetch_url_from_config(&config_path, remote_name)? else {
2157 continue;
2158 };
2159 return parse_configured_remote_url(&url, root).map(Some);
2160 }
2161 Ok(None)
2162}
2163
2164fn parse_configured_remote_url(value: &str, relative_base: &Path) -> GitResult<String> {
2165 if configured_remote_is_local_path(value) {
2166 let path = configured_remote_local_path(value, relative_base);
2167 return Ok(format!("file://{}", path.display()));
2168 }
2169 Ok(value.to_string())
2170}
2171
2172fn configured_remote_local_path(value: &str, relative_base: &Path) -> PathBuf {
2173 if value == "~"
2174 && let Some(home) = std::env::var_os("HOME")
2175 {
2176 return PathBuf::from(home);
2177 }
2178 if let Some(rest) = value.strip_prefix("~/")
2179 && let Some(home) = std::env::var_os("HOME")
2180 {
2181 return PathBuf::from(home).join(rest);
2182 }
2183
2184 let path = Path::new(value);
2185 if path.is_absolute() {
2186 path.to_path_buf()
2187 } else {
2188 relative_base.join(path)
2189 }
2190}
2191
2192fn configured_remote_is_local_path(value: &str) -> bool {
2193 value.starts_with('/')
2194 || value.starts_with("./")
2195 || value.starts_with("../")
2196 || value.starts_with('~')
2197 || value.starts_with(std::path::MAIN_SEPARATOR)
2198}
2199
2200fn checkout_git_config_paths(root: &Path) -> Vec<PathBuf> {
2201 let dot_git = root.join(".git");
2202 let mut paths = Vec::new();
2203 if dot_git.is_dir() {
2204 paths.push(dot_git.join("config"));
2205 if let Some(common_dir) = common_git_dir_from_git_dir(&dot_git) {
2206 paths.push(common_dir.join("config"));
2207 }
2208 return paths;
2209 }
2210 let Ok(contents) = fs::read_to_string(&dot_git) else {
2211 return paths;
2212 };
2213 let Some(target) = contents.trim().strip_prefix("gitdir:").map(str::trim) else {
2214 return paths;
2215 };
2216 let git_dir = {
2217 let path = Path::new(target);
2218 if path.is_absolute() {
2219 path.to_path_buf()
2220 } else {
2221 dot_git
2222 .parent()
2223 .map(|parent| parent.join(path))
2224 .unwrap_or_else(|| path.to_path_buf())
2225 }
2226 };
2227 paths.push(git_dir.join("config"));
2228 if let Some(common_dir) = common_git_dir_from_git_dir(&git_dir) {
2229 paths.push(common_dir.join("config"));
2230 }
2231 paths
2232}
2233
2234fn common_git_dir_from_git_dir(git_dir: &Path) -> Option<PathBuf> {
2235 let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
2236 let target = contents.trim();
2237 let path = Path::new(target);
2238 Some(if path.is_absolute() {
2239 path.to_path_buf()
2240 } else {
2241 git_dir.join(path)
2242 })
2243}
2244
2245fn parse_remote_fetch_url_from_config(path: &Path, remote_name: &str) -> GitResult<Option<String>> {
2246 let Ok(contents) = fs::read_to_string(path) else {
2247 return Ok(None);
2248 };
2249 let mut in_remote = false;
2250 for raw in contents.lines() {
2251 let line = raw.trim();
2252 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2253 continue;
2254 }
2255 if line.starts_with('[') && line.ends_with(']') {
2256 in_remote = line
2257 .strip_prefix("[remote \"")
2258 .and_then(|rest| rest.strip_suffix("\"]"))
2259 == Some(remote_name);
2260 continue;
2261 }
2262 if !in_remote {
2263 continue;
2264 }
2265 let Some((key, value)) = line.split_once('=') else {
2266 continue;
2267 };
2268 if key.trim().eq_ignore_ascii_case("url") {
2269 return git_config_value(value.trim()).map(Some);
2270 }
2271 }
2272 Ok(None)
2273}
2274
2275fn common_repo_for_worktree(repo: &SleyRepository) -> GitResult<SleyRepository> {
2276 let common_dir_file = repo.git_dir().join("commondir");
2277 let Ok(contents) = fs::read_to_string(&common_dir_file) else {
2278 return Ok(repo.clone());
2279 };
2280 let target = contents.trim();
2281 if target.is_empty() {
2282 return Ok(repo.clone());
2283 }
2284 let common_dir = {
2285 let path = Path::new(target);
2286 if path.is_absolute() {
2287 path.to_path_buf()
2288 } else {
2289 repo.git_dir().join(path)
2290 }
2291 };
2292 open_repo(&common_dir)
2293}
2294
2295pub(crate) fn git_err(err: impl std::fmt::Display) -> GitBridgeError {
2296 GitBridgeError::Git(err.to_string())
2297}
2298
2299fn restore_file(path: PathBuf, previous: Option<&[u8]>) -> GitResult<()> {
2300 if let Some(previous) = previous {
2301 fs::write(path, previous)?;
2302 } else if path.exists() {
2303 fs::remove_file(path)?;
2304 }
2305 Ok(())
2306}
2307
2308fn fsync_path(path: &Path) -> GitResult<()> {
2312 match std::fs::File::open(path) {
2313 Ok(file) => {
2314 file.sync_all()?;
2315 Ok(())
2316 }
2317 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2318 Err(err) => Err(GitBridgeError::Io(err)),
2319 }
2320}
2321
2322pub(crate) struct MirrorInitGuard {
2329 path: PathBuf,
2330 rollback: Option<bool>,
2334}
2335
2336impl MirrorInitGuard {
2337 pub(crate) fn new_from_init(path: PathBuf, did_create: bool) -> Self {
2338 Self {
2339 path,
2340 rollback: Some(did_create),
2341 }
2342 }
2343
2344 pub(crate) fn commit(mut self) {
2345 self.rollback = None;
2346 }
2347}
2348
2349impl Drop for MirrorInitGuard {
2350 fn drop(&mut self) {
2351 if matches!(self.rollback, Some(true))
2352 && self.path.exists()
2353 && let Err(err) = std::fs::remove_dir_all(&self.path)
2354 {
2355 tracing::warn!(
2356 path = %self.path.display(),
2357 error = %err,
2358 "failed to roll back partial bridge mirror; manual cleanup may be required"
2359 );
2360 }
2361 }
2362}
2363
2364pub(crate) fn thread_is_unclaimed_bootstrap(
2375 heddle_repo: &HeddleRepository,
2376 change_id: &ChangeId,
2377) -> GitResult<bool> {
2378 let Some(state) = heddle_repo.store().get_state(change_id)? else {
2379 return Ok(false);
2380 };
2381 if !state.parents.is_empty() {
2382 return Ok(false);
2383 }
2384 let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
2385 return Ok(false);
2386 };
2387 Ok(tree == Tree::new())
2388}
2389
2390pub(crate) fn open_repo(path: &Path) -> GitResult<SleyRepository> {
2391 match SleyRepository::discover(path) {
2392 Ok(repo) => Ok(repo),
2393 Err(_) => SleyRepository::open(path).map_err(git_err),
2394 }
2395}
2396
2397pub(crate) fn delete_reference_if_present(repo: &SleyRepository, name: &str) -> GitResult<()> {
2405 delete_reference(repo, name, None, true)
2406}
2407
2408fn delete_reference_matching(
2409 repo: &SleyRepository,
2410 name: &str,
2411 expected_old: ObjectId,
2412) -> GitResult<()> {
2413 delete_reference(repo, name, Some(expected_old), false)
2414}
2415
2416fn delete_reference(
2417 repo: &SleyRepository,
2418 name: &str,
2419 expected_old: Option<ObjectId>,
2420 missing_ok: bool,
2421) -> GitResult<()> {
2422 let refs = repo.references();
2423 match refs.read_ref(name).map_err(git_err)? {
2424 None if missing_ok => Ok(()),
2425 None => Err(GitBridgeError::Git(format!(
2426 "failed to delete Git reference '{name}': ref is missing"
2427 ))),
2428 Some(ReferenceTarget::Direct(oid)) => repo
2429 .delete_ref(DeleteRef {
2430 name: FullName::new(name).map_err(git_err)?,
2431 expected_old: Some(expected_old.unwrap_or(oid)),
2432 expected: None,
2433 reflog: None,
2434 reflog_committer: None,
2435 })
2436 .map_err(git_err),
2437 Some(ReferenceTarget::Symbolic(_)) => {
2438 if let Some(expected_old) = expected_old {
2439 let current = repo
2440 .find_reference(name)
2441 .map_err(git_err)?
2442 .and_then(|reference| reference.peeled_oid(repo).ok().flatten());
2443 if current != Some(expected_old) {
2444 return Err(GitBridgeError::Git(format!(
2445 "failed to delete Git reference '{name}': expected {expected_old}, found {}",
2446 current
2447 .map(|oid| oid.to_string())
2448 .unwrap_or_else(|| "missing".to_string())
2449 )));
2450 }
2451 }
2452 refs.delete_symbolic_ref(name).map(|_| ()).map_err(git_err)
2453 }
2454 }
2455}
2456
2457pub(crate) fn set_reference(
2458 repo: &SleyRepository,
2459 name: &str,
2460 target: ObjectId,
2461 constraint: RefPrecondition,
2462 log_message: &str,
2463) -> GitResult<()> {
2464 let refs = repo.references();
2465 let old_oid = match refs.read_ref(name).map_err(git_err)? {
2466 Some(ReferenceTarget::Direct(oid)) => oid,
2467 _ => ObjectId::null(repo.object_format()),
2468 };
2469 let reflog = sley::plumbing::sley_refs::ReflogEntry {
2470 old_oid,
2471 new_oid: target,
2472 committer: bridge_signature(),
2473 message: log_message.as_bytes().to_vec(),
2474 };
2475 let mut tx = refs.transaction();
2476 tx.update_to(
2477 name.to_string(),
2478 ReferenceTarget::Direct(target),
2479 constraint,
2480 Some(reflog),
2481 );
2482 tx.commit().map_err(git_err)?;
2483 Ok(())
2484}
2485
2486fn path_prefix_conflict(a: &str, b: &str) -> bool {
2492 let child_of = |parent: &str, child: &str| {
2493 child
2494 .strip_prefix(parent)
2495 .is_some_and(|rest| rest.starts_with('/'))
2496 };
2497 child_of(a, b) || child_of(b, a)
2498}
2499
2500fn collect_capture_paths<S: ObjectStore + ?Sized>(
2505 store: &S,
2506 tree: &Tree,
2507 prefix: &str,
2508 out: &mut Vec<(String, FileMode)>,
2509) -> GitResult<()> {
2510 for entry in tree.iter() {
2511 let path = if prefix.is_empty() {
2512 entry.name.clone()
2513 } else {
2514 format!("{prefix}/{}", entry.name)
2515 };
2516 if entry.is_tree() {
2517 if let Some(subtree) = store.get_tree(&entry.hash)? {
2518 collect_capture_paths(store, &subtree, &path, out)?;
2519 }
2520 } else {
2521 out.push((path, entry.mode));
2522 }
2523 }
2524 Ok(())
2525}
2526
2527fn update_checkout_head_ref(
2528 repo: &SleyRepository,
2529 target: ObjectId,
2530 previous_branch: Option<ObjectId>,
2531 log_message: &str,
2532) -> GitResult<()> {
2533 let expected = previous_branch.map_or(RefPrecondition::MustNotExist, |oid| {
2534 RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(oid))
2535 });
2536 let ref_name = repo
2537 .head()
2538 .ok()
2539 .and_then(|head| head.symbolic_target.map(|name| name.to_string()))
2540 .unwrap_or_else(|| "HEAD".to_string());
2541 let old_oid = previous_branch.unwrap_or_else(|| ObjectId::null(repo.object_format()));
2542 let head_reflog = sley::plumbing::sley_refs::ReflogEntry {
2543 old_oid,
2544 new_oid: target,
2545 committer: bridge_signature(),
2546 message: log_message.as_bytes().to_vec(),
2547 };
2548 set_reference(repo, &ref_name, target, expected, log_message)?;
2549 if ref_name != "HEAD" {
2550 repo.references()
2551 .append_reflog("HEAD", &head_reflog)
2552 .map_err(git_err)?;
2553 }
2554 Ok(())
2555}
2556
2557fn checkout_git_head_is_detached(root: &Path) -> GitResult<bool> {
2558 let repo = SleyRepository::discover(root).map_err(git_err)?;
2559 Ok(repo.head().map(|head| head.is_detached()).unwrap_or(false))
2560}
2561
2562pub(crate) fn resolve_git_commit_identity(
2563 repo_root: &Path,
2564 fallback: &Principal,
2565) -> GitResult<LocalGitIdentity> {
2566 if !principal_is_default_unknown(fallback) {
2567 return Ok(LocalGitIdentity::from_principal(fallback));
2568 }
2569 if let Some(identity) = git_config_identity_with_global_fallback(repo_root)? {
2570 return Ok(identity);
2571 }
2572
2573 Err(GitBridgeError::Git(
2574 "refusing to write a Git commit with Unknown <unknown@example.com>; configure user.name/user.email, HEDDLE_PRINCIPAL_NAME/HEDDLE_PRINCIPAL_EMAIL, or .heddle principal".to_string(),
2575 ))
2576}
2577
2578pub(crate) fn git_config_identity_with_global_fallback(
2579 repo_root: &Path,
2580) -> GitResult<Option<LocalGitIdentity>> {
2581 let name = git_config_value_with_global_fallback(repo_root, "user.name")?;
2582 let email = git_config_value_with_global_fallback(repo_root, "user.email")?;
2583 if let (Some(name), Some(email)) = (name, email)
2584 && !name.trim().is_empty()
2585 && !email.trim().is_empty()
2586 {
2587 return Ok(Some(LocalGitIdentity { name, email }));
2588 }
2589 Ok(None)
2590}
2591
2592pub(crate) fn principal_is_default_unknown(principal: &Principal) -> bool {
2593 principal.name.trim().is_empty()
2594 || principal.email.trim().is_empty()
2595 || (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
2596}
2597
2598fn git_config_value_with_global_fallback(repo_root: &Path, key: &str) -> GitResult<Option<String>> {
2599 let Ok(repo) = SleyRepository::discover(repo_root) else {
2600 return Ok(None);
2601 };
2602 let Some((section, variable)) = key.split_once('.') else {
2603 return Ok(None);
2604 };
2605 Ok(repo
2606 .config_snapshot()
2607 .map_err(git_err)?
2608 .get(section, None, variable)
2609 .map(str::to_string))
2610}
2611
2612fn git_config_value(value: &str) -> GitResult<String> {
2613 let Some(quoted) = value
2614 .strip_prefix('"')
2615 .and_then(|rest| rest.strip_suffix('"'))
2616 else {
2617 return Ok(value.to_string());
2618 };
2619 let mut out = String::new();
2620 let mut chars = quoted.chars();
2621 while let Some(ch) = chars.next() {
2622 if ch != '\\' {
2623 out.push(ch);
2624 continue;
2625 }
2626 let Some(escaped) = chars.next() else {
2627 return Err(GitBridgeError::Git(
2628 "unterminated escape in repo-local Git config".to_string(),
2629 ));
2630 };
2631 match escaped {
2632 '"' | '\\' => out.push(escaped),
2633 'n' => out.push('\n'),
2634 't' => out.push('\t'),
2635 'b' => out.push('\u{0008}'),
2636 other => out.push(other),
2637 }
2638 }
2639 Ok(out)
2640}
2641
2642fn bridge_signature() -> Vec<u8> {
2643 let seconds = SystemTime::now()
2644 .duration_since(UNIX_EPOCH)
2645 .map(|duration| duration.as_secs() as i64)
2646 .unwrap_or(0);
2647 format!("Heddle <heddle@local> {seconds} +0000").into_bytes()
2648}
2649
2650fn repo_relative_base(repo: &SleyRepository) -> PathBuf {
2651 repo.workdir().unwrap_or_else(|| {
2652 if repo
2653 .git_dir()
2654 .file_name()
2655 .is_some_and(|name| name == ".git")
2656 {
2657 repo.git_dir()
2658 .parent()
2659 .map(Path::to_path_buf)
2660 .unwrap_or_else(|| repo.git_dir().to_path_buf())
2661 } else {
2662 repo.git_dir().to_path_buf()
2663 }
2664 })
2665}
2666
2667fn local_path_from_url(url: &str) -> GitResult<Option<PathBuf>> {
2668 let Some(raw_path) = url.strip_prefix("file://") else {
2669 return Ok(None);
2670 };
2671 let path = PathBuf::from(raw_path);
2672 if path.as_os_str().is_empty() {
2673 return Err(GitBridgeError::Git(format!(
2674 "remote '{}' has no filesystem path",
2675 url
2676 )));
2677 }
2678 Ok(Some(path))
2679}
2680
2681fn collect_ref_updates(repo: &SleyRepository) -> GitResult<Vec<RefUpdate>> {
2682 let mut updates = Vec::new();
2683
2684 for reference in repo.references().list_refs().map_err(git_err)? {
2685 let ReferenceTarget::Direct(target) = reference.target else {
2686 continue;
2687 };
2688 if let Some(name) = reference.name.strip_prefix("refs/heads/") {
2689 updates.push(RefUpdate {
2690 name: name.to_string(),
2691 target,
2692 namespace: RefNamespace::Branch,
2693 });
2694 } else if let Some(name) = reference.name.strip_prefix("refs/tags/") {
2695 updates.push(RefUpdate {
2696 name: name.to_string(),
2697 target,
2698 namespace: RefNamespace::Tag,
2699 });
2700 } else if let Some(name) = reference.name.strip_prefix("refs/notes/") {
2701 updates.push(RefUpdate {
2702 name: name.to_string(),
2703 target,
2704 namespace: RefNamespace::Note,
2705 });
2706 }
2707 }
2708
2709 Ok(updates)
2710}
2711
2712#[derive(Debug, Default, Clone, Copy)]
2721pub(crate) struct ExportedCommitCounts {
2722 pub total: usize,
2723 pub newly: usize,
2724}
2725
2726pub(crate) fn count_exported_commits(
2740 repo: &SleyRepository,
2741 newly_minted: &HashSet<ObjectId>,
2742) -> GitResult<ExportedCommitCounts> {
2743 let tips: Vec<ObjectId> = collect_ref_updates(repo)?
2744 .into_iter()
2745 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag))
2746 .map(|update| update.target)
2747 .collect();
2748
2749 let mut stack = tips;
2750 let mut seen = HashSet::new();
2751 let mut counts = ExportedCommitCounts::default();
2752 while let Some(oid) = stack.pop() {
2753 if !seen.insert(oid) {
2754 continue;
2755 }
2756 let object = repo.read_object(&oid).map_err(git_err)?;
2757 match object.object_type {
2758 GitObjectType::Commit => {
2759 counts.total += 1;
2760 if newly_minted.contains(&oid) {
2761 counts.newly += 1;
2762 }
2763 let commit = repo.read_commit(&oid).map_err(git_err)?;
2764 for parent in commit.parents {
2765 stack.push(parent);
2766 }
2767 }
2768 GitObjectType::Tag => {
2772 let tag = repo.read_tag(&oid).map_err(git_err)?;
2773 stack.push(tag.object);
2774 }
2775 GitObjectType::Tree | GitObjectType::Blob => {}
2776 }
2777 }
2778 Ok(counts)
2779}
2780
2781fn collect_ref_updates_for_fetch(
2782 repo: &SleyRepository,
2783 scope: GitFetchScope,
2784) -> GitResult<Vec<RefUpdate>> {
2785 let updates = collect_ref_updates(repo)?;
2786 match scope {
2787 GitFetchScope::AllRefs => Ok(updates),
2788 GitFetchScope::BranchesAndNotes => Ok(updates
2789 .into_iter()
2790 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Note))
2791 .collect()),
2792 }
2793}
2794
2795pub(crate) fn collect_import_source_ref_updates(
2796 repo: &SleyRepository,
2797 refs: &[String],
2798) -> GitResult<Vec<RefUpdate>> {
2799 let updates = collect_ref_updates(repo)?;
2800 if refs.is_empty() {
2801 return Ok(updates);
2802 }
2803
2804 let wanted: HashSet<&str> = refs.iter().map(String::as_str).collect();
2805 Ok(updates
2806 .into_iter()
2807 .filter(|update| matches_import_ref(update, &wanted))
2808 .collect())
2809}
2810
2811fn matches_import_ref(update: &RefUpdate, wanted: &HashSet<&str>) -> bool {
2812 let full = full_ref_name(update);
2813 wanted.contains(update.name.as_str()) || wanted.contains(full.as_str())
2814}
2815
2816fn full_ref_name(update: &RefUpdate) -> String {
2817 match update.namespace {
2818 RefNamespace::Branch => format!("refs/heads/{}", update.name),
2819 RefNamespace::Tag => format!("refs/tags/{}", update.name),
2820 RefNamespace::Note => format!("refs/notes/{}", update.name),
2821 }
2822}
2823
2824#[cfg(test)]
2825pub(crate) fn ensure_commit_update_fast_forward(
2826 repo: &SleyRepository,
2827 name: &str,
2828 old: ObjectId,
2829 new: ObjectId,
2830) -> GitResult<()> {
2831 if old == new || old == ObjectId::null(repo.object_format()) {
2832 return Ok(());
2833 }
2834 match commit_is_descendant_of(repo, new, old) {
2835 Ok(true) => Ok(()),
2836 Ok(false) => Err(GitBridgeError::NonFastForwardRef {
2837 name: name.to_string(),
2838 old,
2839 new,
2840 }),
2841 Err(err) => Err(GitBridgeError::Git(format!(
2842 "ref update would move {name}: {old} -> {new}, but Heddle could not verify it as a fast-forward ({err}); fetch/import first or inspect the refs explicitly"
2843 ))),
2844 }
2845}
2846
2847fn commit_is_descendant_of(
2848 repo: &SleyRepository,
2849 descendant: ObjectId,
2850 ancestor: ObjectId,
2851) -> GitResult<bool> {
2852 let mut stack = vec![descendant];
2853 let mut seen = HashSet::new();
2854 while let Some(oid) = stack.pop() {
2855 if oid == ancestor {
2856 return Ok(true);
2857 }
2858 if !seen.insert(oid) {
2859 continue;
2860 }
2861 let commit = repo.read_commit(&oid).map_err(git_err)?;
2862 for parent in commit.parents {
2863 stack.push(parent);
2864 }
2865 }
2866 Ok(false)
2867}
2868
2869const HEDDLE_EXPORTED_REFS_FILE: &str = "heddle-exported-refs";
2879
2880const HEDDLE_NETWORK_EXPORTED_REFS_DIR: &str = "git-network-exported-refs";
2887
2888fn exported_refs_manifest_path(target_repo: &SleyRepository) -> PathBuf {
2889 target_repo.git_dir().join(HEDDLE_EXPORTED_REFS_FILE)
2890}
2891
2892fn network_exported_refs_path(heddle_dir: &Path, url: &str) -> PathBuf {
2897 let key = ContentHash::compute_typed("git-network-exported-refs", url.as_bytes()).to_hex();
2898 heddle_dir
2899 .join(HEDDLE_NETWORK_EXPORTED_REFS_DIR)
2900 .join(format!("{key}.refs"))
2901}
2902
2903fn read_exported_refs_at(path: &Path) -> GitResult<HashMap<String, ObjectId>> {
2911 match fs::read_to_string(path) {
2912 Ok(text) => {
2913 let mut map = HashMap::new();
2914 for line in text.lines() {
2915 let line = line.trim();
2916 if line.is_empty() {
2917 continue;
2918 }
2919 let mut parts = line.split_whitespace();
2927 let Some(name) = parts.next() else {
2928 continue;
2929 };
2930 let tip = parts
2931 .next()
2932 .and_then(|token| token.parse::<ObjectId>().ok())
2933 .unwrap_or_else(|| ObjectId::null(ObjectFormat::Sha1));
2934 map.insert(name.to_string(), tip);
2935 }
2936 Ok(map)
2937 }
2938 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
2939 Err(e) => Err(GitBridgeError::Io(e)),
2940 }
2941}
2942
2943fn write_exported_refs_at(path: &Path, refs: &HashMap<String, ObjectId>) -> GitResult<()> {
2947 if let Some(parent) = path.parent() {
2948 fs::create_dir_all(parent)?;
2949 }
2950 let mut sorted: Vec<(&str, &ObjectId)> = refs
2951 .iter()
2952 .map(|(name, tip)| (name.as_str(), tip))
2953 .collect();
2954 sorted.sort_unstable_by(|a, b| a.0.cmp(b.0));
2955 let body = sorted
2956 .iter()
2957 .map(|(name, tip)| format!("{name} {tip}"))
2958 .collect::<Vec<_>>()
2959 .join("\n");
2960 let tmp = path.with_extension("tmp");
2961 fs::write(&tmp, body)?;
2962 fs::rename(&tmp, path)?;
2963 Ok(())
2964}
2965
2966pub(crate) fn read_exported_refs(
2969 target_repo: &SleyRepository,
2970) -> GitResult<HashMap<String, ObjectId>> {
2971 read_exported_refs_at(&exported_refs_manifest_path(target_repo))
2972}
2973
2974pub(crate) fn write_exported_refs(
2977 target_repo: &SleyRepository,
2978 refs: &HashMap<String, ObjectId>,
2979) -> GitResult<()> {
2980 write_exported_refs_at(&exported_refs_manifest_path(target_repo), refs)
2981}
2982
2983const HEDDLE_MIRROR_MANAGED_REFS_FILE: &str = "heddle-mirror-managed-refs";
2995
2996fn mirror_managed_refs_path(mirror_repo: &SleyRepository) -> PathBuf {
2998 mirror_repo.git_dir().join(HEDDLE_MIRROR_MANAGED_REFS_FILE)
2999}
3000
3001pub(crate) fn mirror_managed_refs_recorded(mirror_repo: &SleyRepository) -> bool {
3007 mirror_managed_refs_path(mirror_repo).exists()
3008}
3009
3010pub(crate) fn read_mirror_managed_refs(
3014 mirror_repo: &SleyRepository,
3015) -> GitResult<HashMap<String, ObjectId>> {
3016 read_exported_refs_at(&mirror_managed_refs_path(mirror_repo))
3017}
3018
3019pub(crate) fn write_mirror_managed_refs(
3022 mirror_repo: &SleyRepository,
3023 refs: &HashMap<String, ObjectId>,
3024) -> GitResult<()> {
3025 write_exported_refs_at(&mirror_managed_refs_path(mirror_repo), refs)
3026}
3027
3028pub(crate) fn read_or_seed_mirror_managed_refs(
3041 mirror_repo: &SleyRepository,
3042) -> GitResult<HashMap<String, ObjectId>> {
3043 if mirror_managed_refs_recorded(mirror_repo) {
3044 read_mirror_managed_refs(mirror_repo)
3045 } else {
3046 Ok(collect_ref_updates(mirror_repo)?
3047 .into_iter()
3048 .map(|update| (full_ref_name(&update), update.target))
3049 .collect())
3050 }
3051}
3052
3053pub(crate) fn collect_managed_ref_updates(
3063 repo: &SleyRepository,
3064 record: &HashMap<String, ObjectId>,
3065) -> GitResult<Vec<RefUpdate>> {
3066 Ok(collect_ref_updates(repo)?
3067 .into_iter()
3068 .filter(|update| {
3069 matches!(update.namespace, RefNamespace::Note)
3070 || record.contains_key(&full_ref_name(update))
3071 })
3072 .collect())
3073}
3074
3075#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3081enum RefMove {
3082 Unchanged,
3084 Create,
3086 FastForward,
3088 Rewind,
3097 Diverged,
3100}
3101
3102fn classify_ref_move(
3118 repo: &SleyRepository,
3119 old: Option<ObjectId>,
3120 new: ObjectId,
3121 recorded_tip: Option<ObjectId>,
3122) -> GitResult<RefMove> {
3123 let Some(old) = old else {
3124 return Ok(RefMove::Create);
3125 };
3126 if old == ObjectId::null(repo.object_format()) {
3127 return Ok(RefMove::Create);
3128 }
3129 if old == new {
3130 return Ok(RefMove::Unchanged);
3131 }
3132 if commit_is_descendant_of(repo, new, old)? {
3135 return Ok(RefMove::FastForward);
3136 }
3137 if recorded_tip == Some(old)
3147 && repo.read_commit(&old).is_ok()
3148 && commit_is_descendant_of(repo, old, new)?
3149 {
3150 return Ok(RefMove::Rewind);
3151 }
3152 Ok(RefMove::Diverged)
3153}
3154
3155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3169enum WriteVerdict {
3170 Skip,
3172 Write,
3175 RequireForce,
3177}
3178
3179fn verdict_from_move(m: RefMove) -> WriteVerdict {
3184 match m {
3185 RefMove::Unchanged => WriteVerdict::Skip,
3186 RefMove::Create | RefMove::FastForward | RefMove::Rewind => WriteVerdict::Write,
3187 RefMove::Diverged => WriteVerdict::RequireForce,
3188 }
3189}
3190
3191fn classify_tag_move(
3199 old: Option<ObjectId>,
3200 target: ObjectId,
3201 recorded: Option<ObjectId>,
3202) -> WriteVerdict {
3203 match old {
3204 None => WriteVerdict::Write,
3206 Some(o) if o == target => WriteVerdict::Skip,
3208 Some(o) if recorded == Some(o) => WriteVerdict::Write,
3210 Some(_) => WriteVerdict::RequireForce,
3212 }
3213}
3214
3215#[derive(Debug)]
3218pub(crate) struct PlannedRefWrite {
3219 pub(crate) full_name: String,
3220 pub(crate) old: Option<ObjectId>,
3221 pub(crate) new: ObjectId,
3222 pub(crate) force: bool,
3223}
3224
3225#[derive(Debug)]
3228pub(crate) struct PlannedRefDelete {
3229 pub(crate) full_name: String,
3230 pub(crate) old: ObjectId,
3231}
3232
3233#[derive(Debug)]
3236pub(crate) struct DestinationReconcilePlan {
3237 pub(crate) writes: Vec<PlannedRefWrite>,
3239 pub(crate) deletes: Vec<PlannedRefDelete>,
3242 pub(crate) new_manifest: HashMap<String, ObjectId>,
3248}
3249
3250pub(crate) fn planned_write_names(plan: &DestinationReconcilePlan) -> Vec<String> {
3257 let mut names: Vec<String> = plan
3258 .writes
3259 .iter()
3260 .map(|write| write.full_name.clone())
3261 .collect();
3262 names.sort_unstable();
3263 names
3264}
3265
3266fn creatable_ref_names(
3275 served_frontier: &[RefUpdate],
3276 scope: GitPushScope,
3277 current_branch: Option<&str>,
3278) -> Option<HashSet<String>> {
3279 match scope {
3280 GitPushScope::AllThreads => None,
3281 GitPushScope::CurrentThread => {
3282 let branch = current_branch.unwrap_or_default();
3283 Some(
3284 served_frontier
3285 .iter()
3286 .filter(|update| {
3287 (matches!(update.namespace, RefNamespace::Branch) && update.name == branch)
3288 || matches!(update.namespace, RefNamespace::Note)
3289 })
3290 .map(full_ref_name)
3291 .collect(),
3292 )
3293 }
3294 }
3295}
3296
3297pub(crate) fn plan_destination_reconcile(
3345 mirror_repo: &SleyRepository,
3346 served_frontier: &[RefUpdate],
3347 creatable_names: Option<&HashSet<String>>,
3348 old_at_destination: &HashMap<String, ObjectId>,
3349 previously_exported: &HashMap<String, ObjectId>,
3350 force: bool,
3351) -> GitResult<DestinationReconcilePlan> {
3352 let desired: HashMap<String, &RefUpdate> = served_frontier
3358 .iter()
3359 .map(|u| (full_ref_name(u), u))
3360 .collect();
3361
3362 let mut names: BTreeSet<String> = desired.keys().cloned().collect();
3369 names.extend(previously_exported.keys().cloned());
3370
3371 let mut writes = Vec::new();
3372 let mut deletes = Vec::new();
3373 let mut new_manifest: HashMap<String, ObjectId> = HashMap::new();
3374
3375 for full in names {
3376 let old = old_at_destination.get(&full).copied();
3377 let recorded = previously_exported.get(&full).copied();
3378
3379 if let Some(update) = desired.get(&full).copied() {
3380 if old.is_none() && creatable_names.is_some_and(|names| !names.contains(&full)) {
3389 if let Some(recorded) = recorded {
3390 new_manifest.insert(full, recorded);
3391 }
3392 continue;
3393 }
3394 let (verdict, force_write) = match update.namespace {
3403 RefNamespace::Branch | RefNamespace::Note => {
3404 let movement = classify_ref_move(mirror_repo, old, update.target, recorded)?;
3405 (
3406 verdict_from_move(movement),
3407 matches!(movement, RefMove::Rewind),
3408 )
3409 }
3410 RefNamespace::Tag => {
3411 let verdict = classify_tag_move(old, update.target, recorded);
3412 (
3413 verdict,
3414 old.is_some_and(|old| old != update.target)
3415 && matches!(verdict, WriteVerdict::Write),
3416 )
3417 }
3418 };
3419 let proceed = match verdict {
3420 WriteVerdict::Skip => false,
3421 WriteVerdict::Write => true,
3422 WriteVerdict::RequireForce => {
3423 if force {
3424 true
3425 } else {
3426 return Err(GitBridgeError::NonFastForwardRef {
3427 name: full.clone(),
3428 old: old.unwrap_or_else(|| ObjectId::null(mirror_repo.object_format())),
3429 new: update.target,
3430 });
3431 }
3432 }
3433 };
3434 if proceed {
3435 writes.push(PlannedRefWrite {
3436 full_name: full.clone(),
3437 old,
3438 new: update.target,
3439 force: force_write || matches!(verdict, WriteVerdict::RequireForce),
3440 });
3441 }
3442 if proceed || recorded.is_some() {
3450 new_manifest.insert(full, update.target);
3451 }
3452 continue;
3453 }
3454
3455 match old {
3464 Some(old) if recorded == Some(old) || force => {
3465 deletes.push(PlannedRefDelete {
3466 full_name: full,
3467 old,
3468 });
3469 }
3471 Some(_) => {
3472 if let Some(recorded) = recorded {
3475 new_manifest.insert(full, recorded);
3476 }
3477 }
3478 None => {
3479 }
3481 }
3482 }
3483
3484 Ok(DestinationReconcilePlan {
3485 writes,
3486 deletes,
3487 new_manifest,
3488 })
3489}
3490
3491fn read_destination_ref_map(repo: &SleyRepository) -> GitResult<HashMap<String, ObjectId>> {
3495 Ok(collect_ref_updates(repo)?
3496 .iter()
3497 .map(|update| (full_ref_name(update), update.target))
3498 .collect())
3499}
3500
3501pub(crate) fn apply_ref_updates(
3502 repo: &SleyRepository,
3503 updates: &[RefUpdate],
3504 log_message: &str,
3505) -> GitResult<()> {
3506 for update in updates {
3507 let full_name = full_ref_name(update);
3508 set_reference(
3509 repo,
3510 &full_name,
3511 update.target,
3512 RefPrecondition::Any,
3513 log_message,
3514 )?;
3515 }
3516 Ok(())
3517}
3518
3519fn apply_remote_tracking_ref_updates(
3520 repo: &SleyRepository,
3521 remote_name: &str,
3522 updates: &[RefUpdate],
3523 log_message: &str,
3524) -> GitResult<()> {
3525 reject_reserved_git_remote_name(remote_name)?;
3526 for update in updates
3527 .iter()
3528 .filter(|update| update.namespace == RefNamespace::Branch)
3529 {
3530 set_reference(
3531 repo,
3532 &format!("refs/remotes/{remote_name}/{}", update.name),
3533 update.target,
3534 RefPrecondition::Any,
3535 log_message,
3536 )?;
3537 }
3538 Ok(())
3539}
3540
3541pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitResult<()> {
3545 fs::create_dir_all(dest)?;
3546 let source = open_repo(source_path)?;
3547 let target = match SleyRepository::open(dest) {
3548 Ok(repo) => repo,
3549 Err(_) => SleyRepository::init_bare(dest).map_err(git_err)?,
3550 };
3551 let updates = collect_ref_updates(&source)?;
3552 copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
3553 apply_ref_updates(
3554 &target,
3555 &updates,
3556 &format!("heddle: clone from {}", source_path.display()),
3557 )?;
3558
3559 let copied_branches: HashSet<&str> = updates
3567 .iter()
3568 .filter(|update| update.namespace == RefNamespace::Branch)
3569 .map(|update| update.name.as_str())
3570 .collect();
3571 let source_head_branch = source
3572 .head()
3573 .ok()
3574 .and_then(|head| head.branch_name().map(str::to_owned))
3575 .filter(|branch| copied_branches.contains(branch.as_str()));
3576 if let Some(branch) = source_head_branch {
3577 fs::write(dest.join("HEAD"), format!("ref: refs/heads/{branch}\n"))?;
3578 } else if copied_branches.contains("main") {
3579 fs::write(dest.join("HEAD"), b"ref: refs/heads/main\n")?;
3580 } else if let Some(first_branch) = updates
3581 .iter()
3582 .find(|update| update.namespace == RefNamespace::Branch)
3583 {
3584 fs::write(
3585 dest.join("HEAD"),
3586 format!("ref: refs/heads/{}\n", first_branch.name),
3587 )?;
3588 }
3589 Ok(())
3590}
3591
3592pub fn clone_url_to_bare(
3611 url: &str,
3612 dest: &Path,
3613 depth: Option<u32>,
3614 filter: Option<&str>,
3615) -> GitResult<()> {
3616 if let Some(spec) = filter {
3620 return Err(GitBridgeError::Git(format!(
3621 "partial Git clone filter `{spec}` is not supported in Heddle's native no-git runtime yet; retry without --filter/--lazy so Heddle can import a complete object graph"
3622 )));
3623 }
3624 if let Some(source_path) = local_path_from_url(url)? {
3625 if depth.is_some() {
3626 return Err(GitBridgeError::Git(
3627 "shallow file:// Git clones are not supported in Heddle's native no-git runtime yet; retry without --depth so Heddle can copy the local Git object graph without spawning Git transport helpers"
3628 .to_string(),
3629 ));
3630 }
3631 return copy_local_repo_to_bare(&source_path, dest);
3632 }
3633 let default_branch =
3634 clone_url_to_bare_via_sley(url, dest, depth)?.or_else(|| default_branch_from_file_url(url));
3635 if let Some(branch) = default_branch
3645 && bare_branch_exists(dest, &branch)?
3646 {
3647 fs::write(dest.join("HEAD"), format!("ref: refs/heads/{branch}\n"))?;
3648 }
3649 Ok(())
3650}
3651
3652fn default_branch_from_file_url(url: &str) -> Option<String> {
3653 let source_path = local_path_from_url(url).ok().flatten()?;
3654 let head_path = if source_path.join("HEAD").is_file() {
3655 source_path.join("HEAD")
3656 } else {
3657 source_path.join(".git").join("HEAD")
3658 };
3659 let head = fs::read_to_string(head_path).ok()?;
3660 let branch = head.trim().strip_prefix("ref: refs/heads/")?;
3661 (!branch.is_empty()).then(|| branch.to_string())
3662}
3663
3664fn bare_branch_exists(repo_path: &Path, branch: &str) -> GitResult<bool> {
3665 let repo = open_repo(repo_path)?;
3666 Ok(repo
3667 .find_reference(&format!("refs/heads/{branch}"))
3668 .map_err(git_err)?
3669 .is_some())
3670}
3671
3672fn clone_url_to_bare_via_sley(
3673 url: &str,
3674 dest: &Path,
3675 depth: Option<u32>,
3676) -> GitResult<Option<String>> {
3677 fs::create_dir_all(dest)?;
3678 let repo = SleyRepository::init_bare(dest).map_err(git_err)?;
3679 let mut credentials = NoCredentials;
3680 let mut progress = SilentProgress;
3681 let outcome = repo
3682 .fetch(
3683 url,
3684 &heddle_mirror_fetch_refspecs()?,
3685 FetchOptions {
3686 quiet: true,
3687 auto_follow_tags: true,
3688 fetch_all_tags: true,
3689 prune: false,
3690 dry_run: false,
3691 append: false,
3692 write_fetch_head: true,
3693 tag_option_explicit: true,
3694 prune_option_explicit: true,
3695 depth,
3696 merge_srcs: Vec::new(),
3697 filter: None,
3698 cloning: true,
3699 update_shallow: false,
3700 deepen_relative: false,
3701 deepen_since: None,
3702 deepen_not: Vec::new(),
3703 },
3704 &mut credentials,
3705 &mut progress,
3706 )
3707 .map_err(|err| GitBridgeError::Git(format!("clone failed for {url}: {err}")))?;
3708 Ok(outcome
3709 .head_symref
3710 .and_then(|target| target.strip_prefix("refs/heads/").map(str::to_string)))
3711}
3712
3713pub(crate) fn copy_reachable_objects(
3714 source: &SleyRepository,
3715 target: &SleyRepository,
3716 roots: impl IntoIterator<Item = ObjectId>,
3717) -> GitResult<()> {
3718 let roots = roots.into_iter().collect::<Vec<_>>();
3719 target.copy_reachable_from(source, &roots).map_err(git_err)
3720}
3721
3722fn fetch_network_remote(
3723 mirror_repo: &SleyRepository,
3724 remote_name: &str,
3725 url: &str,
3726 scope: GitFetchScope,
3727) -> GitResult<()> {
3728 let mut credentials = NoCredentials;
3729 let mut progress = SilentProgress;
3730 mirror_repo
3731 .fetch(
3732 url,
3733 &heddle_mirror_fetch_refspecs()?,
3734 FetchOptions {
3735 quiet: true,
3736 auto_follow_tags: matches!(scope, GitFetchScope::AllRefs),
3737 fetch_all_tags: matches!(scope, GitFetchScope::AllRefs),
3738 prune: false,
3739 dry_run: false,
3740 append: false,
3741 write_fetch_head: true,
3742 tag_option_explicit: true,
3743 prune_option_explicit: true,
3744 depth: None,
3745 merge_srcs: Vec::new(),
3746 filter: None,
3747 cloning: false,
3748 update_shallow: false,
3749 deepen_relative: false,
3750 deepen_since: None,
3751 deepen_not: Vec::new(),
3752 },
3753 &mut credentials,
3754 &mut progress,
3755 )
3756 .map_err(|err| GitBridgeError::Git(format!("failed to fetch from {url}: {err}")))?;
3757 let _ = remote_name;
3758 Ok(())
3759}
3760
3761fn push_network_remote(
3764 mirror_repo: &SleyRepository,
3765 heddle_dir: &Path,
3766 url: &str,
3767 scope: GitPushScope,
3768 current_branch: Option<&str>,
3769 force: bool,
3770) -> GitResult<Vec<String>> {
3771 let manifest_path = network_exported_refs_path(heddle_dir, url);
3777 let previously_exported = read_exported_refs_at(&manifest_path)?;
3778 let managed_record = read_mirror_managed_refs(mirror_repo)?;
3788 let served_frontier = collect_managed_ref_updates(mirror_repo, &managed_record)?;
3789 if served_frontier.is_empty() && previously_exported.is_empty() {
3790 return Ok(Vec::new());
3791 }
3792
3793 let mut credentials = NoCredentials;
3794 let records = mirror_repo
3795 .ls_remote(
3796 url,
3797 LsRemoteFilter {
3798 heads: false,
3799 tags: false,
3800 refs_only: true,
3801 },
3802 &|_| true,
3803 &mut credentials,
3804 )
3805 .map_err(|err| GitBridgeError::Git(format!("failed to list refs from {url}: {err}")))?;
3806 let remote_refs = records
3807 .into_iter()
3808 .filter(|record| {
3809 record.name.starts_with("refs/heads/")
3810 || record.name.starts_with("refs/tags/")
3811 || record.name.starts_with("refs/notes/")
3812 })
3813 .map(|record| (record.name, record.oid))
3814 .collect::<HashMap<_, _>>();
3815
3816 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
3821 let plan = plan_destination_reconcile(
3822 mirror_repo,
3823 &served_frontier,
3824 creatable.as_ref(),
3825 &remote_refs,
3826 &previously_exported,
3827 force,
3828 )?;
3829
3830 if plan.writes.is_empty() && plan.deletes.is_empty() {
3831 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
3834 return Ok(Vec::new());
3835 }
3836
3837 let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
3838 let mut pack_objects = Vec::with_capacity(plan.writes.len());
3839 let force_transport_checks = plan.writes.iter().any(|write| write.force);
3840 for write in &plan.writes {
3841 commands.push(PushCommand {
3842 src: Some(write.new),
3843 dst: write.full_name.clone(),
3844 expected_old: write.old,
3845 force: write.force,
3846 });
3847 pack_objects.push(write.new);
3848 }
3849 for delete in &plan.deletes {
3850 commands.push(PushCommand {
3851 src: None,
3852 dst: delete.full_name.clone(),
3853 expected_old: Some(delete.old),
3854 force: false,
3855 });
3856 }
3857
3858 let mut credentials = NoCredentials;
3859 let mut progress = SilentProgress;
3860 mirror_repo
3861 .push_actions(
3862 url,
3863 PushActionPlan {
3864 commands,
3865 pack_objects,
3866 options: PushOptions {
3867 quiet: true,
3868 force: force || force_transport_checks,
3869 },
3870 },
3871 &mut credentials,
3872 &mut progress,
3873 )
3874 .map_err(|err| GitBridgeError::Git(format!("push failed for {url}: {err}")))?;
3875 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
3878 Ok(planned_write_names(&plan))
3879}
3880
3881#[cfg(test)]
3882mod tests {
3883 use super::*;
3884
3885 #[test]
3886 fn parse_git_ref_local_branch() {
3887 let parsed = parse_git_ref("refs/heads/main").expect("local branch parses");
3888 assert_eq!(parsed.kind, GitRefKind::Branch);
3889 assert_eq!(parsed.name, "main");
3890 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
3891 }
3892
3893 #[test]
3894 fn parse_git_ref_remote_branch_keeps_nested_name() {
3895 let parsed = parse_git_ref("refs/remotes/origin/feature/x").expect("remote branch parses");
3896 assert_eq!(parsed.kind, GitRefKind::Branch);
3897 assert_eq!(parsed.name, "feature/x");
3898 assert_eq!(parsed.remote, "origin");
3899 }
3900
3901 #[test]
3902 fn parse_git_ref_tag() {
3903 let parsed = parse_git_ref("refs/tags/v1.0").expect("tag parses");
3904 assert_eq!(parsed.kind, GitRefKind::Tag);
3905 assert_eq!(parsed.name, "v1.0");
3906 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
3907 }
3908
3909 #[test]
3910 fn parse_git_ref_skips_head_symrefs() {
3911 assert_eq!(parse_git_ref("refs/heads/HEAD"), None);
3912 assert_eq!(parse_git_ref("refs/remotes/origin/HEAD"), None);
3913 }
3914
3915 #[test]
3916 fn parse_git_ref_rejects_unknown_or_malformed() {
3917 assert_eq!(parse_git_ref("refs/notes/heddle"), None);
3918 assert_eq!(parse_git_ref("HEAD"), None);
3919 assert_eq!(parse_git_ref("refs/remotes/origin"), None);
3921 }
3922
3923 #[test]
3924 fn parse_git_ref_rejects_reserved_git_remote_namespace() {
3925 assert_eq!(parse_git_ref("refs/remotes/git/main"), None);
3928 assert_eq!(parse_git_ref("refs/remotes/git/feature/x"), None);
3929 assert!(is_reserved_git_remote_name(REMOTE_NAME_FOR_LOCAL_GIT_REPO));
3930 assert!(!is_reserved_git_remote_name("origin"));
3931 }
3932
3933 #[test]
3934 fn refspec_forced_round_trips_git_format() {
3935 let spec =
3936 RefSpec::forced("refs/heads/main", "refs/heads/main").expect("valid forced refspec");
3937 assert_eq!(spec.to_git_format(), "+refs/heads/main:refs/heads/main");
3938 assert_eq!(
3939 spec.to_git_format_not_forced(),
3940 "refs/heads/main:refs/heads/main"
3941 );
3942 }
3943
3944 #[test]
3945 fn refspec_constructor_rejects_reserved_remote_name() {
3946 let err = RefSpec::new(
3947 Some("refs/remotes/git/main".to_string()),
3948 "refs/heads/main",
3949 false,
3950 )
3951 .expect_err("reserved remote source is rejected");
3952 assert!(err.to_string().contains("reserved namespace"));
3953
3954 let err = RefSpec::new(
3955 Some("refs/heads/main".to_string()),
3956 "refs/remotes/git/main",
3957 false,
3958 )
3959 .expect_err("reserved remote destination is rejected");
3960 assert!(err.to_string().contains("reserved namespace"));
3961 }
3962
3963 #[test]
3964 fn refspec_forced_rejects_reserved_remote_name() {
3965 assert!(RefSpec::forced("refs/remotes/git/main", "refs/heads/main").is_err());
3966 assert!(RefSpec::forced("refs/heads/main", "refs/remotes/git/main").is_err());
3967 }
3968
3969 #[test]
3970 fn refspec_delete_has_empty_source() {
3971 let spec = RefSpec::delete("refs/heads/stale").expect("valid delete refspec");
3972 assert_eq!(spec.to_git_format(), ":refs/heads/stale");
3973 assert_eq!(spec.to_git_format_not_forced(), ":refs/heads/stale");
3974 }
3975
3976 #[test]
3977 fn refspec_delete_rejects_reserved_remote_name() {
3978 assert!(RefSpec::delete("refs/remotes/git/stale").is_err());
3979 }
3980
3981 #[test]
3982 fn refspec_constructor_rejects_empty_source_and_destination() {
3983 let err = RefSpec::new(None, "", false)
3984 .expect_err("empty source plus empty destination is rejected");
3985 assert!(err.to_string().contains("cannot both be empty"));
3986 }
3987
3988 #[test]
3989 fn negative_refspec_prefixes_caret() {
3990 let spec = NegativeRefSpec::new("refs/heads/wip").expect("valid negative refspec");
3991 assert_eq!(spec.to_git_format(), "^refs/heads/wip");
3992 }
3993
3994 #[test]
3995 fn negative_refspec_constructor_rejects_unparseable_negation() {
3996 let err = NegativeRefSpec::new("refs/heads/wip/*").expect_err("negative glob is rejected");
3997 assert!(err.to_string().contains("Negative glob patterns"));
3998 }
3999
4000 #[test]
4001 fn negative_refspec_constructor_rejects_reserved_remote_name() {
4002 let err = NegativeRefSpec::new("refs/remotes/git/main")
4003 .expect_err("reserved remote negative source is rejected");
4004 assert!(err.to_string().contains("reserved namespace"));
4005 }
4006
4007 #[test]
4008 fn mirror_fetch_refspecs_cover_branches_and_notes() {
4009 assert_eq!(
4010 heddle_mirror_fetch_refspecs().expect("mirror refspecs are valid"),
4011 [
4012 "+refs/heads/*:refs/heads/*".to_string(),
4013 "+refs/notes/*:refs/notes/*".to_string(),
4014 ]
4015 );
4016 }
4017
4018 #[test]
4019 fn scoped_import_ref_updates_do_not_include_notes_implicitly() {
4020 let tmp = tempfile::TempDir::new().unwrap();
4021 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4022 let main = seed_commit(&repo, "main");
4023 let other = seed_commit(&repo, "other");
4024 let notes = seed_commit(&repo, "notes");
4025 set_reference(
4026 &repo,
4027 "refs/heads/main",
4028 main,
4029 RefPrecondition::MustNotExist,
4030 "test: main",
4031 )
4032 .expect("write main");
4033 set_reference(
4034 &repo,
4035 "refs/heads/other",
4036 other,
4037 RefPrecondition::MustNotExist,
4038 "test: other",
4039 )
4040 .expect("write other");
4041 set_reference(
4042 &repo,
4043 "refs/notes/heddle",
4044 notes,
4045 RefPrecondition::MustNotExist,
4046 "test: notes",
4047 )
4048 .expect("write notes");
4049
4050 let updates = collect_import_source_ref_updates(&repo, &["main".to_string()])
4051 .expect("collect scoped updates");
4052 let full_names = updates.iter().map(full_ref_name).collect::<Vec<_>>();
4053
4054 assert_eq!(full_names, vec!["refs/heads/main".to_string()]);
4055 }
4056
4057 #[test]
4058 fn fast_forward_guard_reports_exact_rewrite_before_after() {
4059 let tmp = tempfile::TempDir::new().unwrap();
4060 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4061 let root = test_commit(&repo, "root", &[]);
4062 let old = test_commit(&repo, "old", &[root]);
4063 let new = test_commit(&repo, "new", &[root]);
4064
4065 let err = ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
4066 .expect_err("sibling commit update should be refused");
4067 let message = err.to_string();
4068 assert!(message.contains("refs/heads/main"));
4069 assert!(message.contains(&old.to_string()));
4070 assert!(message.contains(&new.to_string()));
4071 assert!(message.contains("refusing to replace"));
4072 }
4073
4074 #[test]
4075 fn fast_forward_guard_allows_descendant_update() {
4076 let tmp = tempfile::TempDir::new().unwrap();
4077 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4078 let old = test_commit(&repo, "old", &[]);
4079 let new = test_commit(&repo, "new", &[old]);
4080
4081 ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
4082 .expect("descendant update should be allowed");
4083 }
4084
4085 fn test_commit(repo: &SleyRepository, message: &str, parents: &[ObjectId]) -> ObjectId {
4086 let empty_tree_oid = ObjectId::empty_tree(repo.object_format());
4087 let sig = Signature {
4088 name: GitByteString::new(b"Heddle Test".to_vec()),
4089 email: GitByteString::new(b"heddle@test".to_vec()),
4090 time: GitTime::new(0, 0),
4091 raw: b"Heddle Test <heddle@test> 0 +0000".to_vec(),
4092 };
4093 let commit = sley::CommitObject {
4094 tree: empty_tree_oid,
4095 parents: parents.to_vec(),
4096 author: sig.to_ident_bytes(),
4097 committer: sig.to_ident_bytes(),
4098 encoding: None,
4099 message: message.as_bytes().to_vec(),
4100 };
4101 repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
4102 GitObjectType::Commit,
4103 commit.write(),
4104 ))
4105 .expect("write test commit")
4106 }
4107
4108 fn seed_commit(repo: &SleyRepository, message: &str) -> ObjectId {
4109 test_commit(repo, message, &[])
4110 }
4111
4112 #[test]
4119 fn clone_url_to_bare_via_sley_honours_remote_head_symref() {
4120 let tmp = tempfile::TempDir::new().unwrap();
4121 let source = tmp.path().join("source.git");
4122 let dest = tmp.path().join("dest.git");
4123
4124 let src = SleyRepository::init_bare(&source).expect("init bare source");
4131 let seed = seed_commit(&src, "seed");
4132 for name in ["refs/heads/trunk", "refs/heads/abc-feature"] {
4133 set_reference(&src, name, seed, RefPrecondition::Any, "test: seed branch")
4134 .expect("set ref");
4135 }
4136 std::fs::write(source.join("HEAD"), b"ref: refs/heads/trunk\n").unwrap();
4139
4140 let url = format!("file://{}", source.display());
4141 clone_url_to_bare(&url, &dest, None, None).expect("clone url to bare");
4142
4143 let dest_head = std::fs::read_to_string(dest.join("HEAD")).expect("read dest HEAD");
4144 assert_eq!(
4145 dest_head.trim(),
4146 "ref: refs/heads/trunk",
4147 "dest HEAD must mirror the remote's symref (trunk), not sley's \
4148 init-time default and not the alphabetically-first branch \
4149 (abc-feature) — see heddle#141"
4150 );
4151 }
4152}