1#[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, RedactConfig, RepoConfig, TrustedKey};
79#[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
207pub 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 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 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 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; Ok(Box::new(FsStore::new(heddle_dir)))
304 }
305
306 #[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 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 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 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 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 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 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 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 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 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 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 }
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 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 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 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 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 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
1570pub(crate) fn seed_principal() -> Principal {
1575 Principal::new("Heddle", "init@heddle")
1576}
1577
1578pub 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
1589fn 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
1609fn 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
1630fn 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
1646fn detect_git_head_fast(path: &Path) -> Option<Head> {
1652 let head_path = path.join(".git").join("HEAD");
1653 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}