1use std::{
5 collections::{BTreeSet, HashMap, HashSet},
6 fs::{self, OpenOptions},
7 io::Write,
8 path::{Path, PathBuf},
9 sync::OnceLock,
10 time::{SystemTime, UNIX_EPOCH},
11};
12
13use objects::{
14 error::HeddleError,
15 object::{ContentHash, FileMode, Principal, StateId, StateIdParseError, ThreadName, Tree},
16 store::ObjectStore,
17};
18use refs::Head;
19use repo::{
20 AudienceTier, GitCheckpointIntent, GitCheckpointIntentPhase, GitRefName,
21 Repository as HeddleRepository,
22};
23pub use repo::{
24 GitRefContentNamespace as RefNamespace, GitRefKind, ParsedGitRef,
25 REMOTE_NAME_FOR_LOCAL_GIT_REPO, is_reserved_git_remote_name,
26};
27use sley::{
28 BString as GitBString, DeleteRef, FullName, GitObjectType, GitTime, HeadUpdateOptions, Index,
29 IndexEntry, IndexWriteOptions, ObjectFormat, ObjectId, RefPrecondition, ReferenceTarget,
30 Repository as SleyRepository, Signature,
31 plumbing::sley_core::ByteString as GitByteString,
32 remote::{
33 CredentialProvider, FetchOptions, LsRemoteFilter, NoCredentials, ProgressSink,
34 PushActionPlan, PushCommand, PushOptions, SilentProgress,
35 },
36};
37use sley_transport::{HttpClient, UreqHttpClient};
38
39use super::{
40 credential::EmbeddingSafeCredentialProvider,
41 git_export::{
42 ExportStateOptions, commit_is_byte_faithful, export_all, export_current_thread,
43 export_state,
44 },
45 git_ingest::import_git_history,
46 git_notes,
47 git_reconstruct::{commit_object_id, reconstruct_commit_bytes, write_commit_object},
48 git_residual::{ResidualStore, resolve_lossy_object},
49 git_util::{FailedRefExportReason, ImportStats},
50};
51
52#[derive(Debug, thiserror::Error)]
54pub enum GitProjectionError {
55 #[error("git error: {0}")]
56 Git(String),
57
58 #[error("store error: {0}")]
59 Store(#[from] HeddleError),
60
61 #[error("io error: {0}")]
62 Io(#[from] std::io::Error),
63
64 #[error("invalid trailer format: {0}")]
65 InvalidTrailer(String),
66
67 #[error("missing required trailer: {0}")]
68 MissingTrailer(String),
69
70 #[error("invalid mapping: {0}")]
71 InvalidMapping(String),
72
73 #[error("commit not found: {0}")]
74 CommitNotFound(String),
75
76 #[error("state not found: {0}")]
77 StateNotFound(StateId),
78
79 #[error("git repository not initialized")]
80 GitRepoNotInitialized,
81
82 #[error(
83 "shallow Git repository at {repository} cannot be imported until full ancestry is available"
84 )]
85 ShallowClone {
86 repository: PathBuf,
87 retry_command: String,
88 },
89
90 #[error("conflict during sync: {0}")]
91 Conflict(String),
92
93 #[error("Git Projection Mapping conflict: {message}")]
94 MappingConflict { message: String },
95
96 #[error("Git branch '{branch}' cannot be imported as a Heddle thread: {message}")]
97 InvalidThreadName { branch: String, message: String },
98
99 #[error(
100 "Git branch {branch} and Heddle thread {thread} diverged: thread {thread_change}, branch {branch_change}"
101 )]
102 GitHeddleThreadDiverged {
103 thread: String,
104 branch: String,
105 thread_change: StateId,
106 branch_change: StateId,
107 },
108
109 #[error(
110 "ref update would rewrite {name}: {old} -> {new}; refusing to replace a user-visible Git commit with a Heddle export commit"
111 )]
112 NonFastForwardRef {
113 name: String,
114 old: ObjectId,
115 new: ObjectId,
116 remote_destination: bool,
119 },
120
121 #[error(
122 "remote branch {upstream} does not fast-forward the local Git checkpoint for {branch}: local {local}, remote {remote}"
123 )]
124 RemoteDiverged {
125 branch: String,
126 upstream: String,
127 local: ObjectId,
128 remote: ObjectId,
129 },
130
131 #[error("change id parse error: {0}")]
132 StateIdParse(#[from] StateIdParseError),
133
134 #[error("ref {name}: {reason}")]
142 RefExportFailed {
143 name: String,
144 reason: FailedRefExportReason,
145 },
146}
147
148static GIT_HTTPS_CLIENT: OnceLock<Option<UreqHttpClient>> = OnceLock::new();
149
150pub fn configure_https_ca_certificate_pem(ca_pem: Option<&str>) -> GitProjectionResult<()> {
152 let client = ca_pem
153 .map(|pem| UreqHttpClient::with_extra_ca_certificate_pem(pem.as_bytes()))
154 .transpose()
155 .map_err(|error| {
156 GitProjectionError::Git(format!(
157 "invalid CA bundle configured by HEDDLE_REMOTE_TLS_CA_CERT: {error}"
158 ))
159 })?;
160 GIT_HTTPS_CLIENT.set(client).map_err(|_| {
161 GitProjectionError::Git("Git HTTPS trust has already been configured".to_string())
162 })
163}
164
165pub fn configured_https_client() -> Option<&'static dyn HttpClient> {
167 GIT_HTTPS_CLIENT
168 .get()
169 .and_then(Option::as_ref)
170 .map(|client| client as &dyn HttpClient)
171}
172
173pub type GitProjectionResult<T> = std::result::Result<T, GitProjectionError>;
175
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct RefUpdate {
178 pub name: String,
179 pub target: ObjectId,
180 pub namespace: RefNamespace,
181}
182
183fn reject_reserved_git_remote_name(remote: &str) -> GitProjectionResult<()> {
190 if is_reserved_git_remote_name(remote) {
191 return Err(GitProjectionError::Git(format!(
192 "a Git remote named '{remote}' collides with heddle's reserved namespace \
193 (local refs are recorded under the '{REMOTE_NAME_FOR_LOCAL_GIT_REPO}' sentinel); \
194 configure it under another name with `heddle remote add origin <url>`, \
195 remove the reserved entry with `heddle remote remove {remote}`, and retry"
196 )));
197 }
198 Ok(())
199}
200
201fn remote_name_from_remote_ref(ref_name: &str) -> Option<&str> {
202 GitRefName::new(ref_name).remote_name()
203}
204
205fn validate_refspec_ref(ref_name: &str) -> GitProjectionResult<()> {
206 if let Some(remote) = remote_name_from_remote_ref(ref_name) {
207 reject_reserved_git_remote_name(remote)?;
208 }
209 Ok(())
210}
211
212pub fn parse_git_ref(ref_name: &str) -> Option<ParsedGitRef<'_>> {
220 RefSpec::new(None, ref_name, false).ok()?;
221 GitRefName::new(ref_name).git_projection_ref()
222}
223
224mod refspec {
227 use super::{GitProjectionResult, validate_refspec_ref};
228
229 #[derive(Debug, Clone, PartialEq, Eq)]
230 pub struct RefSpec {
231 forced: bool,
232 source: Option<String>,
234 destination: String,
235 }
236
237 impl RefSpec {
238 pub fn new(
240 source: Option<String>,
241 destination: impl Into<String>,
242 forced: bool,
243 ) -> GitProjectionResult<Self> {
244 let destination = destination.into();
245 if source.is_none() && destination.is_empty() {
246 return Err(super::GitProjectionError::InvalidMapping(
247 "refspec source and destination cannot both be empty".to_string(),
248 ));
249 }
250 if let Some(source) = source.as_deref() {
251 validate_refspec_ref(source)?;
252 }
253 validate_refspec_ref(&destination)?;
254 Ok(Self {
255 forced,
256 source,
257 destination,
258 })
259 }
260
261 pub fn forced(
263 source: impl Into<String>,
264 destination: impl Into<String>,
265 ) -> GitProjectionResult<Self> {
266 Self::new(Some(source.into()), destination, true)
267 }
268
269 pub fn delete(destination: impl Into<String>) -> GitProjectionResult<Self> {
272 Self::new(None, destination, false)
273 }
274
275 pub fn to_git_format(&self) -> String {
277 format!(
278 "{}{}",
279 if self.forced { "+" } else { "" },
280 self.to_git_format_not_forced()
281 )
282 }
283
284 pub fn to_git_format_not_forced(&self) -> String {
286 format!(
287 "{}:{}",
288 self.source.as_deref().unwrap_or(""),
289 self.destination
290 )
291 }
292 }
293}
294
295pub use refspec::RefSpec;
296
297mod negative_refspec {
300 use super::{GitProjectionError, GitProjectionResult, validate_refspec_ref};
301
302 #[derive(Debug, Clone, PartialEq, Eq)]
303 pub struct NegativeRefSpec {
304 source: String,
305 }
306
307 impl NegativeRefSpec {
308 pub fn new(source: impl Into<String>) -> GitProjectionResult<Self> {
311 let source = source.into();
312 validate_refspec_ref(&source)?;
313 if source.contains('*') {
314 return Err(GitProjectionError::InvalidMapping(format!(
315 "invalid negative refspec source '{source}': Negative glob patterns are not supported"
316 )));
317 }
318 Ok(Self { source })
319 }
320
321 pub fn to_git_format(&self) -> String {
323 format!("^{}", self.source)
324 }
325 }
326}
327
328pub use negative_refspec::NegativeRefSpec;
332
333fn heddle_mirror_fetch_refspecs() -> GitProjectionResult<[String; 2]> {
337 Ok([
338 RefSpec::forced("refs/heads/*", "refs/heads/*")?.to_git_format(),
339 RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format(),
340 ])
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub enum GitPushScope {
345 CurrentThread,
346 AllThreads,
347}
348
349#[derive(Debug, Clone, Default)]
350pub struct GitPullOutcome {
351 pub changed: bool,
352 pub states_created: usize,
353 pub commits_seen: usize,
354 pub materialized_checkout: bool,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358enum PullPreflight {
359 UpToDate,
360 ImportRequired,
361}
362
363fn pull_outcome(stats: &ImportStats, materialized_checkout: bool) -> GitPullOutcome {
364 GitPullOutcome {
365 changed: materialized_checkout || stats.states_created > 0,
366 states_created: stats.states_created,
367 commits_seen: stats.commits_imported,
368 materialized_checkout,
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373enum GitFetchScope {
374 BranchesAndNotes,
375 AllRefs,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379enum RefreshCheckoutAfterFetch {
380 Yes,
381 No,
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385enum RemoteDirection {
386 Fetch,
387 Push,
388}
389
390#[derive(Debug, Clone)]
391enum ResolvedRemote {
392 Local(PathBuf),
393 Url(String),
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum WriteThroughSkipReason {
398 MissingDotGit,
399 DetachedHead,
400 NoAttachedThread,
401 NoMappedCommit,
402 MirrorIsWorktree,
403 IndexAlreadyDirty,
404}
405
406impl std::fmt::Display for WriteThroughSkipReason {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 match self {
409 WriteThroughSkipReason::MissingDotGit => {
410 write!(f, "this checkout does not have a Git working tree")
411 }
412 WriteThroughSkipReason::DetachedHead => {
413 write!(f, "Git HEAD is detached")
414 }
415 WriteThroughSkipReason::NoAttachedThread => {
416 write!(f, "the attached Heddle thread does not resolve to a state")
417 }
418 WriteThroughSkipReason::NoMappedCommit => {
419 write!(f, "the current Heddle state has not been exported to Git")
420 }
421 WriteThroughSkipReason::MirrorIsWorktree => {
422 write!(f, "the legacy Bridge Mirror target is the checkout itself")
423 }
424 WriteThroughSkipReason::IndexAlreadyDirty => {
425 write!(f, "the Git index is already locked by another operation")
426 }
427 }
428 }
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum WriteThroughOutcome {
433 Wrote(ObjectId),
434 Skipped(WriteThroughSkipReason),
435}
436
437#[derive(Debug, Clone, PartialEq, Eq)]
438pub struct LocalGitIdentity {
439 pub name: String,
440 pub email: String,
441}
442
443impl LocalGitIdentity {
444 pub fn from_principal(principal: &Principal) -> Self {
445 Self {
446 name: principal.name.clone(),
447 email: principal.email.clone(),
448 }
449 }
450
451 pub fn to_ident_line(&self, seconds: i64) -> Vec<u8> {
452 format!("{} <{}> {} +0000", self.name, self.email, seconds).into_bytes()
453 }
454
455 pub fn to_signature(&self, seconds: i64) -> Signature {
456 let ident = self.to_ident_line(seconds);
457 Signature {
458 name: GitByteString::new(self.name.as_bytes().to_vec()),
459 email: GitByteString::new(self.email.as_bytes().to_vec()),
460 time: GitTime::new(seconds, 0),
461 raw: ident,
462 }
463 }
464}
465
466impl WriteThroughOutcome {
467 pub fn object_id(self) -> Option<ObjectId> {
468 match self {
469 WriteThroughOutcome::Wrote(oid) => Some(oid),
470 WriteThroughOutcome::Skipped(_) => None,
471 }
472 }
473
474 pub fn skip_reason(self) -> Option<WriteThroughSkipReason> {
475 match self {
476 WriteThroughOutcome::Skipped(reason) => Some(reason),
477 WriteThroughOutcome::Wrote(_) => None,
478 }
479 }
480}
481
482#[derive(Debug, Clone, Default, PartialEq, Eq)]
484pub struct SyncMapping {
485 heddle_to_git: HashMap<StateId, ObjectId>,
487 git_to_heddle: HashMap<ObjectId, StateId>,
489}
490
491impl SyncMapping {
492 pub fn new() -> Self {
494 Self::default()
495 }
496
497 pub fn insert(&mut self, state_id: StateId, git_oid: ObjectId) {
499 if let Some(previous_git) = self.heddle_to_git.remove(&state_id) {
500 self.git_to_heddle.remove(&previous_git);
501 }
502 if let Some(previous_change) = self.git_to_heddle.remove(&git_oid) {
503 self.heddle_to_git.remove(&previous_change);
504 }
505 self.heddle_to_git.insert(state_id, git_oid);
506 self.git_to_heddle.insert(git_oid, state_id);
507 }
508
509 pub fn insert_checked(
511 &mut self,
512 state_id: StateId,
513 git_oid: ObjectId,
514 ) -> GitProjectionResult<()> {
515 if let Some(existing) = self.heddle_to_git.get(&state_id)
516 && *existing != git_oid
517 {
518 return Err(GitProjectionError::MappingConflict {
519 message: format!(
520 "change id {} mapped to {} (new {})",
521 state_id, existing, git_oid
522 ),
523 });
524 }
525
526 if let Some(existing) = self.git_to_heddle.get(&git_oid)
527 && *existing != state_id
528 {
529 return Err(GitProjectionError::MappingConflict {
530 message: format!(
531 "git oid {} mapped to {} (new {})",
532 git_oid, existing, state_id
533 ),
534 });
535 }
536
537 self.insert(state_id, git_oid);
538 Ok(())
539 }
540
541 pub fn get_git(&self, state_id: &StateId) -> Option<ObjectId> {
543 self.heddle_to_git.get(state_id).copied()
544 }
545
546 pub fn get_heddle(&self, git_oid: ObjectId) -> Option<StateId> {
548 self.git_to_heddle.get(&git_oid).copied()
549 }
550
551 pub fn has_heddle(&self, state_id: &StateId) -> bool {
553 self.heddle_to_git.contains_key(state_id)
554 }
555
556 pub fn remove(&mut self, state_id: &StateId) -> Option<ObjectId> {
566 let git_oid = self.heddle_to_git.remove(state_id)?;
567 self.git_to_heddle.remove(&git_oid);
568 Some(git_oid)
569 }
570
571 pub fn has_git(&self, git_oid: ObjectId) -> bool {
573 self.git_to_heddle.contains_key(&git_oid)
574 }
575
576 pub fn iter(&self) -> impl Iterator<Item = (&StateId, &ObjectId)> {
578 self.heddle_to_git.iter()
579 }
580
581 pub fn is_empty(&self) -> bool {
586 self.heddle_to_git.is_empty()
587 }
588
589 pub fn retain_git_objects(&mut self, repo: &SleyRepository) {
590 let retained: Vec<(StateId, ObjectId)> = self
591 .heddle_to_git
592 .iter()
593 .filter_map(|(state_id, git_oid)| {
594 repo.read_object(git_oid)
595 .ok()
596 .map(|_| (*state_id, *git_oid))
597 })
598 .collect();
599
600 self.heddle_to_git.clear();
601 self.git_to_heddle.clear();
602 for (state_id, git_oid) in retained {
603 self.insert(state_id, git_oid);
604 }
605 }
606
607 #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
608 pub fn retain_git_object_set(&mut self, reachable: &HashSet<ObjectId>) -> usize {
609 let before = self.heddle_to_git.len();
610 let retained: Vec<(StateId, ObjectId)> = self
611 .heddle_to_git
612 .iter()
613 .filter(|(_, git_oid)| reachable.contains(*git_oid))
614 .map(|(state_id, git_oid)| (*state_id, *git_oid))
615 .collect();
616
617 self.heddle_to_git.clear();
618 self.git_to_heddle.clear();
619 for (state_id, git_oid) in retained {
620 self.insert(state_id, git_oid);
621 }
622 before.saturating_sub(self.heddle_to_git.len())
623 }
624}
625
626pub struct GitProjection<'a> {
628 pub heddle_repo: &'a HeddleRepository,
629 pub git_repo_path: Option<PathBuf>,
630 pub mapping: SyncMapping,
631 pub commit_message_overrides: HashMap<StateId, String>,
632 pub commit_parent_overrides: HashMap<StateId, Vec<ObjectId>>,
633 linearize_unmapped_tip_to_checkout: bool,
634}
635
636struct MappingFileSnapshot {
637 path: PathBuf,
638 contents: Option<Vec<u8>>,
639}
640
641impl MappingFileSnapshot {
642 fn read(path: PathBuf) -> GitProjectionResult<Self> {
643 let contents = match fs::read(&path) {
644 Ok(contents) => Some(contents),
645 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
646 Err(error) => return Err(error.into()),
647 };
648 Ok(Self { path, contents })
649 }
650
651 fn restore(self) -> GitProjectionResult<()> {
652 match self.contents {
653 Some(contents) => {
654 if let Some(parent) = self.path.parent() {
655 fs::create_dir_all(parent)?;
656 }
657 fs::write(&self.path, contents)?;
658 }
659 None => match fs::remove_file(&self.path) {
660 Ok(()) => {}
661 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
662 Err(error) => return Err(error.into()),
663 },
664 }
665 Ok(())
666 }
667}
668
669struct CheckoutWrite {
670 checkout_repo: SleyRepository,
671 object_repo: SleyRepository,
672 git_dir: PathBuf,
673 branch_ref: String,
674 head_path: PathBuf,
675 index_path: PathBuf,
676 previous_head: Option<Vec<u8>>,
677 previous_index: Option<Vec<u8>>,
678 previous_branch: Option<ObjectId>,
679}
680
681impl CheckoutWrite {
682 fn prepare(root: &Path, thread: &str) -> GitProjectionResult<Self> {
683 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
684 let git_dir = checkout_repo.git_dir().to_path_buf();
685 if git_dir.join("index.lock").exists() {
686 return Err(GitProjectionError::Git(
687 "Git index is locked; checkpoint write-through cannot proceed".to_string(),
688 ));
689 }
690 let object_repo = common_repo_for_worktree(&checkout_repo)?;
691 let branch_ref = format!("refs/heads/{thread}");
692 let head_path = git_dir.join("HEAD");
693 let index_path = git_dir.join("index");
694 let previous_head = read_optional_file(&head_path)?;
695 let previous_index = read_optional_file(&index_path)?;
696 let previous_branch = match object_repo.find_reference(&branch_ref).map_err(git_err)? {
697 Some(reference) => reference.peeled_oid(&object_repo).map_err(git_err)?,
698 None => None,
699 };
700 Ok(Self {
701 checkout_repo,
702 object_repo,
703 git_dir,
704 branch_ref,
705 head_path,
706 index_path,
707 previous_head,
708 previous_index,
709 previous_branch,
710 })
711 }
712
713 fn excluded_objects(&self) -> GitProjectionResult<HashSet<ObjectId>> {
714 match self.previous_branch {
715 Some(parent) => sley::plumbing::sley_odb::collect_reachable_object_ids(
716 self.object_repo.objects().as_ref(),
717 self.object_repo.object_format(),
718 [parent],
719 )
720 .map_err(git_err),
721 None => Ok(HashSet::new()),
722 }
723 }
724
725 fn publish(&self, git_oid: ObjectId) -> GitProjectionResult<()> {
726 let published_head = format!("ref: {}\n", self.branch_ref).into_bytes();
727 let mut head_written = false;
728 let mut index_written = false;
729 let mut branch_update_attempted = false;
730 let mut published_index = None;
731 let result = (|| -> GitProjectionResult<()> {
732 write_head_symref(&self.git_dir, &self.branch_ref)?;
733 head_written = true;
734 let commit = self.object_repo.read_commit(&git_oid).map_err(git_err)?;
735 let mut index = self
736 .object_repo
737 .index_from_tree(&commit.tree)
738 .map_err(git_err)?;
739 index.upgrade_version_for_flags();
740 published_index = Some(
741 index
742 .write(self.checkout_repo.object_format())
743 .map_err(git_err)?,
744 );
745 self.checkout_repo
746 .write_index(
747 &index,
748 IndexWriteOptions {
749 fsync: true,
750 validate_checksum: true,
751 },
752 )
753 .map_err(git_err)?;
754 index_written = true;
755 branch_update_attempted = true;
756 let head_reflog = update_checkout_branch_ref(
757 &self.checkout_repo,
758 &self.branch_ref,
759 git_oid,
760 self.previous_branch,
761 "heddle: write-through current thread",
762 )?;
763 self.checkout_repo
764 .references()
765 .append_reflog("HEAD", &head_reflog)
766 .map_err(git_err)?;
767 fsync_path(&self.head_path)?;
768 fsync_path(&self.index_path)?;
769 fsync_path(&self.git_dir)?;
770 Ok(())
771 })();
772
773 if let Err(error) = result {
774 if branch_update_attempted {
775 rollback_reference_if_unchanged(
776 &self.object_repo,
777 &self.branch_ref,
778 git_oid,
779 self.previous_branch,
780 )?;
781 }
782 if index_written {
783 restore_file_if_unchanged(
784 &self.index_path,
785 published_index.as_deref().expect("written index bytes"),
786 self.previous_index.as_deref(),
787 )?;
788 }
789 if head_written {
790 restore_file_if_unchanged(
791 &self.head_path,
792 &published_head,
793 self.previous_head.as_deref(),
794 )?;
795 }
796 let _ = fsync_path(&self.head_path);
797 let _ = fsync_path(&self.index_path);
798 let _ = fsync_path(&self.git_dir);
799 return Err(error);
800 }
801 Ok(())
802 }
803}
804
805impl<'a> GitProjection<'a> {
806 pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
808 Self {
809 heddle_repo,
810 git_repo_path: None,
811 mapping: SyncMapping::new(),
812 commit_message_overrides: HashMap::new(),
813 commit_parent_overrides: HashMap::new(),
814 linearize_unmapped_tip_to_checkout: false,
815 }
816 }
817
818 pub fn init_mirror(&mut self) -> GitProjectionResult<()> {
820 let _guard = self.init_mirror_with_guard()?;
821 _guard.commit();
822 Ok(())
823 }
824
825 pub fn init_mirror_with_guard(&mut self) -> GitProjectionResult<MirrorInitGuard> {
830 let git_dir = self.heddle_repo.heddle_dir().join("git");
831
832 let did_create = if git_dir.exists() {
833 let _ = open_repo(&git_dir)?;
834 false
835 } else {
836 fs::create_dir_all(&git_dir)?;
837 let _ = SleyRepository::init_bare(&git_dir).map_err(git_err)?;
838 let mirror_repo = open_repo(&git_dir)?;
839 seed_checkout_note_refs_into_mirror(self.heddle_repo.root(), &mirror_repo)?;
840 true
841 };
842
843 self.git_repo_path = Some(git_dir.clone());
844 Ok(MirrorInitGuard::new_from_init(git_dir, did_create))
845 }
846
847 pub fn mirror_path(&self) -> PathBuf {
849 self.heddle_repo.heddle_dir().join("git")
850 }
851
852 pub fn is_initialized(&self) -> bool {
854 self.mirror_path().exists()
855 }
856
857 pub fn open_git_repo(&self) -> GitProjectionResult<SleyRepository> {
859 if let Some(ref path) = self.git_repo_path {
860 open_repo(path)
861 } else {
862 let mirror_path = self.mirror_path();
863 if mirror_path.exists() {
864 open_repo(&mirror_path)
865 } else {
866 open_repo(self.heddle_repo.root())
867 }
868 }
869 }
870
871 pub fn sort_states_topologically(
873 &self,
874 states: &[StateId],
875 ) -> GitProjectionResult<Vec<StateId>> {
876 let mut sorted = Vec::new();
877 let mut visited: std::collections::HashSet<StateId> = std::collections::HashSet::new();
878
879 fn visit<S: ObjectStore + ?Sized>(
880 state_id: &StateId,
881 store: &S,
882 visited: &mut std::collections::HashSet<StateId>,
883 sorted: &mut Vec<StateId>,
884 ) -> GitProjectionResult<()> {
885 if visited.contains(state_id) {
886 return Ok(());
887 }
888
889 if let Some(state) = store.get_state(state_id)? {
890 for parent in &state.parents {
891 visit(parent, store, visited, sorted)?;
892 }
893 }
894
895 visited.insert(*state_id);
896 sorted.push(*state_id);
897
898 Ok(())
899 }
900
901 for state_id in states {
902 visit(
903 state_id,
904 self.heddle_repo.store(),
905 &mut visited,
906 &mut sorted,
907 )?;
908 }
909
910 Ok(sorted)
911 }
912
913 pub fn export(&mut self) -> GitProjectionResult<super::git_util::ExportStats> {
915 export_all(self)
916 }
917
918 pub fn set_commit_message_override(&mut self, state_id: StateId, message: String) {
919 self.commit_message_overrides.insert(state_id, message);
920 }
921
922 pub fn set_commit_parent_override(&mut self, state_id: StateId, parents: Vec<ObjectId>) {
923 self.commit_parent_overrides.insert(state_id, parents);
924 }
925
926 pub fn linearize_unmapped_tip_to_checkout(&mut self) {
930 self.linearize_unmapped_tip_to_checkout = true;
931 }
932
933 pub fn with_mapping_rollback<T>(
934 &mut self,
935 operation: impl FnOnce(&mut Self) -> GitProjectionResult<T>,
936 ) -> GitProjectionResult<T> {
937 let mapping = self.mapping.clone();
938 let commit_message_overrides = self.commit_message_overrides.clone();
939 let commit_parent_overrides = self.commit_parent_overrides.clone();
940 let mapping_file = MappingFileSnapshot::read(self.mapping_path())?;
941 let mapping_tmp_file = MappingFileSnapshot::read(self.mapping_tmp_path())?;
942
943 match operation(self) {
944 Ok(value) => Ok(value),
945 Err(error) => {
946 self.mapping = mapping;
947 self.commit_message_overrides = commit_message_overrides;
948 self.commit_parent_overrides = commit_parent_overrides;
949 if let Err(rollback_error) = mapping_file
950 .restore()
951 .and_then(|()| mapping_tmp_file.restore())
952 {
953 return Err(GitProjectionError::Git(format!(
954 "operation failed ({error}); additionally failed to roll back Git Projection Mapping state ({rollback_error})"
955 )));
956 }
957 Err(error)
958 }
959 }
960 }
961
962 pub fn push(&mut self, remote_name: &str) -> GitProjectionResult<Vec<String>> {
965 self.push_with_scope(remote_name, GitPushScope::AllThreads)
966 }
967
968 pub fn push_with_scope(
971 &mut self,
972 remote_name: &str,
973 scope: GitPushScope,
974 ) -> GitProjectionResult<Vec<String>> {
975 self.push_with_scope_force(remote_name, scope, false)
976 }
977
978 pub fn push_with_scope_force(
987 &mut self,
988 remote_name: &str,
989 scope: GitPushScope,
990 force: bool,
991 ) -> GitProjectionResult<Vec<String>> {
992 self.init_mirror()?;
993 let current_branch = match scope {
994 GitPushScope::CurrentThread => Some(self.current_attached_thread_for_push()?),
995 GitPushScope::AllThreads => None,
996 };
997 match scope {
998 GitPushScope::CurrentThread => {
999 export_current_thread(self, current_branch.as_deref().expect("current branch"))?;
1000 }
1001 GitPushScope::AllThreads => {
1002 self.export()?;
1003 self.mirror_checkout_tags_for_push()?;
1004 }
1005 }
1006 self.write_current_checkout_from_existing_mirror()?;
1007
1008 let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
1016 match self.resolve_remote(remote_name, RemoteDirection::Push)? {
1017 ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
1018 &target_path,
1019 &log_message,
1020 false,
1021 scope,
1022 current_branch.as_deref(),
1023 force,
1024 ),
1025 ResolvedRemote::Url(url) => {
1026 let mirror_repo = self.open_git_repo()?;
1027 push_network_remote(
1028 &mirror_repo,
1029 self.heddle_repo.heddle_dir(),
1030 &url,
1031 scope,
1032 current_branch.as_deref(),
1033 force,
1034 )
1035 }
1036 }
1037 }
1038
1039 fn current_attached_thread_for_push(&self) -> GitProjectionResult<String> {
1040 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1041 return Err(GitProjectionError::Git(
1042 "cannot push the current Git-overlay branch from a detached Heddle HEAD; use --all-threads to push all exported refs".to_string(),
1043 ));
1044 };
1045 if self.heddle_repo.refs().get_thread(&thread)?.is_none() {
1046 return Err(GitProjectionError::Git(format!(
1047 "attached thread '{thread}' has no state to push"
1048 )));
1049 }
1050 Ok(thread.to_string())
1051 }
1052
1053 pub fn export_to_path(
1057 &mut self,
1058 target_path: &Path,
1059 ) -> GitProjectionResult<super::git_util::ExportStats> {
1060 self.init_mirror()?;
1061 let stats = self.export()?;
1062 self.copy_mirror_to_path(
1063 target_path,
1064 &format!("heddle: export from {}", self.heddle_repo.root().display()),
1065 true,
1066 GitPushScope::AllThreads,
1067 None,
1068 false,
1069 )?;
1070 Ok(stats)
1071 }
1072
1073 fn copy_mirror_to_path(
1082 &mut self,
1083 target_path: &Path,
1084 log_message: &str,
1085 init_if_missing: bool,
1086 scope: GitPushScope,
1087 current_branch: Option<&str>,
1088 force: bool,
1089 ) -> GitProjectionResult<Vec<String>> {
1090 let mirror_repo = self.open_git_repo()?;
1091 let target_repo = if target_path.exists() {
1092 open_repo(target_path)?
1093 } else if init_if_missing {
1094 fs::create_dir_all(target_path)?;
1095 SleyRepository::init_bare(target_path).map_err(git_err)?;
1096 open_repo(target_path)?
1097 } else {
1098 return Err(GitProjectionError::Git(format!(
1099 "destination '{}' does not exist",
1100 target_path.display()
1101 )));
1102 };
1103
1104 let managed_record = read_mirror_managed_refs(&mirror_repo)?;
1118 let served_frontier = collect_managed_ref_updates(&mirror_repo, &managed_record)?;
1119 copy_reachable_objects(
1120 &mirror_repo,
1121 &target_repo,
1122 served_frontier.iter().map(|update| update.target),
1123 )?;
1124
1125 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
1133 let old_at_destination = read_destination_ref_map(&target_repo)?;
1134 let previously_exported = read_exported_refs(&target_repo)?;
1135 let plan = plan_destination_reconcile(
1136 &mirror_repo,
1137 &served_frontier,
1138 creatable.as_ref(),
1139 &old_at_destination,
1140 &previously_exported,
1141 force,
1142 )?;
1143 for write in &plan.writes {
1144 let constraint = match write.old {
1145 Some(old) => RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(old)),
1146 None => RefPrecondition::MustNotExist,
1147 };
1148 set_reference(
1149 &target_repo,
1150 &write.full_name,
1151 write.new,
1152 constraint,
1153 log_message,
1154 )?;
1155 }
1156 for delete in &plan.deletes {
1157 delete_reference_matching(&target_repo, &delete.full_name, delete.old)?;
1158 }
1159 write_exported_refs(&target_repo, &plan.new_manifest)?;
1160 Ok(planned_write_names(&plan))
1161 }
1162
1163 pub fn fetch(&mut self, remote_name: &str) -> GitProjectionResult<()> {
1166 self.fetch_with_scope(
1167 remote_name,
1168 GitFetchScope::BranchesAndNotes,
1169 RefreshCheckoutAfterFetch::Yes,
1170 )
1171 }
1172
1173 fn fetch_with_scope(
1174 &mut self,
1175 remote_name: &str,
1176 scope: GitFetchScope,
1177 refresh_checkout: RefreshCheckoutAfterFetch,
1178 ) -> GitProjectionResult<()> {
1179 reject_reserved_git_remote_name(remote_name)?;
1180 self.init_mirror()?;
1181 let current_branch = self.heddle_repo.git_overlay_current_branch()?;
1182 let tracking_remote = checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
1183 .or_else(|| {
1184 (!looks_like_remote_location(remote_name)).then(|| remote_name.to_string())
1185 });
1186 if let Some(tracking_remote) = tracking_remote.as_deref() {
1190 reject_reserved_git_remote_name(tracking_remote)?;
1191 }
1192
1193 let mirror_repo = self.open_git_repo()?;
1194 match self.resolve_remote(remote_name, RemoteDirection::Fetch)? {
1195 ResolvedRemote::Local(path) => {
1196 let remote_repo = open_repo(&path)?;
1197 let updates = collect_ref_updates_for_fetch(&remote_repo, scope)?;
1198 tracing::debug!(
1199 remote = remote_name,
1200 path = %path.display(),
1201 refs = updates.len(),
1202 notes = updates
1203 .iter()
1204 .filter(|update| update.namespace == RefNamespace::Note)
1205 .count(),
1206 "fetching Git refs from local remote"
1207 );
1208 copy_reachable_objects(
1209 &remote_repo,
1210 &mirror_repo,
1211 updates.iter().map(|update| update.target),
1212 )?;
1213 apply_ref_updates(
1214 &mirror_repo,
1215 &updates,
1216 &format!("heddle: fetch from {remote_name}"),
1217 )?;
1218 if let Some(tracking_remote) = tracking_remote.as_deref() {
1219 apply_remote_tracking_ref_updates(
1220 &mirror_repo,
1221 tracking_remote,
1222 &updates,
1223 &format!("heddle: fetch from {remote_name}"),
1224 )?;
1225 }
1226 }
1227 ResolvedRemote::Url(url) => {
1228 fetch_network_remote(&mirror_repo, remote_name, &url, scope)?;
1229 let updates = collect_ref_updates_for_fetch(&mirror_repo, scope)?;
1230 if let Some(tracking_remote) = tracking_remote.as_deref() {
1231 apply_remote_tracking_ref_updates(
1232 &mirror_repo,
1233 tracking_remote,
1234 &updates,
1235 &format!("heddle: fetch from {remote_name}"),
1236 )?;
1237 }
1238 }
1239 }
1240
1241 self.git_repo_path = Some(self.mirror_path());
1242 if matches!(refresh_checkout, RefreshCheckoutAfterFetch::Yes) {
1243 if let Some(tracking_remote) = tracking_remote.as_deref() {
1244 self.refresh_checkout_remote_tracking_refs(tracking_remote)?;
1245 }
1246 if let Some(branch) = current_branch {
1247 self.refresh_checkout_remote_tracking_ref(remote_name, &branch)?;
1248 }
1249 self.refresh_checkout_note_refs_from_mirror()?;
1250 }
1251 Ok(())
1252 }
1253
1254 pub fn hydrate_checkout_heddle_notes_without_mirror(root: &Path) -> bool {
1262 if checkout_note_ref_exists(root).unwrap_or(false) {
1263 return true;
1264 }
1265
1266 let mut remotes = match checkout_remote_url_items(root) {
1267 Ok(remotes) => remotes
1268 .into_iter()
1269 .map(|(name, _)| name)
1270 .collect::<Vec<_>>(),
1271 Err(error) => {
1272 tracing::debug!(
1273 error = %error,
1274 "skipping configured remote note hydration before ingest-backed adopt"
1275 );
1276 return false;
1277 }
1278 };
1279 remotes.sort_by(|left, right| {
1280 match (left.as_str() == "origin", right.as_str() == "origin") {
1281 (true, false) => std::cmp::Ordering::Less,
1282 (false, true) => std::cmp::Ordering::Greater,
1283 _ => left.cmp(right),
1284 }
1285 });
1286 remotes.dedup();
1287
1288 for remote in remotes {
1289 match hydrate_checkout_notes_from_remote_without_mirror(root, &remote) {
1290 Ok(()) if checkout_note_ref_exists(root).unwrap_or(false) => return true,
1291 Ok(()) => {}
1292 Err(error) => {
1293 tracing::debug!(
1294 remote = remote.as_str(),
1295 error = %error,
1296 "configured remote did not provide Heddle notes during ingest-backed adopt"
1297 );
1298 }
1299 }
1300 }
1301
1302 false
1303 }
1304
1305 pub fn pull(&mut self, remote_name: &str) -> GitProjectionResult<GitPullOutcome> {
1307 let head_before = self.heddle_repo.refs().read_head()?;
1308 let attached_before = match &head_before {
1309 Head::Attached { thread } => self
1310 .heddle_repo
1311 .refs()
1312 .get_thread(thread)?
1313 .map(|state| (thread.to_string(), state)),
1314 Head::Detached { .. } => None,
1315 };
1316 let attached_thread = attached_before.as_ref().map(|(thread, _)| thread.clone());
1317
1318 self.fetch_with_scope(
1319 remote_name,
1320 GitFetchScope::AllRefs,
1321 RefreshCheckoutAfterFetch::No,
1322 )?;
1323 if self.preflight_attached_pull_fast_forward(remote_name, attached_before.as_ref())?
1324 == PullPreflight::UpToDate
1325 {
1326 if let Some(thread) = attached_thread {
1327 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1328 }
1329 self.refresh_checkout_note_refs_from_mirror()?;
1330 return Ok(GitPullOutcome::default());
1331 }
1332 let mirror_path = self.mirror_path();
1333 let stats = import_git_history(self, Some(&mirror_path), &[], Default::default(), None)?;
1334
1335 let mut materialized_attached_thread = false;
1336 if let Some((thread, old_state)) = attached_before
1337 && let Some(new_state) = self
1338 .heddle_repo
1339 .refs()
1340 .get_thread(&ThreadName::new(&thread))?
1341 && new_state != old_state
1342 {
1343 self.heddle_repo
1344 .refs()
1345 .set_thread(&ThreadName::new(&thread), &old_state)?;
1346 self.heddle_repo.refs().write_head(&Head::Attached {
1347 thread: ThreadName::new(&thread),
1348 })?;
1349 self.heddle_repo
1350 .goto_verified_clean_without_record(&new_state)?;
1351 self.heddle_repo
1352 .refs()
1353 .set_thread(&ThreadName::new(&thread), &new_state)?;
1354 self.heddle_repo.refs().write_head(&Head::Attached {
1355 thread: ThreadName::new(&thread),
1356 })?;
1357 materialized_attached_thread = true;
1358 }
1359
1360 if materialized_attached_thread {
1361 self.write_current_checkout_from_existing_mirror()?;
1362 }
1363 if let Some(thread) = attached_thread {
1364 self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
1365 }
1366 self.refresh_checkout_note_refs_from_mirror()?;
1367 Ok(pull_outcome(&stats, materialized_attached_thread))
1368 }
1369
1370 fn preflight_attached_pull_fast_forward(
1371 &mut self,
1372 remote_name: &str,
1373 attached_before: Option<&(String, StateId)>,
1374 ) -> GitProjectionResult<PullPreflight> {
1375 let Some((thread, state_id)) = attached_before else {
1376 return Ok(PullPreflight::ImportRequired);
1377 };
1378 self.build_existing_mapping(None)?;
1379 let Some(local_git_oid) = self.mapping.get_git(state_id) else {
1380 return Ok(PullPreflight::ImportRequired);
1381 };
1382 let mirror_repo = self.open_git_repo()?;
1383 let branch_ref = format!("refs/heads/{thread}");
1384 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
1385 return Ok(PullPreflight::ImportRequired);
1386 };
1387 let Some(remote_git_oid) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
1388 return Ok(PullPreflight::ImportRequired);
1389 };
1390 if remote_git_oid == local_git_oid {
1391 return Ok(PullPreflight::UpToDate);
1392 }
1393 if commit_is_descendant_of(&mirror_repo, remote_git_oid, local_git_oid)? {
1394 return Ok(PullPreflight::ImportRequired);
1395 }
1396 Err(GitProjectionError::RemoteDiverged {
1397 branch: thread.clone(),
1398 upstream: format!("{remote_name}/{thread}"),
1399 local: local_git_oid,
1400 remote: remote_git_oid,
1401 })
1402 }
1403
1404 fn mirror_checkout_tags_for_push(&self) -> GitProjectionResult<()> {
1405 if !self.heddle_repo.root().join(".git").exists() {
1406 return Ok(());
1407 }
1408
1409 let mirror_repo = self.open_git_repo()?;
1410 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1411 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1412 return Ok(());
1413 }
1414 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1415 let tag_updates = collect_ref_updates(&object_repo)?
1416 .into_iter()
1417 .filter(|update| update.namespace == RefNamespace::Tag)
1418 .collect::<Vec<_>>();
1419 if tag_updates.is_empty() {
1420 return Ok(());
1421 }
1422
1423 copy_reachable_objects(
1424 &object_repo,
1425 &mirror_repo,
1426 tag_updates.iter().map(|u| u.target),
1427 )?;
1428 apply_ref_updates(
1429 &mirror_repo,
1430 &tag_updates,
1431 "heddle: mirror checkout tags before push",
1432 )?;
1433 let mut record = read_mirror_managed_refs(&mirror_repo)?;
1441 for update in &tag_updates {
1442 record.insert(full_ref_name(update), update.target);
1443 }
1444 write_mirror_managed_refs(&mirror_repo, &record)?;
1445 Ok(())
1446 }
1447
1448 pub fn seed_git_checkpoint_mappings_from_checkout(
1449 &mut self,
1450 mirror_repo: &SleyRepository,
1451 ) -> GitProjectionResult<()> {
1452 if !self.heddle_repo.root().join(".git").exists() {
1453 return Ok(());
1454 }
1455
1456 let checkout_repo = match SleyRepository::discover(self.heddle_repo.root()) {
1457 Ok(repo) => repo,
1458 Err(_) => return Ok(()),
1459 };
1460 if checkout_repo.git_dir() == mirror_repo.git_dir() {
1461 return Ok(());
1462 }
1463 let object_repo = common_repo_for_worktree(&checkout_repo)?;
1464 for record in self.heddle_repo.list_git_checkpoints()? {
1465 let git_oid = record
1466 .git_commit
1467 .parse::<ObjectId>()
1468 .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?;
1469 if mirror_repo.read_object(&git_oid).is_err() {
1470 copy_reachable_objects(&object_repo, mirror_repo, [git_oid])?;
1471 }
1472 }
1473
1474 self.seed_git_checkpoint_mappings_from_repo(mirror_repo)
1475 }
1476
1477 fn seed_git_checkpoint_mappings_from_repo(
1478 &mut self,
1479 git_repo: &SleyRepository,
1480 ) -> GitProjectionResult<()> {
1481 for record in self.heddle_repo.list_git_checkpoints()? {
1482 let state_id = StateId::parse(&record.state_id)?;
1483 let git_oid = record
1484 .git_commit
1485 .parse::<ObjectId>()
1486 .map_err(|err| GitProjectionError::InvalidMapping(err.to_string()))?;
1487
1488 git_repo
1489 .read_object(&git_oid)
1490 .map_err(|_| GitProjectionError::CommitNotFound(record.git_commit.clone()))?;
1491
1492 self.mapping.insert(state_id, git_oid);
1493 let tier = self
1502 .heddle_repo
1503 .effective_visibility_tier(&state_id)
1504 .map_err(|e| {
1505 GitProjectionError::Git(format!("resolve visibility for {state_id}: {e:#}"))
1506 })?;
1507 if repo::visible(&tier, &repo::AudienceTier::Public)
1508 && super::git_notes::read_note(git_repo, git_oid)?.is_none()
1509 && let Some(state) = self.heddle_repo.store().get_state(&state_id)?
1510 {
1511 let note = super::git_notes::HeddleNote::from_state(&state);
1512 super::git_notes::write_note(git_repo, git_oid, ¬e)?;
1513 }
1514 }
1515
1516 Ok(())
1517 }
1518
1519 pub fn stage_ingest_source_in_mirror(
1520 &mut self,
1521 source: &Path,
1522 refs: &[String],
1523 ) -> GitProjectionResult<()> {
1524 let source_repo = open_repo(source)?;
1525 let updates = collect_import_source_ref_updates(&source_repo, refs)?;
1526 if updates.is_empty() {
1527 return Ok(());
1528 }
1529
1530 self.init_mirror()?;
1531 let mirror_repo = self.open_git_repo()?;
1532 copy_reachable_objects(
1533 &source_repo,
1534 &mirror_repo,
1535 updates.iter().map(|update| update.target),
1536 )?;
1537 apply_ref_updates(
1538 &mirror_repo,
1539 &updates,
1540 &format!("heddle: stage ingest source from {}", source.display()),
1541 )?;
1542
1543 let mut record = read_or_seed_mirror_managed_refs(&mirror_repo)?;
1544 for update in &updates {
1545 record.insert(full_ref_name(update), update.target);
1546 }
1547 write_mirror_managed_refs(&mirror_repo, &record)?;
1548 Ok(())
1549 }
1550
1551 pub fn write_through_current_checkout(&mut self) -> GitProjectionResult<WriteThroughOutcome> {
1556 if !self.heddle_repo.root().join(".git").exists() {
1557 return Ok(WriteThroughOutcome::Skipped(
1558 WriteThroughSkipReason::MissingDotGit,
1559 ));
1560 }
1561 if checkout_git_head_is_detached(self.heddle_repo.root())? {
1562 return Ok(WriteThroughOutcome::Skipped(
1563 WriteThroughSkipReason::DetachedHead,
1564 ));
1565 }
1566 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1567 return Ok(WriteThroughOutcome::Skipped(
1568 WriteThroughSkipReason::DetachedHead,
1569 ));
1570 };
1571 let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1572 return Ok(WriteThroughOutcome::Skipped(
1573 WriteThroughSkipReason::NoAttachedThread,
1574 ));
1575 };
1576 self.write_thread_state_checkout_direct(&thread, &state_id, None)
1577 }
1578
1579 pub fn write_through_current_checkout_with_message(
1580 &mut self,
1581 state_id: StateId,
1582 message: String,
1583 ) -> GitProjectionResult<WriteThroughOutcome> {
1584 if !self.heddle_repo.root().join(".git").exists() {
1585 return Ok(WriteThroughOutcome::Skipped(
1586 WriteThroughSkipReason::MissingDotGit,
1587 ));
1588 }
1589 if checkout_git_head_is_detached(self.heddle_repo.root())? {
1590 return Ok(WriteThroughOutcome::Skipped(
1591 WriteThroughSkipReason::DetachedHead,
1592 ));
1593 }
1594 self.set_commit_message_override(state_id, message);
1595 let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
1596 return Ok(WriteThroughOutcome::Skipped(
1597 WriteThroughSkipReason::DetachedHead,
1598 ));
1599 };
1600 let summary = self
1601 .commit_message_overrides
1602 .get(&state_id)
1603 .cloned()
1604 .unwrap_or_default();
1605 self.write_thread_state_checkout_direct(&thread, &state_id, Some(&summary))
1606 }
1607
1608 pub fn update_intent_to_add(&self, state_id: &StateId) -> GitProjectionResult<()> {
1631 let root = self.heddle_repo.root();
1632 if !root.join(".git").exists() {
1633 return Ok(());
1634 }
1635 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
1636 if checkout_repo
1639 .head()
1640 .map(|head| head.is_detached())
1641 .unwrap_or(false)
1642 {
1643 return Ok(());
1644 }
1645
1646 let Some(state) = self.heddle_repo.store().get_state(state_id)? else {
1648 return Ok(());
1649 };
1650 let Some(tree) = self.heddle_repo.store().get_tree(&state.tree)? else {
1651 return Ok(());
1652 };
1653 let mut captured: Vec<(String, FileMode)> = Vec::new();
1654 collect_capture_paths(self.heddle_repo.store(), &tree, "", &mut captured)?;
1655 let mut index = checkout_repo
1668 .open_index()
1669 .map_err(git_err)?
1670 .unwrap_or_else(|| Index {
1671 version: 2,
1672 entries: Vec::new(),
1673 extensions: Vec::new(),
1674 checksum: None,
1675 });
1676
1677 let mut real_tracked: HashSet<String> = HashSet::new();
1680 let mut existing_ita: HashSet<String> = HashSet::new();
1681 for entry in &index.entries {
1682 let path = String::from_utf8_lossy(entry.path.as_bytes()).into_owned();
1683 if entry.is_intent_to_add() {
1684 existing_ita.insert(path);
1685 } else {
1686 real_tracked.insert(path);
1687 }
1688 }
1689
1690 let captured_paths: HashSet<&str> = captured.iter().map(|(p, _)| p.as_str()).collect();
1693
1694 let before_prune = index.entries.len();
1696 index.entries.retain(|entry| {
1697 !entry.is_intent_to_add()
1698 || captured_paths.contains(String::from_utf8_lossy(entry.path.as_bytes()).as_ref())
1699 });
1700 let mut changed = index.entries.len() != before_prune;
1701
1702 for (path, mode) in &captured {
1704 if real_tracked.contains(path) || existing_ita.contains(path) {
1705 continue;
1706 }
1707 if real_tracked
1715 .iter()
1716 .any(|tracked| path_prefix_conflict(path, tracked))
1717 {
1718 continue;
1719 }
1720 if *mode == FileMode::Spoollink {
1724 continue;
1725 }
1726 let mut entry = IndexEntry::intent_to_add(
1727 checkout_repo.object_format(),
1728 GitBString::from(path.as_str()),
1729 );
1730 entry.mode = match mode {
1731 FileMode::Executable => 0o100755,
1732 FileMode::Symlink => 0o120000,
1733 FileMode::Gitlink => 0o160000,
1734 FileMode::Normal => 0o100644,
1735 FileMode::Spoollink => 0o100644,
1737 };
1738 changed = true;
1739 index.entries.push(entry);
1740 }
1741
1742 if changed {
1743 index
1744 .entries
1745 .sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
1746 index.upgrade_version_for_flags();
1747 checkout_repo
1748 .write_index(
1749 &index,
1750 IndexWriteOptions {
1751 fsync: true,
1752 validate_checksum: true,
1753 },
1754 )
1755 .map_err(git_err)?;
1756 }
1757 Ok(())
1758 }
1759
1760 pub fn write_through_thread_checkout(
1765 &mut self,
1766 thread: &str,
1767 ) -> GitProjectionResult<WriteThroughOutcome> {
1768 if !self.heddle_repo.root().join(".git").exists() {
1769 return Ok(WriteThroughOutcome::Skipped(
1770 WriteThroughSkipReason::MissingDotGit,
1771 ));
1772 }
1773
1774 let Some(state_id) = self
1775 .heddle_repo
1776 .refs()
1777 .get_thread(&ThreadName::new(thread))?
1778 else {
1779 return Ok(WriteThroughOutcome::Skipped(
1780 WriteThroughSkipReason::NoAttachedThread,
1781 ));
1782 };
1783 self.write_thread_state_checkout_direct(thread, &state_id, None)
1784 }
1785
1786 fn write_thread_state_checkout_direct(
1787 &mut self,
1788 thread: &str,
1789 state_id: &StateId,
1790 checkpoint_summary: Option<&str>,
1791 ) -> GitProjectionResult<WriteThroughOutcome> {
1792 let git = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1793 if git.git_dir().join("index.lock").exists() {
1794 return Ok(WriteThroughOutcome::Skipped(
1795 WriteThroughSkipReason::IndexAlreadyDirty,
1796 ));
1797 }
1798 let checkout = CheckoutWrite::prepare(self.heddle_repo.root(), thread)?;
1799 self.build_existing_mapping(Some(self.heddle_repo.root()))?;
1800 self.seed_git_checkpoint_mappings_from_repo(&checkout.object_repo)?;
1801 self.seed_ingest_identity_mappings_from_repo(&checkout.object_repo)?;
1802
1803 if self.linearize_unmapped_tip_to_checkout
1808 && self.mapping.get_git(state_id).is_none()
1809 && !self.commit_parent_overrides.contains_key(state_id)
1810 && let Some(previous) = checkout.previous_branch
1811 {
1812 self.set_commit_parent_override(*state_id, vec![previous]);
1813 }
1814
1815 let identity = git_config_identity_with_global_fallback(self.heddle_repo.root())?;
1816 let audience = AudienceTier::Public;
1817 for reachable in self.sort_states_topologically(&[*state_id])? {
1818 let message_override = self
1819 .commit_message_overrides
1820 .get(&reachable)
1821 .map(String::as_str);
1822 let parent_override = self
1823 .commit_parent_overrides
1824 .get(&reachable)
1825 .map(Vec::as_slice);
1826 if let Some(mapped) = self.mapping.get_git(&reachable) {
1827 let native_object_missing = checkout.object_repo.read_object(&mapped).is_err()
1828 && self
1829 .heddle_repo
1830 .store()
1831 .get_state(&reachable)?
1832 .is_some_and(|state| state.raw_message.is_none());
1833 if !native_object_missing {
1834 continue;
1835 }
1836 }
1837 let Some(git_oid) = export_state(
1838 &mut self.mapping,
1839 self.heddle_repo,
1840 &checkout.object_repo,
1841 &reachable,
1842 ExportStateOptions {
1843 identity: identity.as_ref(),
1844 message_override,
1845 parent_override,
1846 audience: &audience,
1847 },
1848 )?
1849 else {
1850 continue;
1851 };
1852 if let Some(mapped) = self.mapping.get_git(&reachable)
1853 && mapped != git_oid
1854 {
1855 return Err(GitProjectionError::MappingConflict {
1856 message: format!(
1857 "state {reachable} reminted as {git_oid}, but the durable mapping expects {mapped}"
1858 ),
1859 });
1860 }
1861 self.mapping.insert(reachable, git_oid);
1862 if let Some(state) = self.heddle_repo.store().get_state(&reachable)? {
1863 git_notes::write_note(
1864 &checkout.object_repo,
1865 git_oid,
1866 &git_notes::HeddleNote::from_state(&state),
1867 )?;
1868 }
1869 }
1870
1871 let Some(git_oid) = self.mapping.get_git(state_id) else {
1872 return Ok(WriteThroughOutcome::Skipped(
1873 WriteThroughSkipReason::NoMappedCommit,
1874 ));
1875 };
1876 materialize_active_checkout_closure(
1877 self.heddle_repo,
1878 &self.mapping,
1879 &checkout.object_repo,
1880 state_id,
1881 git_oid,
1882 &checkout.excluded_objects()?,
1883 )?;
1884 self.save_mapping_to_disk()?;
1885
1886 if let Some(summary) = checkpoint_summary {
1887 let pending = self.heddle_repo.pending_git_checkpoint_intent()?;
1888 let previous_git_oid = pending
1889 .as_ref()
1890 .filter(|intent| {
1891 intent.state_id == state_id.to_string_full()
1892 && intent.branch == thread
1893 && intent.new_git_oid == git_oid.to_string()
1894 })
1895 .and_then(|intent| intent.previous_git_oid.clone())
1896 .or_else(|| checkout.previous_branch.map(|oid| oid.to_string()));
1897 let intent = GitCheckpointIntent {
1898 version: 1,
1899 state_id: state_id.to_string_full(),
1900 branch: thread.to_string(),
1901 previous_git_oid,
1902 new_git_oid: git_oid.to_string(),
1903 summary: summary.to_string(),
1904 phase: GitCheckpointIntentPhase::Prepared,
1905 };
1906 self.heddle_repo.begin_git_checkpoint_intent(&intent)?;
1907 objects::fault_inject::maybe_panic_at("git_checkpoint_after_intent_before_publish");
1908 }
1909
1910 checkout.publish(git_oid)?;
1911 if checkpoint_summary.is_some() {
1912 objects::fault_inject::maybe_panic_at("git_checkpoint_after_publish_before_phase");
1913 self.heddle_repo
1914 .mark_git_checkpoint_published(state_id, &git_oid.to_string())?;
1915 }
1916 Ok(WriteThroughOutcome::Wrote(git_oid))
1917 }
1918
1919 pub fn write_current_checkout_from_existing_mirror(
1920 &mut self,
1921 ) -> GitProjectionResult<WriteThroughOutcome> {
1922 if !self.heddle_repo.root().join(".git").exists() {
1923 return Ok(WriteThroughOutcome::Skipped(
1924 WriteThroughSkipReason::MissingDotGit,
1925 ));
1926 }
1927
1928 let (thread, state_id) = match self.heddle_repo.head_ref()? {
1929 Head::Attached { thread } => {
1930 let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
1931 return Ok(WriteThroughOutcome::Skipped(
1932 WriteThroughSkipReason::NoAttachedThread,
1933 ));
1934 };
1935 (thread, state_id)
1936 }
1937 Head::Detached { .. } => {
1938 return Ok(WriteThroughOutcome::Skipped(
1939 WriteThroughSkipReason::DetachedHead,
1940 ));
1941 }
1942 };
1943 self.write_thread_state_checkout_from_existing_mirror(&thread, &state_id)
1944 }
1945
1946 fn write_thread_state_checkout_from_existing_mirror(
1947 &mut self,
1948 thread: &str,
1949 state_id: &StateId,
1950 ) -> GitProjectionResult<WriteThroughOutcome> {
1951 let mirror_repo = self.open_git_repo()?;
1952 if self.mapping.is_empty() {
1953 self.build_existing_mapping(None)?;
1954 }
1955 let git_oid = if let Some(git_oid) = self.mapping.get_git(state_id) {
1956 git_oid
1957 } else if let Some(git_commit) = self
1958 .heddle_repo
1959 .git_overlay_mapped_git_commit_for_state(state_id)
1960 .map_err(|error| GitProjectionError::Git(error.to_string()))?
1961 {
1962 ObjectId::from_hex(mirror_repo.object_format(), &git_commit)
1963 .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?
1964 } else {
1965 return Ok(WriteThroughOutcome::Skipped(
1966 WriteThroughSkipReason::NoMappedCommit,
1967 ));
1968 };
1969
1970 let git = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
1971 if git.git_dir().join("index.lock").exists() {
1972 return Ok(WriteThroughOutcome::Skipped(
1973 WriteThroughSkipReason::IndexAlreadyDirty,
1974 ));
1975 }
1976 let checkout = CheckoutWrite::prepare(self.heddle_repo.root(), thread)?;
1977 if checkout.checkout_repo.git_dir() == mirror_repo.git_dir() {
1978 return Ok(WriteThroughOutcome::Skipped(
1979 WriteThroughSkipReason::MirrorIsWorktree,
1980 ));
1981 }
1982 materialize_checkout_closure_from_state(
1983 self.heddle_repo,
1984 &self.mapping,
1985 &mirror_repo,
1986 &checkout.object_repo,
1987 state_id,
1988 git_oid,
1989 &checkout.excluded_objects()?,
1990 )?;
1991 checkout.publish(git_oid)?;
1992 Ok(WriteThroughOutcome::Wrote(git_oid))
1993 }
1994
1995 fn refresh_checkout_remote_tracking_ref(
1996 &self,
1997 remote_name: &str,
1998 branch: &str,
1999 ) -> GitProjectionResult<()> {
2000 if !self.heddle_repo.root().join(".git").exists() {
2001 return Ok(());
2002 }
2003 let Some(tracking_remote) =
2004 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
2005 else {
2006 return Ok(());
2007 };
2008 reject_reserved_git_remote_name(&tracking_remote)?;
2009
2010 let mirror_repo = self.open_git_repo()?;
2011 let branch_ref = format!("refs/heads/{branch}");
2012 let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
2013 return Ok(());
2014 };
2015 let Some(target) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
2016 return Ok(());
2017 };
2018
2019 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
2020 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2021 return Ok(());
2022 }
2023 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2024 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
2025 set_reference(
2026 &object_repo,
2027 &format!("refs/remotes/{tracking_remote}/{branch}"),
2028 target,
2029 RefPrecondition::Any,
2030 "heddle: refresh remote-tracking branch after pull",
2031 )?;
2032 Ok(())
2033 }
2034
2035 fn refresh_checkout_remote_tracking_refs(&self, remote_name: &str) -> GitProjectionResult<()> {
2036 if !self.heddle_repo.root().join(".git").exists() {
2037 return Ok(());
2038 }
2039 let Some(tracking_remote) =
2040 checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
2041 else {
2042 return Ok(());
2043 };
2044 reject_reserved_git_remote_name(&tracking_remote)?;
2045
2046 let mirror_repo = self.open_git_repo()?;
2047 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
2048 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2049 return Ok(());
2050 }
2051 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2052 let prefix = format!("refs/remotes/{remote_name}/");
2053 for reference in mirror_repo.references().list_refs().map_err(git_err)? {
2054 if !reference.name.starts_with(&prefix) {
2055 continue;
2056 }
2057 let ReferenceTarget::Direct(target) = reference.target else {
2058 continue;
2059 };
2060 let full = reference.name;
2061 let Some(branch) = full.strip_prefix(&prefix) else {
2062 continue;
2063 };
2064 if branch.ends_with("/HEAD") {
2065 continue;
2066 }
2067 copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
2068 set_reference(
2069 &object_repo,
2070 &format!("refs/remotes/{tracking_remote}/{branch}"),
2071 target,
2072 RefPrecondition::Any,
2073 "heddle: refresh remote-tracking branch after fetch",
2074 )?;
2075 }
2076 Ok(())
2077 }
2078
2079 fn refresh_checkout_note_refs_from_mirror(&self) -> GitProjectionResult<()> {
2080 if !self.heddle_repo.root().join(".git").exists() {
2081 return Ok(());
2082 }
2083
2084 let mirror_repo = self.open_git_repo()?;
2085 let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
2086 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2087 return Ok(());
2088 }
2089 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2090 let note_updates = collect_ref_updates(&mirror_repo)?
2091 .into_iter()
2092 .filter(|update| update.namespace == RefNamespace::Note)
2093 .collect::<Vec<_>>();
2094 if note_updates.is_empty() {
2095 return Ok(());
2096 }
2097
2098 copy_reachable_objects(
2099 &mirror_repo,
2100 &object_repo,
2101 note_updates.iter().map(|u| u.target),
2102 )?;
2103 apply_ref_updates(
2104 &object_repo,
2105 ¬e_updates,
2106 "heddle: refresh Heddle note refs",
2107 )?;
2108 Ok(())
2109 }
2110
2111 fn resolve_remote(
2112 &self,
2113 remote_name: &str,
2114 direction: RemoteDirection,
2115 ) -> GitProjectionResult<ResolvedRemote> {
2116 let repo = self.open_git_repo()?;
2117 let url = match remote_url_from_repo(&repo, remote_name, direction)? {
2118 Some(url) => Some(url),
2119 None => self.checkout_remote_url(remote_name, direction)?,
2120 };
2121
2122 let base = repo_relative_base(&repo);
2123 let url = match url {
2124 Some(url) => url,
2125 None => parse_configured_remote_url(remote_name, &base)?,
2126 };
2127
2128 if let Some(path) = local_path_from_url(&url)? {
2129 Ok(ResolvedRemote::Local(path))
2130 } else {
2131 Ok(ResolvedRemote::Url(url))
2132 }
2133 }
2134
2135 fn checkout_remote_url(
2136 &self,
2137 remote_name: &str,
2138 direction: RemoteDirection,
2139 ) -> GitProjectionResult<Option<String>> {
2140 if direction == RemoteDirection::Fetch
2141 && let Some(url) =
2142 remote_fetch_url_from_checkout_config(self.heddle_repo.root(), remote_name)?
2143 {
2144 return Ok(Some(url));
2145 }
2146 let Ok(repo) = SleyRepository::discover(self.heddle_repo.root()) else {
2147 return Ok(None);
2148 };
2149 remote_url_from_repo(&repo, remote_name, direction)
2150 }
2151}
2152
2153fn remote_url_from_repo(
2154 repo: &SleyRepository,
2155 remote_name: &str,
2156 direction: RemoteDirection,
2157) -> GitProjectionResult<Option<String>> {
2158 let config = repo.config_snapshot().map_err(git_err)?;
2159 let push = direction == RemoteDirection::Push;
2160 let value = if push {
2161 config
2162 .get("remote", Some(remote_name), "pushurl")
2163 .or_else(|| config.get("remote", Some(remote_name), "url"))
2164 } else {
2165 config.get("remote", Some(remote_name), "url")
2166 };
2167 let Some(value) = value else {
2168 return Ok(None);
2169 };
2170 let rewritten =
2171 sley::plumbing::sley_config::remotes::rewrite_url_with_config(&config, value, push);
2172 parse_configured_remote_url(&rewritten, &repo_relative_base(repo)).map(Some)
2173}
2174
2175fn checkout_tracking_remote_name(
2176 root: &Path,
2177 requested: &str,
2178) -> GitProjectionResult<Option<String>> {
2179 let remotes = checkout_remote_url_items(root)?;
2180 if remotes.is_empty() {
2181 return Ok(None);
2182 }
2183 if let Some((name, _)) = remotes.iter().find(|(name, _)| name == requested) {
2184 return Ok(Some(name.clone()));
2185 }
2186 if let Some((name, _)) = remotes
2187 .iter()
2188 .find(|(_, url)| configured_remote_values_match(url, requested))
2189 {
2190 return Ok(Some(name.clone()));
2191 }
2192 if looks_like_remote_location(requested) && remotes.len() == 1 {
2193 return Ok(Some(remotes[0].0.clone()));
2194 }
2195 if !looks_like_remote_location(requested) {
2196 return Ok(Some(requested.to_string()));
2197 }
2198 Ok(None)
2199}
2200
2201fn checkout_remote_url_items(root: &Path) -> GitProjectionResult<Vec<(String, String)>> {
2202 let mut remotes = Vec::new();
2203 for config_path in checkout_git_config_paths(root) {
2204 parse_remote_url_items_from_config(&config_path, &mut remotes)?;
2205 }
2206 Ok(remotes)
2207}
2208
2209fn checkout_note_ref_exists(root: &Path) -> GitProjectionResult<bool> {
2210 if !root.join(".git").exists() {
2211 return Ok(false);
2212 }
2213 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2214 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2215 Ok(object_repo
2216 .find_reference(super::git_notes::NOTES_REF)
2217 .map_err(git_err)?
2218 .is_some())
2219}
2220
2221fn seed_checkout_note_refs_into_mirror(
2222 root: &Path,
2223 mirror_repo: &SleyRepository,
2224) -> GitProjectionResult<()> {
2225 if !root.join(".git").exists() {
2226 return Ok(());
2227 }
2228
2229 let checkout_repo = match SleyRepository::discover(root) {
2230 Ok(repo) => repo,
2231 Err(_) => return Ok(()),
2232 };
2233 if checkout_repo.git_dir() == mirror_repo.git_dir() {
2234 return Ok(());
2235 }
2236 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2237 let note_updates = collect_ref_updates(&object_repo)?
2238 .into_iter()
2239 .filter(|update| update.namespace == RefNamespace::Note)
2240 .collect::<Vec<_>>();
2241 if note_updates.is_empty() {
2242 return Ok(());
2243 }
2244
2245 copy_reachable_objects(
2246 &object_repo,
2247 mirror_repo,
2248 note_updates.iter().map(|update| update.target),
2249 )?;
2250 apply_ref_updates(
2251 mirror_repo,
2252 ¬e_updates,
2253 "heddle: seed mirror note refs from checkout",
2254 )
2255}
2256
2257fn hydrate_checkout_notes_from_remote_without_mirror(
2258 root: &Path,
2259 remote_name: &str,
2260) -> GitProjectionResult<()> {
2261 reject_reserved_git_remote_name(remote_name)?;
2262 let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
2263 let object_repo = common_repo_for_worktree(&checkout_repo)?;
2264 let url = remote_fetch_url_from_checkout_config(root, remote_name)?.ok_or_else(|| {
2265 GitProjectionError::Git(format!("remote '{remote_name}' has no fetch URL"))
2266 })?;
2267
2268 if let Some(path) = local_path_from_url(&url)? {
2269 let remote_repo = open_repo(&path)?;
2270 let note_updates = collect_ref_updates(&remote_repo)?
2271 .into_iter()
2272 .filter(|update| update.namespace == RefNamespace::Note)
2273 .collect::<Vec<_>>();
2274 if note_updates.is_empty() {
2275 return Ok(());
2276 }
2277 copy_reachable_objects(
2278 &remote_repo,
2279 &object_repo,
2280 note_updates.iter().map(|update| update.target),
2281 )?;
2282 apply_ref_updates(
2283 &object_repo,
2284 ¬e_updates,
2285 &format!("heddle: hydrate notes from {remote_name}"),
2286 )?;
2287 return Ok(());
2288 }
2289
2290 fetch_heddle_notes_into_repo(&object_repo, remote_name, &url)
2291}
2292
2293fn fetch_heddle_notes_into_repo(
2294 repo: &SleyRepository,
2295 remote_name: &str,
2296 url: &str,
2297) -> GitProjectionResult<()> {
2298 let mut credentials = NoCredentials;
2299 let mut progress = SilentProgress;
2300 let refspec = RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format();
2301 repo.fetch_with_http_client(
2302 url,
2303 &[refspec],
2304 FetchOptions {
2305 filter_auto: false,
2307 progress: None,
2308 upload_pack_command: None,
2309 negotiation_include: None,
2312 negotiation_restrict: None,
2313 reject_shallow: false,
2314 negotiate_only: false,
2315 quiet: true,
2316 auto_follow_tags: false,
2317 fetch_all_tags: false,
2318 prune: false,
2319 dry_run: false,
2320 append: false,
2321 write_fetch_head: true,
2322 force: false,
2323 tag_option_explicit: true,
2324 prune_option_explicit: true,
2325 prune_tags: false,
2326 prune_tags_option_explicit: false,
2327 refmap: None,
2328 refetch: false,
2329 record_promisor_refs: false,
2330 update_head_ok: false,
2331 ssh_options: None,
2332 atomic: false,
2333 depth: None,
2334 merge_srcs: Vec::new(),
2335 filter: None,
2336 cloning: false,
2337 update_shallow: false,
2338 deepen_relative: false,
2339 deepen_since: None,
2340 deepen_not: Vec::new(),
2341 },
2342 &mut credentials,
2343 &mut progress,
2344 configured_https_client(),
2345 )
2346 .map(|_| ())
2347 .map_err(|err| {
2348 GitProjectionError::Git(git_transport_error_message(format!(
2349 "failed to fetch notes from {remote_name}: {err}"
2350 )))
2351 })
2352}
2353
2354fn parse_remote_url_items_from_config(
2355 path: &Path,
2356 remotes: &mut Vec<(String, String)>,
2357) -> GitProjectionResult<()> {
2358 let Ok(contents) = fs::read_to_string(path) else {
2359 return Ok(());
2360 };
2361 let mut current_remote: Option<String> = None;
2362 for raw in contents.lines() {
2363 let line = raw.trim();
2364 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2365 continue;
2366 }
2367 if line.starts_with('[') && line.ends_with(']') {
2368 current_remote = line
2369 .strip_prefix("[remote \"")
2370 .and_then(|rest| rest.strip_suffix("\"]"))
2371 .map(str::to_string);
2372 continue;
2373 }
2374 let Some(name) = current_remote.as_ref() else {
2375 continue;
2376 };
2377 let Some((key, value)) = line.split_once('=') else {
2378 continue;
2379 };
2380 if key.trim().eq_ignore_ascii_case("url") {
2381 remotes.push((name.clone(), git_config_value(value.trim())?));
2382 }
2383 }
2384 Ok(())
2385}
2386
2387fn configured_remote_values_match(left: &str, right: &str) -> bool {
2388 if left == right {
2389 return true;
2390 }
2391 let left_path = Path::new(left);
2392 let right_path = Path::new(right);
2393 if let (Ok(left), Ok(right)) = (left_path.canonicalize(), right_path.canonicalize()) {
2394 return left == right;
2395 }
2396 false
2397}
2398
2399fn materialize_active_checkout_closure(
2400 heddle_repo: &HeddleRepository,
2401 mapping: &SyncMapping,
2402 object_repo: &SleyRepository,
2403 tip_state_id: &StateId,
2404 tip_oid: ObjectId,
2405 excluded: &HashSet<ObjectId>,
2406) -> GitProjectionResult<()> {
2407 let residuals = ResidualStore::open(heddle_repo.heddle_dir());
2408 let mut stack = vec![*tip_state_id];
2409 let mut seen = HashSet::new();
2410
2411 while let Some(state_id) = stack.pop() {
2412 if !seen.insert(state_id) {
2413 continue;
2414 }
2415 let git_oid = if state_id == *tip_state_id {
2416 tip_oid
2417 } else {
2418 mapping
2419 .get_git(&state_id)
2420 .ok_or(GitProjectionError::StateNotFound(state_id))?
2421 };
2422 if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
2423 continue;
2424 }
2425
2426 let state = heddle_repo
2427 .store()
2428 .get_state(&state_id)?
2429 .ok_or(GitProjectionError::StateNotFound(state_id))?;
2430 if commit_is_byte_faithful(&state) {
2431 let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
2432 let reconstructed = commit_object_id(&content);
2433 if reconstructed != git_oid {
2434 return Err(GitProjectionError::MappingConflict {
2435 message: format!(
2436 "state {state_id} reconstructs as {reconstructed}, expected {git_oid}"
2437 ),
2438 });
2439 }
2440 write_commit_object(object_repo, &content)?;
2441 stack.extend(state.parents);
2442 continue;
2443 }
2444
2445 if !residuals.install_into(object_repo, &git_oid)? {
2446 return Err(GitProjectionError::CommitNotFound(format!(
2447 "{git_oid} for state {state_id}; the authoritative checkout .git and Raw Git Object Residual store do not contain it"
2448 )));
2449 }
2450 match verify_closure_present(object_repo, &[git_oid], excluded) {
2451 Ok(()) => {}
2452 Err(ClosureCheck::Incomplete { missing }) => {
2453 return Err(GitProjectionError::CommitNotFound(format!(
2454 "{missing} in the closure of {git_oid}; restore the original object to the authoritative checkout .git"
2455 )));
2456 }
2457 Err(ClosureCheck::Walk(error)) => return Err(error),
2458 }
2459 }
2460 Ok(())
2461}
2462
2463fn looks_like_remote_location(value: &str) -> bool {
2464 value.starts_with('/')
2465 || value.starts_with("./")
2466 || value.starts_with("../")
2467 || value.starts_with("~/")
2468 || value.contains("://")
2469 || value.contains('\\')
2470}
2471
2472fn remote_fetch_url_from_checkout_config(
2473 root: &Path,
2474 remote_name: &str,
2475) -> GitProjectionResult<Option<String>> {
2476 for config_path in checkout_git_config_paths(root) {
2477 let Some(url) = parse_remote_fetch_url_from_config(&config_path, remote_name)? else {
2478 continue;
2479 };
2480 return parse_configured_remote_url(&url, root).map(Some);
2481 }
2482 Ok(None)
2483}
2484
2485fn parse_configured_remote_url(value: &str, relative_base: &Path) -> GitProjectionResult<String> {
2486 if configured_remote_is_local_path(value) {
2487 let path = configured_remote_local_path(value, relative_base);
2488 return Ok(format!("file://{}", path.display()));
2489 }
2490 Ok(value.to_string())
2491}
2492
2493fn configured_remote_local_path(value: &str, relative_base: &Path) -> PathBuf {
2494 if value == "~"
2495 && let Some(home) = std::env::var_os("HOME")
2496 {
2497 return PathBuf::from(home);
2498 }
2499 if let Some(rest) = value.strip_prefix("~/")
2500 && let Some(home) = std::env::var_os("HOME")
2501 {
2502 return PathBuf::from(home).join(rest);
2503 }
2504
2505 let path = Path::new(value);
2506 if path.is_absolute() {
2507 path.to_path_buf()
2508 } else {
2509 relative_base.join(path)
2510 }
2511}
2512
2513fn configured_remote_is_local_path(value: &str) -> bool {
2514 value.starts_with('/')
2515 || value.starts_with("./")
2516 || value.starts_with("../")
2517 || value.starts_with('~')
2518 || value.starts_with(std::path::MAIN_SEPARATOR)
2519}
2520
2521fn checkout_git_config_paths(root: &Path) -> Vec<PathBuf> {
2522 let dot_git = root.join(".git");
2523 let mut paths = Vec::new();
2524 if dot_git.is_dir() {
2525 paths.push(dot_git.join("config"));
2526 if let Some(common_dir) = common_git_dir_from_git_dir(&dot_git) {
2527 paths.push(common_dir.join("config"));
2528 }
2529 return paths;
2530 }
2531 let Ok(contents) = fs::read_to_string(&dot_git) else {
2532 return paths;
2533 };
2534 let Some(target) = contents.trim().strip_prefix("gitdir:").map(str::trim) else {
2535 return paths;
2536 };
2537 let git_dir = {
2538 let path = Path::new(target);
2539 if path.is_absolute() {
2540 path.to_path_buf()
2541 } else {
2542 dot_git
2543 .parent()
2544 .map(|parent| parent.join(path))
2545 .unwrap_or_else(|| path.to_path_buf())
2546 }
2547 };
2548 paths.push(git_dir.join("config"));
2549 if let Some(common_dir) = common_git_dir_from_git_dir(&git_dir) {
2550 paths.push(common_dir.join("config"));
2551 }
2552 paths
2553}
2554
2555fn common_git_dir_from_git_dir(git_dir: &Path) -> Option<PathBuf> {
2556 let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
2557 let target = contents.trim();
2558 let path = Path::new(target);
2559 Some(if path.is_absolute() {
2560 path.to_path_buf()
2561 } else {
2562 git_dir.join(path)
2563 })
2564}
2565
2566fn parse_remote_fetch_url_from_config(
2567 path: &Path,
2568 remote_name: &str,
2569) -> GitProjectionResult<Option<String>> {
2570 let Ok(contents) = fs::read_to_string(path) else {
2571 return Ok(None);
2572 };
2573 let mut in_remote = false;
2574 for raw in contents.lines() {
2575 let line = raw.trim();
2576 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2577 continue;
2578 }
2579 if line.starts_with('[') && line.ends_with(']') {
2580 in_remote = line
2581 .strip_prefix("[remote \"")
2582 .and_then(|rest| rest.strip_suffix("\"]"))
2583 == Some(remote_name);
2584 continue;
2585 }
2586 if !in_remote {
2587 continue;
2588 }
2589 let Some((key, value)) = line.split_once('=') else {
2590 continue;
2591 };
2592 if key.trim().eq_ignore_ascii_case("url") {
2593 return git_config_value(value.trim()).map(Some);
2594 }
2595 }
2596 Ok(None)
2597}
2598
2599fn common_repo_for_worktree(repo: &SleyRepository) -> GitProjectionResult<SleyRepository> {
2600 let common_dir_file = repo.git_dir().join("commondir");
2601 let Ok(contents) = fs::read_to_string(&common_dir_file) else {
2602 return Ok(repo.clone());
2603 };
2604 let target = contents.trim();
2605 if target.is_empty() {
2606 return Ok(repo.clone());
2607 }
2608 let common_dir = {
2609 let path = Path::new(target);
2610 if path.is_absolute() {
2611 path.to_path_buf()
2612 } else {
2613 repo.git_dir().join(path)
2614 }
2615 };
2616 open_repo(&common_dir)
2617}
2618
2619pub fn git_err(err: impl std::fmt::Display) -> GitProjectionError {
2620 GitProjectionError::Git(git_transport_error_message(err))
2621}
2622
2623pub fn git_transport_error_message(error: impl std::fmt::Display) -> String {
2625 let message = error.to_string();
2626 let normalized = message.to_ascii_lowercase();
2627 let tls_trust_failure = normalized.contains("invalid peer certificate")
2628 || normalized.contains("unknownissuer")
2629 || normalized.contains("unknown issuer")
2630 || normalized.contains("certificateunknown");
2631 if tls_trust_failure && !message.contains("HEDDLE_REMOTE_TLS_CA_CERT") {
2632 format!(
2633 "{message}; trust this server's CA with \
2634 HEDDLE_REMOTE_TLS_CA_CERT=/path/to/ca.pem"
2635 )
2636 } else {
2637 message
2638 }
2639}
2640
2641fn restore_file_if_unchanged(
2642 path: &Path,
2643 expected: &[u8],
2644 previous: Option<&[u8]>,
2645) -> GitProjectionResult<()> {
2646 let file_name = path.file_name().ok_or_else(|| {
2647 GitProjectionError::Git(format!("cannot lock rollback path {}", path.display()))
2648 })?;
2649 let mut lock_name = file_name.to_os_string();
2650 lock_name.push(".lock");
2651 let lock_path = path.with_file_name(lock_name);
2652 let mut lock = OpenOptions::new()
2653 .write(true)
2654 .create_new(true)
2655 .open(&lock_path)
2656 .map_err(|error| {
2657 GitProjectionError::Git(format!(
2658 "cannot acquire Git rollback lock {}: {error}",
2659 lock_path.display()
2660 ))
2661 })?;
2662 let result = (|| {
2663 let current = fs::read(path)?;
2664 if current != expected {
2665 return Err(GitProjectionError::Git(format!(
2666 "refusing to roll back {} because another Git operation changed it",
2667 path.display()
2668 )));
2669 }
2670 if let Some(previous) = previous {
2671 lock.write_all(previous)?;
2672 lock.sync_all()?;
2673 drop(lock);
2674 fs::rename(&lock_path, path)?;
2675 } else {
2676 drop(lock);
2677 fs::remove_file(path)?;
2678 fs::remove_file(&lock_path)?;
2679 }
2680 Ok(())
2681 })();
2682 if result.is_err() {
2683 let _ = fs::remove_file(&lock_path);
2684 }
2685 result
2686}
2687
2688fn read_optional_file(path: &Path) -> GitProjectionResult<Option<Vec<u8>>> {
2689 match fs::read(path) {
2690 Ok(contents) => Ok(Some(contents)),
2691 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2692 Err(error) => Err(error.into()),
2693 }
2694}
2695
2696fn rollback_reference_if_unchanged(
2697 repo: &SleyRepository,
2698 name: &str,
2699 published: ObjectId,
2700 previous: Option<ObjectId>,
2701) -> GitProjectionResult<()> {
2702 let current = match repo.find_reference(name).map_err(git_err)? {
2703 Some(reference) => reference.peeled_oid(repo).map_err(git_err)?,
2704 None => None,
2705 };
2706 if current == previous {
2707 return Ok(());
2708 }
2709 if current != Some(published) {
2710 return Err(GitProjectionError::Git(format!(
2711 "refusing to roll back Git reference '{name}' because another Git operation changed it"
2712 )));
2713 }
2714 let rollback = match previous {
2715 Some(previous) => set_reference(
2716 repo,
2717 name,
2718 previous,
2719 RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(published)),
2720 "heddle: rollback failed write-through",
2721 ),
2722 None => delete_reference_matching(repo, name, published),
2723 };
2724 if rollback.is_ok() {
2725 return Ok(());
2726 }
2727 let current = match repo.find_reference(name).map_err(git_err)? {
2728 Some(reference) => reference.peeled_oid(repo).map_err(git_err)?,
2729 None => None,
2730 };
2731 if current == previous {
2732 Ok(())
2733 } else {
2734 rollback
2735 }
2736}
2737
2738fn fsync_path(path: &Path) -> GitProjectionResult<()> {
2742 match std::fs::File::open(path) {
2743 Ok(file) => {
2744 file.sync_all()?;
2745 Ok(())
2746 }
2747 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2748 Err(err) => Err(GitProjectionError::Io(err)),
2749 }
2750}
2751
2752pub struct MirrorInitGuard {
2759 path: PathBuf,
2760 rollback: Option<bool>,
2764}
2765
2766impl MirrorInitGuard {
2767 pub fn new_from_init(path: PathBuf, did_create: bool) -> Self {
2768 Self {
2769 path,
2770 rollback: Some(did_create),
2771 }
2772 }
2773
2774 pub fn commit(mut self) {
2775 self.rollback = None;
2776 }
2777}
2778
2779impl Drop for MirrorInitGuard {
2780 fn drop(&mut self) {
2781 if matches!(self.rollback, Some(true))
2782 && self.path.exists()
2783 && let Err(err) = std::fs::remove_dir_all(&self.path)
2784 {
2785 tracing::warn!(
2786 path = %self.path.display(),
2787 error = %err,
2788 "failed to roll back partial legacy Bridge Mirror; manual cleanup may be required"
2789 );
2790 }
2791 }
2792}
2793
2794pub fn thread_is_unclaimed_bootstrap(
2805 heddle_repo: &HeddleRepository,
2806 state_id: &StateId,
2807) -> GitProjectionResult<bool> {
2808 let Some(state) = heddle_repo.store().get_state(state_id)? else {
2809 return Ok(false);
2810 };
2811 if !state.parents.is_empty() {
2812 return Ok(false);
2813 }
2814 let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
2815 return Ok(false);
2816 };
2817 Ok(tree == Tree::new())
2818}
2819
2820pub fn open_repo(path: &Path) -> GitProjectionResult<SleyRepository> {
2821 match SleyRepository::discover(path) {
2822 Ok(repo) => Ok(repo),
2823 Err(_) => SleyRepository::open(path).map_err(git_err),
2824 }
2825}
2826
2827pub fn delete_reference_if_present(repo: &SleyRepository, name: &str) -> GitProjectionResult<()> {
2835 delete_reference(repo, name, None, true)
2836}
2837
2838fn delete_reference_matching(
2839 repo: &SleyRepository,
2840 name: &str,
2841 expected_old: ObjectId,
2842) -> GitProjectionResult<()> {
2843 delete_reference(repo, name, Some(expected_old), false)
2844}
2845
2846fn delete_reference(
2847 repo: &SleyRepository,
2848 name: &str,
2849 expected_old: Option<ObjectId>,
2850 missing_ok: bool,
2851) -> GitProjectionResult<()> {
2852 let refs = repo.references();
2853 match refs.read_ref(name).map_err(git_err)? {
2854 None if missing_ok => Ok(()),
2855 None => Err(GitProjectionError::Git(format!(
2856 "failed to delete Git reference '{name}': ref is missing"
2857 ))),
2858 Some(ReferenceTarget::Direct(oid)) => repo
2859 .delete_ref(DeleteRef {
2860 name: FullName::new(name).map_err(git_err)?,
2861 expected_old: Some(expected_old.unwrap_or(oid)),
2862 expected: None,
2863 reflog: None,
2864 reflog_committer: None,
2865 })
2866 .map_err(git_err),
2867 Some(ReferenceTarget::Symbolic(_)) => {
2868 if let Some(expected_old) = expected_old {
2869 let current = repo
2870 .find_reference(name)
2871 .map_err(git_err)?
2872 .and_then(|reference| reference.peeled_oid(repo).ok().flatten());
2873 if current != Some(expected_old) {
2874 return Err(GitProjectionError::Git(format!(
2875 "failed to delete Git reference '{name}': expected {expected_old}, found {}",
2876 current
2877 .map(|oid| oid.to_string())
2878 .unwrap_or_else(|| "missing".to_string())
2879 )));
2880 }
2881 }
2882 refs.delete_symbolic_ref(name).map(|_| ()).map_err(git_err)
2883 }
2884 }
2885}
2886
2887pub fn set_reference(
2888 repo: &SleyRepository,
2889 name: &str,
2890 target: ObjectId,
2891 constraint: RefPrecondition,
2892 log_message: &str,
2893) -> GitProjectionResult<()> {
2894 let refs = repo.references();
2895 let old_oid = match refs.read_ref(name).map_err(git_err)? {
2896 Some(ReferenceTarget::Direct(oid)) => oid,
2897 _ => ObjectId::null(repo.object_format()),
2898 };
2899 let reflog = sley::plumbing::sley_refs::ReflogEntry {
2900 old_oid,
2901 new_oid: target,
2902 committer: git_projection_signature(),
2903 message: log_message.as_bytes().to_vec(),
2904 };
2905 let mut tx = refs.transaction();
2906 tx.update_to(
2907 name.to_string(),
2908 ReferenceTarget::Direct(target),
2909 constraint,
2910 Some(reflog),
2911 );
2912 tx.commit().map_err(git_err)?;
2913 Ok(())
2914}
2915
2916fn path_prefix_conflict(a: &str, b: &str) -> bool {
2922 let child_of = |parent: &str, child: &str| {
2923 child
2924 .strip_prefix(parent)
2925 .is_some_and(|rest| rest.starts_with('/'))
2926 };
2927 child_of(a, b) || child_of(b, a)
2928}
2929
2930fn collect_capture_paths<S: ObjectStore + ?Sized>(
2935 store: &S,
2936 tree: &Tree,
2937 prefix: &str,
2938 out: &mut Vec<(String, FileMode)>,
2939) -> GitProjectionResult<()> {
2940 for entry in tree.iter() {
2941 let path = if prefix.is_empty() {
2942 entry.name().to_string()
2943 } else {
2944 format!("{prefix}/{}", entry.name())
2945 };
2946 if entry.is_tree() {
2947 if let Some(hash) = entry.tree_hash()
2948 && let Some(subtree) = store.get_tree(&hash)?
2949 {
2950 collect_capture_paths(store, &subtree, &path, out)?;
2951 }
2952 } else {
2953 out.push((path, entry.mode()));
2954 }
2955 }
2956 Ok(())
2957}
2958
2959fn update_checkout_branch_ref(
2960 repo: &SleyRepository,
2961 branch_ref: &str,
2962 target: ObjectId,
2963 previous_branch: Option<ObjectId>,
2964 log_message: &str,
2965) -> GitProjectionResult<sley::plumbing::sley_refs::ReflogEntry> {
2966 let expected = previous_branch.map_or(RefPrecondition::MustNotExist, |oid| {
2967 RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(oid))
2968 });
2969 let old_oid = previous_branch.unwrap_or_else(|| ObjectId::null(repo.object_format()));
2970 let head_reflog = sley::plumbing::sley_refs::ReflogEntry {
2971 old_oid,
2972 new_oid: target,
2973 committer: git_projection_signature(),
2974 message: log_message.as_bytes().to_vec(),
2975 };
2976 set_reference(repo, branch_ref, target, expected, log_message)?;
2977 Ok(head_reflog)
2978}
2979
2980fn checkout_git_head_is_detached(root: &Path) -> GitProjectionResult<bool> {
2981 let repo = SleyRepository::discover(root).map_err(git_err)?;
2982 Ok(repo.head().map(|head| head.is_detached()).unwrap_or(false))
2983}
2984
2985pub fn resolve_git_commit_identity(
2986 repo_root: &Path,
2987 fallback: &Principal,
2988) -> GitProjectionResult<LocalGitIdentity> {
2989 if !principal_is_default_unknown(fallback) {
2990 return Ok(LocalGitIdentity::from_principal(fallback));
2991 }
2992 if let Some(identity) = git_config_identity_with_global_fallback(repo_root)? {
2993 return Ok(identity);
2994 }
2995
2996 Err(GitProjectionError::Git(
2997 "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(),
2998 ))
2999}
3000
3001pub fn git_config_identity_with_global_fallback(
3002 repo_root: &Path,
3003) -> GitProjectionResult<Option<LocalGitIdentity>> {
3004 let name = git_config_value_with_global_fallback(repo_root, "user.name")?;
3005 let email = git_config_value_with_global_fallback(repo_root, "user.email")?;
3006 if let (Some(name), Some(email)) = (name, email)
3007 && !name.trim().is_empty()
3008 && !email.trim().is_empty()
3009 {
3010 return Ok(Some(LocalGitIdentity { name, email }));
3011 }
3012 Ok(None)
3013}
3014
3015pub fn principal_is_default_unknown(principal: &Principal) -> bool {
3016 principal.name.trim().is_empty()
3017 || principal.email.trim().is_empty()
3018 || (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
3019}
3020
3021fn git_config_value_with_global_fallback(
3022 repo_root: &Path,
3023 key: &str,
3024) -> GitProjectionResult<Option<String>> {
3025 let Ok(repo) = SleyRepository::discover(repo_root) else {
3026 return Ok(None);
3027 };
3028 let Some((section, variable)) = key.split_once('.') else {
3029 return Ok(None);
3030 };
3031 Ok(repo
3032 .config_snapshot()
3033 .map_err(git_err)?
3034 .get(section, None, variable)
3035 .map(str::to_string))
3036}
3037
3038fn git_config_value(value: &str) -> GitProjectionResult<String> {
3039 let Some(quoted) = value
3040 .strip_prefix('"')
3041 .and_then(|rest| rest.strip_suffix('"'))
3042 else {
3043 return Ok(value.to_string());
3044 };
3045 let mut out = String::new();
3046 let mut chars = quoted.chars();
3047 while let Some(ch) = chars.next() {
3048 if ch != '\\' {
3049 out.push(ch);
3050 continue;
3051 }
3052 let Some(escaped) = chars.next() else {
3053 return Err(GitProjectionError::Git(
3054 "unterminated escape in repo-local Git config".to_string(),
3055 ));
3056 };
3057 match escaped {
3058 '"' | '\\' => out.push(escaped),
3059 'n' => out.push('\n'),
3060 't' => out.push('\t'),
3061 'b' => out.push('\u{0008}'),
3062 other => out.push(other),
3063 }
3064 }
3065 Ok(out)
3066}
3067
3068fn git_projection_signature() -> Vec<u8> {
3069 let seconds = SystemTime::now()
3070 .duration_since(UNIX_EPOCH)
3071 .map(|duration| duration.as_secs() as i64)
3072 .unwrap_or(0);
3073 format!("Heddle <heddle@local> {seconds} +0000").into_bytes()
3074}
3075
3076fn repo_relative_base(repo: &SleyRepository) -> PathBuf {
3077 repo.workdir().unwrap_or_else(|| {
3078 if repo
3079 .git_dir()
3080 .file_name()
3081 .is_some_and(|name| name == ".git")
3082 {
3083 repo.git_dir()
3084 .parent()
3085 .map(Path::to_path_buf)
3086 .unwrap_or_else(|| repo.git_dir().to_path_buf())
3087 } else {
3088 repo.git_dir().to_path_buf()
3089 }
3090 })
3091}
3092
3093fn local_path_from_url(url: &str) -> GitProjectionResult<Option<PathBuf>> {
3094 if url.starts_with("heddle://") {
3104 return Err(GitProjectionError::Git(format!(
3105 "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"
3106 )));
3107 }
3108 let Some(raw_path) = url.strip_prefix("file://") else {
3109 return Ok(None);
3110 };
3111 let path = PathBuf::from(raw_path);
3112 if path.as_os_str().is_empty() {
3113 return Err(GitProjectionError::Git(format!(
3114 "remote '{}' has no filesystem path",
3115 url
3116 )));
3117 }
3118 Ok(Some(path))
3119}
3120
3121fn collect_ref_updates(repo: &SleyRepository) -> GitProjectionResult<Vec<RefUpdate>> {
3122 let mut updates = Vec::new();
3123
3124 for reference in repo.references().list_refs().map_err(git_err)? {
3125 let ReferenceTarget::Direct(target) = reference.target else {
3126 continue;
3127 };
3128 let ref_name = GitRefName::new(&reference.name);
3129 if let Some(namespace) = ref_name.content_namespace()
3130 && let Some(name) = ref_name.short_name()
3131 {
3132 updates.push(RefUpdate {
3133 name: name.to_string(),
3134 target,
3135 namespace,
3136 });
3137 }
3138 }
3139
3140 Ok(updates)
3141}
3142
3143#[derive(Debug, Default, Clone, Copy)]
3152pub struct ExportedCommitCounts {
3153 pub total: usize,
3154 pub newly: usize,
3155}
3156
3157pub fn count_exported_commits(
3171 repo: &SleyRepository,
3172 newly_minted: &HashSet<ObjectId>,
3173) -> GitProjectionResult<ExportedCommitCounts> {
3174 let tips: Vec<ObjectId> = collect_ref_updates(repo)?
3175 .into_iter()
3176 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag))
3177 .map(|update| update.target)
3178 .collect();
3179
3180 let mut stack = tips;
3181 let mut seen = HashSet::new();
3182 let mut counts = ExportedCommitCounts::default();
3183 while let Some(oid) = stack.pop() {
3184 if !seen.insert(oid) {
3185 continue;
3186 }
3187 let object = repo.read_object(&oid).map_err(git_err)?;
3188 match object.object_type {
3189 GitObjectType::Commit => {
3190 counts.total += 1;
3191 if newly_minted.contains(&oid) {
3192 counts.newly += 1;
3193 }
3194 let commit = repo.read_commit(&oid).map_err(git_err)?;
3195 for parent in commit.parents {
3196 stack.push(parent);
3197 }
3198 }
3199 GitObjectType::Tag => {
3203 let tag = repo.read_tag(&oid).map_err(git_err)?;
3204 stack.push(tag.object);
3205 }
3206 GitObjectType::Tree | GitObjectType::Blob => {}
3207 }
3208 }
3209 Ok(counts)
3210}
3211
3212fn collect_ref_updates_for_fetch(
3213 repo: &SleyRepository,
3214 scope: GitFetchScope,
3215) -> GitProjectionResult<Vec<RefUpdate>> {
3216 let updates = collect_ref_updates(repo)?;
3217 match scope {
3218 GitFetchScope::AllRefs => Ok(updates),
3219 GitFetchScope::BranchesAndNotes => Ok(updates
3220 .into_iter()
3221 .filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Note))
3222 .collect()),
3223 }
3224}
3225
3226pub fn collect_import_source_ref_updates(
3227 repo: &SleyRepository,
3228 refs: &[String],
3229) -> GitProjectionResult<Vec<RefUpdate>> {
3230 let updates = collect_ref_updates(repo)?;
3231 if refs.is_empty() {
3232 return Ok(updates);
3233 }
3234
3235 let wanted: HashSet<&str> = refs.iter().map(String::as_str).collect();
3236 Ok(updates
3237 .into_iter()
3238 .filter(|update| matches_import_ref(update, &wanted))
3239 .collect())
3240}
3241
3242fn matches_import_ref(update: &RefUpdate, wanted: &HashSet<&str>) -> bool {
3243 let full = full_ref_name(update);
3244 wanted.contains(update.name.as_str()) || wanted.contains(full.as_str())
3245}
3246
3247fn full_ref_name(update: &RefUpdate) -> String {
3248 GitRefName::content_full_name(update.namespace, &update.name)
3249}
3250
3251#[cfg(test)]
3252pub fn ensure_commit_update_fast_forward(
3253 repo: &SleyRepository,
3254 name: &str,
3255 old: ObjectId,
3256 new: ObjectId,
3257) -> GitProjectionResult<()> {
3258 if old == new || old == ObjectId::null(repo.object_format()) {
3259 return Ok(());
3260 }
3261 match commit_is_descendant_of(repo, new, old) {
3262 Ok(true) => Ok(()),
3263 Ok(false) => Err(GitProjectionError::NonFastForwardRef {
3264 name: name.to_string(),
3265 old,
3266 new,
3267 remote_destination: false,
3268 }),
3269 Err(err) => Err(GitProjectionError::Git(format!(
3270 "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"
3271 ))),
3272 }
3273}
3274
3275fn commit_is_descendant_of(
3276 repo: &SleyRepository,
3277 descendant: ObjectId,
3278 ancestor: ObjectId,
3279) -> GitProjectionResult<bool> {
3280 let mut stack = vec![descendant];
3281 let mut seen = HashSet::new();
3282 while let Some(oid) = stack.pop() {
3283 if oid == ancestor {
3284 return Ok(true);
3285 }
3286 if !seen.insert(oid) {
3287 continue;
3288 }
3289 let commit = repo.read_commit(&oid).map_err(git_err)?;
3290 for parent in commit.parents {
3291 stack.push(parent);
3292 }
3293 }
3294 Ok(false)
3295}
3296
3297const HEDDLE_EXPORTED_REFS_FILE: &str = "heddle-exported-refs";
3307
3308const HEDDLE_NETWORK_EXPORTED_REFS_DIR: &str = "git-network-exported-refs";
3315
3316fn exported_refs_manifest_path(target_repo: &SleyRepository) -> PathBuf {
3317 target_repo.git_dir().join(HEDDLE_EXPORTED_REFS_FILE)
3318}
3319
3320fn network_exported_refs_path(heddle_dir: &Path, url: &str) -> PathBuf {
3325 let key = ContentHash::compute_typed("git-network-exported-refs", url.as_bytes()).to_hex();
3326 heddle_dir
3327 .join(HEDDLE_NETWORK_EXPORTED_REFS_DIR)
3328 .join(format!("{key}.refs"))
3329}
3330
3331fn read_exported_refs_at(path: &Path) -> GitProjectionResult<HashMap<String, ObjectId>> {
3339 match fs::read_to_string(path) {
3340 Ok(text) => {
3341 let mut map = HashMap::new();
3342 for line in text.lines() {
3343 let line = line.trim();
3344 if line.is_empty() {
3345 continue;
3346 }
3347 let mut parts = line.split_whitespace();
3355 let Some(name) = parts.next() else {
3356 continue;
3357 };
3358 let tip = parts
3359 .next()
3360 .and_then(|token| token.parse::<ObjectId>().ok())
3361 .unwrap_or_else(|| ObjectId::null(ObjectFormat::Sha1));
3362 map.insert(name.to_string(), tip);
3363 }
3364 Ok(map)
3365 }
3366 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
3367 Err(e) => Err(GitProjectionError::Io(e)),
3368 }
3369}
3370
3371fn write_exported_refs_at(
3375 path: &Path,
3376 refs: &HashMap<String, ObjectId>,
3377) -> GitProjectionResult<()> {
3378 if let Some(parent) = path.parent() {
3379 fs::create_dir_all(parent)?;
3380 }
3381 let mut sorted: Vec<(&str, &ObjectId)> = refs
3382 .iter()
3383 .map(|(name, tip)| (name.as_str(), tip))
3384 .collect();
3385 sorted.sort_unstable_by(|a, b| a.0.cmp(b.0));
3386 let body = sorted
3387 .iter()
3388 .map(|(name, tip)| format!("{name} {tip}"))
3389 .collect::<Vec<_>>()
3390 .join("\n");
3391 let tmp = path.with_extension("tmp");
3392 fs::write(&tmp, body)?;
3393 fs::rename(&tmp, path)?;
3394 Ok(())
3395}
3396
3397pub fn write_head_symref(git_dir: &Path, branch_ref: &str) -> GitProjectionResult<()> {
3400 let repo = repo_for_git_dir(git_dir)?;
3401 repo.set_head_symref(branch_ref, HeadUpdateOptions::new())
3402 .map_err(git_err)?;
3403 Ok(())
3404}
3405
3406fn repo_for_git_dir(git_dir: &Path) -> GitProjectionResult<SleyRepository> {
3407 if let Ok(repo) = open_repo(git_dir) {
3408 return Ok(repo);
3409 }
3410 if git_dir.file_name().is_some_and(|name| name == ".git")
3411 && let Some(parent) = git_dir.parent()
3412 {
3413 return open_repo(parent);
3414 }
3415 open_repo(git_dir)
3416}
3417
3418pub fn read_exported_refs(
3421 target_repo: &SleyRepository,
3422) -> GitProjectionResult<HashMap<String, ObjectId>> {
3423 read_exported_refs_at(&exported_refs_manifest_path(target_repo))
3424}
3425
3426pub fn write_exported_refs(
3429 target_repo: &SleyRepository,
3430 refs: &HashMap<String, ObjectId>,
3431) -> GitProjectionResult<()> {
3432 write_exported_refs_at(&exported_refs_manifest_path(target_repo), refs)
3433}
3434
3435const HEDDLE_MIRROR_MANAGED_REFS_FILE: &str = "heddle-mirror-managed-refs";
3447
3448fn mirror_managed_refs_path(mirror_repo: &SleyRepository) -> PathBuf {
3450 mirror_repo.git_dir().join(HEDDLE_MIRROR_MANAGED_REFS_FILE)
3451}
3452
3453pub fn mirror_managed_refs_recorded(mirror_repo: &SleyRepository) -> bool {
3459 mirror_managed_refs_path(mirror_repo).exists()
3460}
3461
3462pub fn read_mirror_managed_refs(
3466 mirror_repo: &SleyRepository,
3467) -> GitProjectionResult<HashMap<String, ObjectId>> {
3468 read_exported_refs_at(&mirror_managed_refs_path(mirror_repo))
3469}
3470
3471pub fn write_mirror_managed_refs(
3474 mirror_repo: &SleyRepository,
3475 refs: &HashMap<String, ObjectId>,
3476) -> GitProjectionResult<()> {
3477 write_exported_refs_at(&mirror_managed_refs_path(mirror_repo), refs)
3478}
3479
3480pub fn read_or_seed_mirror_managed_refs(
3493 mirror_repo: &SleyRepository,
3494) -> GitProjectionResult<HashMap<String, ObjectId>> {
3495 if mirror_managed_refs_recorded(mirror_repo) {
3496 read_mirror_managed_refs(mirror_repo)
3497 } else {
3498 Ok(collect_ref_updates(mirror_repo)?
3499 .into_iter()
3500 .map(|update| (full_ref_name(&update), update.target))
3501 .collect())
3502 }
3503}
3504
3505pub fn collect_managed_ref_updates(
3515 repo: &SleyRepository,
3516 record: &HashMap<String, ObjectId>,
3517) -> GitProjectionResult<Vec<RefUpdate>> {
3518 Ok(collect_ref_updates(repo)?
3519 .into_iter()
3520 .filter(|update| {
3521 matches!(update.namespace, RefNamespace::Note)
3522 || record.contains_key(&full_ref_name(update))
3523 })
3524 .collect())
3525}
3526
3527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3533enum RefMove {
3534 Unchanged,
3536 Create,
3538 FastForward,
3540 Rewind,
3549 Diverged,
3552}
3553
3554fn classify_ref_move(
3570 repo: &SleyRepository,
3571 old: Option<ObjectId>,
3572 new: ObjectId,
3573 recorded_tip: Option<ObjectId>,
3574) -> GitProjectionResult<RefMove> {
3575 let Some(old) = old else {
3576 return Ok(RefMove::Create);
3577 };
3578 if old == ObjectId::null(repo.object_format()) {
3579 return Ok(RefMove::Create);
3580 }
3581 if old == new {
3582 return Ok(RefMove::Unchanged);
3583 }
3584 if commit_is_descendant_of(repo, new, old)? {
3587 return Ok(RefMove::FastForward);
3588 }
3589 if recorded_tip == Some(old)
3599 && repo.read_commit(&old).is_ok()
3600 && commit_is_descendant_of(repo, old, new)?
3601 {
3602 return Ok(RefMove::Rewind);
3603 }
3604 Ok(RefMove::Diverged)
3605}
3606
3607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3621enum WriteVerdict {
3622 Skip,
3624 Write,
3627 RequireForce,
3629}
3630
3631fn verdict_from_move(m: RefMove) -> WriteVerdict {
3636 match m {
3637 RefMove::Unchanged => WriteVerdict::Skip,
3638 RefMove::Create | RefMove::FastForward | RefMove::Rewind => WriteVerdict::Write,
3639 RefMove::Diverged => WriteVerdict::RequireForce,
3640 }
3641}
3642
3643fn classify_tag_move(
3651 old: Option<ObjectId>,
3652 target: ObjectId,
3653 recorded: Option<ObjectId>,
3654) -> WriteVerdict {
3655 match old {
3656 None => WriteVerdict::Write,
3658 Some(o) if o == target => WriteVerdict::Skip,
3660 Some(o) if recorded == Some(o) => WriteVerdict::Write,
3662 Some(_) => WriteVerdict::RequireForce,
3664 }
3665}
3666
3667#[derive(Debug)]
3670pub struct PlannedRefWrite {
3671 pub full_name: String,
3672 pub old: Option<ObjectId>,
3673 pub new: ObjectId,
3674 pub force: bool,
3675}
3676
3677#[derive(Debug)]
3680pub struct PlannedRefDelete {
3681 pub full_name: String,
3682 pub old: ObjectId,
3683}
3684
3685#[derive(Debug)]
3688pub struct DestinationReconcilePlan {
3689 pub writes: Vec<PlannedRefWrite>,
3691 pub deletes: Vec<PlannedRefDelete>,
3694 pub new_manifest: HashMap<String, ObjectId>,
3700}
3701
3702pub fn planned_write_names(plan: &DestinationReconcilePlan) -> Vec<String> {
3709 let mut names: Vec<String> = plan
3710 .writes
3711 .iter()
3712 .map(|write| write.full_name.clone())
3713 .collect();
3714 names.sort_unstable();
3715 names
3716}
3717
3718fn creatable_ref_names(
3727 served_frontier: &[RefUpdate],
3728 scope: GitPushScope,
3729 current_branch: Option<&str>,
3730) -> Option<HashSet<String>> {
3731 match scope {
3732 GitPushScope::AllThreads => None,
3733 GitPushScope::CurrentThread => {
3734 let branch = current_branch.unwrap_or_default();
3735 Some(
3736 served_frontier
3737 .iter()
3738 .filter(|update| {
3739 (matches!(update.namespace, RefNamespace::Branch) && update.name == branch)
3740 || matches!(update.namespace, RefNamespace::Note)
3741 })
3742 .map(full_ref_name)
3743 .collect(),
3744 )
3745 }
3746 }
3747}
3748
3749pub fn plan_destination_reconcile(
3797 mirror_repo: &SleyRepository,
3798 served_frontier: &[RefUpdate],
3799 creatable_names: Option<&HashSet<String>>,
3800 old_at_destination: &HashMap<String, ObjectId>,
3801 previously_exported: &HashMap<String, ObjectId>,
3802 force: bool,
3803) -> GitProjectionResult<DestinationReconcilePlan> {
3804 let desired: HashMap<String, &RefUpdate> = served_frontier
3810 .iter()
3811 .map(|u| (full_ref_name(u), u))
3812 .collect();
3813
3814 let mut names: BTreeSet<String> = desired.keys().cloned().collect();
3821 names.extend(previously_exported.keys().cloned());
3822
3823 let mut writes = Vec::new();
3824 let mut deletes = Vec::new();
3825 let mut new_manifest: HashMap<String, ObjectId> = HashMap::new();
3826
3827 for full in names {
3828 let old = old_at_destination.get(&full).copied();
3829 let recorded = previously_exported.get(&full).copied();
3830
3831 if let Some(update) = desired.get(&full).copied() {
3832 if old.is_none() && creatable_names.is_some_and(|names| !names.contains(&full)) {
3841 if let Some(recorded) = recorded {
3842 new_manifest.insert(full, recorded);
3843 }
3844 continue;
3845 }
3846 let (verdict, force_write) = match update.namespace {
3855 RefNamespace::Branch | RefNamespace::Note => {
3856 let movement = classify_ref_move(mirror_repo, old, update.target, recorded)?;
3857 (
3858 verdict_from_move(movement),
3859 matches!(movement, RefMove::Rewind),
3860 )
3861 }
3862 RefNamespace::Tag => {
3863 let verdict = classify_tag_move(old, update.target, recorded);
3864 (
3865 verdict,
3866 old.is_some_and(|old| old != update.target)
3867 && matches!(verdict, WriteVerdict::Write),
3868 )
3869 }
3870 };
3871 let proceed = match verdict {
3872 WriteVerdict::Skip => false,
3873 WriteVerdict::Write => true,
3874 WriteVerdict::RequireForce => {
3875 if force {
3876 true
3877 } else {
3878 return Err(GitProjectionError::NonFastForwardRef {
3879 name: full.clone(),
3880 old: old.unwrap_or_else(|| ObjectId::null(mirror_repo.object_format())),
3881 new: update.target,
3882 remote_destination: true,
3883 });
3884 }
3885 }
3886 };
3887 if proceed {
3888 writes.push(PlannedRefWrite {
3889 full_name: full.clone(),
3890 old,
3891 new: update.target,
3892 force: force_write || matches!(verdict, WriteVerdict::RequireForce),
3893 });
3894 }
3895 if proceed || recorded.is_some() {
3903 new_manifest.insert(full, update.target);
3904 }
3905 continue;
3906 }
3907
3908 match old {
3917 Some(old) if recorded == Some(old) || force => {
3918 deletes.push(PlannedRefDelete {
3919 full_name: full,
3920 old,
3921 });
3922 }
3924 Some(_) => {
3925 if let Some(recorded) = recorded {
3928 new_manifest.insert(full, recorded);
3929 }
3930 }
3931 None => {
3932 }
3934 }
3935 }
3936
3937 Ok(DestinationReconcilePlan {
3938 writes,
3939 deletes,
3940 new_manifest,
3941 })
3942}
3943
3944fn read_destination_ref_map(
3948 repo: &SleyRepository,
3949) -> GitProjectionResult<HashMap<String, ObjectId>> {
3950 Ok(collect_ref_updates(repo)?
3951 .iter()
3952 .map(|update| (full_ref_name(update), update.target))
3953 .collect())
3954}
3955
3956pub fn apply_ref_updates(
3957 repo: &SleyRepository,
3958 updates: &[RefUpdate],
3959 log_message: &str,
3960) -> GitProjectionResult<()> {
3961 for update in updates {
3962 let full_name = full_ref_name(update);
3963 set_reference(
3964 repo,
3965 &full_name,
3966 update.target,
3967 RefPrecondition::Any,
3968 log_message,
3969 )?;
3970 }
3971 Ok(())
3972}
3973
3974fn apply_remote_tracking_ref_updates(
3975 repo: &SleyRepository,
3976 remote_name: &str,
3977 updates: &[RefUpdate],
3978 log_message: &str,
3979) -> GitProjectionResult<()> {
3980 reject_reserved_git_remote_name(remote_name)?;
3981 for update in updates
3982 .iter()
3983 .filter(|update| update.namespace == RefNamespace::Branch)
3984 {
3985 set_reference(
3986 repo,
3987 &format!("refs/remotes/{remote_name}/{}", update.name),
3988 update.target,
3989 RefPrecondition::Any,
3990 log_message,
3991 )?;
3992 }
3993 Ok(())
3994}
3995
3996pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitProjectionResult<()> {
4000 fs::create_dir_all(dest)?;
4001 let source = open_repo(source_path)?;
4002 let target = match SleyRepository::open(dest) {
4003 Ok(repo) => repo,
4004 Err(_) => SleyRepository::init_bare(dest).map_err(git_err)?,
4005 };
4006 let updates = collect_ref_updates(&source)?;
4007 copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
4008 apply_ref_updates(
4009 &target,
4010 &updates,
4011 &format!("heddle: clone from {}", source_path.display()),
4012 )?;
4013
4014 let copied_branches: HashSet<&str> = updates
4022 .iter()
4023 .filter(|update| update.namespace == RefNamespace::Branch)
4024 .map(|update| update.name.as_str())
4025 .collect();
4026 let source_head_branch = source
4027 .head()
4028 .ok()
4029 .and_then(|head| head.branch_name().map(str::to_owned))
4030 .filter(|branch| copied_branches.contains(branch.as_str()));
4031 if let Some(branch) = source_head_branch {
4032 write_head_symref(dest, &format!("refs/heads/{branch}"))?;
4033 } else if copied_branches.contains("main") {
4034 write_head_symref(dest, "refs/heads/main")?;
4035 } else if let Some(first_branch) = updates
4036 .iter()
4037 .find(|update| update.namespace == RefNamespace::Branch)
4038 {
4039 write_head_symref(dest, &format!("refs/heads/{}", first_branch.name))?;
4040 }
4041 Ok(())
4042}
4043
4044pub fn clone_url_to_bare(
4063 url: &str,
4064 dest: &Path,
4065 depth: Option<u32>,
4066 filter: Option<&str>,
4067 progress: &mut dyn ProgressSink,
4068) -> GitProjectionResult<()> {
4069 if let Some(spec) = filter {
4073 return Err(GitProjectionError::Git(format!(
4074 "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"
4075 )));
4076 }
4077 if let Some(source_path) = local_path_from_url(url)? {
4078 if depth.is_some() {
4079 return Err(GitProjectionError::Git(
4080 "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"
4081 .to_string(),
4082 ));
4083 }
4084 return copy_local_repo_to_bare(&source_path, dest);
4085 }
4086 let default_branch = clone_url_to_bare_via_sley(url, dest, depth, progress)?
4087 .or_else(|| default_branch_from_file_url(url));
4088 if let Some(branch) = default_branch
4098 && bare_branch_exists(dest, &branch)?
4099 {
4100 write_head_symref(dest, &format!("refs/heads/{branch}"))?;
4101 }
4102 Ok(())
4103}
4104
4105fn default_branch_from_file_url(url: &str) -> Option<String> {
4106 let source_path = local_path_from_url(url).ok().flatten()?;
4107 let repo = open_repo(&source_path).ok()?;
4108 let head = repo.head_state().ok()?;
4109 let branch = head.branch_name()?;
4110 (!branch.is_empty()).then(|| branch.to_string())
4111}
4112
4113fn bare_branch_exists(repo_path: &Path, branch: &str) -> GitProjectionResult<bool> {
4114 let repo = open_repo(repo_path)?;
4115 Ok(repo
4116 .find_reference(&format!("refs/heads/{branch}"))
4117 .map_err(git_err)?
4118 .is_some())
4119}
4120
4121fn clone_url_to_bare_via_sley(
4122 url: &str,
4123 dest: &Path,
4124 depth: Option<u32>,
4125 progress: &mut dyn ProgressSink,
4126) -> GitProjectionResult<Option<String>> {
4127 fs::create_dir_all(dest)?;
4128 let repo = SleyRepository::init_bare(dest).map_err(git_err)?;
4129 let mut credentials =
4130 EmbeddingSafeCredentialProvider::new(&repo.config_snapshot().map_err(git_err)?);
4131 let display_url = sley::plumbing::sley_core::redact_url_for_display(url);
4132 let outcome = repo
4133 .fetch_with_http_client(
4134 url,
4135 &heddle_mirror_fetch_refspecs()?,
4136 FetchOptions {
4137 filter_auto: false,
4139 progress: None,
4140 upload_pack_command: None,
4141 negotiation_include: None,
4142 negotiation_restrict: None,
4143 reject_shallow: false,
4144 negotiate_only: false,
4145 quiet: true,
4146 auto_follow_tags: true,
4147 fetch_all_tags: true,
4148 prune: false,
4149 dry_run: false,
4150 append: false,
4151 write_fetch_head: true,
4152 force: false,
4153 tag_option_explicit: true,
4154 prune_option_explicit: true,
4155 prune_tags: false,
4156 prune_tags_option_explicit: false,
4157 refmap: None,
4158 refetch: false,
4159 record_promisor_refs: false,
4160 update_head_ok: false,
4161 ssh_options: None,
4162 atomic: false,
4163 depth,
4164 merge_srcs: Vec::new(),
4165 filter: None,
4166 cloning: true,
4167 update_shallow: false,
4168 deepen_relative: false,
4169 deepen_since: None,
4170 deepen_not: Vec::new(),
4171 },
4172 &mut credentials,
4173 progress,
4174 configured_https_client(),
4175 )
4176 .map_err(|err| {
4177 GitProjectionError::Git(git_transport_error_message(format!(
4178 "clone failed for {display_url}: {err}"
4179 )))
4180 })?;
4181 Ok(outcome
4182 .head_symref
4183 .and_then(|target| target.strip_prefix("refs/heads/").map(str::to_string)))
4184}
4185
4186#[allow(clippy::too_many_arguments)]
4219pub fn materialize_checkout_closure_from_state(
4220 heddle_repo: &HeddleRepository,
4221 mapping: &SyncMapping,
4222 mirror_repo: &SleyRepository,
4223 object_repo: &SleyRepository,
4224 tip_state_id: &StateId,
4225 tip_oid: ObjectId,
4226 excluded: &HashSet<ObjectId>,
4227) -> GitProjectionResult<()> {
4228 let mut lossy_roots: Vec<ObjectId> = Vec::new();
4232 let mut stack: Vec<StateId> = vec![*tip_state_id];
4233 let mut seen: HashSet<StateId> = HashSet::new();
4234 let residual_store = ResidualStore::open(heddle_repo.heddle_dir());
4235
4236 while let Some(state_id) = stack.pop() {
4237 if !seen.insert(state_id) {
4238 continue;
4239 }
4240 let Some(git_oid) = resolve_mapped_git_oid(heddle_repo, mapping, &state_id, object_repo)?
4241 else {
4242 continue;
4248 };
4249
4250 if excluded.contains(&git_oid) || object_repo.read_object(&git_oid).is_ok() {
4254 continue;
4255 }
4256
4257 let state = heddle_repo
4258 .store()
4259 .get_state(&state_id)?
4260 .ok_or(GitProjectionError::StateNotFound(state_id))?;
4261
4262 if commit_is_byte_faithful(&state) {
4263 let content = reconstruct_commit_bytes(heddle_repo, object_repo, mapping, &state)?;
4264 let reconstructed = commit_object_id(&content);
4268 if reconstructed != git_oid {
4269 return Err(GitProjectionError::Git(format!(
4270 "checkout reconstruction OID mismatch for state {state_id}: reconstructed {reconstructed}, expected mapped {git_oid}; \
4271 refusing to materialize a wrong-OID checkout (unmodeled fidelity gap)"
4272 )));
4273 }
4274 let written = write_commit_object(object_repo, &content)?;
4275 debug_assert_eq!(written, git_oid);
4276 stack.extend(state.parents.iter().copied());
4277 } else {
4278 lossy_roots.push(git_oid);
4285 }
4286 }
4287
4288 if object_repo.read_object(&tip_oid).is_err() && !lossy_roots.contains(&tip_oid) {
4294 lossy_roots.push(tip_oid);
4295 }
4296
4297 if !lossy_roots.is_empty() {
4298 materialize_lossy_roots_from_residual_or_mirror(
4299 &residual_store,
4300 mirror_repo,
4301 object_repo,
4302 &lossy_roots,
4303 excluded,
4304 )?;
4305 }
4306
4307 Ok(())
4308}
4309
4310fn materialize_lossy_roots_from_residual_or_mirror(
4315 residual_store: &ResidualStore,
4316 mirror_repo: &SleyRepository,
4317 object_repo: &SleyRepository,
4318 lossy_roots: &[ObjectId],
4319 excluded: &HashSet<ObjectId>,
4320) -> GitProjectionResult<()> {
4321 let format = object_repo.object_format();
4322 let mut mirror_needed: Vec<ObjectId> = Vec::new();
4323
4324 for oid in lossy_roots {
4325 if excluded.contains(oid) || object_repo.read_object(oid).is_ok() {
4326 continue;
4327 }
4328 match residual_store.install_into(object_repo, oid) {
4335 Ok(true) => {
4336 mirror_needed.push(*oid);
4339 }
4340 Ok(false) => {
4341 let residual = resolve_lossy_object(
4344 residual_store,
4345 Some(mirror_repo),
4346 format,
4347 oid,
4348 true, )?;
4350 let written = object_repo
4351 .write_object(sley::plumbing::sley_object::EncodedObject::new(
4352 residual.object_type,
4353 residual.body,
4354 ))
4355 .map_err(git_err)?;
4356 if written != *oid {
4357 return Err(GitProjectionError::Git(format!(
4358 "lossy materialize wrote {written}, expected {oid}"
4359 )));
4360 }
4361 mirror_needed.push(*oid);
4362 }
4363 Err(error) => return Err(error),
4364 }
4365 }
4366
4367 if !mirror_needed.is_empty() {
4368 if let Err(error) = copy_reachable_objects_excluding(
4374 mirror_repo,
4375 object_repo,
4376 mirror_needed.iter().copied(),
4377 excluded,
4378 ) {
4379 match verify_closure_present(object_repo, &mirror_needed, excluded) {
4385 Ok(()) => {
4386 }
4389 Err(ClosureCheck::Incomplete { missing }) => {
4390 return Err(GitProjectionError::Git(format!(
4395 "checkout object closure incomplete after mirror copy failure: \
4396missing reachable object {missing} (mirror error: {error})"
4397 )));
4398 }
4399 Err(ClosureCheck::Walk(walk_error)) => {
4400 return Err(GitProjectionError::Git(format!(
4403 "mirror copy failed ({error}); closure verification also failed: {walk_error}"
4404 )));
4405 }
4406 }
4407 }
4408 }
4409
4410 Ok(())
4411}
4412
4413enum ClosureCheck {
4415 Incomplete { missing: ObjectId },
4418 Walk(GitProjectionError),
4420}
4421
4422fn verify_closure_present(
4432 object_repo: &SleyRepository,
4433 roots: &[ObjectId],
4434 excluded: &HashSet<ObjectId>,
4435) -> Result<(), ClosureCheck> {
4436 const GITLINK_MODE: u32 = 0o160000;
4437
4438 let mut stack: Vec<ObjectId> = roots.to_vec();
4439 let mut seen: HashSet<ObjectId> = HashSet::new();
4440 while let Some(oid) = stack.pop() {
4441 if excluded.contains(&oid) || !seen.insert(oid) {
4442 continue;
4443 }
4444 let object = match object_repo.read_object(&oid) {
4445 Ok(object) => object,
4446 Err(_) => return Err(ClosureCheck::Incomplete { missing: oid }),
4447 };
4448 match object.object_type {
4449 GitObjectType::Commit => {
4450 let commit = object_repo
4451 .read_commit(&oid)
4452 .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4453 stack.push(commit.tree);
4454 for parent in commit.parents {
4455 stack.push(parent);
4456 }
4457 }
4458 GitObjectType::Tree => {
4459 let tree = object_repo
4460 .read_tree(&oid)
4461 .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4462 for entry in tree.entries {
4463 if entry.mode == GITLINK_MODE {
4466 continue;
4467 }
4468 stack.push(entry.oid);
4469 }
4470 }
4471 GitObjectType::Tag => {
4472 let tag = object_repo
4473 .read_tag(&oid)
4474 .map_err(|err| ClosureCheck::Walk(git_err(err)))?;
4475 stack.push(tag.object);
4476 }
4477 GitObjectType::Blob => {}
4478 }
4479 }
4480 Ok(())
4481}
4482
4483fn resolve_mapped_git_oid(
4488 heddle_repo: &HeddleRepository,
4489 mapping: &SyncMapping,
4490 state_id: &StateId,
4491 object_repo: &SleyRepository,
4492) -> GitProjectionResult<Option<ObjectId>> {
4493 if let Some(git_oid) = mapping.get_git(state_id) {
4494 return Ok(Some(git_oid));
4495 }
4496 if let Some(git_commit) = heddle_repo
4497 .git_overlay_mapped_git_commit_for_state(state_id)
4498 .map_err(|error| GitProjectionError::Git(error.to_string()))?
4499 {
4500 let oid = ObjectId::from_hex(object_repo.object_format(), &git_commit)
4501 .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?;
4502 return Ok(Some(oid));
4503 }
4504 Ok(None)
4505}
4506
4507pub fn copy_reachable_objects(
4508 source: &SleyRepository,
4509 target: &SleyRepository,
4510 roots: impl IntoIterator<Item = ObjectId>,
4511) -> GitProjectionResult<()> {
4512 let roots = roots.into_iter().collect::<Vec<_>>();
4516 target.copy_reachable_from(source, &roots).map_err(git_err)
4517}
4518
4519pub fn copy_reachable_objects_excluding(
4534 source: &SleyRepository,
4535 target: &SleyRepository,
4536 roots: impl IntoIterator<Item = ObjectId>,
4537 excluded: &HashSet<ObjectId>,
4538) -> GitProjectionResult<()> {
4539 if excluded.is_empty() {
4540 return copy_reachable_objects(source, target, roots);
4541 }
4542 if source.object_format() != target.object_format() {
4543 return copy_reachable_objects(source, target, roots);
4546 }
4547 sley::plumbing::sley_odb::install_reachable_pack_excluding(
4551 source.objects().as_ref(),
4552 target.objects().as_ref(),
4553 target.object_format(),
4554 roots,
4555 excluded,
4556 )
4557 .map_err(|error| GitProjectionError::Git(error.to_string()))?;
4558 target.refresh_objects();
4561 Ok(())
4562}
4563
4564fn fetch_network_remote(
4565 mirror_repo: &SleyRepository,
4566 remote_name: &str,
4567 url: &str,
4568 scope: GitFetchScope,
4569) -> GitProjectionResult<()> {
4570 let mut credentials = NoCredentials;
4571 let mut progress = SilentProgress;
4572 mirror_repo
4573 .fetch_with_http_client(
4574 url,
4575 &heddle_mirror_fetch_refspecs()?,
4576 FetchOptions {
4577 filter_auto: false,
4579 progress: None,
4580 upload_pack_command: None,
4581 negotiation_include: None,
4582 negotiation_restrict: None,
4583 reject_shallow: false,
4584 negotiate_only: false,
4585 quiet: true,
4586 auto_follow_tags: matches!(scope, GitFetchScope::AllRefs),
4587 fetch_all_tags: matches!(scope, GitFetchScope::AllRefs),
4588 prune: false,
4589 dry_run: false,
4590 append: false,
4591 write_fetch_head: true,
4592 force: false,
4593 tag_option_explicit: true,
4594 prune_option_explicit: true,
4595 prune_tags: false,
4596 prune_tags_option_explicit: false,
4597 refmap: None,
4598 refetch: false,
4599 record_promisor_refs: false,
4600 update_head_ok: false,
4601 ssh_options: None,
4602 atomic: false,
4603 depth: None,
4604 merge_srcs: Vec::new(),
4605 filter: None,
4606 cloning: false,
4607 update_shallow: false,
4608 deepen_relative: false,
4609 deepen_since: None,
4610 deepen_not: Vec::new(),
4611 },
4612 &mut credentials,
4613 &mut progress,
4614 configured_https_client(),
4615 )
4616 .map_err(|err| {
4617 GitProjectionError::Git(git_transport_error_message(format!(
4618 "failed to fetch from {url}: {err}"
4619 )))
4620 })?;
4621 let _ = remote_name;
4622 Ok(())
4623}
4624
4625fn push_network_remote(
4628 mirror_repo: &SleyRepository,
4629 heddle_dir: &Path,
4630 url: &str,
4631 scope: GitPushScope,
4632 current_branch: Option<&str>,
4633 force: bool,
4634) -> GitProjectionResult<Vec<String>> {
4635 let manifest_path = network_exported_refs_path(heddle_dir, url);
4641 let previously_exported = read_exported_refs_at(&manifest_path)?;
4642 let managed_record = read_mirror_managed_refs(mirror_repo)?;
4652 let served_frontier = collect_managed_ref_updates(mirror_repo, &managed_record)?;
4653 if served_frontier.is_empty() && previously_exported.is_empty() {
4654 return Ok(Vec::new());
4655 }
4656
4657 let mut credentials = NoCredentials;
4658 let records = mirror_repo
4659 .ls_remote_with_http_client(
4660 url,
4661 LsRemoteFilter {
4662 heads: false,
4663 tags: false,
4664 refs_only: true,
4665 },
4666 &|_| true,
4667 &mut credentials,
4668 configured_https_client(),
4669 )
4670 .map_err(|err| {
4671 GitProjectionError::Git(git_transport_error_message(format!(
4672 "failed to list refs from {url}: {err}"
4673 )))
4674 })?;
4675 let remote_refs = records
4676 .into_iter()
4677 .filter(|record| GitRefName::new(&record.name).content_namespace().is_some())
4678 .map(|record| (record.name, record.oid))
4679 .collect::<HashMap<_, _>>();
4680
4681 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4686 let plan = plan_destination_reconcile(
4687 mirror_repo,
4688 &served_frontier,
4689 creatable.as_ref(),
4690 &remote_refs,
4691 &previously_exported,
4692 force,
4693 )?;
4694
4695 if plan.writes.is_empty() && plan.deletes.is_empty() {
4696 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4699 return Ok(Vec::new());
4700 }
4701
4702 let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4703 let mut pack_objects = Vec::with_capacity(plan.writes.len());
4704 let force_transport_checks = plan.writes.iter().any(|write| write.force);
4705 for write in &plan.writes {
4706 commands.push(PushCommand {
4707 src: Some(write.new),
4708 dst: write.full_name.clone(),
4709 expected_old: write.old,
4710 force: write.force,
4711 });
4712 pack_objects.push(write.new);
4713 }
4714 for delete in &plan.deletes {
4715 commands.push(PushCommand {
4716 src: None,
4717 dst: delete.full_name.clone(),
4718 expected_old: Some(delete.old),
4719 force: false,
4720 });
4721 }
4722
4723 let mut credentials = NoCredentials;
4724 let mut progress = SilentProgress;
4725 mirror_repo
4726 .push_actions_with_http_client(
4727 url,
4728 PushActionPlan {
4729 commands,
4730 pack_objects,
4731 options: PushOptions {
4732 atomic: false,
4734 push_options: Vec::new(),
4735 quiet: true,
4736 force: force || force_transport_checks,
4737 thin: sley::remote::PushThinMode::Auto,
4738 },
4739 },
4740 &mut credentials,
4741 &mut progress,
4742 configured_https_client(),
4743 )
4744 .map_err(|err| {
4745 GitProjectionError::Git(git_transport_error_message(format!(
4746 "push failed for {url}: {err}"
4747 )))
4748 })?;
4749 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4752 Ok(planned_write_names(&plan))
4753}
4754
4755pub struct AuthoritativeGitPushOptions<'a> {
4761 pub heddle_dir: &'a Path,
4762 pub remote: &'a str,
4763 pub scope: GitPushScope,
4764 pub current_branch: Option<&'a str>,
4765 pub force: bool,
4766}
4767
4768pub fn push_authoritative_git_refs(
4769 source: &SleyRepository,
4770 options: AuthoritativeGitPushOptions<'_>,
4771 credentials: &mut dyn CredentialProvider,
4772 progress: &mut dyn ProgressSink,
4773) -> GitProjectionResult<Vec<String>> {
4774 let AuthoritativeGitPushOptions {
4775 heddle_dir,
4776 remote,
4777 scope,
4778 current_branch,
4779 force,
4780 } = options;
4781 let remote_url = source.remote(remote).map_err(git_err)?.push_url();
4782 let manifest_path = network_exported_refs_path(heddle_dir, &remote_url);
4783 let previously_exported = read_exported_refs_at(&manifest_path)?;
4784 let served_frontier = collect_ref_updates(source)?
4785 .into_iter()
4786 .filter(|update| {
4787 matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag)
4788 || (update.namespace == RefNamespace::Note && update.name == "heddle")
4789 })
4790 .filter(|update| match scope {
4791 GitPushScope::AllThreads => true,
4792 GitPushScope::CurrentThread => {
4793 update.namespace == RefNamespace::Note
4794 || (update.namespace == RefNamespace::Branch
4795 && Some(update.name.as_str()) == current_branch)
4796 }
4797 })
4798 .collect::<Vec<_>>();
4799
4800 let records = source
4801 .ls_remote_with_http_client(
4802 &remote_url,
4803 LsRemoteFilter {
4804 heads: false,
4805 tags: false,
4806 refs_only: true,
4807 },
4808 &|_| true,
4809 credentials,
4810 configured_https_client(),
4811 )
4812 .map_err(git_err)?;
4813 let remote_refs = records
4814 .into_iter()
4815 .filter(|record| GitRefName::new(&record.name).content_namespace().is_some())
4816 .map(|record| (record.name, record.oid))
4817 .collect::<HashMap<_, _>>();
4818 let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
4819 let previously_exported_in_scope = previously_exported
4820 .iter()
4821 .filter(|(name, _)| match scope {
4822 GitPushScope::AllThreads => true,
4823 GitPushScope::CurrentThread => {
4824 name.as_str() == "refs/notes/heddle"
4825 || current_branch
4826 .is_some_and(|branch| name.as_str() == format!("refs/heads/{branch}"))
4827 }
4828 })
4829 .map(|(name, oid)| (name.clone(), *oid))
4830 .collect::<HashMap<_, _>>();
4831 let mut plan = plan_destination_reconcile(
4832 source,
4833 &served_frontier,
4834 creatable.as_ref(),
4835 &remote_refs,
4836 &previously_exported_in_scope,
4837 force,
4838 )?;
4839 if scope == GitPushScope::CurrentThread {
4840 for (name, oid) in previously_exported {
4841 plan.new_manifest.entry(name).or_insert(oid);
4842 }
4843 }
4844 if plan.writes.is_empty() && plan.deletes.is_empty() {
4845 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4846 return Ok(Vec::new());
4847 }
4848
4849 let force_transport_checks = plan.writes.iter().any(|write| write.force);
4850 let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
4851 let mut pack_objects = Vec::with_capacity(plan.writes.len());
4852 for write in &plan.writes {
4853 commands.push(PushCommand {
4854 src: Some(write.new),
4855 dst: write.full_name.clone(),
4856 expected_old: write.old,
4857 force: write.force,
4858 });
4859 pack_objects.push(write.new);
4860 }
4861 for delete in &plan.deletes {
4862 commands.push(PushCommand {
4863 src: None,
4864 dst: delete.full_name.clone(),
4865 expected_old: Some(delete.old),
4866 force: false,
4867 });
4868 }
4869 source
4870 .push_actions_with_http_client(
4871 remote,
4872 PushActionPlan {
4873 commands,
4874 pack_objects,
4875 options: PushOptions {
4876 quiet: true,
4877 force: force || force_transport_checks,
4878 thin: sley::remote::PushThinMode::Auto,
4879 atomic: false,
4880 push_options: Vec::new(),
4881 },
4882 },
4883 credentials,
4884 progress,
4885 configured_https_client(),
4886 )
4887 .map_err(git_err)?;
4888 write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
4889 Ok(planned_write_names(&plan))
4890}
4891
4892#[cfg(test)]
4893mod tests {
4894 use super::*;
4895
4896 #[test]
4897 fn parse_git_ref_local_branch() {
4898 let parsed = parse_git_ref("refs/heads/main").expect("local branch parses");
4899 assert_eq!(parsed.kind, GitRefKind::Branch);
4900 assert_eq!(parsed.name, "main");
4901 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4902 }
4903
4904 #[test]
4905 fn parse_git_ref_remote_branch_keeps_nested_name() {
4906 let parsed = parse_git_ref("refs/remotes/origin/feature/x").expect("remote branch parses");
4907 assert_eq!(parsed.kind, GitRefKind::Branch);
4908 assert_eq!(parsed.name, "feature/x");
4909 assert_eq!(parsed.remote, "origin");
4910 }
4911
4912 #[test]
4913 fn parse_git_ref_tag() {
4914 let parsed = parse_git_ref("refs/tags/v1.0").expect("tag parses");
4915 assert_eq!(parsed.kind, GitRefKind::Tag);
4916 assert_eq!(parsed.name, "v1.0");
4917 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4918 }
4919
4920 #[test]
4921 fn parse_git_ref_note() {
4922 let parsed = parse_git_ref("refs/notes/heddle").expect("note parses");
4923 assert_eq!(parsed.kind, GitRefKind::Note);
4924 assert_eq!(parsed.name, "heddle");
4925 assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
4926 }
4927
4928 #[test]
4929 fn parse_git_ref_skips_head_symrefs() {
4930 assert_eq!(parse_git_ref("refs/heads/HEAD"), None);
4931 assert_eq!(parse_git_ref("refs/remotes/origin/HEAD"), None);
4932 }
4933
4934 #[test]
4935 fn parse_git_ref_rejects_unknown_or_malformed() {
4936 assert_eq!(parse_git_ref("HEAD"), None);
4937 assert_eq!(parse_git_ref("refs/remotes/origin"), None);
4939 }
4940
4941 #[test]
4942 fn parse_git_ref_rejects_reserved_git_remote_namespace() {
4943 assert_eq!(parse_git_ref("refs/remotes/git/main"), None);
4946 assert_eq!(parse_git_ref("refs/remotes/git/feature/x"), None);
4947 assert!(is_reserved_git_remote_name(REMOTE_NAME_FOR_LOCAL_GIT_REPO));
4948 assert!(!is_reserved_git_remote_name("origin"));
4949 }
4950
4951 #[test]
4952 fn local_path_from_url_rejects_hosted_heddle_scheme() {
4953 let err = local_path_from_url("heddle://weft.local:8421/org/repo")
4961 .expect_err("heddle:// must be rejected by the git exporter classifier");
4962 let msg = err.to_string();
4963 assert!(
4964 msg.contains("heddle://") && msg.contains("hosted"),
4965 "error should explain the hosted scheme cannot be pushed via the git-overlay exporter, got: {msg}"
4966 );
4967 }
4968
4969 #[test]
4970 fn local_path_from_url_still_accepts_file_and_git_urls() {
4971 assert!(
4975 local_path_from_url("file:///tmp/repo.git")
4976 .expect("file url ok")
4977 .is_some(),
4978 "file:// must still resolve to a local path"
4979 );
4980 assert!(
4981 local_path_from_url("https://example.com/org/repo.git")
4982 .expect("https url ok")
4983 .is_none(),
4984 "https git url must pass through as a network URL"
4985 );
4986 assert!(
4987 local_path_from_url("git@github.com:org/repo.git")
4988 .expect("ssh url ok")
4989 .is_none(),
4990 "ssh git url must pass through as a network URL"
4991 );
4992 }
4993
4994 #[test]
4995 fn refspec_forced_round_trips_git_format() {
4996 let spec =
4997 RefSpec::forced("refs/heads/main", "refs/heads/main").expect("valid forced refspec");
4998 assert_eq!(spec.to_git_format(), "+refs/heads/main:refs/heads/main");
4999 assert_eq!(
5000 spec.to_git_format_not_forced(),
5001 "refs/heads/main:refs/heads/main"
5002 );
5003 }
5004
5005 #[test]
5006 fn refspec_constructor_rejects_reserved_remote_name() {
5007 let err = RefSpec::new(
5008 Some("refs/remotes/git/main".to_string()),
5009 "refs/heads/main",
5010 false,
5011 )
5012 .expect_err("reserved remote source is rejected");
5013 assert!(err.to_string().contains("reserved namespace"));
5014
5015 let err = RefSpec::new(
5016 Some("refs/heads/main".to_string()),
5017 "refs/remotes/git/main",
5018 false,
5019 )
5020 .expect_err("reserved remote destination is rejected");
5021 assert!(err.to_string().contains("reserved namespace"));
5022 }
5023
5024 #[test]
5025 fn refspec_forced_rejects_reserved_remote_name() {
5026 assert!(RefSpec::forced("refs/remotes/git/main", "refs/heads/main").is_err());
5027 assert!(RefSpec::forced("refs/heads/main", "refs/remotes/git/main").is_err());
5028 }
5029
5030 #[test]
5031 fn refspec_delete_has_empty_source() {
5032 let spec = RefSpec::delete("refs/heads/stale").expect("valid delete refspec");
5033 assert_eq!(spec.to_git_format(), ":refs/heads/stale");
5034 assert_eq!(spec.to_git_format_not_forced(), ":refs/heads/stale");
5035 }
5036
5037 #[test]
5038 fn refspec_delete_rejects_reserved_remote_name() {
5039 assert!(RefSpec::delete("refs/remotes/git/stale").is_err());
5040 }
5041
5042 #[test]
5043 fn refspec_constructor_rejects_empty_source_and_destination() {
5044 let err = RefSpec::new(None, "", false)
5045 .expect_err("empty source plus empty destination is rejected");
5046 assert!(err.to_string().contains("cannot both be empty"));
5047 }
5048
5049 #[test]
5050 fn negative_refspec_prefixes_caret() {
5051 let spec = NegativeRefSpec::new("refs/heads/wip").expect("valid negative refspec");
5052 assert_eq!(spec.to_git_format(), "^refs/heads/wip");
5053 }
5054
5055 #[test]
5056 fn negative_refspec_constructor_rejects_unparseable_negation() {
5057 let err = NegativeRefSpec::new("refs/heads/wip/*").expect_err("negative glob is rejected");
5058 assert!(err.to_string().contains("Negative glob patterns"));
5059 }
5060
5061 #[test]
5062 fn negative_refspec_constructor_rejects_reserved_remote_name() {
5063 let err = NegativeRefSpec::new("refs/remotes/git/main")
5064 .expect_err("reserved remote negative source is rejected");
5065 assert!(err.to_string().contains("reserved namespace"));
5066 }
5067
5068 #[test]
5069 fn mirror_fetch_refspecs_cover_branches_and_notes() {
5070 assert_eq!(
5071 heddle_mirror_fetch_refspecs().expect("mirror refspecs are valid"),
5072 [
5073 "+refs/heads/*:refs/heads/*".to_string(),
5074 "+refs/notes/*:refs/notes/*".to_string(),
5075 ]
5076 );
5077 }
5078
5079 #[test]
5080 fn scoped_import_ref_updates_do_not_include_notes_implicitly() {
5081 let tmp = tempfile::TempDir::new().unwrap();
5082 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
5083 let main = seed_commit(&repo, "main");
5084 let other = seed_commit(&repo, "other");
5085 let notes = seed_commit(&repo, "notes");
5086 set_reference(
5087 &repo,
5088 "refs/heads/main",
5089 main,
5090 RefPrecondition::MustNotExist,
5091 "test: main",
5092 )
5093 .expect("write main");
5094 set_reference(
5095 &repo,
5096 "refs/heads/other",
5097 other,
5098 RefPrecondition::MustNotExist,
5099 "test: other",
5100 )
5101 .expect("write other");
5102 set_reference(
5103 &repo,
5104 "refs/notes/heddle",
5105 notes,
5106 RefPrecondition::MustNotExist,
5107 "test: notes",
5108 )
5109 .expect("write notes");
5110
5111 let updates = collect_import_source_ref_updates(&repo, &["main".to_string()])
5112 .expect("collect scoped updates");
5113 let full_names = updates.iter().map(full_ref_name).collect::<Vec<_>>();
5114
5115 assert_eq!(full_names, vec!["refs/heads/main".to_string()]);
5116 }
5117
5118 #[test]
5119 fn fast_forward_guard_reports_exact_rewrite_before_after() {
5120 let tmp = tempfile::TempDir::new().unwrap();
5121 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
5122 let root = test_commit(&repo, "root", &[]);
5123 let old = test_commit(&repo, "old", &[root]);
5124 let new = test_commit(&repo, "new", &[root]);
5125
5126 let err = ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
5127 .expect_err("sibling commit update should be refused");
5128 let message = err.to_string();
5129 assert!(message.contains("refs/heads/main"));
5130 assert!(message.contains(&old.to_string()));
5131 assert!(message.contains(&new.to_string()));
5132 assert!(message.contains("refusing to replace"));
5133 }
5134
5135 #[test]
5136 fn fast_forward_guard_allows_descendant_update() {
5137 let tmp = tempfile::TempDir::new().unwrap();
5138 let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
5139 let old = test_commit(&repo, "old", &[]);
5140 let new = test_commit(&repo, "new", &[old]);
5141
5142 ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
5143 .expect("descendant update should be allowed");
5144 }
5145
5146 fn test_commit(repo: &SleyRepository, message: &str, parents: &[ObjectId]) -> ObjectId {
5147 let empty_tree_oid = ObjectId::empty_tree(repo.object_format());
5148 let sig = Signature {
5149 name: GitByteString::new(b"Heddle Test".to_vec()),
5150 email: GitByteString::new(b"heddle@test".to_vec()),
5151 time: GitTime::new(0, 0),
5152 raw: b"Heddle Test <heddle@test> 0 +0000".to_vec(),
5153 };
5154 let commit = sley::CommitObject {
5155 tree: empty_tree_oid,
5156 parents: parents.to_vec(),
5157 author: sig.to_ident_bytes(),
5158 committer: sig.to_ident_bytes(),
5159 encoding: None,
5160 message: message.as_bytes().to_vec(),
5161 };
5162 repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
5163 GitObjectType::Commit,
5164 commit.write(),
5165 ))
5166 .expect("write test commit")
5167 }
5168
5169 fn seed_commit(repo: &SleyRepository, message: &str) -> ObjectId {
5170 test_commit(repo, message, &[])
5171 }
5172
5173 #[test]
5180 fn clone_url_to_bare_via_sley_honours_remote_head_symref() {
5181 let tmp = tempfile::TempDir::new().unwrap();
5182 let source = tmp.path().join("source.git");
5183 let dest = tmp.path().join("dest.git");
5184
5185 let src = SleyRepository::init_bare(&source).expect("init bare source");
5192 let seed = seed_commit(&src, "seed");
5193 for name in ["refs/heads/trunk", "refs/heads/abc-feature"] {
5194 set_reference(&src, name, seed, RefPrecondition::Any, "test: seed branch")
5195 .expect("set ref");
5196 }
5197 std::fs::write(source.join("HEAD"), b"ref: refs/heads/trunk\n").unwrap();
5200
5201 let url = format!("file://{}", source.display());
5202 let mut progress = SilentProgress;
5203 clone_url_to_bare(&url, &dest, None, None, &mut progress).expect("clone url to bare");
5204
5205 let dest_head = std::fs::read_to_string(dest.join("HEAD")).expect("read dest HEAD");
5206 assert_eq!(
5207 dest_head.trim(),
5208 "ref: refs/heads/trunk",
5209 "dest HEAD must mirror the remote's symref (trunk), not sley's \
5210 init-time default and not the alphabetically-first branch \
5211 (abc-feature) — see heddle#141"
5212 );
5213 }
5214
5215 #[test]
5216 fn write_head_symref_writes_git_head_bytes() {
5217 let tmp = tempfile::TempDir::new().unwrap();
5218 let git_dir = tmp.path();
5219 SleyRepository::init_bare(git_dir).expect("init bare");
5220
5221 write_head_symref(git_dir, "refs/heads/feature/x").expect("write HEAD symref");
5222 assert_eq!(
5223 std::fs::read_to_string(git_dir.join("HEAD")).expect("read HEAD"),
5224 "ref: refs/heads/feature/x\n"
5225 );
5226
5227 write_head_symref(git_dir, "refs/heads/main").expect("rewrite HEAD symref");
5228 assert_eq!(
5229 std::fs::read_to_string(git_dir.join("HEAD")).unwrap(),
5230 "ref: refs/heads/main\n"
5231 );
5232 }
5233
5234 #[test]
5235 fn rollback_restore_uses_git_lock_and_compare_and_swap() {
5236 let tmp = tempfile::TempDir::new().unwrap();
5237 let path = tmp.path().join("HEAD");
5238 std::fs::write(&path, b"published").unwrap();
5239
5240 restore_file_if_unchanged(&path, b"published", Some(b"previous")).unwrap();
5241 assert_eq!(std::fs::read(&path).unwrap(), b"previous");
5242 assert!(!tmp.path().join("HEAD.lock").exists());
5243
5244 std::fs::write(&path, b"concurrent").unwrap();
5245 let error = restore_file_if_unchanged(&path, b"published", Some(b"previous"))
5246 .expect_err("concurrent write must block rollback");
5247 assert!(error.to_string().contains("another Git operation changed"));
5248 assert_eq!(std::fs::read(&path).unwrap(), b"concurrent");
5249 assert!(!tmp.path().join("HEAD.lock").exists());
5250 }
5251
5252 #[test]
5253 fn checkout_publish_rolls_back_branch_when_head_reflog_fails() {
5254 let tmp = tempfile::TempDir::new().unwrap();
5255 let repo = SleyRepository::init(tmp.path()).expect("init repository");
5256 repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
5257 GitObjectType::Tree,
5258 Vec::new(),
5259 ))
5260 .expect("write empty tree");
5261 let previous = test_commit(&repo, "previous", &[]);
5262 let next = test_commit(&repo, "next", &[previous]);
5263 set_reference(
5264 &repo,
5265 "refs/heads/main",
5266 previous,
5267 RefPrecondition::Any,
5268 "test: seed branch",
5269 )
5270 .unwrap();
5271 write_head_symref(repo.git_dir(), "refs/heads/main").unwrap();
5272
5273 let write = CheckoutWrite::prepare(tmp.path(), "main").unwrap();
5274 let previous_head = write.previous_head.clone();
5275 let previous_index = write.previous_index.clone();
5276 std::fs::remove_file(write.git_dir.join("logs/HEAD")).expect("remove existing HEAD reflog");
5277 std::fs::create_dir_all(write.git_dir.join("logs/HEAD"))
5278 .expect("block HEAD reflog file creation");
5279
5280 write
5281 .publish(next)
5282 .expect_err("HEAD reflog failure must fail publication");
5283
5284 let branch = repo
5285 .find_reference("refs/heads/main")
5286 .unwrap()
5287 .unwrap()
5288 .peeled_oid(&repo)
5289 .unwrap()
5290 .unwrap();
5291 assert_eq!(branch, previous);
5292 assert_eq!(read_optional_file(&write.head_path).unwrap(), previous_head);
5293 assert_eq!(
5294 read_optional_file(&write.index_path).unwrap(),
5295 previous_index
5296 );
5297 assert!(!write.git_dir.join("index.lock").exists());
5298 assert!(!write.git_dir.join("HEAD.lock").exists());
5299 }
5300
5301 #[test]
5304 fn head_state_matches_legacy_head_symref_parse() {
5305 let tmp = tempfile::TempDir::new().unwrap();
5306 let root = tmp.path();
5307 let git_dir = root.join(".git");
5308 SleyRepository::init_bare(&git_dir).expect("init bare overlay");
5309
5310 fn legacy_branch_parse(head_path: &Path) -> Option<String> {
5311 let contents = std::fs::read_to_string(head_path).ok()?;
5312 let trimmed = contents.trim();
5313 let suffix = trimmed.strip_prefix("ref: ")?;
5314 let branch = suffix.strip_prefix("refs/heads/")?;
5315 (!branch.is_empty()).then(|| branch.to_string())
5316 }
5317
5318 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
5320 let repo = open_repo(root).expect("open");
5321 assert_eq!(repo.head_state().unwrap().branch_name(), Some("main"));
5322 assert_eq!(
5323 legacy_branch_parse(&git_dir.join("HEAD")),
5324 Some("main".into())
5325 );
5326
5327 let oid = ObjectId::from_hex(
5329 ObjectFormat::Sha1,
5330 "0000000000000000000000000000000000000001",
5331 )
5332 .unwrap();
5333 std::fs::write(git_dir.join("HEAD"), format!("{oid}\n")).unwrap();
5334 let repo = open_repo(root).expect("open");
5335 let state = repo.head_state().unwrap();
5336 assert!(state.is_detached());
5337 assert_eq!(state.branch_name(), None);
5338 assert_eq!(legacy_branch_parse(&git_dir.join("HEAD")), None);
5339
5340 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
5342 let repo = open_repo(root).expect("open");
5343 assert_eq!(repo.head_state().unwrap().branch_name(), Some("feature"));
5344 assert_eq!(
5345 legacy_branch_parse(&git_dir.join("HEAD")),
5346 Some("feature".into())
5347 );
5348 }
5349}