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