Skip to main content

heddle_core/
save.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared save primitive for `capture` / `commit` / `checkpoint` / ready auto-capture.
3//!
4//! CLI verbs become thin shells that build a [`SavePlan`] and call
5//! [`execute_save`]. Repo keeps atomic tree/state mutation; this module owns
6//! the composition of preflight-adjacent routing, Heddle snapshot, and
7//! optional Git-overlay write-through.
8
9use 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/// How far a save should write through into Git (Git-overlay only).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum GitScope {
41    /// Heddle state only — no Git checkpoint (capture; native commit).
42    None,
43    /// Checkpoint the staged Git index boundary (caller supplies the tree).
44    Staged,
45    /// Capture/checkpoint the full worktree (or current clean state).
46    WorktreeAll,
47}
48
49/// Public CLI / facade verb that requested the save.
50#[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/// Inputs for [`execute_save`]. Attribution is resolved by the caller so CLI
59/// env/harness/agent precedence stays at the embedding surface.
60#[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    /// When set, snapshot this tree instead of walking the worktree
68    /// (staged-index commits).
69    pub supplied_tree: Option<Tree>,
70    /// Prefer the current HEAD state when present (checkpoint bootstrap path).
71    pub reuse_current_state: bool,
72    /// After ensuring state, refuse dirty Heddle worktree before Git write-through.
73    pub require_clean_worktree: bool,
74    /// Refuse a worktree snapshot whose tree is identical to the current state.
75    /// The comparison happens during the snapshot tree build, avoiding a
76    /// separate preflight walk.
77    pub require_worktree_change: bool,
78    pub worktree_status_options: WorktreeStatusOptions,
79    /// Run pre/post snapshot hooks when creating a new Heddle state.
80    pub run_hooks: bool,
81    /// Map post-verify "commit" next actions to `heddle status` (commit UX).
82    pub commit_safe_post_verify: bool,
83    /// Fold snapshot + GitCheckpoint oplog batches into one undo unit.
84    pub coalesce_snapshot_and_checkpoint: bool,
85    /// Export an unmapped checkpoint state on top of the checkout's current
86    /// Git tip. Used only by sequential multi-peer land.
87    pub linearize_git_parent: bool,
88    /// Optional precomputed git-overlay worktree status for verification reuse
89    /// on the no-new-state path. Post-mutation paths always recompute.
90    pub precomputed_worktree_status:
91        Option<repo::Result<Option<objects::worktree::WorktreeStatus>>>,
92    /// Optional embedding-surface machine-contract inventory. Passing it into
93    /// core verification lets callers reuse the post-save proof instead of
94    /// rebuilding the entire repository health envelope for presentation.
95    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/// Result of a successful save.
198#[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    /// Number of paths changed by this save relative to the state that was
214    /// current when the operation began.
215    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
229/// Pure routing helper: which Git write-through scope a verb should use.
230///
231/// Used by unit tests and by CLI shells that build a [`SavePlan`] before
232/// calling [`execute_save`].
233pub 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
260/// Whether this plan should create a new Heddle state (vs reusing HEAD).
261pub 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    // Checkpoint without current state still bootstraps a capture.
269    if plan.verb == SaveVerb::Checkpoint && has_current_state {
270        return false;
271    }
272    true
273}
274
275/// Whether this plan should perform a Git-overlay write-through.
276pub fn plan_writes_git_checkpoint(plan: &SavePlan, capability: RepositoryCapability) -> bool {
277    plan.git_scope != GitScope::None && capability == RepositoryCapability::GitOverlay
278}
279
280/// Leaf path component for Git index → Heddle tree entry names.
281pub fn tree_leaf_name(path: &str) -> String {
282    path.rsplit('/').next().unwrap_or(path).to_string()
283}
284
285/// Next-action after a git-projection commit from verification facts only.
286///
287/// Precedence: explicit trust recommendation → verify when untrusted → push
288/// when a default remote is configured.
289pub 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// ---------------------------------------------------------------------------
304// Git-projection commit index planning (pure)
305// ---------------------------------------------------------------------------
306
307/// Pure commit index plan for internal Git projection writes.
308#[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
319/// Split `unstaged: ` / `untracked: ` prefixed extra paths from status rows.
320pub 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
333/// Plan commit scope from staged + extra worktree paths and `--all`.
334pub 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
374/// Index-only plan: commit staged paths, preserve all extras.
375pub 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
391/// Human scope line for git-projection commit text mode.
392pub 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
404/// Annotate a commit summary when staged-only commit leaves extras behind.
405pub 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
418/// Execute a save: optional Heddle snapshot + optional Git checkpoint write-through.
419///
420/// Callers own clap validation (missing message/intent) and plain-Git refusal.
421/// Mutation composition, hooks, thread metadata, Git write-through, and post
422/// verification live here.
423pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
424    // A plan that asks for a Git checkpoint on a non-overlay repo is a hard
425    // error: `plan_writes_git_checkpoint` silently returns false for native
426    // repos, so guard on the raw `git_scope` intent instead (the previous
427    // `plan_writes_git_checkpoint(..) && capability != GitOverlay` was
428    // self-contradictory and never fired).
429    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    // Post-mutation verification is always fresh when we created state or wrote
531    // a Git checkpoint (those mutations flip health classification). Otherwise
532    // reuse a caller-supplied worktree status to avoid a redundant walk.
533    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
765/// Finish the metadata/oplog half of a checkpoint whose Git ref was already
766/// published before a crash. Returns `None` when no matching published intent
767/// exists, so callers can continue with their own recovery policy.
768pub 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}