1use std::time::Instant;
10
11use anyhow::{Context, Result, anyhow};
12use heddle_git_projection::{GitProjection, WriteThroughOutcome};
13use objects::{
14 HeddleError, RecoveryDetails,
15 lock::RepositoryLockExt,
16 object::{Agent, Attribution, ContentHash, Principal, State, StateId, Tree},
17 store::ObjectStore,
18};
19use oplog::{OpLogBackend, OpRecord};
20use refs::Head;
21use repo::{
22 GitCheckpointRecord, Hook, HookContext, HookManager, Repository, RepositoryCapability,
23 SnapshotProfile, WorktreeStateLookupProfile, WorktreeStatusOptions,
24 refresh_active_thread_metadata,
25};
26use serde::Serialize;
27use sley::Repository as SleyRepository;
28
29use crate::{
30 MachineContractInput, RepositoryVerificationState,
31 build_repository_verification_health_with_worktree_status, build_repository_verification_state,
32 build_repository_verification_state_with_machine_contract,
33 build_repository_verification_state_with_worktree_status,
34 build_repository_verification_state_with_worktree_status_and_machine_contract,
35};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum GitScope {
41 None,
43 Staged,
45 WorktreeAll,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum SaveVerb {
53 Capture,
54 Commit,
55 Checkpoint,
56}
57
58#[derive(Debug)]
61pub struct SavePlan {
62 pub verb: SaveVerb,
63 pub intent: Option<String>,
64 pub confidence: Option<f32>,
65 pub attribution: Attribution,
66 pub git_scope: GitScope,
67 pub supplied_tree: Option<Tree>,
70 pub reuse_current_state: bool,
72 pub require_clean_worktree: bool,
74 pub require_worktree_change: bool,
78 pub worktree_status_options: WorktreeStatusOptions,
79 pub run_hooks: bool,
81 pub commit_safe_post_verify: bool,
83 pub coalesce_snapshot_and_checkpoint: bool,
85 pub linearize_git_parent: bool,
88 pub precomputed_worktree_status:
91 Option<repo::Result<Option<objects::worktree::WorktreeStatus>>>,
92 pub machine_contract_input: Option<MachineContractInput>,
96}
97
98impl SavePlan {
99 pub fn capture(intent: impl Into<String>, attribution: Attribution) -> Self {
100 Self {
101 verb: SaveVerb::Capture,
102 intent: Some(intent.into()),
103 confidence: None,
104 attribution,
105 git_scope: GitScope::None,
106 supplied_tree: None,
107 reuse_current_state: false,
108 require_clean_worktree: false,
109 require_worktree_change: false,
110 worktree_status_options: WorktreeStatusOptions::default(),
111 run_hooks: true,
112 commit_safe_post_verify: false,
113 coalesce_snapshot_and_checkpoint: false,
114 linearize_git_parent: false,
115 precomputed_worktree_status: None,
116 machine_contract_input: None,
117 }
118 }
119
120 pub fn commit(
121 intent: impl Into<String>,
122 attribution: Attribution,
123 git_scope: GitScope,
124 ) -> Self {
125 Self {
126 verb: SaveVerb::Commit,
127 intent: Some(intent.into()),
128 confidence: None,
129 attribution,
130 git_scope,
131 supplied_tree: None,
132 reuse_current_state: false,
133 require_clean_worktree: matches!(git_scope, GitScope::WorktreeAll),
134 require_worktree_change: false,
135 worktree_status_options: WorktreeStatusOptions::default(),
136 run_hooks: true,
137 commit_safe_post_verify: true,
138 coalesce_snapshot_and_checkpoint: matches!(
139 git_scope,
140 GitScope::Staged | GitScope::WorktreeAll
141 ),
142 linearize_git_parent: false,
143 precomputed_worktree_status: None,
144 machine_contract_input: None,
145 }
146 }
147
148 pub fn checkpoint(message: Option<String>, attribution: Attribution, staged: bool) -> Self {
149 Self {
150 verb: SaveVerb::Checkpoint,
151 intent: message,
152 confidence: None,
153 attribution,
154 git_scope: if staged {
155 GitScope::Staged
156 } else {
157 GitScope::WorktreeAll
158 },
159 supplied_tree: None,
160 reuse_current_state: true,
161 require_clean_worktree: !staged,
162 require_worktree_change: false,
163 worktree_status_options: WorktreeStatusOptions::default(),
164 run_hooks: true,
165 commit_safe_post_verify: false,
166 coalesce_snapshot_and_checkpoint: false,
167 linearize_git_parent: false,
168 precomputed_worktree_status: None,
169 machine_contract_input: None,
170 }
171 }
172
173 pub fn with_confidence(mut self, confidence: Option<f32>) -> Self {
174 self.confidence = confidence;
175 self
176 }
177
178 pub fn with_supplied_tree(mut self, tree: Tree) -> Self {
179 self.supplied_tree = Some(tree);
180 self
181 }
182
183 pub fn with_worktree_status_options(mut self, options: WorktreeStatusOptions) -> Self {
184 self.worktree_status_options = options;
185 self
186 }
187
188 pub fn with_precomputed_worktree_status(
189 mut self,
190 status: repo::Result<Option<objects::worktree::WorktreeStatus>>,
191 ) -> Self {
192 self.precomputed_worktree_status = Some(status);
193 self
194 }
195}
196
197#[derive(Debug, Clone)]
199pub struct SaveReport {
200 pub verb: SaveVerb,
201 pub state_id: StateId,
202 pub content_hash: ContentHash,
203 pub intent: Option<String>,
204 pub confidence: Option<f32>,
205 pub signed: bool,
206 pub git_commit: Option<String>,
207 pub git_previous_commit: Option<String>,
208 pub summary: String,
209 pub principal: Principal,
210 pub agent: Option<Agent>,
211 pub promotion_suggested: bool,
212 pub heavy_impact_paths: Vec<String>,
213 pub captured_path_count: usize,
216 pub verification: RepositoryVerificationState,
217 pub created_new_state: bool,
218 pub git_checkpoint: Option<GitCheckpointRecord>,
219 pub snapshot_profile: SnapshotProfile,
220 pub state_create_ms: u128,
221 pub captured_path_count_ms: u128,
222 pub post_verification_ms: u128,
223 pub thread_metadata_ms: u128,
224 pub previous_state_ms: u128,
225 pub previous_state_profile: WorktreeStateLookupProfile,
226 pub signature_lookup_ms: u128,
227}
228
229pub fn plan_git_scope(
234 verb: SaveVerb,
235 capability: RepositoryCapability,
236 staged_index_paths: bool,
237 include_all_worktree: bool,
238) -> GitScope {
239 match verb {
240 SaveVerb::Capture => GitScope::None,
241 SaveVerb::Checkpoint => {
242 if staged_index_paths {
243 GitScope::Staged
244 } else {
245 GitScope::WorktreeAll
246 }
247 }
248 SaveVerb::Commit => {
249 if capability != RepositoryCapability::GitOverlay {
250 GitScope::None
251 } else if staged_index_paths && !include_all_worktree {
252 GitScope::Staged
253 } else {
254 GitScope::WorktreeAll
255 }
256 }
257 }
258}
259
260pub fn plan_creates_new_state(plan: &SavePlan, has_current_state: bool) -> bool {
262 if plan.supplied_tree.is_some() {
263 return true;
264 }
265 if plan.reuse_current_state && has_current_state {
266 return false;
267 }
268 if plan.verb == SaveVerb::Checkpoint && has_current_state {
270 return false;
271 }
272 true
273}
274
275pub fn plan_writes_git_checkpoint(plan: &SavePlan, capability: RepositoryCapability) -> bool {
277 plan.git_scope != GitScope::None && capability == RepositoryCapability::GitOverlay
278}
279
280pub fn tree_leaf_name(path: &str) -> String {
282 path.rsplit('/').next().unwrap_or(path).to_string()
283}
284
285pub fn commit_next_action_from_trust(
290 recommended_action: &str,
291 verified: bool,
292 has_default_remote: bool,
293) -> Option<String> {
294 if !recommended_action.trim().is_empty() {
295 return Some(recommended_action.to_string());
296 }
297 if !verified {
298 return Some("heddle verify".to_string());
299 }
300 has_default_remote.then(|| "heddle push".to_string())
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct CommitGitIndexPlan {
310 pub commit_mode: &'static str,
311 pub has_staged_changes: bool,
312 pub staged_paths: Vec<String>,
313 pub unstaged_paths: Vec<String>,
314 pub untracked_paths: Vec<String>,
315 pub will_commit: Vec<String>,
316 pub preserved_after_commit: Vec<String>,
317}
318
319pub fn split_git_extra_paths(extra_paths: &[String]) -> (Vec<String>, Vec<String>) {
321 let mut unstaged_paths = Vec::new();
322 let mut untracked_paths = Vec::new();
323 for path in extra_paths {
324 if let Some(path) = path.strip_prefix("unstaged: ") {
325 unstaged_paths.push(path.to_string());
326 } else if let Some(path) = path.strip_prefix("untracked: ") {
327 untracked_paths.push(path.to_string());
328 }
329 }
330 (unstaged_paths, untracked_paths)
331}
332
333pub fn plan_commit_git_index(
335 staged_paths: &[String],
336 extra_paths: &[String],
337 include_all: bool,
338) -> CommitGitIndexPlan {
339 let (unstaged_paths, untracked_paths) = split_git_extra_paths(extra_paths);
340 let has_staged_changes = !staged_paths.is_empty();
341 let mut will_commit = Vec::new();
342 if has_staged_changes {
343 will_commit.extend(staged_paths.iter().cloned());
344 }
345 if include_all || !has_staged_changes {
346 will_commit.extend(unstaged_paths.iter().cloned());
347 will_commit.extend(untracked_paths.iter().cloned());
348 }
349 let commit_mode = if has_staged_changes && include_all {
350 "worktree_all_explicit"
351 } else if has_staged_changes {
352 "staged_index"
353 } else if will_commit.is_empty() {
354 "none"
355 } else {
356 "worktree_all"
357 };
358 let preserved_after_commit = if has_staged_changes && !include_all {
359 extra_paths.to_vec()
360 } else {
361 Vec::new()
362 };
363 CommitGitIndexPlan {
364 commit_mode,
365 has_staged_changes,
366 staged_paths: staged_paths.to_vec(),
367 unstaged_paths,
368 untracked_paths,
369 will_commit,
370 preserved_after_commit,
371 }
372}
373
374pub fn plan_commit_git_index_only(
376 staged_paths: &[String],
377 extra_paths: &[String],
378) -> CommitGitIndexPlan {
379 let (unstaged_paths, untracked_paths) = split_git_extra_paths(extra_paths);
380 CommitGitIndexPlan {
381 commit_mode: "staged_index",
382 has_staged_changes: !staged_paths.is_empty(),
383 staged_paths: staged_paths.to_vec(),
384 unstaged_paths,
385 untracked_paths,
386 will_commit: staged_paths.to_vec(),
387 preserved_after_commit: extra_paths.to_vec(),
388 }
389}
390
391pub fn commit_scope_text(commit_mode: &str) -> &'static str {
393 match commit_mode {
394 "staged_index" => {
395 "staged Git index only; unstaged and untracked paths stay in the worktree"
396 }
397 "worktree_all_explicit" => "all staged, unstaged, and untracked worktree changes (--all)",
398 "worktree_all" => "all unstaged and untracked worktree changes",
399 "none" => "no Git paths",
400 _ => "Git worktree changes",
401 }
402}
403
404pub fn staged_commit_summary(
406 summary: &str,
407 staged_path_count: usize,
408 extra_path_count: usize,
409) -> String {
410 if extra_path_count == 0 {
411 return summary.to_string();
412 }
413 format!(
414 "{summary} (committed {staged_path_count} staged path(s); left {extra_path_count} unstaged/untracked path(s) in the worktree)"
415 )
416}
417
418pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
424 if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
430 return Err(anyhow!(HeddleError::recovery(
431 RecoveryDetails::safety_refusal(
432 "native_checkpoint_unavailable",
433 "Git checkpointing is only available in Git-overlay repositories",
434 "Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
435 "this checkout is not a Git-overlay repository",
436 "checkpoint would try to write a Git commit where no active Git store is bound",
437 "repository state, refs, and worktree files were left unchanged",
438 ),
439 )));
440 }
441
442 let previous_state_started = Instant::now();
443 let (previous_state, previous_state_profile) =
444 repo.current_state_for_worktree_status_profiled()?;
445 let previous_state_ms = previous_state_started.elapsed().as_millis();
446 let has_current = previous_state.is_some();
447 let mut created_new_state = false;
448 let mut snapshot_profile = SnapshotProfile::default();
449 let mut thread_metadata_ms = 0u128;
450 let mut promotion_suggested = false;
451 let mut heavy_impact_paths = Vec::new();
452 let mut snapshot_state_id: Option<StateId> = None;
453 let mut captured_path_count = 0usize;
454 let mut state_create_ms = 0u128;
455 let mut captured_path_count_ms = 0u128;
456
457 let mut state = if plan_creates_new_state(&plan, has_current) {
458 created_new_state = true;
459 let state_create_started = Instant::now();
460 let execution = create_heddle_state(repo, &plan)?;
461 state_create_ms = state_create_started.elapsed().as_millis();
462 snapshot_profile = execution.profile;
463 thread_metadata_ms = execution.thread_metadata_ms;
464 promotion_suggested = execution.promotion_suggested;
465 heavy_impact_paths = execution.heavy_impact_paths;
466 snapshot_state_id = Some(execution.state.state_id);
467 let previous_tree = match previous_state.as_ref() {
468 Some(state) => state.tree,
469 None => repo.store().put_tree(&Tree::new())?,
470 };
471 let captured_path_count_started = Instant::now();
472 captured_path_count = repo
473 .diff_trees(&previous_tree, &execution.state.tree)?
474 .len();
475 captured_path_count_ms = captured_path_count_started.elapsed().as_millis();
476 execution.state
477 } else {
478 repo.current_state()?
479 .ok_or_else(|| anyhow!("no captured state found for save"))?
480 };
481
482 let mut git_commit = None;
483 let mut git_previous_commit = None;
484 let mut git_checkpoint = None;
485
486 if plan_writes_git_checkpoint(&plan, repo.capability()) {
487 if plan.require_clean_worktree {
488 let tree = repo.require_tree(&state.tree)?;
489 let status = repo.compare_worktree_cached_detailed_with_options(
490 &tree,
491 &plan.worktree_status_options,
492 )?;
493 if !status.is_clean() {
494 return Err(anyhow!(HeddleError::recovery(
495 RecoveryDetails::safety_refusal(
496 "dirty_worktree",
497 "Save worktree changes before committing",
498 "Save the work with `heddle capture -m \"...\"`, then retry the commit.",
499 "the current Heddle state was left unchanged; these paths have not been captured",
500 "commit would write Git history that does not include dirty worktree paths",
501 "the current Heddle state was left unchanged; these paths have not been captured",
502 ),
503 )));
504 }
505 }
506
507 if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
508 && repo.pending_git_checkpoint_intent()?.is_none()
509 {
510 git_commit = Some(existing.git_commit.clone());
511 git_checkpoint = Some(existing);
512 } else {
513 let previous = repo
514 .pending_git_checkpoint_intent()?
515 .and_then(|intent| intent.previous_git_oid)
516 .or_else(|| git_rev_parse_head(repo.root()));
517 git_previous_commit = previous.clone();
518 let summary = checkpoint_summary(&plan, &state);
519 let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
520 if plan.coalesce_snapshot_and_checkpoint
521 && let Some(state_id) = snapshot_state_id.as_ref()
522 {
523 coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
524 }
525 git_commit = Some(record.git_commit.clone());
526 git_checkpoint = Some(record);
527 }
528 }
529
530 let captured_native_worktree = created_new_state
534 && plan.supplied_tree.is_none()
535 && repo.capability() == RepositoryCapability::NativeHeddle;
536 let captured_worktree_status = Ok(Some(objects::worktree::WorktreeStatus::default()));
537 let verification_started = Instant::now();
538 let mut verification = if captured_native_worktree && git_checkpoint.is_none() {
539 let health = build_repository_verification_health_with_worktree_status(
540 repo,
541 &captured_worktree_status,
542 );
543 if let Some(input) = &plan.machine_contract_input {
544 build_repository_verification_state_with_worktree_status_and_machine_contract(
545 repo,
546 health,
547 &captured_worktree_status,
548 input,
549 )
550 } else {
551 build_repository_verification_state_with_worktree_status(
552 repo,
553 health,
554 &captured_worktree_status,
555 )
556 }
557 } else if created_new_state || git_checkpoint.is_some() {
558 if let Some(input) = &plan.machine_contract_input {
559 build_repository_verification_state_with_machine_contract(repo, input)?
560 } else {
561 build_repository_verification_state(repo)?
562 }
563 } else if let Some(status) = &plan.precomputed_worktree_status {
564 let health = build_repository_verification_health_with_worktree_status(repo, status);
565 if let Some(input) = &plan.machine_contract_input {
566 build_repository_verification_state_with_worktree_status_and_machine_contract(
567 repo, health, status, input,
568 )
569 } else {
570 build_repository_verification_state_with_worktree_status(repo, health, status)
571 }
572 } else {
573 if let Some(input) = &plan.machine_contract_input {
574 build_repository_verification_state_with_machine_contract(repo, input)?
575 } else {
576 build_repository_verification_state(repo)?
577 }
578 };
579 if plan.commit_safe_post_verify {
580 soften_commit_next_action(&mut verification);
581 }
582 let post_verification_ms = verification_started.elapsed().as_millis();
583
584 let summary = match plan.verb {
585 SaveVerb::Capture => format!(
586 "Captured state {} ({})",
587 state.state_id.short(),
588 state.hash().short()
589 ),
590 SaveVerb::Commit => plan
591 .intent
592 .clone()
593 .unwrap_or_else(|| format!("Commit {}", state.state_id.short())),
594 SaveVerb::Checkpoint => git_checkpoint
595 .as_ref()
596 .map(|r| r.summary.clone())
597 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
598 };
599
600 let signature_lookup_started = Instant::now();
601 let signed = repo.get_state_signature(&state.id())?.is_some();
602 let signature_lookup_ms = signature_lookup_started.elapsed().as_millis();
603 Ok(SaveReport {
604 verb: plan.verb,
605 state_id: state.state_id,
606 content_hash: state.hash(),
607 intent: state.intent.clone(),
608 confidence: state.confidence,
609 signed,
610 git_commit,
611 git_previous_commit,
612 summary,
613 principal: state.attribution.principal.clone(),
614 agent: state.attribution.agent.clone(),
615 promotion_suggested,
616 heavy_impact_paths,
617 captured_path_count,
618 verification,
619 created_new_state,
620 git_checkpoint,
621 snapshot_profile,
622 state_create_ms,
623 captured_path_count_ms,
624 post_verification_ms,
625 thread_metadata_ms,
626 previous_state_ms,
627 previous_state_profile,
628 signature_lookup_ms,
629 })
630}
631
632struct CreatedState {
633 state: State,
634 profile: SnapshotProfile,
635 thread_metadata_ms: u128,
636 promotion_suggested: bool,
637 heavy_impact_paths: Vec<String>,
638}
639
640fn create_heddle_state(repo: &Repository, plan: &SavePlan) -> Result<CreatedState> {
641 let hook_manager = HookManager::new(repo);
642 let hook_ctx = HookContext::new(repo);
643
644 if plan.run_hooks {
645 hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
646 let pre_capture_payload = serde_json::json!({
647 "thread": current_thread_name(repo),
648 "intent": plan.intent.clone().unwrap_or_default(),
649 });
650 let pre_capture_response = hook_manager.run_with_payload(
651 Hook::PreSnapshot,
652 &hook_ctx,
653 &pre_capture_payload,
654 std::time::Duration::from_secs(5),
655 )?;
656 if let Some(resp) = pre_capture_response
657 && !resp.abort.is_empty()
658 {
659 return Err(anyhow!(HeddleError::recovery(
660 RecoveryDetails::safety_refusal(
661 "hook_veto",
662 format!("pre_capture hook vetoed: {}", resp.abort),
663 "Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
664 format!("pre_capture hook vetoed capture: {}", resp.abort),
665 "capture would continue after repository policy explicitly aborted the operation",
666 "the operation stopped at the hook boundary before the protected action ran",
667 )
668 .with_recovery_commands(vec!["heddle hook list".to_string()]),
669 )));
670 }
671 }
672
673 let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
674 repo.snapshot_tree_with_attribution_profiled(
675 tree,
676 plan.intent.clone(),
677 plan.confidence,
678 plan.attribution.clone(),
679 )?
680 } else if plan.require_worktree_change {
681 repo.snapshot_with_attribution_profiled_if_changed(
682 plan.intent.clone(),
683 plan.confidence,
684 plan.attribution.clone(),
685 )?
686 } else {
687 repo.snapshot_with_attribution_profiled(
688 plan.intent.clone(),
689 plan.confidence,
690 plan.attribution.clone(),
691 )?
692 };
693
694 let thread_metadata_start = Instant::now();
695 let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
696 let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
697
698 if plan.run_hooks {
699 hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
700 let post_capture_payload = serde_json::json!({
701 "state_id": execution.state.state_id.to_string_full(),
702 });
703 if let Err(err) = hook_manager.run_with_payload(
704 Hook::PostSnapshot,
705 &hook_ctx,
706 &post_capture_payload,
707 std::time::Duration::from_secs(5),
708 ) {
709 tracing::warn!(error = %err, "post_capture hook error swallowed");
710 }
711 }
712
713 Ok(CreatedState {
714 state: execution.state,
715 profile: std::mem::take(&mut execution.profile),
716 thread_metadata_ms,
717 promotion_suggested: refresh.promotion_suggested,
718 heavy_impact_paths: refresh.heavy_impact_paths,
719 })
720}
721
722fn write_git_checkpoint(
723 repo: &Repository,
724 state: &State,
725 summary: String,
726 linearize_git_parent: bool,
727) -> Result<GitCheckpointRecord> {
728 let _lock = repo.locker().write()?;
729 objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
730 let mut bridge = GitProjection::new(repo);
731 if linearize_git_parent {
732 bridge.linearize_unmapped_tip_to_checkout();
733 }
734 let git_commit = match bridge
735 .write_through_current_checkout_with_message(state.state_id, summary.clone())?
736 {
737 WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
738 WriteThroughOutcome::Skipped(reason) => {
739 return Err(anyhow!(HeddleError::recovery(
740 RecoveryDetails::safety_refusal(
741 "checkpoint_git_write_skipped",
742 format!("Git checkpoint write-through was skipped: {reason}"),
743 "Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
744 format!("write-through skipped: {reason}"),
745 "checkpoint would need to write the current Heddle state into the Git branch and index",
746 "the current Heddle state was preserved; no Git checkpoint record was written",
747 ),
748 )));
749 }
750 };
751 let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
752 anyhow!("Git checkpoint published without its durable finalization intent")
753 })?;
754 if intent.phase != repo::GitCheckpointIntentPhase::Published
755 || intent.state_id != state.state_id.to_string_full()
756 || intent.new_git_oid != git_commit
757 {
758 return Err(anyhow!(
759 "published Git checkpoint does not match its durable finalization intent"
760 ));
761 }
762 finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
763}
764
765pub fn recover_published_git_checkpoint(
769 repo: &Repository,
770 state_id: &StateId,
771) -> Result<Option<GitCheckpointRecord>> {
772 let _lock = repo.locker().write()?;
773 let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
774 return Ok(None);
775 };
776 if intent.state_id != state_id.to_string_full() {
777 return Ok(None);
778 }
779 let current_branch = repo.git_overlay_current_branch()?;
780 if current_branch.as_deref() != Some(intent.branch.as_str()) {
781 return Err(anyhow!(
782 "pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
783 intent.branch,
784 current_branch.as_deref().unwrap_or("detached HEAD")
785 ));
786 }
787 let current_oid = git_rev_parse_head(repo.root());
788 if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
789 if current_oid == intent.previous_git_oid {
790 return Ok(None);
791 }
792 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
793 return Err(anyhow!(
794 "prepared Git checkpoint expected HEAD at {} or {}, found {}",
795 intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
796 intent.new_git_oid,
797 current_oid.as_deref().unwrap_or("<unborn>")
798 ));
799 }
800 let git_oid = intent.new_git_oid.clone();
801 intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
802 }
803 if intent.phase != repo::GitCheckpointIntentPhase::Published {
804 return Ok(None);
805 }
806 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
807 return Err(anyhow!(
808 "published Git checkpoint expected HEAD at {}, found {}",
809 intent.new_git_oid,
810 current_oid.as_deref().unwrap_or("<unborn>")
811 ));
812 }
813 let git_commit = intent.new_git_oid.clone();
814 let summary = intent.summary.clone();
815 finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
816}
817
818fn finalize_published_git_checkpoint(
819 repo: &Repository,
820 state_id: &StateId,
821 git_commit: String,
822 summary: String,
823 intent: repo::GitCheckpointIntent,
824) -> Result<GitCheckpointRecord> {
825 let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
826 objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
827 let transaction_id = format!(
828 "git-checkpoint:v1:{}:{}",
829 state_id.to_string_full(),
830 git_commit
831 );
832 repo.oplog().record_batch_exactly_once(
833 vec![
834 OpRecord::GitCheckpoint {
835 branch: intent.branch,
836 state: *state_id,
837 previous_git_oid: intent.previous_git_oid,
838 new_git_oid: git_commit.clone(),
839 },
840 OpRecord::TransactionCommit {
841 transaction_id: transaction_id.clone(),
842 op_count: 1,
843 },
844 ],
845 Some(&repo.op_scope()),
846 &transaction_id,
847 )?;
848 objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
849 repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
850 Ok(record)
851}
852
853fn coalesce_snapshot_and_checkpoint(
854 repo: &Repository,
855 state_id: &StateId,
856 git_commit: &str,
857) -> Result<()> {
858 let snapshot_batch = repo
859 .oplog()
860 .recent_batches_scoped(8, Some(&repo.op_scope()))?
861 .into_iter()
862 .find(|batch| {
863 batch.entries.iter().any(|entry| {
864 matches!(
865 &entry.operation,
866 OpRecord::Snapshot { new_state, .. } if new_state == state_id
867 )
868 })
869 })
870 .ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
871 let checkpoint_batch = repo
872 .oplog()
873 .recent_batches_scoped(8, Some(&repo.op_scope()))?
874 .into_iter()
875 .find(|batch| {
876 batch.entries.iter().any(|entry| {
877 matches!(
878 &entry.operation,
879 OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
880 )
881 })
882 })
883 .ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
884 repo.oplog()
885 .coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
886 .context(
887 "commit completed but failed to record capture and Git checkpoint as one undo batch",
888 )?;
889 Ok(())
890}
891
892fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
893 plan.intent
894 .clone()
895 .or_else(|| state.intent.clone())
896 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
897}
898
899fn current_thread_name(repo: &Repository) -> String {
900 match repo.head_ref() {
901 Ok(Head::Attached { thread }) => thread.to_string(),
902 _ => String::new(),
903 }
904}
905
906fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
907 let git = SleyRepository::discover(root).ok()?;
908 git.head().ok()?.oid.map(|id| id.to_string())
909}
910
911fn soften_commit_next_action(trust: &mut RepositoryVerificationState) {
912 if is_commit_action(&trust.recommended_action) {
913 trust.recommended_action = "heddle status".to_string();
914 trust.recommended_action_template = None;
915 }
916 for check in &mut trust.checks {
917 if check
918 .recommended_action
919 .as_deref()
920 .is_some_and(is_commit_action)
921 {
922 check.recommended_action = Some("heddle status".to_string());
923 check.recommended_action_template = None;
924 }
925 }
926}
927
928fn is_commit_action(action: &str) -> bool {
929 let trimmed = action.trim();
930 trimmed == "heddle capture" || trimmed.starts_with("heddle capture ")
931}
932
933#[cfg(test)]
934mod tests {
935 use repo::RepositoryCapability;
936
937 use super::*;
938
939 #[test]
940 fn capture_always_uses_git_scope_none() {
941 assert_eq!(
942 plan_git_scope(
943 SaveVerb::Capture,
944 RepositoryCapability::GitOverlay,
945 true,
946 true
947 ),
948 GitScope::None
949 );
950 assert_eq!(
951 plan_git_scope(
952 SaveVerb::Capture,
953 RepositoryCapability::NativeHeddle,
954 false,
955 false
956 ),
957 GitScope::None
958 );
959 }
960
961 #[test]
962 fn commit_native_never_writes_git() {
963 assert_eq!(
964 plan_git_scope(
965 SaveVerb::Commit,
966 RepositoryCapability::NativeHeddle,
967 true,
968 true
969 ),
970 GitScope::None
971 );
972 }
973
974 #[test]
975 fn commit_git_overlay_routes_staged_vs_worktree() {
976 assert_eq!(
977 plan_git_scope(
978 SaveVerb::Commit,
979 RepositoryCapability::GitOverlay,
980 true,
981 false
982 ),
983 GitScope::Staged
984 );
985 assert_eq!(
986 plan_git_scope(
987 SaveVerb::Commit,
988 RepositoryCapability::GitOverlay,
989 true,
990 true
991 ),
992 GitScope::WorktreeAll
993 );
994 assert_eq!(
995 plan_git_scope(
996 SaveVerb::Commit,
997 RepositoryCapability::GitOverlay,
998 false,
999 false
1000 ),
1001 GitScope::WorktreeAll
1002 );
1003 }
1004
1005 #[test]
1006 fn checkpoint_routes_staged_flag() {
1007 assert_eq!(
1008 plan_git_scope(
1009 SaveVerb::Checkpoint,
1010 RepositoryCapability::GitOverlay,
1011 true,
1012 false
1013 ),
1014 GitScope::Staged
1015 );
1016 assert_eq!(
1017 plan_git_scope(
1018 SaveVerb::Checkpoint,
1019 RepositoryCapability::GitOverlay,
1020 false,
1021 false
1022 ),
1023 GitScope::WorktreeAll
1024 );
1025 }
1026
1027 #[test]
1028 fn plan_creates_new_state_routing() {
1029 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1030 let capture = SavePlan::capture("wip", attr.clone());
1031 assert!(plan_creates_new_state(&capture, true));
1032 assert!(plan_creates_new_state(&capture, false));
1033
1034 let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
1035 assert!(!plan_creates_new_state(&checkpoint, true));
1036 assert!(plan_creates_new_state(&checkpoint, false));
1037
1038 let staged =
1039 SavePlan::commit("msg", attr, GitScope::Staged).with_supplied_tree(Tree::new());
1040 assert!(plan_creates_new_state(&staged, true));
1041 }
1042
1043 #[test]
1044 fn plan_writes_git_checkpoint_respects_scope_and_capability() {
1045 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1046 let capture = SavePlan::capture("wip", attr.clone());
1047 assert!(!plan_writes_git_checkpoint(
1048 &capture,
1049 RepositoryCapability::GitOverlay
1050 ));
1051
1052 let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
1053 assert!(plan_writes_git_checkpoint(
1054 &commit,
1055 RepositoryCapability::GitOverlay
1056 ));
1057 assert!(!plan_writes_git_checkpoint(
1058 &commit,
1059 RepositoryCapability::NativeHeddle
1060 ));
1061
1062 let none = SavePlan::commit("msg", attr, GitScope::None);
1063 assert!(!plan_writes_git_checkpoint(
1064 &none,
1065 RepositoryCapability::GitOverlay
1066 ));
1067 }
1068
1069 #[test]
1070 fn save_plan_builders_set_expected_defaults() {
1071 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1072 let capture = SavePlan::capture("intent", attr.clone());
1073 assert_eq!(capture.verb, SaveVerb::Capture);
1074 assert_eq!(capture.git_scope, GitScope::None);
1075 assert!(!capture.coalesce_snapshot_and_checkpoint);
1076
1077 let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
1078 assert_eq!(commit.verb, SaveVerb::Commit);
1079 assert!(commit.coalesce_snapshot_and_checkpoint);
1080 assert!(commit.commit_safe_post_verify);
1081
1082 let staged = SavePlan::checkpoint(None, attr, true);
1083 assert_eq!(staged.git_scope, GitScope::Staged);
1084 assert!(!staged.require_clean_worktree);
1085 assert!(staged.reuse_current_state);
1086 }
1087
1088 #[test]
1089 fn tree_leaf_name_and_commit_next_action() {
1090 assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
1091 assert_eq!(tree_leaf_name("solo"), "solo");
1092 assert_eq!(
1093 commit_next_action_from_trust("heddle push", false, false).as_deref(),
1094 Some("heddle push")
1095 );
1096 assert_eq!(
1097 commit_next_action_from_trust("", false, true).as_deref(),
1098 Some("heddle verify")
1099 );
1100 assert_eq!(
1101 commit_next_action_from_trust("", true, true).as_deref(),
1102 Some("heddle push")
1103 );
1104 assert_eq!(commit_next_action_from_trust("", true, false), None);
1105 }
1106
1107 #[test]
1108 fn commit_git_index_plan_modes() {
1109 let staged = vec!["a.rs".into()];
1110 let extra = vec!["unstaged: b.rs".into(), "untracked: c.rs".into()];
1111 let staged_only = plan_commit_git_index(&staged, &extra, false);
1112 assert_eq!(staged_only.commit_mode, "staged_index");
1113 assert_eq!(staged_only.will_commit, vec!["a.rs"]);
1114 assert_eq!(staged_only.preserved_after_commit.len(), 2);
1115
1116 let all = plan_commit_git_index(&staged, &extra, true);
1117 assert_eq!(all.commit_mode, "worktree_all_explicit");
1118 assert_eq!(all.will_commit.len(), 3);
1119
1120 let index_only = plan_commit_git_index_only(&staged, &extra);
1121 assert_eq!(index_only.commit_mode, "staged_index");
1122 assert_eq!(index_only.will_commit, vec!["a.rs"]);
1123
1124 assert!(commit_scope_text("staged_index").contains("staged Git index"));
1125 assert!(staged_commit_summary("ok", 1, 2).contains("left 2 unstaged/untracked"));
1126 assert_eq!(staged_commit_summary("ok", 1, 0), "ok");
1127 }
1128}