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"]
13pub(crate) mod 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 Progress,
72 error::{HeddleError, Result},
73 fs_atomic::write_file_atomic,
74 lock::{RepoLock, RepositoryLockExt},
75 object::{Attribution, ChangeId, ContentHash, MarkerName, Principal, State, ThreadName, Tree},
76 store::{AnyStore, FsStore, ObjectStore, ShallowInfo},
77 sync::RwLockExt,
78 worktree::WorktreeStatus,
79};
80use oplog::{OpLog, OpLogBackend, OpRecord};
81pub use refs::RefSummaryIndexInspection;
82pub use refs::SpoolFacet;
83use refs::{Head, RefBackend, RefExpectation, RefManager, RefUpdate};
84pub use repo_config::{HostedConfig, OutputFormat, RedactConfig, RepoConfig, TrustedKey};
85use crate::{GitRefContentNamespace, GitRefName};
86#[allow(unused_imports)]
90pub use repo_config::{
91 PatternDeviationToml, ReviewConfig, ReviewSignalsToml, SelfFlaggedToml, SignalEnableToml,
92 SignalModuleToml, TestReachabilityToml,
93};
94#[cfg(feature = "async-source")]
95pub use repository_history::query_history_async;
96pub use repository_history::{ChangedPathFilter, ChangedPathFilters, HistoryQuery};
97pub use repository_maintenance::{
98 ChangeMonitorInspection, CommitGraphInspection, PackFilesInspection, PartialFetchInspection,
99 PullPlannerCacheInspection, RefCountsInspection, RepositoryMaintenanceRunReport,
100 RepositoryPerformanceInspectionReport, WorktreeIndexInspection,
101};
102pub use repository_materialization::WarmCanonicalStoreStats;
103pub use repository_partial_fetch::MissingBlob;
104pub use repository_snapshot::{SnapshotExecution, SnapshotProfile};
105pub use repository_thread_materialize::{CheckoutMaterialization, ThreadCaptureOutcome};
106pub use repository_tree::{TreeBuildProfile, WorktreeCompareProfile};
107pub use repository_worktree_status::{UntrackedSet, UntrackedSubtree, WorktreeStatusDetailed};
108use rusqlite::{Connection, OpenFlags};
109use serde::{Deserialize, Serialize};
110use sley::{
111 ObjectId as SleyObjectId, Reference as SleyReference, ReferenceTarget as SleyRefTarget,
112 Repository as SleyRepository, ShortStatusOptions as SleyShortStatusOptions,
113 StatusUntrackedMode as SleyStatusUntrackedMode, StreamControl as SleyStreamControl,
114};
115
116const GIT_CHECKPOINTS_FILE: &str = "git-checkpoints.json";
117const GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS: &[&str] = &[".heddle/"];
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum RepositoryCapability {
121 GitOverlay,
122 NativeHeddle,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126enum GitHeadState {
127 Attached(String),
128 Detached(SleyObjectId),
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct GitCheckpointRecord {
133 pub change_id: String,
134 pub git_commit: String,
135 pub summary: String,
136 pub committed_at: String,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct GitOverlayImportHint {
141 pub current_branch: String,
142 pub missing_branch_count: usize,
143 pub missing_branches: Vec<String>,
144 pub recommended_command: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct GitOverlayBranchTip {
149 pub branch: String,
150 pub git_commit: String,
151 pub history_imported: bool,
152 #[serde(skip)]
153 pub mapped_change: Option<ChangeId>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct GitOverlayTagTip {
158 pub tag: String,
159 pub git_commit: String,
160 pub history_imported: bool,
161 #[serde(skip)]
162 pub mapped_change: Option<ChangeId>,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct GitOverlayOutOfBandCommits {
170 pub count: usize,
171 pub truncated: bool,
174}
175
176const GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT: usize = 1000;
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182#[serde(rename_all = "kebab-case")]
183pub enum OperationScope {
184 Git,
185 Heddle,
186}
187
188impl std::fmt::Display for OperationScope {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 match self {
191 Self::Git => write!(f, "git"),
192 Self::Heddle => write!(f, "heddle"),
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "kebab-case")]
199pub enum OperationKind {
200 Merge,
201 Rebase,
202 CherryPick,
203 Revert,
204 Bisect,
205}
206
207impl std::fmt::Display for OperationKind {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 match self {
210 Self::Merge => write!(f, "merge"),
211 Self::Rebase => write!(f, "rebase"),
212 Self::CherryPick => write!(f, "cherry-pick"),
213 Self::Revert => write!(f, "revert"),
214 Self::Bisect => write!(f, "bisect"),
215 }
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct RepositoryOperationStatus {
221 pub scope: OperationScope,
222 pub kind: OperationKind,
223 pub in_progress: bool,
224 pub state: String,
225 pub message: String,
226 pub next_action: String,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct GitRemoteTrackingStatus {
231 pub branch: String,
232 pub upstream: String,
233 pub ahead: usize,
234 pub behind: usize,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub local_oid: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub upstream_oid: Option<String>,
239 #[serde(default, skip_serializing_if = "is_false")]
240 pub upstream_is_undone_checkpoint: bool,
241 pub message: String,
242 pub next_action: String,
243}
244
245fn is_false(value: &bool) -> bool {
246 !*value
247}
248
249#[derive(Debug, Deserialize)]
250struct GitBridgeMappingEntry {
251 change_id: String,
252 git_oid: String,
253}
254
255#[derive(Debug, Deserialize, Default)]
256struct GitBridgeMappingFile {
257 entries: Vec<GitBridgeMappingEntry>,
258}
259
260pub trait BlobHydrator: Send + Sync {
281 fn hydrate(&self, repo: &Repository, hash: &ContentHash) -> Result<()>;
282}
283
284pub struct Repository<R = RefManager, O = OpLog, S = AnyStore>
298where
299 R: RefBackend,
300 O: OpLogBackend,
301 S: ObjectStore,
302{
303 root: PathBuf,
304 heddle_dir: PathBuf,
305 capability: RepositoryCapability,
306 store: S,
307 refs: R,
308 oplog: O,
309 config: RepoConfig,
310 shallow: RwLock<ShallowInfo>,
311 blob_hydrator: RwLock<Option<Arc<dyn BlobHydrator>>>,
312 git_overlay_repo: RwLock<Option<SleyRepository>>,
313 progress: RwLock<Progress>,
321}
322
323impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> RepositoryLockExt for Repository<R, O, S> {
324 fn locker(&self) -> RepoLock {
325 let lock_root = self.heddle_dir.parent().expect(
326 "heddle_dir has no parent component; cannot determine lock root. This indicates a misconfigured repository.",
327 );
328 RepoLock::new(lock_root)
329 }
330}
331
332impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> Repository<R, O, S> {
333 pub fn from_parts(
342 root: PathBuf,
343 heddle_dir: PathBuf,
344 store: S,
345 refs: R,
346 oplog: O,
347 config: RepoConfig,
348 shallow: ShallowInfo,
349 ) -> Self {
350 let capability = repository_capability_for_root(&root);
351 Self {
352 root,
353 heddle_dir,
354 capability,
355 store,
356 refs,
357 oplog,
358 config,
359 shallow: RwLock::new(shallow),
360 blob_hydrator: RwLock::new(None),
361 git_overlay_repo: RwLock::new(None),
362 progress: RwLock::new(Progress::null()),
363 }
364 }
365
366 pub fn store(&self) -> &S {
368 &self.store
369 }
370
371 pub fn refs(&self) -> &R {
373 &self.refs
374 }
375
376 pub fn oplog(&self) -> &O {
378 &self.oplog
379 }
380}
381
382pub(crate) fn compute_op_scope(root: &Path) -> String {
393 let local_head = root.join(".heddle").join("HEAD");
394 let canonical = local_head.canonicalize().unwrap_or(local_head);
395 let digest = blake3::hash(canonical.to_string_lossy().as_bytes());
396 format!("wt-{}", &digest.to_hex().as_str()[..16])
397}
398
399fn ensure_supported_repo_format(config_path: &Path, config: &RepoConfig) -> Result<()> {
400 let found = config.repository.version;
401 let supported = repo_config::SUPPORTED_REPO_FORMAT;
402 if found > supported {
403 return Err(HeddleError::RepositoryFormatTooNew {
404 path: config_path.to_path_buf(),
405 found,
406 supported,
407 });
408 }
409 Ok(())
410}
411
412impl<S: ObjectStore> Repository<RefManager, OpLog, S> {
413 fn open_raw(
414 root: PathBuf,
415 heddle_dir: PathBuf,
416 store: S,
417 config: RepoConfig,
418 refs: RefManager,
419 ) -> Result<Self> {
420 let actor = config
421 .principal
422 .as_ref()
423 .map(|p| objects::object::Principal::new(&p.name, &p.email))
424 .unwrap_or_else(|| objects::object::Principal::new("<unknown>", ""));
425 let oplog = OpLog::new(&heddle_dir, actor.clone());
426 let shallow = ShallowInfo::load(&heddle_dir)?;
427 let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
431 &heddle_dir,
432 compute_op_scope(&root),
433 ));
434 let committer =
435 std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(&heddle_dir, actor));
436 let refs = refs.with_reconciler(reconciler).with_committer(committer);
437 refs.init_reconcile_watermark()?;
442 Ok(Self::from_parts(
443 root, heddle_dir, store, refs, oplog, config, shallow,
444 ))
445 }
446}
447
448impl Repository {
449 fn run_open_hooks(&self) -> Result<()> {
455 crate::migration::apply_pending(self)?;
461 match crate::lazy_hydrator::try_reconstruct(self.root(), self.heddle_dir()) {
469 Ok(Some(hydrator)) => self.set_blob_hydrator(hydrator),
470 Ok(None) => {}
471 Err(err) => {
472 tracing::warn!("lazy hydrator reconstruction failed during open: {err}");
479 }
480 }
481 Ok(())
482 }
483
484 fn build_store(config: &RepoConfig, heddle_dir: &Path) -> Result<AnyStore> {
489 let _ = config;
490 Ok(AnyStore::Fs(FsStore::new(heddle_dir)))
491 }
492
493 pub fn init(path: impl AsRef<Path>) -> Result<Self> {
502 let root = path.as_ref().to_path_buf();
503 let heddle_dir = root.join(".heddle");
504
505 if heddle_dir.exists() {
506 return Err(HeddleError::RepositoryExists(root));
507 }
508
509 fs::create_dir_all(&heddle_dir)?;
510
511 let store = FsStore::new(&heddle_dir);
512 store.init()?;
513
514 let refs = RefManager::new(&heddle_dir);
515 refs.init()?;
516
517 let oplog = OpLog::new_unattributed(&heddle_dir);
522 oplog.init()?;
523
524 let config = RepoConfig::default();
525 config.save(&heddle_dir.join("config.toml"))?;
526
527 refs.write_head(&Head::Attached {
528 thread: ThreadName::from("main"),
529 })?;
530
531 let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
535 &heddle_dir,
536 compute_op_scope(&root),
537 ));
538 let committer = std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(
539 &heddle_dir,
540 objects::object::Principal::new("<unknown>", ""),
541 ));
542 let refs = refs.with_reconciler(reconciler).with_committer(committer);
543 refs.init_reconcile_watermark()?;
547
548 let capability = repository_capability_for_root(&root);
549 Ok(Self {
550 root,
551 heddle_dir: heddle_dir.clone(),
552 capability,
553 store: AnyStore::Fs(store),
554 refs,
555 oplog,
556 config,
557 shallow: RwLock::new(ShallowInfo::load(&heddle_dir)?),
558 blob_hydrator: RwLock::new(None),
559 git_overlay_repo: RwLock::new(None),
560 progress: RwLock::new(Progress::null()),
561 })
562 }
563
564 pub fn init_default(path: impl AsRef<Path>) -> Result<Self> {
570 let repo = Self::init(path)?;
571 repo.seed_default_thread()?;
572 Ok(repo)
573 }
574
575 pub fn bootstrap_git_overlay(path: impl AsRef<Path>) -> Result<Self> {
582 let root = path.as_ref();
583 if root.join(".heddle").exists() {
584 ensure_git_overlay_exclude(root)?;
585 return Self::open(root);
586 }
587
588 let repo = Self::init(root)?;
589 ensure_git_overlay_exclude(root)?;
590 if let Some(head) = detect_git_head(root)? {
591 repo.refs.write_head(&head)?;
592 }
593 Ok(repo)
594 }
595
596 pub fn ensure_git_overlay_local_excludes(path: impl AsRef<Path>) -> Result<()> {
600 ensure_git_overlay_exclude(path.as_ref())
601 }
602
603 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
615 let start_path = path.as_ref().canonicalize()?;
616 if let Some(mount_root) = metadataless_managed_thread_root(&start_path) {
626 return Err(HeddleError::Config(format!(
627 "'{}' is a Heddle-managed virtualized thread mount with no checkout \
628 metadata of its own; refusing to operate on the parent repository from \
629 inside it. Run heddle from the repository root, or use a solid/materialized \
630 thread checkout.",
631 mount_root.display()
632 )));
633 }
634 let mut discovered_git_root = None;
635
636 let mut current = Some(start_path.as_path());
637 while let Some(dir) = current {
638 if discovered_git_root.is_none() && has_git_metadata(dir) {
639 discovered_git_root = Some(dir.to_path_buf());
640 }
641 let heddle_path = dir.join(".heddle");
642
643 if heddle_path.is_dir() {
644 if let Some(git_root) = discovered_git_root.as_ref()
645 && git_root != dir
646 && git_root.starts_with(dir)
647 && !git_root.join(".heddle").exists()
648 {
649 ensure_git_overlay_exclude(git_root)?;
650 Self::bootstrap_git_overlay(git_root)?;
651 return Self::open(git_root);
652 }
653 let pointer_path = heddle_path.join("objectstore");
654 let objects_dir = heddle_path.join("objects");
655
656 if pointer_path.is_file() {
657 let content = fs::read_to_string(&pointer_path)?;
660 let raw_shared = parse_objectstore_pointer(&content).ok_or_else(|| {
661 HeddleError::Config(format!(
662 "invalid .heddle/objectstore pointer at {}: expected 'objectstore: <path>'",
663 pointer_path.display()
664 ))
665 })?;
666
667 if raw_shared.is_relative() {
668 return Err(HeddleError::Config(format!(
669 ".heddle/objectstore pointer at {} contains a relative path '{}'; \
670 objectstore path must be absolute",
671 pointer_path.display(),
672 raw_shared.display()
673 )));
674 }
675
676 let shared_galeed_dir = raw_shared.canonicalize().map_err(|e| {
677 HeddleError::Config(format!(
678 ".heddle/objectstore pointer at {} points to non-existent path '{}': {}",
679 pointer_path.display(),
680 raw_shared.display(),
681 e
682 ))
683 })?;
684
685 if !shared_galeed_dir.join("objects").is_dir() {
686 return Err(HeddleError::Config(format!(
687 ".heddle/objectstore pointer at {} resolves to '{}' which does not \
688 contain an 'objects/' directory; not a valid Heddle store",
689 pointer_path.display(),
690 shared_galeed_dir.display()
691 )));
692 }
693
694 let config_path = shared_galeed_dir.join("config.toml");
695 let config = RepoConfig::load(&config_path)?;
696 ensure_supported_repo_format(&config_path, &config)?;
697 let store = Self::build_store(&config, &shared_galeed_dir)?;
698 let local_head_path = heddle_path.join("HEAD");
699 let refs = RefManager::new(&shared_galeed_dir).with_local_head(local_head_path);
700 let repo =
701 Self::open_raw(dir.to_path_buf(), shared_galeed_dir, store, config, refs)?;
702 repo.run_open_hooks()?;
703 return Ok(repo);
704 }
705
706 if objects_dir.is_dir() {
707 let config_path = heddle_path.join("config.toml");
709 let config = RepoConfig::load(&config_path)?;
710 ensure_supported_repo_format(&config_path, &config)?;
711 let store = Self::build_store(&config, &heddle_path)?;
712 let refs = RefManager::new(&heddle_path);
713 let repo = Self::open_raw(dir.to_path_buf(), heddle_path, store, config, refs)?;
714 repo.run_open_hooks()?;
715 if repo.capability() == RepositoryCapability::GitOverlay {
716 match detect_git_head_state(dir) {
717 Ok(Some(GitHeadState::Attached(thread))) => {
718 let git_head = Head::Attached {
719 thread: ThreadName::from(thread),
720 };
721 let stale = match (repo.refs.read_head(), &git_head) {
738 (Ok(Head::Detached { state }), Head::Attached { thread }) => {
739 match repo.refs.get_thread(thread) {
740 Ok(Some(tip)) => tip == state,
741 _ => false,
742 }
743 }
744 (Ok(Head::Detached { .. }), _) => false,
745 (Ok(current), _) => current != git_head,
746 (Err(_), _) => true,
747 };
748 if stale {
749 repo.refs.write_head(&git_head)?;
750 }
751 }
752 Ok(Some(GitHeadState::Detached(git_oid))) => {
753 if let Ok(Some(state)) =
754 repo.git_overlay_mapped_change_for_git_oid(git_oid)
755 {
756 let git_head = Head::Detached { state };
757 let stale = match repo.refs.read_head() {
758 Ok(current) => current != git_head,
759 Err(_) => true,
760 };
761 if stale {
762 repo.refs.write_head(&git_head)?;
763 }
764 }
765 }
766 Ok(None) | Err(_) => {}
767 }
768 }
769 return Ok(repo);
770 }
771
772 }
775
776 current = dir.parent();
777 }
778
779 if let Some(git_root) = discovered_git_root {
780 ensure_git_overlay_exclude(&git_root)?;
781 Self::bootstrap_git_overlay(&git_root)?;
782 return Self::open(git_root);
783 }
784
785 Err(HeddleError::RepositoryNotFound(path.as_ref().to_path_buf()))
786 }
787
788 pub fn root(&self) -> &Path {
789 &self.root
790 }
791
792 pub fn heddle_dir(&self) -> &Path {
793 &self.heddle_dir
794 }
795
796 pub fn managed_checkout_source_root(&self) -> &Path {
805 self.heddle_dir.parent().unwrap_or(self.root.as_path())
806 }
807
808 pub fn managed_checkout_path(&self, thread: &str) -> PathBuf {
810 crate::thread_manifest::managed_checkout_path(
811 &self.heddle_dir,
812 thread,
813 self.managed_checkout_source_root(),
814 )
815 }
816
817 pub fn capability(&self) -> RepositoryCapability {
818 self.capability
819 }
820
821 pub fn git_overlay_sley_repository(&self) -> Result<Option<SleyRepository>> {
822 if self.capability() != RepositoryCapability::GitOverlay {
823 return Ok(None);
824 }
825
826 if let Some(repo) = self
827 .git_overlay_repo
828 .read()
829 .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?
830 .clone()
831 {
832 return Ok(Some(repo));
833 }
834
835 let mut cached = self
836 .git_overlay_repo
837 .write()
838 .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?;
839 if let Some(repo) = cached.clone() {
840 return Ok(Some(repo));
841 }
842
843 let repo = SleyRepository::discover(&self.root).map_err(|error| {
844 HeddleError::Config(format!(
845 "failed to inspect Git repository at '{}': {}",
846 self.root.display(),
847 error
848 ))
849 })?;
850 *cached = Some(repo.clone());
851 Ok(Some(repo))
852 }
853
854 pub fn capability_label(&self) -> &'static str {
855 match self.capability() {
856 RepositoryCapability::GitOverlay => "git-overlay",
857 RepositoryCapability::NativeHeddle => "native-heddle",
858 }
859 }
860
861 pub fn storage_model_label(&self) -> &'static str {
862 match self.capability() {
863 RepositoryCapability::GitOverlay => "git+heddle-sidecar",
864 RepositoryCapability::NativeHeddle => "heddle-native",
865 }
866 }
867
868 pub fn hosted_enabled(&self) -> bool {
869 self.config
870 .hosted
871 .upstream_url
872 .as_deref()
873 .is_some_and(|value| !value.trim().is_empty())
874 || self
875 .config
876 .hosted
877 .namespace
878 .as_deref()
879 .is_some_and(|value| !value.trim().is_empty())
880 }
881
882 pub fn current_lane(&self) -> Result<Option<String>> {
883 if self.capability() == RepositoryCapability::GitOverlay
884 && self.git_overlay_head_is_detached()?
885 && detect_git_in_progress_branch(&self.root)?.is_none()
886 {
887 return Ok(None);
888 }
889
890 if self.current_state()?.is_none() && self.capability() == RepositoryCapability::GitOverlay
891 {
892 return self.git_overlay_current_branch();
893 }
894
895 match self.head_ref()? {
896 Head::Attached { thread } => Ok(Some(thread.to_string())),
897 Head::Detached { .. } => Ok(None),
898 }
899 }
900
901 pub fn operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
902 if let Some(status) = self.heddle_operation_status()? {
903 return Ok(Some(status));
904 }
905 self.git_operation_status()
906 }
907
908 pub fn git_remote_tracking_status(&self) -> Result<Option<GitRemoteTrackingStatus>> {
909 if self.capability() != RepositoryCapability::GitOverlay {
910 return Ok(None);
911 }
912
913 let branch = match self.git_overlay_current_branch()? {
914 Some(branch) => branch,
915 None => return Ok(None),
916 };
917
918 let Some(git) = self.git_overlay_sley_repository()? else {
919 return Ok(None);
920 };
921 let Some(head) = git_resolve_oid(&git, "HEAD")? else {
922 return Ok(None);
923 };
924
925 let local_ref_name = GitRefName::branch_full_name(&branch);
926 if git_find_reference(&git, &local_ref_name)?.is_some()
927 && let Some(tracking_name) = git_configured_tracking_ref(&git, &branch)?
928 && let Some(upstream_head) = git_resolve_oid(&git, &tracking_name)?
929 {
930 let (ahead, behind) = git_ahead_behind_counts(&git, head, upstream_head)?;
931 if ahead == 0 && behind == 0 {
932 return Ok(None);
933 }
934 let upstream = git_remote_tracking_display_name(&tracking_name);
935 let local_oid = head.to_string();
936 let upstream_oid = upstream_head.to_string();
937 let upstream_is_undone_checkpoint =
938 self.remote_tracks_undone_git_checkpoint(&branch, &local_oid, &upstream_oid)?;
939 return Ok(Some(GitRemoteTrackingStatus {
940 branch: branch.clone(),
941 upstream: upstream.clone(),
942 ahead,
943 behind,
944 local_oid: Some(local_oid),
945 upstream_oid: Some(upstream_oid),
946 upstream_is_undone_checkpoint,
947 message: git_remote_tracking_message(
948 &branch,
949 &upstream,
950 ahead,
951 behind,
952 upstream_is_undone_checkpoint,
953 ),
954 next_action: git_remote_tracking_next_action(
955 ahead,
956 behind,
957 upstream_is_undone_checkpoint,
958 ),
959 }));
960 }
961
962 let remotes = git_remote_names(&self.root)?;
963 if remotes.is_empty() {
964 return Ok(None);
965 }
966 for remote in &remotes {
967 let remote_ref = GitRefName::remote_branch_full_name(remote, &branch);
968 if let Some(remote_head) = git_resolve_oid(&git, &remote_ref)? {
969 if remote_head == head {
970 return Ok(None);
971 }
972 let (ahead, behind) = git_ahead_behind_counts(&git, head, remote_head)?;
973 if behind > 0 {
974 let upstream = format!("{remote}/{branch}");
975 let local_oid = head.to_string();
976 let upstream_oid = remote_head.to_string();
977 let upstream_is_undone_checkpoint = self.remote_tracks_undone_git_checkpoint(
978 &branch,
979 &local_oid,
980 &upstream_oid,
981 )?;
982 return Ok(Some(GitRemoteTrackingStatus {
983 branch: branch.clone(),
984 upstream: upstream.clone(),
985 ahead,
986 behind,
987 local_oid: Some(local_oid),
988 upstream_oid: Some(upstream_oid),
989 upstream_is_undone_checkpoint,
990 message: git_remote_tracking_message(
991 &branch,
992 &upstream,
993 ahead,
994 behind,
995 upstream_is_undone_checkpoint,
996 ),
997 next_action: git_remote_tracking_next_action(
998 ahead,
999 behind,
1000 upstream_is_undone_checkpoint,
1001 ),
1002 }));
1003 }
1004 }
1005 }
1006
1007 Ok(Some(GitRemoteTrackingStatus {
1008 branch: branch.clone(),
1009 upstream: String::new(),
1010 ahead: 0,
1011 behind: 0,
1012 local_oid: Some(head.to_string()),
1013 upstream_oid: None,
1014 upstream_is_undone_checkpoint: false,
1015 message: format!("Git branch '{branch}' has no upstream tracking branch"),
1016 next_action: "heddle push".to_string(),
1017 }))
1018 }
1019
1020 fn remote_tracks_undone_git_checkpoint(
1021 &self,
1022 branch: &str,
1023 local_oid: &str,
1024 upstream_oid: &str,
1025 ) -> Result<bool> {
1026 let scope = self.op_scope();
1027 let batches = match self.oplog().redo_batches_scoped(64, Some(&scope)) {
1028 Ok(batches) => batches,
1029 Err(error) => {
1030 tracing::warn!(
1031 branch,
1032 local_oid,
1033 upstream_oid,
1034 error = %error,
1035 "could not inspect redo oplog for undone Git checkpoint status"
1036 );
1037 return Ok(false);
1038 }
1039 };
1040 Ok(batches.iter().any(|batch| {
1041 batch.entries.iter().any(|entry| {
1042 if !entry.undone {
1043 return false;
1044 }
1045 matches!(
1046 &entry.operation,
1047 OpRecord::GitCheckpoint {
1048 branch: checkpoint_branch,
1049 previous_git_oid: Some(previous_git_oid),
1050 new_git_oid,
1051 ..
1052 } if checkpoint_branch == branch
1053 && previous_git_oid == local_oid
1054 && new_git_oid == upstream_oid
1055 )
1056 })
1057 }))
1058 }
1059
1060 pub fn git_overlay_import_hint(&self) -> Result<Option<GitOverlayImportHint>> {
1061 if self.capability() != RepositoryCapability::GitOverlay {
1062 return Ok(None);
1063 }
1064 Ok(None)
1069 }
1070
1071 pub fn git_overlay_branch_tips(&self) -> Result<Vec<GitOverlayBranchTip>> {
1072 if self.capability() != RepositoryCapability::GitOverlay {
1073 return Ok(Vec::new());
1074 }
1075
1076 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1077 return Ok(Vec::new());
1078 };
1079
1080 let imported_threads: std::collections::HashSet<ThreadName> =
1081 self.refs().list_threads()?.into_iter().collect();
1082 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1083 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1084 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1085 let mut branch_tips = Vec::new();
1086
1087 for branch in git_repo.references().list_refs().map_err(|error| {
1088 HeddleError::Config(format!(
1089 "failed to enumerate git branches at '{}': {}",
1090 self.root.display(),
1091 error
1092 ))
1093 })? {
1094 let ref_name = GitRefName::new(&branch.name);
1095 if ref_name.content_namespace() != Some(GitRefContentNamespace::Branch) {
1096 continue;
1097 };
1098 let Some(name) = ref_name.short_name().map(str::to_string) else {
1099 continue;
1100 };
1101 let Some(target) =
1102 self.git_overlay_commit_tip_oid(&git_repo, &branch, "branch", &name)?
1103 else {
1104 continue;
1105 };
1106 let git_commit = target.to_string();
1107 let mapped_change = self.git_overlay_mapped_change_for_commit(
1108 &git_commit,
1109 &bridge_mapping,
1110 &ingest_mapping,
1111 &checkpoint_mapping,
1112 )?;
1113 let thread_name = ThreadName::from(name.as_str());
1114 let history_imported = if imported_threads.contains(&thread_name) {
1115 let existing_thread = self.refs().get_thread(&thread_name)?;
1119 let mapped = matches!(
1120 (existing_thread.as_ref(), mapped_change.as_ref()),
1121 (Some(existing), Some(mapped_change))
1122 if existing == mapped_change
1123 );
1124 let checkpointed = if mapped {
1125 false
1126 } else if let Some(existing) = existing_thread {
1127 self.latest_git_checkpoint_for_change(&existing)?
1128 .is_some_and(|record| record.git_commit == git_commit)
1129 || mapped_change.as_ref().is_some_and(|mapped_change| {
1130 self.change_is_ancestor(mapped_change, &existing)
1131 })
1132 } else {
1133 false
1134 };
1135 mapped || checkpointed
1136 } else {
1137 mapped_change.is_some()
1138 };
1139 branch_tips.push(GitOverlayBranchTip {
1140 branch: name,
1141 git_commit,
1142 history_imported,
1143 mapped_change,
1144 });
1145 }
1146 branch_tips.sort_by(|a, b| a.branch.cmp(&b.branch));
1147 Ok(branch_tips)
1148 }
1149
1150 pub fn git_overlay_tag_tips(&self) -> Result<Vec<GitOverlayTagTip>> {
1151 if self.capability() != RepositoryCapability::GitOverlay {
1152 return Ok(Vec::new());
1153 }
1154
1155 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1156 return Ok(Vec::new());
1157 };
1158
1159 let imported_markers: std::collections::HashSet<MarkerName> =
1160 self.refs().list_markers()?.into_iter().collect();
1161 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1162 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1163 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1164 let mut tag_tips = Vec::new();
1165
1166 for tag in git_repo.references().list_refs().map_err(|error| {
1167 HeddleError::Config(format!(
1168 "failed to enumerate git tags at '{}': {}",
1169 self.root.display(),
1170 error
1171 ))
1172 })? {
1173 let ref_name = GitRefName::new(&tag.name);
1174 if ref_name.content_namespace() != Some(GitRefContentNamespace::Tag) {
1175 continue;
1176 };
1177 let Some(name) = ref_name.short_name().map(str::to_string) else {
1178 continue;
1179 };
1180 let Some(target) = self.git_overlay_commit_tip_oid(&git_repo, &tag, "tag", &name)?
1181 else {
1182 continue;
1183 };
1184 let git_commit = target.to_string();
1185 let mapped_change = self.git_overlay_mapped_change_for_commit(
1186 &git_commit,
1187 &bridge_mapping,
1188 &ingest_mapping,
1189 &checkpoint_mapping,
1190 )?;
1191 let marker_name = MarkerName::from(name.as_str());
1192 let history_imported = if imported_markers.contains(&marker_name) {
1193 matches!(
1194 (self.refs().get_marker(&marker_name)?, mapped_change.as_ref()),
1195 (Some(existing), Some(mapped_change)) if existing == *mapped_change
1196 )
1197 } else {
1198 false
1199 };
1200 tag_tips.push(GitOverlayTagTip {
1201 tag: name,
1202 git_commit,
1203 history_imported,
1204 mapped_change,
1205 });
1206 }
1207
1208 tag_tips.sort_by(|a, b| a.tag.cmp(&b.tag));
1209 Ok(tag_tips)
1210 }
1211
1212 pub fn git_overlay_branch_tip(&self, name: &str) -> Result<Option<GitOverlayBranchTip>> {
1213 Ok(self
1214 .git_overlay_branch_tips()?
1215 .into_iter()
1216 .find(|tip| tip.branch == name))
1217 }
1218
1219 pub fn git_overlay_tag_tip(&self, name: &str) -> Result<Option<GitOverlayTagTip>> {
1220 Ok(self
1221 .git_overlay_tag_tips()?
1222 .into_iter()
1223 .find(|tip| tip.tag == name))
1224 }
1225
1226 pub fn git_overlay_mapped_change_for_branch(&self, name: &str) -> Result<Option<ChangeId>> {
1227 Ok(self
1228 .git_overlay_branch_tip(name)?
1229 .and_then(|tip| tip.mapped_change))
1230 }
1231
1232 pub fn git_overlay_mapped_change_for_remote_tracking_ref(
1233 &self,
1234 name: &str,
1235 ) -> Result<Option<ChangeId>> {
1236 if self.capability() != RepositoryCapability::GitOverlay {
1237 return Ok(None);
1238 }
1239 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1240 return Ok(None);
1241 };
1242 let full_name = GitRefName::remote_tracking_full_name(name);
1243 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1244 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1245 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1246 for reference in git_repo.references().list_refs().map_err(|error| {
1247 HeddleError::Config(format!(
1248 "failed to enumerate git remote-tracking refs at '{}': {}",
1249 self.root.display(),
1250 error
1251 ))
1252 })? {
1253 if reference.name != full_name {
1254 continue;
1255 }
1256 let Some(target) =
1257 self.git_overlay_commit_tip_oid(&git_repo, &reference, "remote branch", name)?
1258 else {
1259 return Ok(None);
1260 };
1261 return self.git_overlay_mapped_change_for_commit(
1262 &target.to_string(),
1263 &bridge_mapping,
1264 &ingest_mapping,
1265 &checkpoint_mapping,
1266 );
1267 }
1268 Ok(None)
1269 }
1270
1271 pub fn git_overlay_mapped_change_for_tag(&self, name: &str) -> Result<Option<ChangeId>> {
1272 Ok(self
1273 .git_overlay_tag_tip(name)?
1274 .and_then(|tip| tip.mapped_change))
1275 }
1276
1277 fn change_is_ancestor(&self, ancestor: &ChangeId, descendant: &ChangeId) -> bool {
1278 let mut graph = CommitGraphIndex::new(self);
1279 graph.is_ancestor(ancestor, descendant).unwrap_or(false)
1280 }
1281
1282 pub fn git_overlay_worktree_status(&self) -> Result<Option<WorktreeStatus>> {
1296 if self.capability() != RepositoryCapability::GitOverlay {
1297 return Ok(None);
1298 }
1299 let git_repo = match self.git_overlay_sley_repository() {
1300 Ok(Some(repo)) => repo,
1301 Ok(None) | Err(_) => return Ok(None),
1302 };
1303 if git_repo.workdir().is_none() {
1304 return Ok(None);
1305 }
1306
1307 let mut added = BTreeSet::new();
1308 let mut modified = BTreeSet::new();
1309 let mut deleted = BTreeSet::new();
1310 let ignore_patterns = self.ignore_patterns()?;
1311 let ignore_matcher = crate::worktree_ignore::WorktreeIgnoreMatcher::new(&ignore_patterns);
1312
1313 git_repo
1314 .stream_short_status_with_options(
1315 SleyShortStatusOptions {
1316 untracked_mode: SleyStatusUntrackedMode::All,
1317 ..SleyShortStatusOptions::default()
1318 },
1319 |entry| {
1320 let path = git_path(entry.path);
1321 if ignored_git_overlay_status_path(&path) {
1322 return Ok(SleyStreamControl::Continue);
1323 }
1324 let path = PathBuf::from(path);
1325
1326 if entry.index == b'?' && entry.worktree == b'?' {
1327 if git_overlay_untracked_path_ignored(&ignore_matcher, &path) {
1328 return Ok(SleyStreamControl::Continue);
1329 }
1330 added.insert(path);
1331 } else if entry.index == b'D' || entry.worktree == b'D' {
1332 deleted.insert(path);
1333 } else if entry.index == b'A'
1334 || entry.index == b'R'
1335 || entry.index == b'C'
1336 || entry.head_oid.is_none()
1337 {
1338 added.insert(path);
1339 } else {
1340 modified.insert(path);
1341 }
1342
1343 Ok(SleyStreamControl::Continue)
1344 },
1345 )
1346 .map_err(|error| {
1347 HeddleError::Config(format!(
1348 "failed to inspect Git worktree status at '{}': {}",
1349 self.root.display(),
1350 error
1351 ))
1352 })?;
1353
1354 Ok(Some(WorktreeStatus {
1355 modified: modified.into_iter().collect(),
1356 added: added.into_iter().collect(),
1357 deleted: deleted.into_iter().collect(),
1358 }))
1359 }
1360
1361 fn git_overlay_bridge_mapping(&self) -> Result<HashMap<String, String>> {
1362 let path = self
1363 .heddle_dir
1364 .join("git-bridge")
1365 .join("bridge-mapping.json");
1366 if !path.exists() {
1367 return Ok(HashMap::new());
1368 }
1369
1370 let contents = fs::read_to_string(path)?;
1371 if contents.trim().is_empty() {
1372 return Ok(HashMap::new());
1373 }
1374
1375 let file: GitBridgeMappingFile = serde_json::from_str(&contents)?;
1376 Ok(file
1377 .entries
1378 .into_iter()
1379 .map(|entry| (entry.git_oid, entry.change_id))
1380 .collect())
1381 }
1382
1383 pub fn git_overlay_ingest_commit_mapping(&self) -> Result<HashMap<String, String>> {
1384 let path = self.heddle_dir.join("ingest").join("sha_map.sqlite");
1385 if !path.exists() {
1386 return Ok(HashMap::new());
1387 }
1388
1389 let conn = Connection::open_with_flags(
1390 &path,
1391 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1392 )
1393 .map_err(|error| {
1394 HeddleError::Config(format!(
1395 "failed to open ingest SHA map at '{}': {}",
1396 path.display(),
1397 error
1398 ))
1399 })?;
1400 let mut stmt = conn
1401 .prepare_cached("SELECT git_sha, heddle_repr FROM sha_map WHERE kind = 0")
1402 .map_err(|error| {
1403 HeddleError::Config(format!(
1404 "failed to read ingest SHA map at '{}': {}",
1405 path.display(),
1406 error
1407 ))
1408 })?;
1409 let rows = stmt
1410 .query_map([], |row| {
1411 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1412 })
1413 .map_err(|error| {
1414 HeddleError::Config(format!(
1415 "failed to enumerate ingest SHA map at '{}': {}",
1416 path.display(),
1417 error
1418 ))
1419 })?;
1420
1421 let mut mapping = HashMap::new();
1422 for row in rows {
1423 let (git_sha, change_id) = row.map_err(|error| {
1424 HeddleError::Config(format!(
1425 "failed to read ingest SHA map row at '{}': {}",
1426 path.display(),
1427 error
1428 ))
1429 })?;
1430 mapping.insert(git_sha, change_id);
1431 }
1432 Ok(mapping)
1433 }
1434
1435 fn git_overlay_checkpoint_mapping(&self) -> Result<HashMap<String, String>> {
1436 Ok(self
1437 .list_git_checkpoints()?
1438 .into_iter()
1439 .map(|record| (record.git_commit, record.change_id))
1440 .collect())
1441 }
1442
1443 fn git_overlay_mapped_change_for_commit(
1444 &self,
1445 git_commit: &str,
1446 bridge_mapping: &HashMap<String, String>,
1447 ingest_mapping: &HashMap<String, String>,
1448 checkpoint_mapping: &HashMap<String, String>,
1449 ) -> Result<Option<ChangeId>> {
1450 let Some(change) = bridge_mapping
1451 .get(git_commit)
1452 .or_else(|| ingest_mapping.get(git_commit))
1453 .or_else(|| checkpoint_mapping.get(git_commit))
1454 else {
1455 return Ok(None);
1456 };
1457 let change_id = ChangeId::parse(change).map_err(|error| {
1458 HeddleError::Config(format!(
1459 "git commit {git_commit} maps to invalid Heddle change id '{change}': {error}"
1460 ))
1461 })?;
1462 if self.store.get_state(&change_id)?.is_some() {
1463 Ok(Some(change_id))
1464 } else {
1465 Ok(None)
1466 }
1467 }
1468
1469 fn git_overlay_mapped_git_commit_for_change_in(
1470 &self,
1471 change_id: &ChangeId,
1472 mapping: &HashMap<String, String>,
1473 ) -> Result<Option<String>> {
1474 for (git_commit, mapped_change) in mapping {
1475 let mapped_change_id = ChangeId::parse(mapped_change).map_err(|error| {
1476 HeddleError::Config(format!(
1477 "git commit {git_commit} maps to invalid Heddle change id '{mapped_change}': {error}"
1478 ))
1479 })?;
1480 if mapped_change_id == *change_id {
1481 return Ok(Some(git_commit.clone()));
1482 }
1483 }
1484 Ok(None)
1485 }
1486
1487 pub fn git_overlay_mapped_git_commit_for_change(
1488 &self,
1489 change_id: &ChangeId,
1490 ) -> Result<Option<String>> {
1491 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1492 if let Some(git_commit) =
1493 self.git_overlay_mapped_git_commit_for_change_in(change_id, &bridge_mapping)?
1494 {
1495 return Ok(Some(git_commit));
1496 }
1497
1498 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1499 if let Some(git_commit) =
1500 self.git_overlay_mapped_git_commit_for_change_in(change_id, &ingest_mapping)?
1501 {
1502 return Ok(Some(git_commit));
1503 }
1504
1505 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1506 self.git_overlay_mapped_git_commit_for_change_in(change_id, &checkpoint_mapping)
1507 }
1508
1509 pub fn git_overlay_mapped_change_for_git_commit(
1510 &self,
1511 git_commit: &str,
1512 ) -> Result<Option<ChangeId>> {
1513 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1514 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1515 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1516 self.git_overlay_mapped_change_for_commit(
1517 git_commit,
1518 &bridge_mapping,
1519 &ingest_mapping,
1520 &checkpoint_mapping,
1521 )
1522 }
1523
1524 fn git_overlay_mapped_change_for_git_oid(
1525 &self,
1526 git_oid: SleyObjectId,
1527 ) -> Result<Option<ChangeId>> {
1528 self.git_overlay_mapped_change_for_git_commit(&git_oid.to_string())
1529 }
1530
1531 pub fn git_overlay_out_of_band_commits(
1540 &self,
1541 tip_git_commit: &str,
1542 ) -> Result<Option<GitOverlayOutOfBandCommits>> {
1543 if self.capability() != RepositoryCapability::GitOverlay {
1544 return Ok(None);
1545 }
1546 let git_repo = match self.git_overlay_sley_repository() {
1547 Ok(Some(repo)) => repo,
1548 Ok(None) | Err(_) => return Ok(None),
1549 };
1550 let Ok(tip) = SleyObjectId::from_hex(git_repo.object_format(), tip_git_commit) else {
1551 return Ok(None);
1552 };
1553
1554 let bridge_mapping = self.git_overlay_bridge_mapping()?;
1555 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1556 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1557
1558 let mut pending = vec![tip];
1559 let mut visited = std::collections::HashSet::new();
1560 let mut count = 0usize;
1561 while let Some(oid) = pending.pop() {
1562 if !visited.insert(oid) {
1563 continue;
1564 }
1565 let git_commit = oid.to_string();
1566 if self
1567 .git_overlay_mapped_change_for_commit(
1568 &git_commit,
1569 &bridge_mapping,
1570 &ingest_mapping,
1571 &checkpoint_mapping,
1572 )?
1573 .is_some()
1574 {
1575 continue;
1577 }
1578 count += 1;
1579 if count >= GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT {
1580 return Ok(Some(GitOverlayOutOfBandCommits {
1581 count,
1582 truncated: true,
1583 }));
1584 }
1585 let Ok(commit) = git_repo.read_commit(&oid) else {
1586 continue;
1587 };
1588 for parent in commit.parents {
1589 pending.push(parent);
1590 }
1591 }
1592 Ok(Some(GitOverlayOutOfBandCommits {
1593 count,
1594 truncated: false,
1595 }))
1596 }
1597
1598 pub fn git_overlay_current_branch(&self) -> Result<Option<String>> {
1599 if self.capability() != RepositoryCapability::GitOverlay {
1600 return Ok(None);
1601 }
1602
1603 match detect_git_head_state(&self.root)? {
1604 Some(GitHeadState::Attached(branch)) => return Ok(Some(branch)),
1605 Some(GitHeadState::Detached(_)) | None => {}
1606 }
1607
1608 detect_git_in_progress_branch(&self.root)
1609 }
1610
1611 pub fn git_overlay_head_is_detached(&self) -> Result<bool> {
1612 if self.capability() != RepositoryCapability::GitOverlay {
1613 return Ok(false);
1614 }
1615
1616 Ok(matches!(
1617 detect_git_head_state(&self.root)?,
1618 Some(GitHeadState::Detached(_))
1619 ))
1620 }
1621
1622 pub fn git_overlay_detached_head_commit(&self) -> Result<Option<String>> {
1623 if self.capability() != RepositoryCapability::GitOverlay {
1624 return Ok(None);
1625 }
1626
1627 Ok(match detect_git_head_state(&self.root)? {
1628 Some(GitHeadState::Detached(git_oid)) => Some(git_oid.to_string()),
1629 Some(GitHeadState::Attached(_)) | None => None,
1630 })
1631 }
1632
1633 fn git_overlay_commit_tip_oid(
1634 &self,
1635 git_repo: &SleyRepository,
1636 reference: &sley::plumbing::sley_refs::Ref,
1637 ref_kind: &str,
1638 ref_name: &str,
1639 ) -> Result<Option<SleyObjectId>> {
1640 let target = match &reference.target {
1641 SleyRefTarget::Direct(oid) => *oid,
1642 SleyRefTarget::Symbolic(_) => return Ok(None),
1643 };
1644 let target = match sley::plumbing::sley_rev::peel_to_commit(
1645 git_repo.objects().as_ref(),
1646 git_repo.object_format(),
1647 &target,
1648 ) {
1649 Ok(target) => target,
1650 Err(_) => return Ok(None),
1651 };
1652
1653 let _ = (ref_kind, ref_name);
1654 Ok(Some(target))
1655 }
1656
1657 fn heddle_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1658 if self.merge_state_manager().is_merge_in_progress() {
1659 return Ok(Some(RepositoryOperationStatus {
1660 scope: OperationScope::Heddle,
1661 kind: OperationKind::Merge,
1662 in_progress: true,
1663 state: "in-progress".to_string(),
1664 message: "Heddle merge is in progress".to_string(),
1665 next_action: "heddle continue".to_string(),
1666 }));
1667 }
1668
1669 let rebase_state = self.heddle_dir.join("REBASE_STATE");
1670 if rebase_state.exists() {
1671 return Ok(Some(RepositoryOperationStatus {
1672 scope: OperationScope::Heddle,
1673 kind: OperationKind::Rebase,
1674 in_progress: true,
1675 state: "in-progress".to_string(),
1676 message: "Heddle rebase is in progress".to_string(),
1677 next_action: "heddle continue".to_string(),
1678 }));
1679 }
1680
1681 let bisect_state = self.heddle_dir.join("BISECT_STATE");
1682 if bisect_state.exists() {
1683 return Ok(Some(RepositoryOperationStatus {
1684 scope: OperationScope::Heddle,
1685 kind: OperationKind::Bisect,
1686 in_progress: true,
1687 state: "in-progress".to_string(),
1688 message: "Heddle bisect is in progress".to_string(),
1692 next_action: "heddle abort".to_string(),
1693 }));
1694 }
1695
1696 Ok(None)
1697 }
1698
1699 fn git_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1700 if self.capability() != RepositoryCapability::GitOverlay {
1701 return Ok(None);
1702 }
1703
1704 let git_dir = resolve_git_dir(&self.root)?;
1705 let raw_git_next_action = "heddle bridge git status";
1706 let candidates = [
1707 (
1708 git_dir.join("rebase-merge"),
1709 OperationKind::Rebase,
1710 "Git rebase is in progress",
1711 raw_git_next_action,
1712 ),
1713 (
1714 git_dir.join("rebase-apply"),
1715 OperationKind::Rebase,
1716 "Git rebase is in progress",
1717 raw_git_next_action,
1718 ),
1719 (
1720 git_dir.join("MERGE_HEAD"),
1721 OperationKind::Merge,
1722 "Git merge is in progress",
1723 raw_git_next_action,
1724 ),
1725 (
1726 git_dir.join("CHERRY_PICK_HEAD"),
1727 OperationKind::CherryPick,
1728 "Git cherry-pick is in progress",
1729 raw_git_next_action,
1730 ),
1731 (
1732 git_dir.join("REVERT_HEAD"),
1733 OperationKind::Revert,
1734 "Git revert is in progress",
1735 raw_git_next_action,
1736 ),
1737 (
1738 git_dir.join("BISECT_LOG"),
1739 OperationKind::Bisect,
1740 "Git bisect is in progress",
1741 raw_git_next_action,
1742 ),
1743 ];
1744
1745 for (path, kind, message, next_action) in candidates {
1746 if path.exists() {
1747 return Ok(Some(RepositoryOperationStatus {
1748 scope: OperationScope::Git,
1749 kind,
1750 in_progress: true,
1751 state: "in-progress".to_string(),
1752 message: message.to_string(),
1753 next_action: next_action.to_string(),
1754 }));
1755 }
1756 }
1757
1758 Ok(None)
1759 }
1760
1761 pub fn list_git_checkpoints(&self) -> Result<Vec<GitCheckpointRecord>> {
1762 let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1763 if !path.exists() {
1764 return Ok(Vec::new());
1765 }
1766 let contents = fs::read_to_string(path)?;
1767 if contents.trim().is_empty() {
1768 return Ok(Vec::new());
1769 }
1770 Ok(serde_json::from_str(&contents)?)
1771 }
1772
1773 pub fn latest_git_checkpoint_for_change(
1774 &self,
1775 change_id: &ChangeId,
1776 ) -> Result<Option<GitCheckpointRecord>> {
1777 let full_id = change_id.to_string_full();
1778 Ok(self
1779 .list_git_checkpoints()?
1780 .into_iter()
1781 .rev()
1782 .find(|record| record.change_id == full_id))
1783 }
1784
1785 pub fn record_git_checkpoint(
1786 &self,
1787 change_id: &ChangeId,
1788 git_commit: impl Into<String>,
1789 summary: impl Into<String>,
1790 ) -> Result<GitCheckpointRecord> {
1791 let mut records = self.list_git_checkpoints()?;
1792 let record = GitCheckpointRecord {
1793 change_id: change_id.to_string_full(),
1794 git_commit: git_commit.into(),
1795 summary: summary.into(),
1796 committed_at: Utc::now().to_rfc3339(),
1797 };
1798 let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1799 if let Some(parent) = path.parent() {
1800 fs::create_dir_all(parent)?;
1801 }
1802 records.push(record.clone());
1803 write_file_atomic(&path, serde_json::to_string_pretty(&records)?.as_bytes())?;
1804 Ok(record)
1805 }
1806
1807 pub fn init_worktree(
1808 path: impl AsRef<Path>,
1809 shared_galeed_dir: impl AsRef<Path>,
1810 ) -> Result<()> {
1811 let path = path.as_ref();
1812 let shared = shared_galeed_dir.as_ref().canonicalize()?;
1813 fs::create_dir_all(path)?;
1814 let heddle_dir = path.join(".heddle");
1815 if heddle_dir.exists() {
1816 return Err(HeddleError::RepositoryExists(path.to_path_buf()));
1817 }
1818 fs::create_dir_all(&heddle_dir)?;
1819 write_file_atomic(
1820 &heddle_dir.join("objectstore"),
1821 format!("objectstore: {}\n", shared.display()).as_bytes(),
1822 )?;
1823 fs::create_dir_all(heddle_dir.join("state"))?;
1824 Ok(())
1825 }
1826
1827 pub fn op_scope(&self) -> String {
1828 compute_op_scope(&self.root)
1842 }
1843
1844 pub fn op_scope_for_facet(&self, facet: &SpoolFacet) -> String {
1858 facet.scope_token(&self.op_scope())
1859 }
1860
1861 pub fn facet_head(&self, facet: &SpoolFacet, main_thread: &str) -> Result<Option<Head>> {
1873 if facet.is_default() {
1874 return Ok(Some(self.head_ref()?));
1875 }
1876 let thread = ThreadName::from(facet.thread_ref(main_thread).as_str());
1877 match self.refs.get_thread(&thread)? {
1878 Some(_) => Ok(Some(Head::Attached { thread })),
1879 None => Ok(None),
1880 }
1881 }
1882
1883 pub fn facet_head_state(
1885 &self,
1886 facet: &SpoolFacet,
1887 main_thread: &str,
1888 ) -> Result<Option<ChangeId>> {
1889 if facet.is_default() {
1890 return self.head();
1891 }
1892 let thread = ThreadName::from(facet.thread_ref(main_thread).as_str());
1893 self.refs.get_thread(&thread)
1894 }
1895
1896 pub fn set_facet_head(
1904 &self,
1905 facet: &SpoolFacet,
1906 main_thread: &str,
1907 state: &ChangeId,
1908 ) -> Result<()> {
1909 if facet.is_default() {
1910 return Err(HeddleError::InvalidObject(
1911 "set_facet_head is for named facets; the content facet HEAD moves via snapshot/goto"
1912 .to_string(),
1913 ));
1914 }
1915 let thread = ThreadName::from(facet.thread_ref(main_thread).as_str());
1916 self.refs.set_thread(&thread, state)
1917 }
1918
1919 pub fn commit_and_publish(
1928 &self,
1929 records: Vec<OpRecord>,
1930 ref_updates: &[RefUpdate],
1931 ) -> Result<()> {
1932 let encoded = records
1933 .iter()
1934 .map(|record| {
1935 rmp_serde::to_vec(record).map_err(|e| HeddleError::Serialization(e.to_string()))
1936 })
1937 .collect::<Result<Vec<_>>>()?;
1938 let scope = self.op_scope();
1939 let result = self
1940 .refs
1941 .commit_and_publish(&encoded, ref_updates, Some(&scope));
1942 let _ = self.oplog.refresh_cache();
1949 result
1950 }
1951
1952 pub fn commit_snapshot_atomic(
1970 &self,
1971 new_state: &ChangeId,
1972 prev_head: Option<ChangeId>,
1973 thread: Option<&ThreadName>,
1974 ) -> Result<()> {
1975 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, Vec::new())
1976 }
1977
1978 pub fn commit_snapshot_atomic_with_records(
1988 &self,
1989 new_state: &ChangeId,
1990 prev_head: Option<ChangeId>,
1991 thread: Option<&ThreadName>,
1992 extra: Vec<OpRecord>,
1993 ) -> Result<()> {
1994 let record = OpRecord::Snapshot {
1995 new_state: *new_state,
1996 prev_head,
1997 head: thread.is_none().then_some(*new_state),
1998 thread: thread.map(|name| name.to_string()),
1999 };
2000 let mut records = vec![record];
2001 records.extend(extra);
2002 let ref_update = match thread {
2003 Some(name) => RefUpdate::Thread {
2004 name: name.clone(),
2005 expected: RefExpectation::Any,
2006 new: Some(*new_state),
2007 },
2008 None => RefUpdate::Head {
2009 expected: RefExpectation::Any,
2010 new: Head::Detached { state: *new_state },
2011 },
2012 };
2013 self.commit_and_publish(records, &[ref_update])
2014 }
2015
2016 pub fn commit_snapshot_atomic_with_capture_visibility(
2035 &self,
2036 new_state: &ChangeId,
2037 prev_head: Option<ChangeId>,
2038 thread: Option<&ThreadName>,
2039 lock_held: bool,
2040 ) -> Result<()> {
2041 let binding = self
2042 .stage_default_visibility_binding(new_state, lock_held)
2043 .map_err(|e| HeddleError::Io(std::io::Error::other(format!("{e:#}"))))?;
2044 let (extra, rewind_to): (Vec<OpRecord>, Option<Option<Vec<u8>>>) = match binding {
2045 Some(binding) => (vec![binding.record], Some(binding.prior_sidecar)),
2046 None => (Vec::new(), None),
2047 };
2048
2049 #[cfg(test)]
2052 let commit_result = if crate::repository_state_visibility::take_visibility_commit_fault(
2053 crate::repository_state_visibility::VisibilityCommitFault::SnapshotCommit,
2054 ) {
2055 Err(HeddleError::Io(std::io::Error::other(
2056 "injected snapshot-commit failure after staging visibility binding",
2057 )))
2058 } else {
2059 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra)
2060 };
2061 #[cfg(not(test))]
2062 let commit_result =
2063 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra);
2064
2065 match commit_result {
2066 Ok(()) => Ok(()),
2067 Err(commit_err) => {
2068 if let Some(prior) = rewind_to {
2069 if let Err(rewind_err) = self.restore_state_visibility_sidecar(new_state, prior)
2073 {
2074 tracing::warn!(
2075 state = %new_state,
2076 error = %rewind_err,
2077 "rewind of staged visibility binding after a failed snapshot commit also failed"
2078 );
2079 }
2080 }
2081 Err(commit_err)
2082 }
2083 }
2084 }
2085
2086 pub fn repo_config(&self) -> &RepoConfig {
2087 &self.config
2088 }
2089
2090 pub fn config(&self) -> &RepoConfig {
2091 self.repo_config()
2092 }
2093
2094 pub fn get_tree_for_state(&self, state_id: &ChangeId) -> Result<Option<Tree>> {
2095 let state = match self.store.get_state(state_id)? {
2096 Some(state) => state,
2097 None => return Ok(None),
2098 };
2099 self.store.get_tree(&state.tree)
2100 }
2101
2102 pub fn ignore_patterns(&self) -> Result<Vec<String>> {
2103 let mut patterns = self.config.worktree.ignore.clone();
2104 patterns.push(format!(
2116 "/{}",
2117 repository_thread_materialize::COURTESY_STUB_FILENAME
2118 ));
2119 if self.capability() == RepositoryCapability::GitOverlay {
2120 patterns.push(".git".to_string());
2121 append_ignore_file_patterns(&mut patterns, &self.root.join(".gitignore"))?;
2122 }
2123 append_ignore_file_patterns(
2131 &mut patterns,
2132 &self.root.join(".heddle").join("info").join("exclude"),
2133 )?;
2134 let path = self.root.join(".heddleignore");
2135
2136 if path.exists() {
2137 append_ignore_file_patterns(&mut patterns, &path)?;
2138 }
2139
2140 Ok(patterns)
2141 }
2142
2143 pub fn nested_thread_worktree_exclusions(&self, walk_root: &Path) -> Result<Vec<PathBuf>> {
2158 let canonical_walk_root = walk_root
2159 .canonicalize()
2160 .unwrap_or_else(|_| walk_root.to_path_buf());
2161 let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2162 let mut exclusions: Vec<PathBuf> = Vec::new();
2163 let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
2164 for thread in manager.list()? {
2165 for candidate in [
2166 Some(&thread.execution_path),
2167 thread.materialized_path.as_ref(),
2168 ]
2169 .into_iter()
2170 .flatten()
2171 {
2172 if candidate.as_os_str().is_empty() {
2173 continue;
2174 }
2175 let canonical = match candidate.canonicalize() {
2176 Ok(path) => path,
2177 Err(_) => continue,
2178 };
2179 if canonical == canonical_walk_root {
2180 continue;
2181 }
2182 if !canonical.starts_with(&canonical_walk_root) {
2183 continue;
2184 }
2185 if seen.insert(canonical.clone()) {
2186 exclusions.push(canonical);
2187 }
2188 }
2189 }
2190 Ok(exclusions)
2191 }
2192
2193 pub fn head(&self) -> Result<Option<ChangeId>> {
2194 Ok(match self.head_ref()? {
2195 Head::Attached { thread } => match self.refs.get_thread(&thread)? {
2196 Some(change_id) => Some(change_id),
2197 None if self.capability() == RepositoryCapability::GitOverlay => {
2198 self.git_overlay_mapped_change_for_branch(&thread)?
2199 }
2200 None => None,
2201 },
2202 Head::Detached { state } => Some(state),
2203 })
2204 }
2205
2206 pub fn head_ref(&self) -> Result<Head> {
2207 let raw = self.refs.read_head()?;
2208 if self.capability() != RepositoryCapability::GitOverlay {
2209 return Ok(raw);
2210 }
2211 if matches!(raw, Head::Detached { .. }) {
2212 return Ok(raw);
2213 }
2214 if let Some(GitHeadState::Detached(git_oid)) = detect_git_head_state(&self.root)?
2215 && let Some(state) = self.git_overlay_mapped_change_for_git_oid(git_oid)?
2216 {
2217 return Ok(Head::Detached { state });
2218 }
2219 let Some(branch) = self.git_overlay_current_branch()? else {
2220 return Ok(raw);
2221 };
2222 if matches!(&raw, Head::Attached { thread } if *thread == branch) {
2223 return Ok(raw);
2224 }
2225 let branch_thread = ThreadName::from(branch.as_str());
2226 if self.refs.get_thread(&branch_thread)?.is_some()
2227 || self
2228 .git_overlay_mapped_change_for_branch(&branch)?
2229 .is_some()
2230 {
2231 return Ok(Head::Attached {
2232 thread: branch_thread,
2233 });
2234 }
2235 Ok(raw)
2236 }
2237
2238 pub fn active_worktree_path(&self) -> Result<PathBuf> {
2255 let head = self.refs.read_head()?;
2256 let Head::Attached { thread } = head else {
2257 return Ok(self.root.clone());
2258 };
2259 let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2260 let Some(thread_record) = manager.find_by_thread(&thread)? else {
2261 return Ok(self.root.clone());
2262 };
2263 if !thread_record.execution_path.as_os_str().is_empty() {
2264 return Ok(thread_record.execution_path);
2265 }
2266 if let Some(path) = thread_record.materialized_path {
2267 return Ok(path);
2268 }
2269 Ok(self.root.clone())
2270 }
2271
2272 pub fn current_state(&self) -> Result<Option<State>> {
2273 match self.head()? {
2274 Some(id) => self.store.get_state(&id),
2275 None => Ok(None),
2276 }
2277 }
2278
2279 pub fn get_principal(&self) -> Result<Principal> {
2280 if let Some(principal) = Principal::from_env() {
2281 return Ok(principal);
2282 }
2283
2284 if let Some(config) = &self.config.principal {
2285 return Ok(Principal::new(&config.name, &config.email));
2286 }
2287
2288 if self.capability() == RepositoryCapability::GitOverlay
2289 && let Some(principal) = git_config_principal(&self.root)
2290 {
2291 return Ok(principal);
2292 }
2293
2294 if let Some(principal) = self.shared_checkout_parent_git_principal() {
2295 return Ok(principal);
2296 }
2297
2298 Ok(Principal::new("Unknown", "unknown@example.com"))
2299 }
2300
2301 fn shared_checkout_parent_git_principal(&self) -> Option<Principal> {
2302 let local_heddle_dir = self.root.join(".heddle");
2303 if local_heddle_dir == self.heddle_dir || !local_heddle_dir.join("objectstore").is_file() {
2304 return None;
2305 }
2306 let parent_root = self.heddle_dir.parent()?;
2307 if parent_root == self.root {
2308 return None;
2309 }
2310 git_config_principal(parent_root)
2311 }
2312
2313 pub fn get_attribution(&self) -> Result<Attribution> {
2314 let principal = self.get_principal()?;
2315
2316 if let Some(agent) = self.resolve_agent() {
2317 Ok(Attribution::with_agent(principal, agent))
2318 } else {
2319 Ok(Attribution::human(principal))
2320 }
2321 }
2322
2323 pub fn is_shallow(&self, id: &ChangeId) -> bool {
2324 self.shallow.read_or_poisoned().is_shallow(id)
2325 }
2326
2327 pub fn set_shallow(&self, state_id: &ChangeId, _parents: &[ChangeId]) -> Result<()> {
2328 self.shallow.write_or_poisoned().add_shallow(*state_id)?;
2329 Ok(())
2330 }
2331
2332 pub fn record_missing_blob(&self, hash: ContentHash) -> Result<()> {
2333 self.partial_fetch_metadata().record_missing_blob(hash)?;
2334 Ok(())
2335 }
2336
2337 pub fn seed_default_thread(&self) -> Result<()> {
2352 let main_thread = ThreadName::from("main");
2353 if self.refs.get_thread(&main_thread)?.is_some() {
2354 return Ok(());
2355 }
2356
2357 let empty_tree = Tree::new();
2358 let tree_hash = self.store.put_tree(&empty_tree)?;
2359 let state = State::new_snapshot(tree_hash, vec![], Attribution::human(seed_principal()));
2360 self.store.put_state(&state)?;
2361 self.refs.set_thread(&main_thread, &state.change_id)?;
2362 Ok(())
2363 }
2364
2365 pub fn clear_missing_blob(&self, hash: &ContentHash) -> Result<()> {
2366 self.partial_fetch_metadata().clear_missing_blob(hash)?;
2367 Ok(())
2368 }
2369
2370 pub fn missing_blobs(&self) -> Result<Vec<ContentHash>> {
2371 self.partial_fetch_metadata().missing_blobs()
2372 }
2373
2374 pub fn clear_all_missing_blobs(&self) -> Result<bool> {
2375 self.partial_fetch_metadata().clear_all_missing_blobs()
2376 }
2377
2378 pub fn is_missing_blob(&self, hash: &ContentHash) -> Result<bool> {
2379 self.partial_fetch_metadata().is_missing_blob(hash)
2380 }
2381
2382 pub fn require_tree(&self, hash: &ContentHash) -> Result<Tree> {
2409 self.store
2410 .get_tree(hash)?
2411 .ok_or_else(|| HeddleError::MissingObject {
2412 object_type: "tree".to_string(),
2413 id: hash.to_hex(),
2414 })
2415 }
2416
2417 pub fn require_blob(&self, hash: &ContentHash) -> Result<objects::object::Blob> {
2418 if let Some(blob) = self.store.get_blob(hash)? {
2419 if self.is_missing_blob(hash)? {
2420 self.clear_missing_blob(hash)?;
2421 }
2422 return Ok(blob);
2423 }
2424
2425 if self.is_missing_blob(hash)? {
2426 if let Some(hydrator) = self.blob_hydrator() {
2430 hydrator.hydrate(self, hash)?;
2431 if let Some(blob) = self.store.get_blob(hash)? {
2432 self.clear_missing_blob(hash)?;
2433 return Ok(blob);
2434 }
2435 }
2440 return Err(HeddleError::MissingObject {
2441 object_type: "blob".to_string(),
2442 id: hash.to_hex(),
2443 });
2444 }
2445
2446 Err(HeddleError::NotFound(hash.to_hex()))
2447 }
2448
2449 pub fn set_blob_hydrator(&self, hydrator: Arc<dyn BlobHydrator>) {
2462 *self.blob_hydrator.write_or_poisoned() = Some(hydrator);
2463 }
2464
2465 pub fn blob_hydrator(&self) -> Option<Arc<dyn BlobHydrator>> {
2467 self.blob_hydrator.read_or_poisoned().clone()
2468 }
2469
2470 pub fn set_progress(&self, progress: Progress) {
2477 *self.progress.write_or_poisoned() = progress;
2478 }
2479
2480 pub fn progress(&self) -> Progress {
2483 self.progress.read_or_poisoned().clone()
2484 }
2485
2486 fn partial_fetch_metadata(&self) -> repository_partial_fetch::PartialFetchMetadataManager {
2487 repository_partial_fetch::PartialFetchMetadataManager::new(&self.heddle_dir)
2488 }
2489
2490 pub fn shallow(&self) -> std::sync::RwLockReadGuard<'_, ShallowInfo> {
2491 self.shallow.read_or_poisoned()
2492 }
2493}
2494
2495fn ensure_git_overlay_exclude(root: &Path) -> Result<()> {
2496 let git_dir = match SleyRepository::discover(root) {
2497 Ok(repo) if repo.workdir().is_some() => repo.git_dir().to_path_buf(),
2498 _ => root.join(".git"),
2499 };
2500 if !git_dir.is_dir() {
2501 return Ok(());
2502 }
2503
2504 let info_dir = git_dir.join("info");
2505 fs::create_dir_all(&info_dir)?;
2506 let exclude_path = info_dir.join("exclude");
2507 let mut contents = fs::read_to_string(&exclude_path).unwrap_or_default();
2508 let existing_lines = contents.lines().map(str::trim).collect::<BTreeSet<_>>();
2509 let mut missing = Vec::new();
2510 for pattern in GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS {
2511 if !existing_lines
2512 .iter()
2513 .any(|line| git_overlay_exclude_line_matches(line, pattern))
2514 {
2515 missing.push(*pattern);
2516 }
2517 }
2518 if missing.is_empty() {
2519 return Ok(());
2520 }
2521 if !contents.is_empty() && !contents.ends_with('\n') {
2522 contents.push('\n');
2523 }
2524 contents.push_str("# Heddle local metadata\n");
2525 for pattern in missing {
2526 contents.push_str(pattern);
2527 contents.push('\n');
2528 }
2529 fs::write(exclude_path, contents)?;
2530 Ok(())
2531}
2532
2533fn git_overlay_exclude_line_matches(line: &str, pattern: &str) -> bool {
2534 line == pattern
2535 || matches!(
2536 (line, pattern),
2537 (".heddle", ".heddle/") | ("/.heddle/", ".heddle/") | ("/.heddle", ".heddle/")
2538 )
2539}
2540
2541pub(crate) fn seed_principal() -> Principal {
2546 Principal::new("Heddle", "init@heddle")
2547}
2548
2549pub fn is_synthetic_root(state: &State) -> bool {
2554 state.parents.is_empty()
2555 && state.intent.is_none()
2556 && state.attribution.principal == seed_principal()
2557 && state.attribution.agent.is_none()
2558}
2559
2560fn parse_objectstore_pointer(content: &str) -> Option<PathBuf> {
2564 for line in content.lines() {
2565 if let Some(path) = line.strip_prefix("objectstore:") {
2566 let path = path.trim();
2567 if !path.is_empty() {
2568 return Some(PathBuf::from(path));
2569 }
2570 }
2571 }
2572 None
2573}
2574
2575fn has_git_metadata(path: &Path) -> bool {
2576 let dot_git = path.join(".git");
2577 if !(dot_git.is_dir() || dot_git.is_file()) {
2578 return false;
2579 }
2580
2581 SleyRepository::discover(path).is_ok()
2582}
2583
2584fn metadataless_managed_thread_root(start_path: &Path) -> Option<PathBuf> {
2598 let mut cur: Option<&Path> = Some(start_path);
2599 while let Some(dir) = cur {
2600 if let Some(thread_dir) = dir.parent()
2601 && let Some(threads) = thread_dir.parent()
2602 && threads.file_name().and_then(|n| n.to_str()) == Some("threads")
2603 && let Some(heddle) = threads.parent()
2604 && heddle.file_name().and_then(|n| n.to_str()) == Some(".heddle")
2605 && heddle.join("objects").is_dir()
2606 && !dir.join(".heddle").exists()
2607 {
2608 return Some(dir.to_path_buf());
2609 }
2610 cur = dir.parent();
2611 }
2612 None
2613}
2614
2615fn git_config_principal(root: &Path) -> Option<Principal> {
2616 let git_repo = SleyRepository::discover(root).ok()?;
2617 let config = git_repo.config_snapshot().ok()?;
2618 let name = config.get("user", None, "name")?.to_string();
2619 let email = config.get("user", None, "email")?.to_string();
2620 if name.trim().is_empty() || email.trim().is_empty() {
2621 return None;
2622 }
2623 Some(Principal::new(&name, &email))
2624}
2625
2626fn git_path(path: &[u8]) -> String {
2627 String::from_utf8_lossy(path).into_owned()
2628}
2629
2630fn ignored_git_overlay_status_path(path: &str) -> bool {
2631 path == ".heddle" || path.starts_with(".heddle/")
2632}
2633
2634fn git_overlay_untracked_path_ignored(
2635 ignore_matcher: &crate::worktree_ignore::WorktreeIgnoreMatcher,
2636 path: &Path,
2637) -> bool {
2638 let parent = path.parent().unwrap_or_else(|| Path::new(""));
2639 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
2640 return false;
2641 };
2642 ignore_matcher.should_prune_directory_child(parent, name)
2643}
2644
2645fn git_remote_names(root: &Path) -> Result<Vec<String>> {
2646 let repo = match SleyRepository::discover(root) {
2647 Ok(repo) => repo,
2648 Err(_) => return Ok(Vec::new()),
2649 };
2650 repo.remote_names()
2651 .map(|names| {
2652 names
2653 .into_iter()
2654 .filter(|name| !name.trim().is_empty())
2655 .collect()
2656 })
2657 .map_err(|error| HeddleError::Config(error.to_string()))
2658}
2659
2660fn git_find_reference(repo: &SleyRepository, name: &str) -> Result<Option<SleyReference>> {
2661 repo.find_reference(name).map_err(|error| {
2662 HeddleError::Config(format!("failed to inspect Git reference '{name}': {error}"))
2663 })
2664}
2665
2666fn git_resolve_oid(repo: &SleyRepository, rev: &str) -> Result<Option<SleyObjectId>> {
2667 match repo.rev_parse(rev) {
2668 Ok(id) => Ok(Some(id)),
2669 Err(_) => Ok(None),
2670 }
2671}
2672
2673fn git_configured_tracking_ref(repo: &SleyRepository, branch: &str) -> Result<Option<String>> {
2674 let config = repo
2675 .config_snapshot()
2676 .map_err(|error| HeddleError::Config(error.to_string()))?;
2677 let Some(remote) = config.get("branch", Some(branch), "remote") else {
2678 return Ok(None);
2679 };
2680 let Some(merge) = config.get("branch", Some(branch), "merge") else {
2681 return Ok(None);
2682 };
2683 if remote == "." {
2684 return Ok(Some(merge.to_string()));
2685 }
2686 let merge_ref = GitRefName::new(merge);
2687 if merge_ref.content_namespace() != Some(GitRefContentNamespace::Branch) {
2688 return Ok(None);
2689 };
2690 let Some(short) = merge_ref.short_name() else {
2691 return Ok(None);
2692 };
2693 Ok(Some(GitRefName::remote_branch_full_name(remote, short)))
2694}
2695
2696fn git_ahead_behind_counts(
2697 git: &SleyRepository,
2698 head: SleyObjectId,
2699 upstream: SleyObjectId,
2700) -> Result<(usize, usize)> {
2701 if upstream == head {
2702 return Ok((0, 0));
2703 }
2704 let db = sley::ObjectDatabase::from_git_dir(git.common_dir(), git.object_format());
2705 let (ahead, behind) = sley::plumbing::sley_rev::ahead_behind_counts(
2706 git.git_dir(),
2707 git.object_format(),
2708 &db,
2709 &head,
2710 &upstream,
2711 )
2712 .map_err(|error| HeddleError::Config(error.to_string()))?;
2713 Ok((ahead, behind))
2714}
2715
2716fn git_remote_tracking_display_name(name: &str) -> String {
2717 name.strip_prefix("refs/remotes/")
2718 .unwrap_or(name)
2719 .to_string()
2720}
2721
2722fn git_remote_tracking_message(
2723 branch: &str,
2724 upstream: &str,
2725 ahead: usize,
2726 behind: usize,
2727 upstream_is_undone_checkpoint: bool,
2728) -> String {
2729 if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
2730 return format!(
2731 "Upstream '{upstream}' still points at a Git commit that was undone locally on branch '{branch}'"
2732 );
2733 }
2734 match (ahead, behind) {
2735 (0, behind) => format!(
2736 "Git branch '{}' is behind upstream '{}' by {} commit(s)",
2737 branch, upstream, behind
2738 ),
2739 (ahead, 0) => format!(
2740 "Git branch '{}' is ahead of upstream '{}' by {} commit(s)",
2741 branch, upstream, ahead
2742 ),
2743 (ahead, behind) => format!(
2744 "Git branch '{}' has diverged from upstream '{}' (ahead {}, behind {})",
2745 branch, upstream, ahead, behind
2746 ),
2747 }
2748}
2749
2750fn git_remote_tracking_next_action(
2751 ahead: usize,
2752 behind: usize,
2753 upstream_is_undone_checkpoint: bool,
2754) -> String {
2755 if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
2756 return "heddle push --force".to_string();
2757 }
2758 match (ahead, behind) {
2759 (0, _) => "heddle pull".to_string(),
2760 (_, 0) => "heddle push".to_string(),
2761 _ => "heddle pull".to_string(),
2762 }
2763}
2764
2765fn repository_capability_for_root(root: &Path) -> RepositoryCapability {
2766 if has_git_metadata(root) {
2767 RepositoryCapability::GitOverlay
2768 } else {
2769 RepositoryCapability::NativeHeddle
2770 }
2771}
2772
2773fn append_ignore_file_patterns(patterns: &mut Vec<String>, path: &Path) -> Result<()> {
2774 if !path.exists() {
2775 return Ok(());
2776 }
2777 let contents = std::fs::read_to_string(path)?;
2778 for line in contents.lines() {
2779 let trimmed = line.trim();
2780 if trimmed.is_empty() || trimmed.starts_with('#') {
2781 continue;
2782 }
2783 if !patterns.iter().any(|pattern| pattern == trimmed) {
2784 patterns.push(trimmed.to_string());
2785 }
2786 }
2787 Ok(())
2788}
2789
2790fn detect_git_head_state(path: &Path) -> Result<Option<GitHeadState>> {
2793 let repo = SleyRepository::discover(path).map_err(|error| {
2794 HeddleError::Config(format!(
2795 "failed to inspect git repository at '{}': {}",
2796 path.display(),
2797 error
2798 ))
2799 })?;
2800 let head = match repo.head_state() {
2801 Ok(head) => head,
2802 Err(_) => return Ok(None),
2803 };
2804
2805 if head.is_missing() {
2806 return Ok(None);
2807 }
2808 if let Some(name) = head.branch_name() {
2809 if name.is_empty() {
2810 return Ok(None);
2811 }
2812 return Ok(Some(GitHeadState::Attached(name.to_string())));
2813 }
2814 if head.is_detached()
2815 && let Some(id) = head.oid()
2816 {
2817 return Ok(Some(GitHeadState::Detached(id)));
2818 }
2819 Ok(None)
2820}
2821
2822fn detect_git_head(path: &Path) -> Result<Option<Head>> {
2824 if let Some(GitHeadState::Attached(thread)) = detect_git_head_state(path)? {
2825 return Ok(Some(Head::Attached {
2826 thread: ThreadName::from(thread),
2827 }));
2828 }
2829 Ok(None)
2830}
2831
2832fn resolve_git_dir(path: &Path) -> Result<PathBuf> {
2833 let repo = SleyRepository::discover(path).map_err(|error| {
2834 HeddleError::Config(format!(
2835 "failed to resolve git dir at '{}': {}",
2836 path.display(),
2837 error
2838 ))
2839 })?;
2840 Ok(repo.git_dir().to_path_buf())
2841}
2842
2843fn detect_git_in_progress_branch(path: &Path) -> Result<Option<String>> {
2844 let git_dir = resolve_git_dir(path)?;
2845 for marker in ["rebase-merge/head-name", "rebase-apply/head-name"] {
2846 let branch_path = git_dir.join(marker);
2847 if !branch_path.exists() {
2848 continue;
2849 }
2850 let raw = fs::read_to_string(&branch_path)?;
2851 let value = raw.trim();
2852 let ref_name = GitRefName::new(value);
2853 if ref_name.content_namespace() == Some(GitRefContentNamespace::Branch)
2854 && let Some(short) = ref_name.short_name()
2855 {
2856 return Ok(Some(short.to_string()));
2857 }
2858 if !value.is_empty() {
2859 return Ok(Some(value.to_string()));
2860 }
2861 }
2862 Ok(None)
2863}
2864
2865#[cfg(test)]
2866mod tests {
2867 use std::{path::Path, process::Command};
2868
2869 use tempfile::TempDir;
2870
2871 use super::Repository;
2872 use crate::RepositoryCapability;
2873
2874 fn git(root: &Path, args: &[&str]) {
2875 let status = Command::new("git")
2876 .current_dir(root)
2877 .args(args)
2878 .status()
2879 .expect("spawn git");
2880 assert!(status.success(), "git {:?} failed in {}", args, root.display());
2881 }
2882
2883 fn git_output(root: &Path, args: &[&str]) -> String {
2884 let output = Command::new("git")
2885 .current_dir(root)
2886 .args(args)
2887 .output()
2888 .expect("spawn git");
2889 assert!(
2890 output.status.success(),
2891 "git {:?} failed: {}",
2892 args,
2893 String::from_utf8_lossy(&output.stderr)
2894 );
2895 String::from_utf8(output.stdout).unwrap().trim().to_string()
2896 }
2897
2898 fn init_git_with_identity(root: &Path) {
2899 sley::Repository::init(root).expect("init git repository");
2900 git(root, &["config", "user.email", "test@heddle.local"]);
2901 git(root, &["config", "user.name", "Heddle Test"]);
2902 }
2903
2904 fn configure_main_tracks_origin(root: &Path) {
2905 git(root, &["config", "branch.main.remote", "origin"]);
2906 git(root, &["config", "branch.main.merge", "refs/heads/main"]);
2907 }
2908
2909 fn diverged_two_ahead_one_behind_fixture() -> TempDir {
2920 let temp = TempDir::new().unwrap();
2921 let root = temp.path();
2922 init_git_with_identity(root);
2923 git(root, &["commit", "--allow-empty", "-m", "base"]);
2924 let base = git_output(root, &["rev-parse", "HEAD"]);
2925 git(root, &["commit", "--allow-empty", "-m", "u1"]);
2926 let upstream_tip = git_output(root, &["rev-parse", "HEAD"]);
2927 git(root, &["reset", "--hard", &base]);
2928 git(root, &["commit", "--allow-empty", "-m", "l1"]);
2929 git(root, &["commit", "--allow-empty", "-m", "l2"]);
2930 git(
2931 root,
2932 &["update-ref", "refs/remotes/origin/main", &upstream_tip],
2933 );
2934 configure_main_tracks_origin(root);
2935 temp
2936 }
2937
2938 #[test]
2939 fn git_remote_tracking_reports_diverged_ahead_behind() {
2940 let temp = diverged_two_ahead_one_behind_fixture();
2941 let repo = Repository::init_default(temp.path()).unwrap();
2942 assert_eq!(repo.capability(), RepositoryCapability::GitOverlay);
2943
2944 let status = repo
2945 .git_remote_tracking_status()
2946 .unwrap()
2947 .expect("configured upstream with drift should return status");
2948 assert_eq!(status.ahead, 2);
2949 assert_eq!(status.behind, 1);
2950 assert_eq!(status.upstream, "origin/main");
2951 }
2952
2953 #[test]
2954 fn git_remote_tracking_in_sync_returns_none() {
2955 let temp = TempDir::new().unwrap();
2956 let root = temp.path();
2957 init_git_with_identity(root);
2958 git(root, &["commit", "--allow-empty", "-m", "only"]);
2959 let tip = git_output(root, &["rev-parse", "HEAD"]);
2960 git(root, &["update-ref", "refs/remotes/origin/main", &tip]);
2961 configure_main_tracks_origin(root);
2962
2963 let repo = Repository::init_default(root).unwrap();
2964 assert!(repo.git_remote_tracking_status().unwrap().is_none());
2965 }
2966
2967 #[test]
2968 fn git_remote_tracking_without_upstream_config() {
2969 let temp = TempDir::new().unwrap();
2970 let root = temp.path();
2971 init_git_with_identity(root);
2972 git(root, &["commit", "--allow-empty", "-m", "only"]);
2973 git(root, &["remote", "add", "origin", root.to_str().unwrap()]);
2974
2975 let repo = Repository::init_default(root).unwrap();
2976 let status = repo
2977 .git_remote_tracking_status()
2978 .unwrap()
2979 .expect("no upstream config still reports actionable status");
2980 assert_eq!(status.ahead, 0);
2981 assert_eq!(status.behind, 0);
2982 assert!(status.upstream.is_empty());
2983 assert!(
2984 status
2985 .message
2986 .contains("has no upstream tracking branch")
2987 );
2988 }
2989}