1use std::time::Instant;
10
11use anyhow::{Context, Result, anyhow};
12use chrono::Utc;
13use heddle_git_projection::{GitProjection, WriteThroughOutcome};
14use objects::{
15 HeddleError, RecoveryDetails,
16 lock::RepositoryLockExt,
17 object::{Agent, Attribution, ContentHash, Principal, State, StateId, ThreadName, Tree},
18 store::ObjectStore,
19 worktree::WorktreeStatus,
20};
21use oplog::{OpLogBackend, OpRecord};
22use refs::Head;
23use repo::{
24 ActorPresenceStore, CommitGraphIndex, GitCheckpointRecord, Hook, HookContext, HookManager,
25 OperationScope, Repository, RepositoryCapability, SnapshotProfile, Thread, ThreadFreshness,
26 ThreadIntegrationPolicy, ThreadManager, ThreadMode, ThreadState, WorktreeStateLookupProfile,
27 WorktreeStatusOptions, refresh_active_thread_metadata, update_thread_state_from_state,
28};
29use schemars::JsonSchema;
30use serde::Serialize;
31use sley::Repository as SleyRepository;
32
33use crate::{
34 ActionTemplate, ExecutionContext, HeddleReport, MachineContractInput, MachineOutputKind,
35 OutputDiscriminator, ReportContract, RepositoryVerificationState,
36 build_repository_verification_health_with_worktree_status, build_repository_verification_state,
37 build_repository_verification_state_with_machine_contract,
38 build_repository_verification_state_with_worktree_status,
39 build_repository_verification_state_with_worktree_status_and_machine_contract,
40 schema_for_report,
41 status::next_action::{contextual_thread_action, import_guidance_includes_active_branch},
42 verify::action_template,
43};
44
45const BULK_CAPTURE_WARNING_THRESHOLD: usize = 500;
46
47#[derive(Debug)]
54pub struct CaptureOptions {
55 pub intent: String,
56 pub confidence: Option<f32>,
57 pub force: bool,
58 pub worktree_status_options: WorktreeStatusOptions,
59 pub machine_contract_input: Option<MachineContractInput>,
60}
61
62#[derive(Debug, Clone)]
64pub struct CaptureAttribution {
65 pub attribution: Attribution,
66 pub principal_source: String,
67 pub harness_session_id: Option<String>,
70}
71
72#[derive(Debug, Clone, Copy, Default)]
74pub struct CaptureProfile {
75 pub worktree_status_ms: u128,
76 pub preflight_ms: u128,
77 pub attribution_ms: u128,
78 pub execute_save_ms: u128,
79}
80
81#[derive(Debug, Clone, Serialize, JsonSchema)]
86pub struct CaptureReport {
87 pub output_kind: &'static str,
88 pub state_id: String,
89 pub content_hash: String,
90 pub intent: Option<String>,
91 pub confidence: Option<f32>,
92 pub task_assignment_id: Option<String>,
93 pub principal: CapturePrincipalReport,
94 pub principal_source: String,
95 pub agent: Option<CaptureAgentReport>,
96 pub promotion_suggested: bool,
97 pub heavy_impact_paths: Vec<String>,
98 pub captured_path_count: usize,
99 pub warnings: Vec<String>,
100 pub signed: bool,
101 pub message: String,
102 pub recommended_action: Option<String>,
103 pub recommended_action_template: Option<ActionTemplate>,
104 pub verification: RepositoryVerificationState,
105 #[serde(skip)]
106 #[schemars(skip)]
107 pub captured_thread_targets_integration: bool,
108 #[serde(skip)]
109 #[schemars(skip)]
110 pub diagnostics: CaptureDiagnostics,
111}
112
113impl CaptureReport {
114 pub const CONTRACT: ReportContract = ReportContract {
115 schema_name: "capture",
116 machine_output_kind: MachineOutputKind::Json,
117 output_discriminator: Some(OutputDiscriminator {
118 field: "output_kind",
119 value: "capture",
120 }),
121 schema: schema_for_report::<CaptureReport>,
122 };
123}
124
125impl HeddleReport for CaptureReport {
126 const CONTRACT: ReportContract = CaptureReport::CONTRACT;
127}
128
129#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
130pub struct CapturePrincipalReport {
131 pub name: String,
132 pub email: String,
133}
134
135#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
136pub struct CaptureAgentReport {
137 pub provider: String,
138 pub model: String,
139 pub session_id: Option<String>,
140 pub segment_id: Option<String>,
141 pub policy_id: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub thought_level: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub parent: Option<String>,
146}
147
148#[derive(Debug, Clone)]
149pub struct CaptureDiagnostics {
150 pub save: SaveReport,
151 pub profile: CaptureProfile,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
156#[serde(rename_all = "snake_case")]
157pub enum GitScope {
158 None,
160 Staged,
162 WorktreeAll,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "snake_case")]
169pub enum SaveVerb {
170 Capture,
171 Commit,
172 Checkpoint,
173}
174
175#[derive(Debug)]
178pub struct SavePlan {
179 pub verb: SaveVerb,
180 pub intent: Option<String>,
181 pub confidence: Option<f32>,
182 pub attribution: Attribution,
183 pub git_scope: GitScope,
184 pub supplied_tree: Option<Tree>,
187 pub reuse_current_state: bool,
189 pub require_clean_worktree: bool,
191 pub require_worktree_change: bool,
195 pub worktree_status_options: WorktreeStatusOptions,
196 pub known_worktree_changes: Option<WorktreeStatus>,
200 pub run_hooks: bool,
202 pub commit_safe_post_verify: bool,
204 pub coalesce_snapshot_and_checkpoint: bool,
206 pub linearize_git_parent: bool,
209 pub precomputed_worktree_status:
212 Option<repo::Result<Option<objects::worktree::WorktreeStatus>>>,
213 pub machine_contract_input: Option<MachineContractInput>,
217}
218
219impl SavePlan {
220 pub fn capture(intent: impl Into<String>, attribution: Attribution) -> Self {
221 Self {
222 verb: SaveVerb::Capture,
223 intent: Some(intent.into()),
224 confidence: None,
225 attribution,
226 git_scope: GitScope::None,
227 supplied_tree: None,
228 reuse_current_state: false,
229 require_clean_worktree: false,
230 require_worktree_change: false,
231 worktree_status_options: WorktreeStatusOptions::default(),
232 known_worktree_changes: None,
233 run_hooks: true,
234 commit_safe_post_verify: false,
235 coalesce_snapshot_and_checkpoint: false,
236 linearize_git_parent: false,
237 precomputed_worktree_status: None,
238 machine_contract_input: None,
239 }
240 }
241
242 pub fn commit(
243 intent: impl Into<String>,
244 attribution: Attribution,
245 git_scope: GitScope,
246 ) -> Self {
247 Self {
248 verb: SaveVerb::Commit,
249 intent: Some(intent.into()),
250 confidence: None,
251 attribution,
252 git_scope,
253 supplied_tree: None,
254 reuse_current_state: false,
255 require_clean_worktree: matches!(git_scope, GitScope::WorktreeAll),
256 require_worktree_change: false,
257 worktree_status_options: WorktreeStatusOptions::default(),
258 known_worktree_changes: None,
259 run_hooks: true,
260 commit_safe_post_verify: true,
261 coalesce_snapshot_and_checkpoint: matches!(
262 git_scope,
263 GitScope::Staged | GitScope::WorktreeAll
264 ),
265 linearize_git_parent: false,
266 precomputed_worktree_status: None,
267 machine_contract_input: None,
268 }
269 }
270
271 pub fn checkpoint(message: Option<String>, attribution: Attribution, staged: bool) -> Self {
272 Self {
273 verb: SaveVerb::Checkpoint,
274 intent: message,
275 confidence: None,
276 attribution,
277 git_scope: if staged {
278 GitScope::Staged
279 } else {
280 GitScope::WorktreeAll
281 },
282 supplied_tree: None,
283 reuse_current_state: true,
284 require_clean_worktree: !staged,
285 require_worktree_change: false,
286 worktree_status_options: WorktreeStatusOptions::default(),
287 known_worktree_changes: None,
288 run_hooks: true,
289 commit_safe_post_verify: false,
290 coalesce_snapshot_and_checkpoint: false,
291 linearize_git_parent: false,
292 precomputed_worktree_status: None,
293 machine_contract_input: None,
294 }
295 }
296
297 pub fn with_confidence(mut self, confidence: Option<f32>) -> Self {
298 self.confidence = confidence;
299 self
300 }
301
302 pub fn with_supplied_tree(mut self, tree: Tree) -> Self {
303 self.supplied_tree = Some(tree);
304 self
305 }
306
307 pub fn with_worktree_status_options(mut self, options: WorktreeStatusOptions) -> Self {
308 self.worktree_status_options = options;
309 self
310 }
311
312 pub fn with_precomputed_worktree_status(
313 mut self,
314 status: repo::Result<Option<objects::worktree::WorktreeStatus>>,
315 ) -> Self {
316 self.precomputed_worktree_status = Some(status);
317 self
318 }
319}
320
321#[derive(Debug, Clone)]
323pub struct SaveReport {
324 pub verb: SaveVerb,
325 pub state_id: StateId,
326 pub content_hash: ContentHash,
327 pub intent: Option<String>,
328 pub confidence: Option<f32>,
329 pub signed: bool,
330 pub git_commit: Option<String>,
331 pub git_previous_commit: Option<String>,
332 pub summary: String,
333 pub principal: Principal,
334 pub agent: Option<Agent>,
335 pub promotion_suggested: bool,
336 pub heavy_impact_paths: Vec<String>,
337 pub captured_path_count: usize,
340 pub verification: RepositoryVerificationState,
341 pub created_new_state: bool,
342 pub git_checkpoint: Option<GitCheckpointRecord>,
343 pub snapshot_profile: SnapshotProfile,
344 pub state_create_ms: u128,
345 pub captured_path_count_ms: u128,
346 pub post_verification_ms: u128,
347 pub thread_metadata_ms: u128,
348 pub previous_state_ms: u128,
349 pub previous_state_profile: WorktreeStateLookupProfile,
350 pub signature_lookup_ms: u128,
351}
352
353pub fn plan_git_scope(
358 verb: SaveVerb,
359 capability: RepositoryCapability,
360 staged_index_paths: bool,
361 include_all_worktree: bool,
362) -> GitScope {
363 match verb {
364 SaveVerb::Capture => GitScope::None,
365 SaveVerb::Checkpoint => {
366 if staged_index_paths {
367 GitScope::Staged
368 } else {
369 GitScope::WorktreeAll
370 }
371 }
372 SaveVerb::Commit => {
373 if capability != RepositoryCapability::GitOverlay {
374 GitScope::None
375 } else if staged_index_paths && !include_all_worktree {
376 GitScope::Staged
377 } else {
378 GitScope::WorktreeAll
379 }
380 }
381 }
382}
383
384pub fn plan_creates_new_state(plan: &SavePlan, has_current_state: bool) -> bool {
386 if plan.supplied_tree.is_some() {
387 return true;
388 }
389 if plan.reuse_current_state && has_current_state {
390 return false;
391 }
392 if plan.verb == SaveVerb::Checkpoint && has_current_state {
394 return false;
395 }
396 true
397}
398
399pub fn plan_writes_git_checkpoint(plan: &SavePlan, capability: RepositoryCapability) -> bool {
401 plan.git_scope != GitScope::None && capability == RepositoryCapability::GitOverlay
402}
403
404pub fn tree_leaf_name(path: &str) -> String {
406 path.rsplit('/').next().unwrap_or(path).to_string()
407}
408
409pub fn commit_next_action_from_trust(
414 recommended_action: &str,
415 verified: bool,
416 has_default_remote: bool,
417) -> Option<String> {
418 if !recommended_action.trim().is_empty() {
419 return Some(recommended_action.to_string());
420 }
421 if !verified {
422 return Some("heddle verify".to_string());
423 }
424 has_default_remote.then(|| "heddle push".to_string())
425}
426
427#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct CommitGitIndexPlan {
434 pub commit_mode: &'static str,
435 pub has_staged_changes: bool,
436 pub staged_paths: Vec<String>,
437 pub unstaged_paths: Vec<String>,
438 pub untracked_paths: Vec<String>,
439 pub will_commit: Vec<String>,
440 pub preserved_after_commit: Vec<String>,
441}
442
443pub fn split_git_extra_paths(extra_paths: &[String]) -> (Vec<String>, Vec<String>) {
445 let mut unstaged_paths = Vec::new();
446 let mut untracked_paths = Vec::new();
447 for path in extra_paths {
448 if let Some(path) = path.strip_prefix("unstaged: ") {
449 unstaged_paths.push(path.to_string());
450 } else if let Some(path) = path.strip_prefix("untracked: ") {
451 untracked_paths.push(path.to_string());
452 }
453 }
454 (unstaged_paths, untracked_paths)
455}
456
457pub fn plan_commit_git_index(
459 staged_paths: &[String],
460 extra_paths: &[String],
461 include_all: bool,
462) -> CommitGitIndexPlan {
463 let (unstaged_paths, untracked_paths) = split_git_extra_paths(extra_paths);
464 let has_staged_changes = !staged_paths.is_empty();
465 let mut will_commit = Vec::new();
466 if has_staged_changes {
467 will_commit.extend(staged_paths.iter().cloned());
468 }
469 if include_all || !has_staged_changes {
470 will_commit.extend(unstaged_paths.iter().cloned());
471 will_commit.extend(untracked_paths.iter().cloned());
472 }
473 let commit_mode = if has_staged_changes && include_all {
474 "worktree_all_explicit"
475 } else if has_staged_changes {
476 "staged_index"
477 } else if will_commit.is_empty() {
478 "none"
479 } else {
480 "worktree_all"
481 };
482 let preserved_after_commit = if has_staged_changes && !include_all {
483 extra_paths.to_vec()
484 } else {
485 Vec::new()
486 };
487 CommitGitIndexPlan {
488 commit_mode,
489 has_staged_changes,
490 staged_paths: staged_paths.to_vec(),
491 unstaged_paths,
492 untracked_paths,
493 will_commit,
494 preserved_after_commit,
495 }
496}
497
498pub fn plan_commit_git_index_only(
500 staged_paths: &[String],
501 extra_paths: &[String],
502) -> CommitGitIndexPlan {
503 let (unstaged_paths, untracked_paths) = split_git_extra_paths(extra_paths);
504 CommitGitIndexPlan {
505 commit_mode: "staged_index",
506 has_staged_changes: !staged_paths.is_empty(),
507 staged_paths: staged_paths.to_vec(),
508 unstaged_paths,
509 untracked_paths,
510 will_commit: staged_paths.to_vec(),
511 preserved_after_commit: extra_paths.to_vec(),
512 }
513}
514
515pub fn commit_scope_text(commit_mode: &str) -> &'static str {
517 match commit_mode {
518 "staged_index" => {
519 "staged Git index only; unstaged and untracked paths stay in the worktree"
520 }
521 "worktree_all_explicit" => "all staged, unstaged, and untracked worktree changes (--all)",
522 "worktree_all" => "all unstaged and untracked worktree changes",
523 "none" => "no Git paths",
524 _ => "Git worktree changes",
525 }
526}
527
528pub fn staged_commit_summary(
530 summary: &str,
531 staged_path_count: usize,
532 extra_path_count: usize,
533) -> String {
534 if extra_path_count == 0 {
535 return summary.to_string();
536 }
537 format!(
538 "{summary} (committed {staged_path_count} staged path(s); left {extra_path_count} unstaged/untracked path(s) in the worktree)"
539 )
540}
541
542pub fn capture(
549 ctx: &ExecutionContext,
550 options: CaptureOptions,
551 resolve_attribution: impl FnOnce(&Repository) -> Result<CaptureAttribution>,
552) -> Result<CaptureReport> {
553 let repo = ctx.require_repo()?;
554 if options.intent.trim().is_empty() {
555 return Err(capture_refusal(
556 "missing_capture_intent",
557 "refusing to capture without an intent",
558 "Provide a short intent with `heddle capture -m \"...\"`.",
559 "no capture intent was supplied with -m/--message/--intent",
560 "capturing without intent would create a weak provenance record",
561 "repository state, refs, metadata, and worktree files were left unchanged",
562 vec!["heddle capture -m \"...\"".to_string()],
563 ));
564 }
565
566 preflight_unimported_git_history(repo, "capture")?;
567 let complete_thread_resolution = merge_resolution_is_complete(repo)?;
568 let worktree_status_started = Instant::now();
569 let known_worktree_changes = if complete_thread_resolution {
570 None
571 } else {
572 let status = capture_worktree_status(repo, &options.worktree_status_options)?;
573 if repo.capability() == RepositoryCapability::GitOverlay && status.is_clean() {
574 return Err(map_capture_error(anyhow!(HeddleError::NoChanges)));
575 }
576 Some(status)
577 };
578
579 let worktree_status = repo.git_overlay_worktree_status();
580 let worktree_status_ms = worktree_status_started.elapsed().as_millis();
581
582 let preflight_started = Instant::now();
583 preflight_large_capture(options.force, &worktree_status)?;
584 preflight_capture_mutation(
585 repo,
586 &worktree_status,
587 options.machine_contract_input.as_ref(),
588 )?;
589 let preflight_ms = preflight_started.elapsed().as_millis();
590 let attribution_started = Instant::now();
591 let resolved_attribution = resolve_attribution(repo)?;
592 let harness_session_id = resolved_attribution
593 .attribution
594 .agent
595 .is_some()
596 .then(|| resolved_attribution.harness_session_id.clone())
597 .flatten();
598 let attribution_ms = attribution_started.elapsed().as_millis();
599
600 let plan = SavePlan {
601 verb: SaveVerb::Capture,
602 intent: Some(options.intent),
603 confidence: options.confidence,
604 attribution: resolved_attribution.attribution,
605 git_scope: GitScope::None,
606 supplied_tree: None,
607 reuse_current_state: false,
608 require_clean_worktree: false,
609 require_worktree_change: repo.capability() == RepositoryCapability::NativeHeddle
614 && !complete_thread_resolution,
615 worktree_status_options: options.worktree_status_options,
616 known_worktree_changes,
617 run_hooks: true,
618 commit_safe_post_verify: false,
619 coalesce_snapshot_and_checkpoint: false,
620 linearize_git_parent: false,
621 precomputed_worktree_status: Some(worktree_status),
622 machine_contract_input: options.machine_contract_input,
623 };
624 let execute_save_started = Instant::now();
625 let save = execute_save(repo, plan).map_err(map_capture_error)?;
626 let execute_save_ms = execute_save_started.elapsed().as_millis();
627 if let Some(session_id) = harness_session_id
628 && let Err(error) = crate::record_last_turn_capture(repo, &session_id, save.state_id)
629 {
630 tracing::warn!(%error, "could not update reconstructible last-turn anchor");
631 }
632
633 let manual_resolution_action = if complete_thread_resolution {
634 complete_current_thread_manual_resolution(repo)?
635 } else {
636 None
637 };
638 update_capture_intent_to_add(repo, &save.state_id);
639
640 let current_thread = current_thread(repo)?;
641 let captured_thread_targets_integration = current_thread
642 .as_ref()
643 .and_then(|thread| thread.target_thread.as_ref())
644 .is_some();
645 let task_assignment_id = active_task_assignment_id(repo, current_thread.as_ref())?;
646 let principal_source = resolved_attribution.principal_source;
647 let warnings = bulk_capture_warning(save.captured_path_count)
648 .into_iter()
649 .collect();
650
651 let mut recommended_action = non_empty_action(&save.verification.recommended_action);
652 let mut recommended_action_template = recommended_action
653 .as_deref()
654 .and_then(action_template)
655 .or_else(|| save.verification.recommended_action_template.clone());
656 if let Some(action) = manual_resolution_action {
657 recommended_action_template = action_template(&action);
658 recommended_action = Some(action);
659 }
660
661 let principal = CapturePrincipalReport {
662 name: save.principal.name_lossy().into_owned(),
663 email: save.principal.email_lossy().into_owned(),
664 };
665 let agent = save.agent.as_ref().map(|agent| CaptureAgentReport {
666 provider: agent.provider.clone(),
667 model: agent.model.clone(),
668 session_id: agent.session_id.clone(),
669 segment_id: agent.segment_id.clone(),
670 policy_id: agent.policy_id.clone(),
671 thought_level: agent.thought_level.clone(),
672 parent: agent.parent.clone(),
673 });
674 Ok(CaptureReport {
675 output_kind: "capture",
676 state_id: save.state_id.short(),
677 content_hash: save.content_hash.short(),
678 intent: save.intent.clone(),
679 confidence: save.confidence,
680 task_assignment_id,
681 principal,
682 principal_source,
683 agent,
684 promotion_suggested: save.promotion_suggested,
685 heavy_impact_paths: save.heavy_impact_paths.clone(),
686 captured_path_count: save.captured_path_count,
687 warnings,
688 signed: save.signed,
689 message: save.summary.clone(),
690 recommended_action,
691 recommended_action_template,
692 verification: save.verification.clone(),
693 captured_thread_targets_integration,
694 diagnostics: CaptureDiagnostics {
695 save,
696 profile: CaptureProfile {
697 worktree_status_ms,
698 preflight_ms,
699 attribution_ms,
700 execute_save_ms,
701 },
702 },
703 })
704}
705
706fn preflight_unimported_git_history(repo: &Repository, action: &str) -> Result<()> {
707 if repo.capability() != RepositoryCapability::GitOverlay {
708 return Ok(());
709 }
710 let Some(guidance) = repo.git_import_guidance()? else {
711 return Ok(());
712 };
713 if !import_guidance_includes_active_branch(&guidance) {
714 return Ok(());
715 }
716 let branches = preview_paths(&guidance.missing_branches);
717 let command = guidance.recommended_command;
718 Err(capture_refusal(
719 "git_history_needs_import",
720 format!("Refusing to {action}: Git history has not been imported into Heddle"),
721 format!("Run `{command}` before retrying `heddle {action}`."),
722 format!("Git branch(es) waiting for Heddle import: {branches}"),
723 format!(
724 "{action} would write new Heddle state before Heddle has adopted the existing Git history"
725 ),
726 "Git refs, Heddle refs, and worktree files were left unchanged",
727 vec![command],
728 ))
729}
730
731fn preflight_capture_mutation(
732 repo: &Repository,
733 worktree_status: &repo::Result<Option<WorktreeStatus>>,
734 machine_contract_input: Option<&MachineContractInput>,
735) -> Result<()> {
736 if repo.capability() != RepositoryCapability::GitOverlay {
737 return Ok(());
738 }
739 if let Some(operation) = repo.operation_status()?
740 && matches!(operation.scope, OperationScope::Git)
741 {
742 return Err(capture_refusal(
743 "raw_git_operation_in_progress",
744 format!(
745 "Refusing to capture: an externally-started Git {} is in progress",
746 operation.kind
747 ),
748 format!(
749 "Inspect with `heddle verify`. Heddle did not start this raw Git {}, so finish or abort it with the Git-compatible tool that started it, then run `heddle verify` for the exact adoption command before retrying `heddle capture`.",
750 operation.kind
751 ),
752 format!(
753 "Git {} is {}; Heddle cannot safely turn sequencer state into a saved change inside the no-git runtime",
754 operation.kind, operation.state
755 ),
756 "capture would capture worktree/index contents while Git still has unresolved sequencer metadata",
757 "Git refs, Git sequencer files, Heddle refs, and worktree files were left unchanged",
758 vec!["heddle verify".to_string()],
759 ));
760 }
761
762 let health = build_repository_verification_health_with_worktree_status(repo, worktree_status);
763 let trust = if let Some(input) = machine_contract_input {
764 build_repository_verification_state_with_worktree_status_and_machine_contract(
765 repo,
766 health,
767 worktree_status,
768 input,
769 )
770 } else {
771 build_repository_verification_state_with_worktree_status(repo, health, worktree_status)
772 };
773 if trust.status != "needs_reconcile" || uncheckpointed_state_is_ahead_of_git(repo)? {
774 return Ok(());
775 }
776 let primary = if trust.recommended_action.trim().is_empty() {
777 "heddle verify".to_string()
778 } else {
779 trust.recommended_action.clone()
780 };
781 let recovery_commands = if trust.recovery_commands.is_empty() {
782 vec![primary.clone()]
783 } else {
784 trust.recovery_commands.clone()
785 };
786 Err(capture_refusal(
787 "repository_verification_blocked",
788 format!(
789 "Refusing to capture: repository verification is blocked ({})",
790 trust.status
791 ),
792 format!("Run `{primary}` before retrying `heddle capture`."),
793 format!(
794 "repository verification status is {}: {}",
795 trust.status, trust.summary
796 ),
797 "capture would write new Heddle or Git state while Git and Heddle disagree",
798 "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
799 recovery_commands,
800 ))
801}
802
803fn uncheckpointed_state_is_ahead_of_git(repo: &Repository) -> Result<bool> {
804 let Some(branch) = repo.git_overlay_current_branch()? else {
805 return Ok(false);
806 };
807 let Some(tip) = repo.git_overlay_branch_tip(&branch)? else {
808 return Ok(false);
809 };
810 let Some(mapped) = tip.mapped_state else {
811 return Ok(false);
812 };
813 let Some(current) = repo.current_state()? else {
814 return Ok(false);
815 };
816 if mapped == current.state_id {
817 return Ok(false);
818 }
819 let mut graph = CommitGraphIndex::new(repo);
820 if !graph
821 .is_ancestor(&mapped, ¤t.state_id)
822 .unwrap_or(false)
823 || graph
824 .is_ancestor(¤t.state_id, &mapped)
825 .unwrap_or(false)
826 {
827 return Ok(false);
828 }
829 Ok(repo
830 .latest_git_checkpoint_for_state(¤t.state_id)?
831 .is_none())
832}
833
834fn preflight_large_capture(
835 force: bool,
836 worktree_status: &repo::Result<Option<WorktreeStatus>>,
837) -> Result<()> {
838 if force {
839 return Ok(());
840 }
841 let Ok(Some(status)) = worktree_status else {
842 return Ok(());
843 };
844 let total = status.change_count();
845 let delete_count = status.deleted.len();
846 let add_count = status.added.len();
847 if !crate::large_capture_requires_force(total, delete_count, add_count) {
848 return Ok(());
849 }
850 let sample = status
851 .deleted
852 .iter()
853 .chain(status.added.iter())
854 .chain(status.modified.iter())
855 .take(5)
856 .map(|path| path.display().to_string())
857 .collect::<Vec<_>>()
858 .join(", ");
859 let sample = if sample.is_empty() {
860 "no sample paths available".to_string()
861 } else {
862 sample
863 };
864 Err(capture_refusal(
865 "large_capture_requires_force",
866 format!(
867 "Large capture safety check: this would capture {total} changed paths ({delete_count} deletions, {add_count} additions)"
868 ),
869 "If this is intentional, rerun with `heddle capture --force -m \"...\"`.",
870 format!("sample changed paths: {sample}"),
871 "capture would preserve an unusually large Git-overlay worktree change without an explicit confirmation",
872 "repository state, refs, metadata, and worktree files were left unchanged",
873 vec!["heddle capture --force -m \"...\"".to_string()],
874 ))
875}
876
877fn merge_resolution_is_complete(repo: &Repository) -> Result<bool> {
878 Ok(repo
879 .merge_state_manager()
880 .load()?
881 .is_some_and(|merge_state| {
882 merge_state
883 .conflicts
884 .iter()
885 .all(|path| merge_state.resolved.contains(path))
886 }))
887}
888
889fn capture_worktree_status(
895 repo: &Repository,
896 options: &WorktreeStatusOptions,
897) -> Result<WorktreeStatus> {
898 if repo.current_state_for_worktree_status()?.is_none()
899 && let Some(status) = repo.git_overlay_worktree_status()?
900 {
901 return Ok(status);
902 }
903 let tree = match repo.current_state_for_worktree_status()? {
904 Some(state) => repo.require_tree_for_worktree_status(&state.tree)?,
905 None => Tree::new(),
906 };
907 Ok(repo.compare_worktree_cached_with_options(&tree, options)?)
908}
909
910pub fn complete_current_thread_manual_resolution(repo: &Repository) -> Result<Option<String>> {
915 let Some(current_thread) = repo.current_lane()? else {
916 return Ok(None);
917 };
918 let Some(current_state) = repo.head()? else {
919 return Ok(None);
920 };
921 let Some(current_state_object) = repo.store().get_state(¤t_state)? else {
922 return Ok(None);
923 };
924 let manager = ThreadManager::new(repo.heddle_dir());
925 let Some(mut thread) = manager.find_by_thread(¤t_thread)? else {
926 return Ok(None);
927 };
928 let Some(target_thread) = thread.target_thread.clone() else {
929 return Ok(None);
930 };
931 let Some(target_state) = repo.refs().get_thread(&ThreadName::new(&target_thread))? else {
932 return Ok(None);
933 };
934 let Some(target_state_object) = repo.store().get_state(&target_state)? else {
935 return Ok(None);
936 };
937 let before = crate::capture_thread_update_before(repo, &manager, &thread)?;
938
939 thread.base_state = target_state.short();
940 thread.base_root = target_state_object.tree.short();
941 update_thread_state_from_state(&mut thread, ¤t_state_object);
942 thread.state = ThreadState::Ready;
943 thread.freshness = ThreadFreshness::Current;
944 thread.integration_policy_result = ThreadIntegrationPolicy {
945 status: Some("manual_resolved".to_string()),
946 reason: Some("manual conflict resolution captured".to_string()),
947 manual_resolution_state: Some(current_state.short()),
948 conflicts_resolved_manually: true,
949 };
950 thread.updated_at = Utc::now();
951 let thread_id = thread.id.clone();
952 let target = thread.target_thread.clone();
953 crate::save_thread_update(repo, &manager, &thread, before, current_state)?;
954
955 Ok(Some(manual_resolution_land_action(
956 repo,
957 &thread_id,
958 target.as_deref(),
959 )))
960}
961
962fn manual_resolution_land_action(
963 repo: &Repository,
964 thread_id: &str,
965 target_thread: Option<&str>,
966) -> String {
967 let action = crate::status::next_action::land_local_command(thread_id);
968 contextual_thread_action(repo, thread_id, target_thread, &action)
969}
970
971fn update_capture_intent_to_add(repo: &Repository, state_id: &StateId) {
972 if repo.capability() != RepositoryCapability::GitOverlay {
973 return;
974 }
975 let projection = GitProjection::new(repo);
976 if let Err(error) = projection.update_intent_to_add(state_id) {
977 tracing::debug!(%error, "intent-to-add index update skipped");
978 }
979}
980
981fn current_thread(repo: &Repository) -> Result<Option<Thread>> {
982 let manager = ThreadManager::new(repo.heddle_dir());
983 if let Some(thread) = manager.find_by_execution_root(repo.root())? {
984 return Ok(Some(thread));
985 }
986 let Head::Attached { thread } = repo.head_ref()? else {
987 return Ok(None);
988 };
989 let current_state_id = repo.refs().get_thread(&thread)?;
990 let current_state = current_state_id.map(|state| state.short());
991 let base_root = current_state_id
992 .and_then(|state| repo.store().get_state(&state).ok().flatten())
993 .map(|state| state.tree.short())
994 .unwrap_or_default();
995 let thread = thread.to_string();
996 Ok(Some(Thread {
997 id: thread.clone(),
998 thread,
999 target_thread: None,
1000 parent_thread: None,
1001 mode: ThreadMode::Materialized,
1002 state: ThreadState::Active,
1003 base_state: current_state.clone().unwrap_or_default(),
1004 base_root,
1005 current_state,
1006 merged_state: None,
1007 task: None,
1008 execution_path: repo.root().to_path_buf(),
1009 materialized_path: None,
1010 changed_paths: Vec::new(),
1011 impact_categories: Vec::new(),
1012 heavy_impact_paths: Vec::new(),
1013 promotion_suggested: false,
1014 freshness: ThreadFreshness::Unknown,
1015 verification_summary: Default::default(),
1016 confidence_summary: Default::default(),
1017 integration_policy_result: Default::default(),
1018 created_at: Utc::now(),
1019 updated_at: Utc::now(),
1020 ephemeral: None,
1021 auto: false,
1022 shared_target_dir: None,
1023 }))
1024}
1025
1026fn active_task_assignment_id(repo: &Repository, thread: Option<&Thread>) -> Result<Option<String>> {
1027 let Some(thread) = thread else {
1028 return Ok(None);
1029 };
1030 let store = ActorPresenceStore::new(repo.heddle_dir());
1031 Ok(store
1032 .active_entries()?
1033 .into_iter()
1034 .filter(|entry| entry.thread == thread.id)
1035 .max_by_key(|entry| entry.started_at)
1036 .and_then(|entry| entry.task_assignment_id))
1037}
1038
1039fn bulk_capture_warning(captured_path_count: usize) -> Option<String> {
1040 (captured_path_count >= BULK_CAPTURE_WARNING_THRESHOLD).then(|| {
1041 format!(
1042 "captured {captured_path_count} paths in one operation; check root .gitignore and .heddleignore rules if build artifacts or tool state were included"
1043 )
1044 })
1045}
1046
1047fn non_empty_action(action: &str) -> Option<String> {
1048 (!action.trim().is_empty()).then(|| action.to_string())
1049}
1050
1051fn map_capture_error(error: anyhow::Error) -> anyhow::Error {
1052 if error.chain().any(|cause| {
1053 cause
1054 .downcast_ref::<HeddleError>()
1055 .is_some_and(|error| matches!(error, HeddleError::NoChanges))
1056 }) {
1057 return capture_refusal(
1058 "nothing_to_capture",
1059 "nothing to capture: worktree has no changes eligible for Heddle capture",
1060 "Inspect the worktree with `heddle status`; make changes before running `heddle capture -m \"...\"`.",
1061 "the worktree has no modified, deleted, or untracked paths relative to the current Heddle state",
1062 "capture would not create a meaningful Heddle state",
1063 "repository state was left unchanged",
1064 vec!["heddle status".to_string()],
1065 );
1066 }
1067 if error.chain().any(|cause| {
1068 cause
1069 .downcast_ref::<std::io::Error>()
1070 .is_some_and(objects::fs_atomic::is_out_of_space)
1071 }) {
1072 return capture_refusal(
1073 "capture_out_of_space",
1074 format!("Capture aborted because the filesystem is out of space: {error:#}"),
1075 "Free disk space and re-run `heddle capture`. Your working tree changes are intact.",
1076 "the filesystem reported no remaining space while Heddle was writing captured objects",
1077 "retrying before freeing space may fail again or leave another incomplete object write",
1078 "the working tree was not modified; already-committed repository data remains behind atomic write boundaries",
1079 vec!["heddle capture -m \"...\"".to_string()],
1080 );
1081 }
1082 error
1083}
1084
1085#[allow(clippy::too_many_arguments)]
1086fn capture_refusal(
1087 kind: &'static str,
1088 error: impl Into<String>,
1089 hint: impl Into<String>,
1090 unsafe_condition: impl Into<String>,
1091 would_change: impl Into<String>,
1092 preserved: impl Into<String>,
1093 recovery_commands: Vec<String>,
1094) -> anyhow::Error {
1095 anyhow!(HeddleError::recovery(
1096 RecoveryDetails::safety_refusal(
1097 kind,
1098 error,
1099 hint,
1100 unsafe_condition,
1101 would_change,
1102 preserved,
1103 )
1104 .with_recovery_commands(recovery_commands),
1105 ))
1106}
1107
1108fn preview_paths(paths: &[String]) -> String {
1109 let shown = paths
1110 .iter()
1111 .take(12)
1112 .cloned()
1113 .collect::<Vec<_>>()
1114 .join(", ");
1115 let hidden = paths.len().saturating_sub(12);
1116 if hidden == 0 {
1117 shown
1118 } else {
1119 format!("{shown}, and {hidden} more")
1120 }
1121}
1122
1123pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
1129 if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
1135 return Err(anyhow!(HeddleError::recovery(
1136 RecoveryDetails::safety_refusal(
1137 "native_checkpoint_unavailable",
1138 "Git checkpointing is only available in Git-overlay repositories",
1139 "Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
1140 "this checkout is not a Git-overlay repository",
1141 "checkpoint would try to write a Git commit where no active Git store is bound",
1142 "repository state, refs, and worktree files were left unchanged",
1143 ),
1144 )));
1145 }
1146
1147 let previous_state_started = Instant::now();
1148 let (previous_state, previous_state_profile) =
1149 repo.current_state_for_worktree_status_profiled()?;
1150 let previous_state_ms = previous_state_started.elapsed().as_millis();
1151 let has_current = previous_state.is_some();
1152 let mut created_new_state = false;
1153 let mut snapshot_profile = SnapshotProfile::default();
1154 let mut thread_metadata_ms = 0u128;
1155 let mut promotion_suggested = false;
1156 let mut heavy_impact_paths = Vec::new();
1157 let mut snapshot_state_id: Option<StateId> = None;
1158 let mut captured_path_count = 0usize;
1159 let mut state_create_ms = 0u128;
1160 let mut captured_path_count_ms = 0u128;
1161
1162 let mut state = if plan_creates_new_state(&plan, has_current) {
1163 created_new_state = true;
1164 let state_create_started = Instant::now();
1165 let execution = create_heddle_state(repo, &plan)?;
1166 state_create_ms = state_create_started.elapsed().as_millis();
1167 snapshot_profile = execution.profile;
1168 thread_metadata_ms = execution.thread_metadata_ms;
1169 promotion_suggested = execution.promotion_suggested;
1170 heavy_impact_paths = execution.heavy_impact_paths;
1171 snapshot_state_id = Some(execution.state.state_id);
1172 let previous_tree = match previous_state.as_ref() {
1173 Some(state) => state.tree,
1174 None => repo.store().put_tree(&Tree::new())?,
1175 };
1176 let captured_path_count_started = Instant::now();
1177 captured_path_count = repo
1178 .diff_trees(&previous_tree, &execution.state.tree)?
1179 .len();
1180 captured_path_count_ms = captured_path_count_started.elapsed().as_millis();
1181 execution.state
1182 } else {
1183 repo.current_state()?
1184 .ok_or_else(|| anyhow!("no captured state found for save"))?
1185 };
1186
1187 let mut git_commit = None;
1188 let mut git_previous_commit = None;
1189 let mut git_checkpoint = None;
1190
1191 if plan_writes_git_checkpoint(&plan, repo.capability()) {
1192 if plan.require_clean_worktree {
1193 let tree = repo.require_tree(&state.tree)?;
1194 let status = repo.compare_worktree_cached_detailed_with_options(
1195 &tree,
1196 &plan.worktree_status_options,
1197 )?;
1198 if !status.is_clean() {
1199 return Err(anyhow!(HeddleError::recovery(
1200 RecoveryDetails::safety_refusal(
1201 "dirty_worktree",
1202 "Save worktree changes before committing",
1203 "Save the work with `heddle capture -m \"...\"`, then retry the commit.",
1204 "the current Heddle state was left unchanged; these paths have not been captured",
1205 "commit would write Git history that does not include dirty worktree paths",
1206 "the current Heddle state was left unchanged; these paths have not been captured",
1207 ),
1208 )));
1209 }
1210 }
1211
1212 if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
1213 && repo.pending_git_checkpoint_intent()?.is_none()
1214 {
1215 git_commit = Some(existing.git_commit.clone());
1216 git_checkpoint = Some(existing);
1217 } else {
1218 let previous = repo
1219 .pending_git_checkpoint_intent()?
1220 .and_then(|intent| intent.previous_git_oid)
1221 .or_else(|| git_rev_parse_head(repo.root()));
1222 git_previous_commit = previous.clone();
1223 let summary = checkpoint_summary(&plan, &state);
1224 let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
1225 if plan.coalesce_snapshot_and_checkpoint
1226 && let Some(state_id) = snapshot_state_id.as_ref()
1227 {
1228 coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
1229 }
1230 git_commit = Some(record.git_commit.clone());
1231 git_checkpoint = Some(record);
1232 }
1233 }
1234
1235 let captured_native_worktree = created_new_state
1239 && plan.supplied_tree.is_none()
1240 && repo.capability() == RepositoryCapability::NativeHeddle;
1241 let captured_worktree_status = Ok(Some(objects::worktree::WorktreeStatus::default()));
1242 let verification_started = Instant::now();
1243 let mut verification = if captured_native_worktree && git_checkpoint.is_none() {
1244 let health = build_repository_verification_health_with_worktree_status(
1245 repo,
1246 &captured_worktree_status,
1247 );
1248 if let Some(input) = &plan.machine_contract_input {
1249 build_repository_verification_state_with_worktree_status_and_machine_contract(
1250 repo,
1251 health,
1252 &captured_worktree_status,
1253 input,
1254 )
1255 } else {
1256 build_repository_verification_state_with_worktree_status(
1257 repo,
1258 health,
1259 &captured_worktree_status,
1260 )
1261 }
1262 } else if created_new_state || git_checkpoint.is_some() {
1263 if let Some(input) = &plan.machine_contract_input {
1264 build_repository_verification_state_with_machine_contract(repo, input)?
1265 } else {
1266 build_repository_verification_state(repo)?
1267 }
1268 } else if let Some(status) = &plan.precomputed_worktree_status {
1269 let health = build_repository_verification_health_with_worktree_status(repo, status);
1270 if let Some(input) = &plan.machine_contract_input {
1271 build_repository_verification_state_with_worktree_status_and_machine_contract(
1272 repo, health, status, input,
1273 )
1274 } else {
1275 build_repository_verification_state_with_worktree_status(repo, health, status)
1276 }
1277 } else {
1278 if let Some(input) = &plan.machine_contract_input {
1279 build_repository_verification_state_with_machine_contract(repo, input)?
1280 } else {
1281 build_repository_verification_state(repo)?
1282 }
1283 };
1284 if plan.commit_safe_post_verify {
1285 soften_commit_next_action(&mut verification);
1286 }
1287 let post_verification_ms = verification_started.elapsed().as_millis();
1288
1289 let summary = match plan.verb {
1290 SaveVerb::Capture => format!(
1291 "Captured state {} ({})",
1292 state.state_id.short(),
1293 state.hash().short()
1294 ),
1295 SaveVerb::Commit => plan
1296 .intent
1297 .clone()
1298 .unwrap_or_else(|| format!("Commit {}", state.state_id.short())),
1299 SaveVerb::Checkpoint => git_checkpoint
1300 .as_ref()
1301 .map(|r| r.summary.clone())
1302 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
1303 };
1304
1305 let signature_lookup_started = Instant::now();
1306 let signed = repo.get_state_signature(&state.id())?.is_some();
1307 let signature_lookup_ms = signature_lookup_started.elapsed().as_millis();
1308 Ok(SaveReport {
1309 verb: plan.verb,
1310 state_id: state.state_id,
1311 content_hash: state.hash(),
1312 intent: state.intent.clone(),
1313 confidence: state.confidence,
1314 signed,
1315 git_commit,
1316 git_previous_commit,
1317 summary,
1318 principal: state.attribution.principal.clone(),
1319 agent: state.attribution.agent.clone(),
1320 promotion_suggested,
1321 heavy_impact_paths,
1322 captured_path_count,
1323 verification,
1324 created_new_state,
1325 git_checkpoint,
1326 snapshot_profile,
1327 state_create_ms,
1328 captured_path_count_ms,
1329 post_verification_ms,
1330 thread_metadata_ms,
1331 previous_state_ms,
1332 previous_state_profile,
1333 signature_lookup_ms,
1334 })
1335}
1336
1337struct CreatedState {
1338 state: State,
1339 profile: SnapshotProfile,
1340 thread_metadata_ms: u128,
1341 promotion_suggested: bool,
1342 heavy_impact_paths: Vec<String>,
1343}
1344
1345fn create_heddle_state(repo: &Repository, plan: &SavePlan) -> Result<CreatedState> {
1346 let hook_manager = HookManager::new(repo);
1347 let hook_ctx = HookContext::new(repo);
1348 let mut post_hook_worktree_changes = None;
1349
1350 if plan.run_hooks {
1351 let pre_snapshot_ran = hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
1352 let pre_capture_payload = serde_json::json!({
1353 "thread": current_thread_name(repo),
1354 "intent": plan.intent.clone().unwrap_or_default(),
1355 });
1356 let pre_capture_response = hook_manager.run_with_payload(
1357 Hook::PreSnapshot,
1358 &hook_ctx,
1359 &pre_capture_payload,
1360 std::time::Duration::from_secs(5),
1361 )?;
1362 if let Some(resp) = pre_capture_response
1363 && !resp.abort.is_empty()
1364 {
1365 return Err(anyhow!(HeddleError::recovery(
1366 RecoveryDetails::safety_refusal(
1367 "hook_veto",
1368 format!("pre_capture hook vetoed: {}", resp.abort),
1369 "Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
1370 format!("pre_capture hook vetoed capture: {}", resp.abort),
1371 "capture would continue after repository policy explicitly aborted the operation",
1372 "the operation stopped at the hook boundary before the protected action ran",
1373 )
1374 .with_recovery_commands(vec!["heddle hook list".to_string()]),
1375 )));
1376 }
1377 if pre_snapshot_ran && plan.supplied_tree.is_none() {
1378 let authoritative_options = WorktreeStatusOptions {
1382 fsmonitor: repo::FsMonitorSettings {
1383 mode: repo::FsMonitorMode::Off,
1384 },
1385 };
1386 post_hook_worktree_changes =
1387 Some(capture_worktree_status(repo, &authoritative_options)?);
1388 }
1389 }
1390 let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
1391 repo.snapshot_tree_with_attribution_profiled(
1392 tree,
1393 plan.intent.clone(),
1394 plan.confidence,
1395 plan.attribution.clone(),
1396 )?
1397 } else if let Some(status) =
1398 post_hook_worktree_changes.or_else(|| plan.known_worktree_changes.clone())
1399 {
1400 repo.snapshot_with_attribution_profiled_from_status(
1401 plan.intent.clone(),
1402 plan.confidence,
1403 plan.attribution.clone(),
1404 status,
1405 plan.require_worktree_change,
1406 )?
1407 } else if plan.require_worktree_change {
1408 repo.snapshot_with_attribution_profiled_if_changed(
1409 plan.intent.clone(),
1410 plan.confidence,
1411 plan.attribution.clone(),
1412 )?
1413 } else {
1414 repo.snapshot_with_attribution_profiled(
1415 plan.intent.clone(),
1416 plan.confidence,
1417 plan.attribution.clone(),
1418 )?
1419 };
1420
1421 let thread_metadata_start = Instant::now();
1422 let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
1423 let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
1424
1425 if plan.run_hooks {
1426 hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
1427 let post_capture_payload = serde_json::json!({
1428 "state_id": execution.state.state_id.to_string_full(),
1429 });
1430 if let Err(err) = hook_manager.run_with_payload(
1431 Hook::PostSnapshot,
1432 &hook_ctx,
1433 &post_capture_payload,
1434 std::time::Duration::from_secs(5),
1435 ) {
1436 tracing::warn!(error = %err, "post_capture hook error swallowed");
1437 }
1438 }
1439
1440 Ok(CreatedState {
1441 state: execution.state,
1442 profile: std::mem::take(&mut execution.profile),
1443 thread_metadata_ms,
1444 promotion_suggested: refresh.promotion_suggested,
1445 heavy_impact_paths: refresh.heavy_impact_paths,
1446 })
1447}
1448
1449fn write_git_checkpoint(
1450 repo: &Repository,
1451 state: &State,
1452 summary: String,
1453 linearize_git_parent: bool,
1454) -> Result<GitCheckpointRecord> {
1455 let _lock = repo.locker().write()?;
1456 objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
1457 let mut bridge = GitProjection::new(repo);
1458 if linearize_git_parent {
1459 bridge.linearize_unmapped_tip_to_checkout();
1460 }
1461 let git_commit = match bridge
1462 .write_through_current_checkout_with_message(state.state_id, summary.clone())?
1463 {
1464 WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
1465 WriteThroughOutcome::Skipped(reason) => {
1466 return Err(anyhow!(HeddleError::recovery(
1467 RecoveryDetails::safety_refusal(
1468 "checkpoint_git_write_skipped",
1469 format!("Git checkpoint write-through was skipped: {reason}"),
1470 "Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
1471 format!("write-through skipped: {reason}"),
1472 "checkpoint would need to write the current Heddle state into the Git branch and index",
1473 "the current Heddle state was preserved; no Git checkpoint record was written",
1474 ),
1475 )));
1476 }
1477 };
1478 let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
1479 anyhow!("Git checkpoint published without its durable finalization intent")
1480 })?;
1481 if intent.phase != repo::GitCheckpointIntentPhase::Published
1482 || intent.state_id != state.state_id.to_string_full()
1483 || intent.new_git_oid != git_commit
1484 {
1485 return Err(anyhow!(
1486 "published Git checkpoint does not match its durable finalization intent"
1487 ));
1488 }
1489 finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
1490}
1491
1492pub fn recover_published_git_checkpoint(
1496 repo: &Repository,
1497 state_id: &StateId,
1498) -> Result<Option<GitCheckpointRecord>> {
1499 let _lock = repo.locker().write()?;
1500 let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
1501 return Ok(None);
1502 };
1503 if intent.state_id != state_id.to_string_full() {
1504 return Ok(None);
1505 }
1506 let current_branch = repo.git_overlay_current_branch()?;
1507 if current_branch.as_deref() != Some(intent.branch.as_str()) {
1508 return Err(anyhow!(
1509 "pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
1510 intent.branch,
1511 current_branch.as_deref().unwrap_or("detached HEAD")
1512 ));
1513 }
1514 let current_oid = git_rev_parse_head(repo.root());
1515 if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
1516 if current_oid == intent.previous_git_oid {
1517 return Ok(None);
1518 }
1519 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1520 return Err(anyhow!(
1521 "prepared Git checkpoint expected HEAD at {} or {}, found {}",
1522 intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
1523 intent.new_git_oid,
1524 current_oid.as_deref().unwrap_or("<unborn>")
1525 ));
1526 }
1527 let git_oid = intent.new_git_oid.clone();
1528 intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
1529 }
1530 if intent.phase != repo::GitCheckpointIntentPhase::Published {
1531 return Ok(None);
1532 }
1533 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1534 return Err(anyhow!(
1535 "published Git checkpoint expected HEAD at {}, found {}",
1536 intent.new_git_oid,
1537 current_oid.as_deref().unwrap_or("<unborn>")
1538 ));
1539 }
1540 let git_commit = intent.new_git_oid.clone();
1541 let summary = intent.summary.clone();
1542 finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
1543}
1544
1545fn finalize_published_git_checkpoint(
1546 repo: &Repository,
1547 state_id: &StateId,
1548 git_commit: String,
1549 summary: String,
1550 intent: repo::GitCheckpointIntent,
1551) -> Result<GitCheckpointRecord> {
1552 let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
1553 objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
1554 let transaction_id = format!(
1555 "git-checkpoint:v1:{}:{}",
1556 state_id.to_string_full(),
1557 git_commit
1558 );
1559 repo.oplog().record_batch_exactly_once(
1560 vec![
1561 OpRecord::GitCheckpoint {
1562 branch: intent.branch,
1563 state: *state_id,
1564 previous_git_oid: intent.previous_git_oid,
1565 new_git_oid: git_commit.clone(),
1566 },
1567 OpRecord::TransactionCommit {
1568 transaction_id: transaction_id.clone(),
1569 op_count: 1,
1570 },
1571 ],
1572 Some(&repo.op_scope()),
1573 &transaction_id,
1574 )?;
1575 objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
1576 repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
1577 Ok(record)
1578}
1579
1580fn coalesce_snapshot_and_checkpoint(
1581 repo: &Repository,
1582 state_id: &StateId,
1583 git_commit: &str,
1584) -> Result<()> {
1585 let snapshot_batch = repo
1586 .oplog()
1587 .recent_batches_scoped(8, Some(&repo.op_scope()))?
1588 .into_iter()
1589 .find(|batch| {
1590 batch.entries.iter().any(|entry| {
1591 matches!(
1592 &entry.operation,
1593 OpRecord::Snapshot { new_state, .. } if new_state == state_id
1594 )
1595 })
1596 })
1597 .ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
1598 let checkpoint_batch = repo
1599 .oplog()
1600 .recent_batches_scoped(8, Some(&repo.op_scope()))?
1601 .into_iter()
1602 .find(|batch| {
1603 batch.entries.iter().any(|entry| {
1604 matches!(
1605 &entry.operation,
1606 OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
1607 )
1608 })
1609 })
1610 .ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
1611 repo.oplog()
1612 .coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
1613 .context(
1614 "commit completed but failed to record capture and Git checkpoint as one undo batch",
1615 )?;
1616 Ok(())
1617}
1618
1619fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
1620 plan.intent
1621 .clone()
1622 .or_else(|| state.intent.clone())
1623 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
1624}
1625
1626fn current_thread_name(repo: &Repository) -> String {
1627 match repo.head_ref() {
1628 Ok(Head::Attached { thread }) => thread.to_string(),
1629 _ => String::new(),
1630 }
1631}
1632
1633fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
1634 let git = SleyRepository::discover(root).ok()?;
1635 git.head().ok()?.oid.map(|id| id.to_string())
1636}
1637
1638fn soften_commit_next_action(trust: &mut RepositoryVerificationState) {
1639 if is_commit_action(&trust.recommended_action) {
1640 trust.recommended_action = "heddle status".to_string();
1641 trust.recommended_action_template = None;
1642 }
1643 for check in &mut trust.checks {
1644 if check
1645 .recommended_action
1646 .as_deref()
1647 .is_some_and(is_commit_action)
1648 {
1649 check.recommended_action = Some("heddle status".to_string());
1650 check.recommended_action_template = None;
1651 }
1652 }
1653}
1654
1655fn is_commit_action(action: &str) -> bool {
1656 let trimmed = action.trim();
1657 trimmed == "heddle capture" || trimmed.starts_with("heddle capture ")
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662 use std::cell::Cell;
1663
1664 use repo::RepositoryCapability;
1665 use tempfile::TempDir;
1666
1667 use super::*;
1668
1669 #[test]
1670 fn capture_interface_owns_mutation_and_returns_the_report_contract() {
1671 let temp = TempDir::new().expect("create temp repository");
1672 let repo = Repository::init_default(temp.path()).expect("initialize repository");
1673 std::fs::write(temp.path().join("tracked.txt"), "captured\n")
1674 .expect("write worktree change");
1675 let ctx = ExecutionContext::builder()
1676 .repo(repo)
1677 .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
1678 .build();
1679
1680 let report = capture(
1681 &ctx,
1682 CaptureOptions {
1683 intent: "exercise the deep capture interface".into(),
1684 confidence: Some(0.9),
1685 force: false,
1686 worktree_status_options: WorktreeStatusOptions::default(),
1687 machine_contract_input: None,
1688 },
1689 |_| {
1690 Ok(CaptureAttribution {
1691 attribution: Attribution::human(Principal::new("Ada", "ada@example.com")),
1692 principal_source: "embedder".into(),
1693 harness_session_id: None,
1694 })
1695 },
1696 )
1697 .expect("capture succeeds");
1698
1699 assert_eq!(report.output_kind, "capture");
1700 assert_eq!(report.captured_path_count, 1);
1701 assert_eq!(report.principal.name, "Ada");
1702 assert_eq!(report.principal.email, "ada@example.com");
1703 assert_eq!(report.principal_source, "embedder");
1704 assert_eq!(CaptureReport::CONTRACT.schema_name, "capture");
1705 assert_eq!(
1706 ctx.require_repo()
1707 .expect("repository")
1708 .head()
1709 .expect("read head")
1710 .expect("captured head")
1711 .short(),
1712 report.state_id
1713 );
1714
1715 let wire = serde_json::to_value(&report).expect("serialize report");
1716 assert_eq!(wire["output_kind"], "capture");
1717 assert!(wire.get("diagnostics").is_none());
1718 assert!(wire.get("captured_thread_targets_integration").is_none());
1719 }
1720
1721 #[test]
1722 fn manual_resolution_land_action_quotes_untrusted_thread_ids() {
1723 let temp = TempDir::new().expect("create temp repository");
1724 let repo = Repository::init_default(temp.path()).expect("initialize repository");
1725
1726 assert_eq!(
1727 manual_resolution_land_action(&repo, "bad;echo pwn", None),
1728 "heddle land --thread 'bad;echo pwn'"
1729 );
1730 assert_eq!(
1731 manual_resolution_land_action(&repo, "-danger", None),
1732 "heddle land --thread=-danger"
1733 );
1734 }
1735
1736 #[test]
1737 fn clean_overlay_refuses_before_resolving_attribution() {
1738 let temp = TempDir::new().expect("create temp repository");
1739 SleyRepository::init(temp.path()).expect("initialize Git repository");
1740 let repo = Repository::init_git_overlay_sidecar(temp.path())
1741 .expect("initialize Git-overlay sidecar");
1742 let ctx = ExecutionContext::builder().repo(repo).build();
1743 let resolver_called = Cell::new(false);
1744
1745 let error = capture(
1746 &ctx,
1747 CaptureOptions {
1748 intent: "nothing changed".into(),
1749 confidence: None,
1750 force: false,
1751 worktree_status_options: WorktreeStatusOptions::default(),
1752 machine_contract_input: None,
1753 },
1754 |_| {
1755 resolver_called.set(true);
1756 Ok(CaptureAttribution {
1757 attribution: Attribution::human(Principal::new("Ada", "ada@example.com")),
1758 principal_source: "embedder".into(),
1759 harness_session_id: None,
1760 })
1761 },
1762 )
1763 .expect_err("clean overlay must refuse capture");
1764
1765 assert!(!resolver_called.get());
1766 assert!(error.to_string().contains("nothing to capture"));
1767 }
1768
1769 #[test]
1770 fn capture_always_uses_git_scope_none() {
1771 assert_eq!(
1772 plan_git_scope(
1773 SaveVerb::Capture,
1774 RepositoryCapability::GitOverlay,
1775 true,
1776 true
1777 ),
1778 GitScope::None
1779 );
1780 assert_eq!(
1781 plan_git_scope(
1782 SaveVerb::Capture,
1783 RepositoryCapability::NativeHeddle,
1784 false,
1785 false
1786 ),
1787 GitScope::None
1788 );
1789 }
1790
1791 #[test]
1792 fn commit_native_never_writes_git() {
1793 assert_eq!(
1794 plan_git_scope(
1795 SaveVerb::Commit,
1796 RepositoryCapability::NativeHeddle,
1797 true,
1798 true
1799 ),
1800 GitScope::None
1801 );
1802 }
1803
1804 #[test]
1805 fn commit_git_overlay_routes_staged_vs_worktree() {
1806 assert_eq!(
1807 plan_git_scope(
1808 SaveVerb::Commit,
1809 RepositoryCapability::GitOverlay,
1810 true,
1811 false
1812 ),
1813 GitScope::Staged
1814 );
1815 assert_eq!(
1816 plan_git_scope(
1817 SaveVerb::Commit,
1818 RepositoryCapability::GitOverlay,
1819 true,
1820 true
1821 ),
1822 GitScope::WorktreeAll
1823 );
1824 assert_eq!(
1825 plan_git_scope(
1826 SaveVerb::Commit,
1827 RepositoryCapability::GitOverlay,
1828 false,
1829 false
1830 ),
1831 GitScope::WorktreeAll
1832 );
1833 }
1834
1835 #[test]
1836 fn checkpoint_routes_staged_flag() {
1837 assert_eq!(
1838 plan_git_scope(
1839 SaveVerb::Checkpoint,
1840 RepositoryCapability::GitOverlay,
1841 true,
1842 false
1843 ),
1844 GitScope::Staged
1845 );
1846 assert_eq!(
1847 plan_git_scope(
1848 SaveVerb::Checkpoint,
1849 RepositoryCapability::GitOverlay,
1850 false,
1851 false
1852 ),
1853 GitScope::WorktreeAll
1854 );
1855 }
1856
1857 #[test]
1858 fn plan_creates_new_state_routing() {
1859 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1860 let capture = SavePlan::capture("wip", attr.clone());
1861 assert!(plan_creates_new_state(&capture, true));
1862 assert!(plan_creates_new_state(&capture, false));
1863
1864 let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
1865 assert!(!plan_creates_new_state(&checkpoint, true));
1866 assert!(plan_creates_new_state(&checkpoint, false));
1867
1868 let staged =
1869 SavePlan::commit("msg", attr, GitScope::Staged).with_supplied_tree(Tree::new());
1870 assert!(plan_creates_new_state(&staged, true));
1871 }
1872
1873 #[test]
1874 fn plan_writes_git_checkpoint_respects_scope_and_capability() {
1875 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1876 let capture = SavePlan::capture("wip", attr.clone());
1877 assert!(!plan_writes_git_checkpoint(
1878 &capture,
1879 RepositoryCapability::GitOverlay
1880 ));
1881
1882 let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
1883 assert!(plan_writes_git_checkpoint(
1884 &commit,
1885 RepositoryCapability::GitOverlay
1886 ));
1887 assert!(!plan_writes_git_checkpoint(
1888 &commit,
1889 RepositoryCapability::NativeHeddle
1890 ));
1891
1892 let none = SavePlan::commit("msg", attr, GitScope::None);
1893 assert!(!plan_writes_git_checkpoint(
1894 &none,
1895 RepositoryCapability::GitOverlay
1896 ));
1897 }
1898
1899 #[test]
1900 fn save_plan_builders_set_expected_defaults() {
1901 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1902 let capture = SavePlan::capture("intent", attr.clone());
1903 assert_eq!(capture.verb, SaveVerb::Capture);
1904 assert_eq!(capture.git_scope, GitScope::None);
1905 assert!(!capture.coalesce_snapshot_and_checkpoint);
1906
1907 let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
1908 assert_eq!(commit.verb, SaveVerb::Commit);
1909 assert!(commit.coalesce_snapshot_and_checkpoint);
1910 assert!(commit.commit_safe_post_verify);
1911
1912 let staged = SavePlan::checkpoint(None, attr, true);
1913 assert_eq!(staged.git_scope, GitScope::Staged);
1914 assert!(!staged.require_clean_worktree);
1915 assert!(staged.reuse_current_state);
1916 }
1917
1918 #[test]
1919 fn tree_leaf_name_and_commit_next_action() {
1920 assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
1921 assert_eq!(tree_leaf_name("solo"), "solo");
1922 assert_eq!(
1923 commit_next_action_from_trust("heddle push", false, false).as_deref(),
1924 Some("heddle push")
1925 );
1926 assert_eq!(
1927 commit_next_action_from_trust("", false, true).as_deref(),
1928 Some("heddle verify")
1929 );
1930 assert_eq!(
1931 commit_next_action_from_trust("", true, true).as_deref(),
1932 Some("heddle push")
1933 );
1934 assert_eq!(commit_next_action_from_trust("", true, false), None);
1935 }
1936
1937 #[test]
1938 fn commit_git_index_plan_modes() {
1939 let staged = vec!["a.rs".into()];
1940 let extra = vec!["unstaged: b.rs".into(), "untracked: c.rs".into()];
1941 let staged_only = plan_commit_git_index(&staged, &extra, false);
1942 assert_eq!(staged_only.commit_mode, "staged_index");
1943 assert_eq!(staged_only.will_commit, vec!["a.rs"]);
1944 assert_eq!(staged_only.preserved_after_commit.len(), 2);
1945
1946 let all = plan_commit_git_index(&staged, &extra, true);
1947 assert_eq!(all.commit_mode, "worktree_all_explicit");
1948 assert_eq!(all.will_commit.len(), 3);
1949
1950 let index_only = plan_commit_git_index_only(&staged, &extra);
1951 assert_eq!(index_only.commit_mode, "staged_index");
1952 assert_eq!(index_only.will_commit, vec!["a.rs"]);
1953
1954 assert!(commit_scope_text("staged_index").contains("staged Git index"));
1955 assert!(staged_commit_summary("ok", 1, 2).contains("left 2 unstaged/untracked"));
1956 assert_eq!(staged_commit_summary("ok", 1, 0), "ok");
1957 }
1958}