1use std::{
5 collections::{BTreeSet, HashMap, HashSet},
6 fs::{self, OpenOptions},
7 io::Write,
8 path::{Path, PathBuf},
9 time::{SystemTime, UNIX_EPOCH},
10};
11
12use objects::{
13 error::HeddleError,
14 object::{ContentHash, FileMode, Principal, StateId, StateIdParseError, ThreadName, Tree},
15 store::ObjectStore,
16};
17use refs::Head;
18use repo::{
19 AudienceTier, GitCheckpointIntent, GitCheckpointIntentPhase, GitRefName,
20 Repository as HeddleRepository,
21};
22pub use repo::{
23 GitRefContentNamespace as RefNamespace, GitRefKind, ParsedGitRef,
24 REMOTE_NAME_FOR_LOCAL_GIT_REPO, is_reserved_git_remote_name,
25};
26use sley::{
27 BString as GitBString, DeleteRef, FullName, GitObjectType, GitTime, HeadUpdateOptions, Index,
28 IndexEntry, IndexWriteOptions, ObjectFormat, ObjectId, RefPrecondition, ReferenceTarget,
29 Repository as SleyRepository, Signature,
30 plumbing::sley_core::ByteString as GitByteString,
31 remote::{
32 CredentialProvider, FetchOptions, LsRemoteFilter, NoCredentials, ProgressSink,
33 PushActionPlan, PushCommand, PushOptions, SilentProgress,
34 },
35};
36
37use super::{
38 credential::EmbeddingSafeCredentialProvider,
39 git_export::{
40 ExportStateOptions, commit_is_byte_faithful, export_all, export_current_thread,
41 export_state,
42 },
43 git_ingest::import_git_history,
44 git_notes,
45 git_reconstruct::{commit_object_id, reconstruct_commit_bytes, write_commit_object},
46 git_residual::{ResidualStore, resolve_lossy_object},
47 git_util::ImportStats,
48};
49
50#[derive(Debug, thiserror::Error)]
52pub enum GitProjectionError {
53 #[error("git error: {0}")]
54 Git(String),
55
56 #[error("store error: {0}")]
57 Store(#[from] HeddleError),
58
59 #[error("io error: {0}")]
60 Io(#[from] std::io::Error),
61
62 #[error("invalid trailer format: {0}")]
63 InvalidTrailer(String),
64
65 #[error("missing required trailer: {0}")]
66 MissingTrailer(String),
67
68 #[error("invalid mapping: {0}")]
69 InvalidMapping(String),
70
71 #[error("commit not found: {0}")]
72 CommitNotFound(String),
73
74 #[error("state not found: {0}")]
75 StateNotFound(StateId),
76
77 #[error("git repository not initialized")]
78 GitRepoNotInitialized,
79
80 #[error(
81 "shallow Git repository at {repository} cannot be imported until full ancestry is available"
82 )]
83 ShallowClone {
84 repository: PathBuf,
85 retry_command: String,
86 },
87
88 #[error("conflict during sync: {0}")]
89 Conflict(String),
90
91 #[error("Git Projection Mapping conflict: {message}")]
92 MappingConflict { message: String },
93
94 #[error("Git branch '{branch}' cannot be imported as a Heddle thread: {message}")]
95 InvalidThreadName { branch: String, message: String },
96
97 #[error(
98 "Git branch {branch} and Heddle thread {thread} diverged: thread {thread_change}, branch {branch_change}"
99 )]
100 GitHeddleThreadDiverged {
101 thread: String,
102 branch: String,
103 thread_change: StateId,
104 branch_change: StateId,
105 },
106
107 #[error(
108 "ref update would rewrite {name}: {old} -> {new}; refusing to replace a user-visible Git commit with a Heddle export commit"
109 )]
110 NonFastForwardRef {
111 name: String,
112 old: ObjectId,
113 new: ObjectId,
114 remote_destination: bool,
117 },
118
119 #[error(
120 "remote branch {upstream} does not fast-forward the local Git checkpoint for {branch}: local {local}, remote {remote}"
121 )]
122 RemoteDiverged {
123 branch: String,
124 upstream: String,
125 local: ObjectId,
126 remote: ObjectId,
127 },
128
129 #[error("change id parse error: {0}")]
130 StateIdParse(#[from] StateIdParseError),
131}
132
133pub type GitProjectionResult<T> = std::result::Result<T, GitProjectionError>;
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct RefUpdate {
138 pub name: String,
139 pub target: ObjectId,
140 pub namespace: RefNamespace,
141}
142
143fn reject_reserved_git_remote_name(remote: &str) -> GitProjectionResult<()> {
150 if is_reserved_git_remote_name(remote) {
151 return Err(GitProjectionError::Git(format!(
152 "a Git remote named '{remote}' collides with heddle's reserved namespace \
153 (local refs are recorded under the '{REMOTE_NAME_FOR_LOCAL_GIT_REPO}' sentinel); \
154 configure it under another name with `heddle remote add origin <url>`, \
155 remove the reserved entry with `heddle remote remove {remote}`, and retry"
156 )));
157 }
158 Ok(())
159}
160
161fn remote_name_from_remote_ref(ref_name: &str) -> Option<&str> {
162 GitRefName::new(ref_name).remote_name()
163}
164
165fn validate_refspec_ref(ref_name: &str) -> GitProjectionResult<()> {
166 if let Some(remote) = remote_name_from_remote_ref(ref_name) {
167 reject_reserved_git_remote_name(remote)?;
168 }
169 Ok(())
170}
171
172pub fn parse_git_ref(ref_name: &str) -> Option<ParsedGitRef<'_>> {
180 RefSpec::new(None, ref_name, false).ok()?;
181 GitRefName::new(ref_name).git_projection_ref()
182}
183
184mod refspec {
187 use super::{GitProjectionResult, validate_refspec_ref};
188
189 #[derive(Debug, Clone, PartialEq, Eq)]
190 pub struct RefSpec {
191 forced: bool,
192 source: Option<String>,
194 destination: String,
195 }
196
197 impl RefSpec {
198 pub fn new(
200 source: Option<String>,
201 destination: impl Into<String>,
202 forced: bool,
203 ) -> GitProjectionResult<Self> {
204 let destination = destination.into();
205 if source.is_none() && destination.is_empty() {
206 return Err(super::GitProjectionError::InvalidMapping(
207 "refspec source and destination cannot both be empty".to_string(),
208 ));
209 }
210 if let Some(source) = source.as_deref() {
211 validate_refspec_ref(source)?;
212 }
213 validate_refspec_ref(&destination)?;
214 Ok(Self {
215 forced,
216 source,
217 destination,
218 })
219 }
220
221 pub fn forced(
223 source: impl Into<String>,
224 destination: impl Into<String>,
225 ) -> GitProjectionResult<Self> {
226 Self::new(Some(source.into()), destination, true)
227 }
228
229 pub fn delete(destination: impl Into<String>) -> GitProjectionResult<Self> {
232 Self::new(None, destination, false)
233 }
234
235 pub fn to_git_format(&self) -> String {
237 format!(
238 "{}{}",
239 if self.forced { "+" } else { "" },
240 self.to_git_format_not_forced()
241 )
242 }
243
244 pub fn to_git_format_not_forced(&self) -> String {
246 format!(
247 "{}:{}",
248 self.source.as_deref().unwrap_or(""),
249 self.destination
250 )
251 }
252 }
253}
254
255pub use refspec::RefSpec;
256
257mod negative_refspec {
260 use super::{GitProjectionError, GitProjectionResult, validate_refspec_ref};
261
262 #[derive(Debug, Clone, PartialEq, Eq)]
263 pub struct NegativeRefSpec {
264 source: String,
265 }
266
267 impl NegativeRefSpec {
268 pub fn new(source: impl Into<String>) -> GitProjectionResult<Self> {
271 let source = source.into();
272 validate_refspec_ref(&source)?;
273 if source.contains('*') {
274 return Err(GitProjectionError::InvalidMapping(format!(
275 "invalid negative refspec source '{source}': Negative glob patterns are not supported"
276 )));
277 }
278 Ok(Self { source })
279 }
280
281 pub fn to_git_format(&self) -> String {
283 format!("^{}", self.source)
284 }
285 }
286}
287
288pub use negative_refspec::NegativeRefSpec;
292
293fn heddle_mirror_fetch_refspecs() -> GitProjectionResult<[String; 2]> {
297 Ok([
298 RefSpec::forced("refs/heads/*", "refs/heads/*")?.to_git_format(),
299 RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format(),
300 ])
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum GitPushScope {
305 CurrentThread,
306 AllThreads,
307}
308
309#[derive(Debug, Clone, Default)]
310pub struct GitPullOutcome {
311 pub changed: bool,
312 pub states_created: usize,
313 pub commits_seen: usize,
314 pub materialized_checkout: bool,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318enum PullPreflight {
319 UpToDate,
320 ImportRequired,
321}
322
323fn pull_outcome(stats: &ImportStats, materialized_checkout: bool) -> GitPullOutcome {
324 GitPullOutcome {
325 changed: materialized_checkout || stats.states_created > 0,
326 states_created: stats.states_created,
327 commits_seen: stats.commits_imported,
328 materialized_checkout,
329 }
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333enum GitFetchScope {
334 BranchesAndNotes,
335 AllRefs,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339enum RefreshCheckoutAfterFetch {
340 Yes,
341 No,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345enum RemoteDirection {
346 Fetch,
347 Push,
348}
349
350#[derive(Debug, Clone)]
351enum ResolvedRemote {
352 Local(PathBuf),
353 Url(String),
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum WriteThroughSkipReason {
358 MissingDotGit,
359 DetachedHead,
360 NoAttachedThread,
361 NoMappedCommit,
362 MirrorIsWorktree,
363 IndexAlreadyDirty,
364}
365
366impl std::fmt::Display for WriteThroughSkipReason {
367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368 match self {
369 WriteThroughSkipReason::MissingDotGit => {
370 write!(f, "this checkout does not have a Git working tree")
371 }
372 WriteThroughSkipReason::DetachedHead => {
373 write!(f, "Git HEAD is detached")
374 }
375 WriteThroughSkipReason::NoAttachedThread => {
376 write!(f, "the attached Heddle thread does not resolve to a state")
377 }
378 WriteThroughSkipReason::NoMappedCommit => {
379 write!(f, "the current Heddle state has not been exported to Git")
380 }
381 WriteThroughSkipReason::MirrorIsWorktree => {
382 write!(f, "the legacy Bridge Mirror target is the checkout itself")
383 }
384 WriteThroughSkipReason::IndexAlreadyDirty => {
385 write!(f, "the Git index is already locked by another operation")
386 }
387 }
388 }
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum WriteThroughOutcome {
393 Wrote(ObjectId),
394 Skipped(WriteThroughSkipReason),
395}
396
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct LocalGitIdentity {
399 pub name: String,
400 pub email: String,
401}
402
403impl LocalGitIdentity {
404 pub fn from_principal(principal: &Principal) -> Self {
405 Self {
406 name: principal.name.clone(),
407 email: principal.email.clone(),
408 }
409 }
410
411 pub fn to_ident_line(&self, seconds: i64) -> Vec<u8> {
412 format!("{} <{}> {} +0000", self.name, self.email, seconds).into_bytes()
413 }
414
415 pub fn to_signature(&self, seconds: i64) -> Signature {
416 let ident = self.to_ident_line(seconds);
417 Signature {
418 name: GitByteString::new(self.name.as_bytes().to_vec()),
419 email: GitByteString::new(self.email.as_bytes().to_vec()),
420 time: GitTime::new(seconds, 0),
421 raw: ident,
422 }
423 }
424}
425
426impl WriteThroughOutcome {
427 pub fn object_id(self) -> Option<ObjectId> {
428 match self {
429 WriteThroughOutcome::Wrote(oid) => Some(oid),
430 WriteThroughOutcome::Skipped(_) => None,
431 }
432 }
433
434 pub fn skip_reason(self) -> Option<WriteThroughSkipReason> {
435 match self {
436 WriteThroughOutcome::Skipped(reason) => Some(reason),
437 WriteThroughOutcome::Wrote(_) => None,
438 }
439 }
440}
441
442#[derive(Debug, Clone, Default, PartialEq, Eq)]
444pub struct SyncMapping {
445 heddle_to_git: HashMap<StateId, ObjectId>,
447 git_to_heddle: HashMap<ObjectId, StateId>,
449}
450
451impl SyncMapping {
452 pub fn new() -> Self {
454 Self::default()
455 }
456
457 pub fn insert(&mut self, state_id: StateId, git_oid: ObjectId) {
459 if let Some(previous_git) = self.heddle_to_git.remove(&state_id) {
460 self.git_to_heddle.remove(&previous_git);
461 }
462 if let Some(previous_change) = self.git_to_heddle.remove(&git_oid) {
463 self.heddle_to_git.remove(&previous_change);
464 }
465 self.heddle_to_git.insert(state_id, git_oid);
466 self.git_to_heddle.insert(git_oid, state_id);
467 }
468
469 pub fn insert_checked(
471 &mut self,
472 state_id: StateId,
473 git_oid: ObjectId,
474 ) -> GitProjectionResult<()> {
475 if let Some(existing) = self.heddle_to_git.get(&state_id)
476 && *existing != git_oid
477 {
478 return Err(GitProjectionError::MappingConflict {
479 message: format!(
480 "change id {} mapped to {} (new {})",
481 state_id, existing, git_oid
482 ),
483 });
484 }
485
486 if let Some(existing) = self.git_to_heddle.get(&git_oid)
487 && *existing != state_id
488 {
489 return Err(GitProjectionError::MappingConflict {
490 message: format!(
491 "git oid {} mapped to {} (new {})",
492 git_oid, existing, state_id
493 ),
494 });
495 }
496
497 self.insert(state_id, git_oid);
498 Ok(())
499 }
500
501 pub fn get_git(&self, state_id: &StateId) -> Option<ObjectId> {
503 self.heddle_to_git.get(state_id).copied()
504 }
505
506 pub fn get_heddle(&self, git_oid: ObjectId) -> Option<StateId> {
508 self.git_to_heddle.get(&git_oid).copied()
509 }
510
511 pub fn has_heddle(&self, state_id: &StateId) -> bool {
513 self.heddle_to_git.contains_key(state_id)
514 }
515
516 pub fn remove(&mut self, state_id: &StateId) -> Option<ObjectId> {
526 let git_oid = self.heddle_to_git.remove(state_id)?;
527 self.git_to_heddle.remove(&git_oid);
528 Some(git_oid)
529 }
530
531 pub fn has_git(&self, git_oid: ObjectId) -> bool {
533 self.git_to_heddle.contains_key(&git_oid)
534 }
535
536 pub fn iter(&self) -> impl Iterator<Item = (&StateId, &ObjectId)> {
538 self.heddle_to_git.iter()
539 }
540
541 pub fn is_empty(&self) -> bool {
546 self.heddle_to_git.is_empty()
547 }
548
549 pub fn retain_git_objects(&mut self, repo: &SleyRepository) {
550 let retained: Vec<(StateId, ObjectId)> = self
551 .heddle_to_git
552 .iter()
553 .filter_map(|(state_id, git_oid)| {
554 repo.read_object(git_oid)
555 .ok()
556 .map(|_| (*state_id, *git_oid))
557 })
558 .collect();
559
560 self.heddle_to_git.clear();
561 self.git_to_heddle.clear();
562 for (state_id, git_oid) in retained {
563 self.insert(state_id, git_oid);
564 }
565 }
566
567 #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
568 pub fn retain_git_object_set(&mut self, reachable: &HashSet<ObjectId>) -> usize {
569 let before = self.heddle_to_git.len();
570 let retained: Vec<(StateId, ObjectId)> = self
571 .heddle_to_git
572 .iter()
573 .filter(|(_, git_oid)| reachable.contains(*git_oid))
574 .map(|(state_id, git_oid)| (*state_id, *git_oid))
575 .collect();
576
577 self.heddle_to_git.clear();
578 self.git_to_heddle.clear();
579 for (state_id, git_oid) in retained {
580 self.insert(state_id, git_oid);
581 }
582 before.saturating_sub(self.heddle_to_git.len())
583 }
584}
585
586pub struct GitProjection<'a> {
588 pub heddle_repo: &'a HeddleRepository,
589 pub git_repo_path: Option<PathBuf>,
590 pub mapping: SyncMapping,
591 pub commit_message_overrides: HashMap<StateId, String>,
592 pub commit_parent_overrides: HashMap<StateId, Vec<ObjectId>>,
593 linearize_unmapped_tip_to_checkout: bool,
594}
595
596struct MappingFileSnapshot {
597 path: PathBuf,
598 contents: Option<Vec<u8>>,
599}
600
601impl MappingFileSnapshot {
602 fn read(path: PathBuf) -> GitProjectionResult<Self> {
603 let contents = match fs::read(&path) {
604 Ok(contents) => Some(contents),
605 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
606 Err(error) => return Err(error.into()),
607 };
608 Ok(Self { path, contents })
609 }
610
611 fn restore(self) -> GitProjectionResult<()> {
612 match self.contents {
613 Some(contents) => {
614 if let Some(parent) = self.path.parent() {
615 fs::create_dir_all(parent)?;
616 }
617 fs::write(&self.path, contents)?;
618 }
619 None => match fs::remove_file(&self.path) {
620 Ok(()) => {}
621 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
622 Err(error) => return Err(error.into()),
623 },
624 }
625 Ok(())
626 }
627}
628
629struct CheckoutWrite {
630 checkout_repo: SleyRepository,
631 object_repo: SleyRepository,
632 git_dir: PathBuf,
633 branch_ref: String,
634 head_path: PathBuf,
635 index_path: PathBuf,
636 previous_head: Option<Vec<u8>>,
637 previous_index: Option<Vec<u8>>,
638 previous_branch: Option<ObjectId>,
639}
640
641impl CheckoutWrite {
642 fn prepare(root: &Path, thread: &str) -> GitProjectionResult<Self> {
643 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
644 let git_dir = checkout_repo.git_dir().to_path_buf();
645 if git_dir.join("index.lock").exists() {
646 return Err(GitProjectionError::Git(
647 "Git index is locked; checkpoint write-through cannot proceed".to_string(),
648 ));
649 }
650 let object_repo = common_repo_for_worktree(&checkout_repo)?;
651 let branch_ref = format!("refs/heads/{thread}");
652 let head_path = git_dir.join("HEAD");
653 let index_path = git_dir.join("index");
654 let previous_head = read_optional_file(&head_path)?;
655 let previous_index = read_optional_file(&index_path)?;
656 let previous_branch = match object_repo.find_reference(&branch_ref).map_err(git_err)? {
657 Some(reference) => reference.peeled_oid(&object_repo).map_err(git_err)?,
658 None => None,
659 };
660 Ok(Self {
661 checkout_repo,
662 object_repo,
663 git_dir,
664 branch_ref,
665 head_path,
666 index_path,
667 previous_head,
668 previous_index,
669 previous_branch,
670 })
671 }
672
673 fn excluded_objects(&self) -> GitProjectionResult<HashSet<ObjectId>> {
674 match self.previous_branch {
675 Some(parent) => sley::plumbing::sley_odb::collect_reachable_object_ids(
676 self.object_repo.objects().as_ref(),
677 self.object_repo.object_format(),
678 [parent],
679 )
680 .map_err(git_err),
681 None => Ok(HashSet::new()),
682 }
683 }
684
685 fn publish(&self, git_oid: ObjectId) -> GitProjectionResult<()> {
686 let published_head = format!("ref: {}\n", self.branch_ref).into_bytes();
687 let mut head_written = false;
688 let mut index_written = false;
689 let mut branch_update_attempted = false;
690 let mut published_index = None;
691 let result = (|| -> GitProjectionResult<()> {
692 write_head_symref(&self.git_dir, &self.branch_ref)?;
693 head_written = true;
694 let commit = self.object_repo.read_commit(&git_oid).map_err(git_err)?;
695 let mut index = self
696 .object_repo
697 .index_from_tree(&commit.tree)
698 .map_err(git_err)?;
699 index.upgrade_version_for_flags();
700 published_index = Some(
701 index
702 .write(self.checkout_repo.object_format())
703 .map_err(git_err)?,
704 );
705 self.checkout_repo
706 .write_index(
707 &index,
708 IndexWriteOptions {
709 fsync: true,
710 validate_checksum: true,
711 },
712 )
713 .map_err(git_err)?;
714 index_written = true;
715 branch_update_attempted = true;
716 let head_reflog = update_checkout_branch_ref(
717 &self.checkout_repo,
718 &self.branch_ref,
719 git_oid,
720 self.previous_branch,
721 "heddle: write-through current thread",
722 )?;
723 self.checkout_repo
724 .references()
725 .append_reflog("HEAD", &head_reflog)
726 .map_err(git_err)?;
727 fsync_path(&self.head_path)?;
728 fsync_path(&self.index_path)?;
729 fsync_path(&self.git_dir)?;
730 Ok(())
731 })();
732
733 if let Err(error) = result {
734 if branch_update_attempted {
735 rollback_reference_if_unchanged(
736 &self.object_repo,
737 &self.branch_ref,
738 git_oid,
739 self.previous_branch,
740 )?;
741 }
742 if index_written {
743 restore_file_if_unchanged(
744 &self.index_path,
745 published_index.as_deref().expect("written index bytes"),
746 self.previous_index.as_deref(),
747 )?;
748 }
749 if head_written {
750 restore_file_if_unchanged(
751 &self.head_path,
752 &published_head,
753 self.previous_head.as_deref(),
754 )?;
755 }
756 let _ = fsync_path(&self.head_path);
757 let _ = fsync_path(&self.index_path);
758 let _ = fsync_path(&self.git_dir);
759 return Err(error);
760 }
761 Ok(())
762 }
763}
764
765impl<'a> GitProjection<'a> {
766 pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
768 Self {
769 heddle_repo,
770 git_repo_path: None,
771 mapping: SyncMapping::new(),
772 commit_message_overrides: HashMap::new(),
773 commit_parent_overrides: HashMap::new(),
774 linearize_unmapped_tip_to_checkout: false,
775 }
776 }
777
778 pub fn init_mirror(&mut self) -> GitProjectionResult<()> {
780 let _guard = self.init_mirror_with_guard()?;
781 _guard.commit();
782 Ok(())
783 }
784
785 pub fn init_mirror_with_guard(&mut self) -> GitProjectionResult<MirrorInitGuard> {
790 let git_dir = self.heddle_repo.heddle_dir().join("git");
791
792 let did_create = if git_dir.exists() {
793 let _ = open_repo(&git_dir)?;
794 false
795 } else {
796 fs::create_dir_all(&git_dir)?;
797 let _ = SleyRepository::init_bare(&git_dir).map_err(git_err)?;
798 let mirror_repo = open_repo(&git_dir)?;
799 seed_checkout_note_refs_into_mirror(self.heddle_repo.root(), &mirror_repo)?;
800 true
801 };
802
803 self.git_repo_path = Some(git_dir.clone());
804 Ok(MirrorInitGuard::new_from_init(git_dir, did_create))
805 }
806
807 pub fn mirror_path(&self) -> PathBuf {
809 self.heddle_repo.heddle_dir().join("git")
810 }
811
812 pub fn is_initialized(&self) -> bool {
814 self.mirror_path().exists()
815 }
816
817 pub fn open_git_repo(&self) -> GitProjectionResult<SleyRepository> {
819 if let Some(ref path) = self.git_repo_path {
820 open_repo(path)
821 } else {
822 let mirror_path = self.mirror_path();
823 if mirror_path.exists() {
824 open_repo(&mirror_path)
825 } else {
826 open_repo(self.heddle_repo.root())
827 }
828 }
829 }
830
831 pub fn sort_states_topologically(
833 &self,
834 states: &[StateId],
835 ) -> GitProjectionResult<Vec<StateId>> {
836 let mut sorted = Vec::new();
837 let mut visited: std::collections::HashSet<StateId> = std::collections::HashSet::new();
838
839 fn visit<S: ObjectStore + ?Sized>(
840 state_id: &StateId,
841 store: &S,
842 visited: &mut std::collections::HashSet<StateId>,
843 sorted: &mut Vec<StateId>,
844 ) -> GitProjectionResult<()> {
845 if visited.contains(state_id) {
846 return Ok(());
847 }
848
849 if let Some(state) = store.get_state(state_id)? {
850 for parent in &state.parents {
851 visit(parent, store, visited, sorted)?;
852 }
853 }
854
855 visited.insert(*state_id);
856 sorted.push(*state_id);
857
858 Ok(())
859 }
860
861 for state_id in states {
862 visit(
863 state_id,
864 self.heddle_repo.store(),
865 &mut visited,
866 &mut sorted,
867 )?;
868 }
869
870 Ok(sorted)
871 }
872
873 pub fn export(&mut self) -> GitProjectionResult<super::git_util::ExportStats> {
875 export_all(self)
876 }
877
878 pub fn set_commit_message_override(&mut self, state_id: StateId, message: String) {
879 self.commit_message_overrides.insert(state_id, message);
880 }
881
882 pub fn set_commit_parent_override(&mut self, state_id: StateId, parents: Vec<ObjectId>) {
883 self.commit_parent_overrides.insert(state_id, parents);
884 }
885
886 pub fn linearize_unmapped_tip_to_checkout(&mut self) {
890 self.linearize_unmapped_tip_to_checkout = true;
891 }
892
893 pub fn with_mapping_rollback<T>(
894 &mut self,
895 operation: impl FnOnce(&mut Self) -> GitProjectionResult<T>,
896 ) -> GitProjectionResult<T> {
897 let mapping = self.mapping.clone();
898 let commit_message_overrides = self.commit_message_overrides.clone();
899 let commit_parent_overrides = self.commit_parent_overrides.clone();
900 let mapping_file = MappingFileSnapshot::read(self.mapping_path())?;
901 let mapping_tmp_file = MappingFileSnapshot::read(self.mapping_tmp_path())?;
902
903 match operation(self) {
904 Ok(value) => Ok(value),
905 Err(error) => {
906 self.mapping = mapping;
907 self.commit_message_overrides = commit_message_overrides;
908 self.commit_parent_overrides = commit_parent_overrides;
909 if let Err(rollback_error) = mapping_file
910 .restore()
911 .and_then(|()| mapping_tmp_file.restore())
912 {
913 return Err(GitProjectionError::Git(format!(
914 "operation failed ({error}); additionally failed to roll back Git Projection Mapping state ({rollback_error})"
915 )));
916 }
917 Err(error)
918 }
919 }
920 }
921
922 pub fn push(&mut self, remote_name: &str) -> GitProjectionResult<Vec<String>> {
925 self.push_with_scope(remote_name, GitPushScope::AllThreads)
926 }
927
928 pub fn push_with_scope(
931 &mut self,
932 remote_name: &str,
933 scope: GitPushScope,
934 ) -> GitProjectionResult<Vec<String>> {
935 self.push_with_scope_force(remote_name, scope, false)
936 }
937
938 pub fn push_with_scope_force(
947 &mut self,
948 remote_name: &str,
949 scope: GitPushScope,
950 force: bool,
951 ) -> GitProjectionResult<Vec<String>> {
952 self.init_mirror()?;
953 let current_branch = match scope {
954 GitPushScope::CurrentThread => Some(self.current_attached_thread_for_push()?),
955 GitPushScope::AllThreads => None,
956 };
957 match scope {
958 GitPushScope::CurrentThread => {
959 export_current_thread(self, current_branch.as_deref().expect("current branch"))?;
960 }
961 GitPushScope::AllThreads => {
962 self.export()?;
963 self.mirror_checkout_tags_for_push()?;
964 }
965 }
966 self.write_current_checkout_from_existing_mirror()?;
967
968 let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
976 match self.resolve_remote(remote_name, RemoteDirection::Push)? {
977 ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
978 &target_path,
979 &log_message,
980 false,
981 scope,
982 current_branch.as_deref(),
983 force,
984 ),
985 ResolvedRemote::Url(url) => {
986 let mirror_repo = self.open_git_repo()?;
987 push_network_remote(
988 &mirror_repo,
989 self.heddle_repo.heddle_dir(),
990 &url,
991 scope,
992 current_branch.as_deref(),
993 force,
994 )
995 }
996 }
997 }
998
999 fn current_attached_thread_for_push(&self) -> GitProjectionResult<String> {
1000 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1001 return Err(GitProjectionError::Git(
1002 "cannot push the current Git-overlay branch from a detached Heddle HEAD; use --all-threads to push all exported refs".to_string(),
1003 ));
1004 };
1005 if self.heddle_repo.refs().get_thread(&thread)?.is_none() {
1006 return Err(GitProjectionError::Git(format!(
1007 "attached thread '{thread}' has no state to push"
1008 )));
1009 }
1010 Ok(thread.to_string())
1011 }
1012
1013 pub fn export_to_path(
1017 &mut self,
1018 target_path: &Path,
1019 ) -> GitProjectionResult<super::git_util::ExportStats> {
1020 self.init_mirror()?;
1021 let stats = self.export()?;
1022 self.copy_mirror_to_path(
1023 target_path,
1024 &format!("heddle: export from {}", self.heddle_repo.root().display()),
1025 true,
1026 GitPushScope::AllThreads,
1027 None,
1028 false,
1029 )?;
1030 Ok(stats)
1031 }
1032
1033 fn copy_mirror_to_path(
1042 &mut self,
1043 target_path: &Path,
1044 log_message: &str,
1045 init_if_missing: bool,
1046 scope: GitPushScope,
1047 current_branch: Option<&str>,
1048 force: bool,
1049 ) -> GitProjectionResult<Vec<String>> {
1050 let mirror_repo = self.open_git_repo()?;
1051 let target_repo = if target_path.exists() {
1052 open_repo(target_path)?
1053 } else if init_if_missing {
1054 fs::create_dir_all(target_path)?;
1055 SleyRepository::init_bare(target_path).map_err(git_err)?;
1056 open_repo(target_path)?
1057 } else {
1058 return Err(GitProjectionError::Git(format!(
1059 "destination '{}' does not exist",
1060 target_path.display()
1061 )));
1062 };
1063
1064 let managed_record = read_mirror_managed_refs(&mirror_repo)?;
1078 let served_frontier = collect_managed_ref_updates(&mirror_repo, &managed_record)?;
1079 copy_reachable_objects(
1080 &mirror_repo,
1081 &target_repo,
1082 served_frontier.iter().map(|update| update.target),
1083 )?;
1084
1085 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
1093 let old_at_destination = read_destination_ref_map(&target_repo)?;
1094 let previously_exported = read_exported_refs(&target_repo)?;
1095 let plan = plan_destination_reconcile(
1096 &mirror_repo,
1097 &served_frontier,
1098 creatable.as_ref(),
1099 &old_at_destination,
1100 &previously_exported,
1101 force,
1102 )?;
1103 for write in &plan.writes {
1104 let constraint = match write.old {
1105 Some(old) => RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(old)),
1106 None => RefPrecondition::MustNotExist,
1107 };
1108 set_reference(
1109 &target_repo,
1110 &write.full_name,
1111 write.new,
1112 constraint,
1113 log_message,
1114 )?;
1115 }
1116 for delete in &plan.deletes {
1117 delete_reference_matching(&target_repo, &delete.full_name, delete.old)?;
1118 }
1119 write_exported_refs(&target_repo, &plan.new_manifest)?;
1120 Ok(planned_write_names(&plan))
1121 }
1122
1123 pub fn fetch(&mut self, remote_name: &str) -> GitProjectionResult<()> {
1126 self.fetch_with_scope(
1127 remote_name,
1128 GitFetchScope::BranchesAndNotes,
1129 RefreshCheckoutAfterFetch::Yes,
1130 )
1131 }
1132
1133 fn fetch_with_scope(
1134 &mut self,
1135 remote_name: &str,
1136 scope: GitFetchScope,
1137 refresh_checkout: RefreshCheckoutAfterFetch,
1138 ) -> GitProjectionResult<()> {
1139 reject_reserved_git_remote_name(remote_name)?;
1140 self.init_mirror()?;
1141 let current_branch = self.heddle_repo.git_overlay_current_branch()?;
1142 let tracking_remote = checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1143 .or_else(|| {
1144 (!looks_like_remote_location(remote_name)).then(|| remote_name.to_string())
1145 });
1146 if let Some(tracking_remote) = tracking_remote.as_deref() {
1150 reject_reserved_git_remote_name(tracking_remote)?;
1151 }
1152
1153 let mirror_repo = self.open_git_repo()?;
1154 match self.resolve_remote(remote_name, RemoteDirection::Fetch)? {
1155 ResolvedRemote::Local(path) => {
1156 let remote_repo = open_repo(&path)?;
1157 let updates = collect_ref_updates_for_fetch(&remote_repo, scope)?;
1158 tracing::debug!(
1159 remote = remote_name,
1160 path = %path.display(),
1161 refs = updates.len(),
1162 notes = updates
1163 .iter()
1164 .filter(|update| update.namespace == RefNamespace::Note)
1165 .count(),
1166 "fetching Git refs from local remote"
1167 );
1168 copy_reachable_objects(
1169 &remote_repo,
1170 &mirror_repo,
1171 updates.iter().map(|update| update.target),
1172 )?;
1173 apply_ref_updates(
1174 &mirror_repo,
1175 &updates,
1176 &format!("heddle: fetch from {remote_name}"),
1177 )?;
1178 if let Some(tracking_remote) = tracking_remote.as_deref() {
1179 apply_remote_tracking_ref_updates(
1180 &mirror_repo,
1181 tracking_remote,
1182 &updates,
1183 &format!("heddle: fetch from {remote_name}"),
1184 )?;
1185 }
1186 }
1187 ResolvedRemote::Url(url) => {
1188 fetch_network_remote(&mirror_repo, remote_name, &url, scope)?;
1189 let updates = collect_ref_updates_for_fetch(&mirror_repo, scope)?;
1190 if let Some(tracking_remote) = tracking_remote.as_deref() {
1191 apply_remote_tracking_ref_updates(
1192 &mirror_repo,
1193 tracking_remote,
1194 &updates,
1195 &format!("heddle: fetch from {remote_name}"),
1196 )?;
1197 }
1198 }
1199 }
1200
1201 self.git_repo_path = Some(self.mirror_path());
1202 if matches!(refresh_checkout, RefreshCheckoutAfterFetch::Yes) {
1203 if let Some(tracking_remote) = tracking_remote.as_deref() {
1204 self.refresh_checkout_remote_tracking_refs(tracking_remote)?;
1205 }
1206 if let Some(branch) = current_branch {
1207 self.refresh_checkout_remote_tracking_ref(remote_name, &branch)?;
1208 }
1209 self.refresh_checkout_note_refs_from_mirror()?;
1210 }
1211 Ok(())
1212 }
1213
1214 pub fn hydrate_checkout_heddle_notes_without_mirror(root: &Path) -> bool {
1222 if checkout_note_ref_exists(root).unwrap_or(false) {
1223 return true;
1224 }
1225
1226 let mut remotes = match checkout_remote_url_items(root) {
1227 Ok(remotes) => remotes
1228 .into_iter()
1229 .map(|(name, _)| name)
1230 .collect::<Vec<_>>(),
1231 Err(error) => {
1232 tracing::debug!(
1233 error = %error,
1234 "skipping configured remote note hydration before ingest-backed adopt"
1235 );
1236 return false;
1237 }
1238 };
1239 remotes.sort_by(|left, right| {
1240 match (left.as_str() == "origin", right.as_str() == "origin") {
1241 (true, false) => std::cmp::Ordering::Less,
1242 (false, true) => std::cmp::Ordering::Greater,
1243 _ => left.cmp(right),
1244 }
1245 });
1246 remotes.dedup();
1247
1248 for remote in remotes {
1249 match hydrate_checkout_notes_from_remote_without_mirror(root, &remote) {
1250 Ok(()) if checkout_note_ref_exists(root).unwrap_or(false) => return true,
1251 Ok(()) => {}
1252 Err(error) => {
1253 tracing::debug!(
1254 remote = remote.as_str(),
1255 error = %error,
1256 "configured remote did not provide Heddle notes during ingest-backed adopt"
1257 );
1258 }
1259 }
1260 }
1261
1262 false
1263 }
1264
1265 pub fn pull(&mut self, remote_name: &str) -> GitProjectionResult<GitPullOutcome> {
1267 let head_before = self.heddle_repo.refs().read_head()?;
1268 let attached_before = match &head_before {
1269 Head::Attached { thread } => self
1270 .heddle_repo
1271 .refs()
1272 .get_thread(thread)?
1273 .map(|state| (thread.to_string(), state)),
1274 Head::Detached { .. } => None,
1275 };
1276 let attached_thread = attached_before.as_ref().map(|(thread, _)| thread.clone());
1277
1278 self.fetch_with_scope(
1279 remote_name,
1280 GitFetchScope::AllRefs,
1281 RefreshCheckoutAfterFetch::No,
1282 )?;
1283 if self.preflight_attached_pull_fast_forward(remote_name, attached_before.as_ref())?
1284 == PullPreflight::UpToDate
1285 {
1286 if let Some(thread) = attached_thread {
1287 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1288 }
1289 self.refresh_checkout_note_refs_from_mirror()?;
1290 return Ok(GitPullOutcome::default());
1291 }
1292 let mirror_path = self.mirror_path();
1293 let stats = import_git_history(self, Some(&mirror_path), &[], Default::default(), None)?;
1294
1295 let mut materialized_attached_thread = false;
1296 if let Some((thread, old_state)) = attached_before
1297 && let Some(new_state) = self
1298 .heddle_repo
1299 .refs()
1300 .get_thread(&ThreadName::new(&thread))?
1301 && new_state != old_state
1302 {
1303 self.heddle_repo
1304 .refs()
1305 .set_thread(&ThreadName::new(&thread), &old_state)?;
1306 self.heddle_repo.refs().write_head(&Head::Attached {
1307 thread: ThreadName::new(&thread),
1308 })?;
1309 self.heddle_repo
1310 .goto_verified_clean_without_record(&new_state)?;
1311 self.heddle_repo
1312 .refs()
1313 .set_thread(&ThreadName::new(&thread), &new_state)?;
1314 self.heddle_repo.refs().write_head(&Head::Attached {
1315 thread: ThreadName::new(&thread),
1316 })?;
1317 materialized_attached_thread = true;
1318 }
1319
1320 if materialized_attached_thread {
1321 self.write_current_checkout_from_existing_mirror()?;
1322 }
1323 if let Some(thread) = attached_thread {
1324 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1325 }
1326 self.refresh_checkout_note_refs_from_mirror()?;
1327 Ok(pull_outcome(&stats, materialized_attached_thread))
1328 }
1329
1330 fn preflight_attached_pull_fast_forward(
1331 &mut self,
1332 remote_name: &str,
1333 attached_before: Option<&(String, StateId)>,
1334 ) -> GitProjectionResult<PullPreflight> {
1335 let Some((thread, state_id)) = attached_before else {
1336 return Ok(PullPreflight::ImportRequired);
1337 };
1338 self.build_existing_mapping(None)?;
1339 let Some(local_git_oid) = self.mapping.get_git(state_id) else {
1340 return Ok(PullPreflight::ImportRequired);
1341 };
1342 let mirror_repo = self.open_git_repo()?;
1343 let branch_ref = format!("refs/heads/{thread}");
1344 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1345 return Ok(PullPreflight::ImportRequired);
1346 };
1347 let Some(remote_git_oid) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1348 return Ok(PullPreflight::ImportRequired);
1349 };
1350 if remote_git_oid == local_git_oid {
1351 return Ok(PullPreflight::UpToDate);
1352 }
1353 if commit_is_descendant_of(&mirror_repo, remote_git_oid, local_git_oid)? {
1354 return Ok(PullPreflight::ImportRequired);
1355 }
1356 Err(GitProjectionError::RemoteDiverged {
1357 branch: thread.clone(),
1358 upstream: format!("{remote_name}/{thread}"),
1359 local: local_git_oid,
1360 remote: remote_git_oid,
1361 })
1362 }
1363
1364 fn mirror_checkout_tags_for_push(&self) -> GitProjectionResult<()> {
1365 if !self.heddle_repo.root().join(".git").exists() {
1366 return Ok(());
1367 }
1368
1369 let mirror_repo = self.open_git_repo()?;
1370 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1371 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1372 return Ok(());
1373 }
1374 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1375 let tag_updates = collect_ref_updates(&object_repo)?
1376 .into_iter()
1377 .filter(|update| update.namespace == RefNamespace::Tag)
1378 .collect::<Vec<_>>();
1379 if tag_updates.is_empty() {
1380 return Ok(());
1381 }
1382
1383 copy_reachable_objects(
1384 &object_repo,
1385 &mirror_repo,
1386 tag_updates.iter().map(|u| u.target),
1387 )?;
1388 apply_ref_updates(
1389 &mirror_repo,
1390 &tag_updates,
1391 "heddle: mirror checkout tags before push",
1392 )?;
1393 let mut record = read_mirror_managed_refs(&mirror_repo)?;
1401 for update in &tag_updates {
1402 record.insert(full_ref_name(update), update.target);
1403 }
1404 write_mirror_managed_refs(&mirror_repo, &record)?;
1405 Ok(())
1406 }
1407
1408 pub fn seed_git_checkpoint_mappings_from_checkout(
1409 &mut self,
1410 mirror_repo: &SleyRepository,
1411 ) -> GitProjectionResult<()> {
1412 if !self.heddle_repo.root().join(".git").exists() {
1413 return Ok(());
1414 }
1415
1416 let checkout_repo = match SleyRepository::discover(self.heddle_repo.root()) {
1417 Ok(repo) => repo,
1418 Err(_) => return Ok(()),
1419 };
1420 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1421 return Ok(());
1422 }
1423 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1424 for record in self.heddle_repo.list_git_checkpoints()? {
1425 let git_oid = record
1426 .git_commit
1427 .parse::<ObjectId>()
1428 .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?;
1429 if mirror_repo.read_object(&git_oid).is_err() {
1430 copy_reachable_objects(&object_repo, mirror_repo, [git_oid])?;
1431 }
1432 }
1433
1434 self.seed_git_checkpoint_mappings_from_repo(mirror_repo)
1435 }
1436
1437 fn seed_git_checkpoint_mappings_from_repo(
1438 &mut self,
1439 git_repo: &SleyRepository,
1440 ) -> GitProjectionResult<()> {
1441 for record in self.heddle_repo.list_git_checkpoints()? {
1442 let state_id = StateId::parse(&record.state_id)?;
1443 let git_oid = record
1444 .git_commit
1445 .parse::<ObjectId>()
1446 .map_err(|err| GitProjectionError::InvalidMapping(err.to_string()))?;
1447
1448 git_repo
1449 .read_object(&git_oid)
1450 .map_err(|_| GitProjectionError::CommitNotFound(record.git_commit.clone()))?;
1451
1452 self.mapping.insert(state_id, git_oid);
1453 let tier = self
1462 .heddle_repo
1463 .effective_visibility_tier(&state_id)
1464 .map_err(|e| {
1465 GitProjectionError::Git(format!("resolve visibility for {state_id}: {e:#}"))
1466 })?;
1467 if repo::visible(&tier, &repo::AudienceTier::Public)
1468 && super::git_notes::read_note(git_repo, git_oid)?.is_none()
1469 && let Some(state) = self.heddle_repo.store().get_state(&state_id)?
1470 {
1471 let note = super::git_notes::HeddleNote::from_state(&state);
1472 super::git_notes::write_note(git_repo, git_oid, ¬e)?;
1473 }
1474 }
1475
1476 Ok(())
1477 }
1478
1479 pub fn stage_ingest_source_in_mirror(
1480 &mut self,
1481 source: &Path,
1482 refs: &[String],
1483 ) -> GitProjectionResult<()> {
1484 let source_repo = open_repo(source)?;
1485 let updates = collect_import_source_ref_updates(&source_repo, refs)?;
1486 if updates.is_empty() {
1487 return Ok(());
1488 }
1489
1490 self.init_mirror()?;
1491 let mirror_repo = self.open_git_repo()?;
1492 copy_reachable_objects(
1493 &source_repo,
1494 &mirror_repo,
1495 updates.iter().map(|update| update.target),
1496 )?;
1497 apply_ref_updates(
1498 &mirror_repo,
1499 &updates,
1500 &format!("heddle: stage ingest source from {}", source.display()),
1501 )?;
1502
1503 let mut record = read_or_seed_mirror_managed_refs(&mirror_repo)?;
1504 for update in &updates {
1505 record.insert(full_ref_name(update), update.target);
1506 }
1507 write_mirror_managed_refs(&mirror_repo, &record)?;
1508 Ok(())
1509 }
1510
1511 pub fn write_through_current_checkout(&mut self) -> GitProjectionResult<WriteThroughOutcome> {
1516 if !self.heddle_repo.root().join(".git").exists() {
1517 return Ok(WriteThroughOutcome::Skipped(
1518 WriteThroughSkipReason::MissingDotGit,
1519 ));
1520 }
1521 if checkout_git_head_is_detached(self.heddle_repo.root())? {
1522 return Ok(WriteThroughOutcome::Skipped(
1523 WriteThroughSkipReason::DetachedHead,
1524 ));
1525 }
1526 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1527 return Ok(WriteThroughOutcome::Skipped(
1528 WriteThroughSkipReason::DetachedHead,
1529 ));
1530 };
1531 let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1532 return Ok(WriteThroughOutcome::Skipped(
1533 WriteThroughSkipReason::NoAttachedThread,
1534 ));
1535 };
1536 self.write_thread_state_checkout_direct(&thread, &state_id, None)
1537 }
1538
1539 pub fn write_through_current_checkout_with_message(
1540 &mut self,
1541 state_id: StateId,
1542 message: String,
1543 ) -> GitProjectionResult<WriteThroughOutcome> {
1544 if !self.heddle_repo.root().join(".git").exists() {
1545 return Ok(WriteThroughOutcome::Skipped(
1546 WriteThroughSkipReason::MissingDotGit,
1547 ));
1548 }
1549 if checkout_git_head_is_detached(self.heddle_repo.root())? {
1550 return Ok(WriteThroughOutcome::Skipped(
1551 WriteThroughSkipReason::DetachedHead,
1552 ));
1553 }
1554 self.set_commit_message_override(state_id, message);
1555 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1556 return Ok(WriteThroughOutcome::Skipped(
1557 WriteThroughSkipReason::DetachedHead,
1558 ));
1559 };
1560 let summary = self
1561 .commit_message_overrides
1562 .get(&state_id)
1563 .cloned()
1564 .unwrap_or_default();
1565 self.write_thread_state_checkout_direct(&thread, &state_id, Some(&summary))
1566 }
1567
1568 pub fn update_intent_to_add(&self, state_id: &StateId) -> GitProjectionResult<()> {
1591 let root = self.heddle_repo.root();
1592 if !root.join(".git").exists() {
1593 return Ok(());
1594 }
1595 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1596 if checkout_repo
1599 .head()
1600 .map(|head| head.is_detached())
1601 .unwrap_or(false)
1602 {
1603 return Ok(());
1604 }
1605
1606 let Some(state) = self.heddle_repo.store().get_state(state_id)? else {
1608 return Ok(());
1609 };
1610 let Some(tree) = self.heddle_repo.store().get_tree(&state.tree)? else {
1611 return Ok(());
1612 };
1613 let mut captured: Vec<(String, FileMode)> = Vec::new();
1614 collect_capture_paths(self.heddle_repo.store(), &tree, "", &mut captured)?;
1615 let mut index = checkout_repo
1628 .open_index()
1629 .map_err(git_err)?
1630 .unwrap_or_else(|| Index {
1631 version: 2,
1632 entries: Vec::new(),
1633 extensions: Vec::new(),
1634 checksum: None,
1635 });
1636
1637 let mut real_tracked: HashSet<String> = HashSet::new();
1640 let mut existing_ita: HashSet<String> = HashSet::new();
1641 for entry in &index.entries {
1642 let path = String::from_utf8_lossy(entry.path.as_bytes()).into_owned();
1643 if entry.is_intent_to_add() {
1644 existing_ita.insert(path);
1645 } else {
1646 real_tracked.insert(path);
1647 }
1648 }
1649
1650 let captured_paths: HashSet<&str> = captured.iter().map(|(p, _)| p.as_str()).collect();
1653
1654 let before_prune = index.entries.len();
1656 index.entries.retain(|entry| {
1657 !entry.is_intent_to_add()
1658 || captured_paths.contains(String::from_utf8_lossy(entry.path.as_bytes()).as_ref())
1659 });
1660 let mut changed = index.entries.len() != before_prune;
1661
1662 for (path, mode) in &captured {
1664 if real_tracked.contains(path) || existing_ita.contains(path) {
1665 continue;
1666 }
1667 if real_tracked
1675 .iter()
1676 .any(|tracked| path_prefix_conflict(path, tracked))
1677 {
1678 continue;
1679 }
1680 if *mode == FileMode::Spoollink {
1684 continue;
1685 }
1686 let mut entry = IndexEntry::intent_to_add(
1687 checkout_repo.object_format(),
1688 GitBString::from(path.as_str()),
1689 );
1690 entry.mode = match mode {
1691 FileMode::Executable => 0o100755,
1692 FileMode::Symlink => 0o120000,
1693 FileMode::Gitlink => 0o160000,
1694 FileMode::Normal => 0o100644,
1695 FileMode::Spoollink => 0o100644,
1697 };
1698 changed = true;
1699 index.entries.push(entry);
1700 }
1701
1702 if changed {
1703 index
1704 .entries
1705 .sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
1706 index.upgrade_version_for_flags();
1707 checkout_repo
1708 .write_index(
1709 &index,
1710 IndexWriteOptions {
1711 fsync: true,
1712 validate_checksum: true,
1713 },
1714 )
1715 .map_err(git_err)?;
1716 }
1717 Ok(())
1718 }
1719
1720 pub fn write_through_thread_checkout(
1725 &mut self,
1726 thread: &str,
1727 ) -> GitProjectionResult<WriteThroughOutcome> {
1728 if !self.heddle_repo.root().join(".git").exists() {
1729 return Ok(WriteThroughOutcome::Skipped(
1730 WriteThroughSkipReason::MissingDotGit,
1731 ));
1732 }
1733
1734 let Some(state_id) = self
1735 .heddle_repo
1736 .refs()
1737 .get_thread(&ThreadName::new(thread))?
1738 else {
1739 return Ok(WriteThroughOutcome::Skipped(
1740 WriteThroughSkipReason::NoAttachedThread,
1741 ));
1742 };
1743 self.write_thread_state_checkout_direct(thread, &state_id, None)
1744 }
1745
1746 fn write_thread_state_checkout_direct(
1747 &mut self,
1748 thread: &str,
1749 state_id: &StateId,
1750 checkpoint_summary: Option<&str>,
1751 ) -> GitProjectionResult<WriteThroughOutcome> {
1752 let git = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1753 if git.git_dir().join("index.lock").exists() {
1754 return Ok(WriteThroughOutcome::Skipped(
1755 WriteThroughSkipReason::IndexAlreadyDirty,
1756 ));
1757 }
1758 let checkout = CheckoutWrite::prepare(self.heddle_repo.root(), thread)?;
1759 self.build_existing_mapping(Some(self.heddle_repo.root()))?;
1760 self.seed_git_checkpoint_mappings_from_repo(&checkout.object_repo)?;
1761 self.seed_ingest_identity_mappings_from_repo(&checkout.object_repo)?;
1762
1763 if self.linearize_unmapped_tip_to_checkout
1768 && self.mapping.get_git(state_id).is_none()
1769 && !self.commit_parent_overrides.contains_key(state_id)
1770 && let Some(previous) = checkout.previous_branch
1771 {
1772 self.set_commit_parent_override(*state_id, vec![previous]);
1773 }
1774
1775 let identity = git_config_identity_with_global_fallback(self.heddle_repo.root())?;
1776 let audience = AudienceTier::Public;
1777 for reachable in self.sort_states_topologically(&[*state_id])? {
1778 let message_override = self
1779 .commit_message_overrides
1780 .get(&reachable)
1781 .map(String::as_str);
1782 let parent_override = self
1783 .commit_parent_overrides
1784 .get(&reachable)
1785 .map(Vec::as_slice);
1786 if let Some(mapped) = self.mapping.get_git(&reachable) {
1787 let native_object_missing = checkout.object_repo.read_object(&mapped).is_err()
1788 && self
1789 .heddle_repo
1790 .store()
1791 .get_state(&reachable)?
1792 .is_some_and(|state| state.raw_message.is_none());
1793 if !native_object_missing {
1794 continue;
1795 }
1796 }
1797 let Some(git_oid) = export_state(
1798 &mut self.mapping,
1799 self.heddle_repo,
1800 &checkout.object_repo,
1801 &reachable,
1802 ExportStateOptions {
1803 identity: identity.as_ref(),
1804 message_override,
1805 parent_override,
1806 audience: &audience,
1807 },
1808 )?
1809 else {
1810 continue;
1811 };
1812 if let Some(mapped) = self.mapping.get_git(&reachable)
1813 && mapped != git_oid
1814 {
1815 return Err(GitProjectionError::MappingConflict {
1816 message: format!(
1817 "state {reachable} reminted as {git_oid}, but the durable mapping expects {mapped}"
1818 ),
1819 });
1820 }
1821 self.mapping.insert(reachable, git_oid);
1822 if let Some(state) = self.heddle_repo.store().get_state(&reachable)? {
1823 git_notes::write_note(
1824 &checkout.object_repo,
1825 git_oid,
1826 &git_notes::HeddleNote::from_state(&state),
1827 )?;
1828 }
1829 }
1830
1831 let Some(git_oid) = self.mapping.get_git(state_id) else {
1832 return Ok(WriteThroughOutcome::Skipped(
1833 WriteThroughSkipReason::NoMappedCommit,
1834 ));
1835 };
1836 materialize_active_checkout_closure(
1837 self.heddle_repo,
1838 &self.mapping,
1839 &checkout.object_repo,
1840 state_id,
1841 git_oid,
1842 &checkout.excluded_objects()?,
1843 )?;
1844 self.save_mapping_to_disk()?;
1845
1846 if let Some(summary) = checkpoint_summary {
1847 let pending = self.heddle_repo.pending_git_checkpoint_intent()?;
1848 let previous_git_oid = pending
1849 .as_ref()
1850 .filter(|intent| {
1851 intent.state_id == state_id.to_string_full()
1852 && intent.branch == thread
1853 && intent.new_git_oid == git_oid.to_string()
1854 })
1855 .and_then(|intent| intent.previous_git_oid.clone())
1856 .or_else(|| checkout.previous_branch.map(|oid| oid.to_string()));
1857 let intent = GitCheckpointIntent {
1858 version: 1,
1859 state_id: state_id.to_string_full(),
1860 branch: thread.to_string(),
1861 previous_git_oid,
1862 new_git_oid: git_oid.to_string(),
1863 summary: summary.to_string(),
1864 phase: GitCheckpointIntentPhase::Prepared,
1865 };
1866 self.heddle_repo.begin_git_checkpoint_intent(&intent)?;
1867 objects::fault_inject::maybe_panic_at("git_checkpoint_after_intent_before_publish");
1868 }
1869
1870 checkout.publish(git_oid)?;
1871 if checkpoint_summary.is_some() {
1872 objects::fault_inject::maybe_panic_at("git_checkpoint_after_publish_before_phase");
1873 self.heddle_repo
1874 .mark_git_checkpoint_published(state_id, &git_oid.to_string())?;
1875 }
1876 Ok(WriteThroughOutcome::Wrote(git_oid))
1877 }
1878
1879 pub fn write_current_checkout_from_existing_mirror(
1880 &mut self,
1881 ) -> GitProjectionResult<WriteThroughOutcome> {
1882 if !self.heddle_repo.root().join(".git").exists() {
1883 return Ok(WriteThroughOutcome::Skipped(
1884 WriteThroughSkipReason::MissingDotGit,
1885 ));
1886 }
1887
1888 let (thread, state_id) = match self.heddle_repo.head_ref()? {
1889 Head::Attached { thread } => {
1890 let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1891 return Ok(WriteThroughOutcome::Skipped(
1892 WriteThroughSkipReason::NoAttachedThread,
1893 ));
1894 };
1895 (thread, state_id)
1896 }
1897 Head::Detached { .. } => {
1898 return Ok(WriteThroughOutcome::Skipped(
1899 WriteThroughSkipReason::DetachedHead,
1900 ));
1901 }
1902 };
1903 self.write_thread_state_checkout_from_existing_mirror(&thread, &state_id)
1904 }
1905
1906 fn write_thread_state_checkout_from_existing_mirror(
1907 &mut self,
1908 thread: &str,
1909 state_id: &StateId,
1910 ) -> GitProjectionResult<WriteThroughOutcome> {
1911 let mirror_repo = self.open_git_repo()?;
1912 if self.mapping.is_empty() {
1913 self.build_existing_mapping(None)?;
1914 }
1915 let git_oid = if let Some(git_oid) = self.mapping.get_git(state_id) {
1916 git_oid
1917 } else if let Some(git_commit) = self
1918 .heddle_repo
1919 .git_overlay_mapped_git_commit_for_state(state_id)
1920 .map_err(|error| GitProjectionError::Git(error.to_string()))?
1921 {
1922 ObjectId::from_hex(mirror_repo.object_format(), &git_commit)
1923 .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?
1924 } else {
1925 return Ok(WriteThroughOutcome::Skipped(
1926 WriteThroughSkipReason::NoMappedCommit,
1927 ));
1928 };
1929
1930 let git = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1931 if git.git_dir().join("index.lock").exists() {
1932 return Ok(WriteThroughOutcome::Skipped(
1933 WriteThroughSkipReason::IndexAlreadyDirty,
1934 ));
1935 }
1936 let checkout = CheckoutWrite::prepare(self.heddle_repo.root(), thread)?;
1937 if checkout.checkout_repo.git_dir() == mirror_repo.git_dir() {
1938 return Ok(WriteThroughOutcome::Skipped(
1939 WriteThroughSkipReason::MirrorIsWorktree,
1940 ));
1941 }
1942 materialize_checkout_closure_from_state(
1943 self.heddle_repo,
1944 &self.mapping,
1945 &mirror_repo,
1946 &checkout.object_repo,
1947 state_id,
1948 git_oid,
1949 &checkout.excluded_objects()?,
1950 )?;
1951 checkout.publish(git_oid)?;
1952 Ok(WriteThroughOutcome::Wrote(git_oid))
1953 }
1954
1955 fn refresh_checkout_remote_tracking_ref(
1956 &self,
1957 remote_name: &str,
1958 branch: &str,
1959 ) -> GitProjectionResult<()> {
1960 if !self.heddle_repo.root().join(".git").exists() {
1961 return Ok(());
1962 }
1963 let Some(tracking_remote) =
1964 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1965 else {
1966 return Ok(());
1967 };
1968 reject_reserved_git_remote_name(&tracking_remote)?;
1969
1970 let mirror_repo = self.open_git_repo()?;
1971 let branch_ref = format!("refs/heads/{branch}");
1972 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1973 return Ok(());
1974 };
1975 let Some(target) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1976 return Ok(());
1977 };
1978
1979 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1980 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1981 return Ok(());
1982 }
1983 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1984 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
1985 set_reference(
1986 &object_repo,
1987 &format!("refs/remotes/{tracking_remote}/{branch}"),
1988 target,
1989 RefPrecondition::Any,
1990 "heddle: refresh remote-tracking branch after pull",
1991 )?;
1992 Ok(())
1993 }
1994
1995 fn refresh_checkout_remote_tracking_refs(&self, remote_name: &str) -> GitProjectionResult<()> {
1996 if !self.heddle_repo.root().join(".git").exists() {
1997 return Ok(());
1998 }
1999 let Some(tracking_remote) =
2000 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
2001 else {
2002 return Ok(());
2003 };
2004 reject_reserved_git_remote_name(&tracking_remote)?;
2005
2006 let mirror_repo = self.open_git_repo()?;
2007 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
2008 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2009 return Ok(());
2010 }
2011 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2012 let prefix = format!("refs/remotes/{remote_name}/");
2013 for reference in mirror_repo.references().list_refs().map_err(git_err)? {
2014 if !reference.name.starts_with(&prefix) {
2015 continue;
2016 }
2017 let ReferenceTarget::Direct(target) = reference.target else {
2018 continue;
2019 };
2020 let full = reference.name;
2021 let Some(branch) = full.strip_prefix(&prefix) else {
2022 continue;
2023 };
2024 if branch.ends_with("/HEAD") {
2025 continue;
2026 }
2027 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
2028 set_reference(
2029 &object_repo,
2030 &format!("refs/remotes/{tracking_remote}/{branch}"),
2031 target,
2032 RefPrecondition::Any,
2033 "heddle: refresh remote-tracking branch after fetch",
2034 )?;
2035 }
2036 Ok(())
2037 }
2038
2039 fn refresh_checkout_note_refs_from_mirror(&self) -> GitProjectionResult<()> {
2040 if !self.heddle_repo.root().join(".git").exists() {
2041 return Ok(());
2042 }
2043
2044 let mirror_repo = self.open_git_repo()?;
2045 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
2046 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2047 return Ok(());
2048 }
2049 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2050 let note_updates = collect_ref_updates(&mirror_repo)?
2051 .into_iter()
2052 .filter(|update| update.namespace == RefNamespace::Note)
2053 .collect::<Vec<_>>();
2054 if note_updates.is_empty() {
2055 return Ok(());
2056 }
2057
2058 copy_reachable_objects(
2059 &mirror_repo,
2060 &object_repo,
2061 note_updates.iter().map(|u| u.target),
2062 )?;
2063 apply_ref_updates(
2064 &object_repo,
2065 ¬e_updates,
2066 "heddle: refresh Heddle note refs",
2067 )?;
2068 Ok(())
2069 }
2070
2071 fn resolve_remote(
2072 &self,
2073 remote_name: &str,
2074 direction: RemoteDirection,
2075 ) -> GitProjectionResult<ResolvedRemote> {
2076 let repo = self.open_git_repo()?;
2077 let url = match remote_url_from_repo(&repo, remote_name, direction)? {
2078 Some(url) => Some(url),
2079 None => self.checkout_remote_url(remote_name, direction)?,
2080 };
2081
2082 let base = repo_relative_base(&repo);
2083 let url = match url {
2084 Some(url) => url,
2085 None => parse_configured_remote_url(remote_name, &base)?,
2086 };
2087
2088 if let Some(path) = local_path_from_url(&url)? {
2089 Ok(ResolvedRemote::Local(path))
2090 } else {
2091 Ok(ResolvedRemote::Url(url))
2092 }
2093 }
2094
2095 fn checkout_remote_url(
2096 &self,
2097 remote_name: &str,
2098 direction: RemoteDirection,
2099 ) -> GitProjectionResult<Option<String>> {
2100 if direction == RemoteDirection::Fetch
2101 && let Some(url) =
2102 remote_fetch_url_from_checkout_config(self.heddle_repo.root(), remote_name)?
2103 {
2104 return Ok(Some(url));
2105 }
2106 let Ok(repo) = SleyRepository::discover(self.heddle_repo.root()) else {
2107 return Ok(None);
2108 };
2109 remote_url_from_repo(&repo, remote_name, direction)
2110 }
2111}
2112
2113fn remote_url_from_repo(
2114 repo: &SleyRepository,
2115 remote_name: &str,
2116 direction: RemoteDirection,
2117) -> GitProjectionResult<Option<String>> {
2118 let config = repo.config_snapshot().map_err(git_err)?;
2119 let push = direction == RemoteDirection::Push;
2120 let value = if push {
2121 config
2122 .get("remote", Some(remote_name), "pushurl")
2123 .or_else(|| config.get("remote", Some(remote_name), "url"))
2124 } else {
2125 config.get("remote", Some(remote_name), "url")
2126 };
2127 let Some(value) = value else {
2128 return Ok(None);
2129 };
2130 let rewritten =
2131 sley::plumbing::sley_config::remotes::rewrite_url_with_config(&config, value, push);
2132 parse_configured_remote_url(&rewritten, &repo_relative_base(repo)).map(Some)
2133}
2134
2135fn checkout_tracking_remote_name(
2136 root: &Path,
2137 requested: &str,
2138) -> GitProjectionResult<Option<String>> {
2139 let remotes = checkout_remote_url_items(root)?;
2140 if remotes.is_empty() {
2141 return Ok(None);
2142 }
2143 if let Some((name, _)) = remotes.iter().find(|(name, _)| name == requested) {
2144 return Ok(Some(name.clone()));
2145 }
2146 if let Some((name, _)) = remotes
2147 .iter()
2148 .find(|(_, url)| configured_remote_values_match(url, requested))
2149 {
2150 return Ok(Some(name.clone()));
2151 }
2152 if looks_like_remote_location(requested) && remotes.len() == 1 {
2153 return Ok(Some(remotes[0].0.clone()));
2154 }
2155 if !looks_like_remote_location(requested) {
2156 return Ok(Some(requested.to_string()));
2157 }
2158 Ok(None)
2159}
2160
2161fn checkout_remote_url_items(root: &Path) -> GitProjectionResult<Vec<(String, String)>> {
2162 let mut remotes = Vec::new();
2163 for config_path in checkout_git_config_paths(root) {
2164 parse_remote_url_items_from_config(&config_path, &mut remotes)?;
2165 }
2166 Ok(remotes)
2167}
2168
2169fn checkout_note_ref_exists(root: &Path) -> GitProjectionResult<bool> {
2170 if !root.join(".git").exists() {
2171 return Ok(false);
2172 }
2173 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2174 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2175 Ok(object_repo
2176 .find_reference(super::git_notes::NOTES_REF)
2177 .map_err(git_err)?
2178 .is_some())
2179}
2180
2181fn seed_checkout_note_refs_into_mirror(
2182 root: &Path,
2183 mirror_repo: &SleyRepository,
2184) -> GitProjectionResult<()> {
2185 if !root.join(".git").exists() {
2186 return Ok(());
2187 }
2188
2189 let checkout_repo = match SleyRepository::discover(root) {
2190 Ok(repo) => repo,
2191 Err(_) => return Ok(()),
2192 };
2193 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2194 return Ok(());
2195 }
2196 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2197 let note_updates = collect_ref_updates(&object_repo)?
2198 .into_iter()
2199 .filter(|update| update.namespace == RefNamespace::Note)
2200 .collect::<Vec<_>>();
2201 if note_updates.is_empty() {
2202 return Ok(());
2203 }
2204
2205 copy_reachable_objects(
2206 &object_repo,
2207 mirror_repo,
2208 note_updates.iter().map(|update| update.target),
2209 )?;
2210 apply_ref_updates(
2211 mirror_repo,
2212 ¬e_updates,
2213 "heddle: seed mirror note refs from checkout",
2214 )
2215}
2216
2217fn hydrate_checkout_notes_from_remote_without_mirror(
2218 root: &Path,
2219 remote_name: &str,
2220) -> GitProjectionResult<()> {
2221 reject_reserved_git_remote_name(remote_name)?;
2222 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2223 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2224 let url = remote_fetch_url_from_checkout_config(root, remote_name)?.ok_or_else(|| {
2225 GitProjectionError::Git(format!("remote '{remote_name}' has no fetch URL"))
2226 })?;
2227
2228 if let Some(path) = local_path_from_url(&url)? {
2229 let remote_repo = open_repo(&path)?;
2230 let note_updates = collect_ref_updates(&remote_repo)?
2231 .into_iter()
2232 .filter(|update| update.namespace == RefNamespace::Note)
2233 .collect::<Vec<_>>();
2234 if note_updates.is_empty() {
2235 return Ok(());
2236 }
2237 copy_reachable_objects(
2238 &remote_repo,
2239 &object_repo,
2240 note_updates.iter().map(|update| update.target),
2241 )?;
2242 apply_ref_updates(
2243 &object_repo,
2244 ¬e_updates,
2245 &format!("heddle: hydrate notes from {remote_name}"),
2246 )?;
2247 return Ok(());
2248 }
2249
2250 fetch_heddle_notes_into_repo(&object_repo, remote_name, &url)
2251}
2252
2253fn fetch_heddle_notes_into_repo(
2254 repo: &SleyRepository,
2255 remote_name: &str,
2256 url: &str,
2257) -> GitProjectionResult<()> {
2258 let mut credentials = NoCredentials;
2259 let mut progress = SilentProgress;
2260 let refspec = RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format();
2261 repo.fetch(
2262 url,
2263 &[refspec],
2264 FetchOptions {
2265 filter_auto: false,
2267 progress: None,
2268 upload_pack_command: None,
2269 negotiation_include: None,
2272 negotiation_restrict: None,
2273 reject_shallow: false,
2274 quiet: true,
2275 auto_follow_tags: false,
2276 fetch_all_tags: false,
2277 prune: false,
2278 dry_run: false,
2279 append: false,
2280 write_fetch_head: true,
2281 force: false,
2282 tag_option_explicit: true,
2283 prune_option_explicit: true,
2284 prune_tags: false,
2285 prune_tags_option_explicit: false,
2286 refmap: None,
2287 refetch: false,
2288 record_promisor_refs: false,
2289 update_head_ok: false,
2290 ssh_options: None,
2291 atomic: false,
2292 depth: None,
2293 merge_srcs: Vec::new(),
2294 filter: None,
2295 cloning: false,
2296 update_shallow: false,
2297 deepen_relative: false,
2298 deepen_since: None,
2299 deepen_not: Vec::new(),
2300 },
2301 &mut credentials,
2302 &mut progress,
2303 )
2304 .map(|_| ())
2305 .map_err(|err| {
2306 GitProjectionError::Git(format!("failed to fetch notes from {remote_name}: {err}"))
2307 })
2308}
2309
2310fn parse_remote_url_items_from_config(
2311 path: &Path,
2312 remotes: &mut Vec<(String, String)>,
2313) -> GitProjectionResult<()> {
2314 let Ok(contents) = fs::read_to_string(path) else {
2315 return Ok(());
2316 };
2317 let mut current_remote: Option<String> = None;
2318 for raw in contents.lines() {
2319 let line = raw.trim();
2320 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2321 continue;
2322 }
2323 if line.starts_with('[') && line.ends_with(']') {
2324 current_remote = line
2325 .strip_prefix("[remote \"")
2326 .and_then(|rest| rest.strip_suffix("\"]"))
2327 .map(str::to_string);
2328 continue;
2329 }
2330 let Some(name) = current_remote.as_ref() else {
2331 continue;
2332 };
2333 let Some((key, value)) = line.split_once('=') else {
2334 continue;
2335 };
2336 if key.trim().eq_ignore_ascii_case("url") {
2337 remotes.push((name.clone(), git_config_value(value.trim())?));
2338 }
2339 }
2340 Ok(())
2341}
2342
2343fn configured_remote_values_match(left: &str, right: &str) -> bool {
2344 if left == right {
2345 return true;
2346 }
2347 let left_path = Path::new(left);
2348 let right_path = Path::new(right);
2349 if let (Ok(left), Ok(right)) = (left_path.canonicalize(), right_path.canonicalize()) {
2350 return left == right;
2351 }
2352 false
2353}
2354
2355fn materialize_active_checkout_closure(
2356 heddle_repo: &HeddleRepository,
2357 mapping: &SyncMapping,
2358 object_repo: &SleyRepository,
2359 tip_state_id: &StateId,
2360 tip_oid: ObjectId,
2361 excluded: &HashSet<ObjectId>,
2362) -> GitProjectionResult<()> {
2363 let residuals = ResidualStore::open(heddle_repo.heddle_dir());
2364 let mut stack = vec![*tip_state_id];
2365 let mut seen = HashSet::new();
2366
2367 while let Some(state_id) = stack.pop() {
2368 if !seen.insert(state_id) {
2369 continue;
2370 }
2371 let git_oid = if state_id == *tip_state_id {
2372 tip_oid
2373 } else {
2374 mapping
2375 .get_git(&state_id)
2376 .ok_or(GitProjectionError::StateNotFound(state_id))?
2377 };
2378 if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
2379 continue;
2380 }
2381
2382 let state = heddle_repo
2383 .store()
2384 .get_state(&state_id)?
2385 .ok_or(GitProjectionError::StateNotFound(state_id))?;
2386 if commit_is_byte_faithful(&state) {
2387 let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
2388 let reconstructed = commit_object_id(&content);
2389 if reconstructed != git_oid {
2390 return Err(GitProjectionError::MappingConflict {
2391 message: format!(
2392 "state {state_id} reconstructs as {reconstructed}, expected {git_oid}"
2393 ),
2394 });
2395 }
2396 write_commit_object(object_repo, &content)?;
2397 stack.extend(state.parents);
2398 continue;
2399 }
2400
2401 if !residuals.install_into(object_repo, &git_oid)? {
2402 return Err(GitProjectionError::CommitNotFound(format!(
2403 "{git_oid} for state {state_id}; the authoritative checkout .git and Raw Git Object Residual store do not contain it"
2404 )));
2405 }
2406 match verify_closure_present(object_repo, &[git_oid], excluded) {
2407 Ok(()) => {}
2408 Err(ClosureCheck::Incomplete { missing }) => {
2409 return Err(GitProjectionError::CommitNotFound(format!(
2410 "{missing} in the closure of {git_oid}; restore the original object to the authoritative checkout .git"
2411 )));
2412 }
2413 Err(ClosureCheck::Walk(error)) => return Err(error),
2414 }
2415 }
2416 Ok(())
2417}
2418
2419fn looks_like_remote_location(value: &str) -> bool {
2420 value.starts_with('/')
2421 || value.starts_with("./")
2422 || value.starts_with("../")
2423 || value.starts_with("~/")
2424 || value.contains("://")
2425 || value.contains('\\')
2426}
2427
2428fn remote_fetch_url_from_checkout_config(
2429 root: &Path,
2430 remote_name: &str,
2431) -> GitProjectionResult<Option<String>> {
2432 for config_path in checkout_git_config_paths(root) {
2433 let Some(url) = parse_remote_fetch_url_from_config(&config_path, remote_name)? else {
2434 continue;
2435 };
2436 return parse_configured_remote_url(&url, root).map(Some);
2437 }
2438 Ok(None)
2439}
2440
2441fn parse_configured_remote_url(value: &str, relative_base: &Path) -> GitProjectionResult<String> {
2442 if configured_remote_is_local_path(value) {
2443 let path = configured_remote_local_path(value, relative_base);
2444 return Ok(format!("file://{}", path.display()));
2445 }
2446 Ok(value.to_string())
2447}
2448
2449fn configured_remote_local_path(value: &str, relative_base: &Path) -> PathBuf {
2450 if value == "~"
2451 && let Some(home) = std::env::var_os("HOME")
2452 {
2453 return PathBuf::from(home);
2454 }
2455 if let Some(rest) = value.strip_prefix("~/")
2456 && let Some(home) = std::env::var_os("HOME")
2457 {
2458 return PathBuf::from(home).join(rest);
2459 }
2460
2461 let path = Path::new(value);
2462 if path.is_absolute() {
2463 path.to_path_buf()
2464 } else {
2465 relative_base.join(path)
2466 }
2467}
2468
2469fn configured_remote_is_local_path(value: &str) -> bool {
2470 value.starts_with('/')
2471 || value.starts_with("./")
2472 || value.starts_with("../")
2473 || value.starts_with('~')
2474 || value.starts_with(std::path::MAIN_SEPARATOR)
2475}
2476
2477fn checkout_git_config_paths(root: &Path) -> Vec<PathBuf> {
2478 let dot_git = root.join(".git");
2479 let mut paths = Vec::new();
2480 if dot_git.is_dir() {
2481 paths.push(dot_git.join("config"));
2482 if let Some(common_dir) = common_git_dir_from_git_dir(&dot_git) {
2483 paths.push(common_dir.join("config"));
2484 }
2485 return paths;
2486 }
2487 let Ok(contents) = fs::read_to_string(&dot_git) else {
2488 return paths;
2489 };
2490 let Some(target) = contents.trim().strip_prefix("gitdir:").map(str::trim) else {
2491 return paths;
2492 };
2493 let git_dir = {
2494 let path = Path::new(target);
2495 if path.is_absolute() {
2496 path.to_path_buf()
2497 } else {
2498 dot_git
2499 .parent()
2500 .map(|parent| parent.join(path))
2501 .unwrap_or_else(|| path.to_path_buf())
2502 }
2503 };
2504 paths.push(git_dir.join("config"));
2505 if let Some(common_dir) = common_git_dir_from_git_dir(&git_dir) {
2506 paths.push(common_dir.join("config"));
2507 }
2508 paths
2509}
2510
2511fn common_git_dir_from_git_dir(git_dir: &Path) -> Option<PathBuf> {
2512 let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
2513 let target = contents.trim();
2514 let path = Path::new(target);
2515 Some(if path.is_absolute() {
2516 path.to_path_buf()
2517 } else {
2518 git_dir.join(path)
2519 })
2520}
2521
2522fn parse_remote_fetch_url_from_config(
2523 path: &Path,
2524 remote_name: &str,
2525) -> GitProjectionResult<Option<String>> {
2526 let Ok(contents) = fs::read_to_string(path) else {
2527 return Ok(None);
2528 };
2529 let mut in_remote = false;
2530 for raw in contents.lines() {
2531 let line = raw.trim();
2532 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2533 continue;
2534 }
2535 if line.starts_with('[') && line.ends_with(']') {
2536 in_remote = line
2537 .strip_prefix("[remote \"")
2538 .and_then(|rest| rest.strip_suffix("\"]"))
2539 == Some(remote_name);
2540 continue;
2541 }
2542 if !in_remote {
2543 continue;
2544 }
2545 let Some((key, value)) = line.split_once('=') else {
2546 continue;
2547 };
2548 if key.trim().eq_ignore_ascii_case("url") {
2549 return git_config_value(value.trim()).map(Some);
2550 }
2551 }
2552 Ok(None)
2553}
2554
2555fn common_repo_for_worktree(repo: &SleyRepository) -> GitProjectionResult<SleyRepository> {
2556 let common_dir_file = repo.git_dir().join("commondir");
2557 let Ok(contents) = fs::read_to_string(&common_dir_file) else {
2558 return Ok(repo.clone());
2559 };
2560 let target = contents.trim();
2561 if target.is_empty() {
2562 return Ok(repo.clone());
2563 }
2564 let common_dir = {
2565 let path = Path::new(target);
2566 if path.is_absolute() {
2567 path.to_path_buf()
2568 } else {
2569 repo.git_dir().join(path)
2570 }
2571 };
2572 open_repo(&common_dir)
2573}
2574
2575pub fn git_err(err: impl std::fmt::Display) -> GitProjectionError {
2576 GitProjectionError::Git(err.to_string())
2577}
2578
2579fn restore_file_if_unchanged(
2580 path: &Path,
2581 expected: &[u8],
2582 previous: Option<&[u8]>,
2583) -> GitProjectionResult<()> {
2584 let file_name = path.file_name().ok_or_else(|| {
2585 GitProjectionError::Git(format!("cannot lock rollback path {}", path.display()))
2586 })?;
2587 let mut lock_name = file_name.to_os_string();
2588 lock_name.push(".lock");
2589 let lock_path = path.with_file_name(lock_name);
2590 let mut lock = OpenOptions::new()
2591 .write(true)
2592 .create_new(true)
2593 .open(&lock_path)
2594 .map_err(|error| {
2595 GitProjectionError::Git(format!(
2596 "cannot acquire Git rollback lock {}: {error}",
2597 lock_path.display()
2598 ))
2599 })?;
2600 let result = (|| {
2601 let current = fs::read(path)?;
2602 if current != expected {
2603 return Err(GitProjectionError::Git(format!(
2604 "refusing to roll back {} because another Git operation changed it",
2605 path.display()
2606 )));
2607 }
2608 if let Some(previous) = previous {
2609 lock.write_all(previous)?;
2610 lock.sync_all()?;
2611 drop(lock);
2612 fs::rename(&lock_path, path)?;
2613 } else {
2614 drop(lock);
2615 fs::remove_file(path)?;
2616 fs::remove_file(&lock_path)?;
2617 }
2618 Ok(())
2619 })();
2620 if result.is_err() {
2621 let _ = fs::remove_file(&lock_path);
2622 }
2623 result
2624}
2625
2626fn read_optional_file(path: &Path) -> GitProjectionResult<Option<Vec<u8>>> {
2627 match fs::read(path) {
2628 Ok(contents) => Ok(Some(contents)),
2629 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2630 Err(error) => Err(error.into()),
2631 }
2632}
2633
2634fn rollback_reference_if_unchanged(
2635 repo: &SleyRepository,
2636 name: &str,
2637 published: ObjectId,
2638 previous: Option<ObjectId>,
2639) -> GitProjectionResult<()> {
2640 let current = match repo.find_reference(name).map_err(git_err)? {
2641 Some(reference) => reference.peeled_oid(repo).map_err(git_err)?,
2642 None => None,
2643 };
2644 if current == previous {
2645 return Ok(());
2646 }
2647 if current != Some(published) {
2648 return Err(GitProjectionError::Git(format!(
2649 "refusing to roll back Git reference '{name}' because another Git operation changed it"
2650 )));
2651 }
2652 let rollback = match previous {
2653 Some(previous) => set_reference(
2654 repo,
2655 name,
2656 previous,
2657 RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(published)),
2658 "heddle: rollback failed write-through",
2659 ),
2660 None => delete_reference_matching(repo, name, published),
2661 };
2662 if rollback.is_ok() {
2663 return Ok(());
2664 }
2665 let current = match repo.find_reference(name).map_err(git_err)? {
2666 Some(reference) => reference.peeled_oid(repo).map_err(git_err)?,
2667 None => None,
2668 };
2669 if current == previous {
2670 Ok(())
2671 } else {
2672 rollback
2673 }
2674}
2675
2676fn fsync_path(path: &Path) -> GitProjectionResult<()> {
2680 match std::fs::File::open(path) {
2681 Ok(file) => {
2682 file.sync_all()?;
2683 Ok(())
2684 }
2685 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2686 Err(err) => Err(GitProjectionError::Io(err)),
2687 }
2688}
2689
2690pub struct MirrorInitGuard {
2697 path: PathBuf,
2698 rollback: Option<bool>,
2702}
2703
2704impl MirrorInitGuard {
2705 pub fn new_from_init(path: PathBuf, did_create: bool) -> Self {
2706 Self {
2707 path,
2708 rollback: Some(did_create),
2709 }
2710 }
2711
2712 pub fn commit(mut self) {
2713 self.rollback = None;
2714 }
2715}
2716
2717impl Drop for MirrorInitGuard {
2718 fn drop(&mut self) {
2719 if matches!(self.rollback, Some(true))
2720 && self.path.exists()
2721 && let Err(err) = std::fs::remove_dir_all(&self.path)
2722 {
2723 tracing::warn!(
2724 path = %self.path.display(),
2725 error = %err,
2726 "failed to roll back partial legacy Bridge Mirror; manual cleanup may be required"
2727 );
2728 }
2729 }
2730}
2731
2732pub fn thread_is_unclaimed_bootstrap(
2743 heddle_repo: &HeddleRepository,
2744 state_id: &StateId,
2745) -> GitProjectionResult<bool> {
2746 let Some(state) = heddle_repo.store().get_state(state_id)? else {
2747 return Ok(false);
2748 };
2749 if !state.parents.is_empty() {
2750 return Ok(false);
2751 }
2752 let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
2753 return Ok(false);
2754 };
2755 Ok(tree == Tree::new())
2756}
2757
2758pub fn open_repo(path: &Path) -> GitProjectionResult<SleyRepository> {
2759 match SleyRepository::discover(path) {
2760 Ok(repo) => Ok(repo),
2761 Err(_) => SleyRepository::open(path).map_err(git_err),
2762 }
2763}
2764
2765pub fn delete_reference_if_present(repo: &SleyRepository, name: &str) -> GitProjectionResult<()> {
2773 delete_reference(repo, name, None, true)
2774}
2775
2776fn delete_reference_matching(
2777 repo: &SleyRepository,
2778 name: &str,
2779 expected_old: ObjectId,
2780) -> GitProjectionResult<()> {
2781 delete_reference(repo, name, Some(expected_old), false)
2782}
2783
2784fn delete_reference(
2785 repo: &SleyRepository,
2786 name: &str,
2787 expected_old: Option<ObjectId>,
2788 missing_ok: bool,
2789) -> GitProjectionResult<()> {
2790 let refs = repo.references();
2791 match refs.read_ref(name).map_err(git_err)? {
2792 None if missing_ok => Ok(()),
2793 None => Err(GitProjectionError::Git(format!(
2794 "failed to delete Git reference '{name}': ref is missing"
2795 ))),
2796 Some(ReferenceTarget::Direct(oid)) => repo
2797 .delete_ref(DeleteRef {
2798 name: FullName::new(name).map_err(git_err)?,
2799 expected_old: Some(expected_old.unwrap_or(oid)),
2800 expected: None,
2801 reflog: None,
2802 reflog_committer: None,
2803 })
2804 .map_err(git_err),
2805 Some(ReferenceTarget::Symbolic(_)) => {
2806 if let Some(expected_old) = expected_old {
2807 let current = repo
2808 .find_reference(name)
2809 .map_err(git_err)?
2810 .and_then(|reference| reference.peeled_oid(repo).ok().flatten());
2811 if current != Some(expected_old) {
2812 return Err(GitProjectionError::Git(format!(
2813 "failed to delete Git reference '{name}': expected {expected_old}, found {}",
2814 current
2815 .map(|oid| oid.to_string())
2816 .unwrap_or_else(|| "missing".to_string())
2817 )));
2818 }
2819 }
2820 refs.delete_symbolic_ref(name).map(|_| ()).map_err(git_err)
2821 }
2822 }
2823}
2824
2825pub fn set_reference(
2826 repo: &SleyRepository,
2827 name: &str,
2828 target: ObjectId,
2829 constraint: RefPrecondition,
2830 log_message: &str,
2831) -> GitProjectionResult<()> {
2832 let refs = repo.references();
2833 let old_oid = match refs.read_ref(name).map_err(git_err)? {
2834 Some(ReferenceTarget::Direct(oid)) => oid,
2835 _ => ObjectId::null(repo.object_format()),
2836 };
2837 let reflog = sley::plumbing::sley_refs::ReflogEntry {
2838 old_oid,
2839 new_oid: target,
2840 committer: git_projection_signature(),
2841 message: log_message.as_bytes().to_vec(),
2842 };
2843 let mut tx = refs.transaction();
2844 tx.update_to(
2845 name.to_string(),
2846 ReferenceTarget::Direct(target),
2847 constraint,
2848 Some(reflog),
2849 );
2850 tx.commit().map_err(git_err)?;
2851 Ok(())
2852}
2853
2854fn path_prefix_conflict(a: &str, b: &str) -> bool {
2860 let child_of = |parent: &str, child: &str| {
2861 child
2862 .strip_prefix(parent)
2863 .is_some_and(|rest| rest.starts_with('/'))
2864 };
2865 child_of(a, b) || child_of(b, a)
2866}
2867
2868fn collect_capture_paths<S: ObjectStore + ?Sized>(
2873 store: &S,
2874 tree: &Tree,
2875 prefix: &str,
2876 out: &mut Vec<(String, FileMode)>,
2877) -> GitProjectionResult<()> {
2878 for entry in tree.iter() {
2879 let path = if prefix.is_empty() {
2880 entry.name().to_string()
2881 } else {
2882 format!("{prefix}/{}", entry.name())
2883 };
2884 if entry.is_tree() {
2885 if let Some(hash) = entry.tree_hash()
2886 && let Some(subtree) = store.get_tree(&hash)?
2887 {
2888 collect_capture_paths(store, &subtree, &path, out)?;
2889 }
2890 } else {
2891 out.push((path, entry.mode()));
2892 }
2893 }
2894 Ok(())
2895}
2896
2897fn update_checkout_branch_ref(
2898 repo: &SleyRepository,
2899 branch_ref: &str,
2900 target: ObjectId,
2901 previous_branch: Option<ObjectId>,
2902 log_message: &str,
2903) -> GitProjectionResult<sley::plumbing::sley_refs::ReflogEntry> {
2904 let expected = previous_branch.map_or(RefPrecondition::MustNotExist, |oid| {
2905 RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(oid))
2906 });
2907 let old_oid = previous_branch.unwrap_or_else(|| ObjectId::null(repo.object_format()));
2908 let head_reflog = sley::plumbing::sley_refs::ReflogEntry {
2909 old_oid,
2910 new_oid: target,
2911 committer: git_projection_signature(),
2912 message: log_message.as_bytes().to_vec(),
2913 };
2914 set_reference(repo, branch_ref, target, expected, log_message)?;
2915 Ok(head_reflog)
2916}
2917
2918fn checkout_git_head_is_detached(root: &Path) -> GitProjectionResult<bool> {
2919 let repo = SleyRepository::discover(root).map_err(git_err)?;
2920 Ok(repo.head().map(|head| head.is_detached()).unwrap_or(false))
2921}
2922
2923pub fn resolve_git_commit_identity(
2924 repo_root: &Path,
2925 fallback: &Principal,
2926) -> GitProjectionResult<LocalGitIdentity> {
2927 if !principal_is_default_unknown(fallback) {
2928 return Ok(LocalGitIdentity::from_principal(fallback));
2929 }
2930 if let Some(identity) = git_config_identity_with_global_fallback(repo_root)? {
2931 return Ok(identity);
2932 }
2933
2934 Err(GitProjectionError::Git(
2935 "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(),
2936 ))
2937}
2938
2939pub fn git_config_identity_with_global_fallback(
2940 repo_root: &Path,
2941) -> GitProjectionResult<Option<LocalGitIdentity>> {
2942 let name = git_config_value_with_global_fallback(repo_root, "user.name")?;
2943 let email = git_config_value_with_global_fallback(repo_root, "user.email")?;
2944 if let (Some(name), Some(email)) = (name, email)
2945 && !name.trim().is_empty()
2946 && !email.trim().is_empty()
2947 {
2948 return Ok(Some(LocalGitIdentity { name, email }));
2949 }
2950 Ok(None)
2951}
2952
2953pub fn principal_is_default_unknown(principal: &Principal) -> bool {
2954 principal.name.trim().is_empty()
2955 || principal.email.trim().is_empty()
2956 || (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
2957}
2958
2959fn git_config_value_with_global_fallback(
2960 repo_root: &Path,
2961 key: &str,
2962) -> GitProjectionResult<Option<String>> {
2963 let Ok(repo) = SleyRepository::discover(repo_root) else {
2964 return Ok(None);
2965 };
2966 let Some((section, variable)) = key.split_once('.') else {
2967 return Ok(None);
2968 };
2969 Ok(repo
2970 .config_snapshot()
2971 .map_err(git_err)?
2972 .get(section, None, variable)
2973 .map(str::to_string))
2974}
2975
2976fn git_config_value(value: &str) -> GitProjectionResult<String> {
2977 let Some(quoted) = value
2978 .strip_prefix('"')
2979 .and_then(|rest| rest.strip_suffix('"'))
2980 else {
2981 return Ok(value.to_string());
2982 };
2983 let mut out = String::new();
2984 let mut chars = quoted.chars();
2985 while let Some(ch) = chars.next() {
2986 if ch != '\\' {
2987 out.push(ch);
2988 continue;
2989 }
2990 let Some(escaped) = chars.next() else {
2991 return Err(GitProjectionError::Git(
2992 "unterminated escape in repo-local Git config".to_string(),
2993 ));
2994 };
2995 match escaped {
2996 '"' | '\\' => out.push(escaped),
2997 'n' => out.push('\n'),
2998 't' => out.push('\t'),
2999 'b' => out.push('\u{0008}'),
3000 other => out.push(other),
3001 }
3002 }
3003 Ok(out)
3004}
3005
3006fn git_projection_signature() -> Vec<u8> {
3007 let seconds = SystemTime::now()
3008 .duration_since(UNIX_EPOCH)
3009 .map(|duration| duration.as_secs() as i64)
3010 .unwrap_or(0);
3011 format!("Heddle <heddle@local> {seconds} +0000").into_bytes()
3012}
3013
3014fn repo_relative_base(repo: &SleyRepository) -> PathBuf {
3015 repo.workdir().unwrap_or_else(|| {
3016 if repo
3017 .git_dir()
3018 .file_name()
3019 .is_some_and(|name| name == ".git")
3020 {
3021 repo.git_dir()
3022 .parent()
3023 .map(Path::to_path_buf)
3024 .unwrap_or_else(|| repo.git_dir().to_path_buf())
3025 } else {
3026 repo.git_dir().to_path_buf()
3027 }
3028 })
3029}
3030
3031fn local_path_from_url(url: &str) -> GitProjectionResult<Option<PathBuf>> {
3032 if url.starts_with("heddle://") {
3042 return Err(GitProjectionError::Git(format!(
3043 "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"
3044 )));
3045 }
3046 let Some(raw_path) = url.strip_prefix("file://") else {
3047 return Ok(None);
3048 };
3049 let path = PathBuf::from(raw_path);
3050 if path.as_os_str().is_empty() {
3051 return Err(GitProjectionError::Git(format!(
3052 "remote '{}' has no filesystem path",
3053 url
3054 )));
3055 }
3056 Ok(Some(path))
3057}
3058
3059fn collect_ref_updates(repo: &SleyRepository) -> GitProjectionResult<Vec<RefUpdate>> {
3060 let mut updates = Vec::new();
3061
3062 for reference in repo.references().list_refs().map_err(git_err)? {
3063 let ReferenceTarget::Direct(target) = reference.target else {
3064 continue;
3065 };
3066 let ref_name = GitRefName::new(&reference.name);
3067 if let Some(namespace) = ref_name.content_namespace()
3068 && let Some(name) = ref_name.short_name()
3069 {
3070 updates.push(RefUpdate {
3071 name: name.to_string(),
3072 target,
3073 namespace,
3074 });
3075 }
3076 }
3077
3078 Ok(updates)
3079}
3080
3081#[derive(Debug, Default, Clone, Copy)]
3090pub struct ExportedCommitCounts {
3091 pub total: usize,
3092 pub newly: usize,
3093}
3094
3095pub fn count_exported_commits(
3109 repo: &SleyRepository,
3110 newly_minted: &HashSet<ObjectId>,
3111) -> GitProjectionResult<ExportedCommitCounts> {
3112 let tips: Vec<ObjectId> = collect_ref_updates(repo)?
3113 .into_iter()
3114 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag))
3115 .map(|update| update.target)
3116 .collect();
3117
3118 let mut stack = tips;
3119 let mut seen = HashSet::new();
3120 let mut counts = ExportedCommitCounts::default();
3121 while let Some(oid) = stack.pop() {
3122 if !seen.insert(oid) {
3123 continue;
3124 }
3125 let object = repo.read_object(&oid).map_err(git_err)?;
3126 match object.object_type {
3127 GitObjectType::Commit => {
3128 counts.total += 1;
3129 if newly_minted.contains(&oid) {
3130 counts.newly += 1;
3131 }
3132 let commit = repo.read_commit(&oid).map_err(git_err)?;
3133 for parent in commit.parents {
3134 stack.push(parent);
3135 }
3136 }
3137 GitObjectType::Tag => {
3141 let tag = repo.read_tag(&oid).map_err(git_err)?;
3142 stack.push(tag.object);
3143 }
3144 GitObjectType::Tree | GitObjectType::Blob => {}
3145 }
3146 }
3147 Ok(counts)
3148}
3149
3150fn collect_ref_updates_for_fetch(
3151 repo: &SleyRepository,
3152 scope: GitFetchScope,
3153) -> GitProjectionResult<Vec<RefUpdate>> {
3154 let updates = collect_ref_updates(repo)?;
3155 match scope {
3156 GitFetchScope::AllRefs => Ok(updates),
3157 GitFetchScope::BranchesAndNotes => Ok(updates
3158 .into_iter()
3159 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Note))
3160 .collect()),
3161 }
3162}
3163
3164pub fn collect_import_source_ref_updates(
3165 repo: &SleyRepository,
3166 refs: &[String],
3167) -> GitProjectionResult<Vec<RefUpdate>> {
3168 let updates = collect_ref_updates(repo)?;
3169 if refs.is_empty() {
3170 return Ok(updates);
3171 }
3172
3173 let wanted: HashSet<&str> = refs.iter().map(String::as_str).collect();
3174 Ok(updates
3175 .into_iter()
3176 .filter(|update| matches_import_ref(update, &wanted))
3177 .collect())
3178}
3179
3180fn matches_import_ref(update: &RefUpdate, wanted: &HashSet<&str>) -> bool {
3181 let full = full_ref_name(update);
3182 wanted.contains(update.name.as_str()) || wanted.contains(full.as_str())
3183}
3184
3185fn full_ref_name(update: &RefUpdate) -> String {
3186 GitRefName::content_full_name(update.namespace, &update.name)
3187}
3188
3189#[cfg(test)]
3190pub fn ensure_commit_update_fast_forward(
3191 repo: &SleyRepository,
3192 name: &str,
3193 old: ObjectId,
3194 new: ObjectId,
3195) -> GitProjectionResult<()> {
3196 if old == new || old == ObjectId::null(repo.object_format()) {
3197 return Ok(());
3198 }
3199 match commit_is_descendant_of(repo, new, old) {
3200 Ok(true) => Ok(()),
3201 Ok(false) => Err(GitProjectionError::NonFastForwardRef {
3202 name: name.to_string(),
3203 old,
3204 new,
3205 remote_destination: false,
3206 }),
3207 Err(err) => Err(GitProjectionError::Git(format!(
3208 "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"
3209 ))),
3210 }
3211}
3212
3213fn commit_is_descendant_of(
3214 repo: &SleyRepository,
3215 descendant: ObjectId,
3216 ancestor: ObjectId,
3217) -> GitProjectionResult<bool> {
3218 let mut stack = vec![descendant];
3219 let mut seen = HashSet::new();
3220 while let Some(oid) = stack.pop() {
3221 if oid == ancestor {
3222 return Ok(true);
3223 }
3224 if !seen.insert(oid) {
3225 continue;
3226 }
3227 let commit = repo.read_commit(&oid).map_err(git_err)?;
3228 for parent in commit.parents {
3229 stack.push(parent);
3230 }
3231 }
3232 Ok(false)
3233}
3234
3235const HEDDLE_EXPORTED_REFS_FILE: &str = "heddle-exported-refs";
3245
3246const HEDDLE_NETWORK_EXPORTED_REFS_DIR: &str = "git-network-exported-refs";
3253
3254fn exported_refs_manifest_path(target_repo: &SleyRepository) -> PathBuf {
3255 target_repo.git_dir().join(HEDDLE_EXPORTED_REFS_FILE)
3256}
3257
3258fn network_exported_refs_path(heddle_dir: &Path, url: &str) -> PathBuf {
3263 let key = ContentHash::compute_typed("git-network-exported-refs", url.as_bytes()).to_hex();
3264 heddle_dir
3265 .join(HEDDLE_NETWORK_EXPORTED_REFS_DIR)
3266 .join(format!("{key}.refs"))
3267}
3268
3269fn read_exported_refs_at(path: &Path) -> GitProjectionResult<HashMap<String, ObjectId>> {
3277 match fs::read_to_string(path) {
3278 Ok(text) => {
3279 let mut map = HashMap::new();
3280 for line in text.lines() {
3281 let line = line.trim();
3282 if line.is_empty() {
3283 continue;
3284 }
3285 let mut parts = line.split_whitespace();
3293 let Some(name) = parts.next() else {
3294 continue;
3295 };
3296 let tip = parts
3297 .next()
3298 .and_then(|token| token.parse::<ObjectId>().ok())
3299 .unwrap_or_else(|| ObjectId::null(ObjectFormat::Sha1));
3300 map.insert(name.to_string(), tip);
3301 }
3302 Ok(map)
3303 }
3304 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
3305 Err(e) => Err(GitProjectionError::Io(e)),
3306 }
3307}
3308
3309fn write_exported_refs_at(
3313 path: &Path,
3314 refs: &HashMap<String, ObjectId>,
3315) -> GitProjectionResult<()> {
3316 if let Some(parent) = path.parent() {
3317 fs::create_dir_all(parent)?;
3318 }
3319 let mut sorted: Vec<(&str, &ObjectId)> = refs
3320 .iter()
3321 .map(|(name, tip)| (name.as_str(), tip))
3322 .collect();
3323 sorted.sort_unstable_by(|a, b| a.0.cmp(b.0));
3324 let body = sorted
3325 .iter()
3326 .map(|(name, tip)| format!("{name} {tip}"))
3327 .collect::<Vec<_>>()
3328 .join("\n");
3329 let tmp = path.with_extension("tmp");
3330 fs::write(&tmp, body)?;
3331 fs::rename(&tmp, path)?;
3332 Ok(())
3333}
3334
3335pub fn write_head_symref(git_dir: &Path, branch_ref: &str) -> GitProjectionResult<()> {
3338 let repo = repo_for_git_dir(git_dir)?;
3339 repo.set_head_symref(branch_ref, HeadUpdateOptions::new())
3340 .map_err(git_err)?;
3341 Ok(())
3342}
3343
3344fn repo_for_git_dir(git_dir: &Path) -> GitProjectionResult<SleyRepository> {
3345 if let Ok(repo) = open_repo(git_dir) {
3346 return Ok(repo);
3347 }
3348 if git_dir.file_name().is_some_and(|name| name == ".git")
3349 && let Some(parent) = git_dir.parent()
3350 {
3351 return open_repo(parent);
3352 }
3353 open_repo(git_dir)
3354}
3355
3356pub fn read_exported_refs(
3359 target_repo: &SleyRepository,
3360) -> GitProjectionResult<HashMap<String, ObjectId>> {
3361 read_exported_refs_at(&exported_refs_manifest_path(target_repo))
3362}
3363
3364pub fn write_exported_refs(
3367 target_repo: &SleyRepository,
3368 refs: &HashMap<String, ObjectId>,
3369) -> GitProjectionResult<()> {
3370 write_exported_refs_at(&exported_refs_manifest_path(target_repo), refs)
3371}
3372
3373const HEDDLE_MIRROR_MANAGED_REFS_FILE: &str = "heddle-mirror-managed-refs";
3385
3386fn mirror_managed_refs_path(mirror_repo: &SleyRepository) -> PathBuf {
3388 mirror_repo.git_dir().join(HEDDLE_MIRROR_MANAGED_REFS_FILE)
3389}
3390
3391pub fn mirror_managed_refs_recorded(mirror_repo: &SleyRepository) -> bool {
3397 mirror_managed_refs_path(mirror_repo).exists()
3398}
3399
3400pub fn read_mirror_managed_refs(
3404 mirror_repo: &SleyRepository,
3405) -> GitProjectionResult<HashMap<String, ObjectId>> {
3406 read_exported_refs_at(&mirror_managed_refs_path(mirror_repo))
3407}
3408
3409pub fn write_mirror_managed_refs(
3412 mirror_repo: &SleyRepository,
3413 refs: &HashMap<String, ObjectId>,
3414) -> GitProjectionResult<()> {
3415 write_exported_refs_at(&mirror_managed_refs_path(mirror_repo), refs)
3416}
3417
3418pub fn read_or_seed_mirror_managed_refs(
3431 mirror_repo: &SleyRepository,
3432) -> GitProjectionResult<HashMap<String, ObjectId>> {
3433 if mirror_managed_refs_recorded(mirror_repo) {
3434 read_mirror_managed_refs(mirror_repo)
3435 } else {
3436 Ok(collect_ref_updates(mirror_repo)?
3437 .into_iter()
3438 .map(|update| (full_ref_name(&update), update.target))
3439 .collect())
3440 }
3441}
3442
3443pub fn collect_managed_ref_updates(
3453 repo: &SleyRepository,
3454 record: &HashMap<String, ObjectId>,
3455) -> GitProjectionResult<Vec<RefUpdate>> {
3456 Ok(collect_ref_updates(repo)?
3457 .into_iter()
3458 .filter(|update| {
3459 matches!(update.namespace, RefNamespace::Note)
3460 || record.contains_key(&full_ref_name(update))
3461 })
3462 .collect())
3463}
3464
3465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3471enum RefMove {
3472 Unchanged,
3474 Create,
3476 FastForward,
3478 Rewind,
3487 Diverged,
3490}
3491
3492fn classify_ref_move(
3508 repo: &SleyRepository,
3509 old: Option<ObjectId>,
3510 new: ObjectId,
3511 recorded_tip: Option<ObjectId>,
3512) -> GitProjectionResult<RefMove> {
3513 let Some(old) = old else {
3514 return Ok(RefMove::Create);
3515 };
3516 if old == ObjectId::null(repo.object_format()) {
3517 return Ok(RefMove::Create);
3518 }
3519 if old == new {
3520 return Ok(RefMove::Unchanged);
3521 }
3522 if commit_is_descendant_of(repo, new, old)? {
3525 return Ok(RefMove::FastForward);
3526 }
3527 if recorded_tip == Some(old)
3537 && repo.read_commit(&old).is_ok()
3538 && commit_is_descendant_of(repo, old, new)?
3539 {
3540 return Ok(RefMove::Rewind);
3541 }
3542 Ok(RefMove::Diverged)
3543}
3544
3545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3559enum WriteVerdict {
3560 Skip,
3562 Write,
3565 RequireForce,
3567}
3568
3569fn verdict_from_move(m: RefMove) -> WriteVerdict {
3574 match m {
3575 RefMove::Unchanged => WriteVerdict::Skip,
3576 RefMove::Create | RefMove::FastForward | RefMove::Rewind => WriteVerdict::Write,
3577 RefMove::Diverged => WriteVerdict::RequireForce,
3578 }
3579}
3580
3581fn classify_tag_move(
3589 old: Option<ObjectId>,
3590 target: ObjectId,
3591 recorded: Option<ObjectId>,
3592) -> WriteVerdict {
3593 match old {
3594 None => WriteVerdict::Write,
3596 Some(o) if o == target => WriteVerdict::Skip,
3598 Some(o) if recorded == Some(o) => WriteVerdict::Write,
3600 Some(_) => WriteVerdict::RequireForce,
3602 }
3603}
3604
3605#[derive(Debug)]
3608pub struct PlannedRefWrite {
3609 pub full_name: String,
3610 pub old: Option<ObjectId>,
3611 pub new: ObjectId,
3612 pub force: bool,
3613}
3614
3615#[derive(Debug)]
3618pub struct PlannedRefDelete {
3619 pub full_name: String,
3620 pub old: ObjectId,
3621}
3622
3623#[derive(Debug)]
3626pub struct DestinationReconcilePlan {
3627 pub writes: Vec<PlannedRefWrite>,
3629 pub deletes: Vec<PlannedRefDelete>,
3632 pub new_manifest: HashMap<String, ObjectId>,
3638}
3639
3640pub fn planned_write_names(plan: &DestinationReconcilePlan) -> Vec<String> {
3647 let mut names: Vec<String> = plan
3648 .writes
3649 .iter()
3650 .map(|write| write.full_name.clone())
3651 .collect();
3652 names.sort_unstable();
3653 names
3654}
3655
3656fn creatable_ref_names(
3665 served_frontier: &[RefUpdate],
3666 scope: GitPushScope,
3667 current_branch: Option<&str>,
3668) -> Option<HashSet<String>> {
3669 match scope {
3670 GitPushScope::AllThreads => None,
3671 GitPushScope::CurrentThread => {
3672 let branch = current_branch.unwrap_or_default();
3673 Some(
3674 served_frontier
3675 .iter()
3676 .filter(|update| {
3677 (matches!(update.namespace, RefNamespace::Branch) && update.name == branch)
3678 || matches!(update.namespace, RefNamespace::Note)
3679 })
3680 .map(full_ref_name)
3681 .collect(),
3682 )
3683 }
3684 }
3685}
3686
3687pub fn plan_destination_reconcile(
3735 mirror_repo: &SleyRepository,
3736 served_frontier: &[RefUpdate],
3737 creatable_names: Option<&HashSet<String>>,
3738 old_at_destination: &HashMap<String, ObjectId>,
3739 previously_exported: &HashMap<String, ObjectId>,
3740 force: bool,
3741) -> GitProjectionResult<DestinationReconcilePlan> {
3742 let desired: HashMap<String, &RefUpdate> = served_frontier
3748 .iter()
3749 .map(|u| (full_ref_name(u), u))
3750 .collect();
3751
3752 let mut names: BTreeSet<String> = desired.keys().cloned().collect();
3759 names.extend(previously_exported.keys().cloned());
3760
3761 let mut writes = Vec::new();
3762 let mut deletes = Vec::new();
3763 let mut new_manifest: HashMap<String, ObjectId> = HashMap::new();
3764
3765 for full in names {
3766 let old = old_at_destination.get(&full).copied();
3767 let recorded = previously_exported.get(&full).copied();
3768
3769 if let Some(update) = desired.get(&full).copied() {
3770 if old.is_none() && creatable_names.is_some_and(|names| !names.contains(&full)) {
3779 if let Some(recorded) = recorded {
3780 new_manifest.insert(full, recorded);
3781 }
3782 continue;
3783 }
3784 let (verdict, force_write) = match update.namespace {
3793 RefNamespace::Branch | RefNamespace::Note => {
3794 let movement = classify_ref_move(mirror_repo, old, update.target, recorded)?;
3795 (
3796 verdict_from_move(movement),
3797 matches!(movement, RefMove::Rewind),
3798 )
3799 }
3800 RefNamespace::Tag => {
3801 let verdict = classify_tag_move(old, update.target, recorded);
3802 (
3803 verdict,
3804 old.is_some_and(|old| old != update.target)
3805 && matches!(verdict, WriteVerdict::Write),
3806 )
3807 }
3808 };
3809 let proceed = match verdict {
3810 WriteVerdict::Skip => false,
3811 WriteVerdict::Write => true,
3812 WriteVerdict::RequireForce => {
3813 if force {
3814 true
3815 } else {
3816 return Err(GitProjectionError::NonFastForwardRef {
3817 name: full.clone(),
3818 old: old.unwrap_or_else(|| ObjectId::null(mirror_repo.object_format())),
3819 new: update.target,
3820 remote_destination: true,
3821 });
3822 }
3823 }
3824 };
3825 if proceed {
3826 writes.push(PlannedRefWrite {
3827 full_name: full.clone(),
3828 old,
3829 new: update.target,
3830 force: force_write || matches!(verdict, WriteVerdict::RequireForce),
3831 });
3832 }
3833 if proceed || recorded.is_some() {
3841 new_manifest.insert(full, update.target);
3842 }
3843 continue;
3844 }
3845
3846 match old {
3855 Some(old) if recorded == Some(old) || force => {
3856 deletes.push(PlannedRefDelete {
3857 full_name: full,
3858 old,
3859 });
3860 }
3862 Some(_) => {
3863 if let Some(recorded) = recorded {
3866 new_manifest.insert(full, recorded);
3867 }
3868 }
3869 None => {
3870 }
3872 }
3873 }
3874
3875 Ok(DestinationReconcilePlan {
3876 writes,
3877 deletes,
3878 new_manifest,
3879 })
3880}
3881
3882fn read_destination_ref_map(
3886 repo: &SleyRepository,
3887) -> GitProjectionResult<HashMap<String, ObjectId>> {
3888 Ok(collect_ref_updates(repo)?
3889 .iter()
3890 .map(|update| (full_ref_name(update), update.target))
3891 .collect())
3892}
3893
3894pub fn apply_ref_updates(
3895 repo: &SleyRepository,
3896 updates: &[RefUpdate],
3897 log_message: &str,
3898) -> GitProjectionResult<()> {
3899 for update in updates {
3900 let full_name = full_ref_name(update);
3901 set_reference(
3902 repo,
3903 &full_name,
3904 update.target,
3905 RefPrecondition::Any,
3906 log_message,
3907 )?;
3908 }
3909 Ok(())
3910}
3911
3912fn apply_remote_tracking_ref_updates(
3913 repo: &SleyRepository,
3914 remote_name: &str,
3915 updates: &[RefUpdate],
3916 log_message: &str,
3917) -> GitProjectionResult<()> {
3918 reject_reserved_git_remote_name(remote_name)?;
3919 for update in updates
3920 .iter()
3921 .filter(|update| update.namespace == RefNamespace::Branch)
3922 {
3923 set_reference(
3924 repo,
3925 &format!("refs/remotes/{remote_name}/{}", update.name),
3926 update.target,
3927 RefPrecondition::Any,
3928 log_message,
3929 )?;
3930 }
3931 Ok(())
3932}
3933
3934pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitProjectionResult<()> {
3938 fs::create_dir_all(dest)?;
3939 let source = open_repo(source_path)?;
3940 let target = match SleyRepository::open(dest) {
3941 Ok(repo) => repo,
3942 Err(_) => SleyRepository::init_bare(dest).map_err(git_err)?,
3943 };
3944 let updates = collect_ref_updates(&source)?;
3945 copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
3946 apply_ref_updates(
3947 &target,
3948 &updates,
3949 &format!("heddle: clone from {}", source_path.display()),
3950 )?;
3951
3952 let copied_branches: HashSet<&str> = updates
3960 .iter()
3961 .filter(|update| update.namespace == RefNamespace::Branch)
3962 .map(|update| update.name.as_str())
3963 .collect();
3964 let source_head_branch = source
3965 .head()
3966 .ok()
3967 .and_then(|head| head.branch_name().map(str::to_owned))
3968 .filter(|branch| copied_branches.contains(branch.as_str()));
3969 if let Some(branch) = source_head_branch {
3970 write_head_symref(dest, &format!("refs/heads/{branch}"))?;
3971 } else if copied_branches.contains("main") {
3972 write_head_symref(dest, "refs/heads/main")?;
3973 } else if let Some(first_branch) = updates
3974 .iter()
3975 .find(|update| update.namespace == RefNamespace::Branch)
3976 {
3977 write_head_symref(dest, &format!("refs/heads/{}", first_branch.name))?;
3978 }
3979 Ok(())
3980}
3981
3982pub fn clone_url_to_bare(
4001 url: &str,
4002 dest: &Path,
4003 depth: Option<u32>,
4004 filter: Option<&str>,
4005 progress: &mut dyn ProgressSink,
4006) -> GitProjectionResult<()> {
4007 if let Some(spec) = filter {
4011 return Err(GitProjectionError::Git(format!(
4012 "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"
4013 )));
4014 }
4015 if let Some(source_path) = local_path_from_url(url)? {
4016 if depth.is_some() {
4017 return Err(GitProjectionError::Git(
4018 "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"
4019 .to_string(),
4020 ));
4021 }
4022 return copy_local_repo_to_bare(&source_path, dest);
4023 }
4024 let default_branch = clone_url_to_bare_via_sley(url, dest, depth, progress)?
4025 .or_else(|| default_branch_from_file_url(url));
4026 if let Some(branch) = default_branch
4036 && bare_branch_exists(dest, &branch)?
4037 {
4038 write_head_symref(dest, &format!("refs/heads/{branch}"))?;
4039 }
4040 Ok(())
4041}
4042
4043fn default_branch_from_file_url(url: &str) -> Option<String> {
4044 let source_path = local_path_from_url(url).ok().flatten()?;
4045 let repo = open_repo(&source_path).ok()?;
4046 let head = repo.head_state().ok()?;
4047 let branch = head.branch_name()?;
4048 (!branch.is_empty()).then(|| branch.to_string())
4049}
4050
4051fn bare_branch_exists(repo_path: &Path, branch: &str) -> GitProjectionResult<bool> {
4052 let repo = open_repo(repo_path)?;
4053 Ok(repo
4054 .find_reference(&format!("refs/heads/{branch}"))
4055 .map_err(git_err)?
4056 .is_some())
4057}
4058
4059fn clone_url_to_bare_via_sley(
4060 url: &str,
4061 dest: &Path,
4062 depth: Option<u32>,
4063 progress: &mut dyn ProgressSink,
4064) -> GitProjectionResult<Option<String>> {
4065 fs::create_dir_all(dest)?;
4066 let repo = SleyRepository::init_bare(dest).map_err(git_err)?;
4067 let mut credentials =
4068 EmbeddingSafeCredentialProvider::new(&repo.config_snapshot().map_err(git_err)?);
4069 let display_url = sley::plumbing::sley_core::redact_url_for_display(url);
4070 let outcome = repo
4071 .fetch(
4072 url,
4073 &heddle_mirror_fetch_refspecs()?,
4074 FetchOptions {
4075 filter_auto: false,
4077 progress: None,
4078 upload_pack_command: None,
4079 negotiation_include: None,
4080 negotiation_restrict: None,
4081 reject_shallow: false,
4082 quiet: true,
4083 auto_follow_tags: true,
4084 fetch_all_tags: true,
4085 prune: false,
4086 dry_run: false,
4087 append: false,
4088 write_fetch_head: true,
4089 force: false,
4090 tag_option_explicit: true,
4091 prune_option_explicit: true,
4092 prune_tags: false,
4093 prune_tags_option_explicit: false,
4094 refmap: None,
4095 refetch: false,
4096 record_promisor_refs: false,
4097 update_head_ok: false,
4098 ssh_options: None,
4099 atomic: false,
4100 depth,
4101 merge_srcs: Vec::new(),
4102 filter: None,
4103 cloning: true,
4104 update_shallow: false,
4105 deepen_relative: false,
4106 deepen_since: None,
4107 deepen_not: Vec::new(),
4108 },
4109 &mut credentials,
4110 progress,
4111 )
4112 .map_err(|err| GitProjectionError::Git(format!("clone failed for {display_url}: {err}")))?;
4113 Ok(outcome
4114 .head_symref
4115 .and_then(|target| target.strip_prefix("refs/heads/").map(str::to_string)))
4116}
4117
4118#[allow(clippy::too_many_arguments)]
4151pub fn materialize_checkout_closure_from_state(
4152 heddle_repo: &HeddleRepository,
4153 mapping: &SyncMapping,
4154 mirror_repo: &SleyRepository,
4155 object_repo: &SleyRepository,
4156 tip_state_id: &StateId,
4157 tip_oid: ObjectId,
4158 excluded: &HashSet<ObjectId>,
4159) -> GitProjectionResult<()> {
4160 let mut lossy_roots: Vec<ObjectId> = Vec::new();
4164 let mut stack: Vec<StateId> = vec![*tip_state_id];
4165 let mut seen: HashSet<StateId> = HashSet::new();
4166 let residual_store = ResidualStore::open(heddle_repo.heddle_dir());
4167
4168 while let Some(state_id) = stack.pop() {
4169 if !seen.insert(state_id) {
4170 continue;
4171 }
4172 let Some(git_oid) = resolve_mapped_git_oid(heddle_repo, mapping, &state_id, object_repo)?
4173 else {
4174 continue;
4180 };
4181
4182 if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
4186 continue;
4187 }
4188
4189 let state = heddle_repo
4190 .store()
4191 .get_state(&state_id)?
4192 .ok_or(GitProjectionError::StateNotFound(state_id))?;
4193
4194 if commit_is_byte_faithful(&state) {
4195 let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
4196 let reconstructed = commit_object_id(&content);
4200 if reconstructed != git_oid {
4201 return Err(GitProjectionError::Git(format!(
4202 "checkout reconstruction OID mismatch for state {state_id}: reconstructed {reconstructed}, expected mapped {git_oid}; \
4203 refusing to materialize a wrong-OID checkout (unmodeled fidelity gap)"
4204 )));
4205 }
4206 let written = write_commit_object(object_repo, &content)?;
4207 debug_assert_eq!(written, git_oid);
4208 stack.extend(state.parents.iter().copied());
4209 } else {
4210 lossy_roots.push(git_oid);
4217 }
4218 }
4219
4220 if object_repo.read_object(&tip_oid).is_err() && !lossy_roots.contains(&tip_oid) {
4226 lossy_roots.push(tip_oid);
4227 }
4228
4229 if !lossy_roots.is_empty() {
4230 materialize_lossy_roots_from_residual_or_mirror(
4231 &residual_store,
4232 mirror_repo,
4233 object_repo,
4234 &lossy_roots,
4235 excluded,
4236 )?;
4237 }
4238
4239 Ok(())
4240}
4241
4242fn materialize_lossy_roots_from_residual_or_mirror(
4247 residual_store: &ResidualStore,
4248 mirror_repo: &SleyRepository,
4249 object_repo: &SleyRepository,
4250 lossy_roots: &[ObjectId],
4251 excluded: &HashSet<ObjectId>,
4252) -> GitProjectionResult<()> {
4253 let format = object_repo.object_format();
4254 let mut mirror_needed: Vec<ObjectId> = Vec::new();
4255
4256 for oid in lossy_roots {
4257 if excluded.contains(oid) || object_repo.read_object(oid).is_ok() {
4258 continue;
4259 }
4260 match residual_store.install_into(object_repo, oid) {
4267 Ok(true) => {
4268 mirror_needed.push(*oid);
4271 }
4272 Ok(false) => {
4273 let residual = resolve_lossy_object(
4276 residual_store,
4277 Some(mirror_repo),
4278 format,
4279 oid,
4280 true, )?;
4282 let written = object_repo
4283 .write_object(sley::plumbing::sley_object::EncodedObject::new(
4284 residual.object_type,
4285 residual.body,
4286 ))
4287 .map_err(git_err)?;
4288 if written != *oid {
4289 return Err(GitProjectionError::Git(format!(
4290 "lossy materialize wrote {written}, expected {oid}"
4291 )));
4292 }
4293 mirror_needed.push(*oid);
4294 }
4295 Err(error) => return Err(error),
4296 }
4297 }
4298
4299 if !mirror_needed.is_empty() {
4300 if let Err(error) = copy_reachable_objects_excluding(
4306 mirror_repo,
4307 object_repo,
4308 mirror_needed.iter().copied(),
4309 excluded,
4310 ) {
4311 match verify_closure_present(object_repo, &mirror_needed, excluded) {
4317 Ok(()) => {
4318 }
4321 Err(ClosureCheck::Incomplete { missing }) => {
4322 return Err(GitProjectionError::Git(format!(
4327 "checkout object closure incomplete after mirror copy failure: \
4328missing reachable object {missing} (mirror error: {error})"
4329 )));
4330 }
4331 Err(ClosureCheck::Walk(walk_error)) => {
4332 return Err(GitProjectionError::Git(format!(
4335 "mirror copy failed ({error}); closure verification also failed: {walk_error}"
4336 )));
4337 }
4338 }
4339 }
4340 }
4341
4342 Ok(())
4343}
4344
4345enum ClosureCheck {
4347 Incomplete { missing: ObjectId },
4350 Walk(GitProjectionError),
4352}
4353
4354fn verify_closure_present(
4364 object_repo: &SleyRepository,
4365 roots: &[ObjectId],
4366 excluded: &HashSet<ObjectId>,
4367) -> Result<(), ClosureCheck> {
4368 const GITLINK_MODE: u32 = 0o160000;
4369
4370 let mut stack: Vec<ObjectId> = roots.to_vec();
4371 let mut seen: HashSet<ObjectId> = HashSet::new();
4372 while let Some(oid) = stack.pop() {
4373 if excluded.contains(&oid) || !seen.insert(oid) {
4374 continue;
4375 }
4376 let object = match object_repo.read_object(&oid) {
4377 Ok(object) => object,
4378 Err(_) => return Err(ClosureCheck::Incomplete { missing: oid }),
4379 };
4380 match object.object_type {
4381 GitObjectType::Commit => {
4382 let commit = object_repo
4383 .read_commit(&oid)
4384 .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4385 stack.push(commit.tree);
4386 for parent in commit.parents {
4387 stack.push(parent);
4388 }
4389 }
4390 GitObjectType::Tree => {
4391 let tree = object_repo
4392 .read_tree(&oid)
4393 .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4394 for entry in tree.entries {
4395 if entry.mode == GITLINK_MODE {
4398 continue;
4399 }
4400 stack.push(entry.oid);
4401 }
4402 }
4403 GitObjectType::Tag => {
4404 let tag = object_repo
4405 .read_tag(&oid)
4406 .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4407 stack.push(tag.object);
4408 }
4409 GitObjectType::Blob => {}
4410 }
4411 }
4412 Ok(())
4413}
4414
4415fn resolve_mapped_git_oid(
4420 heddle_repo: &HeddleRepository,
4421 mapping: &SyncMapping,
4422 state_id: &StateId,
4423 object_repo: &SleyRepository,
4424) -> GitProjectionResult<Option<ObjectId>> {
4425 if let Some(git_oid) = mapping.get_git(state_id) {
4426 return Ok(Some(git_oid));
4427 }
4428 if let Some(git_commit) = heddle_repo
4429 .git_overlay_mapped_git_commit_for_state(state_id)
4430 .map_err(|error| GitProjectionError::Git(error.to_string()))?
4431 {
4432 let oid = ObjectId::from_hex(object_repo.object_format(), &git_commit)
4433 .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?;
4434 return Ok(Some(oid));
4435 }
4436 Ok(None)
4437}
4438
4439pub fn copy_reachable_objects(
4440 source: &SleyRepository,
4441 target: &SleyRepository,
4442 roots: impl IntoIterator<Item = ObjectId>,
4443) -> GitProjectionResult<()> {
4444 let roots = roots.into_iter().collect::<Vec<_>>();
4448 target.copy_reachable_from(source, &roots).map_err(git_err)
4449}
4450
4451pub fn copy_reachable_objects_excluding(
4466 source: &SleyRepository,
4467 target: &SleyRepository,
4468 roots: impl IntoIterator<Item = ObjectId>,
4469 excluded: &HashSet<ObjectId>,
4470) -> GitProjectionResult<()> {
4471 if excluded.is_empty() {
4472 return copy_reachable_objects(source, target, roots);
4473 }
4474 if source.object_format() != target.object_format() {
4475 return copy_reachable_objects(source, target, roots);
4478 }
4479 sley::plumbing::sley_odb::install_reachable_pack_excluding(
4483 source.objects().as_ref(),
4484 target.objects().as_ref(),
4485 target.object_format(),
4486 roots,
4487 excluded,
4488 )
4489 .map_err(|error| GitProjectionError::Git(error.to_string()))?;
4490 target.refresh_objects();
4493 Ok(())
4494}
4495
4496fn fetch_network_remote(
4497 mirror_repo: &SleyRepository,
4498 remote_name: &str,
4499 url: &str,
4500 scope: GitFetchScope,
4501) -> GitProjectionResult<()> {
4502 let mut credentials = NoCredentials;
4503 let mut progress = SilentProgress;
4504 mirror_repo
4505 .fetch(
4506 url,
4507 &heddle_mirror_fetch_refspecs()?,
4508 FetchOptions {
4509 filter_auto: false,
4511 progress: None,
4512 upload_pack_command: None,
4513 negotiation_include: None,
4514 negotiation_restrict: None,
4515 reject_shallow: false,
4516 quiet: true,
4517 auto_follow_tags: matches!(scope, GitFetchScope::AllRefs),
4518 fetch_all_tags: matches!(scope, GitFetchScope::AllRefs),
4519 prune: false,
4520 dry_run: false,
4521 append: false,
4522 write_fetch_head: true,
4523 force: false,
4524 tag_option_explicit: true,
4525 prune_option_explicit: true,
4526 prune_tags: false,
4527 prune_tags_option_explicit: false,
4528 refmap: None,
4529 refetch: false,
4530 record_promisor_refs: false,
4531 update_head_ok: false,
4532 ssh_options: None,
4533 atomic: false,
4534 depth: None,
4535 merge_srcs: Vec::new(),
4536 filter: None,
4537 cloning: false,
4538 update_shallow: false,
4539 deepen_relative: false,
4540 deepen_since: None,
4541 deepen_not: Vec::new(),
4542 },
4543 &mut credentials,
4544 &mut progress,
4545 )
4546 .map_err(|err| GitProjectionError::Git(format!("failed to fetch from {url}: {err}")))?;
4547 let _ = remote_name;
4548 Ok(())
4549}
4550
4551fn push_network_remote(
4554 mirror_repo: &SleyRepository,
4555 heddle_dir: &Path,
4556 url: &str,
4557 scope: GitPushScope,
4558 current_branch: Option<&str>,
4559 force: bool,
4560) -> GitProjectionResult<Vec<String>> {
4561 let manifest_path = network_exported_refs_path(heddle_dir, url);
4567 let previously_exported = read_exported_refs_at(&manifest_path)?;
4568 let managed_record = read_mirror_managed_refs(mirror_repo)?;
4578 let served_frontier = collect_managed_ref_updates(mirror_repo, &managed_record)?;
4579 if served_frontier.is_empty() && previously_exported.is_empty() {
4580 return Ok(Vec::new());
4581 }
4582
4583 let mut credentials = NoCredentials;
4584 let records = mirror_repo
4585 .ls_remote(
4586 url,
4587 LsRemoteFilter {
4588 heads: false,
4589 tags: false,
4590 refs_only: true,
4591 },
4592 &|_| true,
4593 &mut credentials,
4594 )
4595 .map_err(|err| GitProjectionError::Git(format!("failed to list refs from {url}: {err}")))?;
4596 let remote_refs = records
4597 .into_iter()
4598 .filter(|record| GitRefName::new(&record.name).content_namespace().is_some())
4599 .map(|record| (record.name, record.oid))
4600 .collect::<HashMap<_, _>>();
4601
4602 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4607 let plan = plan_destination_reconcile(
4608 mirror_repo,
4609 &served_frontier,
4610 creatable.as_ref(),
4611 &remote_refs,
4612 &previously_exported,
4613 force,
4614 )?;
4615
4616 if plan.writes.is_empty() && plan.deletes.is_empty() {
4617 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4620 return Ok(Vec::new());
4621 }
4622
4623 let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4624 let mut pack_objects = Vec::with_capacity(plan.writes.len());
4625 let force_transport_checks = plan.writes.iter().any(|write| write.force);
4626 for write in &plan.writes {
4627 commands.push(PushCommand {
4628 src: Some(write.new),
4629 dst: write.full_name.clone(),
4630 expected_old: write.old,
4631 force: write.force,
4632 });
4633 pack_objects.push(write.new);
4634 }
4635 for delete in &plan.deletes {
4636 commands.push(PushCommand {
4637 src: None,
4638 dst: delete.full_name.clone(),
4639 expected_old: Some(delete.old),
4640 force: false,
4641 });
4642 }
4643
4644 let mut credentials = NoCredentials;
4645 let mut progress = SilentProgress;
4646 mirror_repo
4647 .push_actions(
4648 url,
4649 PushActionPlan {
4650 commands,
4651 pack_objects,
4652 options: PushOptions {
4653 atomic: false,
4655 push_options: Vec::new(),
4656 quiet: true,
4657 force: force || force_transport_checks,
4658 thin: sley::remote::PushThinMode::Auto,
4659 },
4660 },
4661 &mut credentials,
4662 &mut progress,
4663 )
4664 .map_err(|err| GitProjectionError::Git(format!("push failed for {url}: {err}")))?;
4665 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4668 Ok(planned_write_names(&plan))
4669}
4670
4671pub struct AuthoritativeGitPushOptions<'a> {
4677 pub heddle_dir: &'a Path,
4678 pub remote: &'a str,
4679 pub scope: GitPushScope,
4680 pub current_branch: Option<&'a str>,
4681 pub force: bool,
4682}
4683
4684pub fn push_authoritative_git_refs(
4685 source: &SleyRepository,
4686 options: AuthoritativeGitPushOptions<'_>,
4687 credentials: &mut dyn CredentialProvider,
4688 progress: &mut dyn ProgressSink,
4689) -> GitProjectionResult<Vec<String>> {
4690 let AuthoritativeGitPushOptions {
4691 heddle_dir,
4692 remote,
4693 scope,
4694 current_branch,
4695 force,
4696 } = options;
4697 let remote_url = source.remote(remote).map_err(git_err)?.push_url();
4698 let manifest_path = network_exported_refs_path(heddle_dir, &remote_url);
4699 let previously_exported = read_exported_refs_at(&manifest_path)?;
4700 let served_frontier = collect_ref_updates(source)?
4701 .into_iter()
4702 .filter(|update| {
4703 matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag)
4704 || (update.namespace == RefNamespace::Note && update.name == "heddle")
4705 })
4706 .filter(|update| match scope {
4707 GitPushScope::AllThreads => true,
4708 GitPushScope::CurrentThread => {
4709 update.namespace == RefNamespace::Note
4710 || (update.namespace == RefNamespace::Branch
4711 && Some(update.name.as_str()) == current_branch)
4712 }
4713 })
4714 .collect::<Vec<_>>();
4715
4716 let records = source
4717 .ls_remote(
4718 &remote_url,
4719 LsRemoteFilter {
4720 heads: false,
4721 tags: false,
4722 refs_only: true,
4723 },
4724 &|_| true,
4725 credentials,
4726 )
4727 .map_err(git_err)?;
4728 let remote_refs = records
4729 .into_iter()
4730 .filter(|record| GitRefName::new(&record.name).content_namespace().is_some())
4731 .map(|record| (record.name, record.oid))
4732 .collect::<HashMap<_, _>>();
4733 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4734 let previously_exported_in_scope = previously_exported
4735 .iter()
4736 .filter(|(name, _)| match scope {
4737 GitPushScope::AllThreads => true,
4738 GitPushScope::CurrentThread => {
4739 name.as_str() == "refs/notes/heddle"
4740 || current_branch
4741 .is_some_and(|branch| name.as_str() == format!("refs/heads/{branch}"))
4742 }
4743 })
4744 .map(|(name, oid)| (name.clone(), *oid))
4745 .collect::<HashMap<_, _>>();
4746 let mut plan = plan_destination_reconcile(
4747 source,
4748 &served_frontier,
4749 creatable.as_ref(),
4750 &remote_refs,
4751 &previously_exported_in_scope,
4752 force,
4753 )?;
4754 if scope == GitPushScope::CurrentThread {
4755 for (name, oid) in previously_exported {
4756 plan.new_manifest.entry(name).or_insert(oid);
4757 }
4758 }
4759 if plan.writes.is_empty() && plan.deletes.is_empty() {
4760 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4761 return Ok(Vec::new());
4762 }
4763
4764 let force_transport_checks = plan.writes.iter().any(|write| write.force);
4765 let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4766 let mut pack_objects = Vec::with_capacity(plan.writes.len());
4767 for write in &plan.writes {
4768 commands.push(PushCommand {
4769 src: Some(write.new),
4770 dst: write.full_name.clone(),
4771 expected_old: write.old,
4772 force: write.force,
4773 });
4774 pack_objects.push(write.new);
4775 }
4776 for delete in &plan.deletes {
4777 commands.push(PushCommand {
4778 src: None,
4779 dst: delete.full_name.clone(),
4780 expected_old: Some(delete.old),
4781 force: false,
4782 });
4783 }
4784 source
4785 .push_actions(
4786 remote,
4787 PushActionPlan {
4788 commands,
4789 pack_objects,
4790 options: PushOptions {
4791 quiet: true,
4792 force: force || force_transport_checks,
4793 thin: sley::remote::PushThinMode::Auto,
4794 atomic: false,
4795 push_options: Vec::new(),
4796 },
4797 },
4798 credentials,
4799 progress,
4800 )
4801 .map_err(git_err)?;
4802 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4803 Ok(planned_write_names(&plan))
4804}
4805
4806#[cfg(test)]
4807mod tests {
4808 use super::*;
4809
4810 #[test]
4811 fn parse_git_ref_local_branch() {
4812 let parsed = parse_git_ref("refs/heads/main").expect("local branch parses");
4813 assert_eq!(parsed.kind, GitRefKind::Branch);
4814 assert_eq!(parsed.name, "main");
4815 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4816 }
4817
4818 #[test]
4819 fn parse_git_ref_remote_branch_keeps_nested_name() {
4820 let parsed = parse_git_ref("refs/remotes/origin/feature/x").expect("remote branch parses");
4821 assert_eq!(parsed.kind, GitRefKind::Branch);
4822 assert_eq!(parsed.name, "feature/x");
4823 assert_eq!(parsed.remote, "origin");
4824 }
4825
4826 #[test]
4827 fn parse_git_ref_tag() {
4828 let parsed = parse_git_ref("refs/tags/v1.0").expect("tag parses");
4829 assert_eq!(parsed.kind, GitRefKind::Tag);
4830 assert_eq!(parsed.name, "v1.0");
4831 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4832 }
4833
4834 #[test]
4835 fn parse_git_ref_note() {
4836 let parsed = parse_git_ref("refs/notes/heddle").expect("note parses");
4837 assert_eq!(parsed.kind, GitRefKind::Note);
4838 assert_eq!(parsed.name, "heddle");
4839 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4840 }
4841
4842 #[test]
4843 fn parse_git_ref_skips_head_symrefs() {
4844 assert_eq!(parse_git_ref("refs/heads/HEAD"), None);
4845 assert_eq!(parse_git_ref("refs/remotes/origin/HEAD"), None);
4846 }
4847
4848 #[test]
4849 fn parse_git_ref_rejects_unknown_or_malformed() {
4850 assert_eq!(parse_git_ref("HEAD"), None);
4851 assert_eq!(parse_git_ref("refs/remotes/origin"), None);
4853 }
4854
4855 #[test]
4856 fn parse_git_ref_rejects_reserved_git_remote_namespace() {
4857 assert_eq!(parse_git_ref("refs/remotes/git/main"), None);
4860 assert_eq!(parse_git_ref("refs/remotes/git/feature/x"), None);
4861 assert!(is_reserved_git_remote_name(REMOTE_NAME_FOR_LOCAL_GIT_REPO));
4862 assert!(!is_reserved_git_remote_name("origin"));
4863 }
4864
4865 #[test]
4866 fn local_path_from_url_rejects_hosted_heddle_scheme() {
4867 let err = local_path_from_url("heddle://weft.local:8421/org/repo")
4875 .expect_err("heddle:// must be rejected by the git exporter classifier");
4876 let msg = err.to_string();
4877 assert!(
4878 msg.contains("heddle://") && msg.contains("hosted"),
4879 "error should explain the hosted scheme cannot be pushed via the git-overlay exporter, got: {msg}"
4880 );
4881 }
4882
4883 #[test]
4884 fn local_path_from_url_still_accepts_file_and_git_urls() {
4885 assert!(
4889 local_path_from_url("file:///tmp/repo.git")
4890 .expect("file url ok")
4891 .is_some(),
4892 "file:// must still resolve to a local path"
4893 );
4894 assert!(
4895 local_path_from_url("https://example.com/org/repo.git")
4896 .expect("https url ok")
4897 .is_none(),
4898 "https git url must pass through as a network URL"
4899 );
4900 assert!(
4901 local_path_from_url("git@github.com:org/repo.git")
4902 .expect("ssh url ok")
4903 .is_none(),
4904 "ssh git url must pass through as a network URL"
4905 );
4906 }
4907
4908 #[test]
4909 fn refspec_forced_round_trips_git_format() {
4910 let spec =
4911 RefSpec::forced("refs/heads/main", "refs/heads/main").expect("valid forced refspec");
4912 assert_eq!(spec.to_git_format(), "+refs/heads/main:refs/heads/main");
4913 assert_eq!(
4914 spec.to_git_format_not_forced(),
4915 "refs/heads/main:refs/heads/main"
4916 );
4917 }
4918
4919 #[test]
4920 fn refspec_constructor_rejects_reserved_remote_name() {
4921 let err = RefSpec::new(
4922 Some("refs/remotes/git/main".to_string()),
4923 "refs/heads/main",
4924 false,
4925 )
4926 .expect_err("reserved remote source is rejected");
4927 assert!(err.to_string().contains("reserved namespace"));
4928
4929 let err = RefSpec::new(
4930 Some("refs/heads/main".to_string()),
4931 "refs/remotes/git/main",
4932 false,
4933 )
4934 .expect_err("reserved remote destination is rejected");
4935 assert!(err.to_string().contains("reserved namespace"));
4936 }
4937
4938 #[test]
4939 fn refspec_forced_rejects_reserved_remote_name() {
4940 assert!(RefSpec::forced("refs/remotes/git/main", "refs/heads/main").is_err());
4941 assert!(RefSpec::forced("refs/heads/main", "refs/remotes/git/main").is_err());
4942 }
4943
4944 #[test]
4945 fn refspec_delete_has_empty_source() {
4946 let spec = RefSpec::delete("refs/heads/stale").expect("valid delete refspec");
4947 assert_eq!(spec.to_git_format(), ":refs/heads/stale");
4948 assert_eq!(spec.to_git_format_not_forced(), ":refs/heads/stale");
4949 }
4950
4951 #[test]
4952 fn refspec_delete_rejects_reserved_remote_name() {
4953 assert!(RefSpec::delete("refs/remotes/git/stale").is_err());
4954 }
4955
4956 #[test]
4957 fn refspec_constructor_rejects_empty_source_and_destination() {
4958 let err = RefSpec::new(None, "", false)
4959 .expect_err("empty source plus empty destination is rejected");
4960 assert!(err.to_string().contains("cannot both be empty"));
4961 }
4962
4963 #[test]
4964 fn negative_refspec_prefixes_caret() {
4965 let spec = NegativeRefSpec::new("refs/heads/wip").expect("valid negative refspec");
4966 assert_eq!(spec.to_git_format(), "^refs/heads/wip");
4967 }
4968
4969 #[test]
4970 fn negative_refspec_constructor_rejects_unparseable_negation() {
4971 let err = NegativeRefSpec::new("refs/heads/wip/*").expect_err("negative glob is rejected");
4972 assert!(err.to_string().contains("Negative glob patterns"));
4973 }
4974
4975 #[test]
4976 fn negative_refspec_constructor_rejects_reserved_remote_name() {
4977 let err = NegativeRefSpec::new("refs/remotes/git/main")
4978 .expect_err("reserved remote negative source is rejected");
4979 assert!(err.to_string().contains("reserved namespace"));
4980 }
4981
4982 #[test]
4983 fn mirror_fetch_refspecs_cover_branches_and_notes() {
4984 assert_eq!(
4985 heddle_mirror_fetch_refspecs().expect("mirror refspecs are valid"),
4986 [
4987 "+refs/heads/*:refs/heads/*".to_string(),
4988 "+refs/notes/*:refs/notes/*".to_string(),
4989 ]
4990 );
4991 }
4992
4993 #[test]
4994 fn scoped_import_ref_updates_do_not_include_notes_implicitly() {
4995 let tmp = tempfile::TempDir::new().unwrap();
4996 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
4997 let main = seed_commit(&repo, "main");
4998 let other = seed_commit(&repo, "other");
4999 let notes = seed_commit(&repo, "notes");
5000 set_reference(
5001 &repo,
5002 "refs/heads/main",
5003 main,
5004 RefPrecondition::MustNotExist,
5005 "test: main",
5006 )
5007 .expect("write main");
5008 set_reference(
5009 &repo,
5010 "refs/heads/other",
5011 other,
5012 RefPrecondition::MustNotExist,
5013 "test: other",
5014 )
5015 .expect("write other");
5016 set_reference(
5017 &repo,
5018 "refs/notes/heddle",
5019 notes,
5020 RefPrecondition::MustNotExist,
5021 "test: notes",
5022 )
5023 .expect("write notes");
5024
5025 let updates = collect_import_source_ref_updates(&repo, &["main".to_string()])
5026 .expect("collect scoped updates");
5027 let full_names = updates.iter().map(full_ref_name).collect::<Vec<_>>();
5028
5029 assert_eq!(full_names, vec!["refs/heads/main".to_string()]);
5030 }
5031
5032 #[test]
5033 fn fast_forward_guard_reports_exact_rewrite_before_after() {
5034 let tmp = tempfile::TempDir::new().unwrap();
5035 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
5036 let root = test_commit(&repo, "root", &[]);
5037 let old = test_commit(&repo, "old", &[root]);
5038 let new = test_commit(&repo, "new", &[root]);
5039
5040 let err = ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
5041 .expect_err("sibling commit update should be refused");
5042 let message = err.to_string();
5043 assert!(message.contains("refs/heads/main"));
5044 assert!(message.contains(&old.to_string()));
5045 assert!(message.contains(&new.to_string()));
5046 assert!(message.contains("refusing to replace"));
5047 }
5048
5049 #[test]
5050 fn fast_forward_guard_allows_descendant_update() {
5051 let tmp = tempfile::TempDir::new().unwrap();
5052 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
5053 let old = test_commit(&repo, "old", &[]);
5054 let new = test_commit(&repo, "new", &[old]);
5055
5056 ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
5057 .expect("descendant update should be allowed");
5058 }
5059
5060 fn test_commit(repo: &SleyRepository, message: &str, parents: &[ObjectId]) -> ObjectId {
5061 let empty_tree_oid = ObjectId::empty_tree(repo.object_format());
5062 let sig = Signature {
5063 name: GitByteString::new(b"Heddle Test".to_vec()),
5064 email: GitByteString::new(b"heddle@test".to_vec()),
5065 time: GitTime::new(0, 0),
5066 raw: b"Heddle Test <heddle@test> 0 +0000".to_vec(),
5067 };
5068 let commit = sley::CommitObject {
5069 tree: empty_tree_oid,
5070 parents: parents.to_vec(),
5071 author: sig.to_ident_bytes(),
5072 committer: sig.to_ident_bytes(),
5073 encoding: None,
5074 message: message.as_bytes().to_vec(),
5075 };
5076 repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
5077 GitObjectType::Commit,
5078 commit.write(),
5079 ))
5080 .expect("write test commit")
5081 }
5082
5083 fn seed_commit(repo: &SleyRepository, message: &str) -> ObjectId {
5084 test_commit(repo, message, &[])
5085 }
5086
5087 #[test]
5094 fn clone_url_to_bare_via_sley_honours_remote_head_symref() {
5095 let tmp = tempfile::TempDir::new().unwrap();
5096 let source = tmp.path().join("source.git");
5097 let dest = tmp.path().join("dest.git");
5098
5099 let src = SleyRepository::init_bare(&source).expect("init bare source");
5106 let seed = seed_commit(&src, "seed");
5107 for name in ["refs/heads/trunk", "refs/heads/abc-feature"] {
5108 set_reference(&src, name, seed, RefPrecondition::Any, "test: seed branch")
5109 .expect("set ref");
5110 }
5111 std::fs::write(source.join("HEAD"), b"ref: refs/heads/trunk\n").unwrap();
5114
5115 let url = format!("file://{}", source.display());
5116 let mut progress = SilentProgress;
5117 clone_url_to_bare(&url, &dest, None, None, &mut progress).expect("clone url to bare");
5118
5119 let dest_head = std::fs::read_to_string(dest.join("HEAD")).expect("read dest HEAD");
5120 assert_eq!(
5121 dest_head.trim(),
5122 "ref: refs/heads/trunk",
5123 "dest HEAD must mirror the remote's symref (trunk), not sley's \
5124 init-time default and not the alphabetically-first branch \
5125 (abc-feature) — see heddle#141"
5126 );
5127 }
5128
5129 #[test]
5130 fn write_head_symref_writes_git_head_bytes() {
5131 let tmp = tempfile::TempDir::new().unwrap();
5132 let git_dir = tmp.path();
5133 SleyRepository::init_bare(git_dir).expect("init bare");
5134
5135 write_head_symref(git_dir, "refs/heads/feature/x").expect("write HEAD symref");
5136 assert_eq!(
5137 std::fs::read_to_string(git_dir.join("HEAD")).expect("read HEAD"),
5138 "ref: refs/heads/feature/x\n"
5139 );
5140
5141 write_head_symref(git_dir, "refs/heads/main").expect("rewrite HEAD symref");
5142 assert_eq!(
5143 std::fs::read_to_string(git_dir.join("HEAD")).unwrap(),
5144 "ref: refs/heads/main\n"
5145 );
5146 }
5147
5148 #[test]
5149 fn rollback_restore_uses_git_lock_and_compare_and_swap() {
5150 let tmp = tempfile::TempDir::new().unwrap();
5151 let path = tmp.path().join("HEAD");
5152 std::fs::write(&path, b"published").unwrap();
5153
5154 restore_file_if_unchanged(&path, b"published", Some(b"previous")).unwrap();
5155 assert_eq!(std::fs::read(&path).unwrap(), b"previous");
5156 assert!(!tmp.path().join("HEAD.lock").exists());
5157
5158 std::fs::write(&path, b"concurrent").unwrap();
5159 let error = restore_file_if_unchanged(&path, b"published", Some(b"previous"))
5160 .expect_err("concurrent write must block rollback");
5161 assert!(error.to_string().contains("another Git operation changed"));
5162 assert_eq!(std::fs::read(&path).unwrap(), b"concurrent");
5163 assert!(!tmp.path().join("HEAD.lock").exists());
5164 }
5165
5166 #[test]
5167 fn checkout_publish_rolls_back_branch_when_head_reflog_fails() {
5168 let tmp = tempfile::TempDir::new().unwrap();
5169 let repo = SleyRepository::init(tmp.path()).expect("init repository");
5170 repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
5171 GitObjectType::Tree,
5172 Vec::new(),
5173 ))
5174 .expect("write empty tree");
5175 let previous = test_commit(&repo, "previous", &[]);
5176 let next = test_commit(&repo, "next", &[previous]);
5177 set_reference(
5178 &repo,
5179 "refs/heads/main",
5180 previous,
5181 RefPrecondition::Any,
5182 "test: seed branch",
5183 )
5184 .unwrap();
5185 write_head_symref(repo.git_dir(), "refs/heads/main").unwrap();
5186
5187 let write = CheckoutWrite::prepare(tmp.path(), "main").unwrap();
5188 let previous_head = write.previous_head.clone();
5189 let previous_index = write.previous_index.clone();
5190 std::fs::remove_file(write.git_dir.join("logs/HEAD")).expect("remove existing HEAD reflog");
5191 std::fs::create_dir_all(write.git_dir.join("logs/HEAD"))
5192 .expect("block HEAD reflog file creation");
5193
5194 write
5195 .publish(next)
5196 .expect_err("HEAD reflog failure must fail publication");
5197
5198 let branch = repo
5199 .find_reference("refs/heads/main")
5200 .unwrap()
5201 .unwrap()
5202 .peeled_oid(&repo)
5203 .unwrap()
5204 .unwrap();
5205 assert_eq!(branch, previous);
5206 assert_eq!(read_optional_file(&write.head_path).unwrap(), previous_head);
5207 assert_eq!(
5208 read_optional_file(&write.index_path).unwrap(),
5209 previous_index
5210 );
5211 assert!(!write.git_dir.join("index.lock").exists());
5212 assert!(!write.git_dir.join("HEAD.lock").exists());
5213 }
5214
5215 #[test]
5218 fn head_state_matches_legacy_head_symref_parse() {
5219 let tmp = tempfile::TempDir::new().unwrap();
5220 let root = tmp.path();
5221 let git_dir = root.join(".git");
5222 SleyRepository::init_bare(&git_dir).expect("init bare overlay");
5223
5224 fn legacy_branch_parse(head_path: &Path) -> Option<String> {
5225 let contents = std::fs::read_to_string(head_path).ok()?;
5226 let trimmed = contents.trim();
5227 let suffix = trimmed.strip_prefix("ref: ")?;
5228 let branch = suffix.strip_prefix("refs/heads/")?;
5229 (!branch.is_empty()).then(|| branch.to_string())
5230 }
5231
5232 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
5234 let repo = open_repo(root).expect("open");
5235 assert_eq!(repo.head_state().unwrap().branch_name(), Some("main"));
5236 assert_eq!(
5237 legacy_branch_parse(&git_dir.join("HEAD")),
5238 Some("main".into())
5239 );
5240
5241 let oid = ObjectId::from_hex(
5243 ObjectFormat::Sha1,
5244 "0000000000000000000000000000000000000001",
5245 )
5246 .unwrap();
5247 std::fs::write(git_dir.join("HEAD"), format!("{oid}\n")).unwrap();
5248 let repo = open_repo(root).expect("open");
5249 let state = repo.head_state().unwrap();
5250 assert!(state.is_detached());
5251 assert_eq!(state.branch_name(), None);
5252 assert_eq!(legacy_branch_parse(&git_dir.join("HEAD")), None);
5253
5254 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
5256 let repo = open_repo(root).expect("open");
5257 assert_eq!(repo.head_state().unwrap().branch_name(), Some("feature"));
5258 assert_eq!(
5259 legacy_branch_parse(&git_dir.join("HEAD")),
5260 Some("feature".into())
5261 );
5262 }
5263}