Skip to main content

repo/
repository.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Repository: high-level interface for Heddle operations.
3
4#[path = "bloom_filter.rs"]
5mod bloom_filter;
6#[path = "commit_graph.rs"]
7pub(crate) mod commit_graph;
8#[path = "commit_graph_persistence.rs"]
9mod commit_graph_persistence;
10#[path = "context_suggestions.rs"]
11mod context_suggestions;
12#[path = "repo_config.rs"]
13mod repo_config;
14#[path = "repository_context.rs"]
15mod repository_context;
16#[path = "repository_diff.rs"]
17mod repository_diff;
18#[path = "repository_goto.rs"]
19mod repository_goto;
20#[path = "repository_history.rs"]
21mod repository_history;
22#[path = "repository_maintenance.rs"]
23mod repository_maintenance;
24#[path = "repository_materialization.rs"]
25mod repository_materialization;
26#[path = "repository_partial_fetch.rs"]
27mod repository_partial_fetch;
28#[path = "repository_provenance/mod.rs"]
29mod repository_provenance;
30#[path = "repository_resolve.rs"]
31mod repository_resolve;
32#[path = "repository_signing.rs"]
33mod repository_signing;
34pub use repository_signing::ResignOutcome;
35#[path = "repository_snapshot.rs"]
36mod repository_snapshot;
37#[cfg(test)]
38#[path = "repository_tests.rs"]
39mod repository_tests;
40#[path = "repository_thread_materialize.rs"]
41mod repository_thread_materialize;
42#[path = "repository_tree.rs"]
43mod repository_tree;
44#[path = "repository_worktree_apply.rs"]
45pub(crate) mod repository_worktree_apply;
46#[path = "repository_worktree_status.rs"]
47mod repository_worktree_status;
48#[path = "status_tracked_refresh.rs"]
49mod status_tracked_refresh;
50#[path = "status_untracked_scan.rs"]
51mod status_untracked_scan;
52
53use std::{
54    collections::{BTreeMap, BTreeSet, HashMap},
55    fs,
56    path::{Path, PathBuf},
57    sync::{Arc, RwLock},
58};
59
60use chrono::Utc;
61pub use commit_graph::{CommitGraphIndex, find_merge_base};
62pub use context_suggestions::{
63    ContextSuggestion, ContextSuggestionTier, HIGH_SUGGESTION_THRESHOLD,
64    MAJOR_REWRITE_THRESHOLD_PCT, MEDIUM_SUGGESTION_THRESHOLD, SUGGESTION_WINDOW,
65    compute_rewrite_pct, is_major_rewrite,
66};
67pub use objects::object::DiffKind;
68use objects::{
69    error::{HeddleError, Result},
70    fs_atomic::write_file_atomic,
71    lock::{RepoLock, RepositoryLockExt},
72    object::{Attribution, ChangeId, ContentHash, MarkerName, Principal, State, ThreadName, Tree},
73    store::{AnyStore, FsStore, ObjectStore, ShallowInfo},
74    worktree::{WorktreeStatus, should_ignore as should_ignore_path},
75};
76use oplog::{OpLog, OpLogBackend, OpRecord};
77pub use refs::RefSummaryIndexInspection;
78use refs::{Head, RefBackend, RefExpectation, RefManager, RefUpdate};
79pub use repo_config::{HostedConfig, OutputFormat, RedactConfig, RepoConfig, TrustedKey};
80// Review-epic config types — re-exported here so the new
81// `repository_signals.rs` (and external crates wanting to construct a
82// custom signals config) don't need to reach into a private module path.
83#[allow(unused_imports)]
84pub use repo_config::{
85    PatternDeviationToml, ReviewConfig, ReviewSignalsToml, SelfFlaggedToml, SignalEnableToml,
86    SignalModuleToml, TestReachabilityToml,
87};
88pub use repository_history::{ChangedPathFilter, ChangedPathFilters, HistoryQuery};
89pub use repository_maintenance::{
90    ChangeMonitorInspection, CommitGraphInspection, PackFilesInspection, PartialFetchInspection,
91    PullPlannerCacheInspection, RefCountsInspection, RepositoryMaintenanceRunReport,
92    RepositoryPerformanceInspectionReport, WorktreeIndexInspection,
93};
94pub use repository_materialization::WarmCanonicalStoreStats;
95pub use repository_partial_fetch::MissingBlob;
96pub use repository_snapshot::{SnapshotExecution, SnapshotProfile};
97pub use repository_thread_materialize::{CheckoutMaterialization, ThreadCaptureOutcome};
98pub use repository_tree::{TreeBuildProfile, WorktreeCompareProfile};
99pub use repository_worktree_status::{UntrackedSet, UntrackedSubtree, WorktreeStatusDetailed};
100use rusqlite::{Connection, OpenFlags};
101use serde::{Deserialize, Serialize};
102use sley::{
103    ObjectId as SleyObjectId, Reference as SleyReference, ReferenceTarget as SleyRefTarget,
104    Repository as SleyRepository,
105};
106
107use crate::git_worktree_status::GitWorktreeEntryState;
108
109const GIT_CHECKPOINTS_FILE: &str = "git-checkpoints.json";
110const GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS: &[&str] = &[".heddle/"];
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum RepositoryCapability {
114    GitOverlay,
115    NativeHeddle,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119enum GitHeadState {
120    Attached(String),
121    Detached(SleyObjectId),
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct GitCheckpointRecord {
126    pub change_id: String,
127    pub git_commit: String,
128    pub summary: String,
129    pub committed_at: String,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct GitOverlayImportHint {
134    pub current_branch: String,
135    pub missing_branch_count: usize,
136    pub missing_branches: Vec<String>,
137    pub recommended_command: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct GitOverlayBranchTip {
142    pub branch: String,
143    pub git_commit: String,
144    pub history_imported: bool,
145    #[serde(skip)]
146    pub mapped_change: Option<ChangeId>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct GitOverlayTagTip {
151    pub tag: String,
152    pub git_commit: String,
153    pub history_imported: bool,
154    #[serde(skip)]
155    pub mapped_change: Option<ChangeId>,
156}
157
158/// How many Git commits reachable from a branch tip have no Heddle mapping
159/// (neither bridge-imported nor checkpointed). Used to report how far a Git
160/// branch moved out-of-band before `heddle adopt --ref` reconciles it.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct GitOverlayOutOfBandCommits {
163    pub count: usize,
164    /// True when the walk stopped at the scan limit before exhausting the
165    /// unmapped history; `count` is then a lower bound.
166    pub truncated: bool,
167}
168
169/// Cap for the out-of-band commit walk so a read path (status/verify/health)
170/// never pays an O(full-history) traversal when external history was rewritten
171/// and no mapped ancestor exists.
172const GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT: usize = 1000;
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "kebab-case")]
176pub enum OperationScope {
177    Git,
178    Heddle,
179}
180
181impl std::fmt::Display for OperationScope {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            Self::Git => write!(f, "git"),
185            Self::Heddle => write!(f, "heddle"),
186        }
187    }
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(rename_all = "kebab-case")]
192pub enum OperationKind {
193    Merge,
194    Rebase,
195    CherryPick,
196    Revert,
197    Bisect,
198}
199
200impl std::fmt::Display for OperationKind {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        match self {
203            Self::Merge => write!(f, "merge"),
204            Self::Rebase => write!(f, "rebase"),
205            Self::CherryPick => write!(f, "cherry-pick"),
206            Self::Revert => write!(f, "revert"),
207            Self::Bisect => write!(f, "bisect"),
208        }
209    }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct RepositoryOperationStatus {
214    pub scope: OperationScope,
215    pub kind: OperationKind,
216    pub in_progress: bool,
217    pub state: String,
218    pub message: String,
219    pub next_action: String,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct GitRemoteTrackingStatus {
224    pub branch: String,
225    pub upstream: String,
226    pub ahead: usize,
227    pub behind: usize,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub local_oid: Option<String>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub upstream_oid: Option<String>,
232    #[serde(default, skip_serializing_if = "is_false")]
233    pub upstream_is_undone_checkpoint: bool,
234    pub message: String,
235    pub next_action: String,
236}
237
238fn is_false(value: &bool) -> bool {
239    !*value
240}
241
242#[derive(Debug, Deserialize)]
243struct GitBridgeMappingEntry {
244    change_id: String,
245    git_oid: String,
246}
247
248#[derive(Debug, Deserialize, Default)]
249struct GitBridgeMappingFile {
250    entries: Vec<GitBridgeMappingEntry>,
251}
252
253/// Lazy-clone read-time hydration hook.
254///
255/// When `Repository::require_blob` is called for a blob that's recorded
256/// in `.heddle/partial-fetch` (the marker the lazy-pull plumbing leaves
257/// behind) and absent from the local object store, the repo delegates to
258/// a registered `BlobHydrator` to fetch the bytes from the upstream.
259///
260/// Two production implementations exist:
261/// - Git-overlay clones: `cli::commands::clone::GitOverlayBlobHydrator`
262///   uses sley promisor-fetch semantics against the bare `.git/` repo.
263/// - Hosted clones: `heddle_client::grpc_hosted::LazyHostedHydrator`
264///   bridges sync `hydrate` calls to async gRPC via a dedicated worker
265///   thread + private Tokio runtime; on each call the worker invokes
266///   `HostedGrpcClient::hydrate_pulled_state` for the current local-thread
267///   tip.
268///
269/// On success the hydrator is expected to write the blob into
270/// `repo.store()`; the read path then clears the missing marker and
271/// returns the blob. On failure the error is propagated verbatim — the
272/// hook is deliberately not allowed to swallow upstream outages.
273pub trait BlobHydrator: Send + Sync {
274    fn hydrate(&self, repo: &Repository, hash: &ContentHash) -> Result<()>;
275}
276
277/// A Heddle repository.
278///
279/// Generic over its reference, operation-log, and object-store backends.
280/// The CLI uses the defaults — `Repository<RefManager, OpLog, AnyStore>`
281/// (the on-disk local backends) — so the bare name `Repository` resolves to
282/// the local flavor everywhere. The hosted server instantiates
283/// `Repository<PgRefBackend, PgOpLogBackend, …>` via [`Repository::from_parts`].
284///
285/// The object store is the [`AnyStore`] enum by default: [`Repository::open`]
286/// selects `FsStore` vs `S3Store` at *runtime* from config (`build_store`),
287/// but the choice is a concrete enum variant rather than a `Box<dyn>`, so
288/// every object access is static-dispatched through the enum to the inner
289/// store — no vtable (heddle#283). `S` goes last so existing
290/// `Repository<R, O>` references keep resolving with `S = AnyStore`.
291pub struct Repository<R = RefManager, O = OpLog, S = AnyStore>
292where
293    R: RefBackend,
294    O: OpLogBackend,
295    S: ObjectStore,
296{
297    root: PathBuf,
298    heddle_dir: PathBuf,
299    capability: RepositoryCapability,
300    store: S,
301    refs: R,
302    oplog: O,
303    config: RepoConfig,
304    shallow: RwLock<ShallowInfo>,
305    blob_hydrator: RwLock<Option<Arc<dyn BlobHydrator>>>,
306    git_overlay_repo: RwLock<Option<SleyRepository>>,
307}
308
309impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> RepositoryLockExt for Repository<R, O, S> {
310    fn locker(&self) -> RepoLock {
311        let lock_root = self.heddle_dir.parent().expect(
312            "heddle_dir has no parent component; cannot determine lock root. This indicates a misconfigured repository.",
313        );
314        RepoLock::new(lock_root)
315    }
316}
317
318impl<R: RefBackend, O: OpLogBackend, S: ObjectStore> Repository<R, O, S> {
319    /// Expert-only constructor for callers that already own the repository's
320    /// component backends and invariant state.
321    ///
322    /// Callers must ensure all backends point at the same repository root, the
323    /// `heddle_dir` exists and is canonical for that root, and `shallow` matches
324    /// the on-disk shallow metadata. Prefer [`Repository::init`],
325    /// [`Repository::open`], or [`Repository::open_with_store`] unless a
326    /// cross-crate integration genuinely needs to assemble the pieces manually.
327    pub fn from_parts(
328        root: PathBuf,
329        heddle_dir: PathBuf,
330        store: S,
331        refs: R,
332        oplog: O,
333        config: RepoConfig,
334        shallow: ShallowInfo,
335    ) -> Self {
336        let capability = repository_capability_for_root(&root);
337        Self {
338            root,
339            heddle_dir,
340            capability,
341            store,
342            refs,
343            oplog,
344            config,
345            shallow: RwLock::new(shallow),
346            blob_hydrator: RwLock::new(None),
347            git_overlay_repo: RwLock::new(None),
348        }
349    }
350
351    /// The object store backing this repository.
352    pub fn store(&self) -> &S {
353        &self.store
354    }
355
356    /// The reference backend (threads, markers, HEAD).
357    pub fn refs(&self) -> &R {
358        &self.refs
359    }
360
361    /// The operation-log backend.
362    pub fn oplog(&self) -> &O {
363        &self.oplog
364    }
365}
366
367/// Local-flavor opens generic over the object store `S`.
368///
369/// `open_raw` assembles a repository from already-resolved pieces and runs
370/// none of the local-only open hooks (migrations, hydrator reconstruction) —
371/// those are bound to the default `AnyStore` flavor and live in
372/// [`Repository::run_open_hooks`], which the config-driven [`Repository::open`]
373/// invokes after `open_raw`.
374/// The per-worktree checkout lane (heddle#330 §1.5). Free function so the
375/// reconciler can be wired at construction (before a `Repository` exists)
376/// using the same computation as [`Repository::op_scope`].
377pub(crate) fn compute_op_scope(root: &Path) -> String {
378    let local_head = root.join(".heddle").join("HEAD");
379    let canonical = local_head.canonicalize().unwrap_or(local_head);
380    let digest = blake3::hash(canonical.to_string_lossy().as_bytes());
381    format!("wt-{}", &digest.to_hex().as_str()[..16])
382}
383
384fn ensure_supported_repo_format(config_path: &Path, config: &RepoConfig) -> Result<()> {
385    let found = config.repository.version;
386    let supported = repo_config::SUPPORTED_REPO_FORMAT;
387    if found > supported {
388        return Err(HeddleError::RepositoryFormatTooNew {
389            path: config_path.to_path_buf(),
390            found,
391            supported,
392        });
393    }
394    Ok(())
395}
396
397impl<S: ObjectStore> Repository<RefManager, OpLog, S> {
398    fn open_raw(
399        root: PathBuf,
400        heddle_dir: PathBuf,
401        store: S,
402        config: RepoConfig,
403        refs: RefManager,
404    ) -> Result<Self> {
405        let actor = config
406            .principal
407            .as_ref()
408            .map(|p| objects::object::Principal::new(&p.name, &p.email))
409            .unwrap_or_else(|| objects::object::Principal::new("<unknown>", ""));
410        let oplog = OpLog::new(&heddle_dir, actor.clone());
411        let shallow = ShallowInfo::load(&heddle_dir)?;
412        // Inject the oplog-backed read + write chokepoints (heddle#330 §2.2):
413        // every logical read reconciles against the committed oplog tail, and
414        // `commit_and_publish` appends a ref-carrying record before publishing.
415        let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
416            &heddle_dir,
417            compute_op_scope(&root),
418        ));
419        let committer =
420            std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(&heddle_dir, actor));
421        let refs = refs.with_reconciler(reconciler).with_committer(committer);
422        // Seed the per-read watermark from the persisted last-clean point
423        // (heddle#354 r5, cid 3329631074) so a fresh handle folds — and recovers
424        // — a prior process's committed-but-unpublished crash tail on its next
425        // read, without re-deriving long-since-deleted refs from ancient records.
426        refs.init_reconcile_watermark()?;
427        Ok(Self::from_parts(
428            root, heddle_dir, store, refs, oplog, config, shallow,
429        ))
430    }
431
432    /// Open an existing Heddle repository using a custom object store backend.
433    ///
434    /// Expert/test injection point: takes the store by value (any
435    /// [`ObjectStore`]) and skips the local-only open hooks (declarative
436    /// migrations, lazy-clone hydrator reconstruction) that [`Repository::open`]
437    /// runs for the default `AnyStore` flavor.
438    pub fn open_with_store(heddle_dir: impl AsRef<Path>, store: S) -> Result<Self> {
439        let heddle_dir = heddle_dir.as_ref().to_path_buf();
440        let root = heddle_dir
441            .parent()
442            .ok_or_else(|| {
443                HeddleError::Config(format!(
444                    "heddle_dir '{}' has no parent directory",
445                    heddle_dir.display()
446                ))
447            })?
448            .to_path_buf();
449        let config_path = heddle_dir.join("config.toml");
450        let config = RepoConfig::load(&config_path)?;
451        ensure_supported_repo_format(&config_path, &config)?;
452        let refs = RefManager::new(&heddle_dir);
453        Self::open_raw(root, heddle_dir, store, config, refs)
454    }
455}
456
457impl Repository {
458    /// Run the local-only hooks that follow a config-driven [`Repository::open`]:
459    /// declarative migrations + lazy-clone hydrator reconstruction. Both are
460    /// bound to the default `AnyStore` flavor (`apply_pending` and
461    /// `BlobHydrator` operate on the bare `Repository`), so they live here
462    /// rather than in the generic `open_raw`.
463    fn run_open_hooks(&self) {
464        // Run any pending declarative migrations. Idempotent:
465        // re-opening a repo a second time is a no-op for the migration pass.
466        // Failures here are logged but non-fatal; surfacing migration errors
467        // through `open` is worse than letting the repo open and warning later.
468        if let Err(err) = crate::migration::apply_pending(self) {
469            tracing::warn!("declarative migrations failed during repo open: {err}");
470        }
471        // Reconstruct any persisted lazy-clone blob hydrator. When
472        // `.heddle/lazy-hydrator.toml` exists, look up the registered
473        // factory for its `kind` and install the hydrator on the
474        // freshly-opened repo so a subsequent `require_blob` against a
475        // missing-blob marker can fetch transparently — without this
476        // reconstruction, lazy clones would only work inside the single
477        // `cmd_clone` process. See `lazy_hydrator.rs` for the shape.
478        match crate::lazy_hydrator::try_reconstruct(self.root(), self.heddle_dir()) {
479            Ok(Some(hydrator)) => self.set_blob_hydrator(hydrator),
480            Ok(None) => {}
481            Err(err) => {
482                // Hydrator construction failed (factory error or
483                // malformed metadata). Surface as a warning rather
484                // than blocking `open` — eager `heddle status` calls
485                // shouldn't fail just because a stale hosted
486                // endpoint is unreachable; the user will get the real
487                // error on the first `require_blob` that needs it.
488                tracing::warn!("lazy hydrator reconstruction failed during open: {err}");
489            }
490        }
491    }
492
493    /// Build an object store from the repository configuration.
494    ///
495    /// Returns an [`S3Store`] when `[storage.s3]` is configured and the `s3`
496    /// feature is enabled, otherwise falls back to [`FsStore`] — wrapped in
497    /// the [`AnyStore`] enum so the runtime choice stays statically dispatched.
498    fn build_store(config: &RepoConfig, heddle_dir: &Path) -> Result<AnyStore> {
499        #[cfg(feature = "s3")]
500        {
501            if let Some(s3) = &config.storage.s3 {
502                return Self::build_s3_store(s3);
503            }
504        }
505        let _ = config; // suppress unused warning when s3 feature is off
506        Ok(AnyStore::Fs(FsStore::new(heddle_dir)))
507    }
508
509    /// Construct an [`S3Store`] from the repository's S3 storage configuration.
510    #[cfg(feature = "s3")]
511    fn build_s3_store(s3: &repo_config::S3StorageConfig) -> Result<AnyStore> {
512        use objects::store::S3StoreBuilder;
513
514        let mut builder = S3StoreBuilder::new().bucket(&s3.bucket);
515        if let Some(ref region) = s3.region {
516            builder = builder.region(region);
517        }
518        if let Some(ref prefix) = s3.prefix {
519            builder = builder.prefix(prefix);
520        }
521        if let Some(ref url) = s3.endpoint_url {
522            builder = builder.endpoint_url(url);
523        }
524        if let Some(ref key) = s3.access_key_id {
525            builder = builder.access_key_id(key);
526        }
527        if let Some(ref secret) = s3.secret_access_key {
528            builder = builder.secret_access_key(secret);
529        }
530        if let Some(ref token) = s3.session_token {
531            builder = builder.session_token(token);
532        }
533        if s3.force_path_style {
534            builder = builder.force_path_style(true);
535        }
536
537        // `S3StoreBuilder::build` is async. The previous design here was
538        // `Handle::try_current().or_else(Runtime::new()).block_on(builder.build())`
539        // — that nested `block_on` panicked with "Cannot start a runtime
540        // from within a runtime" whenever `Repository::open` was called
541        // from inside a Tokio runtime (`#[tokio::main]`, `#[tokio::test]`,
542        // a daemon worker). `build_blocking` routes the async `build()`
543        // through a short-lived worker-thread runtime, so the caller's
544        // runtime is never re-entered — mirrors the heddle#60 fix for the
545        // `ObjectStore` impl on `S3Store`.
546        let store = builder
547            .build_blocking()
548            .map_err(|e| HeddleError::Config(format!("S3 store initialization failed: {e}")))?;
549        Ok(AnyStore::S3(store))
550    }
551
552    /// Initialize a new bare repository at the given path.
553    ///
554    /// Creates the on-disk `.heddle` structure and an attached `main` HEAD, but
555    /// does not seed any threads or states. Callers that want a ready-to-use
556    /// repository (with a `main` thread pointing at an empty-tree snapshot)
557    /// should use [`Repository::init_default`]. Callers that intend to populate
558    /// the repository from an external source (e.g. git import) should use
559    /// `init` directly so the imported refs become the sole source of truth.
560    pub fn init(path: impl AsRef<Path>) -> Result<Self> {
561        let root = path.as_ref().to_path_buf();
562        let heddle_dir = root.join(".heddle");
563
564        if heddle_dir.exists() {
565            return Err(HeddleError::RepositoryExists(root));
566        }
567
568        fs::create_dir_all(&heddle_dir)?;
569
570        let store = FsStore::new(&heddle_dir);
571        store.init()?;
572
573        let refs = RefManager::new(&heddle_dir);
574        refs.init()?;
575
576        // `init` creates a fresh repo before any principal is configured;
577        // the actor is set when the repo is later opened (which reads
578        // `RepoConfig.principal`). Use the unattributed default for
579        // entries written between init and first open.
580        let oplog = OpLog::new_unattributed(&heddle_dir);
581        oplog.init()?;
582
583        let config = RepoConfig::default();
584        config.save(&heddle_dir.join("config.toml"))?;
585
586        refs.write_head(&Head::Attached {
587            thread: ThreadName::from("main"),
588        })?;
589
590        // Inject the oplog-backed read + write chokepoints (heddle#330 §2.2) —
591        // same as `open_raw`, so a freshly-init'd handle reconciles and
592        // record-commits too.
593        let reconciler = std::sync::Arc::new(crate::atomic::OplogRefReconciler::new(
594            &heddle_dir,
595            compute_op_scope(&root),
596        ));
597        let committer = std::sync::Arc::new(crate::atomic::OplogRefCommitter::new(
598            &heddle_dir,
599            objects::object::Principal::new("<unknown>", ""),
600        ));
601        let refs = refs.with_reconciler(reconciler).with_committer(committer);
602        // Establish the persisted reconcile watermark at init (heddle#354 r5,
603        // cid 3329631074) so subsequent processes seed from a real last-clean
604        // point — parity with `open_raw`.
605        refs.init_reconcile_watermark()?;
606
607        let capability = repository_capability_for_root(&root);
608        Ok(Self {
609            root,
610            heddle_dir: heddle_dir.clone(),
611            capability,
612            store: AnyStore::Fs(store),
613            refs,
614            oplog,
615            config,
616            shallow: RwLock::new(ShallowInfo::load(&heddle_dir)?),
617            blob_hydrator: RwLock::new(None),
618            git_overlay_repo: RwLock::new(None),
619        })
620    }
621
622    /// Initialize a new repository with a seeded `main` thread.
623    ///
624    /// Convenience wrapper: equivalent to [`Repository::init`] followed by
625    /// [`Repository::seed_default_thread`]. This is the normal entry point for
626    /// fresh, user-created repositories where `main` should exist immediately.
627    pub fn init_default(path: impl AsRef<Path>) -> Result<Self> {
628        let repo = Self::init(path)?;
629        repo.seed_default_thread()?;
630        Ok(repo)
631    }
632
633    /// Initialize Heddle sidecar storage in an existing Git repository.
634    ///
635    /// Unlike [`Repository::init_default`], this keeps the repo unseeded and
636    /// mirrors the current Git branch attachment into Heddle's HEAD so
637    /// commands like `heddle status` can immediately reflect the user's
638    /// current branch and dirty worktree.
639    pub fn bootstrap_git_overlay(path: impl AsRef<Path>) -> Result<Self> {
640        let root = path.as_ref();
641        if root.join(".heddle").exists() {
642            ensure_git_overlay_exclude(root)?;
643            return Self::open(root);
644        }
645
646        let repo = Self::init(root)?;
647        ensure_git_overlay_exclude(root)?;
648        if let Some(head) = detect_git_head(root)? {
649            repo.refs.write_head(&head)?;
650        }
651        Ok(repo)
652    }
653
654    /// Install local, untracked Git exclude rules Heddle needs for Git-overlay
655    /// repos. Only Heddle's sidecar is excluded automatically; project
656    /// artifacts must be covered by `.gitignore` or `.heddleignore`.
657    pub fn ensure_git_overlay_local_excludes(path: impl AsRef<Path>) -> Result<()> {
658        ensure_git_overlay_exclude(path.as_ref())
659    }
660
661    /// Open an existing repository.
662    ///
663    /// Searches for `.heddle/` in the given path and its ancestors. `.heddle/`
664    /// is always a directory; its contents distinguish a main repo from a
665    /// worktree pointer:
666    ///
667    /// - Main repo: `.heddle/objects/`, `.heddle/refs/`, `.heddle/HEAD`,
668    ///   `.heddle/state/`, etc.
669    /// - Worktree: `.heddle/objectstore` (text pointer to the shared
670    ///   `.heddle/`), `.heddle/HEAD` (per-checkout), `.heddle/state/`
671    ///   (per-checkout cached state).
672    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
673        let start_path = path.as_ref().canonicalize()?;
674        // A virtualized thread mounts at
675        // `.heddle/threads/<encoded>/<repo-name>` and writes no checkout
676        // metadata of its own. Without this guard, the upward walk below would
677        // sail past the metadata-less mount and open the PARENT repo, so
678        // status/capture/thread operations would silently hit the wrong
679        // checkout. Refuse rather than resolve to the parent (heddle#572 r2).
680        // Solid/materialized checkouts have their own `.heddle` pointer and
681        // are handled by the worktree branch below, so this only fires for a
682        // virtualized (or torn-down) mount root.
683        if let Some(mount_root) = metadataless_managed_thread_root(&start_path) {
684            return Err(HeddleError::Config(format!(
685                "'{}' is a Heddle-managed virtualized thread mount with no checkout \
686                 metadata of its own; refusing to operate on the parent repository from \
687                 inside it. Run heddle from the repository root, or use a solid/materialized \
688                 thread checkout.",
689                mount_root.display()
690            )));
691        }
692        let mut discovered_git_root = None;
693
694        let mut current = Some(start_path.as_path());
695        while let Some(dir) = current {
696            if discovered_git_root.is_none() && has_git_metadata(dir) {
697                discovered_git_root = Some(dir.to_path_buf());
698            }
699            let heddle_path = dir.join(".heddle");
700
701            if heddle_path.is_dir() {
702                if let Some(git_root) = discovered_git_root.as_ref()
703                    && git_root != dir
704                    && git_root.starts_with(dir)
705                    && !git_root.join(".heddle").exists()
706                {
707                    ensure_git_overlay_exclude(git_root)?;
708                    Self::bootstrap_git_overlay(git_root)?;
709                    return Self::open(git_root);
710                }
711                let pointer_path = heddle_path.join("objectstore");
712                let objects_dir = heddle_path.join("objects");
713
714                if pointer_path.is_file() {
715                    // Worktree mode: pointer dir at <dir>/.heddle/, shared
716                    // object store at the path read from .heddle/objectstore.
717                    let content = fs::read_to_string(&pointer_path)?;
718                    let raw_shared = parse_objectstore_pointer(&content).ok_or_else(|| {
719                        HeddleError::Config(format!(
720                            "invalid .heddle/objectstore pointer at {}: expected 'objectstore: <path>'",
721                            pointer_path.display()
722                        ))
723                    })?;
724
725                    if raw_shared.is_relative() {
726                        return Err(HeddleError::Config(format!(
727                            ".heddle/objectstore pointer at {} contains a relative path '{}'; \
728                             objectstore path must be absolute",
729                            pointer_path.display(),
730                            raw_shared.display()
731                        )));
732                    }
733
734                    let shared_galeed_dir = raw_shared.canonicalize().map_err(|e| {
735                        HeddleError::Config(format!(
736                            ".heddle/objectstore pointer at {} points to non-existent path '{}': {}",
737                            pointer_path.display(),
738                            raw_shared.display(),
739                            e
740                        ))
741                    })?;
742
743                    if !shared_galeed_dir.join("objects").is_dir() {
744                        return Err(HeddleError::Config(format!(
745                            ".heddle/objectstore pointer at {} resolves to '{}' which does not \
746                             contain an 'objects/' directory; not a valid Heddle store",
747                            pointer_path.display(),
748                            shared_galeed_dir.display()
749                        )));
750                    }
751
752                    let config_path = shared_galeed_dir.join("config.toml");
753                    let config = RepoConfig::load(&config_path)?;
754                    ensure_supported_repo_format(&config_path, &config)?;
755                    let store = Self::build_store(&config, &shared_galeed_dir)?;
756                    let local_head_path = heddle_path.join("HEAD");
757                    let refs = RefManager::new(&shared_galeed_dir).with_local_head(local_head_path);
758                    let repo =
759                        Self::open_raw(dir.to_path_buf(), shared_galeed_dir, store, config, refs)?;
760                    repo.run_open_hooks();
761                    return Ok(repo);
762                }
763
764                if objects_dir.is_dir() {
765                    // Main repo mode.
766                    let config_path = heddle_path.join("config.toml");
767                    let config = RepoConfig::load(&config_path)?;
768                    ensure_supported_repo_format(&config_path, &config)?;
769                    let store = Self::build_store(&config, &heddle_path)?;
770                    let refs = RefManager::new(&heddle_path);
771                    let repo = Self::open_raw(dir.to_path_buf(), heddle_path, store, config, refs)?;
772                    repo.run_open_hooks();
773                    if repo.capability() == RepositoryCapability::GitOverlay {
774                        match detect_git_head_state(dir) {
775                            Ok(Some(GitHeadState::Attached(thread))) => {
776                                let git_head = Head::Attached {
777                                    thread: ThreadName::from(thread),
778                                };
779                                // Avoid the disk write when our HEAD already matches
780                                // git's. Reading the existing head is a small file
781                                // read; the write that follows hits atomic-rename
782                                // machinery (sync + rename) which dominates here.
783                                //
784                                // Detached Heddle HEAD only counts as an explicit user
785                                // override (e.g. `heddle goto`) when the detached
786                                // state diverges from git's current branch tip.
787                                // `cmd_clone` writes Head::Attached then calls
788                                // repo.goto() — which unconditionally detaches —
789                                // and relies on this reopen path to re-attach;
790                                // when the detached state still matches the branch
791                                // tip we treat that as a bootstrap leftover and
792                                // sync. A user `heddle goto <other>` lands on a
793                                // state that does *not* match the branch tip, so
794                                // it survives (heddle#146).
795                                let stale = match (repo.refs.read_head(), &git_head) {
796                                    (Ok(Head::Detached { state }), Head::Attached { thread }) => {
797                                        match repo.refs.get_thread(thread) {
798                                            Ok(Some(tip)) => tip == state,
799                                            _ => false,
800                                        }
801                                    }
802                                    (Ok(Head::Detached { .. }), _) => false,
803                                    (Ok(current), _) => current != git_head,
804                                    (Err(_), _) => true,
805                                };
806                                if stale {
807                                    repo.refs.write_head(&git_head)?;
808                                }
809                            }
810                            Ok(Some(GitHeadState::Detached(git_oid))) => {
811                                if let Ok(Some(state)) =
812                                    repo.git_overlay_mapped_change_for_git_oid(git_oid)
813                                {
814                                    let git_head = Head::Detached { state };
815                                    let stale = match repo.refs.read_head() {
816                                        Ok(current) => current != git_head,
817                                        Err(_) => true,
818                                    };
819                                    if stale {
820                                        repo.refs.write_head(&git_head)?;
821                                    }
822                                }
823                            }
824                            Ok(None) | Err(_) => {}
825                        }
826                    }
827                    return Ok(repo);
828                }
829
830                // .heddle/ exists but is neither a worktree pointer nor a
831                // main repo. Treat as not-found and continue walking parents.
832            }
833
834            current = dir.parent();
835        }
836
837        if let Some(git_root) = discovered_git_root {
838            ensure_git_overlay_exclude(&git_root)?;
839            Self::bootstrap_git_overlay(&git_root)?;
840            return Self::open(git_root);
841        }
842
843        Err(HeddleError::RepositoryNotFound(path.as_ref().to_path_buf()))
844    }
845
846    pub fn root(&self) -> &Path {
847        &self.root
848    }
849
850    pub fn heddle_dir(&self) -> &Path {
851        &self.heddle_dir
852    }
853
854    /// Root whose directory name should be used for managed thread checkout
855    /// leaves.
856    ///
857    /// For the main checkout this is `repo.root()`. For an isolated checkout,
858    /// `repo.root()` is the checkout's own directory (possibly custom-named),
859    /// while `heddle_dir` points back at the shared source repository's
860    /// `.heddle`; use that shared parent so child threads keep the original
861    /// repo name.
862    pub fn managed_checkout_source_root(&self) -> &Path {
863        self.heddle_dir.parent().unwrap_or(self.root.as_path())
864    }
865
866    /// Default managed checkout path for `thread`.
867    pub fn managed_checkout_path(&self, thread: &str) -> PathBuf {
868        crate::thread_manifest::managed_checkout_path(
869            &self.heddle_dir,
870            thread,
871            self.managed_checkout_source_root(),
872        )
873    }
874
875    pub fn capability(&self) -> RepositoryCapability {
876        self.capability
877    }
878
879    pub fn git_overlay_sley_repository(&self) -> Result<Option<SleyRepository>> {
880        if self.capability() != RepositoryCapability::GitOverlay {
881            return Ok(None);
882        }
883
884        if let Some(repo) = self
885            .git_overlay_repo
886            .read()
887            .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?
888            .clone()
889        {
890            return Ok(Some(repo));
891        }
892
893        let mut cached = self
894            .git_overlay_repo
895            .write()
896            .map_err(|_| HeddleError::Config("git overlay repo cache lock poisoned".into()))?;
897        if let Some(repo) = cached.clone() {
898            return Ok(Some(repo));
899        }
900
901        let repo = SleyRepository::discover(&self.root).map_err(|error| {
902            HeddleError::Config(format!(
903                "failed to inspect Git repository at '{}': {}",
904                self.root.display(),
905                error
906            ))
907        })?;
908        *cached = Some(repo.clone());
909        Ok(Some(repo))
910    }
911
912    pub fn capability_label(&self) -> &'static str {
913        match self.capability() {
914            RepositoryCapability::GitOverlay => "git-overlay",
915            RepositoryCapability::NativeHeddle => "native-heddle",
916        }
917    }
918
919    pub fn storage_model_label(&self) -> &'static str {
920        match self.capability() {
921            RepositoryCapability::GitOverlay => "git+heddle-sidecar",
922            RepositoryCapability::NativeHeddle => "heddle-native",
923        }
924    }
925
926    pub fn hosted_enabled(&self) -> bool {
927        self.config
928            .hosted
929            .upstream_url
930            .as_deref()
931            .is_some_and(|value| !value.trim().is_empty())
932            || self
933                .config
934                .hosted
935                .namespace
936                .as_deref()
937                .is_some_and(|value| !value.trim().is_empty())
938    }
939
940    pub fn current_lane(&self) -> Result<Option<String>> {
941        if self.capability() == RepositoryCapability::GitOverlay
942            && self.git_overlay_head_is_detached()?
943            && detect_git_in_progress_branch(&self.root)?.is_none()
944        {
945            return Ok(None);
946        }
947
948        if self.current_state()?.is_none() && self.capability() == RepositoryCapability::GitOverlay
949        {
950            return self.git_overlay_current_branch();
951        }
952
953        match self.head_ref()? {
954            Head::Attached { thread } => Ok(Some(thread.to_string())),
955            Head::Detached { .. } => Ok(None),
956        }
957    }
958
959    pub fn operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
960        if let Some(status) = self.heddle_operation_status()? {
961            return Ok(Some(status));
962        }
963        self.git_operation_status()
964    }
965
966    pub fn git_remote_tracking_status(&self) -> Result<Option<GitRemoteTrackingStatus>> {
967        if self.capability() != RepositoryCapability::GitOverlay {
968            return Ok(None);
969        }
970
971        let branch = match self.git_overlay_current_branch()? {
972            Some(branch) => branch,
973            None => return Ok(None),
974        };
975
976        let Some(git) = self.git_overlay_sley_repository()? else {
977            return Ok(None);
978        };
979        let Some(head) = git_resolve_oid(&git, "HEAD")? else {
980            return Ok(None);
981        };
982
983        let local_ref_name = format!("refs/heads/{branch}");
984        if git_find_reference(&git, &local_ref_name)?.is_some()
985            && let Some(tracking_name) = git_configured_tracking_ref(&git, &branch)?
986            && let Some(upstream_head) = git_resolve_oid(&git, &tracking_name)?
987        {
988            let (ahead, behind) = git_ahead_behind(&self.root, &git, upstream_head, head)?;
989            if ahead == 0 && behind == 0 {
990                return Ok(None);
991            }
992            let upstream = git_remote_tracking_display_name(&tracking_name);
993            let local_oid = head.to_string();
994            let upstream_oid = upstream_head.to_string();
995            let upstream_is_undone_checkpoint =
996                self.remote_tracks_undone_git_checkpoint(&branch, &local_oid, &upstream_oid)?;
997            return Ok(Some(GitRemoteTrackingStatus {
998                branch: branch.clone(),
999                upstream: upstream.clone(),
1000                ahead,
1001                behind,
1002                local_oid: Some(local_oid),
1003                upstream_oid: Some(upstream_oid),
1004                upstream_is_undone_checkpoint,
1005                message: git_remote_tracking_message(
1006                    &branch,
1007                    &upstream,
1008                    ahead,
1009                    behind,
1010                    upstream_is_undone_checkpoint,
1011                ),
1012                next_action: git_remote_tracking_next_action(
1013                    ahead,
1014                    behind,
1015                    upstream_is_undone_checkpoint,
1016                ),
1017            }));
1018        }
1019
1020        let remotes = git_remote_names(&self.root)?;
1021        if remotes.is_empty() {
1022            return Ok(None);
1023        }
1024        for remote in &remotes {
1025            let remote_ref = format!("refs/remotes/{remote}/{branch}");
1026            if let Some(remote_head) = git_resolve_oid(&git, &remote_ref)? {
1027                if remote_head == head {
1028                    return Ok(None);
1029                }
1030                let (ahead, behind) = git_ahead_behind(&self.root, &git, remote_head, head)?;
1031                if behind > 0 {
1032                    let upstream = format!("{remote}/{branch}");
1033                    let local_oid = head.to_string();
1034                    let upstream_oid = remote_head.to_string();
1035                    let upstream_is_undone_checkpoint = self.remote_tracks_undone_git_checkpoint(
1036                        &branch,
1037                        &local_oid,
1038                        &upstream_oid,
1039                    )?;
1040                    return Ok(Some(GitRemoteTrackingStatus {
1041                        branch: branch.clone(),
1042                        upstream: upstream.clone(),
1043                        ahead,
1044                        behind,
1045                        local_oid: Some(local_oid),
1046                        upstream_oid: Some(upstream_oid),
1047                        upstream_is_undone_checkpoint,
1048                        message: git_remote_tracking_message(
1049                            &branch,
1050                            &upstream,
1051                            ahead,
1052                            behind,
1053                            upstream_is_undone_checkpoint,
1054                        ),
1055                        next_action: git_remote_tracking_next_action(
1056                            ahead,
1057                            behind,
1058                            upstream_is_undone_checkpoint,
1059                        ),
1060                    }));
1061                }
1062            }
1063        }
1064
1065        Ok(Some(GitRemoteTrackingStatus {
1066            branch: branch.clone(),
1067            upstream: String::new(),
1068            ahead: 0,
1069            behind: 0,
1070            local_oid: Some(head.to_string()),
1071            upstream_oid: None,
1072            upstream_is_undone_checkpoint: false,
1073            message: format!("Git branch '{branch}' has no upstream tracking branch"),
1074            next_action: "heddle push".to_string(),
1075        }))
1076    }
1077
1078    fn remote_tracks_undone_git_checkpoint(
1079        &self,
1080        branch: &str,
1081        local_oid: &str,
1082        upstream_oid: &str,
1083    ) -> Result<bool> {
1084        let scope = self.op_scope();
1085        let batches = match self.oplog().redo_batches_scoped(64, Some(&scope)) {
1086            Ok(batches) => batches,
1087            Err(error) => {
1088                tracing::warn!(
1089                    branch,
1090                    local_oid,
1091                    upstream_oid,
1092                    error = %error,
1093                    "could not inspect redo oplog for undone Git checkpoint status"
1094                );
1095                return Ok(false);
1096            }
1097        };
1098        Ok(batches.iter().any(|batch| {
1099            batch.entries.iter().any(|entry| {
1100                if !entry.undone {
1101                    return false;
1102                }
1103                matches!(
1104                    &entry.operation,
1105                    OpRecord::GitCheckpoint {
1106                        branch: checkpoint_branch,
1107                        previous_git_oid: Some(previous_git_oid),
1108                        new_git_oid,
1109                        ..
1110                    } if checkpoint_branch == branch
1111                        && previous_git_oid == local_oid
1112                        && new_git_oid == upstream_oid
1113                )
1114            })
1115        }))
1116    }
1117
1118    pub fn git_overlay_import_hint(&self) -> Result<Option<GitOverlayImportHint>> {
1119        if self.capability() != RepositoryCapability::GitOverlay {
1120            return Ok(None);
1121        }
1122
1123        let current_branch = match self.git_overlay_current_branch()? {
1124            Some(branch) => branch,
1125            None => return Ok(None),
1126        };
1127        let branch_tips = self.git_overlay_branch_tips()?;
1128        let imported_threads: std::collections::HashSet<ThreadName> =
1129            self.refs().list_threads()?.into_iter().collect();
1130        let threads_with_real_history: std::collections::HashSet<String> = imported_threads
1131            .iter()
1132            .filter_map(|thread| {
1133                self.refs()
1134                    .get_thread(thread)
1135                    .ok()
1136                    .flatten()
1137                    .and_then(|change| self.store.get_state(&change).ok())
1138                    .flatten()
1139                    .filter(|state| !is_synthetic_root(state))
1140                    .map(|_| thread.to_string())
1141            })
1142            .collect();
1143        let mut missing_branches = branch_tips
1144            .into_iter()
1145            .filter(|tip| {
1146                !(tip.history_imported
1147                    || threads_with_real_history.contains(&tip.branch)
1148                        && tip.mapped_change.is_some())
1149            })
1150            .map(|tip| tip.branch)
1151            .collect::<Vec<_>>();
1152        missing_branches.sort_by(|left, right| {
1153            match (left == &current_branch, right == &current_branch) {
1154                (true, false) => std::cmp::Ordering::Less,
1155                (false, true) => std::cmp::Ordering::Greater,
1156                _ => left.cmp(right),
1157            }
1158        });
1159        missing_branches.dedup();
1160
1161        if missing_branches.is_empty() {
1162            return Ok(None);
1163        }
1164
1165        let missing_tags = self
1166            .git_overlay_tag_tips()?
1167            .into_iter()
1168            .any(|tip| !tip.history_imported);
1169        let recommended_command = if missing_branches.len() > 1 || missing_tags {
1170            "heddle adopt".to_string()
1171        } else if missing_branches
1172            .iter()
1173            .any(|branch| branch == &current_branch)
1174        {
1175            format!("heddle adopt --ref {current_branch}")
1176        } else if missing_branches.len() == 1 {
1177            format!("heddle adopt --ref {}", missing_branches[0])
1178        } else {
1179            "heddle adopt".to_string()
1180        };
1181
1182        Ok(Some(GitOverlayImportHint {
1183            current_branch,
1184            missing_branch_count: missing_branches.len(),
1185            missing_branches,
1186            recommended_command,
1187        }))
1188    }
1189
1190    pub fn git_overlay_branch_tips(&self) -> Result<Vec<GitOverlayBranchTip>> {
1191        if self.capability() != RepositoryCapability::GitOverlay {
1192            return Ok(Vec::new());
1193        }
1194
1195        let Some(git_repo) = self.git_overlay_sley_repository()? else {
1196            return Ok(Vec::new());
1197        };
1198
1199        let imported_threads: std::collections::HashSet<ThreadName> =
1200            self.refs().list_threads()?.into_iter().collect();
1201        let bridge_mapping = self.git_overlay_bridge_mapping()?;
1202        let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1203        let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1204        let mut branch_tips = Vec::new();
1205
1206        for branch in git_repo.references().list_refs().map_err(|error| {
1207            HeddleError::Config(format!(
1208                "failed to enumerate git branches at '{}': {}",
1209                self.root.display(),
1210                error
1211            ))
1212        })? {
1213            let Some(name) = branch.name.strip_prefix("refs/heads/") else {
1214                continue;
1215            };
1216            let name = name.to_string();
1217            let Some(target) =
1218                self.git_overlay_commit_tip_oid(&git_repo, &branch, "branch", &name)?
1219            else {
1220                continue;
1221            };
1222            let git_commit = target.to_string();
1223            let mapped_change = self.git_overlay_mapped_change_for_commit(
1224                &git_commit,
1225                &bridge_mapping,
1226                &ingest_mapping,
1227                &checkpoint_mapping,
1228            )?;
1229            let thread_name = ThreadName::from(name.as_str());
1230            let history_imported = if imported_threads.contains(&thread_name) {
1231                // Read the thread ref once; the mapped + checkpointed
1232                // checks each used to re-read it, which doubled the
1233                // ref-store hits per branch on a 60+ branch repo.
1234                let existing_thread = self.refs().get_thread(&thread_name)?;
1235                let mapped = matches!(
1236                    (existing_thread.as_ref(), mapped_change.as_ref()),
1237                    (Some(existing), Some(mapped_change))
1238                        if existing == mapped_change
1239                );
1240                let checkpointed = if mapped {
1241                    false
1242                } else if let Some(existing) = existing_thread {
1243                    self.latest_git_checkpoint_for_change(&existing)?
1244                        .is_some_and(|record| record.git_commit == git_commit)
1245                        || mapped_change.as_ref().is_some_and(|mapped_change| {
1246                            self.change_is_ancestor(mapped_change, &existing)
1247                        })
1248                } else {
1249                    false
1250                };
1251                mapped || checkpointed
1252            } else {
1253                mapped_change.is_some()
1254            };
1255            branch_tips.push(GitOverlayBranchTip {
1256                branch: name,
1257                git_commit,
1258                history_imported,
1259                mapped_change,
1260            });
1261        }
1262        branch_tips.sort_by(|a, b| a.branch.cmp(&b.branch));
1263        Ok(branch_tips)
1264    }
1265
1266    pub fn git_overlay_tag_tips(&self) -> Result<Vec<GitOverlayTagTip>> {
1267        if self.capability() != RepositoryCapability::GitOverlay {
1268            return Ok(Vec::new());
1269        }
1270
1271        let Some(git_repo) = self.git_overlay_sley_repository()? else {
1272            return Ok(Vec::new());
1273        };
1274
1275        let imported_markers: std::collections::HashSet<MarkerName> =
1276            self.refs().list_markers()?.into_iter().collect();
1277        let bridge_mapping = self.git_overlay_bridge_mapping()?;
1278        let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1279        let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1280        let mut tag_tips = Vec::new();
1281
1282        for tag in git_repo.references().list_refs().map_err(|error| {
1283            HeddleError::Config(format!(
1284                "failed to enumerate git tags at '{}': {}",
1285                self.root.display(),
1286                error
1287            ))
1288        })? {
1289            let Some(name) = tag.name.strip_prefix("refs/tags/") else {
1290                continue;
1291            };
1292            let name = name.to_string();
1293            let Some(target) = self.git_overlay_commit_tip_oid(&git_repo, &tag, "tag", &name)?
1294            else {
1295                continue;
1296            };
1297            let git_commit = target.to_string();
1298            let mapped_change = self.git_overlay_mapped_change_for_commit(
1299                &git_commit,
1300                &bridge_mapping,
1301                &ingest_mapping,
1302                &checkpoint_mapping,
1303            )?;
1304            let marker_name = MarkerName::from(name.as_str());
1305            let history_imported = if imported_markers.contains(&marker_name) {
1306                matches!(
1307                    (self.refs().get_marker(&marker_name)?, mapped_change.as_ref()),
1308                    (Some(existing), Some(mapped_change)) if existing == *mapped_change
1309                )
1310            } else {
1311                false
1312            };
1313            tag_tips.push(GitOverlayTagTip {
1314                tag: name,
1315                git_commit,
1316                history_imported,
1317                mapped_change,
1318            });
1319        }
1320
1321        tag_tips.sort_by(|a, b| a.tag.cmp(&b.tag));
1322        Ok(tag_tips)
1323    }
1324
1325    pub fn git_overlay_branch_tip(&self, name: &str) -> Result<Option<GitOverlayBranchTip>> {
1326        Ok(self
1327            .git_overlay_branch_tips()?
1328            .into_iter()
1329            .find(|tip| tip.branch == name))
1330    }
1331
1332    pub fn git_overlay_tag_tip(&self, name: &str) -> Result<Option<GitOverlayTagTip>> {
1333        Ok(self
1334            .git_overlay_tag_tips()?
1335            .into_iter()
1336            .find(|tip| tip.tag == name))
1337    }
1338
1339    pub fn git_overlay_mapped_change_for_branch(&self, name: &str) -> Result<Option<ChangeId>> {
1340        Ok(self
1341            .git_overlay_branch_tip(name)?
1342            .and_then(|tip| tip.mapped_change))
1343    }
1344
1345    pub fn git_overlay_mapped_change_for_remote_tracking_ref(
1346        &self,
1347        name: &str,
1348    ) -> Result<Option<ChangeId>> {
1349        if self.capability() != RepositoryCapability::GitOverlay {
1350            return Ok(None);
1351        }
1352        let Some(git_repo) = self.git_overlay_sley_repository()? else {
1353            return Ok(None);
1354        };
1355        let full_name = name
1356            .strip_prefix("refs/remotes/")
1357            .map(|short| format!("refs/remotes/{short}"))
1358            .unwrap_or_else(|| format!("refs/remotes/{name}"));
1359        let bridge_mapping = self.git_overlay_bridge_mapping()?;
1360        let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1361        let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1362        for reference in git_repo.references().list_refs().map_err(|error| {
1363            HeddleError::Config(format!(
1364                "failed to enumerate git remote-tracking refs at '{}': {}",
1365                self.root.display(),
1366                error
1367            ))
1368        })? {
1369            if reference.name != full_name {
1370                continue;
1371            }
1372            let Some(target) =
1373                self.git_overlay_commit_tip_oid(&git_repo, &reference, "remote branch", name)?
1374            else {
1375                return Ok(None);
1376            };
1377            return self.git_overlay_mapped_change_for_commit(
1378                &target.to_string(),
1379                &bridge_mapping,
1380                &ingest_mapping,
1381                &checkpoint_mapping,
1382            );
1383        }
1384        Ok(None)
1385    }
1386
1387    pub fn git_overlay_mapped_change_for_tag(&self, name: &str) -> Result<Option<ChangeId>> {
1388        Ok(self
1389            .git_overlay_tag_tip(name)?
1390            .and_then(|tip| tip.mapped_change))
1391    }
1392
1393    fn change_is_ancestor(&self, ancestor: &ChangeId, descendant: &ChangeId) -> bool {
1394        let mut graph = CommitGraphIndex::new(self);
1395        graph.is_ancestor(ancestor, descendant).unwrap_or(false)
1396    }
1397
1398    pub fn git_overlay_worktree_status(&self) -> Result<Option<WorktreeStatus>> {
1399        if self.capability() != RepositoryCapability::GitOverlay {
1400            return Ok(None);
1401        }
1402        let git_repo = match self.git_overlay_sley_repository() {
1403            Ok(Some(repo)) => repo,
1404            Ok(None) | Err(_) => return Ok(None),
1405        };
1406        if git_repo.workdir().is_none() {
1407            return Ok(None);
1408        }
1409
1410        let index = git_repo.open_index().map_err(|error| {
1411            HeddleError::Config(format!(
1412                "failed to inspect Git index at '{}': {}",
1413                self.root.display(),
1414                error
1415            ))
1416        })?;
1417        let index = index.unwrap_or_else(|| sley::Index {
1418            version: 2,
1419            entries: Vec::new(),
1420            extensions: Vec::new(),
1421            checksum: None,
1422        });
1423        let head_tree = match git_repo
1424            .head()
1425            .map_err(|error| {
1426                HeddleError::Config(format!(
1427                    "failed to inspect Git HEAD tree at '{}': {}",
1428                    self.root.display(),
1429                    error
1430                ))
1431            })?
1432            .oid
1433        {
1434            Some(head) => {
1435                git_repo
1436                    .read_commit(&head)
1437                    .map_err(|error| {
1438                        HeddleError::Config(format!(
1439                            "failed to inspect Git HEAD commit at '{}': {}",
1440                            self.root.display(),
1441                            error
1442                        ))
1443                    })?
1444                    .tree
1445            }
1446            None => sley::ObjectId::empty_tree(git_repo.object_format()),
1447        };
1448        let head_index = git_repo.index_from_tree(&head_tree).map_err(|error| {
1449            HeddleError::Config(format!(
1450                "failed to inspect Git HEAD tree at '{}': {}",
1451                self.root.display(),
1452                error
1453            ))
1454        })?;
1455
1456        let mut head_entries = BTreeMap::new();
1457        for entry in head_index.entries {
1458            let path = git_path(entry.path.as_bytes());
1459            head_entries.insert(path, (entry.oid, entry.mode));
1460        }
1461        let mut index_entries = BTreeMap::new();
1462        let index_path = sley::plumbing::sley_worktree::repository_index_path(git_repo.git_dir());
1463        for entry in index.entries {
1464            let path = git_path(entry.path.as_bytes());
1465            let probe = crate::git_worktree_status::IndexStatProbe::from_index_entry_and_index_path(
1466                entry.clone(),
1467                &index_path,
1468            );
1469            index_entries.insert(path, (entry.oid, entry.mode, probe));
1470        }
1471
1472        let mut added = BTreeSet::new();
1473        let mut modified = BTreeSet::new();
1474        let mut deleted = BTreeSet::new();
1475
1476        for (path, (oid, mode, _probe)) in &index_entries {
1477            if ignored_git_overlay_status_path(path) {
1478                continue;
1479            }
1480            match head_entries.get(path) {
1481                None => {
1482                    added.insert(PathBuf::from(path));
1483                }
1484                Some((head_oid, head_mode)) if (head_oid, head_mode) != (oid, mode) => {
1485                    modified.insert(PathBuf::from(path));
1486                }
1487                Some(_) => {}
1488            }
1489        }
1490        for path in head_entries.keys() {
1491            if !ignored_git_overlay_status_path(path) && !index_entries.contains_key(path) {
1492                deleted.insert(PathBuf::from(path));
1493            }
1494        }
1495
1496        for (path, (oid, mode, probe)) in &index_entries {
1497            if ignored_git_overlay_status_path(path) {
1498                continue;
1499            }
1500            match crate::git_worktree_status::git_worktree_entry_state_in_repo(
1501                &git_repo,
1502                &self.root,
1503                path,
1504                *oid,
1505                *mode,
1506                Some(probe.clone()),
1507            )? {
1508                GitWorktreeEntryState::Clean => {}
1509                GitWorktreeEntryState::Deleted => {
1510                    deleted.insert(PathBuf::from(path));
1511                }
1512                GitWorktreeEntryState::Modified => {
1513                    modified.insert(PathBuf::from(path));
1514                }
1515            }
1516        }
1517
1518        let ignore_patterns = self.ignore_patterns()?;
1519        let tracked_paths: BTreeSet<&str> = index_entries.keys().map(String::as_str).collect();
1520        for path in git_overlay_untracked_paths(&self.root, &tracked_paths, &ignore_patterns)? {
1521            added.insert(PathBuf::from(path));
1522        }
1523
1524        Ok(Some(WorktreeStatus {
1525            modified: modified.into_iter().collect(),
1526            added: added.into_iter().collect(),
1527            deleted: deleted.into_iter().collect(),
1528        }))
1529    }
1530
1531    fn git_overlay_bridge_mapping(&self) -> Result<HashMap<String, String>> {
1532        let path = self
1533            .heddle_dir
1534            .join("git-bridge")
1535            .join("bridge-mapping.json");
1536        if !path.exists() {
1537            return Ok(HashMap::new());
1538        }
1539
1540        let contents = fs::read_to_string(path)?;
1541        if contents.trim().is_empty() {
1542            return Ok(HashMap::new());
1543        }
1544
1545        let file: GitBridgeMappingFile = serde_json::from_str(&contents)?;
1546        Ok(file
1547            .entries
1548            .into_iter()
1549            .map(|entry| (entry.git_oid, entry.change_id))
1550            .collect())
1551    }
1552
1553    pub fn git_overlay_ingest_commit_mapping(&self) -> Result<HashMap<String, String>> {
1554        let path = self.heddle_dir.join("ingest").join("sha_map.sqlite");
1555        if !path.exists() {
1556            return Ok(HashMap::new());
1557        }
1558
1559        let conn = Connection::open_with_flags(
1560            &path,
1561            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1562        )
1563        .map_err(|error| {
1564            HeddleError::Config(format!(
1565                "failed to open ingest SHA map at '{}': {}",
1566                path.display(),
1567                error
1568            ))
1569        })?;
1570        let mut stmt = conn
1571            .prepare_cached("SELECT git_sha, heddle_repr FROM sha_map WHERE kind = 0")
1572            .map_err(|error| {
1573                HeddleError::Config(format!(
1574                    "failed to read ingest SHA map at '{}': {}",
1575                    path.display(),
1576                    error
1577                ))
1578            })?;
1579        let rows = stmt
1580            .query_map([], |row| {
1581                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1582            })
1583            .map_err(|error| {
1584                HeddleError::Config(format!(
1585                    "failed to enumerate ingest SHA map at '{}': {}",
1586                    path.display(),
1587                    error
1588                ))
1589            })?;
1590
1591        let mut mapping = HashMap::new();
1592        for row in rows {
1593            let (git_sha, change_id) = row.map_err(|error| {
1594                HeddleError::Config(format!(
1595                    "failed to read ingest SHA map row at '{}': {}",
1596                    path.display(),
1597                    error
1598                ))
1599            })?;
1600            mapping.insert(git_sha, change_id);
1601        }
1602        Ok(mapping)
1603    }
1604
1605    fn git_overlay_checkpoint_mapping(&self) -> Result<HashMap<String, String>> {
1606        Ok(self
1607            .list_git_checkpoints()?
1608            .into_iter()
1609            .map(|record| (record.git_commit, record.change_id))
1610            .collect())
1611    }
1612
1613    fn git_overlay_mapped_change_for_commit(
1614        &self,
1615        git_commit: &str,
1616        bridge_mapping: &HashMap<String, String>,
1617        ingest_mapping: &HashMap<String, String>,
1618        checkpoint_mapping: &HashMap<String, String>,
1619    ) -> Result<Option<ChangeId>> {
1620        let Some(change) = bridge_mapping
1621            .get(git_commit)
1622            .or_else(|| ingest_mapping.get(git_commit))
1623            .or_else(|| checkpoint_mapping.get(git_commit))
1624        else {
1625            return Ok(None);
1626        };
1627        let change_id = ChangeId::parse(change).map_err(|error| {
1628            HeddleError::Config(format!(
1629                "git commit {git_commit} maps to invalid Heddle change id '{change}': {error}"
1630            ))
1631        })?;
1632        if self.store.get_state(&change_id)?.is_some() {
1633            Ok(Some(change_id))
1634        } else {
1635            Ok(None)
1636        }
1637    }
1638
1639    fn git_overlay_mapped_git_commit_for_change_in(
1640        &self,
1641        change_id: &ChangeId,
1642        mapping: &HashMap<String, String>,
1643    ) -> Result<Option<String>> {
1644        for (git_commit, mapped_change) in mapping {
1645            let mapped_change_id = ChangeId::parse(mapped_change).map_err(|error| {
1646                HeddleError::Config(format!(
1647                    "git commit {git_commit} maps to invalid Heddle change id '{mapped_change}': {error}"
1648                ))
1649            })?;
1650            if mapped_change_id == *change_id {
1651                return Ok(Some(git_commit.clone()));
1652            }
1653        }
1654        Ok(None)
1655    }
1656
1657    pub fn git_overlay_mapped_git_commit_for_change(
1658        &self,
1659        change_id: &ChangeId,
1660    ) -> Result<Option<String>> {
1661        let bridge_mapping = self.git_overlay_bridge_mapping()?;
1662        if let Some(git_commit) =
1663            self.git_overlay_mapped_git_commit_for_change_in(change_id, &bridge_mapping)?
1664        {
1665            return Ok(Some(git_commit));
1666        }
1667
1668        let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1669        if let Some(git_commit) =
1670            self.git_overlay_mapped_git_commit_for_change_in(change_id, &ingest_mapping)?
1671        {
1672            return Ok(Some(git_commit));
1673        }
1674
1675        let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1676        self.git_overlay_mapped_git_commit_for_change_in(change_id, &checkpoint_mapping)
1677    }
1678
1679    pub fn git_overlay_mapped_change_for_git_commit(
1680        &self,
1681        git_commit: &str,
1682    ) -> Result<Option<ChangeId>> {
1683        let bridge_mapping = self.git_overlay_bridge_mapping()?;
1684        let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1685        let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1686        self.git_overlay_mapped_change_for_commit(
1687            git_commit,
1688            &bridge_mapping,
1689            &ingest_mapping,
1690            &checkpoint_mapping,
1691        )
1692    }
1693
1694    fn git_overlay_mapped_change_for_git_oid(
1695        &self,
1696        git_oid: SleyObjectId,
1697    ) -> Result<Option<ChangeId>> {
1698        self.git_overlay_mapped_change_for_git_commit(&git_oid.to_string())
1699    }
1700
1701    /// Count the Git commits reachable from `tip_git_commit` that are not
1702    /// represented in Heddle state (no served bridge mapping, ingest identity
1703    /// mapping, or checkpoint mapping). The walk prunes at the first mapped
1704    /// commit on each lineage, so the cost is proportional to the out-of-band
1705    /// suffix, capped at `GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT`.
1706    ///
1707    /// Returns `Ok(None)` when the repository is not a Git overlay or the tip
1708    /// cannot be resolved; callers should degrade to a countless report.
1709    pub fn git_overlay_out_of_band_commits(
1710        &self,
1711        tip_git_commit: &str,
1712    ) -> Result<Option<GitOverlayOutOfBandCommits>> {
1713        if self.capability() != RepositoryCapability::GitOverlay {
1714            return Ok(None);
1715        }
1716        let git_repo = match self.git_overlay_sley_repository() {
1717            Ok(Some(repo)) => repo,
1718            Ok(None) | Err(_) => return Ok(None),
1719        };
1720        let Ok(tip) = SleyObjectId::from_hex(git_repo.object_format(), tip_git_commit) else {
1721            return Ok(None);
1722        };
1723
1724        let bridge_mapping = self.git_overlay_bridge_mapping()?;
1725        let ingest_mapping = self.git_overlay_ingest_commit_mapping()?;
1726        let checkpoint_mapping = self.git_overlay_checkpoint_mapping()?;
1727
1728        let mut pending = vec![tip];
1729        let mut visited = std::collections::HashSet::new();
1730        let mut count = 0usize;
1731        while let Some(oid) = pending.pop() {
1732            if !visited.insert(oid) {
1733                continue;
1734            }
1735            let git_commit = oid.to_string();
1736            if self
1737                .git_overlay_mapped_change_for_commit(
1738                    &git_commit,
1739                    &bridge_mapping,
1740                    &ingest_mapping,
1741                    &checkpoint_mapping,
1742                )?
1743                .is_some()
1744            {
1745                // Mapped into Heddle: this lineage is reconciled; stop here.
1746                continue;
1747            }
1748            count += 1;
1749            if count >= GIT_OVERLAY_OUT_OF_BAND_SCAN_LIMIT {
1750                return Ok(Some(GitOverlayOutOfBandCommits {
1751                    count,
1752                    truncated: true,
1753                }));
1754            }
1755            let Ok(commit) = git_repo.read_commit(&oid) else {
1756                continue;
1757            };
1758            for parent in commit.parents {
1759                pending.push(parent);
1760            }
1761        }
1762        Ok(Some(GitOverlayOutOfBandCommits {
1763            count,
1764            truncated: false,
1765        }))
1766    }
1767
1768    pub fn git_overlay_current_branch(&self) -> Result<Option<String>> {
1769        if self.capability() != RepositoryCapability::GitOverlay {
1770            return Ok(None);
1771        }
1772
1773        match detect_git_head_state(&self.root)? {
1774            Some(GitHeadState::Attached(branch)) => return Ok(Some(branch)),
1775            Some(GitHeadState::Detached(_)) | None => {}
1776        }
1777
1778        detect_git_in_progress_branch(&self.root)
1779    }
1780
1781    pub fn git_overlay_head_is_detached(&self) -> Result<bool> {
1782        if self.capability() != RepositoryCapability::GitOverlay {
1783            return Ok(false);
1784        }
1785
1786        Ok(matches!(
1787            detect_git_head_state(&self.root)?,
1788            Some(GitHeadState::Detached(_))
1789        ))
1790    }
1791
1792    pub fn git_overlay_detached_head_commit(&self) -> Result<Option<String>> {
1793        if self.capability() != RepositoryCapability::GitOverlay {
1794            return Ok(None);
1795        }
1796
1797        Ok(match detect_git_head_state(&self.root)? {
1798            Some(GitHeadState::Detached(git_oid)) => Some(git_oid.to_string()),
1799            Some(GitHeadState::Attached(_)) | None => None,
1800        })
1801    }
1802
1803    fn git_overlay_commit_tip_oid(
1804        &self,
1805        git_repo: &SleyRepository,
1806        reference: &sley::plumbing::sley_refs::Ref,
1807        ref_kind: &str,
1808        ref_name: &str,
1809    ) -> Result<Option<SleyObjectId>> {
1810        let target = match &reference.target {
1811            SleyRefTarget::Direct(oid) => *oid,
1812            SleyRefTarget::Symbolic(_) => return Ok(None),
1813        };
1814        let target = match sley::plumbing::sley_rev::peel_to_commit(
1815            git_repo.objects().as_ref(),
1816            git_repo.object_format(),
1817            &target,
1818        ) {
1819            Ok(target) => target,
1820            Err(_) => return Ok(None),
1821        };
1822
1823        let _ = (ref_kind, ref_name);
1824        Ok(Some(target))
1825    }
1826
1827    fn heddle_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1828        if self.merge_state_manager().is_merge_in_progress() {
1829            return Ok(Some(RepositoryOperationStatus {
1830                scope: OperationScope::Heddle,
1831                kind: OperationKind::Merge,
1832                in_progress: true,
1833                state: "in-progress".to_string(),
1834                message: "Heddle merge is in progress".to_string(),
1835                next_action: "heddle continue".to_string(),
1836            }));
1837        }
1838
1839        let rebase_state = self.heddle_dir.join("REBASE_STATE");
1840        if rebase_state.exists() {
1841            return Ok(Some(RepositoryOperationStatus {
1842                scope: OperationScope::Heddle,
1843                kind: OperationKind::Rebase,
1844                in_progress: true,
1845                state: "in-progress".to_string(),
1846                message: "Heddle rebase is in progress".to_string(),
1847                next_action: "heddle continue".to_string(),
1848            }));
1849        }
1850
1851        let bisect_state = self.heddle_dir.join("BISECT_STATE");
1852        if bisect_state.exists() {
1853            return Ok(Some(RepositoryOperationStatus {
1854                scope: OperationScope::Heddle,
1855                kind: OperationKind::Bisect,
1856                in_progress: true,
1857                state: "in-progress".to_string(),
1858                // The `bisect` verb was removed in the whole-CLI consolidation
1859                // (heddle#473); a lingering BISECT_STATE can only come from an
1860                // older binary, and the only valid recovery now is to abort.
1861                message: "Heddle bisect is in progress".to_string(),
1862                next_action: "heddle abort".to_string(),
1863            }));
1864        }
1865
1866        Ok(None)
1867    }
1868
1869    fn git_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1870        if self.capability() != RepositoryCapability::GitOverlay {
1871            return Ok(None);
1872        }
1873
1874        let git_dir = resolve_git_dir(&self.root)?;
1875        let raw_git_next_action = "heddle bridge git status";
1876        let candidates = [
1877            (
1878                git_dir.join("rebase-merge"),
1879                OperationKind::Rebase,
1880                "Git rebase is in progress",
1881                raw_git_next_action,
1882            ),
1883            (
1884                git_dir.join("rebase-apply"),
1885                OperationKind::Rebase,
1886                "Git rebase is in progress",
1887                raw_git_next_action,
1888            ),
1889            (
1890                git_dir.join("MERGE_HEAD"),
1891                OperationKind::Merge,
1892                "Git merge is in progress",
1893                raw_git_next_action,
1894            ),
1895            (
1896                git_dir.join("CHERRY_PICK_HEAD"),
1897                OperationKind::CherryPick,
1898                "Git cherry-pick is in progress",
1899                raw_git_next_action,
1900            ),
1901            (
1902                git_dir.join("REVERT_HEAD"),
1903                OperationKind::Revert,
1904                "Git revert is in progress",
1905                raw_git_next_action,
1906            ),
1907            (
1908                git_dir.join("BISECT_LOG"),
1909                OperationKind::Bisect,
1910                "Git bisect is in progress",
1911                raw_git_next_action,
1912            ),
1913        ];
1914
1915        for (path, kind, message, next_action) in candidates {
1916            if path.exists() {
1917                return Ok(Some(RepositoryOperationStatus {
1918                    scope: OperationScope::Git,
1919                    kind,
1920                    in_progress: true,
1921                    state: "in-progress".to_string(),
1922                    message: message.to_string(),
1923                    next_action: next_action.to_string(),
1924                }));
1925            }
1926        }
1927
1928        Ok(None)
1929    }
1930
1931    pub fn list_git_checkpoints(&self) -> Result<Vec<GitCheckpointRecord>> {
1932        let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1933        if !path.exists() {
1934            return Ok(Vec::new());
1935        }
1936        let contents = fs::read_to_string(path)?;
1937        if contents.trim().is_empty() {
1938            return Ok(Vec::new());
1939        }
1940        Ok(serde_json::from_str(&contents)?)
1941    }
1942
1943    pub fn latest_git_checkpoint_for_change(
1944        &self,
1945        change_id: &ChangeId,
1946    ) -> Result<Option<GitCheckpointRecord>> {
1947        let full_id = change_id.to_string_full();
1948        Ok(self
1949            .list_git_checkpoints()?
1950            .into_iter()
1951            .rev()
1952            .find(|record| record.change_id == full_id))
1953    }
1954
1955    pub fn record_git_checkpoint(
1956        &self,
1957        change_id: &ChangeId,
1958        git_commit: impl Into<String>,
1959        summary: impl Into<String>,
1960    ) -> Result<GitCheckpointRecord> {
1961        let mut records = self.list_git_checkpoints()?;
1962        let record = GitCheckpointRecord {
1963            change_id: change_id.to_string_full(),
1964            git_commit: git_commit.into(),
1965            summary: summary.into(),
1966            committed_at: Utc::now().to_rfc3339(),
1967        };
1968        let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1969        if let Some(parent) = path.parent() {
1970            fs::create_dir_all(parent)?;
1971        }
1972        records.push(record.clone());
1973        write_file_atomic(&path, serde_json::to_string_pretty(&records)?.as_bytes())?;
1974        Ok(record)
1975    }
1976
1977    pub fn init_worktree(
1978        path: impl AsRef<Path>,
1979        shared_galeed_dir: impl AsRef<Path>,
1980    ) -> Result<()> {
1981        let path = path.as_ref();
1982        let shared = shared_galeed_dir.as_ref().canonicalize()?;
1983        fs::create_dir_all(path)?;
1984        let heddle_dir = path.join(".heddle");
1985        if heddle_dir.exists() {
1986            return Err(HeddleError::RepositoryExists(path.to_path_buf()));
1987        }
1988        fs::create_dir_all(&heddle_dir)?;
1989        write_file_atomic(
1990            &heddle_dir.join("objectstore"),
1991            format!("objectstore: {}\n", shared.display()).as_bytes(),
1992        )?;
1993        fs::create_dir_all(heddle_dir.join("state"))?;
1994        Ok(())
1995    }
1996
1997    pub fn op_scope(&self) -> String {
1998        // The local HEAD pointer (`<root>/.heddle/HEAD`) is unique per
1999        // worktree even when several worktrees share one oplog backend
2000        // (via `.heddle/objectstore`). `undo`/`redo`/`--list` filter by
2001        // exact-match scope, so the scope must distinguish each
2002        // worktree's local HEAD pointer dir.
2003        //
2004        // Use a content-derived digest of the canonical pointer path:
2005        //   * stable across heddle invocations from the same checkout
2006        //   * unique per worktree (different absolute paths digest
2007        //     differently), so worktree-local undo keeps working in
2008        //     shared-oplog setups
2009        //   * opaque on disk — the user's home directory and username
2010        //     never end up serialized into oplog entries
2011        compute_op_scope(&self.root)
2012    }
2013
2014    /// The write chokepoint (heddle#330 §2.2): commit the ref-carrying
2015    /// `OpRecord` batch (phase 4) **before** publishing the atomic `ref_updates`
2016    /// batch (phase 5), record-before-publish. Encodes the records opaquely and
2017    /// routes through [`RefBackend::commit_and_publish`] so the backend's seam
2018    /// enforces the ordering — the file backend appends-then-publishes, a
2019    /// Postgres backend would co-commit in one SQL transaction. Replaces the
2020    /// publish-then-record order that left a reader-visible ref with no undo
2021    /// record (the fork/collapse bug).
2022    pub fn commit_and_publish(
2023        &self,
2024        records: Vec<OpRecord>,
2025        ref_updates: &[RefUpdate],
2026    ) -> Result<()> {
2027        let encoded = records
2028            .iter()
2029            .map(|record| {
2030                rmp_serde::to_vec(record).map_err(|e| HeddleError::Serialization(e.to_string()))
2031            })
2032            .collect::<Result<Vec<_>>>()?;
2033        let scope = self.op_scope();
2034        let result = self
2035            .refs
2036            .commit_and_publish(&encoded, ref_updates, Some(&scope));
2037        // The committer appended through a fresh `OpLog` handle (the `refs`→`repo`
2038        // seam), so this repository's own cached oplog handle is now stale.
2039        // Refresh it so a same-process read via `self.oplog()` observes the
2040        // just-committed records — the long-lived mount/daemon handle would
2041        // otherwise miss them (heddle#354 r8). Best-effort: a refresh failure
2042        // only costs a stale cache until the next disk reload, never correctness.
2043        let _ = self.oplog.refresh_cache();
2044        result
2045    }
2046
2047    /// Atomically commit a snapshot's `OpRecord::Snapshot` and its paired ref
2048    /// publish through the write chokepoint, **record-first** (heddle#354 r8).
2049    ///
2050    /// The pre-r8 snapshot path published the ref FIRST (`refs.set_thread` /
2051    /// `refs.write_head`) and recorded SECOND. Because the reconciler folds a
2052    /// `Snapshot` record authoritatively (newest committed record wins), a late
2053    /// snapshot record carrying a stale thread value could clobber a newer
2054    /// concurrent write that had already recorded. Routing every snapshot ref
2055    /// write through this single chokepoint makes the record the unit of
2056    /// ordering: the newest committed record IS the newest write, so the
2057    /// authoritative fold can no longer resurrect a stale snapshot.
2058    ///
2059    /// `thread = Some(name)` advances that thread (HEAD stays attached);
2060    /// `thread = None` republishes a detached HEAD. The detached case is now
2061    /// record-first too, so a phase-4-committed / phase-5-unpublished crash is
2062    /// recovered by the reconciler reconstructing `Head::Detached{new_state}`
2063    /// (see `atomic::reconciler`'s detached-`Snapshot` arm).
2064    pub fn commit_snapshot_atomic(
2065        &self,
2066        new_state: &ChangeId,
2067        prev_head: Option<ChangeId>,
2068        thread: Option<&ThreadName>,
2069    ) -> Result<()> {
2070        self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, Vec::new())
2071    }
2072
2073    /// [`commit_snapshot_atomic`](Self::commit_snapshot_atomic) plus `extra`
2074    /// records folded into the SAME batch as the `OpRecord::Snapshot`.
2075    ///
2076    /// Used by the snapshot creators that commit through this chokepoint rather
2077    /// than the `SnapshotMutation` transaction (the in-progress merge branch and
2078    /// the mount capture path) to fold the automatic capture-time
2079    /// default-visibility binding's `OpRecord::StateVisibilitySet` into the
2080    /// snapshot's batch, so one `heddle undo` reverts the snapshot and its
2081    /// auto-applied default tier together (heddle#317 / PR #529 P1).
2082    pub fn commit_snapshot_atomic_with_records(
2083        &self,
2084        new_state: &ChangeId,
2085        prev_head: Option<ChangeId>,
2086        thread: Option<&ThreadName>,
2087        extra: Vec<OpRecord>,
2088    ) -> Result<()> {
2089        let record = OpRecord::Snapshot {
2090            new_state: *new_state,
2091            prev_head,
2092            head: thread.is_none().then_some(*new_state),
2093            thread: thread.map(|name| name.to_string()),
2094        };
2095        let mut records = vec![record];
2096        records.extend(extra);
2097        let ref_update = match thread {
2098            Some(name) => RefUpdate::Thread {
2099                name: name.clone(),
2100                expected: RefExpectation::Any,
2101                new: Some(*new_state),
2102            },
2103            None => RefUpdate::Head {
2104                expected: RefExpectation::Any,
2105                new: Head::Detached { state: *new_state },
2106            },
2107        };
2108        self.commit_and_publish(records, &[ref_update])
2109    }
2110
2111    /// Commit a snapshot batch that folds the automatic capture-time
2112    /// default-visibility binding, **rewinding the staged sidecar if the commit
2113    /// fails** (heddle#317 invariant 2).
2114    ///
2115    /// This is THE single fold-and-rewind chokepoint for snapshot creators that
2116    /// commit *outside* the [`SnapshotMutation`](crate::repository_snapshot)
2117    /// executor — the mount capture path and the in-progress-merge branch. Those
2118    /// paths cannot lean on the executor's `rewind`, so the rollback guarantee
2119    /// lives here, by construction: the binding's sidecar is written by
2120    /// [`stage_default_visibility_binding`](Self::stage_default_visibility_binding)
2121    /// *before* the batch commits, and if the commit errors the sidecar is
2122    /// rewound to its pre-binding image so no orphaned non-public sidecar is left
2123    /// for a state whose snapshot batch never committed.
2124    ///
2125    /// `lock_held` is forwarded to `stage_default_visibility_binding`: the merge
2126    /// branch already holds the snapshot write lock (`true`); the mount path
2127    /// holds none (`false`). A public default stages nothing (absence ≡ public)
2128    /// and the commit runs with no folded record.
2129    pub fn commit_snapshot_atomic_with_capture_visibility(
2130        &self,
2131        new_state: &ChangeId,
2132        prev_head: Option<ChangeId>,
2133        thread: Option<&ThreadName>,
2134        lock_held: bool,
2135    ) -> Result<()> {
2136        let binding = self
2137            .stage_default_visibility_binding(new_state, lock_held)
2138            .map_err(|e| HeddleError::Io(std::io::Error::other(format!("{e:#}"))))?;
2139        let (extra, rewind_to): (Vec<OpRecord>, Option<Option<Vec<u8>>>) = match binding {
2140            Some(binding) => (vec![binding.record], Some(binding.prior_sidecar)),
2141            None => (Vec::new(), None),
2142        };
2143
2144        // Test seam (heddle#317 inv 2): fail the commit AFTER the binding's
2145        // sidecar is staged, so the rewind path is exercised deterministically.
2146        #[cfg(test)]
2147        let commit_result = if crate::repository_state_visibility::take_visibility_commit_fault(
2148            crate::repository_state_visibility::VisibilityCommitFault::SnapshotCommit,
2149        ) {
2150            Err(HeddleError::Io(std::io::Error::other(
2151                "injected snapshot-commit failure after staging visibility binding",
2152            )))
2153        } else {
2154            self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra)
2155        };
2156        #[cfg(not(test))]
2157        let commit_result =
2158            self.commit_snapshot_atomic_with_records(new_state, prev_head, thread, extra);
2159
2160        match commit_result {
2161            Ok(()) => Ok(()),
2162            Err(commit_err) => {
2163                if let Some(prior) = rewind_to {
2164                    // Best-effort rewind to the pre-binding sidecar; the commit
2165                    // error is what the caller acts on. A rewind failure is
2166                    // logged, never masking the original error.
2167                    if let Err(rewind_err) = self.restore_state_visibility_sidecar(new_state, prior)
2168                    {
2169                        tracing::warn!(
2170                            state = %new_state,
2171                            error = %rewind_err,
2172                            "rewind of staged visibility binding after a failed snapshot commit also failed"
2173                        );
2174                    }
2175                }
2176                Err(commit_err)
2177            }
2178        }
2179    }
2180
2181    pub fn repo_config(&self) -> &RepoConfig {
2182        &self.config
2183    }
2184
2185    pub fn config(&self) -> &RepoConfig {
2186        self.repo_config()
2187    }
2188
2189    pub fn get_tree_for_state(&self, state_id: &ChangeId) -> Result<Option<Tree>> {
2190        let state = match self.store.get_state(state_id)? {
2191            Some(state) => state,
2192            None => return Ok(None),
2193        };
2194        self.store.get_tree(&state.tree)
2195    }
2196
2197    pub fn ignore_patterns(&self) -> Result<Vec<String>> {
2198        let mut patterns = self.config.worktree.ignore.clone();
2199        // Reserve the operator-local courtesy-stub filename. It is a Heddle
2200        // artifact written for under-tier checkouts, never tracked content.
2201        // Excluding it here is the single tree-build chokepoint every capture path
2202        // consults (`build_tree`, `build_tree_with_stat_cache`, and the stat-cache
2203        // no-op predicate), so the stub can never be pulled into a captured thread
2204        // by any of them — including a plain `snapshot`/`capture` taken from inside
2205        // a withheld worktree, which does not go through the withheld-manifest guard
2206        // (heddle#316). ROOT-ANCHORED (`/HEDDLE-EMBARGO.txt`): the stub is only ever
2207        // written at the worktree root, so the bare filename — which gitignore
2208        // matches at ANY depth — would silently drop a user's own
2209        // `sub/HEDDLE-EMBARGO.txt` from capture (heddle#316 #9).
2210        patterns.push(format!(
2211            "/{}",
2212            repository_thread_materialize::COURTESY_STUB_FILENAME
2213        ));
2214        if self.capability() == RepositoryCapability::GitOverlay {
2215            patterns.push(".git".to_string());
2216            append_ignore_file_patterns(&mut patterns, &self.root.join(".gitignore"))?;
2217        }
2218        // Worktree-local, never-captured excludes (heddle's analogue of
2219        // `.git/info/exclude`). Lives under THIS worktree's own `.heddle/`
2220        // (`root/.heddle`, which is local even for a shared-store checkout), so
2221        // it is never captured. Lets `start --hydrate` ignore symlinked deps
2222        // without dirtying a tracked `.heddleignore` (heddle#356 cid 3333881577).
2223        // `append_ignore_file_patterns` no-ops when the file is absent — the
2224        // common case for a plain repo.
2225        append_ignore_file_patterns(
2226            &mut patterns,
2227            &self.root.join(".heddle").join("info").join("exclude"),
2228        )?;
2229        let path = self.root.join(".heddleignore");
2230
2231        if path.exists() {
2232            append_ignore_file_patterns(&mut patterns, &path)?;
2233        }
2234
2235        Ok(patterns)
2236    }
2237
2238    /// Canonical absolute paths of *other* threads' worktrees that are
2239    /// strict descendants of `walk_root`. The walker uses these to
2240    /// avoid scanning a sibling thread's files into the current
2241    /// thread's tree (a common shape when an agent worktree is
2242    /// materialized inside the parent repo, e.g. `--path-prefix
2243    /// ./agents`). Computed once per scan, not once per file.
2244    ///
2245    /// Returns paths that
2246    ///   - are strict descendants of canonical `walk_root`, and
2247    ///   - are NOT equal to `walk_root` itself (each thread can scan
2248    ///     its own worktree without excluding itself).
2249    ///
2250    /// Threads with no recorded worktree, or worktrees that no longer
2251    /// exist on disk, are skipped without error.
2252    pub fn nested_thread_worktree_exclusions(&self, walk_root: &Path) -> Result<Vec<PathBuf>> {
2253        let canonical_walk_root = walk_root
2254            .canonicalize()
2255            .unwrap_or_else(|_| walk_root.to_path_buf());
2256        let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2257        let mut exclusions: Vec<PathBuf> = Vec::new();
2258        let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
2259        for thread in manager.list()? {
2260            for candidate in [
2261                Some(&thread.execution_path),
2262                thread.materialized_path.as_ref(),
2263            ]
2264            .into_iter()
2265            .flatten()
2266            {
2267                if candidate.as_os_str().is_empty() {
2268                    continue;
2269                }
2270                let canonical = match candidate.canonicalize() {
2271                    Ok(path) => path,
2272                    Err(_) => continue,
2273                };
2274                if canonical == canonical_walk_root {
2275                    continue;
2276                }
2277                if !canonical.starts_with(&canonical_walk_root) {
2278                    continue;
2279                }
2280                if seen.insert(canonical.clone()) {
2281                    exclusions.push(canonical);
2282                }
2283            }
2284        }
2285        Ok(exclusions)
2286    }
2287
2288    pub fn head(&self) -> Result<Option<ChangeId>> {
2289        Ok(match self.head_ref()? {
2290            Head::Attached { thread } => match self.refs.get_thread(&thread)? {
2291                Some(change_id) => Some(change_id),
2292                None if self.capability() == RepositoryCapability::GitOverlay => {
2293                    self.git_overlay_mapped_change_for_branch(&thread)?
2294                }
2295                None => None,
2296            },
2297            Head::Detached { state } => Some(state),
2298        })
2299    }
2300
2301    pub fn head_ref(&self) -> Result<Head> {
2302        let raw = self.refs.read_head()?;
2303        if self.capability() != RepositoryCapability::GitOverlay {
2304            return Ok(raw);
2305        }
2306        if matches!(raw, Head::Detached { .. }) {
2307            return Ok(raw);
2308        }
2309        if let Some(GitHeadState::Detached(git_oid)) = detect_git_head_state(&self.root)?
2310            && let Some(state) = self.git_overlay_mapped_change_for_git_oid(git_oid)?
2311        {
2312            return Ok(Head::Detached { state });
2313        }
2314        let Some(branch) = self.git_overlay_current_branch()? else {
2315            return Ok(raw);
2316        };
2317        if matches!(&raw, Head::Attached { thread } if *thread == branch) {
2318            return Ok(raw);
2319        }
2320        let branch_thread = ThreadName::from(branch.as_str());
2321        if self.refs.get_thread(&branch_thread)?.is_some()
2322            || self
2323                .git_overlay_mapped_change_for_branch(&branch)?
2324                .is_some()
2325        {
2326            return Ok(Head::Attached {
2327                thread: branch_thread,
2328            });
2329        }
2330        Ok(raw)
2331    }
2332
2333    /// Resolve the on-disk worktree path for the *active thread*.
2334    ///
2335    /// This is the canonical "where does the current thread live on disk"
2336    /// lookup. It reads `HEAD`, looks up the attached thread's metadata
2337    /// (via [`crate::ThreadManager`]), and returns the recorded
2338    /// `execution_path` (or `materialized_path` if unset). When no thread
2339    /// has a recorded path — main, threads created without a separate
2340    /// worktree, or `HEAD::Detached` — this falls back to [`Self::root`].
2341    ///
2342    /// Worktree-mutating commands (merge, rebase, goto, ship) should
2343    /// resolve their target via this helper so that
2344    /// `heddle thread switch X && heddle merge Y` lands the merge into
2345    /// thread `X`'s dedicated worktree, not into whichever directory the
2346    /// operator happened to invoke `heddle` from. Snapshot/capture
2347    /// intentionally stay CWD-based: the agent inside their worktree
2348    /// captures *that* worktree.
2349    pub fn active_worktree_path(&self) -> Result<PathBuf> {
2350        let head = self.refs.read_head()?;
2351        let Head::Attached { thread } = head else {
2352            return Ok(self.root.clone());
2353        };
2354        let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
2355        let Some(thread_record) = manager.find_by_thread(&thread)? else {
2356            return Ok(self.root.clone());
2357        };
2358        if !thread_record.execution_path.as_os_str().is_empty() {
2359            return Ok(thread_record.execution_path);
2360        }
2361        if let Some(path) = thread_record.materialized_path {
2362            return Ok(path);
2363        }
2364        Ok(self.root.clone())
2365    }
2366
2367    pub fn current_state(&self) -> Result<Option<State>> {
2368        match self.head()? {
2369            Some(id) => self.store.get_state(&id),
2370            None => Ok(None),
2371        }
2372    }
2373
2374    pub fn get_principal(&self) -> Result<Principal> {
2375        if let Some(principal) = Principal::from_env() {
2376            return Ok(principal);
2377        }
2378
2379        if let Some(config) = &self.config.principal {
2380            return Ok(Principal::new(&config.name, &config.email));
2381        }
2382
2383        if self.capability() == RepositoryCapability::GitOverlay
2384            && let Some(principal) = git_config_principal(&self.root)
2385        {
2386            return Ok(principal);
2387        }
2388
2389        if let Some(principal) = self.shared_checkout_parent_git_principal() {
2390            return Ok(principal);
2391        }
2392
2393        Ok(Principal::new("Unknown", "unknown@example.com"))
2394    }
2395
2396    fn shared_checkout_parent_git_principal(&self) -> Option<Principal> {
2397        let local_heddle_dir = self.root.join(".heddle");
2398        if local_heddle_dir == self.heddle_dir || !local_heddle_dir.join("objectstore").is_file() {
2399            return None;
2400        }
2401        let parent_root = self.heddle_dir.parent()?;
2402        if parent_root == self.root {
2403            return None;
2404        }
2405        git_config_principal(parent_root)
2406    }
2407
2408    pub fn get_attribution(&self) -> Result<Attribution> {
2409        let principal = self.get_principal()?;
2410
2411        if let Some(agent) = self.resolve_agent() {
2412            Ok(Attribution::with_agent(principal, agent))
2413        } else {
2414            Ok(Attribution::human(principal))
2415        }
2416    }
2417
2418    pub fn is_shallow(&self, id: &ChangeId) -> bool {
2419        self.shallow.read().unwrap().is_shallow(id)
2420    }
2421
2422    pub fn set_shallow(&self, state_id: &ChangeId, _parents: &[ChangeId]) -> Result<()> {
2423        self.shallow.write().unwrap().add_shallow(*state_id)?;
2424        Ok(())
2425    }
2426
2427    pub fn record_missing_blob(&self, hash: ContentHash) -> Result<()> {
2428        self.partial_fetch_metadata().record_missing_blob(hash)?;
2429        Ok(())
2430    }
2431
2432    /// Seed a `main` thread pointing at an empty-tree root state.
2433    ///
2434    /// The seeded state is written to the object store and pointed at by the
2435    /// `main` thread ref, but is deliberately NOT recorded in the oplog: `init`
2436    /// is a point-of-creation event, not user work, and should not be
2437    /// undoable. No-op if `main` already exists.
2438    ///
2439    /// The seed state uses a stable `Heddle <init@heddle>` attribution
2440    /// instead of the user's principal because the user's principal may
2441    /// not yet be configured at init time (e.g. the user writes
2442    /// `.heddle/config.toml` after `heddle init`). Falling back to
2443    /// `Unknown <unknown@example.com>` would surface in `heddle log` as
2444    /// a state owned by no one. The genesis state is also filtered out of
2445    /// user-facing log output (see `repository_history::is_synthetic_root`).
2446    pub fn seed_default_thread(&self) -> Result<()> {
2447        let main_thread = ThreadName::from("main");
2448        if self.refs.get_thread(&main_thread)?.is_some() {
2449            return Ok(());
2450        }
2451
2452        let empty_tree = Tree::new();
2453        let tree_hash = self.store.put_tree(&empty_tree)?;
2454        let state = State::new_snapshot(tree_hash, vec![], Attribution::human(seed_principal()));
2455        self.store.put_state(&state)?;
2456        self.refs.set_thread(&main_thread, &state.change_id)?;
2457        Ok(())
2458    }
2459
2460    pub fn clear_missing_blob(&self, hash: &ContentHash) -> Result<()> {
2461        self.partial_fetch_metadata().clear_missing_blob(hash)?;
2462        Ok(())
2463    }
2464
2465    pub fn missing_blobs(&self) -> Result<Vec<ContentHash>> {
2466        self.partial_fetch_metadata().missing_blobs()
2467    }
2468
2469    pub fn clear_all_missing_blobs(&self) -> Result<bool> {
2470        self.partial_fetch_metadata().clear_all_missing_blobs()
2471    }
2472
2473    pub fn is_missing_blob(&self, hash: &ContentHash) -> Result<bool> {
2474        self.partial_fetch_metadata().is_missing_blob(hash)
2475    }
2476
2477    /// Load a tree by hash from the object store, surfacing a clear
2478    /// error when the hash resolves to nothing.
2479    ///
2480    /// Use this whenever a hash recorded in a `State.tree` field or as
2481    /// a subtree `TreeEntry` MUST resolve to an object: presentation
2482    /// paths (`heddle status`, `heddle ready`, `heddle stash show`),
2483    /// mutation paths (`heddle revert`, `heddle cherry-pick`,
2484    /// `heddle goto`, `heddle resolve`), and inspection paths
2485    /// (semantic diff, harness baseline) all qualify.
2486    ///
2487    /// Replaces the legacy `get_tree(...)?.unwrap_or_default()`
2488    /// pattern. That pattern silently substituted `Tree::default()`
2489    /// for a missing object, so presentation paths rendered "no
2490    /// content" and mutation paths committed subtree-erasure merges
2491    /// (see heddle#90 for the merge-path lock and heddle#93 for the
2492    /// non-merge sweep that motivated this method).
2493    ///
2494    /// Returns [`HeddleError::MissingObject`] with `object_type =
2495    /// "tree"` so callers and the top-level error printer can
2496    /// recognize the bug class. The `Display` impl on `MissingObject`
2497    /// includes the `heddle fsck --full` recovery hint, so call sites
2498    /// don't need to wrap with anyhow context to give the operator a
2499    /// next step.
2500    ///
2501    /// Pair with [`Repository::require_blob`] for the blob side of the
2502    /// same contract.
2503    pub fn require_tree(&self, hash: &ContentHash) -> Result<Tree> {
2504        self.store
2505            .get_tree(hash)?
2506            .ok_or_else(|| HeddleError::MissingObject {
2507                object_type: "tree".to_string(),
2508                id: hash.to_hex(),
2509            })
2510    }
2511
2512    pub fn require_blob(&self, hash: &ContentHash) -> Result<objects::object::Blob> {
2513        if let Some(blob) = self.store.get_blob(hash)? {
2514            if self.is_missing_blob(hash)? {
2515                self.clear_missing_blob(hash)?;
2516            }
2517            return Ok(blob);
2518        }
2519
2520        if self.is_missing_blob(hash)? {
2521            // Lazy-clone read-time hydration (issue #50). If a hydrator
2522            // is registered (by `heddle clone --lazy` / `--filter`),
2523            // delegate; otherwise surface MissingObject as before.
2524            if let Some(hydrator) = self.blob_hydrator() {
2525                hydrator.hydrate(self, hash)?;
2526                if let Some(blob) = self.store.get_blob(hash)? {
2527                    self.clear_missing_blob(hash)?;
2528                    return Ok(blob);
2529                }
2530                // Hydrator returned Ok but did not actually deliver the
2531                // blob — defensive guard so callers never see stale
2532                // state. Leaves the missing marker in place so a future
2533                // attempt re-tries hydration.
2534            }
2535            return Err(HeddleError::MissingObject {
2536                object_type: "blob".to_string(),
2537                id: hash.to_hex(),
2538            });
2539        }
2540
2541        Err(HeddleError::NotFound(hash.to_hex()))
2542    }
2543
2544    /// Register a `BlobHydrator` to fetch blobs on demand from the
2545    /// upstream when `require_blob` hits a missing-blob marker. Used by
2546    /// the clone command after a `--lazy` / `--filter blob:none` clone.
2547    /// Replaces any previously registered hydrator.
2548    ///
2549    /// The trait-object handle itself is process-local, but persistence
2550    /// across `Repository::open` calls is handled by the
2551    /// [`crate::lazy_hydrator`] module: clone writes
2552    /// `.heddle/lazy-hydrator.toml` recording the hydrator kind +
2553    /// config, and `Repository::open` consults
2554    /// [`crate::lazy_hydrator::try_reconstruct`] to look up the
2555    /// registered factory and re-install the hydrator automatically.
2556    pub fn set_blob_hydrator(&self, hydrator: Arc<dyn BlobHydrator>) {
2557        *self.blob_hydrator.write().unwrap() = Some(hydrator);
2558    }
2559
2560    /// The currently registered hydrator, if any.
2561    pub fn blob_hydrator(&self) -> Option<Arc<dyn BlobHydrator>> {
2562        self.blob_hydrator.read().unwrap().clone()
2563    }
2564
2565    fn partial_fetch_metadata(&self) -> repository_partial_fetch::PartialFetchMetadataManager {
2566        repository_partial_fetch::PartialFetchMetadataManager::new(&self.heddle_dir)
2567    }
2568
2569    pub fn shallow(&self) -> std::sync::RwLockReadGuard<'_, ShallowInfo> {
2570        self.shallow.read().unwrap()
2571    }
2572}
2573
2574fn ensure_git_overlay_exclude(root: &Path) -> Result<()> {
2575    let git_dir = match SleyRepository::discover(root) {
2576        Ok(repo) if repo.workdir().is_some() => repo.git_dir().to_path_buf(),
2577        _ => root.join(".git"),
2578    };
2579    if !git_dir.is_dir() {
2580        return Ok(());
2581    }
2582
2583    let info_dir = git_dir.join("info");
2584    fs::create_dir_all(&info_dir)?;
2585    let exclude_path = info_dir.join("exclude");
2586    let mut contents = fs::read_to_string(&exclude_path).unwrap_or_default();
2587    let existing_lines = contents.lines().map(str::trim).collect::<BTreeSet<_>>();
2588    let mut missing = Vec::new();
2589    for pattern in GIT_OVERLAY_LOCAL_EXCLUDE_PATTERNS {
2590        if !existing_lines
2591            .iter()
2592            .any(|line| git_overlay_exclude_line_matches(line, pattern))
2593        {
2594            missing.push(*pattern);
2595        }
2596    }
2597    if missing.is_empty() {
2598        return Ok(());
2599    }
2600    if !contents.is_empty() && !contents.ends_with('\n') {
2601        contents.push('\n');
2602    }
2603    contents.push_str("# Heddle local metadata\n");
2604    for pattern in missing {
2605        contents.push_str(pattern);
2606        contents.push('\n');
2607    }
2608    fs::write(exclude_path, contents)?;
2609    Ok(())
2610}
2611
2612fn git_overlay_exclude_line_matches(line: &str, pattern: &str) -> bool {
2613    line == pattern
2614        || matches!(
2615            (line, pattern),
2616            (".heddle", ".heddle/") | ("/.heddle/", ".heddle/") | ("/.heddle", ".heddle/")
2617        )
2618}
2619
2620/// Stable system principal stamped into the synthetic seed state created
2621/// at `heddle init` time, before any user principal is known. Kept
2622/// distinct from the `Unknown <unknown@example.com>` fallback so the
2623/// genesis state is never confused with an unattributed user state.
2624pub(crate) fn seed_principal() -> Principal {
2625    Principal::new("Heddle", "init@heddle")
2626}
2627
2628/// True if `state` is the synthetic empty-tree genesis stamped by
2629/// [`Repository::seed_default_thread`]. These states are filtered from
2630/// user-facing log walks: they have no parents, no intent, and the
2631/// system seed principal — they represent pre-history, not user work.
2632pub fn is_synthetic_root(state: &State) -> bool {
2633    state.parents.is_empty()
2634        && state.intent.is_none()
2635        && state.attribution.principal == seed_principal()
2636        && state.attribution.agent.is_none()
2637}
2638
2639/// Parse a `.heddle` pointer file and return the shared object store path.
2640///
2641/// The file must contain a line of the form `objectstore: <path>`.
2642fn parse_objectstore_pointer(content: &str) -> Option<PathBuf> {
2643    for line in content.lines() {
2644        if let Some(path) = line.strip_prefix("objectstore:") {
2645            let path = path.trim();
2646            if !path.is_empty() {
2647                return Some(PathBuf::from(path));
2648            }
2649        }
2650    }
2651    None
2652}
2653
2654fn has_git_metadata(path: &Path) -> bool {
2655    let dot_git = path.join(".git");
2656    if !(dot_git.is_dir() || dot_git.is_file()) {
2657        return false;
2658    }
2659
2660    SleyRepository::discover(path).is_ok()
2661}
2662
2663/// If `start_path` lies inside a *managed virtualized thread root*
2664/// (`<repo>/.heddle/threads/<encoded>/<repo-name>`) that carries NO
2665/// checkout metadata of its own, return that mount root.
2666///
2667/// Solid and materialized thread checkouts write their own `.heddle`
2668/// objectstore pointer at the checkout root, so [`Repository::open`]
2669/// resolves them as a worktree before it climbs to the parent. A
2670/// *virtualized* thread mounts a content-addressed projection there and
2671/// writes no such pointer, so a bare upward walk would sail past the
2672/// metadata-less mount and open the PARENT repo. The flat
2673/// `thread_manifest::thread_dir` encoding guarantees `<encoded>` is exactly
2674/// one path component, so any direct checkout leaf below it has the
2675/// unambiguous `<leaf> → <encoded> → threads → .heddle` shape (heddle#572 r2).
2676fn metadataless_managed_thread_root(start_path: &Path) -> Option<PathBuf> {
2677    let mut cur: Option<&Path> = Some(start_path);
2678    while let Some(dir) = cur {
2679        if let Some(thread_dir) = dir.parent()
2680            && let Some(threads) = thread_dir.parent()
2681            && threads.file_name().and_then(|n| n.to_str()) == Some("threads")
2682            && let Some(heddle) = threads.parent()
2683            && heddle.file_name().and_then(|n| n.to_str()) == Some(".heddle")
2684            && heddle.join("objects").is_dir()
2685            && !dir.join(".heddle").exists()
2686        {
2687            return Some(dir.to_path_buf());
2688        }
2689        cur = dir.parent();
2690    }
2691    None
2692}
2693
2694fn git_config_principal(root: &Path) -> Option<Principal> {
2695    let git_repo = SleyRepository::discover(root).ok()?;
2696    let config = git_repo.config_snapshot().ok()?;
2697    let name = config.get("user", None, "name")?.to_string();
2698    let email = config.get("user", None, "email")?.to_string();
2699    if name.trim().is_empty() || email.trim().is_empty() {
2700        return None;
2701    }
2702    Some(Principal::new(&name, &email))
2703}
2704
2705fn git_overlay_untracked_paths(
2706    root: &Path,
2707    tracked_paths: &BTreeSet<&str>,
2708    ignore_patterns: &[String],
2709) -> Result<Vec<String>> {
2710    let mut paths = Vec::new();
2711    let filter_root = root.to_path_buf();
2712    let filter_ignore_patterns = ignore_patterns.to_vec();
2713    let walker = ignore::WalkBuilder::new(root)
2714        .hidden(false)
2715        .git_ignore(true)
2716        .git_exclude(true)
2717        .git_global(true)
2718        .filter_entry(move |entry| {
2719            should_descend_for_git_overlay_status(
2720                &filter_root,
2721                entry.path(),
2722                &filter_ignore_patterns,
2723            )
2724        })
2725        .build();
2726    for entry in walker {
2727        let entry = entry.map_err(|error| HeddleError::Config(error.to_string()))?;
2728        let file_type = entry.file_type();
2729        if !file_type.is_some_and(|file_type| file_type.is_file() || file_type.is_symlink()) {
2730            continue;
2731        }
2732        let path = repo_relative_git_path(root, entry.path())?;
2733        if !tracked_paths.contains(path.as_str())
2734            && !ignored_git_overlay_status_path(&path)
2735            && !should_ignore_path(Path::new(&path), ignore_patterns)
2736        {
2737            paths.push(path);
2738        }
2739    }
2740    Ok(paths)
2741}
2742
2743fn should_descend_for_git_overlay_status(
2744    root: &Path,
2745    path: &Path,
2746    ignore_patterns: &[String],
2747) -> bool {
2748    if is_git_or_heddle_dir(path) {
2749        return false;
2750    }
2751    let Ok(relative) = path.strip_prefix(root) else {
2752        return true;
2753    };
2754    if relative.as_os_str().is_empty() {
2755        return true;
2756    }
2757    !should_ignore_path(relative, ignore_patterns)
2758}
2759
2760fn is_git_or_heddle_dir(path: &Path) -> bool {
2761    path.file_name()
2762        .and_then(|name| name.to_str())
2763        .is_some_and(|name| name == ".git" || name == ".heddle")
2764}
2765
2766fn repo_relative_git_path(root: &Path, path: &Path) -> Result<String> {
2767    let relative = path.strip_prefix(root).map_err(|error| {
2768        HeddleError::Config(format!(
2769            "failed to relativize Git worktree path '{}': {}",
2770            path.display(),
2771            error
2772        ))
2773    })?;
2774    Ok(path_to_git_path(relative))
2775}
2776
2777fn path_to_git_path(path: &Path) -> String {
2778    path.components()
2779        .map(|component| component.as_os_str().to_string_lossy())
2780        .collect::<Vec<_>>()
2781        .join("/")
2782}
2783
2784fn git_path(path: &[u8]) -> String {
2785    String::from_utf8_lossy(path).into_owned()
2786}
2787
2788fn ignored_git_overlay_status_path(path: &str) -> bool {
2789    path == ".heddle" || path.starts_with(".heddle/")
2790}
2791
2792fn git_remote_names(root: &Path) -> Result<Vec<String>> {
2793    let repo = match SleyRepository::discover(root) {
2794        Ok(repo) => repo,
2795        Err(_) => return Ok(Vec::new()),
2796    };
2797    repo.remote_names()
2798        .map(|names| {
2799            names
2800                .into_iter()
2801                .filter(|name| !name.trim().is_empty())
2802                .collect()
2803        })
2804        .map_err(|error| HeddleError::Config(error.to_string()))
2805}
2806
2807fn git_find_reference(repo: &SleyRepository, name: &str) -> Result<Option<SleyReference>> {
2808    repo.find_reference(name).map_err(|error| {
2809        HeddleError::Config(format!("failed to inspect Git reference '{name}': {error}"))
2810    })
2811}
2812
2813fn git_resolve_oid(repo: &SleyRepository, rev: &str) -> Result<Option<SleyObjectId>> {
2814    match repo.rev_parse(rev) {
2815        Ok(id) => Ok(Some(id)),
2816        Err(_) => Ok(None),
2817    }
2818}
2819
2820fn git_configured_tracking_ref(repo: &SleyRepository, branch: &str) -> Result<Option<String>> {
2821    let config = repo
2822        .config_snapshot()
2823        .map_err(|error| HeddleError::Config(error.to_string()))?;
2824    let Some(remote) = config.get("branch", Some(branch), "remote") else {
2825        return Ok(None);
2826    };
2827    let Some(merge) = config.get("branch", Some(branch), "merge") else {
2828        return Ok(None);
2829    };
2830    if remote == "." {
2831        return Ok(Some(merge.to_string()));
2832    }
2833    let Some(short) = merge.strip_prefix("refs/heads/") else {
2834        return Ok(None);
2835    };
2836    Ok(Some(format!("refs/remotes/{remote}/{short}")))
2837}
2838
2839fn git_ahead_behind(
2840    root: &Path,
2841    repo: &SleyRepository,
2842    upstream: SleyObjectId,
2843    head: SleyObjectId,
2844) -> Result<(usize, usize)> {
2845    if upstream == head {
2846        return Ok((0, 0));
2847    }
2848    let ahead = git_reachable_count(root, repo, head, upstream)?;
2849    let behind = git_reachable_count(root, repo, upstream, head)?;
2850    Ok((ahead, behind))
2851}
2852
2853fn git_reachable_count(
2854    root: &Path,
2855    repo: &SleyRepository,
2856    tip: SleyObjectId,
2857    hidden: SleyObjectId,
2858) -> Result<usize> {
2859    let hidden = git_ancestor_set(root, repo, hidden)?;
2860    let mut seen = std::collections::HashSet::new();
2861    let mut pending = vec![tip];
2862    let mut count = 0;
2863    while let Some(oid) = pending.pop() {
2864        if hidden.contains(&oid) || !seen.insert(oid) {
2865            continue;
2866        }
2867        count += 1;
2868        let commit = repo.read_commit(&oid).map_err(|error| {
2869            HeddleError::Config(format!(
2870                "failed to inspect Git upstream drift at '{}': {error}",
2871                root.display()
2872            ))
2873        })?;
2874        pending.extend(commit.parents);
2875    }
2876    Ok(count)
2877}
2878
2879fn git_ancestor_set(
2880    root: &Path,
2881    repo: &SleyRepository,
2882    start: SleyObjectId,
2883) -> Result<std::collections::HashSet<SleyObjectId>> {
2884    let mut seen = std::collections::HashSet::new();
2885    let mut pending = vec![start];
2886    while let Some(oid) = pending.pop() {
2887        if !seen.insert(oid) {
2888            continue;
2889        }
2890        let commit = repo.read_commit(&oid).map_err(|error| {
2891            HeddleError::Config(format!(
2892                "failed to inspect Git upstream drift at '{}': {error}",
2893                root.display()
2894            ))
2895        })?;
2896        pending.extend(commit.parents);
2897    }
2898    Ok(seen)
2899}
2900
2901fn git_remote_tracking_display_name(name: &str) -> String {
2902    name.strip_prefix("refs/remotes/")
2903        .unwrap_or(name)
2904        .to_string()
2905}
2906
2907fn git_remote_tracking_message(
2908    branch: &str,
2909    upstream: &str,
2910    ahead: usize,
2911    behind: usize,
2912    upstream_is_undone_checkpoint: bool,
2913) -> String {
2914    if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
2915        return format!(
2916            "Upstream '{upstream}' still points at a Git commit that was undone locally on branch '{branch}'"
2917        );
2918    }
2919    match (ahead, behind) {
2920        (0, behind) => format!(
2921            "Git branch '{}' is behind upstream '{}' by {} commit(s)",
2922            branch, upstream, behind
2923        ),
2924        (ahead, 0) => format!(
2925            "Git branch '{}' is ahead of upstream '{}' by {} commit(s)",
2926            branch, upstream, ahead
2927        ),
2928        (ahead, behind) => format!(
2929            "Git branch '{}' has diverged from upstream '{}' (ahead {}, behind {})",
2930            branch, upstream, ahead, behind
2931        ),
2932    }
2933}
2934
2935fn git_remote_tracking_next_action(
2936    ahead: usize,
2937    behind: usize,
2938    upstream_is_undone_checkpoint: bool,
2939) -> String {
2940    if upstream_is_undone_checkpoint && ahead == 0 && behind > 0 {
2941        return "heddle push --force".to_string();
2942    }
2943    match (ahead, behind) {
2944        (0, _) => "heddle pull".to_string(),
2945        (_, 0) => "heddle push".to_string(),
2946        _ => "heddle pull".to_string(),
2947    }
2948}
2949
2950fn repository_capability_for_root(root: &Path) -> RepositoryCapability {
2951    if has_git_metadata(root) {
2952        RepositoryCapability::GitOverlay
2953    } else {
2954        RepositoryCapability::NativeHeddle
2955    }
2956}
2957
2958fn append_ignore_file_patterns(patterns: &mut Vec<String>, path: &Path) -> Result<()> {
2959    if !path.exists() {
2960        return Ok(());
2961    }
2962    let contents = std::fs::read_to_string(path)?;
2963    for line in contents.lines() {
2964        let trimmed = line.trim();
2965        if trimmed.is_empty() || trimmed.starts_with('#') {
2966            continue;
2967        }
2968        if !patterns.iter().any(|pattern| pattern == trimmed) {
2969            patterns.push(trimmed.to_string());
2970        }
2971    }
2972    Ok(())
2973}
2974
2975/// Read git's HEAD ref via `sley::Repository::discover` (~25ms — full repository
2976/// inspection). Used as a fallback when the fast path can't parse the
2977/// raw `.git/HEAD` file (e.g. detached HEAD, multi-worktree layouts).
2978fn detect_git_head_state_via_sley(path: &Path) -> Result<Option<GitHeadState>> {
2979    let repo = SleyRepository::discover(path).map_err(|error| {
2980        HeddleError::Config(format!(
2981            "failed to inspect git repository at '{}': {}",
2982            path.display(),
2983            error
2984        ))
2985    })?;
2986    let head = match repo.head() {
2987        Ok(head) => head,
2988        Err(_) => return Ok(None),
2989    };
2990
2991    if let Some(name) = head.branch_name() {
2992        return Ok(Some(GitHeadState::Attached(name.to_string())));
2993    }
2994    if head.is_detached()
2995        && let Some(id) = head.oid
2996    {
2997        return Ok(Some(GitHeadState::Detached(id)));
2998    }
2999    Ok(None)
3000}
3001
3002fn detect_git_head_state(path: &Path) -> Result<Option<GitHeadState>> {
3003    if let Some(head) = detect_git_head_fast(path) {
3004        return Ok(Some(head));
3005    }
3006    detect_git_head_state_via_sley(path)
3007}
3008
3009/// Detect git's current HEAD branch.
3010///
3011/// The fast path reads `.git/HEAD` directly as text. `.git/HEAD` is a
3012/// tiny file (~30 bytes for `ref: refs/heads/<name>\n`) and a direct
3013/// read is ~50us vs. repository discovery's ~25ms full repository
3014/// inspection. Falls back to sley only for the cases the text parser
3015/// can't handle: detached HEAD, multi-worktree `gitdir:` indirections,
3016/// and any malformed file (where we'd rather surface the right error
3017/// than guess).
3018fn detect_git_head(path: &Path) -> Result<Option<Head>> {
3019    if let Some(GitHeadState::Attached(thread)) = detect_git_head_state(path)? {
3020        return Ok(Some(Head::Attached {
3021            thread: ThreadName::from(thread),
3022        }));
3023    }
3024    Ok(None)
3025}
3026
3027/// Fast path for `.git/HEAD` parsing. Returns `Some(GitHeadState::Attached)`
3028/// when `.git/HEAD` is the simple `ref: refs/heads/<name>` form;
3029/// returns `None` for any case we don't trust ourselves to parse
3030/// correctly (detached HEAD raw OIDs, `gitdir:` worktree pointers,
3031/// missing files), letting the sley fallback handle it.
3032fn detect_git_head_fast(path: &Path) -> Option<GitHeadState> {
3033    let head_path = path.join(".git").join("HEAD");
3034    // `.git` may also be a *file* (the gitdir: pointer used by
3035    // worktrees and submodules) — don't try to read it as a directory.
3036    if !head_path.is_file() {
3037        return None;
3038    }
3039    let content = std::fs::read_to_string(&head_path).ok()?;
3040    let trimmed = content.trim();
3041    let suffix = trimmed.strip_prefix("ref: ")?;
3042    let name = suffix.strip_prefix("refs/heads/")?.to_string();
3043    if name.is_empty() {
3044        return None;
3045    }
3046    Some(GitHeadState::Attached(name))
3047}
3048
3049fn resolve_git_dir(path: &Path) -> Result<PathBuf> {
3050    let repo = SleyRepository::discover(path).map_err(|error| {
3051        HeddleError::Config(format!(
3052            "failed to resolve git dir at '{}': {}",
3053            path.display(),
3054            error
3055        ))
3056    })?;
3057    Ok(repo.git_dir().to_path_buf())
3058}
3059
3060fn detect_git_in_progress_branch(path: &Path) -> Result<Option<String>> {
3061    let git_dir = resolve_git_dir(path)?;
3062    for marker in ["rebase-merge/head-name", "rebase-apply/head-name"] {
3063        let branch_path = git_dir.join(marker);
3064        if !branch_path.exists() {
3065            continue;
3066        }
3067        let raw = fs::read_to_string(&branch_path)?;
3068        let value = raw.trim();
3069        if let Some(short) = value.strip_prefix("refs/heads/") {
3070            return Ok(Some(short.to_string()));
3071        }
3072        if !value.is_empty() {
3073            return Ok(Some(value.to_string()));
3074        }
3075    }
3076    Ok(None)
3077}