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