Skip to main content

verbs/
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 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/// Fully-resolved inputs for the normal local capture operation.
48///
49/// Attribution remains an embedding concern because the CLI combines explicit
50/// flags, harness detection, sessions, and environment variables. [`capture`]
51/// resolves it lazily after non-mutating safety checks, then owns the remaining
52/// mutation ordering.
53#[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/// Attribution resolved lazily after capture's non-mutating safety checks.
63#[derive(Debug, Clone)]
64pub struct CaptureAttribution {
65    pub attribution: Attribution,
66    pub principal_source: String,
67}
68
69/// Capture-specific timings owned by the operation implementation.
70#[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/// Final semantic report returned by the capture seam.
79///
80/// The CLI may project this into its stable JSON wire type or render it for a
81/// person, but it must not add recovery state or derive workflow actions.
82#[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/// How far a save should write through into Git (Git-overlay only).
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum GitScope {
155    /// Heddle state only — no Git checkpoint (capture; native commit).
156    None,
157    /// Checkpoint the staged Git index boundary (caller supplies the tree).
158    Staged,
159    /// Capture/checkpoint the full worktree (or current clean state).
160    WorktreeAll,
161}
162
163/// Public CLI / facade verb that requested the save.
164#[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/// Inputs for [`execute_save`]. Attribution is resolved by the caller so CLI
173/// env/harness/agent precedence stays at the embedding surface.
174#[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    /// When set, snapshot this tree instead of walking the worktree
182    /// (staged-index commits).
183    pub supplied_tree: Option<Tree>,
184    /// Prefer the current HEAD state when present (checkpoint bootstrap path).
185    pub reuse_current_state: bool,
186    /// After ensuring state, refuse dirty Heddle worktree before Git write-through.
187    pub require_clean_worktree: bool,
188    /// Refuse a worktree snapshot whose tree is identical to the current state.
189    /// The comparison happens during the snapshot tree build, avoiding a
190    /// separate preflight walk.
191    pub require_worktree_change: bool,
192    pub worktree_status_options: WorktreeStatusOptions,
193    /// Run pre/post snapshot hooks when creating a new Heddle state.
194    pub run_hooks: bool,
195    /// Map post-verify "commit" next actions to `heddle status` (commit UX).
196    pub commit_safe_post_verify: bool,
197    /// Fold snapshot + GitCheckpoint oplog batches into one undo unit.
198    pub coalesce_snapshot_and_checkpoint: bool,
199    /// Export an unmapped checkpoint state on top of the checkout's current
200    /// Git tip. Used only by sequential multi-peer land.
201    pub linearize_git_parent: bool,
202    /// Optional precomputed git-overlay worktree status for verification reuse
203    /// on the no-new-state path. Post-mutation paths always recompute.
204    pub precomputed_worktree_status:
205        Option<repo::Result<Option<objects::worktree::WorktreeStatus>>>,
206    /// Optional embedding-surface machine-contract inventory. Passing it into
207    /// core verification lets callers reuse the post-save proof instead of
208    /// rebuilding the entire repository health envelope for presentation.
209    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/// Result of a successful save.
312#[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    /// Number of paths changed by this save relative to the state that was
328    /// current when the operation began.
329    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
343/// Pure routing helper: which Git write-through scope a verb should use.
344///
345/// Used by unit tests and by CLI shells that build a [`SavePlan`] before
346/// calling [`execute_save`].
347pub 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
374/// Whether this plan should create a new Heddle state (vs reusing HEAD).
375pub 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    // Checkpoint without current state still bootstraps a capture.
383    if plan.verb == SaveVerb::Checkpoint && has_current_state {
384        return false;
385    }
386    true
387}
388
389/// Whether this plan should perform a Git-overlay write-through.
390pub fn plan_writes_git_checkpoint(plan: &SavePlan, capability: RepositoryCapability) -> bool {
391    plan.git_scope != GitScope::None && capability == RepositoryCapability::GitOverlay
392}
393
394/// Leaf path component for Git index → Heddle tree entry names.
395pub fn tree_leaf_name(path: &str) -> String {
396    path.rsplit('/').next().unwrap_or(path).to_string()
397}
398
399/// Next-action after a git-projection commit from verification facts only.
400///
401/// Precedence: explicit trust recommendation → verify when untrusted → push
402/// when a default remote is configured.
403pub 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// ---------------------------------------------------------------------------
418// Git-projection commit index planning (pure)
419// ---------------------------------------------------------------------------
420
421/// Pure commit index plan for internal Git projection writes.
422#[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
433/// Split `unstaged: ` / `untracked: ` prefixed extra paths from status rows.
434pub 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
447/// Plan commit scope from staged + extra worktree paths and `--all`.
448pub 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
488/// Index-only plan: commit staged paths, preserve all extras.
489pub 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
505/// Human scope line for git-projection commit text mode.
506pub 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
518/// Annotate a commit summary when staged-only commit leaves extras behind.
519pub 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
532/// Capture the current worktree as one synchronous local operation.
533///
534/// The implementation owns the complete semantic sequence: authority-aware
535/// worktree checks, safety preflight, Heddle mutation, manual-resolution
536/// completion, best-effort intent-to-add maintenance, and final report
537/// assembly. There are no genuine suspension points on this path.
538pub 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        // Native repositories compare the built tree with their Heddle HEAD.
591        // Git-overlay performs its distinct authority-aware comparison above:
592        // an overlay can legitimately have no Heddle HEAD yet and still need
593        // to capture an empty tree that represents deletion from Git's base.
594        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, &current.state_id)
797        .unwrap_or(false)
798        || graph
799            .is_ancestor(&current.state_id, &mapped)
800            .unwrap_or(false)
801    {
802        return Ok(false);
803    }
804    Ok(repo
805        .latest_git_checkpoint_for_state(&current.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
864/// Compare against Heddle's current tree before asking Git for its index-based
865/// status. These are distinct authorities: Sley's Git status cannot prime
866/// Heddle's persisted worktree index, which the following snapshot consumes.
867/// In particular, retaining this check prevents a fast forced retry after a
868/// refused directory deletion from being misclassified as an unchanged tree.
869fn 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
887/// Complete a captured manual resolution and return its contextual land action.
888///
889/// This is shared by capture and the operator continuation path so the thread
890/// metadata/ref/oplog transaction has one implementation.
891pub 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(&current_state)? else {
899        return Ok(None);
900    };
901    let manager = ThreadManager::new(repo.heddle_dir());
902    let Some(mut thread) = manager.find_by_thread(&current_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, &current_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
1100/// Execute a save: optional Heddle snapshot + optional Git checkpoint write-through.
1101///
1102/// Callers own clap validation (missing message/intent) and plain-Git refusal.
1103/// Mutation composition, hooks, thread metadata, Git write-through, and post
1104/// verification live here.
1105pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
1106    // A plan that asks for a Git checkpoint on a non-overlay repo is a hard
1107    // error: `plan_writes_git_checkpoint` silently returns false for native
1108    // repos, so guard on the raw `git_scope` intent instead (the previous
1109    // `plan_writes_git_checkpoint(..) && capability != GitOverlay` was
1110    // self-contradictory and never fired).
1111    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    // Post-mutation verification is always fresh when we created state or wrote
1213    // a Git checkpoint (those mutations flip health classification). Otherwise
1214    // reuse a caller-supplied worktree status to avoid a redundant walk.
1215    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
1447/// Finish the metadata/oplog half of a checkpoint whose Git ref was already
1448/// published before a crash. Returns `None` when no matching published intent
1449/// exists, so callers can continue with their own recovery policy.
1450pub 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}