Skip to main content

repo/
repository.rs

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