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#[cfg(feature = "git-overlay")]
13#[path = "git_overlay_object_source.rs"]
14mod git_overlay_object_source;
15#[path = "repo_config.rs"]
16pub(crate) mod repo_config;
17#[path = "repository_context.rs"]
18mod repository_context;
19#[path = "repository_diff.rs"]
20mod repository_diff;
21#[path = "repository_goto.rs"]
22mod repository_goto;
23#[path = "repository_history.rs"]
24mod repository_history;
25#[path = "repository_maintenance.rs"]
26mod repository_maintenance;
27#[path = "repository_materialization.rs"]
28mod repository_materialization;
29#[path = "repository_partial_fetch.rs"]
30mod repository_partial_fetch;
31#[path = "repository_provenance/mod.rs"]
32mod repository_provenance;
33#[path = "repository_recovery.rs"]
34mod repository_recovery;
35#[path = "repository_resolve.rs"]
36mod repository_resolve;
37#[path = "repository_signing.rs"]
38mod repository_signing;
39use std::{
40 collections::{BTreeSet, HashMap},
41 fs,
42 path::{Path, PathBuf},
43 sync::{Arc, RwLock},
44};
45
46use chrono::Utc;
47pub use commit_graph::{CommitGraphIndex, find_merge_base};
48#[cfg(feature = "async-source")]
49pub use commit_graph::{find_merge_base_async, is_ancestor_async};
50pub use context_suggestions::{
51 ContextSuggestion, ContextSuggestionTier, HIGH_SUGGESTION_THRESHOLD,
52 MAJOR_REWRITE_THRESHOLD_PCT, MEDIUM_SUGGESTION_THRESHOLD, SUGGESTION_WINDOW,
53 compute_rewrite_pct, is_major_rewrite,
54};
55pub use objects::object::DiffKind;
56#[cfg(feature = "git-overlay")]
57use objects::object::MarkerName;
58use objects::{
59 Progress,
60 error::{HeddleError, Result},
61 fs_atomic::write_file_atomic,
62 lock::{RepoLock, RepositoryLockExt},
63 object::{Attribution, ContentHash, Principal, State, StateId, ThreadName, Tree},
64 store::{AnyStore, FsStore, ObjectStore, ShallowInfo},
65 sync::RwLockExt,
66 worktree::WorktreeStatus,
67};
68use oplog::{OpLog, OpLogBackend, OpRecord};
69use refs::{Head, RefBackend, RefExpectation, RefManager, RefUpdate};
70pub use refs::{RefSummaryIndexInspection, SpoolFacet};
71pub use repo_config::{
72 HostedConfig, OutputFormat, RedactConfig, RepoConfig, RepositorySourceAuthority, TrustedKey,
73};
74#[allow(unused_imports)]
78pub use repo_config::{
79 PatternDeviationToml, ReviewConfig, ReviewSignalsToml, SelfFlaggedToml, SignalEnableToml,
80 SignalModuleToml, TestReachabilityToml,
81};
82#[cfg(feature = "async-source")]
83pub use repository_history::query_history_async;
84pub use repository_history::{
85 ChangedPathFilter, ChangedPathFilters, HistoryQuery, query_history_from_source,
86};
87pub use repository_maintenance::{
88 ChangeMonitorInspection, CommitGraphInspection, PackFilesInspection, PartialFetchInspection,
89 PullPlannerCacheInspection, RefCountsInspection, RepositoryMaintenanceRunReport,
90 RepositoryPerformanceInspectionReport, WorktreeIndexInspection,
91};
92pub use repository_materialization::WarmCanonicalStoreStats;
93pub use repository_partial_fetch::MissingBlob;
94pub use repository_snapshot::{SnapshotExecution, SnapshotProfile};
95pub use repository_thread_materialize::{CheckoutMaterialization, ThreadCaptureOutcome};
96pub use repository_tree::{TreeBuildProfile, WorktreeCompareProfile};
97pub use repository_worktree_status::{UntrackedSet, UntrackedSubtree, WorktreeStatusDetailed};
98use rusqlite::{Connection, OpenFlags};
99use serde::{Deserialize, Serialize};
100use sley::{
101 ObjectId as SleyObjectId, Reference as SleyReference, ReferenceTarget as SleyRefTarget,
102 Repository as SleyRepository,
103};
104#[cfg(feature = "git-overlay")]
105use sley::{
106 ShortStatusOptions as SleyShortStatusOptions, StatusUntrackedMode as SleyStatusUntrackedMode,
107 StreamControl as SleyStreamControl,
108};
109
110use crate::{GitRefContentNamespace, GitRefName};
111#[path = "repository_snapshot.rs"]
112mod repository_snapshot;
113#[cfg(test)]
114#[path = "repository_tests.rs"]
115mod repository_tests;
116#[path = "repository_thread_materialize.rs"]
117mod repository_thread_materialize;
118#[path = "repository_tree.rs"]
119mod repository_tree;
120#[path = "repository_worktree_apply.rs"]
121pub(crate) mod repository_worktree_apply;
122#[path = "repository_worktree_status.rs"]
123mod repository_worktree_status;
124#[path = "status_tracked_refresh.rs"]
125mod status_tracked_refresh;
126#[path = "status_untracked_scan.rs"]
127mod status_untracked_scan;
128
129const GIT_CHECKPOINTS_FILE: &str = "git-checkpoints.json";
130const GIT_CHECKPOINT_INTENT_FILE: &str = "git-checkpoint-intent.json";
131const GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS: &[&str] = &[".heddle/"];
132
133#[derive(Debug)]
134pub struct GitOverlayShortStatus {
135 pub worktree: WorktreeStatus,
136 pub index_staged_paths: Vec<String>,
137 pub index_extra_paths: Vec<String>,
138 pub index_plan_applicable: bool,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum RepositoryCapability {
143 GitOverlay,
144 NativeHeddle,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148enum GitHeadState {
149 Attached(String),
150 Detached(SleyObjectId),
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct GitCheckpointRecord {
155 pub state_id: String,
156 pub git_commit: String,
157 pub summary: String,
158 pub committed_at: String,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
162#[serde(rename_all = "snake_case")]
163pub enum GitCheckpointIntentPhase {
164 Prepared,
165 Published,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169pub struct GitCheckpointIntent {
170 pub version: u32,
171 pub state_id: String,
172 pub branch: String,
173 pub previous_git_oid: Option<String>,
174 pub new_git_oid: String,
175 pub summary: String,
176 pub phase: GitCheckpointIntentPhase,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct GitImportGuidance {
181 pub current_branch: String,
182 pub missing_branch_count: usize,
183 pub missing_branches: Vec<String>,
184 pub recommended_command: String,
185}
186
187#[cfg(feature = "git-overlay")]
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct GitOverlayBranchTip {
190 pub branch: String,
191 pub git_commit: String,
192 pub history_imported: bool,
193 #[serde(skip)]
194 pub mapped_state: Option<StateId>,
195}
196
197#[cfg(feature = "git-overlay")]
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct GitOverlayTagTip {
200 pub tag: String,
201 pub git_commit: String,
202 pub history_imported: bool,
203 #[serde(skip)]
204 pub mapped_state: Option<StateId>,
205}
206
207#[cfg(feature = "git-overlay")]
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct GitOverlayOutOfBandCommits {
214 pub count: usize,
215 pub truncated: bool,
218}
219
220#[cfg(feature = "git-overlay")]
224const GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT: usize = 1000;
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
227#[serde(rename_all = "kebab-case")]
228pub enum OperationScope {
229 Git,
230 Heddle,
231}
232
233impl std::fmt::Display for OperationScope {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 match self {
236 Self::Git => write!(f, "git"),
237 Self::Heddle => write!(f, "heddle"),
238 }
239 }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243#[serde(rename_all = "kebab-case")]
244pub enum OperationKind {
245 Merge,
246 Rebase,
247 CherryPick,
248 Revert,
249 Bisect,
250}
251
252impl std::fmt::Display for OperationKind {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 match self {
255 Self::Merge => write!(f, "merge"),
256 Self::Rebase => write!(f, "rebase"),
257 Self::CherryPick => write!(f, "cherry-pick"),
258 Self::Revert => write!(f, "revert"),
259 Self::Bisect => write!(f, "bisect"),
260 }
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct RepositoryOperationStatus {
266 pub scope: OperationScope,
267 pub kind: OperationKind,
268 pub in_progress: bool,
269 pub state: String,
270 pub message: String,
271 pub next_action: String,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct GitRemoteTrackingStatus {
276 pub branch: String,
277 pub upstream: String,
278 pub ahead: usize,
279 pub behind: usize,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub local_oid: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub upstream_oid: Option<String>,
284 #[serde(default, skip_serializing_if = "is_false")]
285 pub upstream_is_undone_checkpoint: bool,
286 pub message: String,
287 pub next_action: String,
288}
289
290fn is_false(value: &bool) -> bool {
291 !*value
292}
293
294#[derive(Debug, Deserialize)]
295struct GitProjectionMappingEntry {
296 state_id: String,
297 git_oid: String,
298}
299
300#[derive(Debug, Deserialize, Default)]
301struct GitProjectionMappingFile {
302 entries: Vec<GitProjectionMappingEntry>,
303}
304
305pub trait BlobHydrator: Send + Sync {
326 fn hydrate(&self, repo: &Repository, hash: &ContentHash) -> Result<()>;
327}
328
329pub struct Repository<R = RefManager, O = OpLog, S = AnyStore>
343where
344 R: RefBackend,
345 O: OpLogBackend,
346 S: ObjectStore,
347{
348 root: PathBuf,
349 heddle_dir: PathBuf,
350 capability: RepositoryCapability,
351 store: S,
352 refs: R,
353 oplog: O,
354 config: RepoConfig,
355 shallow: RwLock<ShallowInfo>,
356 blob_hydrator: RwLock<Option<Arc<dyn BlobHydrator>>>,
357 git_overlay_repo: RwLock<Option<SleyRepository>>,
358 progress: RwLock<Progress>,
366}
367
368impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> RepositoryLockExt for Repository<R, O, S> {
369 fn locker(&self) -> RepoLock {
370 let lock_root = self.heddle_dir.parent().expect(
371 "heddle_dir has no parent component; cannot determine lock root. This indicates a misconfigured repository.",
372 );
373 RepoLock::new(lock_root)
374 }
375}
376
377impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> Repository<R, O, S> {
378 pub fn from_parts(
387 root: PathBuf,
388 heddle_dir: PathBuf,
389 store: S,
390 refs: R,
391 oplog: O,
392 config: RepoConfig,
393 shallow: ShallowInfo,
394 ) -> Self {
395 let capability = repository_capability_for_authority(config.repository.source_authority);
396 Self {
397 root,
398 heddle_dir,
399 capability,
400 store,
401 refs,
402 oplog,
403 config,
404 shallow: RwLock::new(shallow),
405 blob_hydrator: RwLock::new(None),
406 git_overlay_repo: RwLock::new(None),
407 progress: RwLock::new(Progress::null()),
408 }
409 }
410
411 pub fn store(&self) -> &S {
413 &self.store
414 }
415
416 pub fn refs(&self) -> &R {
418 &self.refs
419 }
420
421 pub fn oplog(&self) -> &O {
423 &self.oplog
424 }
425}
426
427pub(crate) fn compute_op_scope(root: &Path) -> String {
438 let local_head = root.join(".heddle").join("HEAD");
439 let canonical = local_head.canonicalize().unwrap_or(local_head);
440 let digest = blake3::hash(canonical.to_string_lossy().as_bytes());
441 format!("wt-{}", &digest.to_hex().as_str()[..16])
442}
443
444fn ensure_supported_repo_format(config_path: &Path, config: &RepoConfig) -> Result<()> {
445 let found = config.repository.version;
446 let supported = repo_config::SUPPORTED_REPO_FORMAT;
447 if found > supported {
448 return Err(HeddleError::RepositoryFormatTooNew {
449 path: config_path.to_path_buf(),
450 found,
451 supported,
452 });
453 }
454 if found < supported {
455 return Err(HeddleError::RepositoryFormatMigrationRequired {
456 path: config_path.to_path_buf(),
457 found,
458 required: supported,
459 });
460 }
461 Ok(())
462}
463
464impl<S: ObjectStore> Repository<RefManager, OpLog, S> {
465 fn open_raw(
466 root: PathBuf,
467 heddle_dir: PathBuf,
468 store: S,
469 config: RepoConfig,
470 refs: RefManager,
471 ) -> Result<Self> {
472 let actor = config
473 .principal
474 .as_ref()
475 .map(|p| objects::object::Principal::new(&p.name, &p.email))
476 .unwrap_or_else(|| objects::object::Principal::new("<unknown>", ""));
477 let oplog = OpLog::new(&heddle_dir, actor.clone());
478 oplog.validate_current_format()?;
479 let shallow = ShallowInfo::load(&heddle_dir)?;
480 let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
484 &heddle_dir,
485 compute_op_scope(&root),
486 ));
487 let committer =
488 std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(&heddle_dir, actor));
489 let refs = refs.with_reconciler(reconciler).with_committer(committer);
490 refs.init_reconcile_watermark()?;
495 Ok(Self::from_parts(
496 root, heddle_dir, store, refs, oplog, config, shallow,
497 ))
498 }
499}
500
501impl Repository {
502 fn run_open_hooks(&self) -> Result<()> {
508 let hydrator_path = crate::lazy_hydrator::LazyHydratorConfig::path_in(self.heddle_dir());
516 let schema_clean = crate::migration::is_schema_ledger_complete(self.heddle_dir());
517 let no_lazy_hydrator = !hydrator_path.exists();
518 if schema_clean && no_lazy_hydrator {
519 return Ok(());
520 }
521
522 if !schema_clean {
532 crate::migration::apply_pending(self)?;
533 }
534 if !no_lazy_hydrator {
542 match crate::lazy_hydrator::try_reconstruct(self.root(), self.heddle_dir()) {
543 Ok(Some(hydrator)) => self.set_blob_hydrator(hydrator),
544 Ok(None) => {}
545 Err(err) => {
546 tracing::warn!("lazy hydrator reconstruction failed during open: {err}");
553 }
554 }
555 }
556 Ok(())
557 }
558
559 fn build_store(
564 config: &RepoConfig,
565 root: &Path,
566 heddle_dir: &Path,
567 shared_overlay_source_root: Option<&Path>,
568 ) -> Result<AnyStore> {
569 let store = AnyStore::Fs(FsStore::new(heddle_dir));
570 #[cfg(feature = "git-overlay")]
571 let mut store = store;
572 #[cfg(not(feature = "git-overlay"))]
573 let _ = (config, root, shared_overlay_source_root);
574 #[cfg(feature = "git-overlay")]
575 let overlay_source_root = shared_overlay_source_root
576 .map(Path::to_path_buf)
577 .or_else(|| {
578 (config.repository.source_authority == RepositorySourceAuthority::GitOverlay)
579 .then(|| root.to_path_buf())
580 });
581 #[cfg(feature = "git-overlay")]
582 if let Some(source_root) = overlay_source_root {
583 store.set_external_source(Arc::new(
584 git_overlay_object_source::GitOverlayObjectSource::new(
585 source_root,
586 heddle_dir.to_path_buf(),
587 ),
588 ));
589 }
590 Ok(store)
591 }
592
593 pub fn init(path: impl AsRef<Path>) -> Result<Self> {
602 Self::init_with_source_authority(path, RepositorySourceAuthority::Native)
603 }
604
605 fn init_with_source_authority(
606 path: impl AsRef<Path>,
607 source_authority: RepositorySourceAuthority,
608 ) -> Result<Self> {
609 let root = path.as_ref().to_path_buf();
610 let heddle_dir = root.join(".heddle");
611
612 if heddle_dir.exists() {
613 return Err(HeddleError::RepositoryExists(root));
614 }
615
616 objects::fs_atomic::create_private_dir_all(&heddle_dir)?;
618
619 let store = FsStore::new(&heddle_dir);
620 #[cfg(feature = "git-overlay")]
621 let mut store = store;
622 store.init()?;
623
624 let refs = RefManager::new(&heddle_dir);
625 refs.init()?;
626
627 let oplog = OpLog::new_unattributed(&heddle_dir);
632 oplog.init()?;
633
634 let mut config = RepoConfig::default();
635 config.repository.source_authority = source_authority;
636 config.save(&heddle_dir.join("config.toml"))?;
637
638 #[cfg(feature = "git-overlay")]
639 if source_authority == RepositorySourceAuthority::GitOverlay {
640 store.set_external_source(Arc::new(
641 git_overlay_object_source::GitOverlayObjectSource::new(
642 root.clone(),
643 heddle_dir.clone(),
644 ),
645 ));
646 }
647
648 refs.write_head(&Head::Attached {
649 thread: ThreadName::from("main"),
650 })?;
651
652 let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
656 &heddle_dir,
657 compute_op_scope(&root),
658 ));
659 let committer = std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(
660 &heddle_dir,
661 objects::object::Principal::new("<unknown>", ""),
662 ));
663 let refs = refs.with_reconciler(reconciler).with_committer(committer);
664 refs.init_reconcile_watermark()?;
668
669 let repo = Self {
670 root,
671 heddle_dir: heddle_dir.clone(),
672 capability: repository_capability_for_authority(source_authority),
673 store: AnyStore::Fs(store),
674 refs,
675 oplog,
676 config,
677 shallow: RwLock::new(ShallowInfo::load(&heddle_dir)?),
678 blob_hydrator: RwLock::new(None),
679 git_overlay_repo: RwLock::new(None),
680 progress: RwLock::new(Progress::null()),
681 };
682
683 crate::migration::apply_pending(&repo)?;
687 Ok(repo)
688 }
689
690 pub fn init_default(path: impl AsRef<Path>) -> Result<Self> {
696 let repo = Self::init(path)?;
697 repo.seed_default_thread()?;
698 Ok(repo)
699 }
700
701 pub fn bootstrap_git_overlay(path: impl AsRef<Path>) -> Result<Self> {
708 let root = path.as_ref();
709 if root.join(".heddle").exists() {
710 let repo = Self::open(root)?;
711 if repo.capability() == RepositoryCapability::GitOverlay {
712 ensure_git_overlay_exclude(root)?;
713 }
714 return Ok(repo);
715 }
716
717 let repo = Self::init_git_overlay_sidecar(root)?;
718 ensure_git_overlay_exclude(root)?;
719 Ok(repo)
720 }
721
722 pub fn init_git_overlay_sidecar(path: impl AsRef<Path>) -> Result<Self> {
723 let root = path.as_ref();
724 let repo = Self::init_with_source_authority(root, RepositorySourceAuthority::GitOverlay)?;
725 if let Some(head) = detect_git_head(root)? {
726 repo.refs.write_head(&head)?;
727 }
728 Ok(repo)
729 }
730
731 pub fn ensure_git_overlay_local_excludes(path: impl AsRef<Path>) -> Result<()> {
735 ensure_git_overlay_exclude(path.as_ref())
736 }
737
738 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
750 let start_path = path.as_ref().canonicalize()?;
751 if let Some(mount_root) = metadataless_managed_thread_root(&start_path) {
761 return Err(HeddleError::Config(format!(
762 "'{}' is a Heddle-managed virtualized thread mount with no checkout \
763 metadata of its own; refusing to operate on the parent repository from \
764 inside it. Run heddle from the repository root, or use a solid/materialized \
765 thread checkout.",
766 mount_root.display()
767 )));
768 }
769 let mut discovered_git_root = None;
770
771 let mut current = Some(start_path.as_path());
772 while let Some(dir) = current {
773 if discovered_git_root.is_none() && has_git_metadata(dir) {
774 discovered_git_root = Some(dir.to_path_buf());
775 }
776 let heddle_path = dir.join(".heddle");
777
778 if heddle_path.is_dir() {
779 if let Some(git_root) = discovered_git_root.as_ref()
780 && git_root != dir
781 && git_root.starts_with(dir)
782 && !git_root.join(".heddle").exists()
783 {
784 ensure_git_overlay_exclude(git_root)?;
785 Self::bootstrap_git_overlay(git_root)?;
786 return Self::open(git_root);
787 }
788 let pointer_path = heddle_path.join("objectstore");
789 let objects_dir = heddle_path.join("objects");
790
791 if pointer_path.is_file() {
792 let content = fs::read_to_string(&pointer_path)?;
795 let pointer = parse_objectstore_pointer(&content).ok_or_else(|| {
796 HeddleError::Config(format!(
797 "invalid .heddle/objectstore pointer at {}: expected objectstore and source-authority entries",
798 pointer_path.display()
799 ))
800 })?;
801 let raw_shared = pointer.objectstore;
802
803 if raw_shared.is_relative() {
804 return Err(HeddleError::Config(format!(
805 ".heddle/objectstore pointer at {} contains a relative path '{}'; \
806 objectstore path must be absolute",
807 pointer_path.display(),
808 raw_shared.display()
809 )));
810 }
811
812 let shared_galeed_dir = raw_shared.canonicalize().map_err(|e| {
813 HeddleError::Config(format!(
814 ".heddle/objectstore pointer at {} points to non-existent path '{}': {}",
815 pointer_path.display(),
816 raw_shared.display(),
817 e
818 ))
819 })?;
820
821 if !shared_galeed_dir.join("objects").is_dir() {
822 return Err(HeddleError::Config(format!(
823 ".heddle/objectstore pointer at {} resolves to '{}' which does not \
824 contain an 'objects/' directory; not a valid Heddle store",
825 pointer_path.display(),
826 shared_galeed_dir.display()
827 )));
828 }
829
830 let config_path = shared_galeed_dir.join("config.toml");
831 let mut config = RepoConfig::load_for_repository(&config_path)?;
832 ensure_supported_repo_format(&config_path, &config)?;
833 let shared_overlay_source_root = (config.repository.source_authority
834 == RepositorySourceAuthority::GitOverlay)
835 .then(|| shared_galeed_dir.parent().map(Path::to_path_buf))
836 .flatten();
837 config.repository.source_authority = pointer.source_authority;
838 let store = Self::build_store(
839 &config,
840 dir,
841 &shared_galeed_dir,
842 shared_overlay_source_root.as_deref(),
843 )?;
844 let local_head_path = heddle_path.join("HEAD");
845 let refs = RefManager::new(&shared_galeed_dir).with_local_head(local_head_path);
846 let repo =
847 Self::open_raw(dir.to_path_buf(), shared_galeed_dir, store, config, refs)?;
848 repo.run_open_hooks()?;
849 return Ok(repo);
850 }
851
852 if objects_dir.is_dir() {
853 let config_path = heddle_path.join("config.toml");
855 let config = RepoConfig::load_for_repository(&config_path)?;
856 ensure_supported_repo_format(&config_path, &config)?;
857 let store = Self::build_store(&config, dir, &heddle_path, None)?;
858 let refs = RefManager::new(&heddle_path);
859 let repo = Self::open_raw(dir.to_path_buf(), heddle_path, store, config, refs)?;
860 repo.run_open_hooks()?;
861 if repo.capability() == RepositoryCapability::GitOverlay {
862 match detect_git_head_state(dir) {
863 Ok(Some(GitHeadState::Attached(thread))) => {
864 let git_head = Head::Attached {
865 thread: ThreadName::from(thread),
866 };
867 let stale = match (repo.refs.read_head(), &git_head) {
884 (Ok(Head::Detached { state }), Head::Attached { thread }) => {
885 match repo.refs.get_thread(thread) {
886 Ok(Some(tip)) => tip == state,
887 _ => false,
888 }
889 }
890 (Ok(Head::Detached { .. }), _) => false,
891 (Ok(current), _) => current != git_head,
892 (Err(_), _) => true,
893 };
894 if stale {
895 repo.refs.write_head(&git_head)?;
896 }
897 }
898 Ok(Some(GitHeadState::Detached(git_oid))) => {
899 if let Ok(Some(state)) =
900 repo.git_overlay_mapped_state_for_git_oid(git_oid)
901 {
902 let git_head = Head::Detached { state };
903 let stale = match repo.refs.read_head() {
904 Ok(current) => current != git_head,
905 Err(_) => true,
906 };
907 if stale {
908 repo.refs.write_head(&git_head)?;
909 }
910 }
911 }
912 Ok(None) | Err(_) => {}
913 }
914 }
915 return Ok(repo);
916 }
917
918 }
921
922 current = dir.parent();
923 }
924
925 if let Some(git_root) = discovered_git_root {
931 ensure_git_overlay_exclude(&git_root)?;
932 Self::bootstrap_git_overlay(&git_root)?;
933 return Self::open(git_root);
934 }
935
936 Err(HeddleError::RepositoryNotFound(path.as_ref().to_path_buf()))
937 }
938
939 pub fn root(&self) -> &Path {
940 &self.root
941 }
942
943 pub fn heddle_dir(&self) -> &Path {
944 &self.heddle_dir
945 }
946
947 pub fn managed_checkout_source_root(&self) -> &Path {
956 self.heddle_dir.parent().unwrap_or(self.root.as_path())
957 }
958
959 pub fn managed_checkout_path(&self, thread: &str) -> PathBuf {
961 crate::thread_manifest::managed_checkout_path(
962 &self.heddle_dir,
963 thread,
964 self.managed_checkout_source_root(),
965 )
966 }
967
968 pub fn capability(&self) -> RepositoryCapability {
969 self.capability
970 }
971
972 pub fn source_authority(&self) -> RepositorySourceAuthority {
973 match self.capability {
974 RepositoryCapability::GitOverlay => RepositorySourceAuthority::GitOverlay,
975 RepositoryCapability::NativeHeddle => RepositorySourceAuthority::Native,
976 }
977 }
978
979 pub fn transition_source_authority(
980 &self,
981 expected: RepositorySourceAuthority,
982 next: RepositorySourceAuthority,
983 ) -> Result<()> {
984 let _write_lock = self.locker().write().map_err(|error| {
985 HeddleError::Config(format!(
986 "failed to lock repository for source-authority transition: {error}"
987 ))
988 })?;
989 let config_path = self.heddle_dir.join("config.toml");
990 let mut config = RepoConfig::load_for_repository(&config_path)?;
991 if config.repository.source_authority != expected {
992 return Err(HeddleError::Config(format!(
993 "repository source authority changed before transition: expected {expected:?}, found {:?}",
994 config.repository.source_authority
995 )));
996 }
997 config.repository.source_authority = next;
998 config.save(&config_path)
999 }
1000
1001 pub fn git_overlay_sley_repository(&self) -> Result<Option<SleyRepository>> {
1002 if self.capability() != RepositoryCapability::GitOverlay {
1003 return Ok(None);
1004 }
1005
1006 if let Some(repo) = self
1007 .git_overlay_repo
1008 .read()
1009 .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?
1010 .clone()
1011 {
1012 return Ok(Some(repo));
1013 }
1014
1015 let mut cached = self
1016 .git_overlay_repo
1017 .write()
1018 .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?;
1019 if let Some(repo) = cached.clone() {
1020 return Ok(Some(repo));
1021 }
1022
1023 let repo = SleyRepository::discover(&self.root).map_err(|error| {
1024 HeddleError::Config(format!(
1025 "failed to inspect Git repository at '{}': {}",
1026 self.root.display(),
1027 error
1028 ))
1029 })?;
1030 *cached = Some(repo.clone());
1031 Ok(Some(repo))
1032 }
1033
1034 pub fn capability_label(&self) -> &'static str {
1035 match self.capability() {
1036 RepositoryCapability::GitOverlay => "git-overlay",
1037 RepositoryCapability::NativeHeddle => "native-heddle",
1038 }
1039 }
1040
1041 pub fn storage_model_label(&self) -> &'static str {
1042 match self.capability() {
1043 RepositoryCapability::GitOverlay => "git+heddle-sidecar",
1044 RepositoryCapability::NativeHeddle => "heddle-native",
1045 }
1046 }
1047
1048 pub fn hosted_enabled(&self) -> bool {
1049 self.config
1050 .hosted
1051 .upstream_url
1052 .as_deref()
1053 .is_some_and(|value| !value.trim().is_empty())
1054 || self
1055 .config
1056 .hosted
1057 .namespace
1058 .as_deref()
1059 .is_some_and(|value| !value.trim().is_empty())
1060 }
1061
1062 pub fn current_lane(&self) -> Result<Option<String>> {
1063 if self.capability() == RepositoryCapability::GitOverlay && has_git_metadata(&self.root) {
1064 if self.git_overlay_head_is_detached()?
1065 && detect_git_in_progress_branch(&self.root)?.is_none()
1066 {
1067 return Ok(None);
1068 }
1069
1070 if self.current_state()?.is_none() {
1071 return self.git_overlay_current_branch();
1072 }
1073 }
1074
1075 match self.head_ref()? {
1076 Head::Attached { thread } => Ok(Some(thread.to_string())),
1077 Head::Detached { .. } => Ok(None),
1078 }
1079 }
1080
1081 pub fn operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1082 if let Some(status) = self.heddle_operation_status()? {
1083 return Ok(Some(status));
1084 }
1085 self.git_operation_status()
1086 }
1087
1088 pub fn git_remote_tracking_status(&self) -> Result<Option<GitRemoteTrackingStatus>> {
1089 if self.capability() != RepositoryCapability::GitOverlay {
1090 return Ok(None);
1091 }
1092
1093 let branch = match self.git_overlay_current_branch()? {
1094 Some(branch) => branch,
1095 None => return Ok(None),
1096 };
1097
1098 let Some(git) = self.git_overlay_sley_repository()? else {
1099 return Ok(None);
1100 };
1101 let Some(head) = git_resolve_oid(&git, "HEAD")? else {
1102 return Ok(None);
1103 };
1104
1105 let local_ref_name = GitRefName::branch_full_name(&branch);
1106 if git_find_reference(&git, &local_ref_name)?.is_some()
1107 && let Some(tracking_name) = git_configured_tracking_ref(&git, &branch)?
1108 && let Some(upstream_head) = git_resolve_oid(&git, &tracking_name)?
1109 {
1110 let (ahead, behind) = git_ahead_behind_counts(&git, head, upstream_head)?;
1111 if ahead == 0 && behind == 0 {
1112 return Ok(None);
1113 }
1114 let upstream = git_remote_tracking_display_name(&tracking_name);
1115 let local_oid = head.to_string();
1116 let upstream_oid = upstream_head.to_string();
1117 let upstream_is_undone_checkpoint =
1118 self.remote_tracks_undone_git_checkpoint(&branch, &local_oid, &upstream_oid)?;
1119 return Ok(Some(GitRemoteTrackingStatus {
1120 branch: branch.clone(),
1121 upstream: upstream.clone(),
1122 ahead,
1123 behind,
1124 local_oid: Some(local_oid),
1125 upstream_oid: Some(upstream_oid),
1126 upstream_is_undone_checkpoint,
1127 message: git_remote_tracking_message(
1128 &branch,
1129 &upstream,
1130 ahead,
1131 behind,
1132 upstream_is_undone_checkpoint,
1133 ),
1134 next_action: git_remote_tracking_next_action(
1135 ahead,
1136 behind,
1137 upstream_is_undone_checkpoint,
1138 ),
1139 }));
1140 }
1141
1142 let remotes = git_remote_names(&self.root)?;
1143 if remotes.is_empty() {
1144 return Ok(None);
1145 }
1146 for remote in &remotes {
1147 let remote_ref = GitRefName::remote_branch_full_name(remote, &branch);
1148 if let Some(remote_head) = git_resolve_oid(&git, &remote_ref)? {
1149 if remote_head == head {
1150 return Ok(None);
1151 }
1152 let (ahead, behind) = git_ahead_behind_counts(&git, head, remote_head)?;
1153 if behind > 0 {
1154 let upstream = format!("{remote}/{branch}");
1155 let local_oid = head.to_string();
1156 let upstream_oid = remote_head.to_string();
1157 let upstream_is_undone_checkpoint = self.remote_tracks_undone_git_checkpoint(
1158 &branch,
1159 &local_oid,
1160 &upstream_oid,
1161 )?;
1162 return Ok(Some(GitRemoteTrackingStatus {
1163 branch: branch.clone(),
1164 upstream: upstream.clone(),
1165 ahead,
1166 behind,
1167 local_oid: Some(local_oid),
1168 upstream_oid: Some(upstream_oid),
1169 upstream_is_undone_checkpoint,
1170 message: git_remote_tracking_message(
1171 &branch,
1172 &upstream,
1173 ahead,
1174 behind,
1175 upstream_is_undone_checkpoint,
1176 ),
1177 next_action: git_remote_tracking_next_action(
1178 ahead,
1179 behind,
1180 upstream_is_undone_checkpoint,
1181 ),
1182 }));
1183 }
1184 }
1185 }
1186
1187 Ok(Some(GitRemoteTrackingStatus {
1188 branch: branch.clone(),
1189 upstream: String::new(),
1190 ahead: 0,
1191 behind: 0,
1192 local_oid: Some(head.to_string()),
1193 upstream_oid: None,
1194 upstream_is_undone_checkpoint: false,
1195 message: format!("Git branch '{branch}' has no upstream tracking branch"),
1196 next_action: "heddle push".to_string(),
1197 }))
1198 }
1199
1200 fn remote_tracks_undone_git_checkpoint(
1201 &self,
1202 branch: &str,
1203 local_oid: &str,
1204 upstream_oid: &str,
1205 ) -> Result<bool> {
1206 let scope = self.op_scope();
1207 let batches = match self.oplog().redo_batches_scoped(64, Some(&scope)) {
1208 Ok(batches) => batches,
1209 Err(error) => {
1210 tracing::warn!(
1211 branch,
1212 local_oid,
1213 upstream_oid,
1214 error = %error,
1215 "could not inspect redo oplog for undone Git checkpoint status"
1216 );
1217 return Ok(false);
1218 }
1219 };
1220 Ok(batches.iter().any(|batch| {
1221 batch.entries.iter().any(|entry| {
1222 if !entry.undone {
1223 return false;
1224 }
1225 matches!(
1226 &entry.operation,
1227 OpRecord::GitCheckpoint {
1228 branch: checkpoint_branch,
1229 previous_git_oid: Some(previous_git_oid),
1230 new_git_oid,
1231 ..
1232 } if checkpoint_branch == branch
1233 && previous_git_oid == local_oid
1234 && new_git_oid == upstream_oid
1235 )
1236 })
1237 }))
1238 }
1239
1240 pub fn git_import_guidance(&self) -> Result<Option<GitImportGuidance>> {
1241 if self.capability() != RepositoryCapability::GitOverlay {
1242 return Ok(None);
1243 }
1244 Ok(None)
1249 }
1250
1251 #[cfg(feature = "git-overlay")]
1256 pub fn git_overlay_branch_tips(&self) -> Result<Vec<GitOverlayBranchTip>> {
1257 if self.capability() != RepositoryCapability::GitOverlay {
1258 return Ok(Vec::new());
1259 }
1260
1261 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1262 return Ok(Vec::new());
1263 };
1264
1265 let imported_threads: std::collections::HashSet<ThreadName> =
1266 self.refs().list_threads()?.into_iter().collect();
1267 let projection_mapping = self.git_projection_mapping()?;
1268 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1269 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1270 let mut branch_tips = Vec::new();
1271
1272 for branch in git_repo.references().list_refs().map_err(|error| {
1273 HeddleError::Config(format!(
1274 "failed to enumerate git branches at '{}': {}",
1275 self.root.display(),
1276 error
1277 ))
1278 })? {
1279 let ref_name = GitRefName::new(&branch.name);
1280 if ref_name.content_namespace() != Some(GitRefContentNamespace::Branch) {
1281 continue;
1282 };
1283 let Some(name) = ref_name.short_name().map(str::to_string) else {
1284 continue;
1285 };
1286 let Some(target) =
1287 self.git_overlay_commit_tip_oid(&git_repo, &branch, "branch", &name)?
1288 else {
1289 continue;
1290 };
1291 let git_commit = target.to_string();
1292 let mapped_state = self.git_overlay_mapped_state_for_commit(
1293 &git_commit,
1294 &projection_mapping,
1295 &ingest_mapping,
1296 &checkpoint_mapping,
1297 )?;
1298 let thread_name = ThreadName::from(name.as_str());
1299 let history_imported = if imported_threads.contains(&thread_name) {
1300 let existing_thread = self.refs().get_thread(&thread_name)?;
1304 let mapped = matches!(
1305 (existing_thread.as_ref(), mapped_state.as_ref()),
1306 (Some(existing), Some(mapped_state))
1307 if existing == mapped_state
1308 );
1309 let checkpointed = if mapped {
1310 false
1311 } else if let Some(existing) = existing_thread {
1312 self.latest_git_checkpoint_for_state(&existing)?
1313 .is_some_and(|record| record.git_commit == git_commit)
1314 || mapped_state.as_ref().is_some_and(|mapped_state| {
1315 self.state_is_ancestor(mapped_state, &existing)
1316 })
1317 } else {
1318 false
1319 };
1320 mapped || checkpointed
1321 } else {
1322 mapped_state.is_some()
1323 };
1324 branch_tips.push(GitOverlayBranchTip {
1325 branch: name,
1326 git_commit,
1327 history_imported,
1328 mapped_state,
1329 });
1330 }
1331 branch_tips.sort_by(|a, b| a.branch.cmp(&b.branch));
1332 Ok(branch_tips)
1333 }
1334
1335 #[cfg(feature = "git-overlay")]
1336 pub fn git_overlay_tag_tips(&self) -> Result<Vec<GitOverlayTagTip>> {
1337 if self.capability() != RepositoryCapability::GitOverlay {
1338 return Ok(Vec::new());
1339 }
1340
1341 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1342 return Ok(Vec::new());
1343 };
1344
1345 let imported_markers: std::collections::HashSet<MarkerName> =
1346 self.refs().list_markers()?.into_iter().collect();
1347 let projection_mapping = self.git_projection_mapping()?;
1348 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1349 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1350 let mut tag_tips = Vec::new();
1351
1352 for tag in git_repo.references().list_refs().map_err(|error| {
1353 HeddleError::Config(format!(
1354 "failed to enumerate git tags at '{}': {}",
1355 self.root.display(),
1356 error
1357 ))
1358 })? {
1359 let ref_name = GitRefName::new(&tag.name);
1360 if ref_name.content_namespace() != Some(GitRefContentNamespace::Tag) {
1361 continue;
1362 };
1363 let Some(name) = ref_name.short_name().map(str::to_string) else {
1364 continue;
1365 };
1366 let Some(target) = self.git_overlay_commit_tip_oid(&git_repo, &tag, "tag", &name)?
1367 else {
1368 continue;
1369 };
1370 let git_commit = target.to_string();
1371 let mapped_state = self.git_overlay_mapped_state_for_commit(
1372 &git_commit,
1373 &projection_mapping,
1374 &ingest_mapping,
1375 &checkpoint_mapping,
1376 )?;
1377 let marker_name = MarkerName::from(name.as_str());
1378 let history_imported = if imported_markers.contains(&marker_name) {
1379 matches!(
1380 (self.refs().get_marker(&marker_name)?, mapped_state.as_ref()),
1381 (Some(existing), Some(mapped_state)) if existing == *mapped_state
1382 )
1383 } else {
1384 false
1385 };
1386 tag_tips.push(GitOverlayTagTip {
1387 tag: name,
1388 git_commit,
1389 history_imported,
1390 mapped_state,
1391 });
1392 }
1393
1394 tag_tips.sort_by(|a, b| a.tag.cmp(&b.tag));
1395 Ok(tag_tips)
1396 }
1397
1398 #[cfg(feature = "git-overlay")]
1399 pub fn git_overlay_branch_tip(&self, name: &str) -> Result<Option<GitOverlayBranchTip>> {
1400 Ok(self
1401 .git_overlay_branch_tips()?
1402 .into_iter()
1403 .find(|tip| tip.branch == name))
1404 }
1405
1406 #[cfg(feature = "git-overlay")]
1407 pub fn git_overlay_tag_tip(&self, name: &str) -> Result<Option<GitOverlayTagTip>> {
1408 Ok(self
1409 .git_overlay_tag_tips()?
1410 .into_iter()
1411 .find(|tip| tip.tag == name))
1412 }
1413
1414 pub fn git_overlay_mapped_state_for_branch(&self, name: &str) -> Result<Option<StateId>> {
1420 if self.capability() != RepositoryCapability::GitOverlay {
1421 return Ok(None);
1422 }
1423 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1424 return Ok(None);
1425 };
1426 let full_name = format!("refs/heads/{name}");
1427 let projection_mapping = self.git_projection_mapping()?;
1428 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1429 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1430 for reference in git_repo.references().list_refs().map_err(|error| {
1431 HeddleError::Config(format!(
1432 "failed to enumerate git branches at '{}': {}",
1433 self.root.display(),
1434 error
1435 ))
1436 })? {
1437 if reference.name != full_name {
1438 continue;
1439 }
1440 let Some(target) =
1441 self.git_overlay_commit_tip_oid(&git_repo, &reference, "branch", name)?
1442 else {
1443 return Ok(None);
1444 };
1445 return self.git_overlay_mapped_state_for_commit(
1446 &target.to_string(),
1447 &projection_mapping,
1448 &ingest_mapping,
1449 &checkpoint_mapping,
1450 );
1451 }
1452 Ok(None)
1453 }
1454
1455 #[cfg(feature = "git-overlay")]
1456 pub fn git_overlay_mapped_state_for_remote_tracking_ref(
1457 &self,
1458 name: &str,
1459 ) -> Result<Option<StateId>> {
1460 if self.capability() != RepositoryCapability::GitOverlay {
1461 return Ok(None);
1462 }
1463 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1464 return Ok(None);
1465 };
1466 let full_name = GitRefName::remote_tracking_full_name(name);
1467 let projection_mapping = self.git_projection_mapping()?;
1468 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1469 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1470 for reference in git_repo.references().list_refs().map_err(|error| {
1471 HeddleError::Config(format!(
1472 "failed to enumerate git remote-tracking refs at '{}': {}",
1473 self.root.display(),
1474 error
1475 ))
1476 })? {
1477 if reference.name != full_name {
1478 continue;
1479 }
1480 let Some(target) =
1481 self.git_overlay_commit_tip_oid(&git_repo, &reference, "remote branch", name)?
1482 else {
1483 return Ok(None);
1484 };
1485 return self.git_overlay_mapped_state_for_commit(
1486 &target.to_string(),
1487 &projection_mapping,
1488 &ingest_mapping,
1489 &checkpoint_mapping,
1490 );
1491 }
1492 Ok(None)
1493 }
1494
1495 pub fn git_overlay_mapped_state_for_tag(&self, name: &str) -> Result<Option<StateId>> {
1496 if self.capability() != RepositoryCapability::GitOverlay {
1497 return Ok(None);
1498 }
1499 let Some(git_repo) = self.git_overlay_sley_repository()? else {
1500 return Ok(None);
1501 };
1502 let full_name = format!("refs/tags/{name}");
1503 let projection_mapping = self.git_projection_mapping()?;
1504 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1505 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1506 for reference in git_repo.references().list_refs().map_err(|error| {
1507 HeddleError::Config(format!(
1508 "failed to enumerate git tags at '{}': {}",
1509 self.root.display(),
1510 error
1511 ))
1512 })? {
1513 if reference.name != full_name {
1514 continue;
1515 }
1516 let Some(target) =
1517 self.git_overlay_commit_tip_oid(&git_repo, &reference, "tag", name)?
1518 else {
1519 return Ok(None);
1520 };
1521 return self.git_overlay_mapped_state_for_commit(
1522 &target.to_string(),
1523 &projection_mapping,
1524 &ingest_mapping,
1525 &checkpoint_mapping,
1526 );
1527 }
1528 Ok(None)
1529 }
1530
1531 #[cfg(feature = "git-overlay")]
1532 fn state_is_ancestor(&self, ancestor: &StateId, descendant: &StateId) -> bool {
1533 let mut graph = CommitGraphIndex::new(self);
1534 graph.is_ancestor(ancestor, descendant).unwrap_or(false)
1535 }
1536
1537 #[cfg(feature = "git-overlay")]
1551 pub fn git_overlay_worktree_status(&self) -> Result<Option<WorktreeStatus>> {
1552 Ok(self
1553 .git_overlay_short_status()?
1554 .map(|status| status.worktree))
1555 }
1556
1557 #[cfg(feature = "git-overlay")]
1559 pub fn git_overlay_short_status(&self) -> Result<Option<GitOverlayShortStatus>> {
1560 if self.capability() != RepositoryCapability::GitOverlay {
1561 return Ok(None);
1562 }
1563 let git_repo = match self.git_overlay_sley_repository() {
1564 Ok(Some(repo)) => repo,
1565 Ok(None) | Err(_) => return Ok(None),
1566 };
1567 if git_repo.workdir().is_none() {
1568 return Ok(None);
1569 }
1570
1571 let mut added = BTreeSet::new();
1572 let mut modified = BTreeSet::new();
1573 let mut deleted = BTreeSet::new();
1574 let ignore_patterns = self.ignore_patterns()?;
1575 let worktree_ignore = crate::worktree_ignore::WorktreeIgnoreMatcher::new(&ignore_patterns);
1576 let index_ignore = objects::worktree::build_worktree_ignore(&ignore_patterns);
1577 let index_plan_applicable = git_worktree_matches_repo_root(&git_repo, self.root());
1578 let mut index_staged_paths = Vec::new();
1579 let mut index_extra_paths = Vec::new();
1580
1581 git_repo
1582 .stream_short_status_with_options(
1583 SleyShortStatusOptions {
1584 untracked_mode: SleyStatusUntrackedMode::All,
1585 ..SleyShortStatusOptions::default()
1586 },
1587 |entry| {
1588 let path = git_path(entry.path);
1589 if path.is_empty() {
1590 return Ok(SleyStreamControl::Continue);
1591 }
1592 if index_plan_applicable {
1593 append_short_status_to_index_intent(
1594 &mut index_staged_paths,
1595 &mut index_extra_paths,
1596 &index_ignore,
1597 entry,
1598 &path,
1599 );
1600 }
1601 if ignored_git_overlay_status_path(&path) {
1602 return Ok(SleyStreamControl::Continue);
1603 }
1604 let path = PathBuf::from(path);
1605
1606 if entry.index == b'?' && entry.worktree == b'?' {
1607 if git_overlay_untracked_path_ignored(&worktree_ignore, &path) {
1608 return Ok(SleyStreamControl::Continue);
1609 }
1610 added.insert(path);
1611 } else if entry.index == b'D' || entry.worktree == b'D' {
1612 deleted.insert(path);
1613 } else if entry.index == b'A'
1614 || entry.index == b'R'
1615 || entry.index == b'C'
1616 || entry.head_oid.is_none()
1617 {
1618 added.insert(path);
1619 } else {
1620 modified.insert(path);
1621 }
1622
1623 Ok(SleyStreamControl::Continue)
1624 },
1625 )
1626 .map_err(|error| {
1627 HeddleError::Config(format!(
1628 "failed to inspect Git worktree status at '{}': {}",
1629 self.root.display(),
1630 error
1631 ))
1632 })?;
1633
1634 Ok(Some(GitOverlayShortStatus {
1635 worktree: WorktreeStatus {
1636 modified: modified.into_iter().collect(),
1637 added: added.into_iter().collect(),
1638 deleted: deleted.into_iter().collect(),
1639 },
1640 index_staged_paths,
1641 index_extra_paths,
1642 index_plan_applicable,
1643 }))
1644 }
1645
1646 #[cfg(not(feature = "git-overlay"))]
1648 pub fn git_overlay_short_status(&self) -> Result<Option<GitOverlayShortStatus>> {
1649 Ok(None)
1650 }
1651
1652 fn git_projection_mapping(&self) -> Result<HashMap<String, String>> {
1653 let path = self
1654 .heddle_dir
1655 .join("git-projection")
1656 .join("git-projection-mapping.json");
1657 if !path.exists() {
1658 return Ok(HashMap::new());
1659 }
1660
1661 let contents = fs::read_to_string(path)?;
1662 if contents.trim().is_empty() {
1663 return Ok(HashMap::new());
1664 }
1665
1666 let file: GitProjectionMappingFile = serde_json::from_str(&contents)?;
1667 Ok(file
1668 .entries
1669 .into_iter()
1670 .map(|entry| (entry.git_oid, entry.state_id))
1671 .collect())
1672 }
1673
1674 pub fn git_overlay_ingest_commit_mapping(&self) -> Result<HashMap<String, String>> {
1675 let path = self.heddle_dir.join("ingest").join("sha_map.sqlite");
1676 if !path.exists() {
1677 return Ok(HashMap::new());
1678 }
1679
1680 let conn = Connection::open_with_flags(
1681 &path,
1682 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1683 )
1684 .map_err(|error| {
1685 HeddleError::Config(format!(
1686 "failed to open ingest SHA map at '{}': {}",
1687 path.display(),
1688 error
1689 ))
1690 })?;
1691 let mut stmt = conn
1692 .prepare_cached("SELECT git_sha, heddle_repr FROM sha_map WHERE kind = 0")
1693 .map_err(|error| {
1694 HeddleError::Config(format!(
1695 "failed to read ingest SHA map at '{}': {}",
1696 path.display(),
1697 error
1698 ))
1699 })?;
1700 let rows = stmt
1701 .query_map([], |row| {
1702 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1703 })
1704 .map_err(|error| {
1705 HeddleError::Config(format!(
1706 "failed to enumerate ingest SHA map at '{}': {}",
1707 path.display(),
1708 error
1709 ))
1710 })?;
1711
1712 let mut mapping = HashMap::new();
1713 for row in rows {
1714 let (git_sha, state_id) = row.map_err(|error| {
1715 HeddleError::Config(format!(
1716 "failed to read ingest SHA map row at '{}': {}",
1717 path.display(),
1718 error
1719 ))
1720 })?;
1721 mapping.insert(git_sha, state_id);
1722 }
1723 Ok(mapping)
1724 }
1725
1726 fn git_overlay_checkpoint_mapping(&self) -> Result<HashMap<String, String>> {
1727 Ok(self
1728 .list_git_checkpoints()?
1729 .into_iter()
1730 .map(|record| (record.git_commit, record.state_id))
1731 .collect())
1732 }
1733
1734 fn git_overlay_mapped_state_for_commit(
1735 &self,
1736 git_commit: &str,
1737 projection_mapping: &HashMap<String, String>,
1738 ingest_mapping: &HashMap<String, String>,
1739 checkpoint_mapping: &HashMap<String, String>,
1740 ) -> Result<Option<StateId>> {
1741 let Some(change) = projection_mapping
1742 .get(git_commit)
1743 .or_else(|| ingest_mapping.get(git_commit))
1744 .or_else(|| checkpoint_mapping.get(git_commit))
1745 else {
1746 return Ok(None);
1747 };
1748 let state_id = StateId::parse(change).map_err(|error| {
1749 HeddleError::Config(format!(
1750 "git commit {git_commit} maps to invalid Heddle state id '{change}': {error}"
1751 ))
1752 })?;
1753 if self.store.get_state(&state_id)?.is_some() {
1754 Ok(Some(state_id))
1755 } else {
1756 Ok(None)
1757 }
1758 }
1759
1760 fn git_overlay_mapped_git_commit_for_state_in(
1761 &self,
1762 state_id: &StateId,
1763 mapping: &HashMap<String, String>,
1764 ) -> Result<Option<String>> {
1765 for (git_commit, mapped_state) in mapping {
1766 let mapped_state_id = StateId::parse(mapped_state).map_err(|error| {
1767 HeddleError::Config(format!(
1768 "git commit {git_commit} maps to invalid Heddle state id '{mapped_state}': {error}"
1769 ))
1770 })?;
1771 if mapped_state_id == *state_id {
1772 return Ok(Some(git_commit.clone()));
1773 }
1774 }
1775 Ok(None)
1776 }
1777
1778 pub fn git_overlay_mapped_git_commit_for_state(
1779 &self,
1780 state_id: &StateId,
1781 ) -> Result<Option<String>> {
1782 let projection_mapping = self.git_projection_mapping()?;
1783 if let Some(git_commit) =
1784 self.git_overlay_mapped_git_commit_for_state_in(state_id, &projection_mapping)?
1785 {
1786 return Ok(Some(git_commit));
1787 }
1788
1789 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1790 if let Some(git_commit) =
1791 self.git_overlay_mapped_git_commit_for_state_in(state_id, &ingest_mapping)?
1792 {
1793 return Ok(Some(git_commit));
1794 }
1795
1796 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1797 self.git_overlay_mapped_git_commit_for_state_in(state_id, &checkpoint_mapping)
1798 }
1799
1800 pub fn git_overlay_mapped_state_for_git_commit(
1801 &self,
1802 git_commit: &str,
1803 ) -> Result<Option<StateId>> {
1804 let projection_mapping = self.git_projection_mapping()?;
1805 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1806 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1807 self.git_overlay_mapped_state_for_commit(
1808 git_commit,
1809 &projection_mapping,
1810 &ingest_mapping,
1811 &checkpoint_mapping,
1812 )
1813 }
1814
1815 fn git_overlay_mapped_state_for_git_oid(
1816 &self,
1817 git_oid: SleyObjectId,
1818 ) -> Result<Option<StateId>> {
1819 self.git_overlay_mapped_state_for_git_commit(&git_oid.to_string())
1820 }
1821
1822 #[cfg(feature = "git-overlay")]
1831 pub fn git_overlay_out_of_band_commits(
1832 &self,
1833 tip_git_commit: &str,
1834 ) -> Result<Option<GitOverlayOutOfBandCommits>> {
1835 if self.capability() != RepositoryCapability::GitOverlay {
1836 return Ok(None);
1837 }
1838 let git_repo = match self.git_overlay_sley_repository() {
1839 Ok(Some(repo)) => repo,
1840 Ok(None) | Err(_) => return Ok(None),
1841 };
1842 let Ok(tip) = SleyObjectId::from_hex(git_repo.object_format(), tip_git_commit) else {
1843 return Ok(None);
1844 };
1845
1846 let projection_mapping = self.git_projection_mapping()?;
1847 let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1848 let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1849
1850 let mut pending = vec![tip];
1851 let mut visited = std::collections::HashSet::new();
1852 let mut count = 0usize;
1853 while let Some(oid) = pending.pop() {
1854 if !visited.insert(oid) {
1855 continue;
1856 }
1857 let git_commit = oid.to_string();
1858 if self
1859 .git_overlay_mapped_state_for_commit(
1860 &git_commit,
1861 &projection_mapping,
1862 &ingest_mapping,
1863 &checkpoint_mapping,
1864 )?
1865 .is_some()
1866 {
1867 continue;
1869 }
1870 count += 1;
1871 if count >= GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT {
1872 return Ok(Some(GitOverlayOutOfBandCommits {
1873 count,
1874 truncated: true,
1875 }));
1876 }
1877 let Ok(commit) = git_repo.read_commit(&oid) else {
1878 continue;
1879 };
1880 for parent in commit.parents {
1881 pending.push(parent);
1882 }
1883 }
1884 Ok(Some(GitOverlayOutOfBandCommits {
1885 count,
1886 truncated: false,
1887 }))
1888 }
1889
1890 pub fn git_overlay_current_branch(&self) -> Result<Option<String>> {
1891 if self.capability() != RepositoryCapability::GitOverlay {
1892 return Ok(None);
1893 }
1894
1895 match detect_git_head_state(&self.root)? {
1896 Some(GitHeadState::Attached(branch)) => return Ok(Some(branch)),
1897 Some(GitHeadState::Detached(_)) | None => {}
1898 }
1899
1900 detect_git_in_progress_branch(&self.root)
1901 }
1902
1903 pub fn git_overlay_head_is_detached(&self) -> Result<bool> {
1904 if self.capability() != RepositoryCapability::GitOverlay {
1905 return Ok(false);
1906 }
1907
1908 Ok(matches!(
1909 detect_git_head_state(&self.root)?,
1910 Some(GitHeadState::Detached(_))
1911 ))
1912 }
1913
1914 pub fn git_overlay_detached_head_commit(&self) -> Result<Option<String>> {
1915 if self.capability() != RepositoryCapability::GitOverlay {
1916 return Ok(None);
1917 }
1918
1919 Ok(match detect_git_head_state(&self.root)? {
1920 Some(GitHeadState::Detached(git_oid)) => Some(git_oid.to_string()),
1921 Some(GitHeadState::Attached(_)) | None => None,
1922 })
1923 }
1924
1925 fn git_overlay_commit_tip_oid(
1926 &self,
1927 git_repo: &SleyRepository,
1928 reference: &sley::plumbing::sley_refs::Ref,
1929 ref_kind: &str,
1930 ref_name: &str,
1931 ) -> Result<Option<SleyObjectId>> {
1932 let target = match &reference.target {
1933 SleyRefTarget::Direct(oid) => *oid,
1934 SleyRefTarget::Symbolic(_) => return Ok(None),
1935 };
1936 let target = match sley::plumbing::sley_rev::peel_to_commit(
1937 git_repo.objects().as_ref(),
1938 git_repo.object_format(),
1939 &target,
1940 ) {
1941 Ok(target) => target,
1942 Err(_) => return Ok(None),
1943 };
1944
1945 let _ = (ref_kind, ref_name);
1946 Ok(Some(target))
1947 }
1948
1949 fn heddle_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1950 if self.merge_state_manager().is_merge_in_progress() {
1951 return Ok(Some(RepositoryOperationStatus {
1952 scope: OperationScope::Heddle,
1953 kind: OperationKind::Merge,
1954 in_progress: true,
1955 state: "in-progress".to_string(),
1956 message: "Heddle merge is in progress".to_string(),
1957 next_action: "heddle continue".to_string(),
1958 }));
1959 }
1960
1961 let rebase_state = self.heddle_dir.join("REBASE_STATE");
1962 if rebase_state.exists() {
1963 return Ok(Some(RepositoryOperationStatus {
1964 scope: OperationScope::Heddle,
1965 kind: OperationKind::Rebase,
1966 in_progress: true,
1967 state: "in-progress".to_string(),
1968 message: "Heddle rebase is in progress".to_string(),
1969 next_action: "heddle continue".to_string(),
1970 }));
1971 }
1972
1973 let bisect_state = self.heddle_dir.join("BISECT_STATE");
1974 if bisect_state.exists() {
1975 return Ok(Some(RepositoryOperationStatus {
1976 scope: OperationScope::Heddle,
1977 kind: OperationKind::Bisect,
1978 in_progress: true,
1979 state: "in-progress".to_string(),
1980 message: "Heddle bisect is in progress".to_string(),
1984 next_action: "heddle abort".to_string(),
1985 }));
1986 }
1987
1988 Ok(None)
1989 }
1990
1991 fn git_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1992 if self.capability() != RepositoryCapability::GitOverlay {
1993 return Ok(None);
1994 }
1995
1996 let git_dir = resolve_git_dir(&self.root)?;
1997 let raw_git_next_action = "heddle verify";
1998 let candidates = [
1999 (
2000 git_dir.join("rebase-merge"),
2001 OperationKind::Rebase,
2002 "Git rebase is in progress",
2003 raw_git_next_action,
2004 ),
2005 (
2006 git_dir.join("rebase-apply"),
2007 OperationKind::Rebase,
2008 "Git rebase is in progress",
2009 raw_git_next_action,
2010 ),
2011 (
2012 git_dir.join("MERGE_HEAD"),
2013 OperationKind::Merge,
2014 "Git merge is in progress",
2015 raw_git_next_action,
2016 ),
2017 (
2018 git_dir.join("CHERRY_PICK_HEAD"),
2019 OperationKind::CherryPick,
2020 "Git cherry-pick is in progress",
2021 raw_git_next_action,
2022 ),
2023 (
2024 git_dir.join("REVERT_HEAD"),
2025 OperationKind::Revert,
2026 "Git revert is in progress",
2027 raw_git_next_action,
2028 ),
2029 (
2030 git_dir.join("BISECT_LOG"),
2031 OperationKind::Bisect,
2032 "Git bisect is in progress",
2033 raw_git_next_action,
2034 ),
2035 ];
2036
2037 for (path, kind, message, next_action) in candidates {
2038 if path.exists() {
2039 return Ok(Some(RepositoryOperationStatus {
2040 scope: OperationScope::Git,
2041 kind,
2042 in_progress: true,
2043 state: "in-progress".to_string(),
2044 message: message.to_string(),
2045 next_action: next_action.to_string(),
2046 }));
2047 }
2048 }
2049
2050 Ok(None)
2051 }
2052
2053 pub fn list_git_checkpoints(&self) -> Result<Vec<GitCheckpointRecord>> {
2054 let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
2055 if !path.exists() {
2056 return Ok(Vec::new());
2057 }
2058 let contents = fs::read_to_string(path)?;
2059 if contents.trim().is_empty() {
2060 return Ok(Vec::new());
2061 }
2062 Ok(serde_json::from_str(&contents)?)
2063 }
2064
2065 pub fn latest_git_checkpoint_for_state(
2066 &self,
2067 state_id: &StateId,
2068 ) -> Result<Option<GitCheckpointRecord>> {
2069 let full_id = state_id.to_string_full();
2070 Ok(self
2071 .list_git_checkpoints()?
2072 .into_iter()
2073 .rev()
2074 .find(|record| record.state_id == full_id))
2075 }
2076
2077 pub fn record_git_checkpoint(
2078 &self,
2079 state_id: &StateId,
2080 git_commit: impl Into<String>,
2081 summary: impl Into<String>,
2082 ) -> Result<GitCheckpointRecord> {
2083 let mut records = self.list_git_checkpoints()?;
2084 let git_commit = git_commit.into();
2085 if let Some(existing) = records.iter().rev().find(|record| {
2086 record.state_id == state_id.to_string_full() && record.git_commit == git_commit
2087 }) {
2088 return Ok(existing.clone());
2089 }
2090 let record = GitCheckpointRecord {
2091 state_id: state_id.to_string_full(),
2092 git_commit,
2093 summary: summary.into(),
2094 committed_at: Utc::now().to_rfc3339(),
2095 };
2096 let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
2097 if let Some(parent) = path.parent() {
2098 fs::create_dir_all(parent)?;
2099 }
2100 records.push(record.clone());
2101 write_file_atomic(&path, serde_json::to_string_pretty(&records)?.as_bytes())?;
2102 Ok(record)
2103 }
2104
2105 fn git_checkpoint_intent_path(&self) -> PathBuf {
2106 self.root
2107 .join(".heddle/state")
2108 .join(GIT_CHECKPOINT_INTENT_FILE)
2109 }
2110
2111 pub fn pending_git_checkpoint_intent(&self) -> Result<Option<GitCheckpointIntent>> {
2112 let path = self.git_checkpoint_intent_path();
2113 let contents = match fs::read_to_string(&path) {
2114 Ok(contents) => contents,
2115 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2116 Err(error) => return Err(error.into()),
2117 };
2118 Ok(Some(serde_json::from_str(&contents)?))
2119 }
2120
2121 pub fn begin_git_checkpoint_intent(
2122 &self,
2123 intent: &GitCheckpointIntent,
2124 ) -> Result<GitCheckpointIntent> {
2125 if intent.version != 1 || intent.phase != GitCheckpointIntentPhase::Prepared {
2126 return Err(HeddleError::InvalidObject(
2127 "new Git checkpoint intent must be prepared v1".to_string(),
2128 ));
2129 }
2130 if let Some(existing) = self.pending_git_checkpoint_intent()? {
2131 let same_operation = existing.version == intent.version
2132 && existing.state_id == intent.state_id
2133 && existing.branch == intent.branch
2134 && existing.previous_git_oid == intent.previous_git_oid
2135 && existing.new_git_oid == intent.new_git_oid
2136 && existing.summary == intent.summary;
2137 if same_operation {
2138 return Ok(existing);
2139 }
2140 return Err(HeddleError::Config(format!(
2141 "Git checkpoint {} -> {} is still pending on branch '{}'; retry that checkpoint before starting another",
2142 existing.previous_git_oid.as_deref().unwrap_or("<unborn>"),
2143 existing.new_git_oid,
2144 existing.branch
2145 )));
2146 }
2147 let path = self.git_checkpoint_intent_path();
2148 if let Some(parent) = path.parent() {
2149 fs::create_dir_all(parent)?;
2150 }
2151 write_file_atomic(&path, serde_json::to_string_pretty(intent)?.as_bytes())?;
2152 Ok(intent.clone())
2153 }
2154
2155 pub fn mark_git_checkpoint_published(
2156 &self,
2157 state_id: &StateId,
2158 git_oid: &str,
2159 ) -> Result<GitCheckpointIntent> {
2160 let mut intent = self.pending_git_checkpoint_intent()?.ok_or_else(|| {
2161 HeddleError::Config("Git checkpoint intent disappeared before publish".to_string())
2162 })?;
2163 if intent.state_id != state_id.to_string_full() || intent.new_git_oid != git_oid {
2164 return Err(HeddleError::Config(
2165 "Git checkpoint publish does not match the durable intent".to_string(),
2166 ));
2167 }
2168 intent.phase = GitCheckpointIntentPhase::Published;
2169 write_file_atomic(
2170 &self.git_checkpoint_intent_path(),
2171 serde_json::to_string_pretty(&intent)?.as_bytes(),
2172 )?;
2173 Ok(intent)
2174 }
2175
2176 pub fn finish_git_checkpoint_intent(&self, state_id: &StateId, git_oid: &str) -> Result<()> {
2177 let Some(intent) = self.pending_git_checkpoint_intent()? else {
2178 return Ok(());
2179 };
2180 if intent.state_id != state_id.to_string_full() || intent.new_git_oid != git_oid {
2181 return Err(HeddleError::Config(
2182 "cannot finalize a Git checkpoint that does not match the durable intent"
2183 .to_string(),
2184 ));
2185 }
2186 let path = self.git_checkpoint_intent_path();
2187 fs::remove_file(&path)?;
2188 if let Some(parent) = path.parent() {
2189 objects::fs_atomic::sync_directory(parent)?;
2190 }
2191 Ok(())
2192 }
2193
2194 pub fn init_worktree(
2195 path: impl AsRef<Path>,
2196 shared_galeed_dir: impl AsRef<Path>,
2197 ) -> Result<()> {
2198 let path = path.as_ref();
2199 let shared = shared_galeed_dir.as_ref().canonicalize()?;
2200 fs::create_dir_all(path)?;
2201 let heddle_dir = path.join(".heddle");
2202 if heddle_dir.exists() {
2203 return Err(HeddleError::RepositoryExists(path.to_path_buf()));
2204 }
2205 fs::create_dir_all(&heddle_dir)?;
2206 write_file_atomic(
2207 &heddle_dir.join("objectstore"),
2208 format!(
2209 "objectstore: {}\nsource-authority: native\n",
2210 shared.display()
2211 )
2212 .as_bytes(),
2213 )?;
2214 fs::create_dir_all(heddle_dir.join("state"))?;
2215 Ok(())
2216 }
2217
2218 pub fn op_scope(&self) -> String {
2219 compute_op_scope(&self.root)
2233 }
2234
2235 pub fn op_scope_for_facet(&self, facet: &SpoolFacet) -> String {
2249 facet.scope_token(&self.op_scope())
2250 }
2251
2252 pub fn facet_head(&self, facet: &SpoolFacet, main_thread: &str) -> Result<Option<Head>> {
2264 if facet.is_default() {
2265 return Ok(Some(self.head_ref()?));
2266 }
2267 let thread = ThreadName::from(facet.thread_ref(main_thread).as_str());
2268 match self.refs.get_thread(&thread)? {
2269 Some(_) => Ok(Some(Head::Attached { thread })),
2270 None => Ok(None),
2271 }
2272 }
2273
2274 pub fn facet_head_state(
2276 &self,
2277 facet: &SpoolFacet,
2278 main_thread: &str,
2279 ) -> Result<Option<StateId>> {
2280 if facet.is_default() {
2281 return self.head();
2282 }
2283 let thread = ThreadName::from(facet.thread_ref(main_thread).as_str());
2284 self.refs.get_thread(&thread)
2285 }
2286
2287 pub fn set_facet_head(
2295 &self,
2296 facet: &SpoolFacet,
2297 main_thread: &str,
2298 state: &StateId,
2299 ) -> Result<()> {
2300 if facet.is_default() {
2301 return Err(HeddleError::InvalidObject(
2302 "set_facet_head is for named facets; the content facet HEAD moves via snapshot/goto"
2303 .to_string(),
2304 ));
2305 }
2306 let thread = ThreadName::from(facet.thread_ref(main_thread).as_str());
2307 self.refs.set_thread(&thread, state)
2308 }
2309
2310 pub fn commit_and_publish(
2319 &self,
2320 records: Vec<OpRecord>,
2321 ref_updates: &[RefUpdate],
2322 ) -> Result<()> {
2323 let encoded = records
2324 .iter()
2325 .map(|record| {
2326 rmp_serde::to_vec(record).map_err(|e| HeddleError::Serialization(e.to_string()))
2327 })
2328 .collect::<Result<Vec<_>>>()?;
2329 let scope = self.op_scope();
2330 let result = self
2331 .refs
2332 .commit_and_publish(&encoded, ref_updates, Some(&scope));
2333 let _ = self.oplog.refresh_cache();
2340 result
2341 }
2342
2343 pub fn commit_snapshot_atomic(
2361 &self,
2362 new_state: &StateId,
2363 prev_head: Option<StateId>,
2364 thread: Option<&ThreadName>,
2365 ) -> Result<()> {
2366 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, Vec::new())
2367 }
2368
2369 pub fn commit_snapshot_atomic_with_records(
2379 &self,
2380 new_state: &StateId,
2381 prev_head: Option<StateId>,
2382 thread: Option<&ThreadName>,
2383 extra: Vec<OpRecord>,
2384 ) -> Result<()> {
2385 let record = OpRecord::Snapshot {
2386 new_state: *new_state,
2387 prev_head,
2388 head: thread.is_none().then_some(*new_state),
2389 thread: thread.map(|name| name.to_string()),
2390 };
2391 let mut records = vec![record];
2392 records.extend(extra);
2393 let ref_update = match thread {
2394 Some(name) => RefUpdate::Thread {
2395 name: name.clone(),
2396 expected: RefExpectation::Any,
2397 new: Some(*new_state),
2398 },
2399 None => RefUpdate::Head {
2400 expected: RefExpectation::Any,
2401 new: Head::Detached { state: *new_state },
2402 },
2403 };
2404 self.commit_and_publish(records, &[ref_update])
2405 }
2406
2407 pub fn commit_snapshot_atomic_with_capture_visibility(
2426 &self,
2427 new_state: &StateId,
2428 prev_head: Option<StateId>,
2429 thread: Option<&ThreadName>,
2430 lock_held: bool,
2431 ) -> Result<()> {
2432 let binding = self
2433 .stage_default_visibility_binding(new_state, lock_held)
2434 .map_err(|e| HeddleError::Io(std::io::Error::other(format!("{e:#}"))))?;
2435 let (extra, rewind_to): (Vec<OpRecord>, Option<Option<Vec<u8>>>) = match binding {
2436 Some(binding) => (vec![binding.record], Some(binding.prior_sidecar)),
2437 None => (Vec::new(), None),
2438 };
2439
2440 #[cfg(test)]
2443 let commit_result = if crate::repository_state_visibility::take_visibility_commit_fault(
2444 crate::repository_state_visibility::VisibilityCommitFault::SnapshotCommit,
2445 ) {
2446 Err(HeddleError::Io(std::io::Error::other(
2447 "injected snapshot-commit failure after staging visibility binding",
2448 )))
2449 } else {
2450 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra)
2451 };
2452 #[cfg(not(test))]
2453 let commit_result =
2454 self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra);
2455
2456 match commit_result {
2457 Ok(()) => Ok(()),
2458 Err(commit_err) => {
2459 if let Some(prior) = rewind_to {
2460 if let Err(rewind_err) = self.restore_state_visibility_sidecar(new_state, prior)
2464 {
2465 tracing::warn!(
2466 state = %new_state,
2467 error = %rewind_err,
2468 "rewind of staged visibility binding after a failed snapshot commit also failed"
2469 );
2470 }
2471 }
2472 Err(commit_err)
2473 }
2474 }
2475 }
2476
2477 pub fn repo_config(&self) -> &RepoConfig {
2478 &self.config
2479 }
2480
2481 pub fn config(&self) -> &RepoConfig {
2482 self.repo_config()
2483 }
2484
2485 pub fn get_tree_for_state(&self, state_id: &StateId) -> Result<Option<Tree>> {
2486 let state = match self.store.get_state(state_id)? {
2487 Some(state) => state,
2488 None => return Ok(None),
2489 };
2490 self.store.get_tree(&state.tree)
2491 }
2492
2493 pub fn ignore_patterns(&self) -> Result<Vec<String>> {
2494 let mut patterns = self.config.worktree.ignore.clone();
2495 patterns.push(format!(
2507 "/{}",
2508 repository_thread_materialize::COURTESY_STUB_FILENAME
2509 ));
2510 patterns.push("/.git/".to_string());
2512 if self.capability() == RepositoryCapability::GitOverlay {
2513 append_ignore_file_patterns(&mut patterns, &self.root.join(".gitignore"))?;
2514 }
2515 append_ignore_file_patterns(
2523 &mut patterns,
2524 &self.root.join(".heddle").join("info").join("exclude"),
2525 )?;
2526 let path = self.root.join(".heddleignore");
2527
2528 if path.exists() {
2529 append_ignore_file_patterns(&mut patterns, &path)?;
2530 }
2531
2532 Ok(patterns)
2533 }
2534
2535 pub fn nested_thread_worktree_exclusions(&self, walk_root: &Path) -> Result<Vec<PathBuf>> {
2550 let canonical_walk_root = walk_root
2551 .canonicalize()
2552 .unwrap_or_else(|_| walk_root.to_path_buf());
2553 let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2554 let mut exclusions: Vec<PathBuf> = Vec::new();
2555 let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
2556 for thread in manager.list()? {
2557 for candidate in [
2558 Some(&thread.execution_path),
2559 thread.materialized_path.as_ref(),
2560 ]
2561 .into_iter()
2562 .flatten()
2563 {
2564 if candidate.as_os_str().is_empty() {
2565 continue;
2566 }
2567 let canonical = match candidate.canonicalize() {
2568 Ok(path) => path,
2569 Err(_) => continue,
2570 };
2571 if canonical == canonical_walk_root {
2572 continue;
2573 }
2574 if !canonical.starts_with(&canonical_walk_root) {
2575 continue;
2576 }
2577 if seen.insert(canonical.clone()) {
2578 exclusions.push(canonical);
2579 }
2580 }
2581 }
2582 Ok(exclusions)
2583 }
2584
2585 pub fn head(&self) -> Result<Option<StateId>> {
2586 Ok(match self.head_ref()? {
2587 Head::Attached { thread } => match self.refs.get_thread(&thread)? {
2588 Some(state_id) => Some(state_id),
2589 None if self.capability() == RepositoryCapability::GitOverlay => {
2590 self.git_overlay_mapped_state_for_branch(&thread)?
2591 }
2592 None => None,
2593 },
2594 Head::Detached { state } => Some(state),
2595 })
2596 }
2597
2598 pub fn head_ref(&self) -> Result<Head> {
2599 let raw = self.refs.read_head()?;
2600 if self.capability() != RepositoryCapability::GitOverlay {
2601 return Ok(raw);
2602 }
2603 if matches!(raw, Head::Detached { .. }) {
2604 return Ok(raw);
2605 }
2606 if let Some(GitHeadState::Detached(git_oid)) = detect_git_head_state(&self.root)?
2607 && let Some(state) = self.git_overlay_mapped_state_for_git_oid(git_oid)?
2608 {
2609 return Ok(Head::Detached { state });
2610 }
2611 let Some(branch) = self.git_overlay_current_branch()? else {
2612 return Ok(raw);
2613 };
2614 if matches!(&raw, Head::Attached { thread } if *thread == branch) {
2615 return Ok(raw);
2616 }
2617 let branch_thread = ThreadName::from(branch.as_str());
2618 if self.refs.get_thread(&branch_thread)?.is_some()
2619 || self.git_overlay_mapped_state_for_branch(&branch)?.is_some()
2620 {
2621 return Ok(Head::Attached {
2622 thread: branch_thread,
2623 });
2624 }
2625 Ok(raw)
2626 }
2627
2628 pub fn active_worktree_path(&self) -> Result<PathBuf> {
2645 let head = self.refs.read_head()?;
2646 let Head::Attached { thread } = head else {
2647 return Ok(self.root.clone());
2648 };
2649 let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2650 let Some(thread_record) = manager.find_by_thread(&thread)? else {
2651 return Ok(self.root.clone());
2652 };
2653 if !thread_record.execution_path.as_os_str().is_empty() {
2654 return Ok(thread_record.execution_path);
2655 }
2656 if let Some(path) = thread_record.materialized_path {
2657 return Ok(path);
2658 }
2659 Ok(self.root.clone())
2660 }
2661
2662 pub fn current_state(&self) -> Result<Option<State>> {
2663 match self.head()? {
2664 Some(id) => self.store.get_state(&id),
2665 None => Ok(None),
2666 }
2667 }
2668
2669 pub fn get_principal(&self) -> Result<Principal> {
2670 if let Some(principal) = Principal::from_env() {
2671 return Ok(principal);
2672 }
2673
2674 if let Some(config) = &self.config.principal {
2675 return Ok(Principal::new(&config.name, &config.email));
2676 }
2677
2678 if self.capability() == RepositoryCapability::GitOverlay
2679 && let Some(principal) = git_config_principal(&self.root)
2680 {
2681 return Ok(principal);
2682 }
2683
2684 if let Some(principal) = self.shared_checkout_parent_git_principal() {
2685 return Ok(principal);
2686 }
2687
2688 Ok(Principal::new("Unknown", "unknown@example.com"))
2689 }
2690
2691 fn shared_checkout_parent_git_principal(&self) -> Option<Principal> {
2692 let local_heddle_dir = self.root.join(".heddle");
2693 if local_heddle_dir == self.heddle_dir || !local_heddle_dir.join("objectstore").is_file() {
2694 return None;
2695 }
2696 let parent_root = self.heddle_dir.parent()?;
2697 if parent_root == self.root {
2698 return None;
2699 }
2700 git_config_principal(parent_root)
2701 }
2702
2703 pub fn get_attribution(&self) -> Result<Attribution> {
2704 let principal = self.get_principal()?;
2705
2706 if let Some(agent) = self.resolve_agent() {
2707 Ok(Attribution::with_agent(principal, agent))
2708 } else {
2709 Ok(Attribution::human(principal))
2710 }
2711 }
2712
2713 pub fn is_shallow(&self, id: &StateId) -> bool {
2714 self.shallow.read_or_poisoned().is_shallow(id)
2715 }
2716
2717 pub fn set_shallow(&self, state_id: &StateId, _parents: &[StateId]) -> Result<()> {
2718 self.shallow.write_or_poisoned().add_shallow(*state_id)?;
2719 Ok(())
2720 }
2721
2722 pub fn record_missing_blob(&self, hash: ContentHash) -> Result<()> {
2723 self.partial_fetch_metadata().record_missing_blob(hash)?;
2724 Ok(())
2725 }
2726
2727 pub fn seed_default_thread(&self) -> Result<()> {
2742 let main_thread = ThreadName::from("main");
2743 if self.refs.get_thread(&main_thread)?.is_some() {
2744 return Ok(());
2745 }
2746
2747 let empty_tree = Tree::new();
2748 let tree_hash = self.store.put_tree(&empty_tree)?;
2749 let state = State::new_snapshot(tree_hash, vec![], Attribution::human(seed_principal()));
2750 self.store.put_state(&state)?;
2751 self.refs.set_thread(&main_thread, &state.id())?;
2752 Ok(())
2753 }
2754
2755 pub fn clear_missing_blob(&self, hash: &ContentHash) -> Result<()> {
2756 self.partial_fetch_metadata().clear_missing_blob(hash)?;
2757 Ok(())
2758 }
2759
2760 pub fn missing_blobs(&self) -> Result<Vec<ContentHash>> {
2761 self.partial_fetch_metadata().missing_blobs()
2762 }
2763
2764 pub fn clear_all_missing_blobs(&self) -> Result<bool> {
2765 self.partial_fetch_metadata().clear_all_missing_blobs()
2766 }
2767
2768 pub fn is_missing_blob(&self, hash: &ContentHash) -> Result<bool> {
2769 self.partial_fetch_metadata().is_missing_blob(hash)
2770 }
2771
2772 pub fn require_tree(&self, hash: &ContentHash) -> Result<Tree> {
2799 self.store
2800 .get_tree(hash)?
2801 .ok_or_else(|| HeddleError::MissingObject {
2802 object_type: "tree".to_string(),
2803 id: hash.to_hex(),
2804 })
2805 }
2806
2807 pub fn require_blob(&self, hash: &ContentHash) -> Result<objects::object::Blob> {
2808 if let Some(blob) = self.store.get_blob(hash)? {
2809 if self.is_missing_blob(hash)? {
2810 self.clear_missing_blob(hash)?;
2811 }
2812 return Ok(blob);
2813 }
2814
2815 if self.is_missing_blob(hash)? {
2816 if let Some(hydrator) = self.blob_hydrator() {
2820 hydrator.hydrate(self, hash)?;
2821 if let Some(blob) = self.store.get_blob(hash)? {
2822 self.clear_missing_blob(hash)?;
2823 return Ok(blob);
2824 }
2825 }
2830 return Err(HeddleError::MissingObject {
2831 object_type: "blob".to_string(),
2832 id: hash.to_hex(),
2833 });
2834 }
2835
2836 Err(HeddleError::NotFound(hash.to_hex()))
2837 }
2838
2839 pub fn set_blob_hydrator(&self, hydrator: Arc<dyn BlobHydrator>) {
2852 *self.blob_hydrator.write_or_poisoned() = Some(hydrator);
2853 }
2854
2855 pub fn blob_hydrator(&self) -> Option<Arc<dyn BlobHydrator>> {
2857 self.blob_hydrator.read_or_poisoned().clone()
2858 }
2859
2860 pub fn set_progress(&self, progress: Progress) {
2867 *self.progress.write_or_poisoned() = progress;
2868 }
2869
2870 pub fn progress(&self) -> Progress {
2873 self.progress.read_or_poisoned().clone()
2874 }
2875
2876 fn partial_fetch_metadata(&self) -> repository_partial_fetch::PartialFetchMetadataManager {
2877 repository_partial_fetch::PartialFetchMetadataManager::new(&self.heddle_dir)
2878 }
2879
2880 pub fn shallow(&self) -> std::sync::RwLockReadGuard<'_, ShallowInfo> {
2881 self.shallow.read_or_poisoned()
2882 }
2883}
2884
2885fn ensure_git_overlay_exclude(root: &Path) -> Result<()> {
2886 let git_dir = match SleyRepository::discover(root) {
2887 Ok(repo) if repo.workdir().is_some() => repo.git_dir().to_path_buf(),
2888 _ => root.join(".git"),
2889 };
2890 if !git_dir.is_dir() {
2891 return Ok(());
2892 }
2893
2894 let info_dir = git_dir.join("info");
2895 fs::create_dir_all(&info_dir)?;
2896 let exclude_path = info_dir.join("exclude");
2897 let mut contents = fs::read_to_string(&exclude_path).unwrap_or_default();
2898 let existing_lines = contents.lines().map(str::trim).collect::<BTreeSet<_>>();
2899 let mut missing = Vec::new();
2900 for pattern in GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS {
2901 if !existing_lines
2902 .iter()
2903 .any(|line| git_overlay_exclude_line_matches(line, pattern))
2904 {
2905 missing.push(*pattern);
2906 }
2907 }
2908 if missing.is_empty() {
2909 return Ok(());
2910 }
2911 if !contents.is_empty() && !contents.ends_with('\n') {
2912 contents.push('\n');
2913 }
2914 contents.push_str("# Heddle local metadata\n");
2915 for pattern in missing {
2916 contents.push_str(pattern);
2917 contents.push('\n');
2918 }
2919 fs::write(exclude_path, contents)?;
2920 Ok(())
2921}
2922
2923fn git_overlay_exclude_line_matches(line: &str, pattern: &str) -> bool {
2924 line == pattern
2925 || matches!(
2926 (line, pattern),
2927 (".heddle", ".heddle/") | ("/.heddle/", ".heddle/") | ("/.heddle", ".heddle/")
2928 )
2929}
2930
2931pub(crate) fn seed_principal() -> Principal {
2936 Principal::new("Heddle", "init@heddle")
2937}
2938
2939pub fn is_synthetic_root(state: &State) -> bool {
2944 state.parents.is_empty()
2945 && state.intent.is_none()
2946 && state.attribution.principal == seed_principal()
2947 && state.attribution.agent.is_none()
2948}
2949
2950struct WorktreePointer {
2951 objectstore: PathBuf,
2952 source_authority: RepositorySourceAuthority,
2953}
2954
2955fn parse_objectstore_pointer(content: &str) -> Option<WorktreePointer> {
2956 let mut objectstore = None;
2957 let mut source_authority = None;
2958 for line in content.lines() {
2959 if let Some(path) = line.strip_prefix("objectstore:") {
2960 let path = path.trim();
2961 if !path.is_empty() {
2962 objectstore = Some(PathBuf::from(path));
2963 }
2964 } else if let Some(authority) = line.strip_prefix("source-authority:") {
2965 source_authority = match authority.trim() {
2966 "native" => Some(RepositorySourceAuthority::Native),
2967 "git-overlay" => Some(RepositorySourceAuthority::GitOverlay),
2968 _ => return None,
2969 };
2970 }
2971 }
2972 Some(WorktreePointer {
2973 objectstore: objectstore?,
2974 source_authority: source_authority?,
2975 })
2976}
2977
2978pub(crate) fn has_git_metadata(path: &Path) -> bool {
2979 let dot_git = path.join(".git");
2980 if !(dot_git.is_dir() || dot_git.is_file()) {
2981 return false;
2982 }
2983
2984 SleyRepository::discover(path).is_ok()
2985}
2986
2987fn repository_capability_for_authority(
2988 source_authority: RepositorySourceAuthority,
2989) -> RepositoryCapability {
2990 match source_authority {
2991 RepositorySourceAuthority::Native => RepositoryCapability::NativeHeddle,
2992 RepositorySourceAuthority::GitOverlay => RepositoryCapability::GitOverlay,
2993 }
2994}
2995
2996fn metadataless_managed_thread_root(start_path: &Path) -> Option<PathBuf> {
3010 let mut cur: Option<&Path> = Some(start_path);
3011 while let Some(dir) = cur {
3012 if let Some(thread_dir) = dir.parent()
3013 && let Some(threads) = thread_dir.parent()
3014 && threads.file_name().and_then(|n| n.to_str()) == Some("threads")
3015 && let Some(heddle) = threads.parent()
3016 && heddle.file_name().and_then(|n| n.to_str()) == Some(".heddle")
3017 && heddle.join("objects").is_dir()
3018 && !dir.join(".heddle").exists()
3019 {
3020 return Some(dir.to_path_buf());
3021 }
3022 cur = dir.parent();
3023 }
3024 None
3025}
3026
3027fn git_config_principal(root: &Path) -> Option<Principal> {
3028 let git_repo = SleyRepository::discover(root).ok()?;
3029 let config = git_repo.config_snapshot().ok()?;
3030 let name = config.get("user", None, "name")?.to_string();
3031 let email = config.get("user", None, "email")?.to_string();
3032 if name.trim().is_empty() || email.trim().is_empty() {
3033 return None;
3034 }
3035 Some(Principal::new(&name, &email))
3036}
3037
3038#[cfg(feature = "git-overlay")]
3039fn git_path(path: &[u8]) -> String {
3040 String::from_utf8_lossy(path).into_owned()
3041}
3042
3043#[cfg(feature = "git-overlay")]
3044fn ignored_git_overlay_status_path(path: &str) -> bool {
3045 path == ".heddle" || path.starts_with(".heddle/")
3046}
3047
3048#[cfg(feature = "git-overlay")]
3049const GIT_MODE_COMMIT: u32 = 0o160000;
3050
3051#[cfg(feature = "git-overlay")]
3052fn git_worktree_matches_repo_root(git: &SleyRepository, root: &Path) -> bool {
3053 git.workdir().is_some_and(
3054 |workdir| match (workdir.canonicalize(), root.canonicalize()) {
3055 (Ok(workdir), Ok(root)) => workdir == root,
3056 _ => false,
3057 },
3058 )
3059}
3060
3061#[cfg(feature = "git-overlay")]
3062fn append_short_status_to_index_intent(
3063 staged_paths: &mut Vec<String>,
3064 extra_paths: &mut Vec<String>,
3065 ignore_matcher: &objects::worktree::WorktreeIgnoreMatcher,
3066 entry: sley::ShortStatusRow<'_>,
3067 path: &str,
3068) {
3069 if entry.index == b'?' && entry.worktree == b'?' {
3070 if !ignore_matcher.is_ignored(Path::new(path)) {
3071 extra_paths.push(format!("untracked: {path}"));
3072 }
3073 return;
3074 }
3075 if entry.index != b' ' && entry.index != b'!' {
3076 staged_paths.push(path.to_string());
3077 }
3078 if entry.worktree != b' '
3079 && entry.worktree != b'!'
3080 && !status_row_is_gitlink_worktree_only(entry)
3081 {
3082 extra_paths.push(format!("unstaged: {path}"));
3083 }
3084}
3085
3086#[cfg(feature = "git-overlay")]
3087fn status_row_is_gitlink_worktree_only(entry: sley::ShortStatusRow<'_>) -> bool {
3088 entry.index == b' '
3089 && (entry.index_mode == Some(GIT_MODE_COMMIT)
3090 || entry.head_mode == Some(GIT_MODE_COMMIT)
3091 || entry.worktree_mode == Some(GIT_MODE_COMMIT))
3092}
3093
3094#[cfg(feature = "git-overlay")]
3095fn git_overlay_untracked_path_ignored(
3096 ignore_matcher: &crate::worktree_ignore::WorktreeIgnoreMatcher,
3097 path: &Path,
3098) -> bool {
3099 let parent = path.parent().unwrap_or_else(|| Path::new(""));
3100 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
3101 return false;
3102 };
3103 ignore_matcher.should_prune_directory_child(parent, name)
3104}
3105
3106fn git_remote_names(root: &Path) -> Result<Vec<String>> {
3107 let repo = match SleyRepository::discover(root) {
3108 Ok(repo) => repo,
3109 Err(_) => return Ok(Vec::new()),
3110 };
3111 repo.remote_names()
3112 .map(|names| {
3113 names
3114 .into_iter()
3115 .filter(|name| !name.trim().is_empty())
3116 .collect()
3117 })
3118 .map_err(|error| HeddleError::Config(error.to_string()))
3119}
3120
3121fn git_find_reference(repo: &SleyRepository, name: &str) -> Result<Option<SleyReference>> {
3122 repo.find_reference(name).map_err(|error| {
3123 HeddleError::Config(format!("failed to inspect Git reference '{name}': {error}"))
3124 })
3125}
3126
3127fn git_resolve_oid(repo: &SleyRepository, rev: &str) -> Result<Option<SleyObjectId>> {
3128 match repo.rev_parse(rev) {
3129 Ok(id) => Ok(Some(id)),
3130 Err(_) => Ok(None),
3131 }
3132}
3133
3134fn git_configured_tracking_ref(repo: &SleyRepository, branch: &str) -> Result<Option<String>> {
3135 let config = repo
3136 .config_snapshot()
3137 .map_err(|error| HeddleError::Config(error.to_string()))?;
3138 let Some(remote) = config.get("branch", Some(branch), "remote") else {
3139 return Ok(None);
3140 };
3141 let Some(merge) = config.get("branch", Some(branch), "merge") else {
3142 return Ok(None);
3143 };
3144 if remote == "." {
3145 return Ok(Some(merge.to_string()));
3146 }
3147 let merge_ref = GitRefName::new(merge);
3148 if merge_ref.content_namespace() != Some(GitRefContentNamespace::Branch) {
3149 return Ok(None);
3150 };
3151 let Some(short) = merge_ref.short_name() else {
3152 return Ok(None);
3153 };
3154 Ok(Some(GitRefName::remote_branch_full_name(remote, short)))
3155}
3156
3157fn git_ahead_behind_counts(
3158 git: &SleyRepository,
3159 head: SleyObjectId,
3160 upstream: SleyObjectId,
3161) -> Result<(usize, usize)> {
3162 if upstream == head {
3163 return Ok((0, 0));
3164 }
3165 let (ahead, behind) = git
3166 .rev_graph()
3167 .ahead_behind(head, upstream)
3168 .map_err(|error| HeddleError::Config(error.to_string()))?;
3169 Ok((ahead, behind))
3170}
3171
3172fn git_remote_tracking_display_name(name: &str) -> String {
3173 name.strip_prefix("refs/remotes/")
3174 .unwrap_or(name)
3175 .to_string()
3176}
3177
3178fn git_remote_tracking_message(
3179 branch: &str,
3180 upstream: &str,
3181 ahead: usize,
3182 behind: usize,
3183 upstream_is_undone_checkpoint: bool,
3184) -> String {
3185 if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
3186 return format!(
3187 "Upstream '{upstream}' still points at a Git commit that was undone locally on branch '{branch}'"
3188 );
3189 }
3190 match (ahead, behind) {
3191 (0, behind) => format!(
3192 "Git branch '{}' is behind upstream '{}' by {} commit(s)",
3193 branch, upstream, behind
3194 ),
3195 (ahead, 0) => format!(
3196 "Git branch '{}' is ahead of upstream '{}' by {} commit(s)",
3197 branch, upstream, ahead
3198 ),
3199 (ahead, behind) => format!(
3200 "Git branch '{}' has diverged from upstream '{}' (ahead {}, behind {})",
3201 branch, upstream, ahead, behind
3202 ),
3203 }
3204}
3205
3206fn git_remote_tracking_next_action(
3207 ahead: usize,
3208 behind: usize,
3209 upstream_is_undone_checkpoint: bool,
3210) -> String {
3211 if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
3212 return "heddle push --force".to_string();
3213 }
3214 match (ahead, behind) {
3215 (0, _) => "heddle pull".to_string(),
3216 (_, 0) => "heddle push".to_string(),
3217 _ => "heddle pull".to_string(),
3218 }
3219}
3220
3221fn append_ignore_file_patterns(patterns: &mut Vec<String>, path: &Path) -> Result<()> {
3222 if !path.exists() {
3223 return Ok(());
3224 }
3225 let contents = std::fs::read_to_string(path)?;
3226 for line in contents.lines() {
3227 let trimmed = line.trim();
3228 if trimmed.is_empty() || trimmed.starts_with('#') {
3229 continue;
3230 }
3231 if !patterns.iter().any(|pattern| pattern == trimmed) {
3232 patterns.push(trimmed.to_string());
3233 }
3234 }
3235 Ok(())
3236}
3237
3238fn detect_git_head_state(path: &Path) -> Result<Option<GitHeadState>> {
3241 let repo = SleyRepository::discover(path).map_err(|error| {
3242 HeddleError::Config(format!(
3243 "failed to inspect git repository at '{}': {}",
3244 path.display(),
3245 error
3246 ))
3247 })?;
3248 let head = match repo.head_state() {
3249 Ok(head) => head,
3250 Err(_) => return Ok(None),
3251 };
3252
3253 if head.is_missing() {
3254 return Ok(None);
3255 }
3256 if let Some(name) = head.branch_name() {
3257 if name.is_empty() {
3258 return Ok(None);
3259 }
3260 return Ok(Some(GitHeadState::Attached(name.to_string())));
3261 }
3262 if head.is_detached()
3263 && let Some(id) = head.oid()
3264 {
3265 return Ok(Some(GitHeadState::Detached(id)));
3266 }
3267 Ok(None)
3268}
3269
3270fn detect_git_head(path: &Path) -> Result<Option<Head>> {
3272 if let Some(GitHeadState::Attached(thread)) = detect_git_head_state(path)? {
3273 return Ok(Some(Head::Attached {
3274 thread: ThreadName::from(thread),
3275 }));
3276 }
3277 Ok(None)
3278}
3279
3280fn resolve_git_dir(path: &Path) -> Result<PathBuf> {
3281 let repo = SleyRepository::discover(path).map_err(|error| {
3282 HeddleError::Config(format!(
3283 "failed to resolve git dir at '{}': {}",
3284 path.display(),
3285 error
3286 ))
3287 })?;
3288 Ok(repo.git_dir().to_path_buf())
3289}
3290
3291fn detect_git_in_progress_branch(path: &Path) -> Result<Option<String>> {
3292 let git_dir = resolve_git_dir(path)?;
3293 for marker in ["rebase-merge/head-name", "rebase-apply/head-name"] {
3294 let branch_path = git_dir.join(marker);
3295 if !branch_path.exists() {
3296 continue;
3297 }
3298 let raw = fs::read_to_string(&branch_path)?;
3299 let value = raw.trim();
3300 let ref_name = GitRefName::new(value);
3301 if ref_name.content_namespace() == Some(GitRefContentNamespace::Branch)
3302 && let Some(short) = ref_name.short_name()
3303 {
3304 return Ok(Some(short.to_string()));
3305 }
3306 if !value.is_empty() {
3307 return Ok(Some(value.to_string()));
3308 }
3309 }
3310 Ok(None)
3311}
3312
3313#[cfg(test)]
3314mod tests {
3315 use std::{path::Path, process::Command};
3316
3317 use tempfile::TempDir;
3318
3319 use super::Repository;
3320 use crate::RepositoryCapability;
3321
3322 fn git(root: &Path, args: &[&str]) {
3323 let status = Command::new("git")
3324 .current_dir(root)
3325 .args(args)
3326 .status()
3327 .expect("spawn git");
3328 assert!(
3329 status.success(),
3330 "git {:?} failed in {}",
3331 args,
3332 root.display()
3333 );
3334 }
3335
3336 fn git_output(root: &Path, args: &[&str]) -> String {
3337 let output = Command::new("git")
3338 .current_dir(root)
3339 .args(args)
3340 .output()
3341 .expect("spawn git");
3342 assert!(
3343 output.status.success(),
3344 "git {:?} failed: {}",
3345 args,
3346 String::from_utf8_lossy(&output.stderr)
3347 );
3348 String::from_utf8(output.stdout).unwrap().trim().to_string()
3349 }
3350
3351 fn init_git_with_identity(root: &Path) {
3352 sley::Repository::init(root).expect("init git repository");
3353 git(root, &["config", "user.email", "test@heddle.local"]);
3354 git(root, &["config", "user.name", "Heddle Test"]);
3355 }
3356
3357 fn configure_main_tracks_origin(root: &Path) {
3358 git(root, &["config", "branch.main.remote", "origin"]);
3359 git(root, &["config", "branch.main.merge", "refs/heads/main"]);
3360 }
3361
3362 fn diverged_two_ahead_one_behind_fixture() -> TempDir {
3373 let temp = TempDir::new().unwrap();
3374 let root = temp.path();
3375 init_git_with_identity(root);
3376 git(root, &["commit", "--allow-empty", "-m", "base"]);
3377 let base = git_output(root, &["rev-parse", "HEAD"]);
3378 git(root, &["commit", "--allow-empty", "-m", "u1"]);
3379 let upstream_tip = git_output(root, &["rev-parse", "HEAD"]);
3380 git(root, &["reset", "--hard", &base]);
3381 git(root, &["commit", "--allow-empty", "-m", "l1"]);
3382 git(root, &["commit", "--allow-empty", "-m", "l2"]);
3383 git(
3384 root,
3385 &["update-ref", "refs/remotes/origin/main", &upstream_tip],
3386 );
3387 configure_main_tracks_origin(root);
3388 temp
3389 }
3390
3391 #[test]
3392 fn git_remote_tracking_reports_diverged_ahead_behind() {
3393 let temp = diverged_two_ahead_one_behind_fixture();
3394 let repo = Repository::init_git_overlay_sidecar(temp.path()).unwrap();
3395 assert_eq!(repo.capability(), RepositoryCapability::GitOverlay);
3396
3397 let status = repo
3398 .git_remote_tracking_status()
3399 .unwrap()
3400 .expect("configured upstream with drift should return status");
3401 assert_eq!(status.ahead, 2);
3402 assert_eq!(status.behind, 1);
3403 assert_eq!(status.upstream, "origin/main");
3404 }
3405
3406 #[test]
3407 fn git_remote_tracking_in_sync_returns_none() {
3408 let temp = TempDir::new().unwrap();
3409 let root = temp.path();
3410 init_git_with_identity(root);
3411 git(root, &["commit", "--allow-empty", "-m", "only"]);
3412 let tip = git_output(root, &["rev-parse", "HEAD"]);
3413 git(root, &["update-ref", "refs/remotes/origin/main", &tip]);
3414 configure_main_tracks_origin(root);
3415
3416 let repo = Repository::init_git_overlay_sidecar(root).unwrap();
3417 assert!(repo.git_remote_tracking_status().unwrap().is_none());
3418 }
3419
3420 #[test]
3421 fn git_remote_tracking_without_upstream_config() {
3422 let temp = TempDir::new().unwrap();
3423 let root = temp.path();
3424 init_git_with_identity(root);
3425 git(root, &["commit", "--allow-empty", "-m", "only"]);
3426 git(root, &["remote", "add", "origin", root.to_str().unwrap()]);
3427
3428 let repo = Repository::init_git_overlay_sidecar(root).unwrap();
3429 let status = repo
3430 .git_remote_tracking_status()
3431 .unwrap()
3432 .expect("no upstream config still reports actionable status");
3433 assert_eq!(status.ahead, 0);
3434 assert_eq!(status.behind, 0);
3435 assert!(status.upstream.is_empty());
3436 assert!(status.message.contains("has no upstream tracking branch"));
3437 }
3438}