1#[path = "bloom_filter.rs"]
5mod bloom_filter;
6#[path = "commit_graph.rs"]
7pub(crate) mod commit_graph;
8#[path = "commit_graph_persistence.rs"]
9mod commit_graph_persistence;
10#[path = "context_suggestions.rs"]
11mod context_suggestions;
12#[path = "repo_config.rs"]
13mod repo_config;
14#[path = "repository_context.rs"]
15mod repository_context;
16#[path = "repository_diff.rs"]
17mod repository_diff;
18#[path = "repository_goto.rs"]
19mod repository_goto;
20#[path = "repository_history.rs"]
21mod repository_history;
22#[path = "repository_maintenance.rs"]
23mod repository_maintenance;
24#[path = "repository_materialization.rs"]
25mod repository_materialization;
26#[path = "repository_partial_fetch.rs"]
27mod repository_partial_fetch;
28#[path = "repository_provenance/mod.rs"]
29mod repository_provenance;
30#[path = "repository_resolve.rs"]
31mod repository_resolve;
32#[path = "repository_signing.rs"]
33mod repository_signing;
34pub use repository_signing::ResignOutcome;
35#[path = "repository_snapshot.rs"]
36mod repository_snapshot;
37#[cfg(test)]
38#[path = "repository_tests.rs"]
39mod repository_tests;
40#[path = "repository_thread_materialize.rs"]
41mod repository_thread_materialize;
42#[path = "repository_tree.rs"]
43mod repository_tree;
44#[path = "repository_worktree_apply.rs"]
45pub(crate) mod repository_worktree_apply;
46#[path = "repository_worktree_status.rs"]
47mod repository_worktree_status;
48#[path = "status_tracked_refresh.rs"]
49mod status_tracked_refresh;
50#[path = "status_untracked_scan.rs"]
51mod status_untracked_scan;
52
53use std::{
54 collections::{BTreeMap, BTreeSet, HashMap},
55 fs,
56 path::{Path, PathBuf},
57 sync::{Arc, RwLock},
58};
59
60use chrono::Utc;
61pub use commit_graph::{CommitGraphIndex, find_merge_base};
62pub use context_suggestions::{
63 ContextSuggestion, ContextSuggestionTier, HIGH_SUGGESTION_THRESHOLD,
64 MAJOR_REWRITE_THRESHOLD_PCT, MEDIUM_SUGGESTION_THRESHOLD, SUGGESTION_WINDOW,
65 compute_rewrite_pct, is_major_rewrite,
66};
67pub use objects::object::DiffKind;
68use objects::{
69 error::{HeddleError, Result},
70 fs_atomic::write_file_atomic,
71 lock::{RepoLock, RepositoryLockExt},
72 object::{Attribution, ChangeId, ContentHash, MarkerName, Principal, State, ThreadName, Tree},
73 store::{AnyStore, FsStore, ObjectStore, ShallowInfo},
74 worktree::{WorktreeStatus, should_ignore as should_ignore_path},
75};
76use oplog::{OpLog, OpLogBackend, OpRecord};
77pub use refs::RefSummaryIndexInspection;
78use refs::{Head, RefBackend, RefExpectation, RefManager, RefUpdate};
79pub use repo_config::{HostedConfig, OutputFormat, RedactConfig, RepoConfig, TrustedKey};
80#[allow(unused_imports)]
84pub use repo_config::{
85 PatternDeviationToml, ReviewConfig, ReviewSignalsToml, SelfFlaggedToml, SignalEnableToml,
86 SignalModuleToml, TestReachabilityToml,
87};
88pub use repository_history::{ChangedPathFilter, ChangedPathFilters, HistoryQuery};
89pub use repository_maintenance::{
90 ChangeMonitorInspection, CommitGraphInspection, PackFilesInspection, PartialFetchInspection,
91 PullPlannerCacheInspection, RefCountsInspection, RepositoryMaintenanceRunReport,
92 RepositoryPerformanceInspectionReport, WorktreeIndexInspection,
93};
94pub use repository_materialization::WarmCanonicalStoreStats;
95pub use repository_partial_fetch::MissingBlob;
96pub use repository_snapshot::{SnapshotExecution, SnapshotProfile};
97pub use repository_thread_materialize::{CheckoutMaterialization, ThreadCaptureOutcome};
98pub use repository_tree::{TreeBuildProfile, WorktreeCompareProfile};
99pub use repository_worktree_status::{UntrackedSet, UntrackedSubtree, WorktreeStatusDetailed};
100use rusqlite::{Connection, OpenFlags};
101use serde::{Deserialize, Serialize};
102use sley::{
103 ObjectId as SleyObjectId, Reference as SleyReference, ReferenceTarget as SleyRefTarget,
104 Repository as SleyRepository,
105};
106
107use crate::git_worktree_status::GitWorktreeEntryState;
108
109const GIT_CHECKPOINTS_FILE: &str = "git-checkpoints.json";
110const GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS: &[&str] = &[".heddle/"];
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum RepositoryCapability {
114 GitOverlay,
115 NativeHeddle,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119enum GitHeadState {
120 Attached(String),
121 Detached(SleyObjectId),
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct GitCheckpointRecord {
126 pub change_id: String,
127 pub git_commit: String,
128 pub summary: String,
129 pub committed_at: String,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct GitOverlayImportHint {
134 pub current_branch: String,
135 pub missing_branch_count: usize,
136 pub missing_branches: Vec<String>,
137 pub recommended_command: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct GitOverlayBranchTip {
142 pub branch: String,
143 pub git_commit: String,
144 pub history_imported: bool,
145 #[serde(skip)]
146 pub mapped_change: Option<ChangeId>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct GitOverlayTagTip {
151 pub tag: String,
152 pub git_commit: String,
153 pub history_imported: bool,
154 #[serde(skip)]
155 pub mapped_change: Option<ChangeId>,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct GitOverlayOutOfBandCommits {
163 pub count: usize,
164 pub truncated: bool,
167}
168
169const GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT: usize = 1000;
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "kebab-case")]
176pub enum OperationScope {
177 Git,
178 Heddle,
179}
180
181impl std::fmt::Display for OperationScope {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 match self {
184 Self::Git => write!(f, "git"),
185 Self::Heddle => write!(f, "heddle"),
186 }
187 }
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(rename_all = "kebab-case")]
192pub enum OperationKind {
193 Merge,
194 Rebase,
195 CherryPick,
196 Revert,
197 Bisect,
198}
199
200impl std::fmt::Display for OperationKind {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 Self::Merge => write!(f, "merge"),
204 Self::Rebase => write!(f, "rebase"),
205 Self::CherryPick => write!(f, "cherry-pick"),
206 Self::Revert => write!(f, "revert"),
207 Self::Bisect => write!(f, "bisect"),
208 }
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct RepositoryOperationStatus {
214 pub scope: OperationScope,
215 pub kind: OperationKind,
216 pub in_progress: bool,
217 pub state: String,
218 pub message: String,
219 pub next_action: String,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct GitRemoteTrackingStatus {
224 pub branch: String,
225 pub upstream: String,
226 pub ahead: usize,
227 pub behind: usize,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub local_oid: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub upstream_oid: Option<String>,
232 #[serde(default, skip_serializing_if = "is_false")]
233 pub upstream_is_undone_checkpoint: bool,
234 pub message: String,
235 pub next_action: String,
236}
237
238fn is_false(value: &bool) -> bool {
239 !*value
240}
241
242#[derive(Debug, Deserialize)]
243struct GitBridgeMappingEntry {
244 change_id: String,
245 git_oid: String,
246}
247
248#[derive(Debug, Deserialize, Default)]
249struct GitBridgeMappingFile {
250 entries: Vec<GitBridgeMappingEntry>,
251}
252
253pub trait BlobHydrator: Send + Sync {
274 fn hydrate(&self, repo: &Repository, hash: &ContentHash) -> Result<()>;
275}
276
277pub struct Repository<R = RefManager, O = OpLog, S = AnyStore>
292where
293 R: RefBackend,
294 O: OpLogBackend,
295 S: ObjectStore,
296{
297 root: PathBuf,
298 heddle_dir: PathBuf,
299 capability: RepositoryCapability,
300 store: S,
301 refs: R,
302 oplog: O,
303 config: RepoConfig,
304 shallow: RwLock<ShallowInfo>,
305 blob_hydrator: RwLock<Option<Arc<dyn BlobHydrator>>>,
306 git_overlay_repo: RwLock<Option<SleyRepository>>,
307}
308
309impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> RepositoryLockExt for Repository<R, O, S> {
310 fn locker(&self) -> RepoLock {
311 let lock_root = self.heddle_dir.parent().expect(
312 "heddle_dir has no parent component; cannot determine lock root. This indicates a misconfigured repository.",
313 );
314 RepoLock::new(lock_root)
315 }
316}
317
318impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> Repository<R, O, S> {
319 pub fn from_parts(
328 root: PathBuf,
329 heddle_dir: PathBuf,
330 store: S,
331 refs: R,
332 oplog: O,
333 config: RepoConfig,
334 shallow: ShallowInfo,
335 ) -> Self {
336 let capability = repository_capability_for_root(&root);
337 Self {
338 root,
339 heddle_dir,
340 capability,
341 store,
342 refs,
343 oplog,
344 config,
345 shallow: RwLock::new(shallow),
346 blob_hydrator: RwLock::new(None),
347 git_overlay_repo: RwLock::new(None),
348 }
349 }
350
351 pub fn store(&self) -> &S {
353 &self.store
354 }
355
356 pub fn refs(&self) -> &R {
358 &self.refs
359 }
360
361 pub fn oplog(&self) -> &O {
363 &self.oplog
364 }
365}
366
367pub(crate) fn compute_op_scope(root: &Path) -> String {
378 let local_head = root.join(".heddle").join("HEAD");
379 let canonical = local_head.canonicalize().unwrap_or(local_head);
380 let digest = blake3::hash(canonical.to_string_lossy().as_bytes());
381 format!("wt-{}", &digest.to_hex().as_str()[..16])
382}
383
384fn ensure_supported_repo_format(config_path: &Path, config: &RepoConfig) -> Result<()> {
385 let found = config.repository.version;
386 let supported = repo_config::SUPPORTED_REPO_FORMAT;
387 if found > supported {
388 return Err(HeddleError::RepositoryFormatTooNew {
389 path: config_path.to_path_buf(),
390 found,
391 supported,
392 });
393 }
394 Ok(())
395}
396
397impl<S: ObjectStore> Repository<RefManager, OpLog, S> {
398 fn open_raw(
399 root: PathBuf,
400 heddle_dir: PathBuf,
401 store: S,
402 config: RepoConfig,
403 refs: RefManager,
404 ) -> Result<Self> {
405 let actor = config
406 .principal
407 .as_ref()
408 .map(|p| objects::object::Principal::new(&p.name, &p.email))
409 .unwrap_or_else(|| objects::object::Principal::new("<unknown>", ""));
410 let oplog = OpLog::new(&heddle_dir, actor.clone());
411 let shallow = ShallowInfo::load(&heddle_dir)?;
412 let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
416 &heddle_dir,
417 compute_op_scope(&root),
418 ));
419 let committer =
420 std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(&heddle_dir, actor));
421 let refs = refs.with_reconciler(reconciler).with_committer(committer);
422 refs.init_reconcile_watermark()?;
427 Ok(Self::from_parts(
428 root, heddle_dir, store, refs, oplog, config, shallow,
429 ))
430 }
431
432 pub fn open_with_store(heddle_dir: impl AsRef<Path>, store: S) -> Result<Self> {
439 let heddle_dir = heddle_dir.as_ref().to_path_buf();
440 let root = heddle_dir
441 .parent()
442 .ok_or_else(|| {
443 HeddleError::Config(format!(
444 "heddle_dir '{}' has no parent directory",
445 heddle_dir.display()
446 ))
447 })?
448 .to_path_buf();
449 let config_path = heddle_dir.join("config.toml");
450 let config = RepoConfig::load(&config_path)?;
451 ensure_supported_repo_format(&config_path, &config)?;
452 let refs = RefManager::new(&heddle_dir);
453 Self::open_raw(root, heddle_dir, store, config, refs)
454 }
455}
456
457impl Repository {
458 fn run_open_hooks(&self) {
464 if let Err(err) = crate::migration::apply_pending(self) {
469 tracing::warn!("declarative migrations failed during repo open: {err}");
470 }
471 match crate::lazy_hydrator::try_reconstruct(self.root(), self.heddle_dir()) {
479 Ok(Some(hydrator)) => self.set_blob_hydrator(hydrator),
480 Ok(None) => {}
481 Err(err) => {
482 tracing::warn!("lazy hydrator reconstruction failed during open: {err}");
489 }
490 }
491 }
492
493 fn build_store(config: &RepoConfig, heddle_dir: &Path) -> Result<AnyStore> {
499 #[cfg(feature = "s3")]
500 {
501 if let Some(s3) = &config.storage.s3 {
502 return Self::build_s3_store(s3);
503 }
504 }
505 let _ = config; Ok(AnyStore::Fs(FsStore::new(heddle_dir)))
507 }
508
509 #[cfg(feature = "s3")]
511 fn build_s3_store(s3: &repo_config::S3StorageConfig) -> Result<AnyStore> {
512 use objects::store::S3StoreBuilder;
513
514 let mut builder = S3StoreBuilder::new().bucket(&s3.bucket);
515 if let Some(ref region) = s3.region {
516 builder = builder.region(region);
517 }
518 if let Some(ref prefix) = s3.prefix {
519 builder = builder.prefix(prefix);
520 }
521 if let Some(ref url) = s3.endpoint_url {
522 builder = builder.endpoint_url(url);
523 }
524 if let Some(ref key) = s3.access_key_id {
525 builder = builder.access_key_id(key);
526 }
527 if let Some(ref secret) = s3.secret_access_key {
528 builder = builder.secret_access_key(secret);
529 }
530 if let Some(ref token) = s3.session_token {
531 builder = builder.session_token(token);
532 }
533 if s3.force_path_style {
534 builder = builder.force_path_style(true);
535 }
536
537 let store = builder
547 .build_blocking()
548 .map_err(|e| HeddleError::Config(format!("S3 store initialization failed: {e}")))?;
549 Ok(AnyStore::S3(store))
550 }
551
552 pub fn init(path: impl AsRef<Path>) -> Result<Self> {
561 let root = path.as_ref().to_path_buf();
562 let heddle_dir = root.join(".heddle");
563
564 if heddle_dir.exists() {
565 return Err(HeddleError::RepositoryExists(root));
566 }
567
568 fs::create_dir_all(&heddle_dir)?;
569
570 let store = FsStore::new(&heddle_dir);
571 store.init()?;
572
573 let refs = RefManager::new(&heddle_dir);
574 refs.init()?;
575
576 let oplog = OpLog::new_unattributed(&heddle_dir);
581 oplog.init()?;
582
583 let config = RepoConfig::default();
584 config.save(&heddle_dir.join("config.toml"))?;
585
586 refs.write_head(&Head::Attached {
587 thread: ThreadName::from("main"),
588 })?;
589
590 let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
594 &heddle_dir,
595 compute_op_scope(&root),
596 ));
597 let committer = std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(
598 &heddle_dir,
599 objects::object::Principal::new("<unknown>", ""),
600 ));
601 let refs = refs.with_reconciler(reconciler).with_committer(committer);
602 refs.init_reconcile_watermark()?;
606
607 let capability = repository_capability_for_root(&root);
608 Ok(Self {
609 root,
610 heddle_dir: heddle_dir.clone(),
611 capability,
612 store: AnyStore::Fs(store),
613 refs,
614 oplog,
615 config,
616 shallow: RwLock::new(ShallowInfo::load(&heddle_dir)?),
617 blob_hydrator: RwLock::new(None),
618 git_overlay_repo: RwLock::new(None),
619 })
620 }
621
622 pub fn init_default(path: impl AsRef<Path>) -> Result<Self> {
628 let repo = Self::init(path)?;
629 repo.seed_default_thread()?;
630 Ok(repo)
631 }
632
633 pub fn bootstrap_git_overlay(path: impl AsRef<Path>) -> Result<Self> {
640 let root = path.as_ref();
641 if root.join(".heddle").exists() {
642 ensure_git_overlay_exclude(root)?;
643 return Self::open(root);
644 }
645
646 let repo = Self::init(root)?;
647 ensure_git_overlay_exclude(root)?;
648 if let Some(head) = detect_git_head(root)? {
649 repo.refs.write_head(&head)?;
650 }
651 Ok(repo)
652 }
653
654 pub fn ensure_git_overlay_local_excludes(path: impl AsRef<Path>) -> Result<()> {
658 ensure_git_overlay_exclude(path.as_ref())
659 }
660
661 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
673 let start_path = path.as_ref().canonicalize()?;
674 if let Some(mount_root) = metadataless_managed_thread_root(&start_path) {
684 return Err(HeddleError::Config(format!(
685 "'{}' is a Heddle-managed virtualized thread mount with no checkout \
686 metadata of its own; refusing to operate on the parent repository from \
687 inside it. Run heddle from the repository root, or use a solid/materialized \
688 thread checkout.",
689 mount_root.display()
690 )));
691 }
692 let mut discovered_git_root = None;
693
694 let mut current = Some(start_path.as_path());
695 while let Some(dir) = current {
696 if discovered_git_root.is_none() && has_git_metadata(dir) {
697 discovered_git_root = Some(dir.to_path_buf());
698 }
699 let heddle_path = dir.join(".heddle");
700
701 if heddle_path.is_dir() {
702 if let Some(git_root) = discovered_git_root.as_ref()
703 && git_root != dir
704 && git_root.starts_with(dir)
705 && !git_root.join(".heddle").exists()
706 {
707 ensure_git_overlay_exclude(git_root)?;
708 Self::bootstrap_git_overlay(git_root)?;
709 return Self::open(git_root);
710 }
711 let pointer_path = heddle_path.join("objectstore");
712 let objects_dir = heddle_path.join("objects");
713
714 if pointer_path.is_file() {
715 let content = fs::read_to_string(&pointer_path)?;
718 let raw_shared = parse_objectstore_pointer(&content).ok_or_else(|| {
719 HeddleError::Config(format!(
720 "invalid .heddle/objectstore pointer at {}: expected 'objectstore: <path>'",
721 pointer_path.display()
722 ))
723 })?;
724
725 if raw_shared.is_relative() {
726 return Err(HeddleError::Config(format!(
727 ".heddle/objectstore pointer at {} contains a relative path '{}'; \
728 objectstore path must be absolute",
729 pointer_path.display(),
730 raw_shared.display()
731 )));
732 }
733
734 let shared_galeed_dir = raw_shared.canonicalize().map_err(|e| {
735 HeddleError::Config(format!(
736 ".heddle/objectstore pointer at {} points to non-existent path '{}': {}",
737 pointer_path.display(),
738 raw_shared.display(),
739 e
740 ))
741 })?;
742
743 if !shared_galeed_dir.join("objects").is_dir() {
744 return Err(HeddleError::Config(format!(
745 ".heddle/objectstore pointer at {} resolves to '{}' which does not \
746 contain an 'objects/' directory; not a valid Heddle store",
747 pointer_path.display(),
748 shared_galeed_dir.display()
749 )));
750 }
751
752 let config_path = shared_galeed_dir.join("config.toml");
753 let config = RepoConfig::load(&config_path)?;
754 ensure_supported_repo_format(&config_path, &config)?;
755 let store = Self::build_store(&config, &shared_galeed_dir)?;
756 let local_head_path = heddle_path.join("HEAD");
757 let refs = RefManager::new(&shared_galeed_dir).with_local_head(local_head_path);
758 let repo =
759 Self::open_raw(dir.to_path_buf(), shared_galeed_dir, store, config, refs)?;
760 repo.run_open_hooks();
761 return Ok(repo);
762 }
763
764 if objects_dir.is_dir() {
765 let config_path = heddle_path.join("config.toml");
767 let config = RepoConfig::load(&config_path)?;
768 ensure_supported_repo_format(&config_path, &config)?;
769 let store = Self::build_store(&config, &heddle_path)?;
770 let refs = RefManager::new(&heddle_path);
771 let repo = Self::open_raw(dir.to_path_buf(), heddle_path, store, config, refs)?;
772 repo.run_open_hooks();
773 if repo.capability() == RepositoryCapability::GitOverlay {
774 match detect_git_head_state(dir) {
775 Ok(Some(GitHeadState::Attached(thread))) => {
776 let git_head = Head::Attached {
777 thread: ThreadName::from(thread),
778 };
779 let stale = match (repo.refs.read_head(), &git_head) {
796 (Ok(Head::Detached { state }), Head::Attached { thread }) => {
797 match repo.refs.get_thread(thread) {
798 Ok(Some(tip)) => tip == state,
799 _ => false,
800 }
801 }
802 (Ok(Head::Detached { .. }), _) => false,
803 (Ok(current), _) => current != git_head,
804 (Err(_), _) => true,
805 };
806 if stale {
807 repo.refs.write_head(&git_head)?;
808 }
809 }
810 Ok(Some(GitHeadState::Detached(git_oid))) => {
811 if let Ok(Some(state)) =
812 repo.git_overlay_mapped_change_for_git_oid(git_oid)
813 {
814 let git_head = Head::Detached { state };
815 let stale = match repo.refs.read_head() {
816 Ok(current) => current != git_head,
817 Err(_) => true,
818 };
819 if stale {
820 repo.refs.write_head(&git_head)?;
821 }
822 }
823 }
824 Ok(None) | Err(_) => {}
825 }
826 }
827 return Ok(repo);
828 }
829
830 }
833
834 current = dir.parent();
835 }
836
837 if let Some(git_root) = discovered_git_root {
838 ensure_git_overlay_exclude(&git_root)?;
839 Self::bootstrap_git_overlay(&git_root)?;
840 return Self::open(git_root);
841 }
842
843 Err(HeddleError::RepositoryNotFound(path.as_ref().to_path_buf()))
844 }
845
846 pub fn root(&self) -> &Path {
847 &self.root
848 }
849
850 pub fn heddle_dir(&self) -> &Path {
851 &self.heddle_dir
852 }
853
854 pub fn managed_checkout_source_root(&self) -> &Path {
863 self.heddle_dir.parent().unwrap_or(self.root.as_path())
864 }
865
866 pub fn managed_checkout_path(&self, thread: &str) -> PathBuf {
868 crate::thread_manifest::managed_checkout_path(
869 &self.heddle_dir,
870 thread,
871 self.managed_checkout_source_root(),
872 )
873 }
874
875 pub fn capability(&self) -> RepositoryCapability {
876 self.capability
877 }
878
879 pub fn git_overlay_sley_repository(&self) -> Result<Option<SleyRepository>> {
880 if self.capability() != RepositoryCapability::GitOverlay {
881 return Ok(None);
882 }
883
884 if let Some(repo) = self
885 .git_overlay_repo
886 .read()
887 .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?
888 .clone()
889 {
890 return Ok(Some(repo));
891 }
892
893 let mut cached = self
894 .git_overlay_repo
895 .write()
896 .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?;
897 if let Some(repo) = cached.clone() {
898 return Ok(Some(repo));
899 }
900
901 let repo = SleyRepository::discover(&self.root).map_err(|error| {
902 HeddleError::Config(format!(
903 "failed to inspect Git repository at '{}': {}",
904 self.root.display(),
905 error
906 ))
907 })?;
908 *cached = Some(repo.clone());
909 Ok(Some(repo))
910 }
911
912 pub fn capability_label(&self) -> &'static str {
913 match self.capability() {
914 RepositoryCapability::GitOverlay => "git-overlay",
915 RepositoryCapability::NativeHeddle => "native-heddle",
916 }
917 }
918
919 pub fn storage_model_label(&self) -> &'static str {
920 match self.capability() {
921 RepositoryCapability::GitOverlay => "git+heddle-sidecar",
922 RepositoryCapability::NativeHeddle => "heddle-native",
923 }
924 }
925
926 pub fn hosted_enabled(&self) -> bool {
927 self.config
928 .hosted
929 .upstream_url
930 .as_deref()
931 .is_some_and(|value| !value.trim().is_empty())
932 || self
933 .config
934 .hosted
935 .namespace
936 .as_deref()
937 .is_some_and(|value| !value.trim().is_empty())
938 }
939
940 pub fn current_lane(&self) -> Result<Option<String>> {
941 if self.capability() == RepositoryCapability::GitOverlay
942 && self.git_overlay_head_is_detached()?
943 && detect_git_in_progress_branch(&self.root)?.is_none()
944 {
945 return Ok(None);
946 }
947
948 if self.current_state()?.is_none() && self.capability() == RepositoryCapability::GitOverlay
949 {
950 return self.git_overlay_current_branch();
951 }
952
953 match self.head_ref()? {
954 Head::Attached { thread } => Ok(Some(thread.to_string())),
955 Head::Detached { .. } => Ok(None),
956 }
957 }
958
959 pub fn operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
960 if let Some(status) = self.heddle_operation_status()? {
961 return Ok(Some(status));
962 }
963 self.git_operation_status()
964 }
965
966 pub fn git_remote_tracking_status(&self) -> Result<Option<GitRemoteTrackingStatus>> {
967 if self.capability() != RepositoryCapability::GitOverlay {
968 return Ok(None);
969 }
970
971 let branch = match self.git_overlay_current_branch()? {
972 Some(branch) => branch,
973 None => return Ok(None),
974 };
975
976 let Some(git) = self.git_overlay_sley_repository()? else {
977 return Ok(None);
978 };
979 let Some(head) = git_resolve_oid(&git, "HEAD")? else {
980 return Ok(None);
981 };
982
983 let local_ref_name = format!("refs/heads/{branch}");
984 if git_find_reference(&git, &local_ref_name)?.is_some()
985 && let Some(tracking_name) = git_configured_tracking_ref(&git, &branch)?
986 && let Some(upstream_head) = git_resolve_oid(&git, &tracking_name)?
987 {
988 let (ahead, behind) = git_ahead_behind(&self.root, &git, upstream_head, head)?;
989 if ahead == 0 && behind == 0 {
990 return Ok(None);
991 }
992 let upstream = git_remote_tracking_display_name(&tracking_name);
993 let local_oid = head.to_string();
994 let upstream_oid = upstream_head.to_string();
995 let upstream_is_undone_checkpoint =
996 self.remote_tracks_undone_git_checkpoint(&branch, &local_oid, &upstream_oid)?;
997 return Ok(Some(GitRemoteTrackingStatus {
998 branch: branch.clone(),
999 upstream: upstream.clone(),
1000 ahead,
1001 behind,
1002 local_oid: Some(local_oid),
1003 upstream_oid: Some(upstream_oid),
1004 upstream_is_undone_checkpoint,
1005 message: git_remote_tracking_message(
1006 &branch,
1007 &upstream,
1008 ahead,
1009 behind,
1010 upstream_is_undone_checkpoint,
1011 ),
1012 next_action: git_remote_tracking_next_action(
1013 ahead,
1014 behind,
1015 upstream_is_undone_checkpoint,
1016 ),
1017 }));
1018 }
1019
1020 let remotes = git_remote_names(&self.root)?;
1021 if remotes.is_empty() {
1022 return Ok(None);
1023 }
1024 for remote in &remotes {
1025 let remote_ref = format!("refs/remotes/{remote}/{branch}");
1026 if let Some(remote_head) = git_resolve_oid(&git, &remote_ref)? {
1027 if remote_head == head {
1028 return Ok(None);
1029 }
1030 let (ahead, behind) = git_ahead_behind(&self.root, &git, remote_head, head)?;
1031 if behind > 0 {
1032 let upstream = format!("{remote}/{branch}");
1033 let local_oid = head.to_string();
1034 let upstream_oid = remote_head.to_string();
1035 let upstream_is_undone_checkpoint = self.remote_tracks_undone_git_checkpoint(
1036 &branch,
1037 &local_oid,
1038 &upstream_oid,
1039 )?;
1040 return Ok(Some(GitRemoteTrackingStatus {
1041 branch: branch.clone(),
1042 upstream: upstream.clone(),
1043 ahead,
1044 behind,
1045 local_oid: Some(local_oid),
1046 upstream_oid: Some(upstream_oid),
1047 upstream_is_undone_checkpoint,
1048 message: git_remote_tracking_message(
1049 &branch,
1050 &upstream,
1051 ahead,
1052 behind,
1053 upstream_is_undone_checkpoint,
1054 ),
1055 next_action: git_remote_tracking_next_action(
1056 ahead,
1057 behind,
1058 upstream_is_undone_checkpoint,
1059 ),
1060 }));
1061 }
1062 }
1063 }
1064
1065 Ok(Some(GitRemoteTrackingStatus {
1066 branch: branch.clone(),
1067 upstream: String::new(),
1068 ahead: 0,
1069 behind: 0,
1070 local_oid: Some(head.to_string()),
1071 upstream_oid: None,
1072 upstream_is_undone_checkpoint: false,
1073 message: format!("Git branch '{branch}' has no upstream tracking branch"),
1074 next_action: "heddle push".to_string(),
1075 }))
1076 }
1077
1078 fn remote_tracks_undone_git_checkpoint(
1079 &self,
1080 branch: &str,
1081 local_oid: &str,
1082 upstream_oid: &str,
1083 ) -> Result<bool> {
1084 let scope = self.op_scope();
1085 let batches = match self.oplog().redo_batches_scoped(64, Some(&scope)) {
1086 Ok(batches) => batches,
1087 Err(error) => {
1088 tracing::warn!(
1089 branch,
1090 local_oid,
1091 upstream_oid,
1092 error = %error,
1093 "could not inspect redo oplog for undone Git checkpoint status"
1094 );
1095 return Ok(false);
1096 }
1097 };
1098 Ok(batches.iter().any(|batch| {
1099 batch.entries.iter().any(|entry| {
1100 if !entry.undone {
1101 return false;
1102 }
1103 matches!(
1104 &entry.operation,
1105 OpRecord::GitCheckpoint {
1106 branch: checkpoint_branch,
1107 previous_git_oid: Some(previous_git_oid),
1108 new_git_oid,
1109 ..
1110 } if checkpoint_branch == branch
1111 && previous_git_oid == local_oid
1112 && new_git_oid == upstream_oid
1113 )
1114 })
1115 }))
1116 }
1117
1118 pub fn git_overlay_import_hint(&self) -> Result<Option<GitOverlayImportHint>> {
1119 if self.capability() != RepositoryCapability::GitOverlay {
1120 return Ok(None);
1121 }
1122
1123 let current_branch = match self.git_overlay_current_branch()? {
1124 Some(branch) => branch,
1125 None => return Ok(None),
1126 };
1127 let branch_tips = self.git_overlay_branch_tips()?;
1128 let imported_threads: std::collections::HashSet<ThreadName> =
1129 self.refs().list_threads()?.into_iter().collect();
1130 let threads_with_real_history: std::collections::HashSet<String> = imported_threads
1131 .iter()
1132 .filter_map(|thread| {
1133 self.refs()
1134 .get_thread(thread)
1135 .ok()
1136 .flatten()
1137 .and_then(|change| self.store.get_state(&change).ok())
1138 .flatten()
1139 .filter(|state| !is_synthetic_root(state))
1140 .map(|_| thread.to_string())
1141 })
1142 .collect();
1143 let mut missing_branches = branch_tips
1144 .into_iter()
1145 .filter(|tip| {
1146 !(tip.history_imported
1147 || threads_with_real_history.contains(&tip.branch)
1148 && tip.mapped_change.is_some())
1149 })
1150 .map(|tip| tip.branch)
1151 .collect::<Vec<_>>();
1152 missing_branches.sort_by(|left, right| {
1153 match (left == ¤t_branch, right == ¤t_branch) {
1154 (true, false) => std::cmp::Ordering::Less,
1155 (false, true) => std::cmp::Ordering::Greater,
1156 _ => left.cmp(right),
1157 }
1158 });
1159 missing_branches.dedup();
1160
1161 if missing_branches.is_empty() {
1162 return Ok(None);
1163 }
1164
1165 let missing_tags = self
1166 .git_overlay_tag_tips()?
1167 .into_iter()
1168 .any(|tip| !tip.history_imported);
1169 let recommended_command = if missing_branches.len() > 1 || missing_tags {
1170 "heddle adopt".to_string()
1171 } else if missing_branches
1172 .iter()
1173 .any(|branch| branch == ¤t_branch)
1174 {
1175 format!("heddle adopt --ref {current_branch}")
1176 } else if missing_branches.len() == 1 {
1177 format!("heddle adopt --ref {}", missing_branches[0])
1178 } else {
1179 "heddle adopt".to_string()
1180 };
1181
1182 Ok(Some(GitOverlayImportHint {
1183 current_branch,
1184 missing_branch_count: missing_branches.len(),
1185 missing_branches,
1186 recommended_command,
1187 }))
1188 }
1189
1190 pub fn git_overlay_branch_tips(&self) -> Result<Vec<GitOverlayBranchTip>> {
1191 if self.capability() != RepositoryCapability::GitOverlay {
1192 return Ok(Vec::new());
1193 }
1194
1195 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1196 return Ok(Vec::new());
1197 };
1198
1199 let imported_threads: std::collections::HashSet<ThreadName> =
1200 self.refs().list_threads()?.into_iter().collect();
1201 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1202 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1203 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1204 let mut branch_tips = Vec::new();
1205
1206 for branch in git_repo.references().list_refs().map_err(|error| {
1207 HeddleError::Config(format!(
1208 "failed to enumerate git branches at '{}': {}",
1209 self.root.display(),
1210 error
1211 ))
1212 })? {
1213 let Some(name) = branch.name.strip_prefix("refs/heads/") else {
1214 continue;
1215 };
1216 let name = name.to_string();
1217 let Some(target) =
1218 self.git_overlay_commit_tip_oid(&git_repo, &branch, "branch", &name)?
1219 else {
1220 continue;
1221 };
1222 let git_commit = target.to_string();
1223 let mapped_change = self.git_overlay_mapped_change_for_commit(
1224 &git_commit,
1225 &bridge_mapping,
1226 &ingest_mapping,
1227 &checkpoint_mapping,
1228 )?;
1229 let thread_name = ThreadName::from(name.as_str());
1230 let history_imported = if imported_threads.contains(&thread_name) {
1231 let existing_thread = self.refs().get_thread(&thread_name)?;
1235 let mapped = matches!(
1236 (existing_thread.as_ref(), mapped_change.as_ref()),
1237 (Some(existing), Some(mapped_change))
1238 if existing == mapped_change
1239 );
1240 let checkpointed = if mapped {
1241 false
1242 } else if let Some(existing) = existing_thread {
1243 self.latest_git_checkpoint_for_change(&existing)?
1244 .is_some_and(|record| record.git_commit == git_commit)
1245 || mapped_change.as_ref().is_some_and(|mapped_change| {
1246 self.change_is_ancestor(mapped_change, &existing)
1247 })
1248 } else {
1249 false
1250 };
1251 mapped || checkpointed
1252 } else {
1253 mapped_change.is_some()
1254 };
1255 branch_tips.push(GitOverlayBranchTip {
1256 branch: name,
1257 git_commit,
1258 history_imported,
1259 mapped_change,
1260 });
1261 }
1262 branch_tips.sort_by(|a, b| a.branch.cmp(&b.branch));
1263 Ok(branch_tips)
1264 }
1265
1266 pub fn git_overlay_tag_tips(&self) -> Result<Vec<GitOverlayTagTip>> {
1267 if self.capability() != RepositoryCapability::GitOverlay {
1268 return Ok(Vec::new());
1269 }
1270
1271 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1272 return Ok(Vec::new());
1273 };
1274
1275 let imported_markers: std::collections::HashSet<MarkerName> =
1276 self.refs().list_markers()?.into_iter().collect();
1277 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1278 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1279 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1280 let mut tag_tips = Vec::new();
1281
1282 for tag in git_repo.references().list_refs().map_err(|error| {
1283 HeddleError::Config(format!(
1284 "failed to enumerate git tags at '{}': {}",
1285 self.root.display(),
1286 error
1287 ))
1288 })? {
1289 let Some(name) = tag.name.strip_prefix("refs/tags/") else {
1290 continue;
1291 };
1292 let name = name.to_string();
1293 let Some(target) = self.git_overlay_commit_tip_oid(&git_repo, &tag, "tag", &name)?
1294 else {
1295 continue;
1296 };
1297 let git_commit = target.to_string();
1298 let mapped_change = self.git_overlay_mapped_change_for_commit(
1299 &git_commit,
1300 &bridge_mapping,
1301 &ingest_mapping,
1302 &checkpoint_mapping,
1303 )?;
1304 let marker_name = MarkerName::from(name.as_str());
1305 let history_imported = if imported_markers.contains(&marker_name) {
1306 matches!(
1307 (self.refs().get_marker(&marker_name)?, mapped_change.as_ref()),
1308 (Some(existing), Some(mapped_change)) if existing == *mapped_change
1309 )
1310 } else {
1311 false
1312 };
1313 tag_tips.push(GitOverlayTagTip {
1314 tag: name,
1315 git_commit,
1316 history_imported,
1317 mapped_change,
1318 });
1319 }
1320
1321 tag_tips.sort_by(|a, b| a.tag.cmp(&b.tag));
1322 Ok(tag_tips)
1323 }
1324
1325 pub fn git_overlay_branch_tip(&self, name: &str) -> Result<Option<GitOverlayBranchTip>> {
1326 Ok(self
1327 .git_overlay_branch_tips()?
1328 .into_iter()
1329 .find(|tip| tip.branch == name))
1330 }
1331
1332 pub fn git_overlay_tag_tip(&self, name: &str) -> Result<Option<GitOverlayTagTip>> {
1333 Ok(self
1334 .git_overlay_tag_tips()?
1335 .into_iter()
1336 .find(|tip| tip.tag == name))
1337 }
1338
1339 pub fn git_overlay_mapped_change_for_branch(&self, name: &str) -> Result<Option<ChangeId>> {
1340 Ok(self
1341 .git_overlay_branch_tip(name)?
1342 .and_then(|tip| tip.mapped_change))
1343 }
1344
1345 pub fn git_overlay_mapped_change_for_remote_tracking_ref(
1346 &self,
1347 name: &str,
1348 ) -> Result<Option<ChangeId>> {
1349 if self.capability() != RepositoryCapability::GitOverlay {
1350 return Ok(None);
1351 }
1352 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1353 return Ok(None);
1354 };
1355 let full_name = name
1356 .strip_prefix("refs/remotes/")
1357 .map(|short| format!("refs/remotes/{short}"))
1358 .unwrap_or_else(|| format!("refs/remotes/{name}"));
1359 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1360 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1361 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1362 for reference in git_repo.references().list_refs().map_err(|error| {
1363 HeddleError::Config(format!(
1364 "failed to enumerate git remote-tracking refs at '{}': {}",
1365 self.root.display(),
1366 error
1367 ))
1368 })? {
1369 if reference.name != full_name {
1370 continue;
1371 }
1372 let Some(target) =
1373 self.git_overlay_commit_tip_oid(&git_repo, &reference, "remote branch", name)?
1374 else {
1375 return Ok(None);
1376 };
1377 return self.git_overlay_mapped_change_for_commit(
1378 &target.to_string(),
1379 &bridge_mapping,
1380 &ingest_mapping,
1381 &checkpoint_mapping,
1382 );
1383 }
1384 Ok(None)
1385 }
1386
1387 pub fn git_overlay_mapped_change_for_tag(&self, name: &str) -> Result<Option<ChangeId>> {
1388 Ok(self
1389 .git_overlay_tag_tip(name)?
1390 .and_then(|tip| tip.mapped_change))
1391 }
1392
1393 fn change_is_ancestor(&self, ancestor: &ChangeId, descendant: &ChangeId) -> bool {
1394 let mut graph = CommitGraphIndex::new(self);
1395 graph.is_ancestor(ancestor, descendant).unwrap_or(false)
1396 }
1397
1398 pub fn git_overlay_worktree_status(&self) -> Result<Option<WorktreeStatus>> {
1399 if self.capability() != RepositoryCapability::GitOverlay {
1400 return Ok(None);
1401 }
1402 let git_repo = match self.git_overlay_sley_repository() {
1403 Ok(Some(repo)) => repo,
1404 Ok(None) | Err(_) => return Ok(None),
1405 };
1406 if git_repo.workdir().is_none() {
1407 return Ok(None);
1408 }
1409
1410 let index = git_repo.open_index().map_err(|error| {
1411 HeddleError::Config(format!(
1412 "failed to inspect Git index at '{}': {}",
1413 self.root.display(),
1414 error
1415 ))
1416 })?;
1417 let index = index.unwrap_or_else(|| sley::Index {
1418 version: 2,
1419 entries: Vec::new(),
1420 extensions: Vec::new(),
1421 checksum: None,
1422 });
1423 let head_tree = match git_repo
1424 .head()
1425 .map_err(|error| {
1426 HeddleError::Config(format!(
1427 "failed to inspect Git HEAD tree at '{}': {}",
1428 self.root.display(),
1429 error
1430 ))
1431 })?
1432 .oid
1433 {
1434 Some(head) => {
1435 git_repo
1436 .read_commit(&head)
1437 .map_err(|error| {
1438 HeddleError::Config(format!(
1439 "failed to inspect Git HEAD commit at '{}': {}",
1440 self.root.display(),
1441 error
1442 ))
1443 })?
1444 .tree
1445 }
1446 None => sley::ObjectId::empty_tree(git_repo.object_format()),
1447 };
1448 let head_index = git_repo.index_from_tree(&head_tree).map_err(|error| {
1449 HeddleError::Config(format!(
1450 "failed to inspect Git HEAD tree at '{}': {}",
1451 self.root.display(),
1452 error
1453 ))
1454 })?;
1455
1456 let mut head_entries = BTreeMap::new();
1457 for entry in head_index.entries {
1458 let path = git_path(entry.path.as_bytes());
1459 head_entries.insert(path, (entry.oid, entry.mode));
1460 }
1461 let mut index_entries = BTreeMap::new();
1462 let index_path = sley::plumbing::sley_worktree::repository_index_path(git_repo.git_dir());
1463 for entry in index.entries {
1464 let path = git_path(entry.path.as_bytes());
1465 let probe = crate::git_worktree_status::IndexStatProbe::from_index_entry_and_index_path(
1466 entry.clone(),
1467 &index_path,
1468 );
1469 index_entries.insert(path, (entry.oid, entry.mode, probe));
1470 }
1471
1472 let mut added = BTreeSet::new();
1473 let mut modified = BTreeSet::new();
1474 let mut deleted = BTreeSet::new();
1475
1476 for (path, (oid, mode, _probe)) in &index_entries {
1477 if ignored_git_overlay_status_path(path) {
1478 continue;
1479 }
1480 match head_entries.get(path) {
1481 None => {
1482 added.insert(PathBuf::from(path));
1483 }
1484 Some((head_oid, head_mode)) if (head_oid, head_mode) != (oid, mode) => {
1485 modified.insert(PathBuf::from(path));
1486 }
1487 Some(_) => {}
1488 }
1489 }
1490 for path in head_entries.keys() {
1491 if !ignored_git_overlay_status_path(path) && !index_entries.contains_key(path) {
1492 deleted.insert(PathBuf::from(path));
1493 }
1494 }
1495
1496 for (path, (oid, mode, probe)) in &index_entries {
1497 if ignored_git_overlay_status_path(path) {
1498 continue;
1499 }
1500 match crate::git_worktree_status::git_worktree_entry_state_in_repo(
1501 &git_repo,
1502 &self.root,
1503 path,
1504 *oid,
1505 *mode,
1506 Some(probe.clone()),
1507 )? {
1508 GitWorktreeEntryState::Clean => {}
1509 GitWorktreeEntryState::Deleted => {
1510 deleted.insert(PathBuf::from(path));
1511 }
1512 GitWorktreeEntryState::Modified => {
1513 modified.insert(PathBuf::from(path));
1514 }
1515 }
1516 }
1517
1518 let ignore_patterns = self.ignore_patterns()?;
1519 let tracked_paths: BTreeSet<&str> = index_entries.keys().map(String::as_str).collect();
1520 for path in git_overlay_untracked_paths(&self.root, &tracked_paths, &ignore_patterns)? {
1521 added.insert(PathBuf::from(path));
1522 }
1523
1524 Ok(Some(WorktreeStatus {
1525 modified: modified.into_iter().collect(),
1526 added: added.into_iter().collect(),
1527 deleted: deleted.into_iter().collect(),
1528 }))
1529 }
1530
1531 fn git_overlay_bridge_mapping(&self) -> Result<HashMap<String, String>> {
1532 let path = self
1533 .heddle_dir
1534 .join("git-bridge")
1535 .join("bridge-mapping.json");
1536 if !path.exists() {
1537 return Ok(HashMap::new());
1538 }
1539
1540 let contents = fs::read_to_string(path)?;
1541 if contents.trim().is_empty() {
1542 return Ok(HashMap::new());
1543 }
1544
1545 let file: GitBridgeMappingFile = serde_json::from_str(&contents)?;
1546 Ok(file
1547 .entries
1548 .into_iter()
1549 .map(|entry| (entry.git_oid, entry.change_id))
1550 .collect())
1551 }
1552
1553 pub fn git_overlay_ingest_commit_mapping(&self) -> Result<HashMap<String, String>> {
1554 let path = self.heddle_dir.join("ingest").join("sha_map.sqlite");
1555 if !path.exists() {
1556 return Ok(HashMap::new());
1557 }
1558
1559 let conn = Connection::open_with_flags(
1560 &path,
1561 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1562 )
1563 .map_err(|error| {
1564 HeddleError::Config(format!(
1565 "failed to open ingest SHA map at '{}': {}",
1566 path.display(),
1567 error
1568 ))
1569 })?;
1570 let mut stmt = conn
1571 .prepare_cached("SELECT git_sha, heddle_repr FROM sha_map WHERE kind = 0")
1572 .map_err(|error| {
1573 HeddleError::Config(format!(
1574 "failed to read ingest SHA map at '{}': {}",
1575 path.display(),
1576 error
1577 ))
1578 })?;
1579 let rows = stmt
1580 .query_map([], |row| {
1581 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1582 })
1583 .map_err(|error| {
1584 HeddleError::Config(format!(
1585 "failed to enumerate ingest SHA map at '{}': {}",
1586 path.display(),
1587 error
1588 ))
1589 })?;
1590
1591 let mut mapping = HashMap::new();
1592 for row in rows {
1593 let (git_sha, change_id) = row.map_err(|error| {
1594 HeddleError::Config(format!(
1595 "failed to read ingest SHA map row at '{}': {}",
1596 path.display(),
1597 error
1598 ))
1599 })?;
1600 mapping.insert(git_sha, change_id);
1601 }
1602 Ok(mapping)
1603 }
1604
1605 fn git_overlay_checkpoint_mapping(&self) -> Result<HashMap<String, String>> {
1606 Ok(self
1607 .list_git_checkpoints()?
1608 .into_iter()
1609 .map(|record| (record.git_commit, record.change_id))
1610 .collect())
1611 }
1612
1613 fn git_overlay_mapped_change_for_commit(
1614 &self,
1615 git_commit: &str,
1616 bridge_mapping: &HashMap<String, String>,
1617 ingest_mapping: &HashMap<String, String>,
1618 checkpoint_mapping: &HashMap<String, String>,
1619 ) -> Result<Option<ChangeId>> {
1620 let Some(change) = bridge_mapping
1621 .get(git_commit)
1622 .or_else(|| ingest_mapping.get(git_commit))
1623 .or_else(|| checkpoint_mapping.get(git_commit))
1624 else {
1625 return Ok(None);
1626 };
1627 let change_id = ChangeId::parse(change).map_err(|error| {
1628 HeddleError::Config(format!(
1629 "git commit {git_commit} maps to invalid Heddle change id '{change}': {error}"
1630 ))
1631 })?;
1632 if self.store.get_state(&change_id)?.is_some() {
1633 Ok(Some(change_id))
1634 } else {
1635 Ok(None)
1636 }
1637 }
1638
1639 fn git_overlay_mapped_git_commit_for_change_in(
1640 &self,
1641 change_id: &ChangeId,
1642 mapping: &HashMap<String, String>,
1643 ) -> Result<Option<String>> {
1644 for (git_commit, mapped_change) in mapping {
1645 let mapped_change_id = ChangeId::parse(mapped_change).map_err(|error| {
1646 HeddleError::Config(format!(
1647 "git commit {git_commit} maps to invalid Heddle change id '{mapped_change}': {error}"
1648 ))
1649 })?;
1650 if mapped_change_id == *change_id {
1651 return Ok(Some(git_commit.clone()));
1652 }
1653 }
1654 Ok(None)
1655 }
1656
1657 pub fn git_overlay_mapped_git_commit_for_change(
1658 &self,
1659 change_id: &ChangeId,
1660 ) -> Result<Option<String>> {
1661 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1662 if let Some(git_commit) =
1663 self.git_overlay_mapped_git_commit_for_change_in(change_id, &bridge_mapping)?
1664 {
1665 return Ok(Some(git_commit));
1666 }
1667
1668 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1669 if let Some(git_commit) =
1670 self.git_overlay_mapped_git_commit_for_change_in(change_id, &ingest_mapping)?
1671 {
1672 return Ok(Some(git_commit));
1673 }
1674
1675 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1676 self.git_overlay_mapped_git_commit_for_change_in(change_id, &checkpoint_mapping)
1677 }
1678
1679 pub fn git_overlay_mapped_change_for_git_commit(
1680 &self,
1681 git_commit: &str,
1682 ) -> Result<Option<ChangeId>> {
1683 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1684 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1685 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1686 self.git_overlay_mapped_change_for_commit(
1687 git_commit,
1688 &bridge_mapping,
1689 &ingest_mapping,
1690 &checkpoint_mapping,
1691 )
1692 }
1693
1694 fn git_overlay_mapped_change_for_git_oid(
1695 &self,
1696 git_oid: SleyObjectId,
1697 ) -> Result<Option<ChangeId>> {
1698 self.git_overlay_mapped_change_for_git_commit(&git_oid.to_string())
1699 }
1700
1701 pub fn git_overlay_out_of_band_commits(
1710 &self,
1711 tip_git_commit: &str,
1712 ) -> Result<Option<GitOverlayOutOfBandCommits>> {
1713 if self.capability() != RepositoryCapability::GitOverlay {
1714 return Ok(None);
1715 }
1716 let git_repo = match self.git_overlay_sley_repository() {
1717 Ok(Some(repo)) => repo,
1718 Ok(None) | Err(_) => return Ok(None),
1719 };
1720 let Ok(tip) = SleyObjectId::from_hex(git_repo.object_format(), tip_git_commit) else {
1721 return Ok(None);
1722 };
1723
1724 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1725 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1726 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1727
1728 let mut pending = vec![tip];
1729 let mut visited = std::collections::HashSet::new();
1730 let mut count = 0usize;
1731 while let Some(oid) = pending.pop() {
1732 if !visited.insert(oid) {
1733 continue;
1734 }
1735 let git_commit = oid.to_string();
1736 if self
1737 .git_overlay_mapped_change_for_commit(
1738 &git_commit,
1739 &bridge_mapping,
1740 &ingest_mapping,
1741 &checkpoint_mapping,
1742 )?
1743 .is_some()
1744 {
1745 continue;
1747 }
1748 count += 1;
1749 if count >= GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT {
1750 return Ok(Some(GitOverlayOutOfBandCommits {
1751 count,
1752 truncated: true,
1753 }));
1754 }
1755 let Ok(commit) = git_repo.read_commit(&oid) else {
1756 continue;
1757 };
1758 for parent in commit.parents {
1759 pending.push(parent);
1760 }
1761 }
1762 Ok(Some(GitOverlayOutOfBandCommits {
1763 count,
1764 truncated: false,
1765 }))
1766 }
1767
1768 pub fn git_overlay_current_branch(&self) -> Result<Option<String>> {
1769 if self.capability() != RepositoryCapability::GitOverlay {
1770 return Ok(None);
1771 }
1772
1773 match detect_git_head_state(&self.root)? {
1774 Some(GitHeadState::Attached(branch)) => return Ok(Some(branch)),
1775 Some(GitHeadState::Detached(_)) | None => {}
1776 }
1777
1778 detect_git_in_progress_branch(&self.root)
1779 }
1780
1781 pub fn git_overlay_head_is_detached(&self) -> Result<bool> {
1782 if self.capability() != RepositoryCapability::GitOverlay {
1783 return Ok(false);
1784 }
1785
1786 Ok(matches!(
1787 detect_git_head_state(&self.root)?,
1788 Some(GitHeadState::Detached(_))
1789 ))
1790 }
1791
1792 pub fn git_overlay_detached_head_commit(&self) -> Result<Option<String>> {
1793 if self.capability() != RepositoryCapability::GitOverlay {
1794 return Ok(None);
1795 }
1796
1797 Ok(match detect_git_head_state(&self.root)? {
1798 Some(GitHeadState::Detached(git_oid)) => Some(git_oid.to_string()),
1799 Some(GitHeadState::Attached(_)) | None => None,
1800 })
1801 }
1802
1803 fn git_overlay_commit_tip_oid(
1804 &self,
1805 git_repo: &SleyRepository,
1806 reference: &sley::plumbing::sley_refs::Ref,
1807 ref_kind: &str,
1808 ref_name: &str,
1809 ) -> Result<Option<SleyObjectId>> {
1810 let target = match &reference.target {
1811 SleyRefTarget::Direct(oid) => *oid,
1812 SleyRefTarget::Symbolic(_) => return Ok(None),
1813 };
1814 let target = match sley::plumbing::sley_rev::peel_to_commit(
1815 git_repo.objects().as_ref(),
1816 git_repo.object_format(),
1817 &target,
1818 ) {
1819 Ok(target) => target,
1820 Err(_) => return Ok(None),
1821 };
1822
1823 let _ = (ref_kind, ref_name);
1824 Ok(Some(target))
1825 }
1826
1827 fn heddle_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1828 if self.merge_state_manager().is_merge_in_progress() {
1829 return Ok(Some(RepositoryOperationStatus {
1830 scope: OperationScope::Heddle,
1831 kind: OperationKind::Merge,
1832 in_progress: true,
1833 state: "in-progress".to_string(),
1834 message: "Heddle merge is in progress".to_string(),
1835 next_action: "heddle continue".to_string(),
1836 }));
1837 }
1838
1839 let rebase_state = self.heddle_dir.join("REBASE_STATE");
1840 if rebase_state.exists() {
1841 return Ok(Some(RepositoryOperationStatus {
1842 scope: OperationScope::Heddle,
1843 kind: OperationKind::Rebase,
1844 in_progress: true,
1845 state: "in-progress".to_string(),
1846 message: "Heddle rebase is in progress".to_string(),
1847 next_action: "heddle continue".to_string(),
1848 }));
1849 }
1850
1851 let bisect_state = self.heddle_dir.join("BISECT_STATE");
1852 if bisect_state.exists() {
1853 return Ok(Some(RepositoryOperationStatus {
1854 scope: OperationScope::Heddle,
1855 kind: OperationKind::Bisect,
1856 in_progress: true,
1857 state: "in-progress".to_string(),
1858 message: "Heddle bisect is in progress".to_string(),
1862 next_action: "heddle abort".to_string(),
1863 }));
1864 }
1865
1866 Ok(None)
1867 }
1868
1869 fn git_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1870 if self.capability() != RepositoryCapability::GitOverlay {
1871 return Ok(None);
1872 }
1873
1874 let git_dir = resolve_git_dir(&self.root)?;
1875 let raw_git_next_action = "heddle bridge git status";
1876 let candidates = [
1877 (
1878 git_dir.join("rebase-merge"),
1879 OperationKind::Rebase,
1880 "Git rebase is in progress",
1881 raw_git_next_action,
1882 ),
1883 (
1884 git_dir.join("rebase-apply"),
1885 OperationKind::Rebase,
1886 "Git rebase is in progress",
1887 raw_git_next_action,
1888 ),
1889 (
1890 git_dir.join("MERGE_HEAD"),
1891 OperationKind::Merge,
1892 "Git merge is in progress",
1893 raw_git_next_action,
1894 ),
1895 (
1896 git_dir.join("CHERRY_PICK_HEAD"),
1897 OperationKind::CherryPick,
1898 "Git cherry-pick is in progress",
1899 raw_git_next_action,
1900 ),
1901 (
1902 git_dir.join("REVERT_HEAD"),
1903 OperationKind::Revert,
1904 "Git revert is in progress",
1905 raw_git_next_action,
1906 ),
1907 (
1908 git_dir.join("BISECT_LOG"),
1909 OperationKind::Bisect,
1910 "Git bisect is in progress",
1911 raw_git_next_action,
1912 ),
1913 ];
1914
1915 for (path, kind, message, next_action) in candidates {
1916 if path.exists() {
1917 return Ok(Some(RepositoryOperationStatus {
1918 scope: OperationScope::Git,
1919 kind,
1920 in_progress: true,
1921 state: "in-progress".to_string(),
1922 message: message.to_string(),
1923 next_action: next_action.to_string(),
1924 }));
1925 }
1926 }
1927
1928 Ok(None)
1929 }
1930
1931 pub fn list_git_checkpoints(&self) -> Result<Vec<GitCheckpointRecord>> {
1932 let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1933 if !path.exists() {
1934 return Ok(Vec::new());
1935 }
1936 let contents = fs::read_to_string(path)?;
1937 if contents.trim().is_empty() {
1938 return Ok(Vec::new());
1939 }
1940 Ok(serde_json::from_str(&contents)?)
1941 }
1942
1943 pub fn latest_git_checkpoint_for_change(
1944 &self,
1945 change_id: &ChangeId,
1946 ) -> Result<Option<GitCheckpointRecord>> {
1947 let full_id = change_id.to_string_full();
1948 Ok(self
1949 .list_git_checkpoints()?
1950 .into_iter()
1951 .rev()
1952 .find(|record| record.change_id == full_id))
1953 }
1954
1955 pub fn record_git_checkpoint(
1956 &self,
1957 change_id: &ChangeId,
1958 git_commit: impl Into<String>,
1959 summary: impl Into<String>,
1960 ) -> Result<GitCheckpointRecord> {
1961 let mut records = self.list_git_checkpoints()?;
1962 let record = GitCheckpointRecord {
1963 change_id: change_id.to_string_full(),
1964 git_commit: git_commit.into(),
1965 summary: summary.into(),
1966 committed_at: Utc::now().to_rfc3339(),
1967 };
1968 let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1969 if let Some(parent) = path.parent() {
1970 fs::create_dir_all(parent)?;
1971 }
1972 records.push(record.clone());
1973 write_file_atomic(&path, serde_json::to_string_pretty(&records)?.as_bytes())?;
1974 Ok(record)
1975 }
1976
1977 pub fn init_worktree(
1978 path: impl AsRef<Path>,
1979 shared_galeed_dir: impl AsRef<Path>,
1980 ) -> Result<()> {
1981 let path = path.as_ref();
1982 let shared = shared_galeed_dir.as_ref().canonicalize()?;
1983 fs::create_dir_all(path)?;
1984 let heddle_dir = path.join(".heddle");
1985 if heddle_dir.exists() {
1986 return Err(HeddleError::RepositoryExists(path.to_path_buf()));
1987 }
1988 fs::create_dir_all(&heddle_dir)?;
1989 write_file_atomic(
1990 &heddle_dir.join("objectstore"),
1991 format!("objectstore: {}\n", shared.display()).as_bytes(),
1992 )?;
1993 fs::create_dir_all(heddle_dir.join("state"))?;
1994 Ok(())
1995 }
1996
1997 pub fn op_scope(&self) -> String {
1998 compute_op_scope(&self.root)
2012 }
2013
2014 pub fn commit_and_publish(
2023 &self,
2024 records: Vec<OpRecord>,
2025 ref_updates: &[RefUpdate],
2026 ) -> Result<()> {
2027 let encoded = records
2028 .iter()
2029 .map(|record| {
2030 rmp_serde::to_vec(record).map_err(|e| HeddleError::Serialization(e.to_string()))
2031 })
2032 .collect::<Result<Vec<_>>>()?;
2033 let scope = self.op_scope();
2034 let result = self
2035 .refs
2036 .commit_and_publish(&encoded, ref_updates, Some(&scope));
2037 let _ = self.oplog.refresh_cache();
2044 result
2045 }
2046
2047 pub fn commit_snapshot_atomic(
2065 &self,
2066 new_state: &ChangeId,
2067 prev_head: Option<ChangeId>,
2068 thread: Option<&ThreadName>,
2069 ) -> Result<()> {
2070 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, Vec::new())
2071 }
2072
2073 pub fn commit_snapshot_atomic_with_records(
2083 &self,
2084 new_state: &ChangeId,
2085 prev_head: Option<ChangeId>,
2086 thread: Option<&ThreadName>,
2087 extra: Vec<OpRecord>,
2088 ) -> Result<()> {
2089 let record = OpRecord::Snapshot {
2090 new_state: *new_state,
2091 prev_head,
2092 head: thread.is_none().then_some(*new_state),
2093 thread: thread.map(|name| name.to_string()),
2094 };
2095 let mut records = vec![record];
2096 records.extend(extra);
2097 let ref_update = match thread {
2098 Some(name) => RefUpdate::Thread {
2099 name: name.clone(),
2100 expected: RefExpectation::Any,
2101 new: Some(*new_state),
2102 },
2103 None => RefUpdate::Head {
2104 expected: RefExpectation::Any,
2105 new: Head::Detached { state: *new_state },
2106 },
2107 };
2108 self.commit_and_publish(records, &[ref_update])
2109 }
2110
2111 pub fn commit_snapshot_atomic_with_capture_visibility(
2130 &self,
2131 new_state: &ChangeId,
2132 prev_head: Option<ChangeId>,
2133 thread: Option<&ThreadName>,
2134 lock_held: bool,
2135 ) -> Result<()> {
2136 let binding = self
2137 .stage_default_visibility_binding(new_state, lock_held)
2138 .map_err(|e| HeddleError::Io(std::io::Error::other(format!("{e:#}"))))?;
2139 let (extra, rewind_to): (Vec<OpRecord>, Option<Option<Vec<u8>>>) = match binding {
2140 Some(binding) => (vec![binding.record], Some(binding.prior_sidecar)),
2141 None => (Vec::new(), None),
2142 };
2143
2144 #[cfg(test)]
2147 let commit_result = if crate::repository_state_visibility::take_visibility_commit_fault(
2148 crate::repository_state_visibility::VisibilityCommitFault::SnapshotCommit,
2149 ) {
2150 Err(HeddleError::Io(std::io::Error::other(
2151 "injected snapshot-commit failure after staging visibility binding",
2152 )))
2153 } else {
2154 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra)
2155 };
2156 #[cfg(not(test))]
2157 let commit_result =
2158 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra);
2159
2160 match commit_result {
2161 Ok(()) => Ok(()),
2162 Err(commit_err) => {
2163 if let Some(prior) = rewind_to {
2164 if let Err(rewind_err) = self.restore_state_visibility_sidecar(new_state, prior)
2168 {
2169 tracing::warn!(
2170 state = %new_state,
2171 error = %rewind_err,
2172 "rewind of staged visibility binding after a failed snapshot commit also failed"
2173 );
2174 }
2175 }
2176 Err(commit_err)
2177 }
2178 }
2179 }
2180
2181 pub fn repo_config(&self) -> &RepoConfig {
2182 &self.config
2183 }
2184
2185 pub fn config(&self) -> &RepoConfig {
2186 self.repo_config()
2187 }
2188
2189 pub fn get_tree_for_state(&self, state_id: &ChangeId) -> Result<Option<Tree>> {
2190 let state = match self.store.get_state(state_id)? {
2191 Some(state) => state,
2192 None => return Ok(None),
2193 };
2194 self.store.get_tree(&state.tree)
2195 }
2196
2197 pub fn ignore_patterns(&self) -> Result<Vec<String>> {
2198 let mut patterns = self.config.worktree.ignore.clone();
2199 patterns.push(format!(
2211 "/{}",
2212 repository_thread_materialize::COURTESY_STUB_FILENAME
2213 ));
2214 if self.capability() == RepositoryCapability::GitOverlay {
2215 patterns.push(".git".to_string());
2216 append_ignore_file_patterns(&mut patterns, &self.root.join(".gitignore"))?;
2217 }
2218 append_ignore_file_patterns(
2226 &mut patterns,
2227 &self.root.join(".heddle").join("info").join("exclude"),
2228 )?;
2229 let path = self.root.join(".heddleignore");
2230
2231 if path.exists() {
2232 append_ignore_file_patterns(&mut patterns, &path)?;
2233 }
2234
2235 Ok(patterns)
2236 }
2237
2238 pub fn nested_thread_worktree_exclusions(&self, walk_root: &Path) -> Result<Vec<PathBuf>> {
2253 let canonical_walk_root = walk_root
2254 .canonicalize()
2255 .unwrap_or_else(|_| walk_root.to_path_buf());
2256 let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2257 let mut exclusions: Vec<PathBuf> = Vec::new();
2258 let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
2259 for thread in manager.list()? {
2260 for candidate in [
2261 Some(&thread.execution_path),
2262 thread.materialized_path.as_ref(),
2263 ]
2264 .into_iter()
2265 .flatten()
2266 {
2267 if candidate.as_os_str().is_empty() {
2268 continue;
2269 }
2270 let canonical = match candidate.canonicalize() {
2271 Ok(path) => path,
2272 Err(_) => continue,
2273 };
2274 if canonical == canonical_walk_root {
2275 continue;
2276 }
2277 if !canonical.starts_with(&canonical_walk_root) {
2278 continue;
2279 }
2280 if seen.insert(canonical.clone()) {
2281 exclusions.push(canonical);
2282 }
2283 }
2284 }
2285 Ok(exclusions)
2286 }
2287
2288 pub fn head(&self) -> Result<Option<ChangeId>> {
2289 Ok(match self.head_ref()? {
2290 Head::Attached { thread } => match self.refs.get_thread(&thread)? {
2291 Some(change_id) => Some(change_id),
2292 None if self.capability() == RepositoryCapability::GitOverlay => {
2293 self.git_overlay_mapped_change_for_branch(&thread)?
2294 }
2295 None => None,
2296 },
2297 Head::Detached { state } => Some(state),
2298 })
2299 }
2300
2301 pub fn head_ref(&self) -> Result<Head> {
2302 let raw = self.refs.read_head()?;
2303 if self.capability() != RepositoryCapability::GitOverlay {
2304 return Ok(raw);
2305 }
2306 if matches!(raw, Head::Detached { .. }) {
2307 return Ok(raw);
2308 }
2309 if let Some(GitHeadState::Detached(git_oid)) = detect_git_head_state(&self.root)?
2310 && let Some(state) = self.git_overlay_mapped_change_for_git_oid(git_oid)?
2311 {
2312 return Ok(Head::Detached { state });
2313 }
2314 let Some(branch) = self.git_overlay_current_branch()? else {
2315 return Ok(raw);
2316 };
2317 if matches!(&raw, Head::Attached { thread } if *thread == branch) {
2318 return Ok(raw);
2319 }
2320 let branch_thread = ThreadName::from(branch.as_str());
2321 if self.refs.get_thread(&branch_thread)?.is_some()
2322 || self
2323 .git_overlay_mapped_change_for_branch(&branch)?
2324 .is_some()
2325 {
2326 return Ok(Head::Attached {
2327 thread: branch_thread,
2328 });
2329 }
2330 Ok(raw)
2331 }
2332
2333 pub fn active_worktree_path(&self) -> Result<PathBuf> {
2350 let head = self.refs.read_head()?;
2351 let Head::Attached { thread } = head else {
2352 return Ok(self.root.clone());
2353 };
2354 let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2355 let Some(thread_record) = manager.find_by_thread(&thread)? else {
2356 return Ok(self.root.clone());
2357 };
2358 if !thread_record.execution_path.as_os_str().is_empty() {
2359 return Ok(thread_record.execution_path);
2360 }
2361 if let Some(path) = thread_record.materialized_path {
2362 return Ok(path);
2363 }
2364 Ok(self.root.clone())
2365 }
2366
2367 pub fn current_state(&self) -> Result<Option<State>> {
2368 match self.head()? {
2369 Some(id) => self.store.get_state(&id),
2370 None => Ok(None),
2371 }
2372 }
2373
2374 pub fn get_principal(&self) -> Result<Principal> {
2375 if let Some(principal) = Principal::from_env() {
2376 return Ok(principal);
2377 }
2378
2379 if let Some(config) = &self.config.principal {
2380 return Ok(Principal::new(&config.name, &config.email));
2381 }
2382
2383 if self.capability() == RepositoryCapability::GitOverlay
2384 && let Some(principal) = git_config_principal(&self.root)
2385 {
2386 return Ok(principal);
2387 }
2388
2389 if let Some(principal) = self.shared_checkout_parent_git_principal() {
2390 return Ok(principal);
2391 }
2392
2393 Ok(Principal::new("Unknown", "unknown@example.com"))
2394 }
2395
2396 fn shared_checkout_parent_git_principal(&self) -> Option<Principal> {
2397 let local_heddle_dir = self.root.join(".heddle");
2398 if local_heddle_dir == self.heddle_dir || !local_heddle_dir.join("objectstore").is_file() {
2399 return None;
2400 }
2401 let parent_root = self.heddle_dir.parent()?;
2402 if parent_root == self.root {
2403 return None;
2404 }
2405 git_config_principal(parent_root)
2406 }
2407
2408 pub fn get_attribution(&self) -> Result<Attribution> {
2409 let principal = self.get_principal()?;
2410
2411 if let Some(agent) = self.resolve_agent() {
2412 Ok(Attribution::with_agent(principal, agent))
2413 } else {
2414 Ok(Attribution::human(principal))
2415 }
2416 }
2417
2418 pub fn is_shallow(&self, id: &ChangeId) -> bool {
2419 self.shallow.read().unwrap().is_shallow(id)
2420 }
2421
2422 pub fn set_shallow(&self, state_id: &ChangeId, _parents: &[ChangeId]) -> Result<()> {
2423 self.shallow.write().unwrap().add_shallow(*state_id)?;
2424 Ok(())
2425 }
2426
2427 pub fn record_missing_blob(&self, hash: ContentHash) -> Result<()> {
2428 self.partial_fetch_metadata().record_missing_blob(hash)?;
2429 Ok(())
2430 }
2431
2432 pub fn seed_default_thread(&self) -> Result<()> {
2447 let main_thread = ThreadName::from("main");
2448 if self.refs.get_thread(&main_thread)?.is_some() {
2449 return Ok(());
2450 }
2451
2452 let empty_tree = Tree::new();
2453 let tree_hash = self.store.put_tree(&empty_tree)?;
2454 let state = State::new_snapshot(tree_hash, vec![], Attribution::human(seed_principal()));
2455 self.store.put_state(&state)?;
2456 self.refs.set_thread(&main_thread, &state.change_id)?;
2457 Ok(())
2458 }
2459
2460 pub fn clear_missing_blob(&self, hash: &ContentHash) -> Result<()> {
2461 self.partial_fetch_metadata().clear_missing_blob(hash)?;
2462 Ok(())
2463 }
2464
2465 pub fn missing_blobs(&self) -> Result<Vec<ContentHash>> {
2466 self.partial_fetch_metadata().missing_blobs()
2467 }
2468
2469 pub fn clear_all_missing_blobs(&self) -> Result<bool> {
2470 self.partial_fetch_metadata().clear_all_missing_blobs()
2471 }
2472
2473 pub fn is_missing_blob(&self, hash: &ContentHash) -> Result<bool> {
2474 self.partial_fetch_metadata().is_missing_blob(hash)
2475 }
2476
2477 pub fn require_tree(&self, hash: &ContentHash) -> Result<Tree> {
2504 self.store
2505 .get_tree(hash)?
2506 .ok_or_else(|| HeddleError::MissingObject {
2507 object_type: "tree".to_string(),
2508 id: hash.to_hex(),
2509 })
2510 }
2511
2512 pub fn require_blob(&self, hash: &ContentHash) -> Result<objects::object::Blob> {
2513 if let Some(blob) = self.store.get_blob(hash)? {
2514 if self.is_missing_blob(hash)? {
2515 self.clear_missing_blob(hash)?;
2516 }
2517 return Ok(blob);
2518 }
2519
2520 if self.is_missing_blob(hash)? {
2521 if let Some(hydrator) = self.blob_hydrator() {
2525 hydrator.hydrate(self, hash)?;
2526 if let Some(blob) = self.store.get_blob(hash)? {
2527 self.clear_missing_blob(hash)?;
2528 return Ok(blob);
2529 }
2530 }
2535 return Err(HeddleError::MissingObject {
2536 object_type: "blob".to_string(),
2537 id: hash.to_hex(),
2538 });
2539 }
2540
2541 Err(HeddleError::NotFound(hash.to_hex()))
2542 }
2543
2544 pub fn set_blob_hydrator(&self, hydrator: Arc<dyn BlobHydrator>) {
2557 *self.blob_hydrator.write().unwrap() = Some(hydrator);
2558 }
2559
2560 pub fn blob_hydrator(&self) -> Option<Arc<dyn BlobHydrator>> {
2562 self.blob_hydrator.read().unwrap().clone()
2563 }
2564
2565 fn partial_fetch_metadata(&self) -> repository_partial_fetch::PartialFetchMetadataManager {
2566 repository_partial_fetch::PartialFetchMetadataManager::new(&self.heddle_dir)
2567 }
2568
2569 pub fn shallow(&self) -> std::sync::RwLockReadGuard<'_, ShallowInfo> {
2570 self.shallow.read().unwrap()
2571 }
2572}
2573
2574fn ensure_git_overlay_exclude(root: &Path) -> Result<()> {
2575 let git_dir = match SleyRepository::discover(root) {
2576 Ok(repo) if repo.workdir().is_some() => repo.git_dir().to_path_buf(),
2577 _ => root.join(".git"),
2578 };
2579 if !git_dir.is_dir() {
2580 return Ok(());
2581 }
2582
2583 let info_dir = git_dir.join("info");
2584 fs::create_dir_all(&info_dir)?;
2585 let exclude_path = info_dir.join("exclude");
2586 let mut contents = fs::read_to_string(&exclude_path).unwrap_or_default();
2587 let existing_lines = contents.lines().map(str::trim).collect::<BTreeSet<_>>();
2588 let mut missing = Vec::new();
2589 for pattern in GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS {
2590 if !existing_lines
2591 .iter()
2592 .any(|line| git_overlay_exclude_line_matches(line, pattern))
2593 {
2594 missing.push(*pattern);
2595 }
2596 }
2597 if missing.is_empty() {
2598 return Ok(());
2599 }
2600 if !contents.is_empty() && !contents.ends_with('\n') {
2601 contents.push('\n');
2602 }
2603 contents.push_str("# Heddle local metadata\n");
2604 for pattern in missing {
2605 contents.push_str(pattern);
2606 contents.push('\n');
2607 }
2608 fs::write(exclude_path, contents)?;
2609 Ok(())
2610}
2611
2612fn git_overlay_exclude_line_matches(line: &str, pattern: &str) -> bool {
2613 line == pattern
2614 || matches!(
2615 (line, pattern),
2616 (".heddle", ".heddle/") | ("/.heddle/", ".heddle/") | ("/.heddle", ".heddle/")
2617 )
2618}
2619
2620pub(crate) fn seed_principal() -> Principal {
2625 Principal::new("Heddle", "init@heddle")
2626}
2627
2628pub fn is_synthetic_root(state: &State) -> bool {
2633 state.parents.is_empty()
2634 && state.intent.is_none()
2635 && state.attribution.principal == seed_principal()
2636 && state.attribution.agent.is_none()
2637}
2638
2639fn parse_objectstore_pointer(content: &str) -> Option<PathBuf> {
2643 for line in content.lines() {
2644 if let Some(path) = line.strip_prefix("objectstore:") {
2645 let path = path.trim();
2646 if !path.is_empty() {
2647 return Some(PathBuf::from(path));
2648 }
2649 }
2650 }
2651 None
2652}
2653
2654fn has_git_metadata(path: &Path) -> bool {
2655 let dot_git = path.join(".git");
2656 if !(dot_git.is_dir() || dot_git.is_file()) {
2657 return false;
2658 }
2659
2660 SleyRepository::discover(path).is_ok()
2661}
2662
2663fn metadataless_managed_thread_root(start_path: &Path) -> Option<PathBuf> {
2677 let mut cur: Option<&Path> = Some(start_path);
2678 while let Some(dir) = cur {
2679 if let Some(thread_dir) = dir.parent()
2680 && let Some(threads) = thread_dir.parent()
2681 && threads.file_name().and_then(|n| n.to_str()) == Some("threads")
2682 && let Some(heddle) = threads.parent()
2683 && heddle.file_name().and_then(|n| n.to_str()) == Some(".heddle")
2684 && heddle.join("objects").is_dir()
2685 && !dir.join(".heddle").exists()
2686 {
2687 return Some(dir.to_path_buf());
2688 }
2689 cur = dir.parent();
2690 }
2691 None
2692}
2693
2694fn git_config_principal(root: &Path) -> Option<Principal> {
2695 let git_repo = SleyRepository::discover(root).ok()?;
2696 let config = git_repo.config_snapshot().ok()?;
2697 let name = config.get("user", None, "name")?.to_string();
2698 let email = config.get("user", None, "email")?.to_string();
2699 if name.trim().is_empty() || email.trim().is_empty() {
2700 return None;
2701 }
2702 Some(Principal::new(&name, &email))
2703}
2704
2705fn git_overlay_untracked_paths(
2706 root: &Path,
2707 tracked_paths: &BTreeSet<&str>,
2708 ignore_patterns: &[String],
2709) -> Result<Vec<String>> {
2710 let mut paths = Vec::new();
2711 let filter_root = root.to_path_buf();
2712 let filter_ignore_patterns = ignore_patterns.to_vec();
2713 let walker = ignore::WalkBuilder::new(root)
2714 .hidden(false)
2715 .git_ignore(true)
2716 .git_exclude(true)
2717 .git_global(true)
2718 .filter_entry(move |entry| {
2719 should_descend_for_git_overlay_status(
2720 &filter_root,
2721 entry.path(),
2722 &filter_ignore_patterns,
2723 )
2724 })
2725 .build();
2726 for entry in walker {
2727 let entry = entry.map_err(|error| HeddleError::Config(error.to_string()))?;
2728 let file_type = entry.file_type();
2729 if !file_type.is_some_and(|file_type| file_type.is_file() || file_type.is_symlink()) {
2730 continue;
2731 }
2732 let path = repo_relative_git_path(root, entry.path())?;
2733 if !tracked_paths.contains(path.as_str())
2734 && !ignored_git_overlay_status_path(&path)
2735 && !should_ignore_path(Path::new(&path), ignore_patterns)
2736 {
2737 paths.push(path);
2738 }
2739 }
2740 Ok(paths)
2741}
2742
2743fn should_descend_for_git_overlay_status(
2744 root: &Path,
2745 path: &Path,
2746 ignore_patterns: &[String],
2747) -> bool {
2748 if is_git_or_heddle_dir(path) {
2749 return false;
2750 }
2751 let Ok(relative) = path.strip_prefix(root) else {
2752 return true;
2753 };
2754 if relative.as_os_str().is_empty() {
2755 return true;
2756 }
2757 !should_ignore_path(relative, ignore_patterns)
2758}
2759
2760fn is_git_or_heddle_dir(path: &Path) -> bool {
2761 path.file_name()
2762 .and_then(|name| name.to_str())
2763 .is_some_and(|name| name == ".git" || name == ".heddle")
2764}
2765
2766fn repo_relative_git_path(root: &Path, path: &Path) -> Result<String> {
2767 let relative = path.strip_prefix(root).map_err(|error| {
2768 HeddleError::Config(format!(
2769 "failed to relativize Git worktree path '{}': {}",
2770 path.display(),
2771 error
2772 ))
2773 })?;
2774 Ok(path_to_git_path(relative))
2775}
2776
2777fn path_to_git_path(path: &Path) -> String {
2778 path.components()
2779 .map(|component| component.as_os_str().to_string_lossy())
2780 .collect::<Vec<_>>()
2781 .join("/")
2782}
2783
2784fn git_path(path: &[u8]) -> String {
2785 String::from_utf8_lossy(path).into_owned()
2786}
2787
2788fn ignored_git_overlay_status_path(path: &str) -> bool {
2789 path == ".heddle" || path.starts_with(".heddle/")
2790}
2791
2792fn git_remote_names(root: &Path) -> Result<Vec<String>> {
2793 let repo = match SleyRepository::discover(root) {
2794 Ok(repo) => repo,
2795 Err(_) => return Ok(Vec::new()),
2796 };
2797 repo.remote_names()
2798 .map(|names| {
2799 names
2800 .into_iter()
2801 .filter(|name| !name.trim().is_empty())
2802 .collect()
2803 })
2804 .map_err(|error| HeddleError::Config(error.to_string()))
2805}
2806
2807fn git_find_reference(repo: &SleyRepository, name: &str) -> Result<Option<SleyReference>> {
2808 repo.find_reference(name).map_err(|error| {
2809 HeddleError::Config(format!("failed to inspect Git reference '{name}': {error}"))
2810 })
2811}
2812
2813fn git_resolve_oid(repo: &SleyRepository, rev: &str) -> Result<Option<SleyObjectId>> {
2814 match repo.rev_parse(rev) {
2815 Ok(id) => Ok(Some(id)),
2816 Err(_) => Ok(None),
2817 }
2818}
2819
2820fn git_configured_tracking_ref(repo: &SleyRepository, branch: &str) -> Result<Option<String>> {
2821 let config = repo
2822 .config_snapshot()
2823 .map_err(|error| HeddleError::Config(error.to_string()))?;
2824 let Some(remote) = config.get("branch", Some(branch), "remote") else {
2825 return Ok(None);
2826 };
2827 let Some(merge) = config.get("branch", Some(branch), "merge") else {
2828 return Ok(None);
2829 };
2830 if remote == "." {
2831 return Ok(Some(merge.to_string()));
2832 }
2833 let Some(short) = merge.strip_prefix("refs/heads/") else {
2834 return Ok(None);
2835 };
2836 Ok(Some(format!("refs/remotes/{remote}/{short}")))
2837}
2838
2839fn git_ahead_behind(
2840 root: &Path,
2841 repo: &SleyRepository,
2842 upstream: SleyObjectId,
2843 head: SleyObjectId,
2844) -> Result<(usize, usize)> {
2845 if upstream == head {
2846 return Ok((0, 0));
2847 }
2848 let ahead = git_reachable_count(root, repo, head, upstream)?;
2849 let behind = git_reachable_count(root, repo, upstream, head)?;
2850 Ok((ahead, behind))
2851}
2852
2853fn git_reachable_count(
2854 root: &Path,
2855 repo: &SleyRepository,
2856 tip: SleyObjectId,
2857 hidden: SleyObjectId,
2858) -> Result<usize> {
2859 let hidden = git_ancestor_set(root, repo, hidden)?;
2860 let mut seen = std::collections::HashSet::new();
2861 let mut pending = vec![tip];
2862 let mut count = 0;
2863 while let Some(oid) = pending.pop() {
2864 if hidden.contains(&oid) || !seen.insert(oid) {
2865 continue;
2866 }
2867 count += 1;
2868 let commit = repo.read_commit(&oid).map_err(|error| {
2869 HeddleError::Config(format!(
2870 "failed to inspect Git upstream drift at '{}': {error}",
2871 root.display()
2872 ))
2873 })?;
2874 pending.extend(commit.parents);
2875 }
2876 Ok(count)
2877}
2878
2879fn git_ancestor_set(
2880 root: &Path,
2881 repo: &SleyRepository,
2882 start: SleyObjectId,
2883) -> Result<std::collections::HashSet<SleyObjectId>> {
2884 let mut seen = std::collections::HashSet::new();
2885 let mut pending = vec![start];
2886 while let Some(oid) = pending.pop() {
2887 if !seen.insert(oid) {
2888 continue;
2889 }
2890 let commit = repo.read_commit(&oid).map_err(|error| {
2891 HeddleError::Config(format!(
2892 "failed to inspect Git upstream drift at '{}': {error}",
2893 root.display()
2894 ))
2895 })?;
2896 pending.extend(commit.parents);
2897 }
2898 Ok(seen)
2899}
2900
2901fn git_remote_tracking_display_name(name: &str) -> String {
2902 name.strip_prefix("refs/remotes/")
2903 .unwrap_or(name)
2904 .to_string()
2905}
2906
2907fn git_remote_tracking_message(
2908 branch: &str,
2909 upstream: &str,
2910 ahead: usize,
2911 behind: usize,
2912 upstream_is_undone_checkpoint: bool,
2913) -> String {
2914 if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
2915 return format!(
2916 "Upstream '{upstream}' still points at a Git commit that was undone locally on branch '{branch}'"
2917 );
2918 }
2919 match (ahead, behind) {
2920 (0, behind) => format!(
2921 "Git branch '{}' is behind upstream '{}' by {} commit(s)",
2922 branch, upstream, behind
2923 ),
2924 (ahead, 0) => format!(
2925 "Git branch '{}' is ahead of upstream '{}' by {} commit(s)",
2926 branch, upstream, ahead
2927 ),
2928 (ahead, behind) => format!(
2929 "Git branch '{}' has diverged from upstream '{}' (ahead {}, behind {})",
2930 branch, upstream, ahead, behind
2931 ),
2932 }
2933}
2934
2935fn git_remote_tracking_next_action(
2936 ahead: usize,
2937 behind: usize,
2938 upstream_is_undone_checkpoint: bool,
2939) -> String {
2940 if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
2941 return "heddle push --force".to_string();
2942 }
2943 match (ahead, behind) {
2944 (0, _) => "heddle pull".to_string(),
2945 (_, 0) => "heddle push".to_string(),
2946 _ => "heddle pull".to_string(),
2947 }
2948}
2949
2950fn repository_capability_for_root(root: &Path) -> RepositoryCapability {
2951 if has_git_metadata(root) {
2952 RepositoryCapability::GitOverlay
2953 } else {
2954 RepositoryCapability::NativeHeddle
2955 }
2956}
2957
2958fn append_ignore_file_patterns(patterns: &mut Vec<String>, path: &Path) -> Result<()> {
2959 if !path.exists() {
2960 return Ok(());
2961 }
2962 let contents = std::fs::read_to_string(path)?;
2963 for line in contents.lines() {
2964 let trimmed = line.trim();
2965 if trimmed.is_empty() || trimmed.starts_with('#') {
2966 continue;
2967 }
2968 if !patterns.iter().any(|pattern| pattern == trimmed) {
2969 patterns.push(trimmed.to_string());
2970 }
2971 }
2972 Ok(())
2973}
2974
2975fn detect_git_head_state_via_sley(path: &Path) -> Result<Option<GitHeadState>> {
2979 let repo = SleyRepository::discover(path).map_err(|error| {
2980 HeddleError::Config(format!(
2981 "failed to inspect git repository at '{}': {}",
2982 path.display(),
2983 error
2984 ))
2985 })?;
2986 let head = match repo.head() {
2987 Ok(head) => head,
2988 Err(_) => return Ok(None),
2989 };
2990
2991 if let Some(name) = head.branch_name() {
2992 return Ok(Some(GitHeadState::Attached(name.to_string())));
2993 }
2994 if head.is_detached()
2995 && let Some(id) = head.oid
2996 {
2997 return Ok(Some(GitHeadState::Detached(id)));
2998 }
2999 Ok(None)
3000}
3001
3002fn detect_git_head_state(path: &Path) -> Result<Option<GitHeadState>> {
3003 if let Some(head) = detect_git_head_fast(path) {
3004 return Ok(Some(head));
3005 }
3006 detect_git_head_state_via_sley(path)
3007}
3008
3009fn detect_git_head(path: &Path) -> Result<Option<Head>> {
3019 if let Some(GitHeadState::Attached(thread)) = detect_git_head_state(path)? {
3020 return Ok(Some(Head::Attached {
3021 thread: ThreadName::from(thread),
3022 }));
3023 }
3024 Ok(None)
3025}
3026
3027fn detect_git_head_fast(path: &Path) -> Option<GitHeadState> {
3033 let head_path = path.join(".git").join("HEAD");
3034 if !head_path.is_file() {
3037 return None;
3038 }
3039 let content = std::fs::read_to_string(&head_path).ok()?;
3040 let trimmed = content.trim();
3041 let suffix = trimmed.strip_prefix("ref: ")?;
3042 let name = suffix.strip_prefix("refs/heads/")?.to_string();
3043 if name.is_empty() {
3044 return None;
3045 }
3046 Some(GitHeadState::Attached(name))
3047}
3048
3049fn resolve_git_dir(path: &Path) -> Result<PathBuf> {
3050 let repo = SleyRepository::discover(path).map_err(|error| {
3051 HeddleError::Config(format!(
3052 "failed to resolve git dir at '{}': {}",
3053 path.display(),
3054 error
3055 ))
3056 })?;
3057 Ok(repo.git_dir().to_path_buf())
3058}
3059
3060fn detect_git_in_progress_branch(path: &Path) -> Result<Option<String>> {
3061 let git_dir = resolve_git_dir(path)?;
3062 for marker in ["rebase-merge/head-name", "rebase-apply/head-name"] {
3063 let branch_path = git_dir.join(marker);
3064 if !branch_path.exists() {
3065 continue;
3066 }
3067 let raw = fs::read_to_string(&branch_path)?;
3068 let value = raw.trim();
3069 if let Some(short) = value.strip_prefix("refs/heads/") {
3070 return Ok(Some(short.to_string()));
3071 }
3072 if !value.is_empty() {
3073 return Ok(Some(value.to_string()));
3074 }
3075 }
3076 Ok(None)
3077}