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        // The local HEAD pointer (`<root>/.heddle/HEAD`) is unique per
1278        // worktree even when several worktrees share one oplog backend
1279        // (via `.heddle/objectstore`). `undo`/`redo`/`--list` filter by
1280        // exact-match scope, so the scope must distinguish each
1281        // worktree's local HEAD pointer dir.
1282        //
1283        // Use a content-derived digest of the canonical pointer path:
1284        //   * stable across heddle invocations from the same checkout
1285        //   * unique per worktree (different absolute paths digest
1286        //     differently), so worktree-local undo keeps working in
1287        //     shared-oplog setups
1288        //   * opaque on disk — the user's home directory and username
1289        //     never end up serialized into oplog entries
1290        let local_head = self.root.join(".heddle").join("HEAD");
1291        let canonical = local_head.canonicalize().unwrap_or(local_head);
1292        let digest = blake3::hash(canonical.to_string_lossy().as_bytes());
1293        format!("wt-{}", &digest.to_hex().as_str()[..16])
1294    }
1295
1296    pub fn repo_config(&self) -> &RepoConfig {
1297        &self.config
1298    }
1299
1300    pub fn config(&self) -> &RepoConfig {
1301        self.repo_config()
1302    }
1303
1304    pub fn get_tree_for_state(&self, state_id: &ChangeId) -> Result<Option<Tree>> {
1305        let state = match self.store.get_state(state_id)? {
1306            Some(state) => state,
1307            None => return Ok(None),
1308        };
1309        self.store.get_tree(&state.tree)
1310    }
1311
1312    pub fn ignore_patterns(&self) -> Result<Vec<String>> {
1313        let mut patterns = self.config.worktree.ignore.clone();
1314        let path = self.root.join(".heddleignore");
1315
1316        if path.exists() {
1317            let contents = std::fs::read_to_string(path)?;
1318            for line in contents.lines() {
1319                let trimmed = line.trim();
1320                if trimmed.is_empty() || trimmed.starts_with('#') {
1321                    continue;
1322                }
1323                if !patterns.iter().any(|pattern| pattern == trimmed) {
1324                    patterns.push(trimmed.to_string());
1325                }
1326            }
1327        }
1328
1329        Ok(patterns)
1330    }
1331
1332    /// Canonical absolute paths of *other* threads' worktrees that are
1333    /// strict descendants of `walk_root`. The walker uses these to
1334    /// avoid scanning a sibling thread's files into the current
1335    /// thread's tree (a common shape when an agent worktree is
1336    /// materialized inside the parent repo, e.g. `--path-prefix
1337    /// ./agents`). Computed once per scan, not once per file.
1338    ///
1339    /// Returns paths that
1340    ///   - are strict descendants of canonical `walk_root`, and
1341    ///   - are NOT equal to `walk_root` itself (each thread can scan
1342    ///     its own worktree without excluding itself).
1343    ///
1344    /// Threads with no recorded worktree, or worktrees that no longer
1345    /// exist on disk, are skipped without error.
1346    pub fn nested_thread_worktree_exclusions(&self, walk_root: &Path) -> Result<Vec<PathBuf>> {
1347        let canonical_walk_root = walk_root
1348            .canonicalize()
1349            .unwrap_or_else(|_| walk_root.to_path_buf());
1350        let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
1351        let mut exclusions: Vec<PathBuf> = Vec::new();
1352        let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1353        for thread in manager.list()? {
1354            for candidate in [
1355                Some(&thread.execution_path),
1356                thread.materialized_path.as_ref(),
1357            ]
1358            .into_iter()
1359            .flatten()
1360            {
1361                if candidate.as_os_str().is_empty() {
1362                    continue;
1363                }
1364                let canonical = match candidate.canonicalize() {
1365                    Ok(path) => path,
1366                    Err(_) => continue,
1367                };
1368                if canonical == canonical_walk_root {
1369                    continue;
1370                }
1371                if !canonical.starts_with(&canonical_walk_root) {
1372                    continue;
1373                }
1374                if seen.insert(canonical.clone()) {
1375                    exclusions.push(canonical);
1376                }
1377            }
1378        }
1379        Ok(exclusions)
1380    }
1381
1382    pub fn head(&self) -> Result<Option<ChangeId>> {
1383        Ok(match self.refs.read_head()? {
1384            Head::Attached { thread } => self.refs.get_thread(&thread)?,
1385            Head::Detached { state } => Some(state),
1386        })
1387    }
1388
1389    pub fn head_ref(&self) -> Result<Head> {
1390        self.refs.read_head()
1391    }
1392
1393    /// Resolve the on-disk worktree path for the *active thread*.
1394    ///
1395    /// This is the canonical "where does the current thread live on disk"
1396    /// lookup. It reads `HEAD`, looks up the attached thread's metadata
1397    /// (via [`crate::ThreadManager`]), and returns the recorded
1398    /// `execution_path` (or `materialized_path` if unset). When no thread
1399    /// has a recorded path — main, threads created without a separate
1400    /// worktree, or `HEAD::Detached` — this falls back to [`Self::root`].
1401    ///
1402    /// Worktree-mutating commands (merge, rebase, goto, ship) should
1403    /// resolve their target via this helper so that
1404    /// `heddle thread switch X && heddle merge Y` lands the merge into
1405    /// thread `X`'s dedicated worktree, not into whichever directory the
1406    /// operator happened to invoke `heddle` from. Snapshot/capture
1407    /// intentionally stay CWD-based: the agent inside their worktree
1408    /// captures *that* worktree.
1409    pub fn active_worktree_path(&self) -> Result<PathBuf> {
1410        let head = self.refs.read_head()?;
1411        let Head::Attached { thread } = head else {
1412            return Ok(self.root.clone());
1413        };
1414        let manager = crate::thread_storage::ThreadManager::new(self.heddle_dir());
1415        let Some(thread_record) = manager.find_by_thread(&thread)? else {
1416            return Ok(self.root.clone());
1417        };
1418        if !thread_record.execution_path.as_os_str().is_empty() {
1419            return Ok(thread_record.execution_path);
1420        }
1421        if let Some(path) = thread_record.materialized_path {
1422            return Ok(path);
1423        }
1424        Ok(self.root.clone())
1425    }
1426
1427    pub fn current_state(&self) -> Result<Option<State>> {
1428        match self.head()? {
1429            Some(id) => self.store.get_state(&id),
1430            None => Ok(None),
1431        }
1432    }
1433
1434    pub fn get_principal(&self) -> Result<Principal> {
1435        if let Some(principal) = Principal::from_env() {
1436            return Ok(principal);
1437        }
1438
1439        if let Some(config) = &self.config.principal {
1440            return Ok(Principal::new(&config.name, &config.email));
1441        }
1442
1443        Ok(Principal::new("Unknown", "unknown@example.com"))
1444    }
1445
1446    pub fn get_attribution(&self) -> Result<Attribution> {
1447        let principal = self.get_principal()?;
1448
1449        if let Some(agent) = self.resolve_agent() {
1450            Ok(Attribution::with_agent(principal, agent))
1451        } else {
1452            Ok(Attribution::human(principal))
1453        }
1454    }
1455
1456    pub fn is_shallow(&self, id: &ChangeId) -> bool {
1457        self.shallow.read().unwrap().is_shallow(id)
1458    }
1459
1460    pub fn set_shallow(&self, state_id: &ChangeId, _parents: &[ChangeId]) -> Result<()> {
1461        self.shallow.write().unwrap().add_shallow(*state_id)?;
1462        Ok(())
1463    }
1464
1465    pub fn record_missing_blob(&self, hash: ContentHash) -> Result<()> {
1466        self.partial_fetch_metadata().record_missing_blob(hash)?;
1467        Ok(())
1468    }
1469
1470    /// Seed a `main` thread pointing at an empty-tree root state.
1471    ///
1472    /// The seeded state is written to the object store and pointed at by the
1473    /// `main` thread ref, but is deliberately NOT recorded in the oplog: `init`
1474    /// is a point-of-creation event, not user work, and should not be
1475    /// undoable. No-op if `main` already exists.
1476    ///
1477    /// The seed state uses a stable `Heddle <init@heddle>` attribution
1478    /// instead of the user's principal because the user's principal may
1479    /// not yet be configured at init time (e.g. the user writes
1480    /// `.heddle/config.toml` after `heddle init`). Falling back to
1481    /// `Unknown <unknown@example.com>` would surface in `heddle log` as
1482    /// a state owned by no one. The genesis state is also filtered out of
1483    /// user-facing log output (see `repository_history::is_synthetic_root`).
1484    pub fn seed_default_thread(&self) -> Result<()> {
1485        if self.refs.get_thread("main")?.is_some() {
1486            return Ok(());
1487        }
1488
1489        let empty_tree = Tree::new();
1490        let tree_hash = self.store.put_tree(&empty_tree)?;
1491        let state = State::new_snapshot(tree_hash, vec![], Attribution::human(seed_principal()));
1492        self.store.put_state(&state)?;
1493        self.refs.set_thread("main", &state.change_id)?;
1494        Ok(())
1495    }
1496
1497    pub fn clear_missing_blob(&self, hash: &ContentHash) -> Result<()> {
1498        self.partial_fetch_metadata().clear_missing_blob(hash)?;
1499        Ok(())
1500    }
1501
1502    pub fn missing_blobs(&self) -> Result<Vec<ContentHash>> {
1503        self.partial_fetch_metadata().missing_blobs()
1504    }
1505
1506    pub fn clear_all_missing_blobs(&self) -> Result<bool> {
1507        self.partial_fetch_metadata().clear_all_missing_blobs()
1508    }
1509
1510    pub fn is_missing_blob(&self, hash: &ContentHash) -> Result<bool> {
1511        self.partial_fetch_metadata().is_missing_blob(hash)
1512    }
1513
1514    pub fn require_blob(&self, hash: &ContentHash) -> Result<objects::object::Blob> {
1515        if let Some(blob) = self.store.get_blob(hash)? {
1516            if self.is_missing_blob(hash)? {
1517                self.clear_missing_blob(hash)?;
1518            }
1519            return Ok(blob);
1520        }
1521
1522        if self.is_missing_blob(hash)? {
1523            return Err(HeddleError::MissingObject {
1524                object_type: "blob".to_string(),
1525                id: hash.to_hex(),
1526            });
1527        }
1528
1529        Err(HeddleError::NotFound(hash.to_hex()))
1530    }
1531
1532    fn partial_fetch_metadata(&self) -> repository_partial_fetch::PartialFetchMetadataManager {
1533        repository_partial_fetch::PartialFetchMetadataManager::new(&self.heddle_dir)
1534    }
1535
1536    pub fn shallow(&self) -> std::sync::RwLockReadGuard<'_, ShallowInfo> {
1537        self.shallow.read().unwrap()
1538    }
1539}
1540
1541fn ensure_git_overlay_exclude(root: &Path) -> Result<()> {
1542    let git_dir = root.join(".git");
1543    if !git_dir.is_dir() {
1544        return Ok(());
1545    }
1546
1547    let info_dir = git_dir.join("info");
1548    fs::create_dir_all(&info_dir)?;
1549    let exclude_path = info_dir.join("exclude");
1550    let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
1551    let already_has_rule = existing
1552        .lines()
1553        .map(str::trim)
1554        .any(|line| line == ".heddle/" || line == "/.heddle/" || line == ".heddle");
1555    if already_has_rule {
1556        return Ok(());
1557    }
1558
1559    let mut file = fs::OpenOptions::new()
1560        .create(true)
1561        .append(true)
1562        .open(&exclude_path)?;
1563    if !existing.is_empty() && !existing.ends_with('\n') {
1564        writeln!(file)?;
1565    }
1566    writeln!(file, ".heddle/")?;
1567    Ok(())
1568}
1569
1570/// Stable system principal stamped into the synthetic seed state created
1571/// at `heddle init` time, before any user principal is known. Kept
1572/// distinct from the `Unknown <unknown@example.com>` fallback so the
1573/// genesis state is never confused with an unattributed user state.
1574pub(crate) fn seed_principal() -> Principal {
1575    Principal::new("Heddle", "init@heddle")
1576}
1577
1578/// True if `state` is the synthetic empty-tree genesis stamped by
1579/// [`Repository::seed_default_thread`]. These states are filtered from
1580/// user-facing log walks: they have no parents, no intent, and the
1581/// system seed principal — they represent pre-history, not user work.
1582pub fn is_synthetic_root(state: &State) -> bool {
1583    state.parents.is_empty()
1584        && state.intent.is_none()
1585        && state.attribution.principal == seed_principal()
1586        && state.attribution.agent.is_none()
1587}
1588
1589/// Parse a `.heddle` pointer file and return the shared object store path.
1590///
1591/// The file must contain a line of the form `objectstore: <path>`.
1592fn parse_objectstore_pointer(content: &str) -> Option<PathBuf> {
1593    for line in content.lines() {
1594        if let Some(path) = line.strip_prefix("objectstore:") {
1595            let path = path.trim();
1596            if !path.is_empty() {
1597                return Some(PathBuf::from(path));
1598            }
1599        }
1600    }
1601    None
1602}
1603
1604fn has_git_metadata(path: &Path) -> bool {
1605    let dot_git = path.join(".git");
1606    dot_git.is_dir() || dot_git.is_file()
1607}
1608
1609/// Read git's HEAD ref via `gix::discover` (~25ms — full repository
1610/// inspection). Used as a fallback when the fast path can't parse the
1611/// raw `.git/HEAD` file (e.g. detached HEAD, multi-worktree layouts).
1612fn detect_git_head_via_gix(path: &Path) -> Result<Option<Head>> {
1613    let repo = gix::discover(path).map_err(|error| {
1614        HeddleError::Config(format!(
1615            "failed to inspect git repository at '{}': {}",
1616            path.display(),
1617            error
1618        ))
1619    })?;
1620    let head = match repo.head() {
1621        Ok(head) => head,
1622        Err(_) => return Ok(None),
1623    };
1624
1625    Ok(head.referent_name().map(|name| Head::Attached {
1626        thread: name.shorten().to_string(),
1627    }))
1628}
1629
1630/// Detect git's current HEAD branch.
1631///
1632/// The fast path reads `.git/HEAD` directly as text. `.git/HEAD` is a
1633/// tiny file (~30 bytes for `ref: refs/heads/<name>\n`) and a direct
1634/// read is ~50us vs. `gix::discover()`'s ~25ms full repository
1635/// inspection. Falls back to gix only for the cases the text parser
1636/// can't handle: detached HEAD, multi-worktree `gitdir:` indirections,
1637/// and any malformed file (where we'd rather surface the right error
1638/// than guess).
1639fn detect_git_head(path: &Path) -> Result<Option<Head>> {
1640    if let Some(head) = detect_git_head_fast(path) {
1641        return Ok(Some(head));
1642    }
1643    detect_git_head_via_gix(path)
1644}
1645
1646/// Fast path for `.git/HEAD` parsing. Returns `Some(Head::Attached)`
1647/// when `.git/HEAD` is the simple `ref: refs/heads/<name>` form;
1648/// returns `None` for any case we don't trust ourselves to parse
1649/// correctly (detached HEAD raw OIDs, `gitdir:` worktree pointers,
1650/// missing files), letting the gix fallback handle it.
1651fn detect_git_head_fast(path: &Path) -> Option<Head> {
1652    let head_path = path.join(".git").join("HEAD");
1653    // `.git` may also be a *file* (the gitdir: pointer used by
1654    // worktrees and submodules) — don't try to read it as a directory.
1655    if !head_path.is_file() {
1656        return None;
1657    }
1658    let content = std::fs::read_to_string(&head_path).ok()?;
1659    let trimmed = content.trim();
1660    let suffix = trimmed.strip_prefix("ref: ")?;
1661    let name = suffix.strip_prefix("refs/heads/")?.to_string();
1662    if name.is_empty() {
1663        return None;
1664    }
1665    Some(Head::Attached { thread: name })
1666}
1667
1668fn resolve_git_dir(path: &Path) -> Result<PathBuf> {
1669    let repo = gix::discover(path).map_err(|error| {
1670        HeddleError::Config(format!(
1671            "failed to resolve git dir at '{}': {}",
1672            path.display(),
1673            error
1674        ))
1675    })?;
1676    Ok(repo.git_dir().to_path_buf())
1677}
1678
1679fn detect_git_in_progress_branch(path: &Path) -> Result<Option<String>> {
1680    let git_dir = resolve_git_dir(path)?;
1681    for marker in ["rebase-merge/head-name", "rebase-apply/head-name"] {
1682        let branch_path = git_dir.join(marker);
1683        if !branch_path.exists() {
1684            continue;
1685        }
1686        let raw = fs::read_to_string(&branch_path)?;
1687        let value = raw.trim();
1688        if let Some(short) = value.strip_prefix("refs/heads/") {
1689            return Ok(Some(short.to_string()));
1690        }
1691        if !value.is_empty() {
1692            return Ok(Some(value.to_string()));
1693        }
1694    }
1695    Ok(None)
1696}
1697
1698fn discover_git_root(path: &Path) -> Option<PathBuf> {
1699    let start = path.canonicalize().ok()?;
1700    let mut current = Some(start.as_path());
1701    while let Some(dir) = current {
1702        if has_git_metadata(dir) {
1703            return Some(dir.to_path_buf());
1704        }
1705        current = dir.parent();
1706    }
1707    None
1708}