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::{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};
62#[cfg(feature = "async-source")]
63pub use commit_graph::{find_merge_base_async, is_ancestor_async};
64pub use context_suggestions::{
65 ContextSuggestion, ContextSuggestionTier, HIGH_SUGGESTION_THRESHOLD,
66 MAJOR_REWRITE_THRESHOLD_PCT, MEDIUM_SUGGESTION_THRESHOLD, SUGGESTION_WINDOW,
67 compute_rewrite_pct, is_major_rewrite,
68};
69pub use objects::object::DiffKind;
70use objects::{
71 error::{HeddleError, Result},
72 fs_atomic::write_file_atomic,
73 lock::{RepoLock, RepositoryLockExt},
74 object::{Attribution, ChangeId, ContentHash, MarkerName, Principal, State, ThreadName, Tree},
75 store::{AnyStore, FsStore, ObjectStore, ShallowInfo},
76 sync::RwLockExt,
77 worktree::WorktreeStatus,
78};
79use oplog::{OpLog, OpLogBackend, OpRecord};
80pub use refs::RefSummaryIndexInspection;
81use refs::{Head, RefBackend, RefExpectation, RefManager, RefUpdate};
82pub use repo_config::{HostedConfig, OutputFormat, RedactConfig, RepoConfig, TrustedKey};
83#[allow(unused_imports)]
87pub use repo_config::{
88 PatternDeviationToml, ReviewConfig, ReviewSignalsToml, SelfFlaggedToml, SignalEnableToml,
89 SignalModuleToml, TestReachabilityToml,
90};
91#[cfg(feature = "async-source")]
92pub use repository_history::query_history_async;
93pub use repository_history::{ChangedPathFilter, ChangedPathFilters, HistoryQuery};
94pub use repository_maintenance::{
95 ChangeMonitorInspection, CommitGraphInspection, PackFilesInspection, PartialFetchInspection,
96 PullPlannerCacheInspection, RefCountsInspection, RepositoryMaintenanceRunReport,
97 RepositoryPerformanceInspectionReport, WorktreeIndexInspection,
98};
99pub use repository_materialization::WarmCanonicalStoreStats;
100pub use repository_partial_fetch::MissingBlob;
101pub use repository_snapshot::{SnapshotExecution, SnapshotProfile};
102pub use repository_thread_materialize::{CheckoutMaterialization, ThreadCaptureOutcome};
103pub use repository_tree::{TreeBuildProfile, WorktreeCompareProfile};
104pub use repository_worktree_status::{UntrackedSet, UntrackedSubtree, WorktreeStatusDetailed};
105use rusqlite::{Connection, OpenFlags};
106use serde::{Deserialize, Serialize};
107use sley::{
108 ObjectId as SleyObjectId, Reference as SleyReference, ReferenceTarget as SleyRefTarget,
109 Repository as SleyRepository, ShortStatusOptions as SleyShortStatusOptions,
110 StatusUntrackedMode as SleyStatusUntrackedMode, StreamControl as SleyStreamControl,
111};
112
113const GIT_CHECKPOINTS_FILE: &str = "git-checkpoints.json";
114const GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS: &[&str] = &[".heddle/"];
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum RepositoryCapability {
118 GitOverlay,
119 NativeHeddle,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123enum GitHeadState {
124 Attached(String),
125 Detached(SleyObjectId),
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct GitCheckpointRecord {
130 pub change_id: String,
131 pub git_commit: String,
132 pub summary: String,
133 pub committed_at: String,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct GitOverlayImportHint {
138 pub current_branch: String,
139 pub missing_branch_count: usize,
140 pub missing_branches: Vec<String>,
141 pub recommended_command: String,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct GitOverlayBranchTip {
146 pub branch: String,
147 pub git_commit: String,
148 pub history_imported: bool,
149 #[serde(skip)]
150 pub mapped_change: Option<ChangeId>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct GitOverlayTagTip {
155 pub tag: String,
156 pub git_commit: String,
157 pub history_imported: bool,
158 #[serde(skip)]
159 pub mapped_change: Option<ChangeId>,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub struct GitOverlayOutOfBandCommits {
167 pub count: usize,
168 pub truncated: bool,
171}
172
173const GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT: usize = 1000;
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179#[serde(rename_all = "kebab-case")]
180pub enum OperationScope {
181 Git,
182 Heddle,
183}
184
185impl std::fmt::Display for OperationScope {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 match self {
188 Self::Git => write!(f, "git"),
189 Self::Heddle => write!(f, "heddle"),
190 }
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
195#[serde(rename_all = "kebab-case")]
196pub enum OperationKind {
197 Merge,
198 Rebase,
199 CherryPick,
200 Revert,
201 Bisect,
202}
203
204impl std::fmt::Display for OperationKind {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 match self {
207 Self::Merge => write!(f, "merge"),
208 Self::Rebase => write!(f, "rebase"),
209 Self::CherryPick => write!(f, "cherry-pick"),
210 Self::Revert => write!(f, "revert"),
211 Self::Bisect => write!(f, "bisect"),
212 }
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct RepositoryOperationStatus {
218 pub scope: OperationScope,
219 pub kind: OperationKind,
220 pub in_progress: bool,
221 pub state: String,
222 pub message: String,
223 pub next_action: String,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct GitRemoteTrackingStatus {
228 pub branch: String,
229 pub upstream: String,
230 pub ahead: usize,
231 pub behind: usize,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub local_oid: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub upstream_oid: Option<String>,
236 #[serde(default, skip_serializing_if = "is_false")]
237 pub upstream_is_undone_checkpoint: bool,
238 pub message: String,
239 pub next_action: String,
240}
241
242fn is_false(value: &bool) -> bool {
243 !*value
244}
245
246#[derive(Debug, Deserialize)]
247struct GitBridgeMappingEntry {
248 change_id: String,
249 git_oid: String,
250}
251
252#[derive(Debug, Deserialize, Default)]
253struct GitBridgeMappingFile {
254 entries: Vec<GitBridgeMappingEntry>,
255}
256
257pub trait BlobHydrator: Send + Sync {
278 fn hydrate(&self, repo: &Repository, hash: &ContentHash) -> Result<()>;
279}
280
281pub struct Repository<R = RefManager, O = OpLog, S = AnyStore>
295where
296 R: RefBackend,
297 O: OpLogBackend,
298 S: ObjectStore,
299{
300 root: PathBuf,
301 heddle_dir: PathBuf,
302 capability: RepositoryCapability,
303 store: S,
304 refs: R,
305 oplog: O,
306 config: RepoConfig,
307 shallow: RwLock<ShallowInfo>,
308 blob_hydrator: RwLock<Option<Arc<dyn BlobHydrator>>>,
309 git_overlay_repo: RwLock<Option<SleyRepository>>,
310}
311
312impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> RepositoryLockExt for Repository<R, O, S> {
313 fn locker(&self) -> RepoLock {
314 let lock_root = self.heddle_dir.parent().expect(
315 "heddle_dir has no parent component; cannot determine lock root. This indicates a misconfigured repository.",
316 );
317 RepoLock::new(lock_root)
318 }
319}
320
321impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> Repository<R, O, S> {
322 pub fn from_parts(
331 root: PathBuf,
332 heddle_dir: PathBuf,
333 store: S,
334 refs: R,
335 oplog: O,
336 config: RepoConfig,
337 shallow: ShallowInfo,
338 ) -> Self {
339 let capability = repository_capability_for_root(&root);
340 Self {
341 root,
342 heddle_dir,
343 capability,
344 store,
345 refs,
346 oplog,
347 config,
348 shallow: RwLock::new(shallow),
349 blob_hydrator: RwLock::new(None),
350 git_overlay_repo: RwLock::new(None),
351 }
352 }
353
354 pub fn store(&self) -> &S {
356 &self.store
357 }
358
359 pub fn refs(&self) -> &R {
361 &self.refs
362 }
363
364 pub fn oplog(&self) -> &O {
366 &self.oplog
367 }
368}
369
370pub(crate) fn compute_op_scope(root: &Path) -> String {
381 let local_head = root.join(".heddle").join("HEAD");
382 let canonical = local_head.canonicalize().unwrap_or(local_head);
383 let digest = blake3::hash(canonical.to_string_lossy().as_bytes());
384 format!("wt-{}", &digest.to_hex().as_str()[..16])
385}
386
387fn ensure_supported_repo_format(config_path: &Path, config: &RepoConfig) -> Result<()> {
388 let found = config.repository.version;
389 let supported = repo_config::SUPPORTED_REPO_FORMAT;
390 if found > supported {
391 return Err(HeddleError::RepositoryFormatTooNew {
392 path: config_path.to_path_buf(),
393 found,
394 supported,
395 });
396 }
397 Ok(())
398}
399
400impl<S: ObjectStore> Repository<RefManager, OpLog, S> {
401 fn open_raw(
402 root: PathBuf,
403 heddle_dir: PathBuf,
404 store: S,
405 config: RepoConfig,
406 refs: RefManager,
407 ) -> Result<Self> {
408 let actor = config
409 .principal
410 .as_ref()
411 .map(|p| objects::object::Principal::new(&p.name, &p.email))
412 .unwrap_or_else(|| objects::object::Principal::new("<unknown>", ""));
413 let oplog = OpLog::new(&heddle_dir, actor.clone());
414 let shallow = ShallowInfo::load(&heddle_dir)?;
415 let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
419 &heddle_dir,
420 compute_op_scope(&root),
421 ));
422 let committer =
423 std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(&heddle_dir, actor));
424 let refs = refs.with_reconciler(reconciler).with_committer(committer);
425 refs.init_reconcile_watermark()?;
430 Ok(Self::from_parts(
431 root, heddle_dir, store, refs, oplog, config, shallow,
432 ))
433 }
434
435 pub fn open_with_store(heddle_dir: impl AsRef<Path>, store: S) -> Result<Self> {
442 let heddle_dir = heddle_dir.as_ref().to_path_buf();
443 let root = heddle_dir
444 .parent()
445 .ok_or_else(|| {
446 HeddleError::Config(format!(
447 "heddle_dir '{}' has no parent directory",
448 heddle_dir.display()
449 ))
450 })?
451 .to_path_buf();
452 let config_path = heddle_dir.join("config.toml");
453 let config = RepoConfig::load(&config_path)?;
454 ensure_supported_repo_format(&config_path, &config)?;
455 let refs = RefManager::new(&heddle_dir);
456 Self::open_raw(root, heddle_dir, store, config, refs)
457 }
458}
459
460impl Repository {
461 fn run_open_hooks(&self) {
467 if let Err(err) = crate::migration::apply_pending(self) {
472 tracing::warn!("declarative migrations failed during repo open: {err}");
473 }
474 match crate::lazy_hydrator::try_reconstruct(self.root(), self.heddle_dir()) {
482 Ok(Some(hydrator)) => self.set_blob_hydrator(hydrator),
483 Ok(None) => {}
484 Err(err) => {
485 tracing::warn!("lazy hydrator reconstruction failed during open: {err}");
492 }
493 }
494 }
495
496 fn build_store(config: &RepoConfig, heddle_dir: &Path) -> Result<AnyStore> {
501 let _ = config;
502 Ok(AnyStore::Fs(FsStore::new(heddle_dir)))
503 }
504
505 pub fn init(path: impl AsRef<Path>) -> Result<Self> {
514 let root = path.as_ref().to_path_buf();
515 let heddle_dir = root.join(".heddle");
516
517 if heddle_dir.exists() {
518 return Err(HeddleError::RepositoryExists(root));
519 }
520
521 fs::create_dir_all(&heddle_dir)?;
522
523 let store = FsStore::new(&heddle_dir);
524 store.init()?;
525
526 let refs = RefManager::new(&heddle_dir);
527 refs.init()?;
528
529 let oplog = OpLog::new_unattributed(&heddle_dir);
534 oplog.init()?;
535
536 let config = RepoConfig::default();
537 config.save(&heddle_dir.join("config.toml"))?;
538
539 refs.write_head(&Head::Attached {
540 thread: ThreadName::from("main"),
541 })?;
542
543 let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
547 &heddle_dir,
548 compute_op_scope(&root),
549 ));
550 let committer = std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(
551 &heddle_dir,
552 objects::object::Principal::new("<unknown>", ""),
553 ));
554 let refs = refs.with_reconciler(reconciler).with_committer(committer);
555 refs.init_reconcile_watermark()?;
559
560 let capability = repository_capability_for_root(&root);
561 Ok(Self {
562 root,
563 heddle_dir: heddle_dir.clone(),
564 capability,
565 store: AnyStore::Fs(store),
566 refs,
567 oplog,
568 config,
569 shallow: RwLock::new(ShallowInfo::load(&heddle_dir)?),
570 blob_hydrator: RwLock::new(None),
571 git_overlay_repo: RwLock::new(None),
572 })
573 }
574
575 pub fn init_default(path: impl AsRef<Path>) -> Result<Self> {
581 let repo = Self::init(path)?;
582 repo.seed_default_thread()?;
583 Ok(repo)
584 }
585
586 pub fn bootstrap_git_overlay(path: impl AsRef<Path>) -> Result<Self> {
593 let root = path.as_ref();
594 if root.join(".heddle").exists() {
595 ensure_git_overlay_exclude(root)?;
596 return Self::open(root);
597 }
598
599 let repo = Self::init(root)?;
600 ensure_git_overlay_exclude(root)?;
601 if let Some(head) = detect_git_head(root)? {
602 repo.refs.write_head(&head)?;
603 }
604 Ok(repo)
605 }
606
607 pub fn ensure_git_overlay_local_excludes(path: impl AsRef<Path>) -> Result<()> {
611 ensure_git_overlay_exclude(path.as_ref())
612 }
613
614 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
626 let start_path = path.as_ref().canonicalize()?;
627 if let Some(mount_root) = metadataless_managed_thread_root(&start_path) {
637 return Err(HeddleError::Config(format!(
638 "'{}' is a Heddle-managed virtualized thread mount with no checkout \
639 metadata of its own; refusing to operate on the parent repository from \
640 inside it. Run heddle from the repository root, or use a solid/materialized \
641 thread checkout.",
642 mount_root.display()
643 )));
644 }
645 let mut discovered_git_root = None;
646
647 let mut current = Some(start_path.as_path());
648 while let Some(dir) = current {
649 if discovered_git_root.is_none() && has_git_metadata(dir) {
650 discovered_git_root = Some(dir.to_path_buf());
651 }
652 let heddle_path = dir.join(".heddle");
653
654 if heddle_path.is_dir() {
655 if let Some(git_root) = discovered_git_root.as_ref()
656 && git_root != dir
657 && git_root.starts_with(dir)
658 && !git_root.join(".heddle").exists()
659 {
660 ensure_git_overlay_exclude(git_root)?;
661 Self::bootstrap_git_overlay(git_root)?;
662 return Self::open(git_root);
663 }
664 let pointer_path = heddle_path.join("objectstore");
665 let objects_dir = heddle_path.join("objects");
666
667 if pointer_path.is_file() {
668 let content = fs::read_to_string(&pointer_path)?;
671 let raw_shared = parse_objectstore_pointer(&content).ok_or_else(|| {
672 HeddleError::Config(format!(
673 "invalid .heddle/objectstore pointer at {}: expected 'objectstore: <path>'",
674 pointer_path.display()
675 ))
676 })?;
677
678 if raw_shared.is_relative() {
679 return Err(HeddleError::Config(format!(
680 ".heddle/objectstore pointer at {} contains a relative path '{}'; \
681 objectstore path must be absolute",
682 pointer_path.display(),
683 raw_shared.display()
684 )));
685 }
686
687 let shared_galeed_dir = raw_shared.canonicalize().map_err(|e| {
688 HeddleError::Config(format!(
689 ".heddle/objectstore pointer at {} points to non-existent path '{}': {}",
690 pointer_path.display(),
691 raw_shared.display(),
692 e
693 ))
694 })?;
695
696 if !shared_galeed_dir.join("objects").is_dir() {
697 return Err(HeddleError::Config(format!(
698 ".heddle/objectstore pointer at {} resolves to '{}' which does not \
699 contain an 'objects/' directory; not a valid Heddle store",
700 pointer_path.display(),
701 shared_galeed_dir.display()
702 )));
703 }
704
705 let config_path = shared_galeed_dir.join("config.toml");
706 let config = RepoConfig::load(&config_path)?;
707 ensure_supported_repo_format(&config_path, &config)?;
708 let store = Self::build_store(&config, &shared_galeed_dir)?;
709 let local_head_path = heddle_path.join("HEAD");
710 let refs = RefManager::new(&shared_galeed_dir).with_local_head(local_head_path);
711 let repo =
712 Self::open_raw(dir.to_path_buf(), shared_galeed_dir, store, config, refs)?;
713 repo.run_open_hooks();
714 return Ok(repo);
715 }
716
717 if objects_dir.is_dir() {
718 let config_path = heddle_path.join("config.toml");
720 let config = RepoConfig::load(&config_path)?;
721 ensure_supported_repo_format(&config_path, &config)?;
722 let store = Self::build_store(&config, &heddle_path)?;
723 let refs = RefManager::new(&heddle_path);
724 let repo = Self::open_raw(dir.to_path_buf(), heddle_path, store, config, refs)?;
725 repo.run_open_hooks();
726 if repo.capability() == RepositoryCapability::GitOverlay {
727 match detect_git_head_state(dir) {
728 Ok(Some(GitHeadState::Attached(thread))) => {
729 let git_head = Head::Attached {
730 thread: ThreadName::from(thread),
731 };
732 let stale = match (repo.refs.read_head(), &git_head) {
749 (Ok(Head::Detached { state }), Head::Attached { thread }) => {
750 match repo.refs.get_thread(thread) {
751 Ok(Some(tip)) => tip == state,
752 _ => false,
753 }
754 }
755 (Ok(Head::Detached { .. }), _) => false,
756 (Ok(current), _) => current != git_head,
757 (Err(_), _) => true,
758 };
759 if stale {
760 repo.refs.write_head(&git_head)?;
761 }
762 }
763 Ok(Some(GitHeadState::Detached(git_oid))) => {
764 if let Ok(Some(state)) =
765 repo.git_overlay_mapped_change_for_git_oid(git_oid)
766 {
767 let git_head = Head::Detached { state };
768 let stale = match repo.refs.read_head() {
769 Ok(current) => current != git_head,
770 Err(_) => true,
771 };
772 if stale {
773 repo.refs.write_head(&git_head)?;
774 }
775 }
776 }
777 Ok(None) | Err(_) => {}
778 }
779 }
780 return Ok(repo);
781 }
782
783 }
786
787 current = dir.parent();
788 }
789
790 if let Some(git_root) = discovered_git_root {
791 ensure_git_overlay_exclude(&git_root)?;
792 Self::bootstrap_git_overlay(&git_root)?;
793 return Self::open(git_root);
794 }
795
796 Err(HeddleError::RepositoryNotFound(path.as_ref().to_path_buf()))
797 }
798
799 pub fn root(&self) -> &Path {
800 &self.root
801 }
802
803 pub fn heddle_dir(&self) -> &Path {
804 &self.heddle_dir
805 }
806
807 pub fn managed_checkout_source_root(&self) -> &Path {
816 self.heddle_dir.parent().unwrap_or(self.root.as_path())
817 }
818
819 pub fn managed_checkout_path(&self, thread: &str) -> PathBuf {
821 crate::thread_manifest::managed_checkout_path(
822 &self.heddle_dir,
823 thread,
824 self.managed_checkout_source_root(),
825 )
826 }
827
828 pub fn capability(&self) -> RepositoryCapability {
829 self.capability
830 }
831
832 pub fn git_overlay_sley_repository(&self) -> Result<Option<SleyRepository>> {
833 if self.capability() != RepositoryCapability::GitOverlay {
834 return Ok(None);
835 }
836
837 if let Some(repo) = self
838 .git_overlay_repo
839 .read()
840 .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?
841 .clone()
842 {
843 return Ok(Some(repo));
844 }
845
846 let mut cached = self
847 .git_overlay_repo
848 .write()
849 .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?;
850 if let Some(repo) = cached.clone() {
851 return Ok(Some(repo));
852 }
853
854 let repo = SleyRepository::discover(&self.root).map_err(|error| {
855 HeddleError::Config(format!(
856 "failed to inspect Git repository at '{}': {}",
857 self.root.display(),
858 error
859 ))
860 })?;
861 *cached = Some(repo.clone());
862 Ok(Some(repo))
863 }
864
865 pub fn capability_label(&self) -> &'static str {
866 match self.capability() {
867 RepositoryCapability::GitOverlay => "git-overlay",
868 RepositoryCapability::NativeHeddle => "native-heddle",
869 }
870 }
871
872 pub fn storage_model_label(&self) -> &'static str {
873 match self.capability() {
874 RepositoryCapability::GitOverlay => "git+heddle-sidecar",
875 RepositoryCapability::NativeHeddle => "heddle-native",
876 }
877 }
878
879 pub fn hosted_enabled(&self) -> bool {
880 self.config
881 .hosted
882 .upstream_url
883 .as_deref()
884 .is_some_and(|value| !value.trim().is_empty())
885 || self
886 .config
887 .hosted
888 .namespace
889 .as_deref()
890 .is_some_and(|value| !value.trim().is_empty())
891 }
892
893 pub fn current_lane(&self) -> Result<Option<String>> {
894 if self.capability() == RepositoryCapability::GitOverlay
895 && self.git_overlay_head_is_detached()?
896 && detect_git_in_progress_branch(&self.root)?.is_none()
897 {
898 return Ok(None);
899 }
900
901 if self.current_state()?.is_none() && self.capability() == RepositoryCapability::GitOverlay
902 {
903 return self.git_overlay_current_branch();
904 }
905
906 match self.head_ref()? {
907 Head::Attached { thread } => Ok(Some(thread.to_string())),
908 Head::Detached { .. } => Ok(None),
909 }
910 }
911
912 pub fn operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
913 if let Some(status) = self.heddle_operation_status()? {
914 return Ok(Some(status));
915 }
916 self.git_operation_status()
917 }
918
919 pub fn git_remote_tracking_status(&self) -> Result<Option<GitRemoteTrackingStatus>> {
920 if self.capability() != RepositoryCapability::GitOverlay {
921 return Ok(None);
922 }
923
924 let branch = match self.git_overlay_current_branch()? {
925 Some(branch) => branch,
926 None => return Ok(None),
927 };
928
929 let Some(git) = self.git_overlay_sley_repository()? else {
930 return Ok(None);
931 };
932 let Some(head) = git_resolve_oid(&git, "HEAD")? else {
933 return Ok(None);
934 };
935
936 let local_ref_name = format!("refs/heads/{branch}");
937 if git_find_reference(&git, &local_ref_name)?.is_some()
938 && let Some(tracking_name) = git_configured_tracking_ref(&git, &branch)?
939 && let Some(upstream_head) = git_resolve_oid(&git, &tracking_name)?
940 {
941 let (ahead, behind) = git_ahead_behind(&self.root, &git, upstream_head, head)?;
942 if ahead == 0 && behind == 0 {
943 return Ok(None);
944 }
945 let upstream = git_remote_tracking_display_name(&tracking_name);
946 let local_oid = head.to_string();
947 let upstream_oid = upstream_head.to_string();
948 let upstream_is_undone_checkpoint =
949 self.remote_tracks_undone_git_checkpoint(&branch, &local_oid, &upstream_oid)?;
950 return Ok(Some(GitRemoteTrackingStatus {
951 branch: branch.clone(),
952 upstream: upstream.clone(),
953 ahead,
954 behind,
955 local_oid: Some(local_oid),
956 upstream_oid: Some(upstream_oid),
957 upstream_is_undone_checkpoint,
958 message: git_remote_tracking_message(
959 &branch,
960 &upstream,
961 ahead,
962 behind,
963 upstream_is_undone_checkpoint,
964 ),
965 next_action: git_remote_tracking_next_action(
966 ahead,
967 behind,
968 upstream_is_undone_checkpoint,
969 ),
970 }));
971 }
972
973 let remotes = git_remote_names(&self.root)?;
974 if remotes.is_empty() {
975 return Ok(None);
976 }
977 for remote in &remotes {
978 let remote_ref = format!("refs/remotes/{remote}/{branch}");
979 if let Some(remote_head) = git_resolve_oid(&git, &remote_ref)? {
980 if remote_head == head {
981 return Ok(None);
982 }
983 let (ahead, behind) = git_ahead_behind(&self.root, &git, remote_head, head)?;
984 if behind > 0 {
985 let upstream = format!("{remote}/{branch}");
986 let local_oid = head.to_string();
987 let upstream_oid = remote_head.to_string();
988 let upstream_is_undone_checkpoint = self.remote_tracks_undone_git_checkpoint(
989 &branch,
990 &local_oid,
991 &upstream_oid,
992 )?;
993 return Ok(Some(GitRemoteTrackingStatus {
994 branch: branch.clone(),
995 upstream: upstream.clone(),
996 ahead,
997 behind,
998 local_oid: Some(local_oid),
999 upstream_oid: Some(upstream_oid),
1000 upstream_is_undone_checkpoint,
1001 message: git_remote_tracking_message(
1002 &branch,
1003 &upstream,
1004 ahead,
1005 behind,
1006 upstream_is_undone_checkpoint,
1007 ),
1008 next_action: git_remote_tracking_next_action(
1009 ahead,
1010 behind,
1011 upstream_is_undone_checkpoint,
1012 ),
1013 }));
1014 }
1015 }
1016 }
1017
1018 Ok(Some(GitRemoteTrackingStatus {
1019 branch: branch.clone(),
1020 upstream: String::new(),
1021 ahead: 0,
1022 behind: 0,
1023 local_oid: Some(head.to_string()),
1024 upstream_oid: None,
1025 upstream_is_undone_checkpoint: false,
1026 message: format!("Git branch '{branch}' has no upstream tracking branch"),
1027 next_action: "heddle push".to_string(),
1028 }))
1029 }
1030
1031 fn remote_tracks_undone_git_checkpoint(
1032 &self,
1033 branch: &str,
1034 local_oid: &str,
1035 upstream_oid: &str,
1036 ) -> Result<bool> {
1037 let scope = self.op_scope();
1038 let batches = match self.oplog().redo_batches_scoped(64, Some(&scope)) {
1039 Ok(batches) => batches,
1040 Err(error) => {
1041 tracing::warn!(
1042 branch,
1043 local_oid,
1044 upstream_oid,
1045 error = %error,
1046 "could not inspect redo oplog for undone Git checkpoint status"
1047 );
1048 return Ok(false);
1049 }
1050 };
1051 Ok(batches.iter().any(|batch| {
1052 batch.entries.iter().any(|entry| {
1053 if !entry.undone {
1054 return false;
1055 }
1056 matches!(
1057 &entry.operation,
1058 OpRecord::GitCheckpoint {
1059 branch: checkpoint_branch,
1060 previous_git_oid: Some(previous_git_oid),
1061 new_git_oid,
1062 ..
1063 } if checkpoint_branch == branch
1064 && previous_git_oid == local_oid
1065 && new_git_oid == upstream_oid
1066 )
1067 })
1068 }))
1069 }
1070
1071 pub fn git_overlay_import_hint(&self) -> Result<Option<GitOverlayImportHint>> {
1072 if self.capability() != RepositoryCapability::GitOverlay {
1073 return Ok(None);
1074 }
1075 Ok(None)
1080 }
1081
1082 pub fn git_overlay_branch_tips(&self) -> Result<Vec<GitOverlayBranchTip>> {
1083 if self.capability() != RepositoryCapability::GitOverlay {
1084 return Ok(Vec::new());
1085 }
1086
1087 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1088 return Ok(Vec::new());
1089 };
1090
1091 let imported_threads: std::collections::HashSet<ThreadName> =
1092 self.refs().list_threads()?.into_iter().collect();
1093 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1094 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1095 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1096 let mut branch_tips = Vec::new();
1097
1098 for branch in git_repo.references().list_refs().map_err(|error| {
1099 HeddleError::Config(format!(
1100 "failed to enumerate git branches at '{}': {}",
1101 self.root.display(),
1102 error
1103 ))
1104 })? {
1105 let Some(name) = branch.name.strip_prefix("refs/heads/") else {
1106 continue;
1107 };
1108 let name = name.to_string();
1109 let Some(target) =
1110 self.git_overlay_commit_tip_oid(&git_repo, &branch, "branch", &name)?
1111 else {
1112 continue;
1113 };
1114 let git_commit = target.to_string();
1115 let mapped_change = self.git_overlay_mapped_change_for_commit(
1116 &git_commit,
1117 &bridge_mapping,
1118 &ingest_mapping,
1119 &checkpoint_mapping,
1120 )?;
1121 let thread_name = ThreadName::from(name.as_str());
1122 let history_imported = if imported_threads.contains(&thread_name) {
1123 let existing_thread = self.refs().get_thread(&thread_name)?;
1127 let mapped = matches!(
1128 (existing_thread.as_ref(), mapped_change.as_ref()),
1129 (Some(existing), Some(mapped_change))
1130 if existing == mapped_change
1131 );
1132 let checkpointed = if mapped {
1133 false
1134 } else if let Some(existing) = existing_thread {
1135 self.latest_git_checkpoint_for_change(&existing)?
1136 .is_some_and(|record| record.git_commit == git_commit)
1137 || mapped_change.as_ref().is_some_and(|mapped_change| {
1138 self.change_is_ancestor(mapped_change, &existing)
1139 })
1140 } else {
1141 false
1142 };
1143 mapped || checkpointed
1144 } else {
1145 mapped_change.is_some()
1146 };
1147 branch_tips.push(GitOverlayBranchTip {
1148 branch: name,
1149 git_commit,
1150 history_imported,
1151 mapped_change,
1152 });
1153 }
1154 branch_tips.sort_by(|a, b| a.branch.cmp(&b.branch));
1155 Ok(branch_tips)
1156 }
1157
1158 pub fn git_overlay_tag_tips(&self) -> Result<Vec<GitOverlayTagTip>> {
1159 if self.capability() != RepositoryCapability::GitOverlay {
1160 return Ok(Vec::new());
1161 }
1162
1163 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1164 return Ok(Vec::new());
1165 };
1166
1167 let imported_markers: std::collections::HashSet<MarkerName> =
1168 self.refs().list_markers()?.into_iter().collect();
1169 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1170 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1171 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1172 let mut tag_tips = Vec::new();
1173
1174 for tag in git_repo.references().list_refs().map_err(|error| {
1175 HeddleError::Config(format!(
1176 "failed to enumerate git tags at '{}': {}",
1177 self.root.display(),
1178 error
1179 ))
1180 })? {
1181 let Some(name) = tag.name.strip_prefix("refs/tags/") else {
1182 continue;
1183 };
1184 let name = name.to_string();
1185 let Some(target) = self.git_overlay_commit_tip_oid(&git_repo, &tag, "tag", &name)?
1186 else {
1187 continue;
1188 };
1189 let git_commit = target.to_string();
1190 let mapped_change = self.git_overlay_mapped_change_for_commit(
1191 &git_commit,
1192 &bridge_mapping,
1193 &ingest_mapping,
1194 &checkpoint_mapping,
1195 )?;
1196 let marker_name = MarkerName::from(name.as_str());
1197 let history_imported = if imported_markers.contains(&marker_name) {
1198 matches!(
1199 (self.refs().get_marker(&marker_name)?, mapped_change.as_ref()),
1200 (Some(existing), Some(mapped_change)) if existing == *mapped_change
1201 )
1202 } else {
1203 false
1204 };
1205 tag_tips.push(GitOverlayTagTip {
1206 tag: name,
1207 git_commit,
1208 history_imported,
1209 mapped_change,
1210 });
1211 }
1212
1213 tag_tips.sort_by(|a, b| a.tag.cmp(&b.tag));
1214 Ok(tag_tips)
1215 }
1216
1217 pub fn git_overlay_branch_tip(&self, name: &str) -> Result<Option<GitOverlayBranchTip>> {
1218 Ok(self
1219 .git_overlay_branch_tips()?
1220 .into_iter()
1221 .find(|tip| tip.branch == name))
1222 }
1223
1224 pub fn git_overlay_tag_tip(&self, name: &str) -> Result<Option<GitOverlayTagTip>> {
1225 Ok(self
1226 .git_overlay_tag_tips()?
1227 .into_iter()
1228 .find(|tip| tip.tag == name))
1229 }
1230
1231 pub fn git_overlay_mapped_change_for_branch(&self, name: &str) -> Result<Option<ChangeId>> {
1232 Ok(self
1233 .git_overlay_branch_tip(name)?
1234 .and_then(|tip| tip.mapped_change))
1235 }
1236
1237 pub fn git_overlay_mapped_change_for_remote_tracking_ref(
1238 &self,
1239 name: &str,
1240 ) -> Result<Option<ChangeId>> {
1241 if self.capability() != RepositoryCapability::GitOverlay {
1242 return Ok(None);
1243 }
1244 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1245 return Ok(None);
1246 };
1247 let full_name = name
1248 .strip_prefix("refs/remotes/")
1249 .map(|short| format!("refs/remotes/{short}"))
1250 .unwrap_or_else(|| format!("refs/remotes/{name}"));
1251 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1252 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1253 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1254 for reference in git_repo.references().list_refs().map_err(|error| {
1255 HeddleError::Config(format!(
1256 "failed to enumerate git remote-tracking refs at '{}': {}",
1257 self.root.display(),
1258 error
1259 ))
1260 })? {
1261 if reference.name != full_name {
1262 continue;
1263 }
1264 let Some(target) =
1265 self.git_overlay_commit_tip_oid(&git_repo, &reference, "remote branch", name)?
1266 else {
1267 return Ok(None);
1268 };
1269 return self.git_overlay_mapped_change_for_commit(
1270 &target.to_string(),
1271 &bridge_mapping,
1272 &ingest_mapping,
1273 &checkpoint_mapping,
1274 );
1275 }
1276 Ok(None)
1277 }
1278
1279 pub fn git_overlay_mapped_change_for_tag(&self, name: &str) -> Result<Option<ChangeId>> {
1280 Ok(self
1281 .git_overlay_tag_tip(name)?
1282 .and_then(|tip| tip.mapped_change))
1283 }
1284
1285 fn change_is_ancestor(&self, ancestor: &ChangeId, descendant: &ChangeId) -> bool {
1286 let mut graph = CommitGraphIndex::new(self);
1287 graph.is_ancestor(ancestor, descendant).unwrap_or(false)
1288 }
1289
1290 pub fn git_overlay_worktree_status(&self) -> Result<Option<WorktreeStatus>> {
1304 if self.capability() != RepositoryCapability::GitOverlay {
1305 return Ok(None);
1306 }
1307 let git_repo = match self.git_overlay_sley_repository() {
1308 Ok(Some(repo)) => repo,
1309 Ok(None) | Err(_) => return Ok(None),
1310 };
1311 if git_repo.workdir().is_none() {
1312 return Ok(None);
1313 }
1314
1315 let mut added = BTreeSet::new();
1316 let mut modified = BTreeSet::new();
1317 let mut deleted = BTreeSet::new();
1318 let ignore_patterns = self.ignore_patterns()?;
1319 let ignore_matcher = crate::worktree_ignore::WorktreeIgnoreMatcher::new(&ignore_patterns);
1320
1321 git_repo
1322 .stream_short_status_with_options(
1323 SleyShortStatusOptions {
1324 untracked_mode: SleyStatusUntrackedMode::All,
1325 ..SleyShortStatusOptions::default()
1326 },
1327 |entry| {
1328 let path = git_path(entry.path);
1329 if ignored_git_overlay_status_path(&path) {
1330 return Ok(SleyStreamControl::Continue);
1331 }
1332 let path = PathBuf::from(path);
1333
1334 if entry.index == b'?' && entry.worktree == b'?' {
1335 if git_overlay_untracked_path_ignored(&ignore_matcher, &path) {
1336 return Ok(SleyStreamControl::Continue);
1337 }
1338 added.insert(path);
1339 } else if entry.index == b'D' || entry.worktree == b'D' {
1340 deleted.insert(path);
1341 } else if entry.index == b'A'
1342 || entry.index == b'R'
1343 || entry.index == b'C'
1344 || entry.head_oid.is_none()
1345 {
1346 added.insert(path);
1347 } else {
1348 modified.insert(path);
1349 }
1350
1351 Ok(SleyStreamControl::Continue)
1352 },
1353 )
1354 .map_err(|error| {
1355 HeddleError::Config(format!(
1356 "failed to inspect Git worktree status at '{}': {}",
1357 self.root.display(),
1358 error
1359 ))
1360 })?;
1361
1362 Ok(Some(WorktreeStatus {
1363 modified: modified.into_iter().collect(),
1364 added: added.into_iter().collect(),
1365 deleted: deleted.into_iter().collect(),
1366 }))
1367 }
1368
1369 fn git_overlay_bridge_mapping(&self) -> Result<HashMap<String, String>> {
1370 let path = self
1371 .heddle_dir
1372 .join("git-bridge")
1373 .join("bridge-mapping.json");
1374 if !path.exists() {
1375 return Ok(HashMap::new());
1376 }
1377
1378 let contents = fs::read_to_string(path)?;
1379 if contents.trim().is_empty() {
1380 return Ok(HashMap::new());
1381 }
1382
1383 let file: GitBridgeMappingFile = serde_json::from_str(&contents)?;
1384 Ok(file
1385 .entries
1386 .into_iter()
1387 .map(|entry| (entry.git_oid, entry.change_id))
1388 .collect())
1389 }
1390
1391 pub fn git_overlay_ingest_commit_mapping(&self) -> Result<HashMap<String, String>> {
1392 let path = self.heddle_dir.join("ingest").join("sha_map.sqlite");
1393 if !path.exists() {
1394 return Ok(HashMap::new());
1395 }
1396
1397 let conn = Connection::open_with_flags(
1398 &path,
1399 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1400 )
1401 .map_err(|error| {
1402 HeddleError::Config(format!(
1403 "failed to open ingest SHA map at '{}': {}",
1404 path.display(),
1405 error
1406 ))
1407 })?;
1408 let mut stmt = conn
1409 .prepare_cached("SELECT git_sha, heddle_repr FROM sha_map WHERE kind = 0")
1410 .map_err(|error| {
1411 HeddleError::Config(format!(
1412 "failed to read ingest SHA map at '{}': {}",
1413 path.display(),
1414 error
1415 ))
1416 })?;
1417 let rows = stmt
1418 .query_map([], |row| {
1419 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1420 })
1421 .map_err(|error| {
1422 HeddleError::Config(format!(
1423 "failed to enumerate ingest SHA map at '{}': {}",
1424 path.display(),
1425 error
1426 ))
1427 })?;
1428
1429 let mut mapping = HashMap::new();
1430 for row in rows {
1431 let (git_sha, change_id) = row.map_err(|error| {
1432 HeddleError::Config(format!(
1433 "failed to read ingest SHA map row at '{}': {}",
1434 path.display(),
1435 error
1436 ))
1437 })?;
1438 mapping.insert(git_sha, change_id);
1439 }
1440 Ok(mapping)
1441 }
1442
1443 fn git_overlay_checkpoint_mapping(&self) -> Result<HashMap<String, String>> {
1444 Ok(self
1445 .list_git_checkpoints()?
1446 .into_iter()
1447 .map(|record| (record.git_commit, record.change_id))
1448 .collect())
1449 }
1450
1451 fn git_overlay_mapped_change_for_commit(
1452 &self,
1453 git_commit: &str,
1454 bridge_mapping: &HashMap<String, String>,
1455 ingest_mapping: &HashMap<String, String>,
1456 checkpoint_mapping: &HashMap<String, String>,
1457 ) -> Result<Option<ChangeId>> {
1458 let Some(change) = bridge_mapping
1459 .get(git_commit)
1460 .or_else(|| ingest_mapping.get(git_commit))
1461 .or_else(|| checkpoint_mapping.get(git_commit))
1462 else {
1463 return Ok(None);
1464 };
1465 let change_id = ChangeId::parse(change).map_err(|error| {
1466 HeddleError::Config(format!(
1467 "git commit {git_commit} maps to invalid Heddle change id '{change}': {error}"
1468 ))
1469 })?;
1470 if self.store.get_state(&change_id)?.is_some() {
1471 Ok(Some(change_id))
1472 } else {
1473 Ok(None)
1474 }
1475 }
1476
1477 fn git_overlay_mapped_git_commit_for_change_in(
1478 &self,
1479 change_id: &ChangeId,
1480 mapping: &HashMap<String, String>,
1481 ) -> Result<Option<String>> {
1482 for (git_commit, mapped_change) in mapping {
1483 let mapped_change_id = ChangeId::parse(mapped_change).map_err(|error| {
1484 HeddleError::Config(format!(
1485 "git commit {git_commit} maps to invalid Heddle change id '{mapped_change}': {error}"
1486 ))
1487 })?;
1488 if mapped_change_id == *change_id {
1489 return Ok(Some(git_commit.clone()));
1490 }
1491 }
1492 Ok(None)
1493 }
1494
1495 pub fn git_overlay_mapped_git_commit_for_change(
1496 &self,
1497 change_id: &ChangeId,
1498 ) -> Result<Option<String>> {
1499 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1500 if let Some(git_commit) =
1501 self.git_overlay_mapped_git_commit_for_change_in(change_id, &bridge_mapping)?
1502 {
1503 return Ok(Some(git_commit));
1504 }
1505
1506 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1507 if let Some(git_commit) =
1508 self.git_overlay_mapped_git_commit_for_change_in(change_id, &ingest_mapping)?
1509 {
1510 return Ok(Some(git_commit));
1511 }
1512
1513 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1514 self.git_overlay_mapped_git_commit_for_change_in(change_id, &checkpoint_mapping)
1515 }
1516
1517 pub fn git_overlay_mapped_change_for_git_commit(
1518 &self,
1519 git_commit: &str,
1520 ) -> Result<Option<ChangeId>> {
1521 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1522 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1523 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1524 self.git_overlay_mapped_change_for_commit(
1525 git_commit,
1526 &bridge_mapping,
1527 &ingest_mapping,
1528 &checkpoint_mapping,
1529 )
1530 }
1531
1532 fn git_overlay_mapped_change_for_git_oid(
1533 &self,
1534 git_oid: SleyObjectId,
1535 ) -> Result<Option<ChangeId>> {
1536 self.git_overlay_mapped_change_for_git_commit(&git_oid.to_string())
1537 }
1538
1539 pub fn git_overlay_out_of_band_commits(
1548 &self,
1549 tip_git_commit: &str,
1550 ) -> Result<Option<GitOverlayOutOfBandCommits>> {
1551 if self.capability() != RepositoryCapability::GitOverlay {
1552 return Ok(None);
1553 }
1554 let git_repo = match self.git_overlay_sley_repository() {
1555 Ok(Some(repo)) => repo,
1556 Ok(None) | Err(_) => return Ok(None),
1557 };
1558 let Ok(tip) = SleyObjectId::from_hex(git_repo.object_format(), tip_git_commit) else {
1559 return Ok(None);
1560 };
1561
1562 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1563 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1564 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1565
1566 let mut pending = vec![tip];
1567 let mut visited = std::collections::HashSet::new();
1568 let mut count = 0usize;
1569 while let Some(oid) = pending.pop() {
1570 if !visited.insert(oid) {
1571 continue;
1572 }
1573 let git_commit = oid.to_string();
1574 if self
1575 .git_overlay_mapped_change_for_commit(
1576 &git_commit,
1577 &bridge_mapping,
1578 &ingest_mapping,
1579 &checkpoint_mapping,
1580 )?
1581 .is_some()
1582 {
1583 continue;
1585 }
1586 count += 1;
1587 if count >= GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT {
1588 return Ok(Some(GitOverlayOutOfBandCommits {
1589 count,
1590 truncated: true,
1591 }));
1592 }
1593 let Ok(commit) = git_repo.read_commit(&oid) else {
1594 continue;
1595 };
1596 for parent in commit.parents {
1597 pending.push(parent);
1598 }
1599 }
1600 Ok(Some(GitOverlayOutOfBandCommits {
1601 count,
1602 truncated: false,
1603 }))
1604 }
1605
1606 pub fn git_overlay_current_branch(&self) -> Result<Option<String>> {
1607 if self.capability() != RepositoryCapability::GitOverlay {
1608 return Ok(None);
1609 }
1610
1611 match detect_git_head_state(&self.root)? {
1612 Some(GitHeadState::Attached(branch)) => return Ok(Some(branch)),
1613 Some(GitHeadState::Detached(_)) | None => {}
1614 }
1615
1616 detect_git_in_progress_branch(&self.root)
1617 }
1618
1619 pub fn git_overlay_head_is_detached(&self) -> Result<bool> {
1620 if self.capability() != RepositoryCapability::GitOverlay {
1621 return Ok(false);
1622 }
1623
1624 Ok(matches!(
1625 detect_git_head_state(&self.root)?,
1626 Some(GitHeadState::Detached(_))
1627 ))
1628 }
1629
1630 pub fn git_overlay_detached_head_commit(&self) -> Result<Option<String>> {
1631 if self.capability() != RepositoryCapability::GitOverlay {
1632 return Ok(None);
1633 }
1634
1635 Ok(match detect_git_head_state(&self.root)? {
1636 Some(GitHeadState::Detached(git_oid)) => Some(git_oid.to_string()),
1637 Some(GitHeadState::Attached(_)) | None => None,
1638 })
1639 }
1640
1641 fn git_overlay_commit_tip_oid(
1642 &self,
1643 git_repo: &SleyRepository,
1644 reference: &sley::plumbing::sley_refs::Ref,
1645 ref_kind: &str,
1646 ref_name: &str,
1647 ) -> Result<Option<SleyObjectId>> {
1648 let target = match &reference.target {
1649 SleyRefTarget::Direct(oid) => *oid,
1650 SleyRefTarget::Symbolic(_) => return Ok(None),
1651 };
1652 let target = match sley::plumbing::sley_rev::peel_to_commit(
1653 git_repo.objects().as_ref(),
1654 git_repo.object_format(),
1655 &target,
1656 ) {
1657 Ok(target) => target,
1658 Err(_) => return Ok(None),
1659 };
1660
1661 let _ = (ref_kind, ref_name);
1662 Ok(Some(target))
1663 }
1664
1665 fn heddle_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1666 if self.merge_state_manager().is_merge_in_progress() {
1667 return Ok(Some(RepositoryOperationStatus {
1668 scope: OperationScope::Heddle,
1669 kind: OperationKind::Merge,
1670 in_progress: true,
1671 state: "in-progress".to_string(),
1672 message: "Heddle merge is in progress".to_string(),
1673 next_action: "heddle continue".to_string(),
1674 }));
1675 }
1676
1677 let rebase_state = self.heddle_dir.join("REBASE_STATE");
1678 if rebase_state.exists() {
1679 return Ok(Some(RepositoryOperationStatus {
1680 scope: OperationScope::Heddle,
1681 kind: OperationKind::Rebase,
1682 in_progress: true,
1683 state: "in-progress".to_string(),
1684 message: "Heddle rebase is in progress".to_string(),
1685 next_action: "heddle continue".to_string(),
1686 }));
1687 }
1688
1689 let bisect_state = self.heddle_dir.join("BISECT_STATE");
1690 if bisect_state.exists() {
1691 return Ok(Some(RepositoryOperationStatus {
1692 scope: OperationScope::Heddle,
1693 kind: OperationKind::Bisect,
1694 in_progress: true,
1695 state: "in-progress".to_string(),
1696 message: "Heddle bisect is in progress".to_string(),
1700 next_action: "heddle abort".to_string(),
1701 }));
1702 }
1703
1704 Ok(None)
1705 }
1706
1707 fn git_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1708 if self.capability() != RepositoryCapability::GitOverlay {
1709 return Ok(None);
1710 }
1711
1712 let git_dir = resolve_git_dir(&self.root)?;
1713 let raw_git_next_action = "heddle bridge git status";
1714 let candidates = [
1715 (
1716 git_dir.join("rebase-merge"),
1717 OperationKind::Rebase,
1718 "Git rebase is in progress",
1719 raw_git_next_action,
1720 ),
1721 (
1722 git_dir.join("rebase-apply"),
1723 OperationKind::Rebase,
1724 "Git rebase is in progress",
1725 raw_git_next_action,
1726 ),
1727 (
1728 git_dir.join("MERGE_HEAD"),
1729 OperationKind::Merge,
1730 "Git merge is in progress",
1731 raw_git_next_action,
1732 ),
1733 (
1734 git_dir.join("CHERRY_PICK_HEAD"),
1735 OperationKind::CherryPick,
1736 "Git cherry-pick is in progress",
1737 raw_git_next_action,
1738 ),
1739 (
1740 git_dir.join("REVERT_HEAD"),
1741 OperationKind::Revert,
1742 "Git revert is in progress",
1743 raw_git_next_action,
1744 ),
1745 (
1746 git_dir.join("BISECT_LOG"),
1747 OperationKind::Bisect,
1748 "Git bisect is in progress",
1749 raw_git_next_action,
1750 ),
1751 ];
1752
1753 for (path, kind, message, next_action) in candidates {
1754 if path.exists() {
1755 return Ok(Some(RepositoryOperationStatus {
1756 scope: OperationScope::Git,
1757 kind,
1758 in_progress: true,
1759 state: "in-progress".to_string(),
1760 message: message.to_string(),
1761 next_action: next_action.to_string(),
1762 }));
1763 }
1764 }
1765
1766 Ok(None)
1767 }
1768
1769 pub fn list_git_checkpoints(&self) -> Result<Vec<GitCheckpointRecord>> {
1770 let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1771 if !path.exists() {
1772 return Ok(Vec::new());
1773 }
1774 let contents = fs::read_to_string(path)?;
1775 if contents.trim().is_empty() {
1776 return Ok(Vec::new());
1777 }
1778 Ok(serde_json::from_str(&contents)?)
1779 }
1780
1781 pub fn latest_git_checkpoint_for_change(
1782 &self,
1783 change_id: &ChangeId,
1784 ) -> Result<Option<GitCheckpointRecord>> {
1785 let full_id = change_id.to_string_full();
1786 Ok(self
1787 .list_git_checkpoints()?
1788 .into_iter()
1789 .rev()
1790 .find(|record| record.change_id == full_id))
1791 }
1792
1793 pub fn record_git_checkpoint(
1794 &self,
1795 change_id: &ChangeId,
1796 git_commit: impl Into<String>,
1797 summary: impl Into<String>,
1798 ) -> Result<GitCheckpointRecord> {
1799 let mut records = self.list_git_checkpoints()?;
1800 let record = GitCheckpointRecord {
1801 change_id: change_id.to_string_full(),
1802 git_commit: git_commit.into(),
1803 summary: summary.into(),
1804 committed_at: Utc::now().to_rfc3339(),
1805 };
1806 let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1807 if let Some(parent) = path.parent() {
1808 fs::create_dir_all(parent)?;
1809 }
1810 records.push(record.clone());
1811 write_file_atomic(&path, serde_json::to_string_pretty(&records)?.as_bytes())?;
1812 Ok(record)
1813 }
1814
1815 pub fn init_worktree(
1816 path: impl AsRef<Path>,
1817 shared_galeed_dir: impl AsRef<Path>,
1818 ) -> Result<()> {
1819 let path = path.as_ref();
1820 let shared = shared_galeed_dir.as_ref().canonicalize()?;
1821 fs::create_dir_all(path)?;
1822 let heddle_dir = path.join(".heddle");
1823 if heddle_dir.exists() {
1824 return Err(HeddleError::RepositoryExists(path.to_path_buf()));
1825 }
1826 fs::create_dir_all(&heddle_dir)?;
1827 write_file_atomic(
1828 &heddle_dir.join("objectstore"),
1829 format!("objectstore: {}\n", shared.display()).as_bytes(),
1830 )?;
1831 fs::create_dir_all(heddle_dir.join("state"))?;
1832 Ok(())
1833 }
1834
1835 pub fn op_scope(&self) -> String {
1836 compute_op_scope(&self.root)
1850 }
1851
1852 pub fn commit_and_publish(
1861 &self,
1862 records: Vec<OpRecord>,
1863 ref_updates: &[RefUpdate],
1864 ) -> Result<()> {
1865 let encoded = records
1866 .iter()
1867 .map(|record| {
1868 rmp_serde::to_vec(record).map_err(|e| HeddleError::Serialization(e.to_string()))
1869 })
1870 .collect::<Result<Vec<_>>>()?;
1871 let scope = self.op_scope();
1872 let result = self
1873 .refs
1874 .commit_and_publish(&encoded, ref_updates, Some(&scope));
1875 let _ = self.oplog.refresh_cache();
1882 result
1883 }
1884
1885 pub fn commit_snapshot_atomic(
1903 &self,
1904 new_state: &ChangeId,
1905 prev_head: Option<ChangeId>,
1906 thread: Option<&ThreadName>,
1907 ) -> Result<()> {
1908 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, Vec::new())
1909 }
1910
1911 pub fn commit_snapshot_atomic_with_records(
1921 &self,
1922 new_state: &ChangeId,
1923 prev_head: Option<ChangeId>,
1924 thread: Option<&ThreadName>,
1925 extra: Vec<OpRecord>,
1926 ) -> Result<()> {
1927 let record = OpRecord::Snapshot {
1928 new_state: *new_state,
1929 prev_head,
1930 head: thread.is_none().then_some(*new_state),
1931 thread: thread.map(|name| name.to_string()),
1932 };
1933 let mut records = vec![record];
1934 records.extend(extra);
1935 let ref_update = match thread {
1936 Some(name) => RefUpdate::Thread {
1937 name: name.clone(),
1938 expected: RefExpectation::Any,
1939 new: Some(*new_state),
1940 },
1941 None => RefUpdate::Head {
1942 expected: RefExpectation::Any,
1943 new: Head::Detached { state: *new_state },
1944 },
1945 };
1946 self.commit_and_publish(records, &[ref_update])
1947 }
1948
1949 pub fn commit_snapshot_atomic_with_capture_visibility(
1968 &self,
1969 new_state: &ChangeId,
1970 prev_head: Option<ChangeId>,
1971 thread: Option<&ThreadName>,
1972 lock_held: bool,
1973 ) -> Result<()> {
1974 let binding = self
1975 .stage_default_visibility_binding(new_state, lock_held)
1976 .map_err(|e| HeddleError::Io(std::io::Error::other(format!("{e:#}"))))?;
1977 let (extra, rewind_to): (Vec<OpRecord>, Option<Option<Vec<u8>>>) = match binding {
1978 Some(binding) => (vec![binding.record], Some(binding.prior_sidecar)),
1979 None => (Vec::new(), None),
1980 };
1981
1982 #[cfg(test)]
1985 let commit_result = if crate::repository_state_visibility::take_visibility_commit_fault(
1986 crate::repository_state_visibility::VisibilityCommitFault::SnapshotCommit,
1987 ) {
1988 Err(HeddleError::Io(std::io::Error::other(
1989 "injected snapshot-commit failure after staging visibility binding",
1990 )))
1991 } else {
1992 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra)
1993 };
1994 #[cfg(not(test))]
1995 let commit_result =
1996 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra);
1997
1998 match commit_result {
1999 Ok(()) => Ok(()),
2000 Err(commit_err) => {
2001 if let Some(prior) = rewind_to {
2002 if let Err(rewind_err) = self.restore_state_visibility_sidecar(new_state, prior)
2006 {
2007 tracing::warn!(
2008 state = %new_state,
2009 error = %rewind_err,
2010 "rewind of staged visibility binding after a failed snapshot commit also failed"
2011 );
2012 }
2013 }
2014 Err(commit_err)
2015 }
2016 }
2017 }
2018
2019 pub fn repo_config(&self) -> &RepoConfig {
2020 &self.config
2021 }
2022
2023 pub fn config(&self) -> &RepoConfig {
2024 self.repo_config()
2025 }
2026
2027 pub fn get_tree_for_state(&self, state_id: &ChangeId) -> Result<Option<Tree>> {
2028 let state = match self.store.get_state(state_id)? {
2029 Some(state) => state,
2030 None => return Ok(None),
2031 };
2032 self.store.get_tree(&state.tree)
2033 }
2034
2035 pub fn ignore_patterns(&self) -> Result<Vec<String>> {
2036 let mut patterns = self.config.worktree.ignore.clone();
2037 patterns.push(format!(
2049 "/{}",
2050 repository_thread_materialize::COURTESY_STUB_FILENAME
2051 ));
2052 if self.capability() == RepositoryCapability::GitOverlay {
2053 patterns.push(".git".to_string());
2054 append_ignore_file_patterns(&mut patterns, &self.root.join(".gitignore"))?;
2055 }
2056 append_ignore_file_patterns(
2064 &mut patterns,
2065 &self.root.join(".heddle").join("info").join("exclude"),
2066 )?;
2067 let path = self.root.join(".heddleignore");
2068
2069 if path.exists() {
2070 append_ignore_file_patterns(&mut patterns, &path)?;
2071 }
2072
2073 Ok(patterns)
2074 }
2075
2076 pub fn nested_thread_worktree_exclusions(&self, walk_root: &Path) -> Result<Vec<PathBuf>> {
2091 let canonical_walk_root = walk_root
2092 .canonicalize()
2093 .unwrap_or_else(|_| walk_root.to_path_buf());
2094 let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2095 let mut exclusions: Vec<PathBuf> = Vec::new();
2096 let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
2097 for thread in manager.list()? {
2098 for candidate in [
2099 Some(&thread.execution_path),
2100 thread.materialized_path.as_ref(),
2101 ]
2102 .into_iter()
2103 .flatten()
2104 {
2105 if candidate.as_os_str().is_empty() {
2106 continue;
2107 }
2108 let canonical = match candidate.canonicalize() {
2109 Ok(path) => path,
2110 Err(_) => continue,
2111 };
2112 if canonical == canonical_walk_root {
2113 continue;
2114 }
2115 if !canonical.starts_with(&canonical_walk_root) {
2116 continue;
2117 }
2118 if seen.insert(canonical.clone()) {
2119 exclusions.push(canonical);
2120 }
2121 }
2122 }
2123 Ok(exclusions)
2124 }
2125
2126 pub fn head(&self) -> Result<Option<ChangeId>> {
2127 Ok(match self.head_ref()? {
2128 Head::Attached { thread } => match self.refs.get_thread(&thread)? {
2129 Some(change_id) => Some(change_id),
2130 None if self.capability() == RepositoryCapability::GitOverlay => {
2131 self.git_overlay_mapped_change_for_branch(&thread)?
2132 }
2133 None => None,
2134 },
2135 Head::Detached { state } => Some(state),
2136 })
2137 }
2138
2139 pub fn head_ref(&self) -> Result<Head> {
2140 let raw = self.refs.read_head()?;
2141 if self.capability() != RepositoryCapability::GitOverlay {
2142 return Ok(raw);
2143 }
2144 if matches!(raw, Head::Detached { .. }) {
2145 return Ok(raw);
2146 }
2147 if let Some(GitHeadState::Detached(git_oid)) = detect_git_head_state(&self.root)?
2148 && let Some(state) = self.git_overlay_mapped_change_for_git_oid(git_oid)?
2149 {
2150 return Ok(Head::Detached { state });
2151 }
2152 let Some(branch) = self.git_overlay_current_branch()? else {
2153 return Ok(raw);
2154 };
2155 if matches!(&raw, Head::Attached { thread } if *thread == branch) {
2156 return Ok(raw);
2157 }
2158 let branch_thread = ThreadName::from(branch.as_str());
2159 if self.refs.get_thread(&branch_thread)?.is_some()
2160 || self
2161 .git_overlay_mapped_change_for_branch(&branch)?
2162 .is_some()
2163 {
2164 return Ok(Head::Attached {
2165 thread: branch_thread,
2166 });
2167 }
2168 Ok(raw)
2169 }
2170
2171 pub fn active_worktree_path(&self) -> Result<PathBuf> {
2188 let head = self.refs.read_head()?;
2189 let Head::Attached { thread } = head else {
2190 return Ok(self.root.clone());
2191 };
2192 let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2193 let Some(thread_record) = manager.find_by_thread(&thread)? else {
2194 return Ok(self.root.clone());
2195 };
2196 if !thread_record.execution_path.as_os_str().is_empty() {
2197 return Ok(thread_record.execution_path);
2198 }
2199 if let Some(path) = thread_record.materialized_path {
2200 return Ok(path);
2201 }
2202 Ok(self.root.clone())
2203 }
2204
2205 pub fn current_state(&self) -> Result<Option<State>> {
2206 match self.head()? {
2207 Some(id) => self.store.get_state(&id),
2208 None => Ok(None),
2209 }
2210 }
2211
2212 pub fn get_principal(&self) -> Result<Principal> {
2213 if let Some(principal) = Principal::from_env() {
2214 return Ok(principal);
2215 }
2216
2217 if let Some(config) = &self.config.principal {
2218 return Ok(Principal::new(&config.name, &config.email));
2219 }
2220
2221 if self.capability() == RepositoryCapability::GitOverlay
2222 && let Some(principal) = git_config_principal(&self.root)
2223 {
2224 return Ok(principal);
2225 }
2226
2227 if let Some(principal) = self.shared_checkout_parent_git_principal() {
2228 return Ok(principal);
2229 }
2230
2231 Ok(Principal::new("Unknown", "unknown@example.com"))
2232 }
2233
2234 fn shared_checkout_parent_git_principal(&self) -> Option<Principal> {
2235 let local_heddle_dir = self.root.join(".heddle");
2236 if local_heddle_dir == self.heddle_dir || !local_heddle_dir.join("objectstore").is_file() {
2237 return None;
2238 }
2239 let parent_root = self.heddle_dir.parent()?;
2240 if parent_root == self.root {
2241 return None;
2242 }
2243 git_config_principal(parent_root)
2244 }
2245
2246 pub fn get_attribution(&self) -> Result<Attribution> {
2247 let principal = self.get_principal()?;
2248
2249 if let Some(agent) = self.resolve_agent() {
2250 Ok(Attribution::with_agent(principal, agent))
2251 } else {
2252 Ok(Attribution::human(principal))
2253 }
2254 }
2255
2256 pub fn is_shallow(&self, id: &ChangeId) -> bool {
2257 self.shallow.read_or_poisoned().is_shallow(id)
2258 }
2259
2260 pub fn set_shallow(&self, state_id: &ChangeId, _parents: &[ChangeId]) -> Result<()> {
2261 self.shallow.write_or_poisoned().add_shallow(*state_id)?;
2262 Ok(())
2263 }
2264
2265 pub fn record_missing_blob(&self, hash: ContentHash) -> Result<()> {
2266 self.partial_fetch_metadata().record_missing_blob(hash)?;
2267 Ok(())
2268 }
2269
2270 pub fn seed_default_thread(&self) -> Result<()> {
2285 let main_thread = ThreadName::from("main");
2286 if self.refs.get_thread(&main_thread)?.is_some() {
2287 return Ok(());
2288 }
2289
2290 let empty_tree = Tree::new();
2291 let tree_hash = self.store.put_tree(&empty_tree)?;
2292 let state = State::new_snapshot(tree_hash, vec![], Attribution::human(seed_principal()));
2293 self.store.put_state(&state)?;
2294 self.refs.set_thread(&main_thread, &state.change_id)?;
2295 Ok(())
2296 }
2297
2298 pub fn clear_missing_blob(&self, hash: &ContentHash) -> Result<()> {
2299 self.partial_fetch_metadata().clear_missing_blob(hash)?;
2300 Ok(())
2301 }
2302
2303 pub fn missing_blobs(&self) -> Result<Vec<ContentHash>> {
2304 self.partial_fetch_metadata().missing_blobs()
2305 }
2306
2307 pub fn clear_all_missing_blobs(&self) -> Result<bool> {
2308 self.partial_fetch_metadata().clear_all_missing_blobs()
2309 }
2310
2311 pub fn is_missing_blob(&self, hash: &ContentHash) -> Result<bool> {
2312 self.partial_fetch_metadata().is_missing_blob(hash)
2313 }
2314
2315 pub fn require_tree(&self, hash: &ContentHash) -> Result<Tree> {
2342 self.store
2343 .get_tree(hash)?
2344 .ok_or_else(|| HeddleError::MissingObject {
2345 object_type: "tree".to_string(),
2346 id: hash.to_hex(),
2347 })
2348 }
2349
2350 pub fn require_blob(&self, hash: &ContentHash) -> Result<objects::object::Blob> {
2351 if let Some(blob) = self.store.get_blob(hash)? {
2352 if self.is_missing_blob(hash)? {
2353 self.clear_missing_blob(hash)?;
2354 }
2355 return Ok(blob);
2356 }
2357
2358 if self.is_missing_blob(hash)? {
2359 if let Some(hydrator) = self.blob_hydrator() {
2363 hydrator.hydrate(self, hash)?;
2364 if let Some(blob) = self.store.get_blob(hash)? {
2365 self.clear_missing_blob(hash)?;
2366 return Ok(blob);
2367 }
2368 }
2373 return Err(HeddleError::MissingObject {
2374 object_type: "blob".to_string(),
2375 id: hash.to_hex(),
2376 });
2377 }
2378
2379 Err(HeddleError::NotFound(hash.to_hex()))
2380 }
2381
2382 pub fn set_blob_hydrator(&self, hydrator: Arc<dyn BlobHydrator>) {
2395 *self.blob_hydrator.write_or_poisoned() = Some(hydrator);
2396 }
2397
2398 pub fn blob_hydrator(&self) -> Option<Arc<dyn BlobHydrator>> {
2400 self.blob_hydrator.read_or_poisoned().clone()
2401 }
2402
2403 fn partial_fetch_metadata(&self) -> repository_partial_fetch::PartialFetchMetadataManager {
2404 repository_partial_fetch::PartialFetchMetadataManager::new(&self.heddle_dir)
2405 }
2406
2407 pub fn shallow(&self) -> std::sync::RwLockReadGuard<'_, ShallowInfo> {
2408 self.shallow.read_or_poisoned()
2409 }
2410}
2411
2412fn ensure_git_overlay_exclude(root: &Path) -> Result<()> {
2413 let git_dir = match SleyRepository::discover(root) {
2414 Ok(repo) if repo.workdir().is_some() => repo.git_dir().to_path_buf(),
2415 _ => root.join(".git"),
2416 };
2417 if !git_dir.is_dir() {
2418 return Ok(());
2419 }
2420
2421 let info_dir = git_dir.join("info");
2422 fs::create_dir_all(&info_dir)?;
2423 let exclude_path = info_dir.join("exclude");
2424 let mut contents = fs::read_to_string(&exclude_path).unwrap_or_default();
2425 let existing_lines = contents.lines().map(str::trim).collect::<BTreeSet<_>>();
2426 let mut missing = Vec::new();
2427 for pattern in GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS {
2428 if !existing_lines
2429 .iter()
2430 .any(|line| git_overlay_exclude_line_matches(line, pattern))
2431 {
2432 missing.push(*pattern);
2433 }
2434 }
2435 if missing.is_empty() {
2436 return Ok(());
2437 }
2438 if !contents.is_empty() && !contents.ends_with('\n') {
2439 contents.push('\n');
2440 }
2441 contents.push_str("# Heddle local metadata\n");
2442 for pattern in missing {
2443 contents.push_str(pattern);
2444 contents.push('\n');
2445 }
2446 fs::write(exclude_path, contents)?;
2447 Ok(())
2448}
2449
2450fn git_overlay_exclude_line_matches(line: &str, pattern: &str) -> bool {
2451 line == pattern
2452 || matches!(
2453 (line, pattern),
2454 (".heddle", ".heddle/") | ("/.heddle/", ".heddle/") | ("/.heddle", ".heddle/")
2455 )
2456}
2457
2458pub(crate) fn seed_principal() -> Principal {
2463 Principal::new("Heddle", "init@heddle")
2464}
2465
2466pub fn is_synthetic_root(state: &State) -> bool {
2471 state.parents.is_empty()
2472 && state.intent.is_none()
2473 && state.attribution.principal == seed_principal()
2474 && state.attribution.agent.is_none()
2475}
2476
2477fn parse_objectstore_pointer(content: &str) -> Option<PathBuf> {
2481 for line in content.lines() {
2482 if let Some(path) = line.strip_prefix("objectstore:") {
2483 let path = path.trim();
2484 if !path.is_empty() {
2485 return Some(PathBuf::from(path));
2486 }
2487 }
2488 }
2489 None
2490}
2491
2492fn has_git_metadata(path: &Path) -> bool {
2493 let dot_git = path.join(".git");
2494 if !(dot_git.is_dir() || dot_git.is_file()) {
2495 return false;
2496 }
2497
2498 SleyRepository::discover(path).is_ok()
2499}
2500
2501fn metadataless_managed_thread_root(start_path: &Path) -> Option<PathBuf> {
2515 let mut cur: Option<&Path> = Some(start_path);
2516 while let Some(dir) = cur {
2517 if let Some(thread_dir) = dir.parent()
2518 && let Some(threads) = thread_dir.parent()
2519 && threads.file_name().and_then(|n| n.to_str()) == Some("threads")
2520 && let Some(heddle) = threads.parent()
2521 && heddle.file_name().and_then(|n| n.to_str()) == Some(".heddle")
2522 && heddle.join("objects").is_dir()
2523 && !dir.join(".heddle").exists()
2524 {
2525 return Some(dir.to_path_buf());
2526 }
2527 cur = dir.parent();
2528 }
2529 None
2530}
2531
2532fn git_config_principal(root: &Path) -> Option<Principal> {
2533 let git_repo = SleyRepository::discover(root).ok()?;
2534 let config = git_repo.config_snapshot().ok()?;
2535 let name = config.get("user", None, "name")?.to_string();
2536 let email = config.get("user", None, "email")?.to_string();
2537 if name.trim().is_empty() || email.trim().is_empty() {
2538 return None;
2539 }
2540 Some(Principal::new(&name, &email))
2541}
2542
2543fn git_path(path: &[u8]) -> String {
2544 String::from_utf8_lossy(path).into_owned()
2545}
2546
2547fn ignored_git_overlay_status_path(path: &str) -> bool {
2548 path == ".heddle" || path.starts_with(".heddle/")
2549}
2550
2551fn git_overlay_untracked_path_ignored(
2552 ignore_matcher: &crate::worktree_ignore::WorktreeIgnoreMatcher,
2553 path: &Path,
2554) -> bool {
2555 let parent = path.parent().unwrap_or_else(|| Path::new(""));
2556 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
2557 return false;
2558 };
2559 ignore_matcher.should_prune_directory_child(parent, name)
2560}
2561
2562fn git_remote_names(root: &Path) -> Result<Vec<String>> {
2563 let repo = match SleyRepository::discover(root) {
2564 Ok(repo) => repo,
2565 Err(_) => return Ok(Vec::new()),
2566 };
2567 repo.remote_names()
2568 .map(|names| {
2569 names
2570 .into_iter()
2571 .filter(|name| !name.trim().is_empty())
2572 .collect()
2573 })
2574 .map_err(|error| HeddleError::Config(error.to_string()))
2575}
2576
2577fn git_find_reference(repo: &SleyRepository, name: &str) -> Result<Option<SleyReference>> {
2578 repo.find_reference(name).map_err(|error| {
2579 HeddleError::Config(format!("failed to inspect Git reference '{name}': {error}"))
2580 })
2581}
2582
2583fn git_resolve_oid(repo: &SleyRepository, rev: &str) -> Result<Option<SleyObjectId>> {
2584 match repo.rev_parse(rev) {
2585 Ok(id) => Ok(Some(id)),
2586 Err(_) => Ok(None),
2587 }
2588}
2589
2590fn git_configured_tracking_ref(repo: &SleyRepository, branch: &str) -> Result<Option<String>> {
2591 let config = repo
2592 .config_snapshot()
2593 .map_err(|error| HeddleError::Config(error.to_string()))?;
2594 let Some(remote) = config.get("branch", Some(branch), "remote") else {
2595 return Ok(None);
2596 };
2597 let Some(merge) = config.get("branch", Some(branch), "merge") else {
2598 return Ok(None);
2599 };
2600 if remote == "." {
2601 return Ok(Some(merge.to_string()));
2602 }
2603 let Some(short) = merge.strip_prefix("refs/heads/") else {
2604 return Ok(None);
2605 };
2606 Ok(Some(format!("refs/remotes/{remote}/{short}")))
2607}
2608
2609fn git_ahead_behind(
2610 root: &Path,
2611 repo: &SleyRepository,
2612 upstream: SleyObjectId,
2613 head: SleyObjectId,
2614) -> Result<(usize, usize)> {
2615 if upstream == head {
2616 return Ok((0, 0));
2617 }
2618 let ahead = git_reachable_count(root, repo, head, upstream)?;
2619 let behind = git_reachable_count(root, repo, upstream, head)?;
2620 Ok((ahead, behind))
2621}
2622
2623fn git_reachable_count(
2624 root: &Path,
2625 repo: &SleyRepository,
2626 tip: SleyObjectId,
2627 hidden: SleyObjectId,
2628) -> Result<usize> {
2629 let hidden = git_ancestor_set(root, repo, hidden)?;
2630 let mut seen = std::collections::HashSet::new();
2631 let mut pending = vec![tip];
2632 let mut count = 0;
2633 while let Some(oid) = pending.pop() {
2634 if hidden.contains(&oid) || !seen.insert(oid) {
2635 continue;
2636 }
2637 count += 1;
2638 let commit = repo.read_commit(&oid).map_err(|error| {
2639 HeddleError::Config(format!(
2640 "failed to inspect Git upstream drift at '{}': {error}",
2641 root.display()
2642 ))
2643 })?;
2644 pending.extend(commit.parents);
2645 }
2646 Ok(count)
2647}
2648
2649fn git_ancestor_set(
2650 root: &Path,
2651 repo: &SleyRepository,
2652 start: SleyObjectId,
2653) -> Result<std::collections::HashSet<SleyObjectId>> {
2654 let mut seen = std::collections::HashSet::new();
2655 let mut pending = vec![start];
2656 while let Some(oid) = pending.pop() {
2657 if !seen.insert(oid) {
2658 continue;
2659 }
2660 let commit = repo.read_commit(&oid).map_err(|error| {
2661 HeddleError::Config(format!(
2662 "failed to inspect Git upstream drift at '{}': {error}",
2663 root.display()
2664 ))
2665 })?;
2666 pending.extend(commit.parents);
2667 }
2668 Ok(seen)
2669}
2670
2671fn git_remote_tracking_display_name(name: &str) -> String {
2672 name.strip_prefix("refs/remotes/")
2673 .unwrap_or(name)
2674 .to_string()
2675}
2676
2677fn git_remote_tracking_message(
2678 branch: &str,
2679 upstream: &str,
2680 ahead: usize,
2681 behind: usize,
2682 upstream_is_undone_checkpoint: bool,
2683) -> String {
2684 if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
2685 return format!(
2686 "Upstream '{upstream}' still points at a Git commit that was undone locally on branch '{branch}'"
2687 );
2688 }
2689 match (ahead, behind) {
2690 (0, behind) => format!(
2691 "Git branch '{}' is behind upstream '{}' by {} commit(s)",
2692 branch, upstream, behind
2693 ),
2694 (ahead, 0) => format!(
2695 "Git branch '{}' is ahead of upstream '{}' by {} commit(s)",
2696 branch, upstream, ahead
2697 ),
2698 (ahead, behind) => format!(
2699 "Git branch '{}' has diverged from upstream '{}' (ahead {}, behind {})",
2700 branch, upstream, ahead, behind
2701 ),
2702 }
2703}
2704
2705fn git_remote_tracking_next_action(
2706 ahead: usize,
2707 behind: usize,
2708 upstream_is_undone_checkpoint: bool,
2709) -> String {
2710 if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
2711 return "heddle push --force".to_string();
2712 }
2713 match (ahead, behind) {
2714 (0, _) => "heddle pull".to_string(),
2715 (_, 0) => "heddle push".to_string(),
2716 _ => "heddle pull".to_string(),
2717 }
2718}
2719
2720fn repository_capability_for_root(root: &Path) -> RepositoryCapability {
2721 if has_git_metadata(root) {
2722 RepositoryCapability::GitOverlay
2723 } else {
2724 RepositoryCapability::NativeHeddle
2725 }
2726}
2727
2728fn append_ignore_file_patterns(patterns: &mut Vec<String>, path: &Path) -> Result<()> {
2729 if !path.exists() {
2730 return Ok(());
2731 }
2732 let contents = std::fs::read_to_string(path)?;
2733 for line in contents.lines() {
2734 let trimmed = line.trim();
2735 if trimmed.is_empty() || trimmed.starts_with('#') {
2736 continue;
2737 }
2738 if !patterns.iter().any(|pattern| pattern == trimmed) {
2739 patterns.push(trimmed.to_string());
2740 }
2741 }
2742 Ok(())
2743}
2744
2745fn detect_git_head_state_via_sley(path: &Path) -> Result<Option<GitHeadState>> {
2749 let repo = SleyRepository::discover(path).map_err(|error| {
2750 HeddleError::Config(format!(
2751 "failed to inspect git repository at '{}': {}",
2752 path.display(),
2753 error
2754 ))
2755 })?;
2756 let head = match repo.head() {
2757 Ok(head) => head,
2758 Err(_) => return Ok(None),
2759 };
2760
2761 if let Some(name) = head.branch_name() {
2762 return Ok(Some(GitHeadState::Attached(name.to_string())));
2763 }
2764 if head.is_detached()
2765 && let Some(id) = head.oid
2766 {
2767 return Ok(Some(GitHeadState::Detached(id)));
2768 }
2769 Ok(None)
2770}
2771
2772fn detect_git_head_state(path: &Path) -> Result<Option<GitHeadState>> {
2773 if let Some(head) = detect_git_head_fast(path) {
2774 return Ok(Some(head));
2775 }
2776 detect_git_head_state_via_sley(path)
2777}
2778
2779fn detect_git_head(path: &Path) -> Result<Option<Head>> {
2789 if let Some(GitHeadState::Attached(thread)) = detect_git_head_state(path)? {
2790 return Ok(Some(Head::Attached {
2791 thread: ThreadName::from(thread),
2792 }));
2793 }
2794 Ok(None)
2795}
2796
2797fn detect_git_head_fast(path: &Path) -> Option<GitHeadState> {
2803 let head_path = path.join(".git").join("HEAD");
2804 if !head_path.is_file() {
2807 return None;
2808 }
2809 let content = std::fs::read_to_string(&head_path).ok()?;
2810 let trimmed = content.trim();
2811 let suffix = trimmed.strip_prefix("ref: ")?;
2812 let name = suffix.strip_prefix("refs/heads/")?.to_string();
2813 if name.is_empty() {
2814 return None;
2815 }
2816 Some(GitHeadState::Attached(name))
2817}
2818
2819fn resolve_git_dir(path: &Path) -> Result<PathBuf> {
2820 let repo = SleyRepository::discover(path).map_err(|error| {
2821 HeddleError::Config(format!(
2822 "failed to resolve git dir at '{}': {}",
2823 path.display(),
2824 error
2825 ))
2826 })?;
2827 Ok(repo.git_dir().to_path_buf())
2828}
2829
2830fn detect_git_in_progress_branch(path: &Path) -> Result<Option<String>> {
2831 let git_dir = resolve_git_dir(path)?;
2832 for marker in ["rebase-merge/head-name", "rebase-apply/head-name"] {
2833 let branch_path = git_dir.join(marker);
2834 if !branch_path.exists() {
2835 continue;
2836 }
2837 let raw = fs::read_to_string(&branch_path)?;
2838 let value = raw.trim();
2839 if let Some(short) = value.strip_prefix("refs/heads/") {
2840 return Ok(Some(short.to_string()));
2841 }
2842 if !value.is_empty() {
2843 return Ok(Some(value.to_string()));
2844 }
2845 }
2846 Ok(None)
2847}