Skip to main content

verbs/
save.rs

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