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    preflight_unimported_git_history(repo, "capture")?;
567    let complete_thread_resolution = merge_resolution_is_complete(repo)?;
568    let worktree_status_started = Instant::now();
569    let known_worktree_changes = if complete_thread_resolution {
570        None
571    } else {
572        let status = capture_worktree_status(repo, &options.worktree_status_options)?;
573        if repo.capability() == RepositoryCapability::GitOverlay && status.is_clean() {
574            return Err(map_capture_error(anyhow!(HeddleError::NoChanges)));
575        }
576        Some(status)
577    };
578
579    let worktree_status = repo.git_overlay_worktree_status();
580    let worktree_status_ms = worktree_status_started.elapsed().as_millis();
581
582    let preflight_started = Instant::now();
583    preflight_large_capture(options.force, &worktree_status)?;
584    preflight_capture_mutation(
585        repo,
586        &worktree_status,
587        options.machine_contract_input.as_ref(),
588    )?;
589    let preflight_ms = preflight_started.elapsed().as_millis();
590    let attribution_started = Instant::now();
591    let resolved_attribution = resolve_attribution(repo)?;
592    let harness_session_id = resolved_attribution
593        .attribution
594        .agent
595        .is_some()
596        .then(|| resolved_attribution.harness_session_id.clone())
597        .flatten();
598    let attribution_ms = attribution_started.elapsed().as_millis();
599
600    let plan = SavePlan {
601        verb: SaveVerb::Capture,
602        intent: Some(options.intent),
603        confidence: options.confidence,
604        attribution: resolved_attribution.attribution,
605        git_scope: GitScope::None,
606        supplied_tree: None,
607        reuse_current_state: false,
608        require_clean_worktree: false,
609        // Native repositories compare the built tree with their Heddle HEAD.
610        // Git-overlay performs its distinct authority-aware comparison above:
611        // an overlay can legitimately have no Heddle HEAD yet and still need
612        // to capture an empty tree that represents deletion from Git's base.
613        require_worktree_change: repo.capability() == RepositoryCapability::NativeHeddle
614            && !complete_thread_resolution,
615        worktree_status_options: options.worktree_status_options,
616        known_worktree_changes,
617        run_hooks: true,
618        commit_safe_post_verify: false,
619        coalesce_snapshot_and_checkpoint: false,
620        linearize_git_parent: false,
621        precomputed_worktree_status: Some(worktree_status),
622        machine_contract_input: options.machine_contract_input,
623    };
624    let execute_save_started = Instant::now();
625    let save = execute_save(repo, plan).map_err(map_capture_error)?;
626    let execute_save_ms = execute_save_started.elapsed().as_millis();
627    if let Some(session_id) = harness_session_id
628        && let Err(error) = crate::record_last_turn_capture(repo, &session_id, save.state_id)
629    {
630        tracing::warn!(%error, "could not update reconstructible last-turn anchor");
631    }
632
633    let manual_resolution_action = if complete_thread_resolution {
634        complete_current_thread_manual_resolution(repo)?
635    } else {
636        None
637    };
638    update_capture_intent_to_add(repo, &save.state_id);
639
640    let current_thread = current_thread(repo)?;
641    let captured_thread_targets_integration = current_thread
642        .as_ref()
643        .and_then(|thread| thread.target_thread.as_ref())
644        .is_some();
645    let task_assignment_id = active_task_assignment_id(repo, current_thread.as_ref())?;
646    let principal_source = resolved_attribution.principal_source;
647    let warnings = bulk_capture_warning(save.captured_path_count)
648        .into_iter()
649        .collect();
650
651    let mut recommended_action = non_empty_action(&save.verification.recommended_action);
652    let mut recommended_action_template = recommended_action
653        .as_deref()
654        .and_then(action_template)
655        .or_else(|| save.verification.recommended_action_template.clone());
656    if let Some(action) = manual_resolution_action {
657        recommended_action_template = action_template(&action);
658        recommended_action = Some(action);
659    }
660
661    let principal = CapturePrincipalReport {
662        name: save.principal.name_lossy().into_owned(),
663        email: save.principal.email_lossy().into_owned(),
664    };
665    let agent = save.agent.as_ref().map(|agent| CaptureAgentReport {
666        provider: agent.provider.clone(),
667        model: agent.model.clone(),
668        session_id: agent.session_id.clone(),
669        segment_id: agent.segment_id.clone(),
670        policy_id: agent.policy_id.clone(),
671        thought_level: agent.thought_level.clone(),
672        parent: agent.parent.clone(),
673    });
674    Ok(CaptureReport {
675        output_kind: "capture",
676        state_id: save.state_id.short(),
677        content_hash: save.content_hash.short(),
678        intent: save.intent.clone(),
679        confidence: save.confidence,
680        task_assignment_id,
681        principal,
682        principal_source,
683        agent,
684        promotion_suggested: save.promotion_suggested,
685        heavy_impact_paths: save.heavy_impact_paths.clone(),
686        captured_path_count: save.captured_path_count,
687        warnings,
688        signed: save.signed,
689        message: save.summary.clone(),
690        recommended_action,
691        recommended_action_template,
692        verification: save.verification.clone(),
693        captured_thread_targets_integration,
694        diagnostics: CaptureDiagnostics {
695            save,
696            profile: CaptureProfile {
697                worktree_status_ms,
698                preflight_ms,
699                attribution_ms,
700                execute_save_ms,
701            },
702        },
703    })
704}
705
706fn preflight_unimported_git_history(repo: &Repository, action: &str) -> Result<()> {
707    if repo.capability() != RepositoryCapability::GitOverlay {
708        return Ok(());
709    }
710    let Some(guidance) = repo.git_import_guidance()? else {
711        return Ok(());
712    };
713    if !import_guidance_includes_active_branch(&guidance) {
714        return Ok(());
715    }
716    let branches = preview_paths(&guidance.missing_branches);
717    let command = guidance.recommended_command;
718    Err(capture_refusal(
719        "git_history_needs_import",
720        format!("Refusing to {action}: Git history has not been imported into Heddle"),
721        format!("Run `{command}` before retrying `heddle {action}`."),
722        format!("Git branch(es) waiting for Heddle import: {branches}"),
723        format!(
724            "{action} would write new Heddle state before Heddle has adopted the existing Git history"
725        ),
726        "Git refs, Heddle refs, and worktree files were left unchanged",
727        vec![command],
728    ))
729}
730
731fn preflight_capture_mutation(
732    repo: &Repository,
733    worktree_status: &repo::Result<Option<WorktreeStatus>>,
734    machine_contract_input: Option<&MachineContractInput>,
735) -> Result<()> {
736    if repo.capability() != RepositoryCapability::GitOverlay {
737        return Ok(());
738    }
739    if let Some(operation) = repo.operation_status()?
740        && matches!(operation.scope, OperationScope::Git)
741    {
742        return Err(capture_refusal(
743            "raw_git_operation_in_progress",
744            format!(
745                "Refusing to capture: an externally-started Git {} is in progress",
746                operation.kind
747            ),
748            format!(
749                "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`.",
750                operation.kind
751            ),
752            format!(
753                "Git {} is {}; Heddle cannot safely turn sequencer state into a saved change inside the no-git runtime",
754                operation.kind, operation.state
755            ),
756            "capture would capture worktree/index contents while Git still has unresolved sequencer metadata",
757            "Git refs, Git sequencer files, Heddle refs, and worktree files were left unchanged",
758            vec!["heddle verify".to_string()],
759        ));
760    }
761
762    let health = build_repository_verification_health_with_worktree_status(repo, worktree_status);
763    let trust = if let Some(input) = machine_contract_input {
764        build_repository_verification_state_with_worktree_status_and_machine_contract(
765            repo,
766            health,
767            worktree_status,
768            input,
769        )
770    } else {
771        build_repository_verification_state_with_worktree_status(repo, health, worktree_status)
772    };
773    if trust.status != "needs_reconcile" || uncheckpointed_state_is_ahead_of_git(repo)? {
774        return Ok(());
775    }
776    let primary = if trust.recommended_action.trim().is_empty() {
777        "heddle verify".to_string()
778    } else {
779        trust.recommended_action.clone()
780    };
781    let recovery_commands = if trust.recovery_commands.is_empty() {
782        vec![primary.clone()]
783    } else {
784        trust.recovery_commands.clone()
785    };
786    Err(capture_refusal(
787        "repository_verification_blocked",
788        format!(
789            "Refusing to capture: repository verification is blocked ({})",
790            trust.status
791        ),
792        format!("Run `{primary}` before retrying `heddle capture`."),
793        format!(
794            "repository verification status is {}: {}",
795            trust.status, trust.summary
796        ),
797        "capture would write new Heddle or Git state while Git and Heddle disagree",
798        "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
799        recovery_commands,
800    ))
801}
802
803fn uncheckpointed_state_is_ahead_of_git(repo: &Repository) -> Result<bool> {
804    let Some(branch) = repo.git_overlay_current_branch()? else {
805        return Ok(false);
806    };
807    let Some(tip) = repo.git_overlay_branch_tip(&branch)? else {
808        return Ok(false);
809    };
810    let Some(mapped) = tip.mapped_state else {
811        return Ok(false);
812    };
813    let Some(current) = repo.current_state()? else {
814        return Ok(false);
815    };
816    if mapped == current.state_id {
817        return Ok(false);
818    }
819    let mut graph = CommitGraphIndex::new(repo);
820    if !graph
821        .is_ancestor(&mapped, &current.state_id)
822        .unwrap_or(false)
823        || graph
824            .is_ancestor(&current.state_id, &mapped)
825            .unwrap_or(false)
826    {
827        return Ok(false);
828    }
829    Ok(repo
830        .latest_git_checkpoint_for_state(&current.state_id)?
831        .is_none())
832}
833
834fn preflight_large_capture(
835    force: bool,
836    worktree_status: &repo::Result<Option<WorktreeStatus>>,
837) -> Result<()> {
838    if force {
839        return Ok(());
840    }
841    let Ok(Some(status)) = worktree_status else {
842        return Ok(());
843    };
844    let total = status.change_count();
845    let delete_count = status.deleted.len();
846    let add_count = status.added.len();
847    if !crate::large_capture_requires_force(total, delete_count, add_count) {
848        return Ok(());
849    }
850    let sample = status
851        .deleted
852        .iter()
853        .chain(status.added.iter())
854        .chain(status.modified.iter())
855        .take(5)
856        .map(|path| path.display().to_string())
857        .collect::<Vec<_>>()
858        .join(", ");
859    let sample = if sample.is_empty() {
860        "no sample paths available".to_string()
861    } else {
862        sample
863    };
864    Err(capture_refusal(
865        "large_capture_requires_force",
866        format!(
867            "Large capture safety check: this would capture {total} changed paths ({delete_count} deletions, {add_count} additions)"
868        ),
869        "If this is intentional, rerun with `heddle capture --force -m \"...\"`.",
870        format!("sample changed paths: {sample}"),
871        "capture would preserve an unusually large Git-overlay worktree change without an explicit confirmation",
872        "repository state, refs, metadata, and worktree files were left unchanged",
873        vec!["heddle capture --force -m \"...\"".to_string()],
874    ))
875}
876
877fn merge_resolution_is_complete(repo: &Repository) -> Result<bool> {
878    Ok(repo
879        .merge_state_manager()
880        .load()?
881        .is_some_and(|merge_state| {
882            merge_state
883                .conflicts
884                .iter()
885                .all(|path| merge_state.resolved.contains(path))
886        }))
887}
888
889/// Compare against Heddle's current tree before asking Git for its index-based
890/// status. These are distinct authorities: Sley's Git status cannot prime
891/// Heddle's persisted worktree index, which the following snapshot consumes.
892/// In particular, retaining this check prevents a fast forced retry after a
893/// refused directory deletion from being misclassified as an unchanged tree.
894fn capture_worktree_status(
895    repo: &Repository,
896    options: &WorktreeStatusOptions,
897) -> Result<WorktreeStatus> {
898    if repo.current_state_for_worktree_status()?.is_none()
899        && let Some(status) = repo.git_overlay_worktree_status()?
900    {
901        return Ok(status);
902    }
903    let tree = match repo.current_state_for_worktree_status()? {
904        Some(state) => repo.require_tree_for_worktree_status(&state.tree)?,
905        None => Tree::new(),
906    };
907    Ok(repo.compare_worktree_cached_with_options(&tree, options)?)
908}
909
910/// Complete a captured manual resolution and return its contextual land action.
911///
912/// This is shared by capture and the operator continuation path so the thread
913/// metadata/ref/oplog transaction has one implementation.
914pub fn complete_current_thread_manual_resolution(repo: &Repository) -> Result<Option<String>> {
915    let Some(current_thread) = repo.current_lane()? else {
916        return Ok(None);
917    };
918    let Some(current_state) = repo.head()? else {
919        return Ok(None);
920    };
921    let Some(current_state_object) = repo.store().get_state(&current_state)? else {
922        return Ok(None);
923    };
924    let manager = ThreadManager::new(repo.heddle_dir());
925    let Some(mut thread) = manager.find_by_thread(&current_thread)? else {
926        return Ok(None);
927    };
928    let Some(target_thread) = thread.target_thread.clone() else {
929        return Ok(None);
930    };
931    let Some(target_state) = repo.refs().get_thread(&ThreadName::new(&target_thread))? else {
932        return Ok(None);
933    };
934    let Some(target_state_object) = repo.store().get_state(&target_state)? else {
935        return Ok(None);
936    };
937    let before = crate::capture_thread_update_before(repo, &manager, &thread)?;
938
939    thread.base_state = target_state.short();
940    thread.base_root = target_state_object.tree.short();
941    update_thread_state_from_state(&mut thread, &current_state_object);
942    thread.state = ThreadState::Ready;
943    thread.freshness = ThreadFreshness::Current;
944    thread.integration_policy_result = ThreadIntegrationPolicy {
945        status: Some("manual_resolved".to_string()),
946        reason: Some("manual conflict resolution captured".to_string()),
947        manual_resolution_state: Some(current_state.short()),
948        conflicts_resolved_manually: true,
949    };
950    thread.updated_at = Utc::now();
951    let thread_id = thread.id.clone();
952    let target = thread.target_thread.clone();
953    crate::save_thread_update(repo, &manager, &thread, before, current_state)?;
954
955    Ok(Some(manual_resolution_land_action(
956        repo,
957        &thread_id,
958        target.as_deref(),
959    )))
960}
961
962fn manual_resolution_land_action(
963    repo: &Repository,
964    thread_id: &str,
965    target_thread: Option<&str>,
966) -> String {
967    let action = crate::status::next_action::land_local_command(thread_id);
968    contextual_thread_action(repo, thread_id, target_thread, &action)
969}
970
971fn update_capture_intent_to_add(repo: &Repository, state_id: &StateId) {
972    if repo.capability() != RepositoryCapability::GitOverlay {
973        return;
974    }
975    let projection = GitProjection::new(repo);
976    if let Err(error) = projection.update_intent_to_add(state_id) {
977        tracing::debug!(%error, "intent-to-add index update skipped");
978    }
979}
980
981fn current_thread(repo: &Repository) -> Result<Option<Thread>> {
982    let manager = ThreadManager::new(repo.heddle_dir());
983    if let Some(thread) = manager.find_by_execution_root(repo.root())? {
984        return Ok(Some(thread));
985    }
986    let Head::Attached { thread } = repo.head_ref()? else {
987        return Ok(None);
988    };
989    let current_state_id = repo.refs().get_thread(&thread)?;
990    let current_state = current_state_id.map(|state| state.short());
991    let base_root = current_state_id
992        .and_then(|state| repo.store().get_state(&state).ok().flatten())
993        .map(|state| state.tree.short())
994        .unwrap_or_default();
995    let thread = thread.to_string();
996    Ok(Some(Thread {
997        id: thread.clone(),
998        thread,
999        target_thread: None,
1000        parent_thread: None,
1001        mode: ThreadMode::Materialized,
1002        state: ThreadState::Active,
1003        base_state: current_state.clone().unwrap_or_default(),
1004        base_root,
1005        current_state,
1006        merged_state: None,
1007        task: None,
1008        execution_path: repo.root().to_path_buf(),
1009        materialized_path: None,
1010        changed_paths: Vec::new(),
1011        impact_categories: Vec::new(),
1012        heavy_impact_paths: Vec::new(),
1013        promotion_suggested: false,
1014        freshness: ThreadFreshness::Unknown,
1015        verification_summary: Default::default(),
1016        confidence_summary: Default::default(),
1017        integration_policy_result: Default::default(),
1018        created_at: Utc::now(),
1019        updated_at: Utc::now(),
1020        ephemeral: None,
1021        auto: false,
1022        shared_target_dir: None,
1023    }))
1024}
1025
1026fn active_task_assignment_id(repo: &Repository, thread: Option<&Thread>) -> Result<Option<String>> {
1027    let Some(thread) = thread else {
1028        return Ok(None);
1029    };
1030    let store = ActorPresenceStore::new(repo.heddle_dir());
1031    Ok(store
1032        .active_entries()?
1033        .into_iter()
1034        .filter(|entry| entry.thread == thread.id)
1035        .max_by_key(|entry| entry.started_at)
1036        .and_then(|entry| entry.task_assignment_id))
1037}
1038
1039fn bulk_capture_warning(captured_path_count: usize) -> Option<String> {
1040    (captured_path_count >= BULK_CAPTURE_WARNING_THRESHOLD).then(|| {
1041        format!(
1042            "captured {captured_path_count} paths in one operation; check root .gitignore and .heddleignore rules if build artifacts or tool state were included"
1043        )
1044    })
1045}
1046
1047fn non_empty_action(action: &str) -> Option<String> {
1048    (!action.trim().is_empty()).then(|| action.to_string())
1049}
1050
1051fn map_capture_error(error: anyhow::Error) -> anyhow::Error {
1052    if error.chain().any(|cause| {
1053        cause
1054            .downcast_ref::<HeddleError>()
1055            .is_some_and(|error| matches!(error, HeddleError::NoChanges))
1056    }) {
1057        return capture_refusal(
1058            "nothing_to_capture",
1059            "nothing to capture: worktree has no changes eligible for Heddle capture",
1060            "Inspect the worktree with `heddle status`; make changes before running `heddle capture -m \"...\"`.",
1061            "the worktree has no modified, deleted, or untracked paths relative to the current Heddle state",
1062            "capture would not create a meaningful Heddle state",
1063            "repository state was left unchanged",
1064            vec!["heddle status".to_string()],
1065        );
1066    }
1067    if error.chain().any(|cause| {
1068        cause
1069            .downcast_ref::<std::io::Error>()
1070            .is_some_and(objects::fs_atomic::is_out_of_space)
1071    }) {
1072        return capture_refusal(
1073            "capture_out_of_space",
1074            format!("Capture aborted because the filesystem is out of space: {error:#}"),
1075            "Free disk space and re-run `heddle capture`. Your working tree changes are intact.",
1076            "the filesystem reported no remaining space while Heddle was writing captured objects",
1077            "retrying before freeing space may fail again or leave another incomplete object write",
1078            "the working tree was not modified; already-committed repository data remains behind atomic write boundaries",
1079            vec!["heddle capture -m \"...\"".to_string()],
1080        );
1081    }
1082    error
1083}
1084
1085#[allow(clippy::too_many_arguments)]
1086fn capture_refusal(
1087    kind: &'static str,
1088    error: impl Into<String>,
1089    hint: impl Into<String>,
1090    unsafe_condition: impl Into<String>,
1091    would_change: impl Into<String>,
1092    preserved: impl Into<String>,
1093    recovery_commands: Vec<String>,
1094) -> anyhow::Error {
1095    anyhow!(HeddleError::recovery(
1096        RecoveryDetails::safety_refusal(
1097            kind,
1098            error,
1099            hint,
1100            unsafe_condition,
1101            would_change,
1102            preserved,
1103        )
1104        .with_recovery_commands(recovery_commands),
1105    ))
1106}
1107
1108fn preview_paths(paths: &[String]) -> String {
1109    let shown = paths
1110        .iter()
1111        .take(12)
1112        .cloned()
1113        .collect::<Vec<_>>()
1114        .join(", ");
1115    let hidden = paths.len().saturating_sub(12);
1116    if hidden == 0 {
1117        shown
1118    } else {
1119        format!("{shown}, and {hidden} more")
1120    }
1121}
1122
1123/// Execute a save: optional Heddle snapshot + optional Git checkpoint write-through.
1124///
1125/// Callers own clap validation (missing message/intent) and plain-Git refusal.
1126/// Mutation composition, hooks, thread metadata, Git write-through, and post
1127/// verification live here.
1128pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
1129    // A plan that asks for a Git checkpoint on a non-overlay repo is a hard
1130    // error: `plan_writes_git_checkpoint` silently returns false for native
1131    // repos, so guard on the raw `git_scope` intent instead (the previous
1132    // `plan_writes_git_checkpoint(..) && capability != GitOverlay` was
1133    // self-contradictory and never fired).
1134    if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
1135        return Err(anyhow!(HeddleError::recovery(
1136            RecoveryDetails::safety_refusal(
1137                "native_checkpoint_unavailable",
1138                "Git checkpointing is only available in Git-overlay repositories",
1139                "Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
1140                "this checkout is not a Git-overlay repository",
1141                "checkpoint would try to write a Git commit where no active Git store is bound",
1142                "repository state, refs, and worktree files were left unchanged",
1143            ),
1144        )));
1145    }
1146
1147    let previous_state_started = Instant::now();
1148    let (previous_state, previous_state_profile) =
1149        repo.current_state_for_worktree_status_profiled()?;
1150    let previous_state_ms = previous_state_started.elapsed().as_millis();
1151    let has_current = previous_state.is_some();
1152    let mut created_new_state = false;
1153    let mut snapshot_profile = SnapshotProfile::default();
1154    let mut thread_metadata_ms = 0u128;
1155    let mut promotion_suggested = false;
1156    let mut heavy_impact_paths = Vec::new();
1157    let mut snapshot_state_id: Option<StateId> = None;
1158    let mut captured_path_count = 0usize;
1159    let mut state_create_ms = 0u128;
1160    let mut captured_path_count_ms = 0u128;
1161
1162    let mut state = if plan_creates_new_state(&plan, has_current) {
1163        created_new_state = true;
1164        let state_create_started = Instant::now();
1165        let execution = create_heddle_state(repo, &plan)?;
1166        state_create_ms = state_create_started.elapsed().as_millis();
1167        snapshot_profile = execution.profile;
1168        thread_metadata_ms = execution.thread_metadata_ms;
1169        promotion_suggested = execution.promotion_suggested;
1170        heavy_impact_paths = execution.heavy_impact_paths;
1171        snapshot_state_id = Some(execution.state.state_id);
1172        let previous_tree = match previous_state.as_ref() {
1173            Some(state) => state.tree,
1174            None => repo.store().put_tree(&Tree::new())?,
1175        };
1176        let captured_path_count_started = Instant::now();
1177        captured_path_count = repo
1178            .diff_trees(&previous_tree, &execution.state.tree)?
1179            .len();
1180        captured_path_count_ms = captured_path_count_started.elapsed().as_millis();
1181        execution.state
1182    } else {
1183        repo.current_state()?
1184            .ok_or_else(|| anyhow!("no captured state found for save"))?
1185    };
1186
1187    let mut git_commit = None;
1188    let mut git_previous_commit = None;
1189    let mut git_checkpoint = None;
1190
1191    if plan_writes_git_checkpoint(&plan, repo.capability()) {
1192        if plan.require_clean_worktree {
1193            let tree = repo.require_tree(&state.tree)?;
1194            let status = repo.compare_worktree_cached_detailed_with_options(
1195                &tree,
1196                &plan.worktree_status_options,
1197            )?;
1198            if !status.is_clean() {
1199                return Err(anyhow!(HeddleError::recovery(
1200                    RecoveryDetails::safety_refusal(
1201                        "dirty_worktree",
1202                        "Save worktree changes before committing",
1203                        "Save the work with `heddle capture -m \"...\"`, then retry the commit.",
1204                        "the current Heddle state was left unchanged; these paths have not been captured",
1205                        "commit would write Git history that does not include dirty worktree paths",
1206                        "the current Heddle state was left unchanged; these paths have not been captured",
1207                    ),
1208                )));
1209            }
1210        }
1211
1212        if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
1213            && repo.pending_git_checkpoint_intent()?.is_none()
1214        {
1215            git_commit = Some(existing.git_commit.clone());
1216            git_checkpoint = Some(existing);
1217        } else {
1218            let previous = repo
1219                .pending_git_checkpoint_intent()?
1220                .and_then(|intent| intent.previous_git_oid)
1221                .or_else(|| git_rev_parse_head(repo.root()));
1222            git_previous_commit = previous.clone();
1223            let summary = checkpoint_summary(&plan, &state);
1224            let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
1225            if plan.coalesce_snapshot_and_checkpoint
1226                && let Some(state_id) = snapshot_state_id.as_ref()
1227            {
1228                coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
1229            }
1230            git_commit = Some(record.git_commit.clone());
1231            git_checkpoint = Some(record);
1232        }
1233    }
1234
1235    // Post-mutation verification is always fresh when we created state or wrote
1236    // a Git checkpoint (those mutations flip health classification). Otherwise
1237    // reuse a caller-supplied worktree status to avoid a redundant walk.
1238    let captured_native_worktree = created_new_state
1239        && plan.supplied_tree.is_none()
1240        && repo.capability() == RepositoryCapability::NativeHeddle;
1241    let captured_worktree_status = Ok(Some(objects::worktree::WorktreeStatus::default()));
1242    let verification_started = Instant::now();
1243    let mut verification = if captured_native_worktree && git_checkpoint.is_none() {
1244        let health = build_repository_verification_health_with_worktree_status(
1245            repo,
1246            &captured_worktree_status,
1247        );
1248        if let Some(input) = &plan.machine_contract_input {
1249            build_repository_verification_state_with_worktree_status_and_machine_contract(
1250                repo,
1251                health,
1252                &captured_worktree_status,
1253                input,
1254            )
1255        } else {
1256            build_repository_verification_state_with_worktree_status(
1257                repo,
1258                health,
1259                &captured_worktree_status,
1260            )
1261        }
1262    } else if created_new_state || git_checkpoint.is_some() {
1263        if let Some(input) = &plan.machine_contract_input {
1264            build_repository_verification_state_with_machine_contract(repo, input)?
1265        } else {
1266            build_repository_verification_state(repo)?
1267        }
1268    } else if let Some(status) = &plan.precomputed_worktree_status {
1269        let health = build_repository_verification_health_with_worktree_status(repo, status);
1270        if let Some(input) = &plan.machine_contract_input {
1271            build_repository_verification_state_with_worktree_status_and_machine_contract(
1272                repo, health, status, input,
1273            )
1274        } else {
1275            build_repository_verification_state_with_worktree_status(repo, health, status)
1276        }
1277    } else {
1278        if let Some(input) = &plan.machine_contract_input {
1279            build_repository_verification_state_with_machine_contract(repo, input)?
1280        } else {
1281            build_repository_verification_state(repo)?
1282        }
1283    };
1284    if plan.commit_safe_post_verify {
1285        soften_commit_next_action(&mut verification);
1286    }
1287    let post_verification_ms = verification_started.elapsed().as_millis();
1288
1289    let summary = match plan.verb {
1290        SaveVerb::Capture => format!(
1291            "Captured state {} ({})",
1292            state.state_id.short(),
1293            state.hash().short()
1294        ),
1295        SaveVerb::Commit => plan
1296            .intent
1297            .clone()
1298            .unwrap_or_else(|| format!("Commit {}", state.state_id.short())),
1299        SaveVerb::Checkpoint => git_checkpoint
1300            .as_ref()
1301            .map(|r| r.summary.clone())
1302            .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
1303    };
1304
1305    let signature_lookup_started = Instant::now();
1306    let signed = repo.get_state_signature(&state.id())?.is_some();
1307    let signature_lookup_ms = signature_lookup_started.elapsed().as_millis();
1308    Ok(SaveReport {
1309        verb: plan.verb,
1310        state_id: state.state_id,
1311        content_hash: state.hash(),
1312        intent: state.intent.clone(),
1313        confidence: state.confidence,
1314        signed,
1315        git_commit,
1316        git_previous_commit,
1317        summary,
1318        principal: state.attribution.principal.clone(),
1319        agent: state.attribution.agent.clone(),
1320        promotion_suggested,
1321        heavy_impact_paths,
1322        captured_path_count,
1323        verification,
1324        created_new_state,
1325        git_checkpoint,
1326        snapshot_profile,
1327        state_create_ms,
1328        captured_path_count_ms,
1329        post_verification_ms,
1330        thread_metadata_ms,
1331        previous_state_ms,
1332        previous_state_profile,
1333        signature_lookup_ms,
1334    })
1335}
1336
1337struct CreatedState {
1338    state: State,
1339    profile: SnapshotProfile,
1340    thread_metadata_ms: u128,
1341    promotion_suggested: bool,
1342    heavy_impact_paths: Vec<String>,
1343}
1344
1345fn create_heddle_state(repo: &Repository, plan: &SavePlan) -> Result<CreatedState> {
1346    let hook_manager = HookManager::new(repo);
1347    let hook_ctx = HookContext::new(repo);
1348    let mut post_hook_worktree_changes = None;
1349
1350    if plan.run_hooks {
1351        let pre_snapshot_ran = hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
1352        let pre_capture_payload = serde_json::json!({
1353            "thread": current_thread_name(repo),
1354            "intent": plan.intent.clone().unwrap_or_default(),
1355        });
1356        let pre_capture_response = hook_manager.run_with_payload(
1357            Hook::PreSnapshot,
1358            &hook_ctx,
1359            &pre_capture_payload,
1360            std::time::Duration::from_secs(5),
1361        )?;
1362        if let Some(resp) = pre_capture_response
1363            && !resp.abort.is_empty()
1364        {
1365            return Err(anyhow!(HeddleError::recovery(
1366                RecoveryDetails::safety_refusal(
1367                    "hook_veto",
1368                    format!("pre_capture hook vetoed: {}", resp.abort),
1369                    "Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
1370                    format!("pre_capture hook vetoed capture: {}", resp.abort),
1371                    "capture would continue after repository policy explicitly aborted the operation",
1372                    "the operation stopped at the hook boundary before the protected action ran",
1373                )
1374                .with_recovery_commands(vec!["heddle hook list".to_string()]),
1375            )));
1376        }
1377        if pre_snapshot_ran && plan.supplied_tree.is_none() {
1378            // Hooks can mutate paths outside capture's preflight set. Rewalk
1379            // authoritatively so a settled monitor token cannot vouch for a
1380            // tree built from that stale set. No-hook captures keep the fast path.
1381            let authoritative_options = WorktreeStatusOptions {
1382                fsmonitor: repo::FsMonitorSettings {
1383                    mode: repo::FsMonitorMode::Off,
1384                },
1385            };
1386            post_hook_worktree_changes =
1387                Some(capture_worktree_status(repo, &authoritative_options)?);
1388        }
1389    }
1390    let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
1391        repo.snapshot_tree_with_attribution_profiled(
1392            tree,
1393            plan.intent.clone(),
1394            plan.confidence,
1395            plan.attribution.clone(),
1396        )?
1397    } else if let Some(status) =
1398        post_hook_worktree_changes.or_else(|| plan.known_worktree_changes.clone())
1399    {
1400        repo.snapshot_with_attribution_profiled_from_status(
1401            plan.intent.clone(),
1402            plan.confidence,
1403            plan.attribution.clone(),
1404            status,
1405            plan.require_worktree_change,
1406        )?
1407    } else if plan.require_worktree_change {
1408        repo.snapshot_with_attribution_profiled_if_changed(
1409            plan.intent.clone(),
1410            plan.confidence,
1411            plan.attribution.clone(),
1412        )?
1413    } else {
1414        repo.snapshot_with_attribution_profiled(
1415            plan.intent.clone(),
1416            plan.confidence,
1417            plan.attribution.clone(),
1418        )?
1419    };
1420
1421    let thread_metadata_start = Instant::now();
1422    let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
1423    let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
1424
1425    if plan.run_hooks {
1426        hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
1427        let post_capture_payload = serde_json::json!({
1428            "state_id": execution.state.state_id.to_string_full(),
1429        });
1430        if let Err(err) = hook_manager.run_with_payload(
1431            Hook::PostSnapshot,
1432            &hook_ctx,
1433            &post_capture_payload,
1434            std::time::Duration::from_secs(5),
1435        ) {
1436            tracing::warn!(error = %err, "post_capture hook error swallowed");
1437        }
1438    }
1439
1440    Ok(CreatedState {
1441        state: execution.state,
1442        profile: std::mem::take(&mut execution.profile),
1443        thread_metadata_ms,
1444        promotion_suggested: refresh.promotion_suggested,
1445        heavy_impact_paths: refresh.heavy_impact_paths,
1446    })
1447}
1448
1449fn write_git_checkpoint(
1450    repo: &Repository,
1451    state: &State,
1452    summary: String,
1453    linearize_git_parent: bool,
1454) -> Result<GitCheckpointRecord> {
1455    let _lock = repo.locker().write()?;
1456    objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
1457    let mut bridge = GitProjection::new(repo);
1458    if linearize_git_parent {
1459        bridge.linearize_unmapped_tip_to_checkout();
1460    }
1461    let git_commit = match bridge
1462        .write_through_current_checkout_with_message(state.state_id, summary.clone())?
1463    {
1464        WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
1465        WriteThroughOutcome::Skipped(reason) => {
1466            return Err(anyhow!(HeddleError::recovery(
1467                RecoveryDetails::safety_refusal(
1468                    "checkpoint_git_write_skipped",
1469                    format!("Git checkpoint write-through was skipped: {reason}"),
1470                    "Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
1471                    format!("write-through skipped: {reason}"),
1472                    "checkpoint would need to write the current Heddle state into the Git branch and index",
1473                    "the current Heddle state was preserved; no Git checkpoint record was written",
1474                ),
1475            )));
1476        }
1477    };
1478    let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
1479        anyhow!("Git checkpoint published without its durable finalization intent")
1480    })?;
1481    if intent.phase != repo::GitCheckpointIntentPhase::Published
1482        || intent.state_id != state.state_id.to_string_full()
1483        || intent.new_git_oid != git_commit
1484    {
1485        return Err(anyhow!(
1486            "published Git checkpoint does not match its durable finalization intent"
1487        ));
1488    }
1489    finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
1490}
1491
1492/// Finish the metadata/oplog half of a checkpoint whose Git ref was already
1493/// published before a crash. Returns `None` when no matching published intent
1494/// exists, so callers can continue with their own recovery policy.
1495pub fn recover_published_git_checkpoint(
1496    repo: &Repository,
1497    state_id: &StateId,
1498) -> Result<Option<GitCheckpointRecord>> {
1499    let _lock = repo.locker().write()?;
1500    let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
1501        return Ok(None);
1502    };
1503    if intent.state_id != state_id.to_string_full() {
1504        return Ok(None);
1505    }
1506    let current_branch = repo.git_overlay_current_branch()?;
1507    if current_branch.as_deref() != Some(intent.branch.as_str()) {
1508        return Err(anyhow!(
1509            "pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
1510            intent.branch,
1511            current_branch.as_deref().unwrap_or("detached HEAD")
1512        ));
1513    }
1514    let current_oid = git_rev_parse_head(repo.root());
1515    if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
1516        if current_oid == intent.previous_git_oid {
1517            return Ok(None);
1518        }
1519        if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1520            return Err(anyhow!(
1521                "prepared Git checkpoint expected HEAD at {} or {}, found {}",
1522                intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
1523                intent.new_git_oid,
1524                current_oid.as_deref().unwrap_or("<unborn>")
1525            ));
1526        }
1527        let git_oid = intent.new_git_oid.clone();
1528        intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
1529    }
1530    if intent.phase != repo::GitCheckpointIntentPhase::Published {
1531        return Ok(None);
1532    }
1533    if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1534        return Err(anyhow!(
1535            "published Git checkpoint expected HEAD at {}, found {}",
1536            intent.new_git_oid,
1537            current_oid.as_deref().unwrap_or("<unborn>")
1538        ));
1539    }
1540    let git_commit = intent.new_git_oid.clone();
1541    let summary = intent.summary.clone();
1542    finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
1543}
1544
1545fn finalize_published_git_checkpoint(
1546    repo: &Repository,
1547    state_id: &StateId,
1548    git_commit: String,
1549    summary: String,
1550    intent: repo::GitCheckpointIntent,
1551) -> Result<GitCheckpointRecord> {
1552    let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
1553    objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
1554    let transaction_id = format!(
1555        "git-checkpoint:v1:{}:{}",
1556        state_id.to_string_full(),
1557        git_commit
1558    );
1559    repo.oplog().record_batch_exactly_once(
1560        vec![
1561            OpRecord::GitCheckpoint {
1562                branch: intent.branch,
1563                state: *state_id,
1564                previous_git_oid: intent.previous_git_oid,
1565                new_git_oid: git_commit.clone(),
1566            },
1567            OpRecord::TransactionCommit {
1568                transaction_id: transaction_id.clone(),
1569                op_count: 1,
1570            },
1571        ],
1572        Some(&repo.op_scope()),
1573        &transaction_id,
1574    )?;
1575    objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
1576    repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
1577    Ok(record)
1578}
1579
1580fn coalesce_snapshot_and_checkpoint(
1581    repo: &Repository,
1582    state_id: &StateId,
1583    git_commit: &str,
1584) -> Result<()> {
1585    let snapshot_batch = repo
1586        .oplog()
1587        .recent_batches_scoped(8, Some(&repo.op_scope()))?
1588        .into_iter()
1589        .find(|batch| {
1590            batch.entries.iter().any(|entry| {
1591                matches!(
1592                    &entry.operation,
1593                    OpRecord::Snapshot { new_state, .. } if new_state == state_id
1594                )
1595            })
1596        })
1597        .ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
1598    let checkpoint_batch = repo
1599        .oplog()
1600        .recent_batches_scoped(8, Some(&repo.op_scope()))?
1601        .into_iter()
1602        .find(|batch| {
1603            batch.entries.iter().any(|entry| {
1604                matches!(
1605                    &entry.operation,
1606                    OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
1607                )
1608            })
1609        })
1610        .ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
1611    repo.oplog()
1612        .coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
1613        .context(
1614            "commit completed but failed to record capture and Git checkpoint as one undo batch",
1615        )?;
1616    Ok(())
1617}
1618
1619fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
1620    plan.intent
1621        .clone()
1622        .or_else(|| state.intent.clone())
1623        .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
1624}
1625
1626fn current_thread_name(repo: &Repository) -> String {
1627    match repo.head_ref() {
1628        Ok(Head::Attached { thread }) => thread.to_string(),
1629        _ => String::new(),
1630    }
1631}
1632
1633fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
1634    let git = SleyRepository::discover(root).ok()?;
1635    git.head().ok()?.oid.map(|id| id.to_string())
1636}
1637
1638fn soften_commit_next_action(trust: &mut RepositoryVerificationState) {
1639    if is_commit_action(&trust.recommended_action) {
1640        trust.recommended_action = "heddle status".to_string();
1641        trust.recommended_action_template = None;
1642    }
1643    for check in &mut trust.checks {
1644        if check
1645            .recommended_action
1646            .as_deref()
1647            .is_some_and(is_commit_action)
1648        {
1649            check.recommended_action = Some("heddle status".to_string());
1650            check.recommended_action_template = None;
1651        }
1652    }
1653}
1654
1655fn is_commit_action(action: &str) -> bool {
1656    let trimmed = action.trim();
1657    trimmed == "heddle capture" || trimmed.starts_with("heddle capture ")
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662    use std::cell::Cell;
1663
1664    use repo::RepositoryCapability;
1665    use tempfile::TempDir;
1666
1667    use super::*;
1668
1669    #[test]
1670    fn capture_interface_owns_mutation_and_returns_the_report_contract() {
1671        let temp = TempDir::new().expect("create temp repository");
1672        let repo = Repository::init_default(temp.path()).expect("initialize repository");
1673        std::fs::write(temp.path().join("tracked.txt"), "captured\n")
1674            .expect("write worktree change");
1675        let ctx = ExecutionContext::builder()
1676            .repo(repo)
1677            .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
1678            .build();
1679
1680        let report = capture(
1681            &ctx,
1682            CaptureOptions {
1683                intent: "exercise the deep capture interface".into(),
1684                confidence: Some(0.9),
1685                force: false,
1686                worktree_status_options: WorktreeStatusOptions::default(),
1687                machine_contract_input: None,
1688            },
1689            |_| {
1690                Ok(CaptureAttribution {
1691                    attribution: Attribution::human(Principal::new("Ada", "ada@example.com")),
1692                    principal_source: "embedder".into(),
1693                    harness_session_id: None,
1694                })
1695            },
1696        )
1697        .expect("capture succeeds");
1698
1699        assert_eq!(report.output_kind, "capture");
1700        assert_eq!(report.captured_path_count, 1);
1701        assert_eq!(report.principal.name, "Ada");
1702        assert_eq!(report.principal.email, "ada@example.com");
1703        assert_eq!(report.principal_source, "embedder");
1704        assert_eq!(CaptureReport::CONTRACT.schema_name, "capture");
1705        assert_eq!(
1706            ctx.require_repo()
1707                .expect("repository")
1708                .head()
1709                .expect("read head")
1710                .expect("captured head")
1711                .short(),
1712            report.state_id
1713        );
1714
1715        let wire = serde_json::to_value(&report).expect("serialize report");
1716        assert_eq!(wire["output_kind"], "capture");
1717        assert!(wire.get("diagnostics").is_none());
1718        assert!(wire.get("captured_thread_targets_integration").is_none());
1719    }
1720
1721    #[test]
1722    fn manual_resolution_land_action_quotes_untrusted_thread_ids() {
1723        let temp = TempDir::new().expect("create temp repository");
1724        let repo = Repository::init_default(temp.path()).expect("initialize repository");
1725
1726        assert_eq!(
1727            manual_resolution_land_action(&repo, "bad;echo pwn", None),
1728            "heddle land --thread 'bad;echo pwn'"
1729        );
1730        assert_eq!(
1731            manual_resolution_land_action(&repo, "-danger", None),
1732            "heddle land --thread=-danger"
1733        );
1734    }
1735
1736    #[test]
1737    fn clean_overlay_refuses_before_resolving_attribution() {
1738        let temp = TempDir::new().expect("create temp repository");
1739        SleyRepository::init(temp.path()).expect("initialize Git repository");
1740        let repo = Repository::init_git_overlay_sidecar(temp.path())
1741            .expect("initialize Git-overlay sidecar");
1742        let ctx = ExecutionContext::builder().repo(repo).build();
1743        let resolver_called = Cell::new(false);
1744
1745        let error = capture(
1746            &ctx,
1747            CaptureOptions {
1748                intent: "nothing changed".into(),
1749                confidence: None,
1750                force: false,
1751                worktree_status_options: WorktreeStatusOptions::default(),
1752                machine_contract_input: None,
1753            },
1754            |_| {
1755                resolver_called.set(true);
1756                Ok(CaptureAttribution {
1757                    attribution: Attribution::human(Principal::new("Ada", "ada@example.com")),
1758                    principal_source: "embedder".into(),
1759                    harness_session_id: None,
1760                })
1761            },
1762        )
1763        .expect_err("clean overlay must refuse capture");
1764
1765        assert!(!resolver_called.get());
1766        assert!(error.to_string().contains("nothing to capture"));
1767    }
1768
1769    #[test]
1770    fn capture_always_uses_git_scope_none() {
1771        assert_eq!(
1772            plan_git_scope(
1773                SaveVerb::Capture,
1774                RepositoryCapability::GitOverlay,
1775                true,
1776                true
1777            ),
1778            GitScope::None
1779        );
1780        assert_eq!(
1781            plan_git_scope(
1782                SaveVerb::Capture,
1783                RepositoryCapability::NativeHeddle,
1784                false,
1785                false
1786            ),
1787            GitScope::None
1788        );
1789    }
1790
1791    #[test]
1792    fn commit_native_never_writes_git() {
1793        assert_eq!(
1794            plan_git_scope(
1795                SaveVerb::Commit,
1796                RepositoryCapability::NativeHeddle,
1797                true,
1798                true
1799            ),
1800            GitScope::None
1801        );
1802    }
1803
1804    #[test]
1805    fn commit_git_overlay_routes_staged_vs_worktree() {
1806        assert_eq!(
1807            plan_git_scope(
1808                SaveVerb::Commit,
1809                RepositoryCapability::GitOverlay,
1810                true,
1811                false
1812            ),
1813            GitScope::Staged
1814        );
1815        assert_eq!(
1816            plan_git_scope(
1817                SaveVerb::Commit,
1818                RepositoryCapability::GitOverlay,
1819                true,
1820                true
1821            ),
1822            GitScope::WorktreeAll
1823        );
1824        assert_eq!(
1825            plan_git_scope(
1826                SaveVerb::Commit,
1827                RepositoryCapability::GitOverlay,
1828                false,
1829                false
1830            ),
1831            GitScope::WorktreeAll
1832        );
1833    }
1834
1835    #[test]
1836    fn checkpoint_routes_staged_flag() {
1837        assert_eq!(
1838            plan_git_scope(
1839                SaveVerb::Checkpoint,
1840                RepositoryCapability::GitOverlay,
1841                true,
1842                false
1843            ),
1844            GitScope::Staged
1845        );
1846        assert_eq!(
1847            plan_git_scope(
1848                SaveVerb::Checkpoint,
1849                RepositoryCapability::GitOverlay,
1850                false,
1851                false
1852            ),
1853            GitScope::WorktreeAll
1854        );
1855    }
1856
1857    #[test]
1858    fn plan_creates_new_state_routing() {
1859        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1860        let capture = SavePlan::capture("wip", attr.clone());
1861        assert!(plan_creates_new_state(&capture, true));
1862        assert!(plan_creates_new_state(&capture, false));
1863
1864        let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
1865        assert!(!plan_creates_new_state(&checkpoint, true));
1866        assert!(plan_creates_new_state(&checkpoint, false));
1867
1868        let staged =
1869            SavePlan::commit("msg", attr, GitScope::Staged).with_supplied_tree(Tree::new());
1870        assert!(plan_creates_new_state(&staged, true));
1871    }
1872
1873    #[test]
1874    fn plan_writes_git_checkpoint_respects_scope_and_capability() {
1875        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1876        let capture = SavePlan::capture("wip", attr.clone());
1877        assert!(!plan_writes_git_checkpoint(
1878            &capture,
1879            RepositoryCapability::GitOverlay
1880        ));
1881
1882        let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
1883        assert!(plan_writes_git_checkpoint(
1884            &commit,
1885            RepositoryCapability::GitOverlay
1886        ));
1887        assert!(!plan_writes_git_checkpoint(
1888            &commit,
1889            RepositoryCapability::NativeHeddle
1890        ));
1891
1892        let none = SavePlan::commit("msg", attr, GitScope::None);
1893        assert!(!plan_writes_git_checkpoint(
1894            &none,
1895            RepositoryCapability::GitOverlay
1896        ));
1897    }
1898
1899    #[test]
1900    fn save_plan_builders_set_expected_defaults() {
1901        let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
1902        let capture = SavePlan::capture("intent", attr.clone());
1903        assert_eq!(capture.verb, SaveVerb::Capture);
1904        assert_eq!(capture.git_scope, GitScope::None);
1905        assert!(!capture.coalesce_snapshot_and_checkpoint);
1906
1907        let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
1908        assert_eq!(commit.verb, SaveVerb::Commit);
1909        assert!(commit.coalesce_snapshot_and_checkpoint);
1910        assert!(commit.commit_safe_post_verify);
1911
1912        let staged = SavePlan::checkpoint(None, attr, true);
1913        assert_eq!(staged.git_scope, GitScope::Staged);
1914        assert!(!staged.require_clean_worktree);
1915        assert!(staged.reuse_current_state);
1916    }
1917
1918    #[test]
1919    fn tree_leaf_name_and_commit_next_action() {
1920        assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
1921        assert_eq!(tree_leaf_name("solo"), "solo");
1922        assert_eq!(
1923            commit_next_action_from_trust("heddle push", false, false).as_deref(),
1924            Some("heddle push")
1925        );
1926        assert_eq!(
1927            commit_next_action_from_trust("", false, true).as_deref(),
1928            Some("heddle verify")
1929        );
1930        assert_eq!(
1931            commit_next_action_from_trust("", true, true).as_deref(),
1932            Some("heddle push")
1933        );
1934        assert_eq!(commit_next_action_from_trust("", true, false), None);
1935    }
1936
1937    #[test]
1938    fn commit_git_index_plan_modes() {
1939        let staged = vec!["a.rs".into()];
1940        let extra = vec!["unstaged: b.rs".into(), "untracked: c.rs".into()];
1941        let staged_only = plan_commit_git_index(&staged, &extra, false);
1942        assert_eq!(staged_only.commit_mode, "staged_index");
1943        assert_eq!(staged_only.will_commit, vec!["a.rs"]);
1944        assert_eq!(staged_only.preserved_after_commit.len(), 2);
1945
1946        let all = plan_commit_git_index(&staged, &extra, true);
1947        assert_eq!(all.commit_mode, "worktree_all_explicit");
1948        assert_eq!(all.will_commit.len(), 3);
1949
1950        let index_only = plan_commit_git_index_only(&staged, &extra);
1951        assert_eq!(index_only.commit_mode, "staged_index");
1952        assert_eq!(index_only.will_commit, vec!["a.rs"]);
1953
1954        assert!(commit_scope_text("staged_index").contains("staged Git index"));
1955        assert!(staged_commit_summary("ok", 1, 2).contains("left 2 unstaged/untracked"));
1956        assert_eq!(staged_commit_summary("ok", 1, 0), "ok");
1957    }
1958}