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 if let Some(path) = env_store::reserved_materialization_on_disk(repo.root()) {
567 return Err(capture_refusal(
568 "reserved_materialization_path",
569 format!("Refusing to capture reserved path `{path}`"),
570 "Inject secrets with `heddle env run --profile <name> -- <cmd>` instead of writing `.env` files.",
571 format!("`{path}` is a reserved confidential-runtime materialization path"),
572 "capture would ingest reserved plaintext into Source History",
573 "repository state, refs, metadata, and worktree files were left unchanged",
574 vec!["heddle env run --profile <name> -- <cmd>".to_string()],
575 ));
576 }
577 preflight_unimported_git_history(repo, "capture")?;
578 let complete_thread_resolution = merge_resolution_is_complete(repo)?;
579 let worktree_status_started = Instant::now();
580 let known_worktree_changes = if complete_thread_resolution {
581 None
582 } else {
583 let status = capture_worktree_status(repo, &options.worktree_status_options)?;
584 if repo.capability() == RepositoryCapability::GitOverlay && status.is_clean() {
585 return Err(map_capture_error(anyhow!(HeddleError::NoChanges)));
586 }
587 Some(status)
588 };
589
590 let worktree_status = repo.git_overlay_worktree_status();
591 let worktree_status_ms = worktree_status_started.elapsed().as_millis();
592
593 let preflight_started = Instant::now();
594 preflight_large_capture(options.force, &worktree_status)?;
595 preflight_capture_mutation(
596 repo,
597 &worktree_status,
598 options.machine_contract_input.as_ref(),
599 )?;
600 let preflight_ms = preflight_started.elapsed().as_millis();
601 let attribution_started = Instant::now();
602 let resolved_attribution = resolve_attribution(repo)?;
603 let harness_session_id = resolved_attribution
604 .attribution
605 .agent
606 .is_some()
607 .then(|| resolved_attribution.harness_session_id.clone())
608 .flatten();
609 let attribution_ms = attribution_started.elapsed().as_millis();
610
611 let plan = SavePlan {
612 verb: SaveVerb::Capture,
613 intent: Some(options.intent),
614 confidence: options.confidence,
615 attribution: resolved_attribution.attribution,
616 git_scope: GitScope::None,
617 supplied_tree: None,
618 reuse_current_state: false,
619 require_clean_worktree: false,
620 require_worktree_change: repo.capability() == RepositoryCapability::NativeHeddle
625 && !complete_thread_resolution,
626 worktree_status_options: options.worktree_status_options,
627 known_worktree_changes,
628 run_hooks: true,
629 commit_safe_post_verify: false,
630 coalesce_snapshot_and_checkpoint: false,
631 linearize_git_parent: false,
632 precomputed_worktree_status: Some(worktree_status),
633 machine_contract_input: options.machine_contract_input,
634 };
635 let execute_save_started = Instant::now();
636 let save = execute_save(repo, plan).map_err(map_capture_error)?;
637 let execute_save_ms = execute_save_started.elapsed().as_millis();
638 if let Some(session_id) = harness_session_id
639 && let Err(error) = crate::record_last_turn_capture(repo, &session_id, save.state_id)
640 {
641 tracing::warn!(%error, "could not update reconstructible last-turn anchor");
642 }
643
644 let manual_resolution_action = if complete_thread_resolution {
645 complete_current_thread_manual_resolution(repo)?
646 } else {
647 None
648 };
649 update_capture_intent_to_add(repo, &save.state_id);
650
651 let current_thread = current_thread(repo)?;
652 let captured_thread_targets_integration = current_thread
653 .as_ref()
654 .and_then(|thread| thread.target_thread.as_ref())
655 .is_some();
656 let task_assignment_id = active_task_assignment_id(repo, current_thread.as_ref())?;
657 let principal_source = resolved_attribution.principal_source;
658 let warnings = bulk_capture_warning(save.captured_path_count)
659 .into_iter()
660 .collect();
661
662 let mut recommended_action = non_empty_action(&save.verification.recommended_action);
663 let mut recommended_action_template = recommended_action
664 .as_deref()
665 .and_then(action_template)
666 .or_else(|| save.verification.recommended_action_template.clone());
667 if let Some(action) = manual_resolution_action {
668 recommended_action_template = action_template(&action);
669 recommended_action = Some(action);
670 }
671
672 let principal = CapturePrincipalReport {
673 name: save.principal.name_lossy().into_owned(),
674 email: save.principal.email_lossy().into_owned(),
675 };
676 let agent = save.agent.as_ref().map(|agent| CaptureAgentReport {
677 provider: agent.provider.clone(),
678 model: agent.model.clone(),
679 session_id: agent.session_id.clone(),
680 segment_id: agent.segment_id.clone(),
681 policy_id: agent.policy_id.clone(),
682 thought_level: agent.thought_level.clone(),
683 parent: agent.parent.clone(),
684 });
685 Ok(CaptureReport {
686 output_kind: "capture",
687 state_id: save.state_id.short(),
688 content_hash: save.content_hash.short(),
689 intent: save.intent.clone(),
690 confidence: save.confidence,
691 task_assignment_id,
692 principal,
693 principal_source,
694 agent,
695 promotion_suggested: save.promotion_suggested,
696 heavy_impact_paths: save.heavy_impact_paths.clone(),
697 captured_path_count: save.captured_path_count,
698 warnings,
699 signed: save.signed,
700 message: save.summary.clone(),
701 recommended_action,
702 recommended_action_template,
703 verification: save.verification.clone(),
704 captured_thread_targets_integration,
705 diagnostics: CaptureDiagnostics {
706 save,
707 profile: CaptureProfile {
708 worktree_status_ms,
709 preflight_ms,
710 attribution_ms,
711 execute_save_ms,
712 },
713 },
714 })
715}
716
717fn preflight_unimported_git_history(repo: &Repository, action: &str) -> Result<()> {
718 if repo.capability() != RepositoryCapability::GitOverlay {
719 return Ok(());
720 }
721 let Some(guidance) = repo.git_import_guidance()? else {
722 return Ok(());
723 };
724 if !import_guidance_includes_active_branch(&guidance) {
725 return Ok(());
726 }
727 let branches = preview_paths(&guidance.missing_branches);
728 let command = guidance.recommended_command;
729 Err(capture_refusal(
730 "git_history_needs_import",
731 format!("Refusing to {action}: Git history has not been imported into Heddle"),
732 format!("Run `{command}` before retrying `heddle {action}`."),
733 format!("Git branch(es) waiting for Heddle import: {branches}"),
734 format!(
735 "{action} would write new Heddle state before Heddle has adopted the existing Git history"
736 ),
737 "Git refs, Heddle refs, and worktree files were left unchanged",
738 vec![command],
739 ))
740}
741
742fn preflight_capture_mutation(
743 repo: &Repository,
744 worktree_status: &repo::Result<Option<WorktreeStatus>>,
745 machine_contract_input: Option<&MachineContractInput>,
746) -> Result<()> {
747 if repo.capability() != RepositoryCapability::GitOverlay {
748 return Ok(());
749 }
750 if let Some(operation) = repo.operation_status()?
751 && matches!(operation.scope, OperationScope::Git)
752 {
753 return Err(capture_refusal(
754 "raw_git_operation_in_progress",
755 format!(
756 "Refusing to capture: an externally-started Git {} is in progress",
757 operation.kind
758 ),
759 format!(
760 "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`.",
761 operation.kind
762 ),
763 format!(
764 "Git {} is {}; Heddle cannot safely turn sequencer state into a saved change inside the no-git runtime",
765 operation.kind, operation.state
766 ),
767 "capture would capture worktree/index contents while Git still has unresolved sequencer metadata",
768 "Git refs, Git sequencer files, Heddle refs, and worktree files were left unchanged",
769 vec!["heddle verify".to_string()],
770 ));
771 }
772
773 let health = build_repository_verification_health_with_worktree_status(repo, worktree_status);
774 let trust = if let Some(input) = machine_contract_input {
775 build_repository_verification_state_with_worktree_status_and_machine_contract(
776 repo,
777 health,
778 worktree_status,
779 input,
780 )
781 } else {
782 build_repository_verification_state_with_worktree_status(repo, health, worktree_status)
783 };
784 if trust.status != "needs_reconcile" || uncheckpointed_state_is_ahead_of_git(repo)? {
785 return Ok(());
786 }
787 let primary = if trust.recommended_action.trim().is_empty() {
788 "heddle verify".to_string()
789 } else {
790 trust.recommended_action.clone()
791 };
792 let recovery_commands = if trust.recovery_commands.is_empty() {
793 vec![primary.clone()]
794 } else {
795 trust.recovery_commands.clone()
796 };
797 Err(capture_refusal(
798 "repository_verification_blocked",
799 format!(
800 "Refusing to capture: repository verification is blocked ({})",
801 trust.status
802 ),
803 format!("Run `{primary}` before retrying `heddle capture`."),
804 format!(
805 "repository verification status is {}: {}",
806 trust.status, trust.summary
807 ),
808 "capture would write new Heddle or Git state while Git and Heddle disagree",
809 "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
810 recovery_commands,
811 ))
812}
813
814fn uncheckpointed_state_is_ahead_of_git(repo: &Repository) -> Result<bool> {
815 let Some(branch) = repo.git_overlay_current_branch()? else {
816 return Ok(false);
817 };
818 let Some(tip) = repo.git_overlay_branch_tip(&branch)? else {
819 return Ok(false);
820 };
821 let Some(mapped) = tip.mapped_state else {
822 return Ok(false);
823 };
824 let Some(current) = repo.current_state()? else {
825 return Ok(false);
826 };
827 if mapped == current.state_id {
828 return Ok(false);
829 }
830 let mut graph = CommitGraphIndex::new(repo);
831 if !graph
832 .is_ancestor(&mapped, ¤t.state_id)
833 .unwrap_or(false)
834 || graph
835 .is_ancestor(¤t.state_id, &mapped)
836 .unwrap_or(false)
837 {
838 return Ok(false);
839 }
840 Ok(repo
841 .latest_git_checkpoint_for_state(¤t.state_id)?
842 .is_none())
843}
844
845fn preflight_large_capture(
846 force: bool,
847 worktree_status: &repo::Result<Option<WorktreeStatus>>,
848) -> Result<()> {
849 if force {
850 return Ok(());
851 }
852 let Ok(Some(status)) = worktree_status else {
853 return Ok(());
854 };
855 let total = status.change_count();
856 let delete_count = status.deleted.len();
857 let add_count = status.added.len();
858 if !crate::large_capture_requires_force(total, delete_count, add_count) {
859 return Ok(());
860 }
861 let sample = status
862 .deleted
863 .iter()
864 .chain(status.added.iter())
865 .chain(status.modified.iter())
866 .take(5)
867 .map(|path| path.display().to_string())
868 .collect::<Vec<_>>()
869 .join(", ");
870 let sample = if sample.is_empty() {
871 "no sample paths available".to_string()
872 } else {
873 sample
874 };
875 Err(capture_refusal(
876 "large_capture_requires_force",
877 format!(
878 "Large capture safety check: this would capture {total} changed paths ({delete_count} deletions, {add_count} additions)"
879 ),
880 "If this is intentional, rerun with `heddle capture --force -m \"...\"`.",
881 format!("sample changed paths: {sample}"),
882 "capture would preserve an unusually large Git-overlay worktree change without an explicit confirmation",
883 "repository state, refs, metadata, and worktree files were left unchanged",
884 vec!["heddle capture --force -m \"...\"".to_string()],
885 ))
886}
887
888fn merge_resolution_is_complete(repo: &Repository) -> Result<bool> {
889 Ok(repo
890 .merge_state_manager()
891 .load()?
892 .is_some_and(|merge_state| {
893 merge_state
894 .conflicts
895 .iter()
896 .all(|path| merge_state.resolved.contains(path))
897 }))
898}
899
900fn capture_worktree_status(
906 repo: &Repository,
907 options: &WorktreeStatusOptions,
908) -> Result<WorktreeStatus> {
909 if repo.current_state_for_worktree_status()?.is_none()
910 && let Some(status) = repo.git_overlay_worktree_status()?
911 {
912 return Ok(status);
913 }
914 let tree = match repo.current_state_for_worktree_status()? {
915 Some(state) => repo.require_tree_for_worktree_status(&state.tree)?,
916 None => Tree::new(),
917 };
918 Ok(repo.compare_worktree_cached_with_options(&tree, options)?)
919}
920
921pub fn complete_current_thread_manual_resolution(repo: &Repository) -> Result<Option<String>> {
926 let Some(current_thread) = repo.current_lane()? else {
927 return Ok(None);
928 };
929 let Some(current_state) = repo.head()? else {
930 return Ok(None);
931 };
932 let Some(current_state_object) = repo.store().get_state(¤t_state)? else {
933 return Ok(None);
934 };
935 let manager = ThreadManager::new(repo.heddle_dir());
936 let Some(mut thread) = manager.find_by_thread(¤t_thread)? else {
937 return Ok(None);
938 };
939 let Some(target_thread) = thread.target_thread.clone() else {
940 return Ok(None);
941 };
942 let Some(target_state) = repo.refs().get_thread(&ThreadName::new(&target_thread))? else {
943 return Ok(None);
944 };
945 let Some(target_state_object) = repo.store().get_state(&target_state)? else {
946 return Ok(None);
947 };
948 let before = crate::capture_thread_update_before(repo, &manager, &thread)?;
949
950 thread.base_state = target_state.short();
951 thread.base_root = target_state_object.tree.short();
952 update_thread_state_from_state(&mut thread, ¤t_state_object);
953 thread.state = ThreadState::Ready;
954 thread.freshness = ThreadFreshness::Current;
955 thread.integration_policy_result = ThreadIntegrationPolicy {
956 status: Some("manual_resolved".to_string()),
957 reason: Some("manual conflict resolution captured".to_string()),
958 manual_resolution_state: Some(current_state.short()),
959 conflicts_resolved_manually: true,
960 };
961 thread.updated_at = Utc::now();
962 let thread_id = thread.id.clone();
963 let target = thread.target_thread.clone();
964 crate::save_thread_update(repo, &manager, &thread, before, current_state)?;
965
966 Ok(Some(manual_resolution_land_action(
967 repo,
968 &thread_id,
969 target.as_deref(),
970 )))
971}
972
973fn manual_resolution_land_action(
974 repo: &Repository,
975 thread_id: &str,
976 target_thread: Option<&str>,
977) -> String {
978 let action = crate::status::next_action::land_local_command(thread_id);
979 contextual_thread_action(repo, thread_id, target_thread, &action)
980}
981
982fn update_capture_intent_to_add(repo: &Repository, state_id: &StateId) {
983 if repo.capability() != RepositoryCapability::GitOverlay {
984 return;
985 }
986 let projection = GitProjection::new(repo);
987 if let Err(error) = projection.update_intent_to_add(state_id) {
988 tracing::debug!(%error, "intent-to-add index update skipped");
989 }
990}
991
992fn current_thread(repo: &Repository) -> Result<Option<Thread>> {
993 let manager = ThreadManager::new(repo.heddle_dir());
994 if let Some(thread) = manager.find_by_execution_root(repo.root())? {
995 return Ok(Some(thread));
996 }
997 let Head::Attached { thread } = repo.head_ref()? else {
998 return Ok(None);
999 };
1000 let current_state_id = repo.refs().get_thread(&thread)?;
1001 let current_state = current_state_id.map(|state| state.short());
1002 let base_root = current_state_id
1003 .and_then(|state| repo.store().get_state(&state).ok().flatten())
1004 .map(|state| state.tree.short())
1005 .unwrap_or_default();
1006 let thread = thread.to_string();
1007 Ok(Some(Thread {
1008 id: thread.clone(),
1009 thread,
1010 target_thread: None,
1011 parent_thread: None,
1012 mode: ThreadMode::Materialized,
1013 state: ThreadState::Active,
1014 base_state: current_state.clone().unwrap_or_default(),
1015 base_root,
1016 current_state,
1017 merged_state: None,
1018 task: None,
1019 execution_path: repo.root().to_path_buf(),
1020 materialized_path: None,
1021 changed_paths: Vec::new(),
1022 impact_categories: Vec::new(),
1023 heavy_impact_paths: Vec::new(),
1024 promotion_suggested: false,
1025 freshness: ThreadFreshness::Unknown,
1026 verification_summary: Default::default(),
1027 confidence_summary: Default::default(),
1028 integration_policy_result: Default::default(),
1029 created_at: Utc::now(),
1030 updated_at: Utc::now(),
1031 ephemeral: None,
1032 auto: false,
1033 shared_target_dir: None,
1034 }))
1035}
1036
1037fn active_task_assignment_id(repo: &Repository, thread: Option<&Thread>) -> Result<Option<String>> {
1038 let Some(thread) = thread else {
1039 return Ok(None);
1040 };
1041 let store = ActorPresenceStore::new(repo.heddle_dir());
1042 Ok(store
1043 .active_entries()?
1044 .into_iter()
1045 .filter(|entry| entry.thread == thread.id)
1046 .max_by_key(|entry| entry.started_at)
1047 .and_then(|entry| entry.task_assignment_id))
1048}
1049
1050fn bulk_capture_warning(captured_path_count: usize) -> Option<String> {
1051 (captured_path_count >= BULK_CAPTURE_WARNING_THRESHOLD).then(|| {
1052 format!(
1053 "captured {captured_path_count} paths in one operation; check root .gitignore and .heddleignore rules if build artifacts or tool state were included"
1054 )
1055 })
1056}
1057
1058fn non_empty_action(action: &str) -> Option<String> {
1059 (!action.trim().is_empty()).then(|| action.to_string())
1060}
1061
1062fn map_capture_error(error: anyhow::Error) -> anyhow::Error {
1063 if error.chain().any(|cause| {
1064 cause
1065 .downcast_ref::<HeddleError>()
1066 .is_some_and(|error| matches!(error, HeddleError::NoChanges))
1067 }) {
1068 return capture_refusal(
1069 "nothing_to_capture",
1070 "nothing to capture: worktree has no changes eligible for Heddle capture",
1071 "Inspect the worktree with `heddle status`; make changes before running `heddle capture -m \"...\"`.",
1072 "the worktree has no modified, deleted, or untracked paths relative to the current Heddle state",
1073 "capture would not create a meaningful Heddle state",
1074 "repository state was left unchanged",
1075 vec!["heddle status".to_string()],
1076 );
1077 }
1078 if error.chain().any(|cause| {
1079 cause
1080 .downcast_ref::<std::io::Error>()
1081 .is_some_and(objects::fs_atomic::is_out_of_space)
1082 }) {
1083 return capture_refusal(
1084 "capture_out_of_space",
1085 format!("Capture aborted because the filesystem is out of space: {error:#}"),
1086 "Free disk space and re-run `heddle capture`. Your working tree changes are intact.",
1087 "the filesystem reported no remaining space while Heddle was writing captured objects",
1088 "retrying before freeing space may fail again or leave another incomplete object write",
1089 "the working tree was not modified; already-committed repository data remains behind atomic write boundaries",
1090 vec!["heddle capture -m \"...\"".to_string()],
1091 );
1092 }
1093 error
1094}
1095
1096#[allow(clippy::too_many_arguments)]
1097fn capture_refusal(
1098 kind: &'static str,
1099 error: impl Into<String>,
1100 hint: impl Into<String>,
1101 unsafe_condition: impl Into<String>,
1102 would_change: impl Into<String>,
1103 preserved: impl Into<String>,
1104 recovery_commands: Vec<String>,
1105) -> anyhow::Error {
1106 anyhow!(HeddleError::recovery(
1107 RecoveryDetails::safety_refusal(
1108 kind,
1109 error,
1110 hint,
1111 unsafe_condition,
1112 would_change,
1113 preserved,
1114 )
1115 .with_recovery_commands(recovery_commands),
1116 ))
1117}
1118
1119fn preview_paths(paths: &[String]) -> String {
1120 let shown = paths
1121 .iter()
1122 .take(12)
1123 .cloned()
1124 .collect::<Vec<_>>()
1125 .join(", ");
1126 let hidden = paths.len().saturating_sub(12);
1127 if hidden == 0 {
1128 shown
1129 } else {
1130 format!("{shown}, and {hidden} more")
1131 }
1132}
1133
1134pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
1140 if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
1146 return Err(anyhow!(HeddleError::recovery(
1147 RecoveryDetails::safety_refusal(
1148 "native_checkpoint_unavailable",
1149 "Git checkpointing is only available in Git-overlay repositories",
1150 "Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
1151 "this checkout is not a Git-overlay repository",
1152 "checkpoint would try to write a Git commit where no active Git store is bound",
1153 "repository state, refs, and worktree files were left unchanged",
1154 ),
1155 )));
1156 }
1157
1158 let previous_state_started = Instant::now();
1159 let (previous_state, previous_state_profile) =
1160 repo.current_state_for_worktree_status_profiled()?;
1161 let previous_state_ms = previous_state_started.elapsed().as_millis();
1162 let has_current = previous_state.is_some();
1163 let mut created_new_state = false;
1164 let mut snapshot_profile = SnapshotProfile::default();
1165 let mut thread_metadata_ms = 0u128;
1166 let mut promotion_suggested = false;
1167 let mut heavy_impact_paths = Vec::new();
1168 let mut snapshot_state_id: Option<StateId> = None;
1169 let mut captured_path_count = 0usize;
1170 let mut state_create_ms = 0u128;
1171 let mut captured_path_count_ms = 0u128;
1172
1173 let mut state = if plan_creates_new_state(&plan, has_current) {
1174 created_new_state = true;
1175 let state_create_started = Instant::now();
1176 let execution = create_heddle_state(repo, &plan)?;
1177 state_create_ms = state_create_started.elapsed().as_millis();
1178 snapshot_profile = execution.profile;
1179 thread_metadata_ms = execution.thread_metadata_ms;
1180 promotion_suggested = execution.promotion_suggested;
1181 heavy_impact_paths = execution.heavy_impact_paths;
1182 snapshot_state_id = Some(execution.state.state_id);
1183 let previous_tree = match previous_state.as_ref() {
1184 Some(state) => state.tree,
1185 None => repo.store().put_tree(&Tree::new())?,
1186 };
1187 let captured_path_count_started = Instant::now();
1188 captured_path_count = repo
1189 .diff_trees(&previous_tree, &execution.state.tree)?
1190 .len();
1191 captured_path_count_ms = captured_path_count_started.elapsed().as_millis();
1192 execution.state
1193 } else {
1194 repo.current_state()?
1195 .ok_or_else(|| anyhow!("no captured state found for save"))?
1196 };
1197
1198 let mut git_commit = None;
1199 let mut git_previous_commit = None;
1200 let mut git_checkpoint = None;
1201
1202 if plan_writes_git_checkpoint(&plan, repo.capability()) {
1203 if plan.require_clean_worktree {
1204 let tree = repo.require_tree(&state.tree)?;
1205 let status = repo.compare_worktree_cached_detailed_with_options(
1206 &tree,
1207 &plan.worktree_status_options,
1208 )?;
1209 if !status.is_clean() {
1210 return Err(anyhow!(HeddleError::recovery(
1211 RecoveryDetails::safety_refusal(
1212 "dirty_worktree",
1213 "Save worktree changes before committing",
1214 "Save the work with `heddle capture -m \"...\"`, then retry the commit.",
1215 "the current Heddle state was left unchanged; these paths have not been captured",
1216 "commit would write Git history that does not include dirty worktree paths",
1217 "the current Heddle state was left unchanged; these paths have not been captured",
1218 ),
1219 )));
1220 }
1221 }
1222
1223 if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
1224 && repo.pending_git_checkpoint_intent()?.is_none()
1225 {
1226 git_commit = Some(existing.git_commit.clone());
1227 git_checkpoint = Some(existing);
1228 } else {
1229 let previous = repo
1230 .pending_git_checkpoint_intent()?
1231 .and_then(|intent| intent.previous_git_oid)
1232 .or_else(|| git_rev_parse_head(repo.root()));
1233 git_previous_commit = previous.clone();
1234 let summary = checkpoint_summary(&plan, &state);
1235 let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
1236 if plan.coalesce_snapshot_and_checkpoint
1237 && let Some(state_id) = snapshot_state_id.as_ref()
1238 {
1239 coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
1240 }
1241 git_commit = Some(record.git_commit.clone());
1242 git_checkpoint = Some(record);
1243 }
1244 }
1245
1246 let captured_native_worktree = created_new_state
1250 && plan.supplied_tree.is_none()
1251 && repo.capability() == RepositoryCapability::NativeHeddle;
1252 let captured_worktree_status = Ok(Some(objects::worktree::WorktreeStatus::default()));
1253 let verification_started = Instant::now();
1254 let mut verification = if captured_native_worktree && git_checkpoint.is_none() {
1255 let health = build_repository_verification_health_with_worktree_status(
1256 repo,
1257 &captured_worktree_status,
1258 );
1259 if let Some(input) = &plan.machine_contract_input {
1260 build_repository_verification_state_with_worktree_status_and_machine_contract(
1261 repo,
1262 health,
1263 &captured_worktree_status,
1264 input,
1265 )
1266 } else {
1267 build_repository_verification_state_with_worktree_status(
1268 repo,
1269 health,
1270 &captured_worktree_status,
1271 )
1272 }
1273 } else if created_new_state || git_checkpoint.is_some() {
1274 if let Some(input) = &plan.machine_contract_input {
1275 build_repository_verification_state_with_machine_contract(repo, input)?
1276 } else {
1277 build_repository_verification_state(repo)?
1278 }
1279 } else if let Some(status) = &plan.precomputed_worktree_status {
1280 let health = build_repository_verification_health_with_worktree_status(repo, status);
1281 if let Some(input) = &plan.machine_contract_input {
1282 build_repository_verification_state_with_worktree_status_and_machine_contract(
1283 repo, health, status, input,
1284 )
1285 } else {
1286 build_repository_verification_state_with_worktree_status(repo, health, status)
1287 }
1288 } else {
1289 if let Some(input) = &plan.machine_contract_input {
1290 build_repository_verification_state_with_machine_contract(repo, input)?
1291 } else {
1292 build_repository_verification_state(repo)?
1293 }
1294 };
1295 if plan.commit_safe_post_verify {
1296 soften_commit_next_action(&mut verification);
1297 }
1298 let post_verification_ms = verification_started.elapsed().as_millis();
1299
1300 let summary = match plan.verb {
1301 SaveVerb::Capture => format!(
1302 "Captured state {} ({})",
1303 state.state_id.short(),
1304 state.hash().short()
1305 ),
1306 SaveVerb::Commit => plan
1307 .intent
1308 .clone()
1309 .unwrap_or_else(|| format!("Commit {}", state.state_id.short())),
1310 SaveVerb::Checkpoint => git_checkpoint
1311 .as_ref()
1312 .map(|r| r.summary.clone())
1313 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
1314 };
1315
1316 let signature_lookup_started = Instant::now();
1317 let signed = repo.get_state_signature(&state.id())?.is_some();
1318 let signature_lookup_ms = signature_lookup_started.elapsed().as_millis();
1319 Ok(SaveReport {
1320 verb: plan.verb,
1321 state_id: state.state_id,
1322 content_hash: state.hash(),
1323 intent: state.intent.clone(),
1324 confidence: state.confidence,
1325 signed,
1326 git_commit,
1327 git_previous_commit,
1328 summary,
1329 principal: state.attribution.principal.clone(),
1330 agent: state.attribution.agent.clone(),
1331 promotion_suggested,
1332 heavy_impact_paths,
1333 captured_path_count,
1334 verification,
1335 created_new_state,
1336 git_checkpoint,
1337 snapshot_profile,
1338 state_create_ms,
1339 captured_path_count_ms,
1340 post_verification_ms,
1341 thread_metadata_ms,
1342 previous_state_ms,
1343 previous_state_profile,
1344 signature_lookup_ms,
1345 })
1346}
1347
1348struct CreatedState {
1349 state: State,
1350 profile: SnapshotProfile,
1351 thread_metadata_ms: u128,
1352 promotion_suggested: bool,
1353 heavy_impact_paths: Vec<String>,
1354}
1355
1356fn create_heddle_state(repo: &Repository, plan: &SavePlan) -> Result<CreatedState> {
1357 let hook_manager = HookManager::new(repo);
1358 let hook_ctx = HookContext::new(repo);
1359 let mut post_hook_worktree_changes = None;
1360
1361 if plan.run_hooks {
1362 let pre_snapshot_ran = hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
1363 let pre_capture_payload = serde_json::json!({
1364 "thread": current_thread_name(repo),
1365 "intent": plan.intent.clone().unwrap_or_default(),
1366 });
1367 let pre_capture_response = hook_manager.run_with_payload(
1368 Hook::PreSnapshot,
1369 &hook_ctx,
1370 &pre_capture_payload,
1371 std::time::Duration::from_secs(5),
1372 )?;
1373 if let Some(resp) = pre_capture_response
1374 && !resp.abort.is_empty()
1375 {
1376 return Err(anyhow!(HeddleError::recovery(
1377 RecoveryDetails::safety_refusal(
1378 "hook_veto",
1379 format!("pre_capture hook vetoed: {}", resp.abort),
1380 "Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
1381 format!("pre_capture hook vetoed capture: {}", resp.abort),
1382 "capture would continue after repository policy explicitly aborted the operation",
1383 "the operation stopped at the hook boundary before the protected action ran",
1384 )
1385 .with_recovery_commands(vec!["heddle hook list".to_string()]),
1386 )));
1387 }
1388 if pre_snapshot_ran && plan.supplied_tree.is_none() {
1389 let authoritative_options = WorktreeStatusOptions {
1393 fsmonitor: repo::FsMonitorSettings {
1394 mode: repo::FsMonitorMode::Off,
1395 },
1396 };
1397 post_hook_worktree_changes =
1398 Some(capture_worktree_status(repo, &authoritative_options)?);
1399 }
1400 }
1401 let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
1402 repo.snapshot_tree_with_attribution_profiled(
1403 tree,
1404 plan.intent.clone(),
1405 plan.confidence,
1406 plan.attribution.clone(),
1407 )?
1408 } else if let Some(status) =
1409 post_hook_worktree_changes.or_else(|| plan.known_worktree_changes.clone())
1410 {
1411 repo.snapshot_with_attribution_profiled_from_status(
1412 plan.intent.clone(),
1413 plan.confidence,
1414 plan.attribution.clone(),
1415 status,
1416 plan.require_worktree_change,
1417 )?
1418 } else if plan.require_worktree_change {
1419 repo.snapshot_with_attribution_profiled_if_changed(
1420 plan.intent.clone(),
1421 plan.confidence,
1422 plan.attribution.clone(),
1423 )?
1424 } else {
1425 repo.snapshot_with_attribution_profiled(
1426 plan.intent.clone(),
1427 plan.confidence,
1428 plan.attribution.clone(),
1429 )?
1430 };
1431
1432 let thread_metadata_start = Instant::now();
1433 let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
1434 let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
1435
1436 if plan.run_hooks {
1437 hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
1438 let post_capture_payload = serde_json::json!({
1439 "state_id": execution.state.state_id.to_string_full(),
1440 });
1441 if let Err(err) = hook_manager.run_with_payload(
1442 Hook::PostSnapshot,
1443 &hook_ctx,
1444 &post_capture_payload,
1445 std::time::Duration::from_secs(5),
1446 ) {
1447 tracing::warn!(error = %err, "post_capture hook error swallowed");
1448 }
1449 }
1450
1451 Ok(CreatedState {
1452 state: execution.state,
1453 profile: std::mem::take(&mut execution.profile),
1454 thread_metadata_ms,
1455 promotion_suggested: refresh.promotion_suggested,
1456 heavy_impact_paths: refresh.heavy_impact_paths,
1457 })
1458}
1459
1460fn write_git_checkpoint(
1461 repo: &Repository,
1462 state: &State,
1463 summary: String,
1464 linearize_git_parent: bool,
1465) -> Result<GitCheckpointRecord> {
1466 let _lock = repo.locker().write()?;
1467 objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
1468 let mut bridge = GitProjection::new(repo);
1469 if linearize_git_parent {
1470 bridge.linearize_unmapped_tip_to_checkout();
1471 }
1472 let git_commit = match bridge
1473 .write_through_current_checkout_with_message(state.state_id, summary.clone())?
1474 {
1475 WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
1476 WriteThroughOutcome::Skipped(reason) => {
1477 return Err(anyhow!(HeddleError::recovery(
1478 RecoveryDetails::safety_refusal(
1479 "checkpoint_git_write_skipped",
1480 format!("Git checkpoint write-through was skipped: {reason}"),
1481 "Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
1482 format!("write-through skipped: {reason}"),
1483 "checkpoint would need to write the current Heddle state into the Git branch and index",
1484 "the current Heddle state was preserved; no Git checkpoint record was written",
1485 ),
1486 )));
1487 }
1488 };
1489 let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
1490 anyhow!("Git checkpoint published without its durable finalization intent")
1491 })?;
1492 if intent.phase != repo::GitCheckpointIntentPhase::Published
1493 || intent.state_id != state.state_id.to_string_full()
1494 || intent.new_git_oid != git_commit
1495 {
1496 return Err(anyhow!(
1497 "published Git checkpoint does not match its durable finalization intent"
1498 ));
1499 }
1500 finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
1501}
1502
1503pub fn recover_published_git_checkpoint(
1507 repo: &Repository,
1508 state_id: &StateId,
1509) -> Result<Option<GitCheckpointRecord>> {
1510 let _lock = repo.locker().write()?;
1511 let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
1512 return Ok(None);
1513 };
1514 if intent.state_id != state_id.to_string_full() {
1515 return Ok(None);
1516 }
1517 let current_branch = repo.git_overlay_current_branch()?;
1518 if current_branch.as_deref() != Some(intent.branch.as_str()) {
1519 return Err(anyhow!(
1520 "pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
1521 intent.branch,
1522 current_branch.as_deref().unwrap_or("detached HEAD")
1523 ));
1524 }
1525 let current_oid = git_rev_parse_head(repo.root());
1526 if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
1527 if current_oid == intent.previous_git_oid {
1528 return Ok(None);
1529 }
1530 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1531 return Err(anyhow!(
1532 "prepared Git checkpoint expected HEAD at {} or {}, found {}",
1533 intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
1534 intent.new_git_oid,
1535 current_oid.as_deref().unwrap_or("<unborn>")
1536 ));
1537 }
1538 let git_oid = intent.new_git_oid.clone();
1539 intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
1540 }
1541 if intent.phase != repo::GitCheckpointIntentPhase::Published {
1542 return Ok(None);
1543 }
1544 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1545 return Err(anyhow!(
1546 "published Git checkpoint expected HEAD at {}, found {}",
1547 intent.new_git_oid,
1548 current_oid.as_deref().unwrap_or("<unborn>")
1549 ));
1550 }
1551 let git_commit = intent.new_git_oid.clone();
1552 let summary = intent.summary.clone();
1553 finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
1554}
1555
1556fn finalize_published_git_checkpoint(
1557 repo: &Repository,
1558 state_id: &StateId,
1559 git_commit: String,
1560 summary: String,
1561 intent: repo::GitCheckpointIntent,
1562) -> Result<GitCheckpointRecord> {
1563 let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
1564 objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
1565 let transaction_id = format!(
1566 "git-checkpoint:v1:{}:{}",
1567 state_id.to_string_full(),
1568 git_commit
1569 );
1570 repo.oplog().record_batch_exactly_once(
1571 vec![
1572 OpRecord::GitCheckpoint {
1573 branch: intent.branch,
1574 state: *state_id,
1575 previous_git_oid: intent.previous_git_oid,
1576 new_git_oid: git_commit.clone(),
1577 },
1578 OpRecord::TransactionCommit {
1579 transaction_id: transaction_id.clone(),
1580 op_count: 1,
1581 },
1582 ],
1583 Some(&repo.op_scope()),
1584 &transaction_id,
1585 )?;
1586 objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
1587 repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
1588 Ok(record)
1589}
1590
1591fn coalesce_snapshot_and_checkpoint(
1592 repo: &Repository,
1593 state_id: &StateId,
1594 git_commit: &str,
1595) -> Result<()> {
1596 let snapshot_batch = repo
1597 .oplog()
1598 .recent_batches_scoped(8, Some(&repo.op_scope()))?
1599 .into_iter()
1600 .find(|batch| {
1601 batch.entries.iter().any(|entry| {
1602 matches!(
1603 &entry.operation,
1604 OpRecord::Snapshot { new_state, .. } if new_state == state_id
1605 )
1606 })
1607 })
1608 .ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
1609 let checkpoint_batch = repo
1610 .oplog()
1611 .recent_batches_scoped(8, Some(&repo.op_scope()))?
1612 .into_iter()
1613 .find(|batch| {
1614 batch.entries.iter().any(|entry| {
1615 matches!(
1616 &entry.operation,
1617 OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
1618 )
1619 })
1620 })
1621 .ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
1622 repo.oplog()
1623 .coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
1624 .context(
1625 "commit completed but failed to record capture and Git checkpoint as one undo batch",
1626 )?;
1627 Ok(())
1628}
1629
1630fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
1631 plan.intent
1632 .clone()
1633 .or_else(|| state.intent.clone())
1634 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
1635}
1636
1637fn current_thread_name(repo: &Repository) -> String {
1638 match repo.head_ref() {
1639 Ok(Head::Attached { thread }) => thread.to_string(),
1640 _ => String::new(),
1641 }
1642}
1643
1644fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
1645 let git = SleyRepository::discover(root).ok()?;
1646 git.head().ok()?.oid.map(|id| id.to_string())
1647}
1648
1649fn soften_commit_next_action(trust: &mut RepositoryVerificationState) {
1650 if is_commit_action(&trust.recommended_action) {
1651 trust.recommended_action = "heddle status".to_string();
1652 trust.recommended_action_template = None;
1653 }
1654 for check in &mut trust.checks {
1655 if check
1656 .recommended_action
1657 .as_deref()
1658 .is_some_and(is_commit_action)
1659 {
1660 check.recommended_action = Some("heddle status".to_string());
1661 check.recommended_action_template = None;
1662 }
1663 }
1664}
1665
1666fn is_commit_action(action: &str) -> bool {
1667 let trimmed = action.trim();
1668 trimmed == "heddle capture" || trimmed.starts_with("heddle capture ")
1669}
1670
1671#[cfg(test)]
1672mod tests {
1673 use std::cell::Cell;
1674
1675 use repo::RepositoryCapability;
1676 use tempfile::TempDir;
1677
1678 use super::*;
1679
1680 #[test]
1681 fn capture_interface_owns_mutation_and_returns_the_report_contract() {
1682 let temp = TempDir::new().expect("create temp repository");
1683 let repo = Repository::init_default(temp.path()).expect("initialize repository");
1684 std::fs::write(temp.path().join("tracked.txt"), "captured\n")
1685 .expect("write worktree change");
1686 let ctx = ExecutionContext::builder()
1687 .repo(repo)
1688 .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
1689 .build();
1690
1691 let report = capture(
1692 &ctx,
1693 CaptureOptions {
1694 intent: "exercise the deep capture interface".into(),
1695 confidence: Some(0.9),
1696 force: false,
1697 worktree_status_options: WorktreeStatusOptions::default(),
1698 machine_contract_input: None,
1699 },
1700 |_| {
1701 Ok(CaptureAttribution {
1702 attribution: Attribution::human(Principal::new("Ada", "ada@example.com")),
1703 principal_source: "embedder".into(),
1704 harness_session_id: None,
1705 })
1706 },
1707 )
1708 .expect("capture succeeds");
1709
1710 assert_eq!(report.output_kind, "capture");
1711 assert_eq!(report.captured_path_count, 1);
1712 assert_eq!(report.principal.name, "Ada");
1713 assert_eq!(report.principal.email, "ada@example.com");
1714 assert_eq!(report.principal_source, "embedder");
1715 assert_eq!(CaptureReport::CONTRACT.schema_name, "capture");
1716 assert_eq!(
1717 ctx.require_repo()
1718 .expect("repository")
1719 .head()
1720 .expect("read head")
1721 .expect("captured head")
1722 .short(),
1723 report.state_id
1724 );
1725
1726 let wire = serde_json::to_value(&report).expect("serialize report");
1727 assert_eq!(wire["output_kind"], "capture");
1728 assert!(wire.get("diagnostics").is_none());
1729 assert!(wire.get("captured_thread_targets_integration").is_none());
1730 }
1731
1732 #[test]
1733 fn manual_resolution_land_action_quotes_untrusted_thread_ids() {
1734 let temp = TempDir::new().expect("create temp repository");
1735 let repo = Repository::init_default(temp.path()).expect("initialize repository");
1736
1737 assert_eq!(
1738 manual_resolution_land_action(&repo, "bad;echo pwn", None),
1739 "heddle land --thread 'bad;echo pwn'"
1740 );
1741 assert_eq!(
1742 manual_resolution_land_action(&repo, "-danger", None),
1743 "heddle land --thread=-danger"
1744 );
1745 }
1746
1747 #[test]
1748 fn clean_overlay_refuses_before_resolving_attribution() {
1749 let temp = TempDir::new().expect("create temp repository");
1750 SleyRepository::init(temp.path()).expect("initialize Git repository");
1751 let repo = Repository::init_git_overlay_sidecar(temp.path())
1752 .expect("initialize Git-overlay sidecar");
1753 let ctx = ExecutionContext::builder().repo(repo).build();
1754 let resolver_called = Cell::new(false);
1755
1756 let error = capture(
1757 &ctx,
1758 CaptureOptions {
1759 intent: "nothing changed".into(),
1760 confidence: None,
1761 force: false,
1762 worktree_status_options: WorktreeStatusOptions::default(),
1763 machine_contract_input: None,
1764 },
1765 |_| {
1766 resolver_called.set(true);
1767 Ok(CaptureAttribution {
1768 attribution: Attribution::human(Principal::new("Ada", "ada@example.com")),
1769 principal_source: "embedder".into(),
1770 harness_session_id: None,
1771 })
1772 },
1773 )
1774 .expect_err("clean overlay must refuse capture");
1775
1776 assert!(!resolver_called.get());
1777 assert!(error.to_string().contains("nothing to capture"));
1778 }
1779
1780 #[test]
1781 fn capture_always_uses_git_scope_none() {
1782 assert_eq!(
1783 plan_git_scope(
1784 SaveVerb::Capture,
1785 RepositoryCapability::GitOverlay,
1786 true,
1787 true
1788 ),
1789 GitScope::None
1790 );
1791 assert_eq!(
1792 plan_git_scope(
1793 SaveVerb::Capture,
1794 RepositoryCapability::NativeHeddle,
1795 false,
1796 false
1797 ),
1798 GitScope::None
1799 );
1800 }
1801
1802 #[test]
1803 fn commit_native_never_writes_git() {
1804 assert_eq!(
1805 plan_git_scope(
1806 SaveVerb::Commit,
1807 RepositoryCapability::NativeHeddle,
1808 true,
1809 true
1810 ),
1811 GitScope::None
1812 );
1813 }
1814
1815 #[test]
1816 fn commit_git_overlay_routes_staged_vs_worktree() {
1817 assert_eq!(
1818 plan_git_scope(
1819 SaveVerb::Commit,
1820 RepositoryCapability::GitOverlay,
1821 true,
1822 false
1823 ),
1824 GitScope::Staged
1825 );
1826 assert_eq!(
1827 plan_git_scope(
1828 SaveVerb::Commit,
1829 RepositoryCapability::GitOverlay,
1830 true,
1831 true
1832 ),
1833 GitScope::WorktreeAll
1834 );
1835 assert_eq!(
1836 plan_git_scope(
1837 SaveVerb::Commit,
1838 RepositoryCapability::GitOverlay,
1839 false,
1840 false
1841 ),
1842 GitScope::WorktreeAll
1843 );
1844 }
1845
1846 #[test]
1847 fn checkpoint_routes_staged_flag() {
1848 assert_eq!(
1849 plan_git_scope(
1850 SaveVerb::Checkpoint,
1851 RepositoryCapability::GitOverlay,
1852 true,
1853 false
1854 ),
1855 GitScope::Staged
1856 );
1857 assert_eq!(
1858 plan_git_scope(
1859 SaveVerb::Checkpoint,
1860 RepositoryCapability::GitOverlay,
1861 false,
1862 false
1863 ),
1864 GitScope::WorktreeAll
1865 );
1866 }
1867
1868 #[test]
1869 fn plan_creates_new_state_routing() {
1870 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1871 let capture = SavePlan::capture("wip", attr.clone());
1872 assert!(plan_creates_new_state(&capture, true));
1873 assert!(plan_creates_new_state(&capture, false));
1874
1875 let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
1876 assert!(!plan_creates_new_state(&checkpoint, true));
1877 assert!(plan_creates_new_state(&checkpoint, false));
1878
1879 let staged =
1880 SavePlan::commit("msg", attr, GitScope::Staged).with_supplied_tree(Tree::new());
1881 assert!(plan_creates_new_state(&staged, true));
1882 }
1883
1884 #[test]
1885 fn plan_writes_git_checkpoint_respects_scope_and_capability() {
1886 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1887 let capture = SavePlan::capture("wip", attr.clone());
1888 assert!(!plan_writes_git_checkpoint(
1889 &capture,
1890 RepositoryCapability::GitOverlay
1891 ));
1892
1893 let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
1894 assert!(plan_writes_git_checkpoint(
1895 &commit,
1896 RepositoryCapability::GitOverlay
1897 ));
1898 assert!(!plan_writes_git_checkpoint(
1899 &commit,
1900 RepositoryCapability::NativeHeddle
1901 ));
1902
1903 let none = SavePlan::commit("msg", attr, GitScope::None);
1904 assert!(!plan_writes_git_checkpoint(
1905 &none,
1906 RepositoryCapability::GitOverlay
1907 ));
1908 }
1909
1910 #[test]
1911 fn save_plan_builders_set_expected_defaults() {
1912 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1913 let capture = SavePlan::capture("intent", attr.clone());
1914 assert_eq!(capture.verb, SaveVerb::Capture);
1915 assert_eq!(capture.git_scope, GitScope::None);
1916 assert!(!capture.coalesce_snapshot_and_checkpoint);
1917
1918 let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
1919 assert_eq!(commit.verb, SaveVerb::Commit);
1920 assert!(commit.coalesce_snapshot_and_checkpoint);
1921 assert!(commit.commit_safe_post_verify);
1922
1923 let staged = SavePlan::checkpoint(None, attr, true);
1924 assert_eq!(staged.git_scope, GitScope::Staged);
1925 assert!(!staged.require_clean_worktree);
1926 assert!(staged.reuse_current_state);
1927 }
1928
1929 #[test]
1930 fn tree_leaf_name_and_commit_next_action() {
1931 assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
1932 assert_eq!(tree_leaf_name("solo"), "solo");
1933 assert_eq!(
1934 commit_next_action_from_trust("heddle push", false, false).as_deref(),
1935 Some("heddle push")
1936 );
1937 assert_eq!(
1938 commit_next_action_from_trust("", false, true).as_deref(),
1939 Some("heddle verify")
1940 );
1941 assert_eq!(
1942 commit_next_action_from_trust("", true, true).as_deref(),
1943 Some("heddle push")
1944 );
1945 assert_eq!(commit_next_action_from_trust("", true, false), None);
1946 }
1947
1948 #[test]
1949 fn commit_git_index_plan_modes() {
1950 let staged = vec!["a.rs".into()];
1951 let extra = vec!["unstaged: b.rs".into(), "untracked: c.rs".into()];
1952 let staged_only = plan_commit_git_index(&staged, &extra, false);
1953 assert_eq!(staged_only.commit_mode, "staged_index");
1954 assert_eq!(staged_only.will_commit, vec!["a.rs"]);
1955 assert_eq!(staged_only.preserved_after_commit.len(), 2);
1956
1957 let all = plan_commit_git_index(&staged, &extra, true);
1958 assert_eq!(all.commit_mode, "worktree_all_explicit");
1959 assert_eq!(all.will_commit.len(), 3);
1960
1961 let index_only = plan_commit_git_index_only(&staged, &extra);
1962 assert_eq!(index_only.commit_mode, "staged_index");
1963 assert_eq!(index_only.will_commit, vec!["a.rs"]);
1964
1965 assert!(commit_scope_text("staged_index").contains("staged Git index"));
1966 assert!(staged_commit_summary("ok", 1, 2).contains("left 2 unstaged/untracked"));
1967 assert_eq!(staged_commit_summary("ok", 1, 0), "ok");
1968 }
1969}