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