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