Skip to main content

repo/
repository.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Repository: high-level interface for Heddle operations.
3
4#[path = "bloom_filter.rs"]
5mod bloom_filter;
6#[path = "commit_graph.rs"]
7pub(crate) mod commit_graph;
8#[path = "commit_graph_persistence.rs"]
9mod commit_graph_persistence;
10#[path = "context_suggestions.rs"]
11mod context_suggestions;
12#[path = "repo_config.rs"]
13mod repo_config;
14#[path = "repository_context.rs"]
15mod repository_context;
16#[path = "repository_diff.rs"]
17mod repository_diff;
18#[path = "repository_goto.rs"]
19mod repository_goto;
20#[path = "repository_history.rs"]
21mod repository_history;
22#[path = "repository_maintenance.rs"]
23mod repository_maintenance;
24#[path = "repository_materialization.rs"]
25mod repository_materialization;
26#[path = "repository_partial_fetch.rs"]
27mod repository_partial_fetch;
28#[path = "repository_provenance/mod.rs"]
29mod repository_provenance;
30#[path = "repository_resolve.rs"]
31mod repository_resolve;
32#[path = "repository_signing.rs"]
33mod repository_signing;
34#[path = "repository_snapshot.rs"]
35mod repository_snapshot;
36#[cfg(test)]
37#[path = "repository_tests.rs"]
38mod repository_tests;
39#[path = "repository_tree.rs"]
40mod repository_tree;
41#[path = "repository_worktree_apply.rs"]
42mod repository_worktree_apply;
43#[path = "repository_worktree_status.rs"]
44mod repository_worktree_status;
45#[path = "status_tracked_refresh.rs"]
46mod status_tracked_refresh;
47#[path = "status_untracked_scan.rs"]
48mod status_untracked_scan;
49
50use std::{
51    collections::HashMap,
52    fs,
53    io::Write,
54    path::{Path, PathBuf},
55    process::Command,
56    sync::RwLock,
57};
58
59use chrono::Utc;
60pub use commit_graph::{CommitGraphIndex, find_merge_base};
61pub use context_suggestions::{
62    ContextSuggestion, ContextSuggestionTier, HIGH_SUGGESTION_THRESHOLD,
63    MAJOR_REWRITE_THRESHOLD_PCT, MEDIUM_SUGGESTION_THRESHOLD, SUGGESTION_WINDOW,
64    compute_rewrite_pct, is_major_rewrite,
65};
66pub use objects::object::DiffKind;
67use objects::{
68    error::{HeddleError, Result},
69    fs_atomic::write_file_atomic,
70    lock::{RepoLock, RepositoryLockExt},
71    object::{Attribution, ChangeId, ContentHash, Principal, State, Tree},
72    store::{FsStore, ObjectStore, ShallowInfo},
73    worktree::WorktreeStatus,
74};
75use oplog::{OpLog, OpLogBackend};
76pub use refs::RefSummaryIndexInspection;
77use refs::{Head, RefBackend, RefManager};
78pub use repo_config::{HostedConfig, OutputFormat, RepoConfig};
79// Review-epic config types — re-exported here so the new
80// `repository_signals.rs` (and external crates wanting to construct a
81// custom signals config) don't need to reach into a private module path.
82#[allow(unused_imports)]
83pub use repo_config::{
84    PatternDeviationToml, ReviewConfig, ReviewSignalsToml, SelfFlaggedToml, SignalEnableToml,
85    SignalModuleToml, TestReachabilityToml,
86};
87pub use repository_history::{ChangedPathFilter, ChangedPathFilters, HistoryQuery};
88pub use repository_maintenance::{
89    ChangeMonitorInspection, CommitGraphInspection, PackFilesInspection, PartialFetchInspection,
90    PullPlannerCacheInspection, RefCountsInspection, RepositoryMaintenanceRunReport,
91    RepositoryPerformanceInspectionReport, WorktreeIndexInspection,
92};
93pub use repository_materialization::WarmCanonicalStoreStats;
94pub use repository_partial_fetch::MissingBlob;
95pub use repository_snapshot::{SnapshotExecution, SnapshotProfile};
96pub use repository_tree::{TreeBuildProfile, WorktreeCompareProfile};
97pub use repository_worktree_status::{UntrackedSet, UntrackedSubtree, WorktreeStatusDetailed};
98use serde::{Deserialize, Serialize};
99
100const GIT_CHECKPOINTS_FILE: &str = "git-checkpoints.json";
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum RepositoryCapability {
104    GitOverlay,
105    NativeHeddle,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct GitCheckpointRecord {
110    pub change_id: String,
111    pub git_commit: String,
112    pub summary: String,
113    pub committed_at: String,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct GitOverlayImportHint {
118    pub current_branch: String,
119    pub missing_branch_count: usize,
120    pub missing_branches: Vec<String>,
121    pub recommended_command: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct GitOverlayBranchTip {
126    pub branch: String,
127    pub git_commit: String,
128    pub history_imported: bool,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct GitOverlayTagTip {
133    pub tag: String,
134    pub git_commit: String,
135    pub history_imported: bool,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "kebab-case")]
140pub enum OperationScope {
141    Git,
142    Heddle,
143}
144
145impl std::fmt::Display for OperationScope {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            Self::Git => write!(f, "git"),
149            Self::Heddle => write!(f, "heddle"),
150        }
151    }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155#[serde(rename_all = "kebab-case")]
156pub enum OperationKind {
157    Merge,
158    Rebase,
159    CherryPick,
160    Revert,
161    Bisect,
162}
163
164impl std::fmt::Display for OperationKind {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            Self::Merge => write!(f, "merge"),
168            Self::Rebase => write!(f, "rebase"),
169            Self::CherryPick => write!(f, "cherry-pick"),
170            Self::Revert => write!(f, "revert"),
171            Self::Bisect => write!(f, "bisect"),
172        }
173    }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct RepositoryOperationStatus {
178    pub scope: OperationScope,
179    pub kind: OperationKind,
180    pub in_progress: bool,
181    pub state: String,
182    pub message: String,
183    pub next_action: String,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct GitRemoteTrackingStatus {
188    pub branch: String,
189    pub upstream: String,
190    pub ahead: usize,
191    pub behind: usize,
192    pub message: String,
193    pub next_action: String,
194}
195
196#[derive(Debug, Deserialize)]
197struct GitBridgeMappingEntry {
198    change_id: String,
199    git_oid: String,
200}
201
202#[derive(Debug, Deserialize, Default)]
203struct GitBridgeMappingFile {
204    entries: Vec<GitBridgeMappingEntry>,
205}
206
207/// A Heddle repository.
208pub struct Repository {
209    root: PathBuf,
210    heddle_dir: PathBuf,
211    store: Box<dyn ObjectStore>,
212    refs: Box<dyn RefBackend>,
213    oplog: Box<dyn OpLogBackend>,
214    config: RepoConfig,
215    shallow: RwLock<ShallowInfo>,
216}
217
218impl RepositoryLockExt for Repository {
219    fn locker(&self) -> RepoLock {
220        let lock_root = self.heddle_dir.parent().expect(
221            "heddle_dir has no parent component; cannot determine lock root. This indicates a misconfigured repository.",
222        );
223        RepoLock::new(lock_root)
224    }
225}
226
227impl Repository {
228    /// Expert-only constructor for callers that already own the repository's
229    /// component backends and invariant state.
230    ///
231    /// Callers must ensure all backends point at the same repository root, the
232    /// `heddle_dir` exists and is canonical for that root, and `shallow` matches
233    /// the on-disk shallow metadata. Prefer [`Repository::init`],
234    /// [`Repository::open`], or [`Repository::open_with_store`] unless a
235    /// cross-crate integration genuinely needs to assemble the pieces manually.
236    pub fn from_parts(
237        root: PathBuf,
238        heddle_dir: PathBuf,
239        store: Box<dyn ObjectStore>,
240        refs: Box<dyn RefBackend>,
241        oplog: Box<dyn OpLogBackend>,
242        config: RepoConfig,
243        shallow: ShallowInfo,
244    ) -> Self {
245        Self {
246            root,
247            heddle_dir,
248            store,
249            refs,
250            oplog,
251            config,
252            shallow: RwLock::new(shallow),
253        }
254    }
255
256    fn open_raw(
257        root: PathBuf,
258        heddle_dir: PathBuf,
259        store: Box<dyn ObjectStore>,
260        config: RepoConfig,
261        refs: RefManager,
262    ) -> Result<Self> {
263        let actor = config
264            .principal
265            .as_ref()
266            .map(|p| objects::object::Principal::new(&p.name, &p.email))
267            .unwrap_or_else(|| objects::object::Principal::new("<unknown>", ""));
268        let oplog = OpLog::new(&heddle_dir, actor);
269        let shallow = ShallowInfo::load(&heddle_dir)?;
270        let repo = Self::from_parts(
271            root,
272            heddle_dir,
273            store,
274            Box::new(refs),
275            Box::new(oplog),
276            config,
277            shallow,
278        );
279        // Run any pending declarative migrations. Idempotent:
280        // re-opening a repo a second time is a no-op for the migration pass.
281        // Failures here are logged but non-fatal — the inline
282        // `migrate_legacy_tracks` calls before this point already handle the
283        // load-bearing work, and surfacing migration errors through `open` is
284        // worse than letting the repo open and warning later.
285        if let Err(err) = crate::migration::apply_pending(&repo) {
286            tracing::warn!("declarative migrations failed during repo open: {err}");
287        }
288        Ok(repo)
289    }
290
291    /// Build an object store from the repository configuration.
292    ///
293    /// Returns an [`S3Store`] when `[storage.s3]` is configured and the `s3`
294    /// feature is enabled, otherwise falls back to [`FsStore`].
295    fn build_store(config: &RepoConfig, heddle_dir: &Path) -> Result<Box<dyn ObjectStore>> {
296        #[cfg(feature = "s3")]
297        {
298            if let Some(s3) = &config.storage.s3 {
299                return Self::build_s3_store(s3);
300            }
301        }
302        let _ = config; // suppress unused warning when s3 feature is off
303        Ok(Box::new(FsStore::new(heddle_dir)))
304    }
305
306    /// Construct an [`S3Store`] from the repository's S3 storage configuration.
307    #[cfg(feature = "s3")]
308    fn build_s3_store(s3: &repo_config::S3StorageConfig) -> Result<Box<dyn ObjectStore>> {
309        use objects::store::S3StoreBuilder;
310
311        let mut builder = S3StoreBuilder::new().bucket(&s3.bucket);
312        if let Some(ref region) = s3.region {
313            builder = builder.region(region);
314        }
315        if let Some(ref prefix) = s3.prefix {
316            builder = builder.prefix(prefix);
317        }
318        if let Some(ref url) = s3.endpoint_url {
319            builder = builder.endpoint_url(url);
320        }
321        if let Some(ref key) = s3.access_key_id {
322            builder = builder.access_key_id(key);
323        }
324        if let Some(ref secret) = s3.secret_access_key {
325            builder = builder.secret_access_key(secret);
326        }
327        if let Some(ref token) = s3.session_token {
328            builder = builder.session_token(token);
329        }
330        if s3.force_path_style {
331            builder = builder.force_path_style(true);
332        }
333
334        // S3StoreBuilder::build is async; block on it.
335        let rt = tokio::runtime::Handle::try_current().or_else(|_| {
336            tokio::runtime::Runtime::new()
337                .map(|rt| rt.handle().clone())
338                .map_err(|e| HeddleError::Config(format!("failed to create tokio runtime: {e}")))
339        })?;
340        let store = rt
341            .block_on(builder.build())
342            .map_err(|e| HeddleError::Config(format!("S3 store initialization failed: {e}")))?;
343        Ok(Box::new(store))
344    }
345
346    /// Initialize a new bare repository at the given path.
347    ///
348    /// Creates the on-disk `.heddle` structure and an attached `main` HEAD, but
349    /// does not seed any threads or states. Callers that want a ready-to-use
350    /// repository (with a `main` thread pointing at an empty-tree snapshot)
351    /// should use [`Repository::init_default`]. Callers that intend to populate
352    /// the repository from an external source (e.g. git import) should use
353    /// `init` directly so the imported refs become the sole source of truth.
354    pub fn init(path: impl AsRef<Path>) -> Result<Self> {
355        let root = path.as_ref().to_path_buf();
356        let heddle_dir = root.join(".heddle");
357
358        if heddle_dir.exists() {
359            return Err(HeddleError::RepositoryExists(root));
360        }
361
362        fs::create_dir_all(&heddle_dir)?;
363
364        let store = FsStore::new(&heddle_dir);
365        store.init()?;
366
367        let refs = RefManager::new(&heddle_dir);
368        refs.init()?;
369
370        // `init` creates a fresh repo before any principal is configured;
371        // the actor is set when the repo is later opened (which reads
372        // `RepoConfig.principal`). Use the unattributed default for
373        // entries written between init and first open.
374        let oplog = OpLog::new_unattributed(&heddle_dir);
375        oplog.init()?;
376
377        let config = RepoConfig::default();
378        config.save(&heddle_dir.join("config.toml"))?;
379
380        refs.write_head(&Head::Attached {
381            thread: "main".to_string(),
382        })?;
383
384        Ok(Self {
385            root,
386            heddle_dir: heddle_dir.clone(),
387            store: Box::new(store),
388            refs: Box::new(refs),
389            oplog: Box::new(oplog),
390            config,
391            shallow: RwLock::new(ShallowInfo::load(&heddle_dir)?),
392        })
393    }
394
395    /// Initialize a new repository with a seeded `main` thread.
396    ///
397    /// Convenience wrapper: equivalent to [`Repository::init`] followed by
398    /// [`Repository::seed_default_thread`]. This is the normal entry point for
399    /// fresh, user-created repositories where `main` should exist immediately.
400    pub fn init_default(path: impl AsRef<Path>) -> Result<Self> {
401        let repo = Self::init(path)?;
402        repo.seed_default_thread()?;
403        Ok(repo)
404    }
405
406    /// Initialize Heddle sidecar storage in an existing Git repository.
407    ///
408    /// Unlike [`Repository::init_default`], this keeps the repo unseeded and
409    /// mirrors the current Git branch attachment into Heddle's HEAD so
410    /// commands like `heddle status` can immediately reflect the user's
411    /// current branch and dirty worktree.
412    pub fn bootstrap_git_overlay(path: impl AsRef<Path>) -> Result<Self> {
413        let root = path.as_ref();
414        if root.join(".heddle").exists() {
415            ensure_git_overlay_exclude(root)?;
416            return Self::open(root);
417        }
418
419        let repo = Self::init(root)?;
420        ensure_git_overlay_exclude(root)?;
421        if let Some(head) = detect_git_head(root)? {
422            repo.refs.write_head(&head)?;
423        }
424        Ok(repo)
425    }
426
427    /// Open an existing Heddle repository using a custom object store backend.
428    pub fn open_with_store(
429        heddle_dir: impl AsRef<Path>,
430        store: Box<dyn ObjectStore>,
431    ) -> Result<Self> {
432        let heddle_dir = heddle_dir.as_ref().to_path_buf();
433        let root = heddle_dir
434            .parent()
435            .ok_or_else(|| {
436                HeddleError::Config(format!(
437                    "heddle_dir '{}' has no parent directory",
438                    heddle_dir.display()
439                ))
440            })?
441            .to_path_buf();
442        let config = RepoConfig::load(&heddle_dir.join("config.toml"))?;
443        let refs = RefManager::new(&heddle_dir);
444        refs.migrate_legacy_tracks()?;
445        refs.cleanup_stale_temps();
446        Self::open_raw(root, heddle_dir, store, config, refs)
447    }
448
449    /// Open an existing repository.
450    ///
451    /// Searches for `.heddle/` in the given path and its ancestors. `.heddle/`
452    /// is always a directory; its contents distinguish a main repo from a
453    /// worktree pointer:
454    ///
455    /// - Main repo: `.heddle/objects/`, `.heddle/refs/`, `.heddle/HEAD`,
456    ///   `.heddle/state/`, etc.
457    /// - Worktree: `.heddle/objectstore` (text pointer to the shared
458    ///   `.heddle/`), `.heddle/HEAD` (per-checkout), `.heddle/state/`
459    ///   (per-checkout cached state).
460    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
461        let start_path = path.as_ref().canonicalize()?;
462        let discovered_git_root = discover_git_root(&start_path);
463
464        let mut current = Some(start_path.as_path());
465        while let Some(dir) = current {
466            let heddle_path = dir.join(".heddle");
467
468            if heddle_path.is_dir() {
469                if let Some(git_root) = discovered_git_root.as_ref()
470                    && git_root != dir
471                    && git_root.starts_with(dir)
472                    && !git_root.join(".heddle").exists()
473                {
474                    ensure_git_overlay_exclude(git_root)?;
475                    Self::bootstrap_git_overlay(git_root)?;
476                    return Self::open(git_root);
477                }
478                let pointer_path = heddle_path.join("objectstore");
479                let objects_dir = heddle_path.join("objects");
480
481                if pointer_path.is_file() {
482                    // Worktree mode: pointer dir at <dir>/.heddle/, shared
483                    // object store at the path read from .heddle/objectstore.
484                    let content = fs::read_to_string(&pointer_path)?;
485                    let raw_shared = parse_objectstore_pointer(&content).ok_or_else(|| {
486                        HeddleError::Config(format!(
487                            "invalid .heddle/objectstore pointer at {}: expected 'objectstore: <path>'",
488                            pointer_path.display()
489                        ))
490                    })?;
491
492                    if raw_shared.is_relative() {
493                        return Err(HeddleError::Config(format!(
494                            ".heddle/objectstore pointer at {} contains a relative path '{}'; \
495                             objectstore path must be absolute",
496                            pointer_path.display(),
497                            raw_shared.display()
498                        )));
499                    }
500
501                    let shared_galeed_dir = raw_shared.canonicalize().map_err(|e| {
502                        HeddleError::Config(format!(
503                            ".heddle/objectstore pointer at {} points to non-existent path '{}': {}",
504                            pointer_path.display(),
505                            raw_shared.display(),
506                            e
507                        ))
508                    })?;
509
510                    if !shared_galeed_dir.join("objects").is_dir() {
511                        return Err(HeddleError::Config(format!(
512                            ".heddle/objectstore pointer at {} resolves to '{}' which does not \
513                             contain an 'objects/' directory; not a valid Heddle store",
514                            pointer_path.display(),
515                            shared_galeed_dir.display()
516                        )));
517                    }
518
519                    let config = RepoConfig::load(&shared_galeed_dir.join("config.toml"))?;
520                    let store: Box<dyn ObjectStore> =
521                        Self::build_store(&config, &shared_galeed_dir)?;
522                    let local_head_path = heddle_path.join("HEAD");
523                    let refs = RefManager::new(&shared_galeed_dir).with_local_head(local_head_path);
524                    refs.migrate_legacy_tracks()?;
525                    refs.cleanup_stale_temps();
526                    return Self::open_raw(
527                        dir.to_path_buf(),
528                        shared_galeed_dir,
529                        store,
530                        config,
531                        refs,
532                    );
533                }
534
535                if objects_dir.is_dir() {
536                    // Main repo mode.
537                    let config = RepoConfig::load(&heddle_path.join("config.toml"))?;
538                    let store: Box<dyn ObjectStore> = Self::build_store(&config, &heddle_path)?;
539                    let refs = RefManager::new(&heddle_path);
540                    refs.migrate_legacy_tracks()?;
541                    refs.cleanup_stale_temps();
542                    let repo = Self::open_raw(dir.to_path_buf(), heddle_path, store, config, refs)?;
543                    if repo.capability() == RepositoryCapability::GitOverlay
544                        && let Ok(Some(git_head)) = detect_git_head(dir)
545                    {
546                        // Avoid the disk write when our HEAD already matches
547                        // git's. Reading the existing head is a small file
548                        // read; the write that follows hits atomic-rename
549                        // machinery (sync + rename) which dominates here.
550                        let stale = match repo.refs.read_head() {
551                            Ok(current) => current != git_head,
552                            Err(_) => true,
553                        };
554                        if stale {
555                            repo.refs.write_head(&git_head)?;
556                        }
557                    }
558                    return Ok(repo);
559                }
560
561                // .heddle/ exists but is neither a worktree pointer nor a
562                // main repo. Treat as not-found and continue walking parents.
563            }
564
565            current = dir.parent();
566        }
567
568        if let Some(git_root) = discovered_git_root {
569            ensure_git_overlay_exclude(&git_root)?;
570            Self::bootstrap_git_overlay(&git_root)?;
571            return Self::open(git_root);
572        }
573
574        Err(HeddleError::RepositoryNotFound(path.as_ref().to_path_buf()))
575    }
576
577    pub fn root(&self) -> &Path {
578        &self.root
579    }
580
581    pub fn heddle_dir(&self) -> &Path {
582        &self.heddle_dir
583    }
584
585    pub fn capability(&self) -> RepositoryCapability {
586        if has_git_metadata(&self.root) {
587            RepositoryCapability::GitOverlay
588        } else {
589            RepositoryCapability::NativeHeddle
590        }
591    }
592
593    pub fn capability_label(&self) -> &'static str {
594        match self.capability() {
595            RepositoryCapability::GitOverlay => "git-overlay",
596            RepositoryCapability::NativeHeddle => "native-heddle",
597        }
598    }
599
600    pub fn storage_model_label(&self) -> &'static str {
601        match self.capability() {
602            RepositoryCapability::GitOverlay => "git+heddle-sidecar",
603            RepositoryCapability::NativeHeddle => "heddle-native",
604        }
605    }
606
607    pub fn hosted_enabled(&self) -> bool {
608        self.config
609            .hosted
610            .upstream_url
611            .as_deref()
612            .is_some_and(|value| !value.trim().is_empty())
613            || self
614                .config
615                .hosted
616                .namespace
617                .as_deref()
618                .is_some_and(|value| !value.trim().is_empty())
619    }
620
621    pub fn current_lane(&self) -> Result<Option<String>> {
622        if self.current_state()?.is_none() && self.capability() == RepositoryCapability::GitOverlay
623        {
624            return self.git_overlay_current_branch();
625        }
626
627        match self.head_ref()? {
628            Head::Attached { thread } => Ok(Some(thread)),
629            Head::Detached { .. } => Ok(None),
630        }
631    }
632
633    pub fn operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
634        if let Some(status) = self.heddle_operation_status()? {
635            return Ok(Some(status));
636        }
637        self.git_operation_status()
638    }
639
640    pub fn git_remote_tracking_status(&self) -> Result<Option<GitRemoteTrackingStatus>> {
641        if self.capability() != RepositoryCapability::GitOverlay {
642            return Ok(None);
643        }
644
645        let branch = match self.git_overlay_current_branch()? {
646            Some(branch) => branch,
647            None => return Ok(None),
648        };
649
650        let output = Command::new("git")
651            .arg("-C")
652            .arg(&self.root)
653            .args(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
654            .output()
655            .map_err(|error| {
656                HeddleError::Config(format!(
657                    "failed to inspect upstream drift at '{}': {}",
658                    self.root.display(),
659                    error
660                ))
661            })?;
662
663        if !output.status.success() {
664            return Ok(None);
665        }
666
667        let counts = String::from_utf8_lossy(&output.stdout);
668        let mut parts = counts.split_whitespace();
669        let behind = parts
670            .next()
671            .and_then(|value| value.parse::<usize>().ok())
672            .unwrap_or(0);
673        let ahead = parts
674            .next()
675            .and_then(|value| value.parse::<usize>().ok())
676            .unwrap_or(0);
677        if ahead == 0 && behind == 0 {
678            return Ok(None);
679        }
680
681        let upstream_output = Command::new("git")
682            .arg("-C")
683            .arg(&self.root)
684            .args([
685                "rev-parse",
686                "--abbrev-ref",
687                "--symbolic-full-name",
688                "@{upstream}",
689            ])
690            .output()
691            .map_err(|error| {
692                HeddleError::Config(format!(
693                    "failed to inspect upstream branch at '{}': {}",
694                    self.root.display(),
695                    error
696                ))
697            })?;
698
699        if !upstream_output.status.success() {
700            return Ok(None);
701        }
702
703        let upstream = String::from_utf8_lossy(&upstream_output.stdout)
704            .trim()
705            .to_string();
706        if upstream.is_empty() {
707            return Ok(None);
708        }
709
710        let message = match (ahead, behind) {
711            (0, behind) => format!(
712                "Git branch '{}' is behind upstream '{}' by {} commit(s)",
713                branch, upstream, behind
714            ),
715            (ahead, 0) => format!(
716                "Git branch '{}' is ahead of upstream '{}' by {} commit(s)",
717                branch, upstream, ahead
718            ),
719            (ahead, behind) => format!(
720                "Git branch '{}' has diverged from upstream '{}' (ahead {}, behind {})",
721                branch, upstream, ahead, behind
722            ),
723        };
724        let next_action = match (ahead, behind) {
725            (0, _) => "git pull --rebase".to_string(),
726            (_, 0) => "git push".to_string(),
727            _ => "git fetch && git rebase @{upstream}".to_string(),
728        };
729
730        Ok(Some(GitRemoteTrackingStatus {
731            branch,
732            upstream,
733            ahead,
734            behind,
735            message,
736            next_action,
737        }))
738    }
739
740    pub fn git_overlay_import_hint(&self) -> Result<Option<GitOverlayImportHint>> {
741        if self.capability() != RepositoryCapability::GitOverlay {
742            return Ok(None);
743        }
744
745        let current_branch = match self.git_overlay_current_branch()? {
746            Some(branch) => branch,
747            None => return Ok(None),
748        };
749        let branch_tips = self.git_overlay_branch_tips()?;
750        let mut missing_branches = branch_tips
751            .into_iter()
752            .filter(|tip| tip.branch != current_branch && !tip.history_imported)
753            .map(|tip| tip.branch)
754            .collect::<Vec<_>>();
755        missing_branches.sort();
756        missing_branches.dedup();
757
758        if missing_branches.is_empty() {
759            return Ok(None);
760        }
761
762        let recommended_command = if missing_branches.len() == 1 {
763            format!("heddle bridge git import --ref {}", missing_branches[0])
764        } else {
765            "heddle bridge git import".to_string()
766        };
767
768        Ok(Some(GitOverlayImportHint {
769            current_branch,
770            missing_branch_count: missing_branches.len(),
771            missing_branches,
772            recommended_command,
773        }))
774    }
775
776    pub fn git_overlay_branch_tips(&self) -> Result<Vec<GitOverlayBranchTip>> {
777        if self.capability() != RepositoryCapability::GitOverlay {
778            return Ok(Vec::new());
779        }
780
781        let git_repo = gix::discover(&self.root).map_err(|error| {
782            HeddleError::Config(format!(
783                "failed to inspect git branches at '{}': {}",
784                self.root.display(),
785                error
786            ))
787        })?;
788
789        let imported_threads: std::collections::HashSet<String> =
790            self.refs().list_threads()?.into_iter().collect();
791        let bridge_mapping = self.git_overlay_bridge_mapping()?;
792        let mut branch_tips = Vec::new();
793
794        for branch in git_repo
795            .references()
796            .map_err(|error| {
797                HeddleError::Config(format!(
798                    "failed to read git references at '{}': {}",
799                    self.root.display(),
800                    error
801                ))
802            })?
803            .local_branches()
804            .map_err(|error| {
805                HeddleError::Config(format!(
806                    "failed to enumerate git branches at '{}': {}",
807                    self.root.display(),
808                    error
809                ))
810            })?
811        {
812            let mut branch = branch.map_err(|error| {
813                HeddleError::Config(format!(
814                    "failed to inspect git branch at '{}': {}",
815                    self.root.display(),
816                    error
817                ))
818            })?;
819            let name = branch.name().shorten().to_string();
820            let Some(target) =
821                self.git_overlay_commit_tip_oid(&git_repo, &mut branch, "branch", &name)?
822            else {
823                continue;
824            };
825            let history_imported = if imported_threads.contains(&name) {
826                // Read the thread ref once; the mapped + checkpointed
827                // checks each used to re-read it, which doubled the
828                // ref-store hits per branch on a 60+ branch repo.
829                let existing_thread = self.refs().get_thread(&name)?;
830                let mapped = matches!(
831                    (existing_thread.as_ref(), bridge_mapping.get(&target.to_string())),
832                    (Some(existing), Some(mapped_change))
833                        if existing.to_string_full() == *mapped_change
834                );
835                let checkpointed = if mapped {
836                    false
837                } else if let Some(existing) = existing_thread {
838                    self.latest_git_checkpoint_for_change(&existing)?
839                        .is_some_and(|record| record.git_commit == target.to_string())
840                } else {
841                    false
842                };
843                mapped || checkpointed
844            } else {
845                false
846            };
847            branch_tips.push(GitOverlayBranchTip {
848                branch: name,
849                git_commit: target.to_string(),
850                history_imported,
851            });
852        }
853
854        branch_tips.sort_by(|a, b| a.branch.cmp(&b.branch));
855        Ok(branch_tips)
856    }
857
858    pub fn git_overlay_tag_tips(&self) -> Result<Vec<GitOverlayTagTip>> {
859        if self.capability() != RepositoryCapability::GitOverlay {
860            return Ok(Vec::new());
861        }
862
863        let git_repo = gix::discover(&self.root).map_err(|error| {
864            HeddleError::Config(format!(
865                "failed to inspect git tags at '{}': {}",
866                self.root.display(),
867                error
868            ))
869        })?;
870
871        let imported_markers: std::collections::HashSet<String> =
872            self.refs().list_markers()?.into_iter().collect();
873        let bridge_mapping = self.git_overlay_bridge_mapping()?;
874        let mut tag_tips = Vec::new();
875
876        for tag in git_repo
877            .references()
878            .map_err(|error| {
879                HeddleError::Config(format!(
880                    "failed to read git references at '{}': {}",
881                    self.root.display(),
882                    error
883                ))
884            })?
885            .tags()
886            .map_err(|error| {
887                HeddleError::Config(format!(
888                    "failed to enumerate git tags at '{}': {}",
889                    self.root.display(),
890                    error
891                ))
892            })?
893        {
894            let mut tag = tag.map_err(|error| {
895                HeddleError::Config(format!(
896                    "failed to inspect git tag at '{}': {}",
897                    self.root.display(),
898                    error
899                ))
900            })?;
901            let name = tag.name().shorten().to_string();
902            let Some(target) =
903                self.git_overlay_commit_tip_oid(&git_repo, &mut tag, "tag", &name)?
904            else {
905                continue;
906            };
907            let history_imported = if imported_markers.contains(&name) {
908                matches!(
909                    (self.refs().get_marker(&name)?, bridge_mapping.get(&target.to_string())),
910                    (Some(existing), Some(mapped_change))
911                        if existing.to_string_full() == *mapped_change
912                )
913            } else {
914                false
915            };
916            tag_tips.push(GitOverlayTagTip {
917                tag: name,
918                git_commit: target.to_string(),
919                history_imported,
920            });
921        }
922
923        tag_tips.sort_by(|a, b| a.tag.cmp(&b.tag));
924        Ok(tag_tips)
925    }
926
927    pub fn git_overlay_branch_tip(&self, name: &str) -> Result<Option<GitOverlayBranchTip>> {
928        Ok(self
929            .git_overlay_branch_tips()?
930            .into_iter()
931            .find(|tip| tip.branch == name))
932    }
933
934    pub fn git_overlay_tag_tip(&self, name: &str) -> Result<Option<GitOverlayTagTip>> {
935        Ok(self
936            .git_overlay_tag_tips()?
937            .into_iter()
938            .find(|tip| tip.tag == name))
939    }
940
941    pub fn git_overlay_worktree_status(&self) -> Result<Option<WorktreeStatus>> {
942        if self.capability() != RepositoryCapability::GitOverlay {
943            return Ok(None);
944        }
945
946        let output = Command::new("git")
947            .arg("-C")
948            .arg(&self.root)
949            .args(["status", "--porcelain", "--untracked-files=all"])
950            .output()
951            .map_err(|error| {
952                HeddleError::Config(format!(
953                    "failed to inspect git worktree at '{}': {}",
954                    self.root.display(),
955                    error
956                ))
957            })?;
958
959        if !output.status.success() {
960            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
961            return Err(HeddleError::Config(format!(
962                "git status failed at '{}': {}",
963                self.root.display(),
964                stderr
965            )));
966        }
967
968        let stdout = String::from_utf8_lossy(&output.stdout);
969        let mut status = WorktreeStatus::default();
970        for line in stdout.lines() {
971            if line.len() < 3 {
972                continue;
973            }
974            let code = &line[..2];
975            let raw_path = &line[3..];
976            if (code.starts_with('R') || code.ends_with('R'))
977                && let Some((old_path, new_path)) = raw_path.split_once(" -> ")
978            {
979                let old_path = PathBuf::from(old_path);
980                let new_path = PathBuf::from(new_path);
981                if !(old_path == Path::new(".heddle") || old_path.starts_with(".heddle")) {
982                    status.deleted.push(old_path);
983                }
984                if !(new_path == Path::new(".heddle") || new_path.starts_with(".heddle")) {
985                    status.added.push(new_path);
986                }
987                continue;
988            }
989            let path = raw_path
990                .rsplit_once(" -> ")
991                .map(|(_, new_path)| new_path)
992                .unwrap_or(raw_path);
993            let path = PathBuf::from(path);
994            if path == Path::new(".heddle") || path.starts_with(".heddle") {
995                continue;
996            }
997
998            if code == "??" {
999                status.added.push(path);
1000                continue;
1001            }
1002
1003            let chars: Vec<char> = code.chars().collect();
1004            if chars.contains(&'D') {
1005                status.deleted.push(path);
1006            } else if chars.contains(&'A') {
1007                status.added.push(path);
1008            } else {
1009                status.modified.push(path);
1010            }
1011        }
1012
1013        Ok(Some(status))
1014    }
1015
1016    fn git_overlay_bridge_mapping(&self) -> Result<HashMap<String, String>> {
1017        let path = self
1018            .heddle_dir
1019            .join("git-bridge")
1020            .join("bridge-mapping.json");
1021        if !path.exists() {
1022            return Ok(HashMap::new());
1023        }
1024
1025        let contents = fs::read_to_string(path)?;
1026        if contents.trim().is_empty() {
1027            return Ok(HashMap::new());
1028        }
1029
1030        let file: GitBridgeMappingFile = serde_json::from_str(&contents)?;
1031        Ok(file
1032            .entries
1033            .into_iter()
1034            .map(|entry| (entry.git_oid, entry.change_id))
1035            .collect())
1036    }
1037
1038    pub fn git_overlay_current_branch(&self) -> Result<Option<String>> {
1039        if self.capability() != RepositoryCapability::GitOverlay {
1040            return Ok(None);
1041        }
1042
1043        let output = Command::new("git")
1044            .arg("-C")
1045            .arg(&self.root)
1046            .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
1047            .output()
1048            .map_err(|error| {
1049                HeddleError::Config(format!(
1050                    "failed to inspect git HEAD at '{}': {}",
1051                    self.root.display(),
1052                    error
1053                ))
1054            })?;
1055
1056        if output.status.success() {
1057            let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
1058            if branch.is_empty() {
1059                return Ok(None);
1060            }
1061            return Ok(Some(branch));
1062        }
1063
1064        if let Some(branch) = detect_git_in_progress_branch(&self.root)? {
1065            return Ok(Some(branch));
1066        }
1067
1068        Ok(None)
1069    }
1070
1071    fn git_overlay_commit_tip_oid(
1072        &self,
1073        git_repo: &gix::Repository,
1074        reference: &mut gix::Reference,
1075        ref_kind: &str,
1076        ref_name: &str,
1077    ) -> Result<Option<gix::hash::ObjectId>> {
1078        if reference.target().try_id().is_none() {
1079            return Ok(None);
1080        }
1081
1082        let target = match reference.peel_to_id() {
1083            Ok(target) => target.detach(),
1084            Err(_) => return Ok(None),
1085        };
1086        let object = match git_repo.find_object(target) {
1087            Ok(object) => object,
1088            Err(_) => return Ok(None),
1089        };
1090        if object.kind != gix::objs::Kind::Commit {
1091            return Ok(None);
1092        }
1093
1094        let _ = (ref_kind, ref_name);
1095        Ok(Some(target))
1096    }
1097
1098    fn heddle_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1099        if self.merge_state_manager().is_merge_in_progress() {
1100            return Ok(Some(RepositoryOperationStatus {
1101                scope: OperationScope::Heddle,
1102                kind: OperationKind::Merge,
1103                in_progress: true,
1104                state: "in-progress".to_string(),
1105                message: "Heddle merge is in progress".to_string(),
1106                next_action: "heddle continue".to_string(),
1107            }));
1108        }
1109
1110        let rebase_state = self.heddle_dir.join("REBASE_STATE");
1111        if rebase_state.exists() {
1112            return Ok(Some(RepositoryOperationStatus {
1113                scope: OperationScope::Heddle,
1114                kind: OperationKind::Rebase,
1115                in_progress: true,
1116                state: "in-progress".to_string(),
1117                message: "Heddle rebase is in progress".to_string(),
1118                next_action: "heddle continue".to_string(),
1119            }));
1120        }
1121
1122        let bisect_state = self.heddle_dir.join("BISECT_STATE");
1123        if bisect_state.exists() {
1124            return Ok(Some(RepositoryOperationStatus {
1125                scope: OperationScope::Heddle,
1126                kind: OperationKind::Bisect,
1127                in_progress: true,
1128                state: "in-progress".to_string(),
1129                message: "Heddle bisect is in progress".to_string(),
1130                next_action: "heddle bisect good <state> or heddle bisect bad <state>".to_string(),
1131            }));
1132        }
1133
1134        Ok(None)
1135    }
1136
1137    fn git_operation_status(&self) -> Result<Option<RepositoryOperationStatus>> {
1138        if self.capability() != RepositoryCapability::GitOverlay {
1139            return Ok(None);
1140        }
1141
1142        let git_dir = resolve_git_dir(&self.root)?;
1143        let candidates = [
1144            (
1145                git_dir.join("rebase-merge"),
1146                OperationKind::Rebase,
1147                "Git rebase is in progress",
1148                "heddle continue",
1149            ),
1150            (
1151                git_dir.join("rebase-apply"),
1152                OperationKind::Rebase,
1153                "Git rebase is in progress",
1154                "heddle continue",
1155            ),
1156            (
1157                git_dir.join("MERGE_HEAD"),
1158                OperationKind::Merge,
1159                "Git merge is in progress",
1160                "heddle continue",
1161            ),
1162            (
1163                git_dir.join("CHERRY_PICK_HEAD"),
1164                OperationKind::CherryPick,
1165                "Git cherry-pick is in progress",
1166                "heddle continue",
1167            ),
1168            (
1169                git_dir.join("REVERT_HEAD"),
1170                OperationKind::Revert,
1171                "Git revert is in progress",
1172                "heddle continue",
1173            ),
1174            (
1175                git_dir.join("BISECT_LOG"),
1176                OperationKind::Bisect,
1177                "Git bisect is in progress",
1178                "git bisect good or git bisect bad",
1179            ),
1180        ];
1181
1182        for (path, kind, message, next_action) in candidates {
1183            if path.exists() {
1184                return Ok(Some(RepositoryOperationStatus {
1185                    scope: OperationScope::Git,
1186                    kind,
1187                    in_progress: true,
1188                    state: "in-progress".to_string(),
1189                    message: message.to_string(),
1190                    next_action: next_action.to_string(),
1191                }));
1192            }
1193        }
1194
1195        Ok(None)
1196    }
1197
1198    pub fn list_git_checkpoints(&self) -> Result<Vec<GitCheckpointRecord>> {
1199        let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1200        if !path.exists() {
1201            return Ok(Vec::new());
1202        }
1203        let contents = fs::read_to_string(path)?;
1204        if contents.trim().is_empty() {
1205            return Ok(Vec::new());
1206        }
1207        Ok(serde_json::from_str(&contents)?)
1208    }
1209
1210    pub fn latest_git_checkpoint_for_change(
1211        &self,
1212        change_id: &ChangeId,
1213    ) -> Result<Option<GitCheckpointRecord>> {
1214        let full_id = change_id.to_string_full();
1215        Ok(self
1216            .list_git_checkpoints()?
1217            .into_iter()
1218            .rev()
1219            .find(|record| record.change_id == full_id))
1220    }
1221
1222    pub fn record_git_checkpoint(
1223        &self,
1224        change_id: &ChangeId,
1225        git_commit: impl Into<String>,
1226        summary: impl Into<String>,
1227    ) -> Result<GitCheckpointRecord> {
1228        let mut records = self.list_git_checkpoints()?;
1229        let record = GitCheckpointRecord {
1230            change_id: change_id.to_string_full(),
1231            git_commit: git_commit.into(),
1232            summary: summary.into(),
1233            committed_at: Utc::now().to_rfc3339(),
1234        };
1235        let path = self.root.join(".heddle/state").join(GIT_CHECKPOINTS_FILE);
1236        if let Some(parent) = path.parent() {
1237            fs::create_dir_all(parent)?;
1238        }
1239        records.push(record.clone());
1240        write_file_atomic(&path, serde_json::to_string_pretty(&records)?.as_bytes())?;
1241        Ok(record)
1242    }
1243
1244    pub fn store(&self) -> &dyn ObjectStore {
1245        self.store.as_ref()
1246    }
1247
1248    pub fn init_worktree(
1249        path: impl AsRef<Path>,
1250        shared_galeed_dir: impl AsRef<Path>,
1251    ) -> Result<()> {
1252        let path = path.as_ref();
1253        let shared = shared_galeed_dir.as_ref().canonicalize()?;
1254        fs::create_dir_all(path)?;
1255        let heddle_dir = path.join(".heddle");
1256        if heddle_dir.exists() {
1257            return Err(HeddleError::RepositoryExists(path.to_path_buf()));
1258        }
1259        fs::create_dir_all(&heddle_dir)?;
1260        write_file_atomic(
1261            &heddle_dir.join("objectstore"),
1262            format!("objectstore: {}\n", shared.display()).as_bytes(),
1263        )?;
1264        fs::create_dir_all(heddle_dir.join("state"))?;
1265        Ok(())
1266    }
1267
1268    pub fn refs(&self) -> &dyn RefBackend {
1269        &*self.refs
1270    }
1271
1272    pub fn oplog(&self) -> &dyn OpLogBackend {
1273        self.oplog.as_ref()
1274    }
1275
1276    pub fn op_scope(&self) -> String {
1277        // HEAD always lives at <root>/.heddle/HEAD — for both main repos
1278        // (where root/.heddle is the heddle_dir) and worktrees (where
1279        // root/.heddle is the local pointer dir distinct from the shared
1280        // heddle_dir). Using the local-checkout path here keeps op-log
1281        // scoping unique per worktree.
1282        let head_path = self.root.join(".heddle").join("HEAD");
1283        if let Ok(path) = head_path.canonicalize() {
1284            path.display().to_string()
1285        } else {
1286            head_path.display().to_string()
1287        }
1288    }
1289
1290    pub fn repo_config(&self) -> &RepoConfig {
1291        &self.config
1292    }
1293
1294    pub fn config(&self) -> &RepoConfig {
1295        self.repo_config()
1296    }
1297
1298    pub fn get_tree_for_state(&self, state_id: &ChangeId) -> Result<Option<Tree>> {
1299        let state = match self.store.get_state(state_id)? {
1300            Some(state) => state,
1301            None => return Ok(None),
1302        };
1303        self.store.get_tree(&state.tree)
1304    }
1305
1306    pub fn ignore_patterns(&self) -> Result<Vec<String>> {
1307        let mut patterns = self.config.worktree.ignore.clone();
1308        let path = self.root.join(".heddleignore");
1309
1310        if path.exists() {
1311            let contents = std::fs::read_to_string(path)?;
1312            for line in contents.lines() {
1313                let trimmed = line.trim();
1314                if trimmed.is_empty() || trimmed.starts_with('#') {
1315                    continue;
1316                }
1317                if !patterns.iter().any(|pattern| pattern == trimmed) {
1318                    patterns.push(trimmed.to_string());
1319                }
1320            }
1321        }
1322
1323        Ok(patterns)
1324    }
1325
1326    /// Canonical absolute paths of *other* threads' worktrees that are
1327    /// strict descendants of `walk_root`. The walker uses these to
1328    /// avoid scanning a sibling thread's files into the current
1329    /// thread's tree (a common shape when an agent worktree is
1330    /// materialized inside the parent repo, e.g. `--path-prefix
1331    /// ./agents`). Computed once per scan, not once per file.
1332    ///
1333    /// Returns paths that
1334    ///   - are strict descendants of canonical `walk_root`, and
1335    ///   - are NOT equal to `walk_root` itself (each thread can scan
1336    ///     its own worktree without excluding itself).
1337    ///
1338    /// Threads with no recorded worktree, or worktrees that no longer
1339    /// exist on disk, are skipped without error.
1340    pub fn nested_thread_worktree_exclusions(&self, walk_root: &Path) -> Result<Vec<PathBuf>> {
1341        let canonical_walk_root = walk_root
1342            .canonicalize()
1343            .unwrap_or_else(|_| walk_root.to_path_buf());
1344        let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
1345        let mut exclusions: Vec<PathBuf> = Vec::new();
1346        let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1347        for thread in manager.list()? {
1348            for candidate in [
1349                Some(&thread.execution_path),
1350                thread.materialized_path.as_ref(),
1351            ]
1352            .into_iter()
1353            .flatten()
1354            {
1355                if candidate.as_os_str().is_empty() {
1356                    continue;
1357                }
1358                let canonical = match candidate.canonicalize() {
1359                    Ok(path) => path,
1360                    Err(_) => continue,
1361                };
1362                if canonical == canonical_walk_root {
1363                    continue;
1364                }
1365                if !canonical.starts_with(&canonical_walk_root) {
1366                    continue;
1367                }
1368                if seen.insert(canonical.clone()) {
1369                    exclusions.push(canonical);
1370                }
1371            }
1372        }
1373        Ok(exclusions)
1374    }
1375
1376    pub fn head(&self) -> Result<Option<ChangeId>> {
1377        Ok(match self.refs.read_head()? {
1378            Head::Attached { thread } => self.refs.get_thread(&thread)?,
1379            Head::Detached { state } => Some(state),
1380        })
1381    }
1382
1383    pub fn head_ref(&self) -> Result<Head> {
1384        self.refs.read_head()
1385    }
1386
1387    /// Resolve the on-disk worktree path for the *active thread*.
1388    ///
1389    /// This is the canonical "where does the current thread live on disk"
1390    /// lookup. It reads `HEAD`, looks up the attached thread's metadata
1391    /// (via [`crate::ThreadManager`]), and returns the recorded
1392    /// `execution_path` (or `materialized_path` if unset). When no thread
1393    /// has a recorded path — main, threads created without a separate
1394    /// worktree, or `HEAD::Detached` — this falls back to [`Self::root`].
1395    ///
1396    /// Worktree-mutating commands (merge, rebase, goto, ship) should
1397    /// resolve their target via this helper so that
1398    /// `heddle thread switch X && heddle merge Y` lands the merge into
1399    /// thread `X`'s dedicated worktree, not into whichever directory the
1400    /// operator happened to invoke `heddle` from. Snapshot/capture
1401    /// intentionally stay CWD-based: the agent inside their worktree
1402    /// captures *that* worktree.
1403    pub fn active_worktree_path(&self) -> Result<PathBuf> {
1404        let head = self.refs.read_head()?;
1405        let Head::Attached { thread } = head else {
1406            return Ok(self.root.clone());
1407        };
1408        let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
1409        let Some(thread_record) = manager.find_by_thread(&thread)? else {
1410            return Ok(self.root.clone());
1411        };
1412        if !thread_record.execution_path.as_os_str().is_empty() {
1413            return Ok(thread_record.execution_path);
1414        }
1415        if let Some(path) = thread_record.materialized_path {
1416            return Ok(path);
1417        }
1418        Ok(self.root.clone())
1419    }
1420
1421    pub fn current_state(&self) -> Result<Option<State>> {
1422        match self.head()? {
1423            Some(id) => self.store.get_state(&id),
1424            None => Ok(None),
1425        }
1426    }
1427
1428    pub fn get_principal(&self) -> Result<Principal> {
1429        if let Some(principal) = Principal::from_env() {
1430            return Ok(principal);
1431        }
1432
1433        if let Some(config) = &self.config.principal {
1434            return Ok(Principal::new(&config.name, &config.email));
1435        }
1436
1437        Ok(Principal::new("Unknown", "unknown@example.com"))
1438    }
1439
1440    pub fn get_attribution(&self) -> Result<Attribution> {
1441        let principal = self.get_principal()?;
1442
1443        if let Some(agent) = self.resolve_agent() {
1444            Ok(Attribution::with_agent(principal, agent))
1445        } else {
1446            Ok(Attribution::human(principal))
1447        }
1448    }
1449
1450    pub fn is_shallow(&self, id: &ChangeId) -> bool {
1451        self.shallow.read().unwrap().is_shallow(id)
1452    }
1453
1454    pub fn set_shallow(&self, state_id: &ChangeId, _parents: &[ChangeId]) -> Result<()> {
1455        self.shallow.write().unwrap().add_shallow(*state_id)?;
1456        Ok(())
1457    }
1458
1459    pub fn record_missing_blob(&self, hash: ContentHash) -> Result<()> {
1460        self.partial_fetch_metadata().record_missing_blob(hash)?;
1461        Ok(())
1462    }
1463
1464    /// Seed a `main` thread pointing at an empty-tree root state.
1465    ///
1466    /// The seeded state is written to the object store and pointed at by the
1467    /// `main` thread ref, but is deliberately NOT recorded in the oplog: `init`
1468    /// is a point-of-creation event, not user work, and should not be
1469    /// undoable. No-op if `main` already exists.
1470    ///
1471    /// The seed state uses a stable `Heddle <init@heddle>` attribution
1472    /// instead of the user's principal because the user's principal may
1473    /// not yet be configured at init time (e.g. the user writes
1474    /// `.heddle/config.toml` after `heddle init`). Falling back to
1475    /// `Unknown <unknown@example.com>` would surface in `heddle log` as
1476    /// a state owned by no one. The genesis state is also filtered out of
1477    /// user-facing log output (see `repository_history::is_synthetic_root`).
1478    pub fn seed_default_thread(&self) -> Result<()> {
1479        if self.refs.get_thread("main")?.is_some() {
1480            return Ok(());
1481        }
1482
1483        let empty_tree = Tree::new();
1484        let tree_hash = self.store.put_tree(&empty_tree)?;
1485        let state = State::new_snapshot(tree_hash, vec![], Attribution::human(seed_principal()));
1486        self.store.put_state(&state)?;
1487        self.refs.set_thread("main", &state.change_id)?;
1488        Ok(())
1489    }
1490
1491    pub fn clear_missing_blob(&self, hash: &ContentHash) -> Result<()> {
1492        self.partial_fetch_metadata().clear_missing_blob(hash)?;
1493        Ok(())
1494    }
1495
1496    pub fn missing_blobs(&self) -> Result<Vec<ContentHash>> {
1497        self.partial_fetch_metadata().missing_blobs()
1498    }
1499
1500    pub fn clear_all_missing_blobs(&self) -> Result<bool> {
1501        self.partial_fetch_metadata().clear_all_missing_blobs()
1502    }
1503
1504    pub fn is_missing_blob(&self, hash: &ContentHash) -> Result<bool> {
1505        self.partial_fetch_metadata().is_missing_blob(hash)
1506    }
1507
1508    pub fn require_blob(&self, hash: &ContentHash) -> Result<objects::object::Blob> {
1509        if let Some(blob) = self.store.get_blob(hash)? {
1510            if self.is_missing_blob(hash)? {
1511                self.clear_missing_blob(hash)?;
1512            }
1513            return Ok(blob);
1514        }
1515
1516        if self.is_missing_blob(hash)? {
1517            return Err(HeddleError::MissingObject {
1518                object_type: "blob".to_string(),
1519                id: hash.to_hex(),
1520            });
1521        }
1522
1523        Err(HeddleError::NotFound(hash.to_hex()))
1524    }
1525
1526    fn partial_fetch_metadata(&self) -> repository_partial_fetch::PartialFetchMetadataManager {
1527        repository_partial_fetch::PartialFetchMetadataManager::new(&self.heddle_dir)
1528    }
1529
1530    pub fn shallow(&self) -> std::sync::RwLockReadGuard<'_, ShallowInfo> {
1531        self.shallow.read().unwrap()
1532    }
1533}
1534
1535fn ensure_git_overlay_exclude(root: &Path) -> Result<()> {
1536    let git_dir = root.join(".git");
1537    if !git_dir.is_dir() {
1538        return Ok(());
1539    }
1540
1541    let info_dir = git_dir.join("info");
1542    fs::create_dir_all(&info_dir)?;
1543    let exclude_path = info_dir.join("exclude");
1544    let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
1545    let already_has_rule = existing
1546        .lines()
1547        .map(str::trim)
1548        .any(|line| line == ".heddle/" || line == "/.heddle/" || line == ".heddle");
1549    if already_has_rule {
1550        return Ok(());
1551    }
1552
1553    let mut file = fs::OpenOptions::new()
1554        .create(true)
1555        .append(true)
1556        .open(&exclude_path)?;
1557    if !existing.is_empty() && !existing.ends_with('\n') {
1558        writeln!(file)?;
1559    }
1560    writeln!(file, ".heddle/")?;
1561    Ok(())
1562}
1563
1564/// Stable system principal stamped into the synthetic seed state created
1565/// at `heddle init` time, before any user principal is known. Kept
1566/// distinct from the `Unknown <unknown@example.com>` fallback so the
1567/// genesis state is never confused with an unattributed user state.
1568pub(crate) fn seed_principal() -> Principal {
1569    Principal::new("Heddle", "init@heddle")
1570}
1571
1572/// True if `state` is the synthetic empty-tree genesis stamped by
1573/// [`Repository::seed_default_thread`]. These states are filtered from
1574/// user-facing log walks: they have no parents, no intent, and the
1575/// system seed principal — they represent pre-history, not user work.
1576pub fn is_synthetic_root(state: &State) -> bool {
1577    state.parents.is_empty()
1578        && state.intent.is_none()
1579        && state.attribution.principal == seed_principal()
1580        && state.attribution.agent.is_none()
1581}
1582
1583/// Parse a `.heddle` pointer file and return the shared object store path.
1584///
1585/// The file must contain a line of the form `objectstore: <path>`.
1586fn parse_objectstore_pointer(content: &str) -> Option<PathBuf> {
1587    for line in content.lines() {
1588        if let Some(path) = line.strip_prefix("objectstore:") {
1589            let path = path.trim();
1590            if !path.is_empty() {
1591                return Some(PathBuf::from(path));
1592            }
1593        }
1594    }
1595    None
1596}
1597
1598fn has_git_metadata(path: &Path) -> bool {
1599    let dot_git = path.join(".git");
1600    dot_git.is_dir() || dot_git.is_file()
1601}
1602
1603/// Read git's HEAD ref via `gix::discover` (~25ms — full repository
1604/// inspection). Used as a fallback when the fast path can't parse the
1605/// raw `.git/HEAD` file (e.g. detached HEAD, multi-worktree layouts).
1606fn detect_git_head_via_gix(path: &Path) -> Result<Option<Head>> {
1607    let repo = gix::discover(path).map_err(|error| {
1608        HeddleError::Config(format!(
1609            "failed to inspect git repository at '{}': {}",
1610            path.display(),
1611            error
1612        ))
1613    })?;
1614    let head = match repo.head() {
1615        Ok(head) => head,
1616        Err(_) => return Ok(None),
1617    };
1618
1619    Ok(head.referent_name().map(|name| Head::Attached {
1620        thread: name.shorten().to_string(),
1621    }))
1622}
1623
1624/// Detect git's current HEAD branch.
1625///
1626/// The fast path reads `.git/HEAD` directly as text. `.git/HEAD` is a
1627/// tiny file (~30 bytes for `ref: refs/heads/<name>\n`) and a direct
1628/// read is ~50us vs. `gix::discover()`'s ~25ms full repository
1629/// inspection. Falls back to gix only for the cases the text parser
1630/// can't handle: detached HEAD, multi-worktree `gitdir:` indirections,
1631/// and any malformed file (where we'd rather surface the right error
1632/// than guess).
1633fn detect_git_head(path: &Path) -> Result<Option<Head>> {
1634    if let Some(head) = detect_git_head_fast(path) {
1635        return Ok(Some(head));
1636    }
1637    detect_git_head_via_gix(path)
1638}
1639
1640/// Fast path for `.git/HEAD` parsing. Returns `Some(Head::Attached)`
1641/// when `.git/HEAD` is the simple `ref: refs/heads/<name>` form;
1642/// returns `None` for any case we don't trust ourselves to parse
1643/// correctly (detached HEAD raw OIDs, `gitdir:` worktree pointers,
1644/// missing files), letting the gix fallback handle it.
1645fn detect_git_head_fast(path: &Path) -> Option<Head> {
1646    let head_path = path.join(".git").join("HEAD");
1647    // `.git` may also be a *file* (the gitdir: pointer used by
1648    // worktrees and submodules) — don't try to read it as a directory.
1649    if !head_path.is_file() {
1650        return None;
1651    }
1652    let content = std::fs::read_to_string(&head_path).ok()?;
1653    let trimmed = content.trim();
1654    let suffix = trimmed.strip_prefix("ref: ")?;
1655    let name = suffix.strip_prefix("refs/heads/")?.to_string();
1656    if name.is_empty() {
1657        return None;
1658    }
1659    Some(Head::Attached { thread: name })
1660}
1661
1662fn resolve_git_dir(path: &Path) -> Result<PathBuf> {
1663    let repo = gix::discover(path).map_err(|error| {
1664        HeddleError::Config(format!(
1665            "failed to resolve git dir at '{}': {}",
1666            path.display(),
1667            error
1668        ))
1669    })?;
1670    Ok(repo.git_dir().to_path_buf())
1671}
1672
1673fn detect_git_in_progress_branch(path: &Path) -> Result<Option<String>> {
1674    let git_dir = resolve_git_dir(path)?;
1675    for marker in ["rebase-merge/head-name", "rebase-apply/head-name"] {
1676        let branch_path = git_dir.join(marker);
1677        if !branch_path.exists() {
1678            continue;
1679        }
1680        let raw = fs::read_to_string(&branch_path)?;
1681        let value = raw.trim();
1682        if let Some(short) = value.strip_prefix("refs/heads/") {
1683            return Ok(Some(short.to_string()));
1684        }
1685        if !value.is_empty() {
1686            return Ok(Some(value.to_string()));
1687        }
1688    }
1689    Ok(None)
1690}
1691
1692fn discover_git_root(path: &Path) -> Option<PathBuf> {
1693    let start = path.canonicalize().ok()?;
1694    let mut current = Some(start.as_path());
1695    while let Some(dir) = current {
1696        if has_git_metadata(dir) {
1697            return Some(dir.to_path_buf());
1698        }
1699        current = dir.parent();
1700    }
1701    None
1702}