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