Skip to main content

heddle_cli_contract/cli/commands/verification_health/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! CLI verification adapter: Machine-Contract catalog injection, RecoveryAdvice,
3//! and thin wrappers around the single core proof owner.
4//!
5//! **Do not construct Repository Verification Health/State here.** Proof
6//! construction lives in `heddle_core::status` / `heddle_core::verify`. This
7//! module injects command-catalog coverage, builds refusal advice, and
8//! formats setup guidance for text/render paths.
9
10use std::{collections::BTreeSet, path::Path, sync::OnceLock};
11
12use heddle_core::status::next_action::{
13    canonical_git_import_ref_command, canonical_git_repair_ref_preview_command,
14    heddle_action as core_heddle_action, import_guidance_includes_active_branch,
15    remote_tracking_next_action_for, remote_tracking_status,
16};
17pub(crate) use heddle_core::{
18    ActionTemplate, MachineContractCoverage, MachineContractInput, PlainGitVerifyProbe,
19};
20pub use heddle_core::{
21    RepositoryVerificationCheck, RepositoryVerificationHealth, RepositoryVerificationState,
22    VerificationCheck, repository_setup_guidance, verify::serialize_empty_action_as_null,
23};
24use objects::{object::ThreadName, worktree::WorktreeStatus};
25use refs::Head;
26use repo::{
27    CommitGraphIndex, GitOverlayBranchTip, GitRemoteTrackingStatus, OperationKind, OperationScope,
28    Repository,
29};
30
31use super::{
32    advice::RecoveryAdvice,
33    command_catalog::{
34        ActionFields, build_command_catalog, heddle_action, recommended_action_template,
35    },
36    schemas::opaque_schema_verbs,
37};
38
39pub type PlainGitVerificationProbe = PlainGitVerifyProbe;
40
41pub fn primary_recovery_command(health: &RepositoryVerificationHealth) -> Option<&str> {
42    health.recovery_commands.first().map(String::as_str)
43}
44
45pub fn override_trust_recommended_action(
46    trust: &mut RepositoryVerificationState,
47    action: impl Into<String>,
48) {
49    let action = action.into();
50    let action_fields = ActionFields::from_action(&action);
51    trust.recommended_action_template = action_fields.template.clone();
52    trust.recommended_action = action.clone();
53    if let Some(check) = trust
54        .checks
55        .iter_mut()
56        .find(|check| check.name == "Workflow")
57    {
58        check.recommended_action_template = action_fields.template;
59        check.recommended_action = Some(action);
60    }
61}
62pub fn trust_visible_worktree_status(
63    repo: &Repository,
64    trust: &RepositoryVerificationState,
65) -> anyhow::Result<Option<WorktreeStatus>> {
66    if matches!(
67        trust.status.as_str(),
68        "needs_import" | "needs_reconcile" | "git_branch_advanced"
69    ) {
70        return Ok(Some(
71            repo.git_overlay_worktree_status()?.unwrap_or_default(),
72        ));
73    }
74    Ok(None)
75}
76
77fn machine_contract_is_clean(coverage: &MachineContractCoverage) -> bool {
78    coverage.verified_scope_json_commands_without_schema == 0
79        && coverage.verified_scope_mutating_commands_without_schema == 0
80        && coverage.verified_scope_json_commands_with_accepted_opaque_schema == 0
81        && coverage.verified_scope_mutating_commands_with_accepted_opaque_schema == 0
82        && coverage.unaccepted_opaque_schema_verbs_total == 0
83}
84
85pub fn machine_contract_status(coverage: &MachineContractCoverage) -> &'static str {
86    if !machine_contract_is_clean(coverage) {
87        return "schema_gaps";
88    } else if coverage.undocumented_schema_verbs_total > 0 {
89        return "available_with_doc_gaps";
90    }
91    "available"
92}
93
94/// CLI adapter: injects command-catalog Machine-Contract Proof into core's
95/// single Repository Verification State owner.
96pub fn build_repository_verification_state(repo: &Repository) -> RepositoryVerificationState {
97    match heddle_core::verify::build_repository_verification_state_with_machine_contract(
98        repo,
99        &MachineContractInput::from_coverage(machine_contract_coverage()),
100    ) {
101        Ok(state) => state,
102        Err(error) => degraded_repository_verification_state(repo, error.to_string()),
103    }
104}
105
106/// Sub-phase timings for [`build_repository_verification_state_profiled`], in
107/// milliseconds. Used by `adopt`'s `HEDDLE_PROFILE=1` instrumentation.
108#[derive(Debug, Default, Clone, Copy)]
109pub struct VerificationProfile {
110    pub worktree_status_ms: u128,
111    pub health_ms: u128,
112    pub from_health_ms: u128,
113}
114
115/// Profiled adapter around the core verification owner. Classification matches
116/// [`build_repository_verification_state`]; only wall-clock phase splits differ.
117pub fn build_repository_verification_state_profiled(
118    repo: &Repository,
119) -> (RepositoryVerificationState, VerificationProfile) {
120    let total_start = std::time::Instant::now();
121    let worktree_status_start = std::time::Instant::now();
122    let worktree_status = worktree_status_for_verification(repo);
123    let worktree_status_ms = worktree_status_start.elapsed().as_millis();
124
125    let health_start = std::time::Instant::now();
126    let health = heddle_core::status::build_repository_verification_health_with_worktree_status(
127        repo,
128        &worktree_status,
129    );
130    let health_ms = health_start.elapsed().as_millis();
131
132    let from_health_start = std::time::Instant::now();
133    let state = heddle_core::verify::build_repository_verification_state_with_worktree_status_and_machine_contract(
134        repo,
135        health,
136        &worktree_status,
137        &MachineContractInput::from_coverage(machine_contract_coverage()),
138    );
139    let from_health_ms = from_health_start.elapsed().as_millis();
140    let _ = total_start;
141
142    (
143        state,
144        VerificationProfile {
145            worktree_status_ms,
146            health_ms,
147            from_health_ms,
148        },
149    )
150}
151
152/// Core-owned verification state with a reused worktree-status `Result`.
153pub fn build_repository_verification_state_with_worktree_status(
154    repo: &Repository,
155    worktree_status: &repo::Result<Option<WorktreeStatus>>,
156) -> RepositoryVerificationState {
157    let health = heddle_core::status::build_repository_verification_health_with_worktree_status(
158        repo,
159        worktree_status,
160    );
161    heddle_core::verify::build_repository_verification_state_with_worktree_status_and_machine_contract(
162        repo,
163        health,
164        worktree_status,
165        &MachineContractInput::from_coverage(machine_contract_coverage()),
166    )
167}
168
169/// Core-owned health proof (CLI may inspect recovery commands for doctor text).
170pub fn build_verification_health(repo: &Repository) -> RepositoryVerificationHealth {
171    let worktree_status = worktree_status_for_verification(repo);
172    heddle_core::status::build_repository_verification_health_with_worktree_status(
173        repo,
174        &worktree_status,
175    )
176}
177
178/// Health proof reusing a caller's worktree-status `Result`.
179///
180/// Kept as the catalog-injection-free health adapter for hot paths that already
181/// hold a worktree status `Result` (mirrors the state-with-worktree adapter).
182#[allow(dead_code)]
183pub(crate) fn build_verification_health_with_worktree_status(
184    repo: &Repository,
185    worktree_status: &repo::Result<Option<WorktreeStatus>>,
186) -> RepositoryVerificationHealth {
187    heddle_core::status::build_repository_verification_health_with_worktree_status(
188        repo,
189        worktree_status,
190    )
191}
192
193fn worktree_status_for_verification(repo: &Repository) -> repo::Result<Option<WorktreeStatus>> {
194    if repo.capability() == repo::RepositoryCapability::GitOverlay {
195        repo.git_overlay_worktree_status()
196    } else {
197        let Some(state) = repo.current_state()? else {
198            return Ok(Some(WorktreeStatus::default()));
199        };
200        let tree = repo.require_tree(&state.tree)?;
201        repo.compare_worktree_cached(&tree).map(Some)
202    }
203}
204
205fn degraded_repository_verification_state(
206    repo: &Repository,
207    summary: String,
208) -> RepositoryVerificationState {
209    let coverage = machine_contract_coverage();
210    RepositoryVerificationState {
211        verified: false,
212        status: "degraded".to_string(),
213        repository_mode: repo.capability_label().to_string(),
214        heddle_initialized: true,
215        git_branch: repo.git_overlay_current_branch().ok().flatten(),
216        heddle_thread: repo.current_lane().ok().flatten(),
217        worktree_dirty: false,
218        worktree_state: "not_checked".to_string(),
219        import_state: "not_checked".to_string(),
220        mapping_state: "not_checked".to_string(),
221        remote_drift: "not_checked".to_string(),
222        active_operation: None,
223        default_remote: None,
224        clone_verification: "not_checked".to_string(),
225        machine_contract: machine_contract_status(&coverage).to_string(),
226        machine_contract_coverage: coverage,
227        workflow_status: "not_checked".to_string(),
228        workflow_summary: "workflow readiness is checked after repository verification is restored"
229            .to_string(),
230        summary,
231        recommended_action: "heddle doctor".to_string(),
232        recommended_action_template: action_template("heddle doctor"),
233        recovery_commands: vec!["heddle doctor".to_string()],
234        recovery_action_templates: action_templates(&["heddle doctor".to_string()]),
235        checks: Vec::new(),
236    }
237}
238/// Sub-phase timings for [`build_repository_verification_state_profiled`], in
239/// milliseconds. Used by `adopt`'s `HEDDLE_PROFILE=1` instrumentation to attribute
240/// Sub-phase timings for [`build_repository_verification_state_profiled`], in
241/// milliseconds. Used by `adopt`'s `HEDDLE_PROFILE=1` instrumentation to attribute
242/// the post-import verification cost across its three phases.
243/// [`build_repository_verification_state`] that additionally reports per-phase
244/// timings. Classification is identical; the only difference is the elapsed
245/// [`build_repository_verification_state`] that additionally reports per-phase
246/// timings. Classification is identical; the only difference is the elapsed
247/// clocks around each phase.
248/// Verification-state build that reuses an already-computed git-overlay worktree
249/// status instead of re-walking + re-SHA-1ing every tracked file. Used by the
250/// Verification-state build that reuses an already-computed git-overlay worktree
251/// status instead of re-walking + re-SHA-1ing every tracked file. Used by the
252/// `checkpoint` hot path, where the same status would otherwise be recomputed
253/// Verification-state build that reuses an already-computed git-overlay worktree
254/// status instead of re-walking + re-SHA-1ing every tracked file. Used by the
255/// `checkpoint` hot path, where the same status would otherwise be recomputed
256/// across the preflight, the verification preflight, and the output build. The
257/// Verification-state build that reuses an already-computed git-overlay worktree
258/// status instead of re-walking + re-SHA-1ing every tracked file. Used by the
259/// `checkpoint` hot path, where the same status would otherwise be recomputed
260/// across the preflight, the verification preflight, and the output build. The
261/// classification is byte-identical to [`build_repository_verification_state`]
262/// Verification-state build that reuses an already-computed git-overlay worktree
263/// status instead of re-walking + re-SHA-1ing every tracked file. Used by the
264/// `checkpoint` hot path, where the same status would otherwise be recomputed
265/// across the preflight, the verification preflight, and the output build. The
266/// classification is byte-identical to [`build_repository_verification_state`]
267/// because it threads the exact `Result` from `git_overlay_worktree_status()`.
268pub fn unimported_git_history_advice(
269    repo: &Repository,
270    action: &str,
271) -> anyhow::Result<Option<RecoveryAdvice>> {
272    if repo.capability() != repo::RepositoryCapability::GitOverlay {
273        return Ok(None);
274    }
275
276    let Some(hint) = repo.git_import_guidance()? else {
277        return Ok(None);
278    };
279    if !import_guidance_includes_active_branch(&hint) {
280        return Ok(None);
281    }
282    let missing = hint.missing_branches;
283    let primary_command = hint.recommended_command;
284    let branch_summary = crate::cli::render::preview_list(&missing, missing.len());
285    Ok(Some(RecoveryAdvice::safety_refusal(
286        "git_history_needs_import",
287        format!("Refusing to {action}: Git history has not been imported into Heddle"),
288        format!("Run `{primary_command}` before retrying `heddle {action}`."),
289        format!("Git branch(es) waiting for Heddle import: {branch_summary}"),
290        format!(
291            "{action} would write new Heddle state before Heddle has adopted the existing Git history"
292        ),
293        "Git refs, Heddle refs, and worktree files were left unchanged",
294        primary_command.clone(),
295        vec![primary_command],
296    )))
297}
298pub fn raw_git_operation_mutation_advice(
299    repo: &Repository,
300    action: &str,
301) -> anyhow::Result<Option<RecoveryAdvice>> {
302    let Some(operation) = repo.operation_status()? else {
303        return Ok(None);
304    };
305    if !matches!(operation.scope, OperationScope::Git) {
306        return Ok(None);
307    }
308    let primary_command = "heddle verify".to_string();
309    let hint = raw_git_operation_recovery_hint(&operation.kind, &primary_command, action);
310    Ok(Some(RecoveryAdvice::safety_refusal(
311        "raw_git_operation_in_progress",
312        format!(
313            "Refusing to {action}: an externally-started Git {} is in progress",
314            operation.kind
315        ),
316        hint,
317        format!(
318            "Git {} is {}; Heddle cannot safely turn sequencer state into a saved change inside the no-git runtime",
319            operation.kind, operation.state
320        ),
321        format!(
322            "{action} would capture worktree/index contents while Git still has unresolved sequencer metadata"
323        ),
324        "Git refs, Git sequencer files, Heddle refs, and worktree files were left unchanged",
325        primary_command.clone(),
326        vec![primary_command],
327    )))
328}
329fn raw_git_operation_recovery_hint(
330    kind: &OperationKind,
331    primary_command: &str,
332    action: &str,
333) -> String {
334    format!(
335        "Inspect with `{primary_command}`. Heddle did not start this raw Git {kind}, 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 {action}`."
336    )
337}
338pub(crate) fn verification_blocking_mutation_advice(
339    repo: &Repository,
340    action: &str,
341) -> anyhow::Result<Option<RecoveryAdvice>> {
342    verification_blocking_mutation_advice_with_trust(
343        repo,
344        action,
345        build_repository_verification_state(repo),
346    )
347}
348fn verification_blocking_mutation_advice_with_trust(
349    repo: &Repository,
350    action: &str,
351    trust: RepositoryVerificationState,
352) -> anyhow::Result<Option<RecoveryAdvice>> {
353    if trust.status != "needs_reconcile" {
354        return Ok(None);
355    }
356    if uncheckpointed_heddle_state_is_ahead_of_git(repo)? {
357        return Ok(None);
358    }
359    Ok(Some(repository_verification_blocked_advice(
360        "repository_verification_blocked",
361        format!(
362            "Refusing to {action}: repository verification is blocked ({})",
363            trust.status
364        ),
365        format!("retrying `heddle {action}`"),
366        &trust,
367        format!(
368            "repository verification status is {}: {}",
369            trust.status, trust.summary
370        ),
371        format!("{action} would write new Heddle or Git state while Git and Heddle disagree"),
372        "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
373        None,
374    )))
375}
376fn uncheckpointed_heddle_state_is_ahead_of_git(repo: &Repository) -> anyhow::Result<bool> {
377    let Some(tip) = current_branch_tip(repo)? else {
378        return Ok(false);
379    };
380    let Some(mapped) = tip.mapped_state else {
381        return Ok(false);
382    };
383    let Some(current) = repo.current_state()? else {
384        return Ok(false);
385    };
386    if mapped == current.state_id {
387        return Ok(false);
388    }
389    if mapped_change_relation(repo, &mapped, &current.state_id) != "git_behind_heddle" {
390        return Ok(false);
391    }
392    Ok(repo
393        .latest_git_checkpoint_for_state(&current.state_id)?
394        .is_none())
395}
396
397fn current_branch_tip(repo: &Repository) -> anyhow::Result<Option<GitOverlayBranchTip>> {
398    let Some(branch) = repo.git_overlay_current_branch()? else {
399        return Ok(None);
400    };
401    repo.git_overlay_branch_tip(&branch).map_err(Into::into)
402}
403
404fn mapped_change_relation(
405    repo: &Repository,
406    git_mapped: &objects::object::StateId,
407    heddle_current: &objects::object::StateId,
408) -> &'static str {
409    let mut graph = CommitGraphIndex::new(repo);
410    let git_is_ancestor = graph
411        .is_ancestor(git_mapped, heddle_current)
412        .unwrap_or(false);
413    let heddle_is_ancestor = graph
414        .is_ancestor(heddle_current, git_mapped)
415        .unwrap_or(false);
416    match (git_is_ancestor, heddle_is_ancestor) {
417        (true, false) => "git_behind_heddle",
418        (false, true) => "git_ahead_of_heddle",
419        (true, true) => "same",
420        (false, false) => "diverged",
421    }
422}
423#[derive(Debug, Clone, Copy)]
424pub struct GitOverlayMutationPreflight {
425    pub check_detached_head: bool,
426    pub check_unimported_git_history: bool,
427    pub check_raw_git_operation: bool,
428    pub check_verification: bool,
429}
430impl GitOverlayMutationPreflight {
431    pub fn capture_like() -> Self {
432        Self {
433            check_detached_head: false,
434            check_unimported_git_history: true,
435            check_raw_git_operation: true,
436            check_verification: true,
437        }
438    }
439
440    pub fn checkpoint_like() -> Self {
441        Self {
442            check_detached_head: true,
443            check_unimported_git_history: true,
444            check_raw_git_operation: true,
445            check_verification: true,
446        }
447    }
448}
449pub fn plain_git_mutation_preflight_advice(
450    start: &std::path::Path,
451    action: &str,
452) -> anyhow::Result<Option<RecoveryAdvice>> {
453    Ok(build_plain_git_verification_probe(start)?
454        .map(|probe| plain_git_mutation_advice(&probe, action)))
455}
456pub fn plain_git_setup_advice(
457    probe: &PlainGitVerificationProbe,
458    command: &str,
459    requested_target: Option<&str>,
460) -> RecoveryAdvice {
461    let primary = if probe.trust.recommended_action.is_empty() {
462        "heddle init".to_string()
463    } else {
464        probe.trust.recommended_action.clone()
465    };
466    let mut recovery_commands = probe.trust.recovery_commands.clone();
467    if recovery_commands.is_empty() {
468        recovery_commands.push(primary.clone());
469    }
470    let retry = requested_target
471        .map(|target| format!("heddle {command} {target}"))
472        .unwrap_or_else(|| format!("heddle {command}"));
473    let mut advice = RecoveryAdvice::safety_refusal(
474        "plain_git_needs_init",
475        "Heddle is not initialized for this Git repo",
476        format!("Run `{primary}` to create the Heddle sidecar, then retry `{retry}`."),
477        format!(
478            "plain Git repository at '{}' has no .heddle metadata",
479            probe.root.display()
480        ),
481        format!(
482            "`heddle {command}` needs Heddle history before it can inspect Heddle states without guessing"
483        ),
484        "observe-only command; Heddle metadata, Git refs, index, and worktree files were left unchanged",
485        primary,
486        recovery_commands,
487    );
488    advice.extra_json_fields.insert(
489        "repository_capability".to_string(),
490        serde_json::Value::String("plain-git".to_string()),
491    );
492    advice.extra_json_fields.insert(
493        "storage_model".to_string(),
494        serde_json::Value::String("git".to_string()),
495    );
496    advice.extra_json_fields.insert(
497        "requested_command".to_string(),
498        serde_json::Value::String(command.to_string()),
499    );
500    if let Some(target) = requested_target {
501        advice.extra_json_fields.insert(
502            "requested_target".to_string(),
503            serde_json::Value::String(target.to_string()),
504        );
505    }
506    if let Ok(verification) = serde_json::to_value(&probe.trust) {
507        advice
508            .extra_json_fields
509            .insert("verification".to_string(), verification);
510    }
511    advice
512}
513pub fn git_overlay_mutation_preflight_advice(
514    repo: &Repository,
515    action: &str,
516    preflight: GitOverlayMutationPreflight,
517) -> anyhow::Result<Option<RecoveryAdvice>> {
518    git_overlay_mutation_preflight_advice_inner(repo, action, preflight, None)
519}
520/// `checkpoint` hot-path variant of [`git_overlay_mutation_preflight_advice`]
521/// that reuses an already-computed git-overlay worktree status for the
522/// `check_verification` branch instead of re-walking the worktree. All other
523/// branches and the resulting advice are identical.
524pub fn git_overlay_mutation_preflight_advice_with_worktree_status(
525    repo: &Repository,
526    action: &str,
527    preflight: GitOverlayMutationPreflight,
528    worktree_status: &repo::Result<Option<WorktreeStatus>>,
529) -> anyhow::Result<Option<RecoveryAdvice>> {
530    git_overlay_mutation_preflight_advice_inner(repo, action, preflight, Some(worktree_status))
531}
532fn git_overlay_mutation_preflight_advice_inner(
533    repo: &Repository,
534    action: &str,
535    preflight: GitOverlayMutationPreflight,
536    worktree_status: Option<&repo::Result<Option<WorktreeStatus>>>,
537) -> anyhow::Result<Option<RecoveryAdvice>> {
538    if repo.capability() != repo::RepositoryCapability::GitOverlay {
539        return Ok(None);
540    }
541    if preflight.check_detached_head && repo.git_overlay_head_is_detached()? {
542        return Ok(Some(detached_git_head_mutation_advice(repo, action)));
543    }
544    if preflight.check_unimported_git_history
545        && let Some(advice) = unimported_git_history_advice(repo, action)?
546    {
547        return Ok(Some(advice));
548    }
549    if preflight.check_raw_git_operation
550        && let Some(advice) = raw_git_operation_mutation_advice(repo, action)?
551    {
552        return Ok(Some(advice));
553    }
554    if preflight.check_verification {
555        let advice = match worktree_status {
556            Some(status) => verification_blocking_mutation_advice_with_trust(
557                repo,
558                action,
559                build_repository_verification_state_with_worktree_status(repo, status),
560            )?,
561            None => verification_blocking_mutation_advice(repo, action)?,
562        };
563        if let Some(advice) = advice {
564            return Ok(Some(advice));
565        }
566    }
567    Ok(None)
568}
569#[allow(clippy::too_many_arguments)]
570pub fn repository_verification_blocked_advice(
571    kind: &'static str,
572    error: impl Into<String>,
573    retry_context: impl Into<String>,
574    trust: &RepositoryVerificationState,
575    unsafe_condition: impl Into<String>,
576    would_change: impl Into<String>,
577    preserved: impl Into<String>,
578    primary_override: Option<String>,
579) -> RecoveryAdvice {
580    let primary_command =
581        primary_override.unwrap_or_else(|| repository_verification_primary_command(trust));
582    let recovery_commands = repository_verification_recovery_commands(trust, &primary_command);
583    RecoveryAdvice::safety_refusal(
584        kind,
585        error,
586        format!("Run `{}` before {}.", primary_command, retry_context.into()),
587        unsafe_condition,
588        would_change,
589        preserved,
590        primary_command,
591        recovery_commands,
592    )
593}
594pub fn repository_verification_primary_command(trust: &RepositoryVerificationState) -> String {
595    if trust.recommended_action.trim().is_empty() {
596        "heddle verify".to_string()
597    } else {
598        trust.recommended_action.clone()
599    }
600}
601pub fn repository_verification_blockers(trust: &RepositoryVerificationState) -> Vec<String> {
602    trust
603        .checks
604        .iter()
605        .filter(|check| !check.clean)
606        .map(|check| format!("{}: {}", check.name, check.summary))
607        .collect()
608}
609pub(crate) fn repository_verification_recovery_commands(
610    trust: &RepositoryVerificationState,
611    primary_command: &str,
612) -> Vec<String> {
613    if primary_command != trust.recommended_action && !trust.recommended_action.trim().is_empty() {
614        vec![primary_command.to_string(), "heddle verify".to_string()]
615    } else if trust.recovery_commands.is_empty() {
616        vec![primary_command.to_string()]
617    } else {
618        trust.recovery_commands.clone()
619    }
620}
621pub(crate) fn plain_git_mutation_advice(
622    probe: &PlainGitVerificationProbe,
623    action: &str,
624) -> RecoveryAdvice {
625    let primary_command = if probe.trust.recommended_action.trim().is_empty() {
626        "heddle init".to_string()
627    } else {
628        probe.trust.recommended_action.clone()
629    };
630    let recovery_commands = if probe.trust.recovery_commands.is_empty() {
631        vec![primary_command.clone()]
632    } else {
633        probe.trust.recovery_commands.clone()
634    };
635    let dirty_detail = if probe.changes.is_clean() {
636        "Git worktree is clean".to_string()
637    } else {
638        let mut paths = Vec::new();
639        paths.extend(
640            probe
641                .changes
642                .modified
643                .iter()
644                .map(|path| path.display().to_string()),
645        );
646        paths.extend(
647            probe
648                .changes
649                .added
650                .iter()
651                .map(|path| path.display().to_string()),
652        );
653        paths.extend(
654            probe
655                .changes
656                .deleted
657                .iter()
658                .map(|path| path.display().to_string()),
659        );
660        format!(
661            "Git worktree has {} dirty path(s): {}",
662            paths.len(),
663            crate::cli::render::preview_list(&paths, paths.len())
664        )
665    };
666    RecoveryAdvice::safety_refusal(
667        "git_repo_needs_init",
668        format!("Refusing to {action}: Heddle is not initialized for this Git repository"),
669        format!("Run `{primary_command}` before retrying `heddle {action}`."),
670        format!(
671            "plain Git repository at {} has no .heddle metadata; {}",
672            probe.root.display(),
673            dirty_detail
674        ),
675        format!("{action} needs Heddle metadata before it can safely write Heddle state"),
676        "Git refs, Heddle metadata, and worktree files were left unchanged",
677        primary_command,
678        recovery_commands,
679    )
680}
681pub(crate) fn detached_git_head_mutation_advice(repo: &Repository, action: &str) -> RecoveryAdvice {
682    let primary_command = detached_head_primary_recovery(repo);
683    let needs_selection = primary_command == "heddle thread list";
684    let hint = if needs_selection {
685        format!(
686            "Inspect managed threads with `{primary_command}`, then attach this checkout with `heddle thread switch <thread>` before retrying `heddle {action}`. Heddle could not safely infer which thread owns the detached commit."
687        )
688    } else {
689        format!("Run `{primary_command}` before retrying `heddle {action}`.")
690    };
691    let recovery_commands = if needs_selection {
692        vec![
693            primary_command.clone(),
694            "heddle thread switch <thread>".to_string(),
695        ]
696    } else {
697        vec![primary_command.clone()]
698    };
699    RecoveryAdvice::safety_refusal(
700        "git_head_detached",
701        format!("Refusing to {action}: Git HEAD is detached"),
702        hint,
703        "Git HEAD points directly to a commit instead of an attached branch",
704        format!(
705            "{action} would need to write a Git checkpoint through a branch and could reattach or advance the wrong ref"
706        ),
707        "Git refs, Heddle refs, Git checkpoints, and worktree files were left unchanged",
708        primary_command,
709        recovery_commands,
710    )
711}
712fn detached_head_primary_recovery(repo: &Repository) -> String {
713    match repo.refs().read_head() {
714        Ok(Head::Attached { thread })
715            if !thread.trim().is_empty()
716                && repo.refs().get_thread(&thread).ok().flatten().is_some() =>
717        {
718            // `switch` takes the thread as a positional; a leading-dash id needs
719            // the `--` separator so clap binds it as a value, not a flag.
720            // (heddle#464 close-the-class.)
721            return if thread.starts_with('-') {
722                heddle_action(["thread", "switch", "--", thread.as_str()])
723            } else {
724                heddle_action(["thread", "switch", thread.as_str()])
725            };
726        }
727        _ => {}
728    }
729    if let Ok(Some(detached_commit)) = repo.git_overlay_detached_head_commit()
730        && let Ok(branch_tips) = repo.git_overlay_branch_tips()
731        && let Some(tip) = branch_tips
732            .iter()
733            .filter(|tip| tip.history_imported)
734            .find(|tip| {
735                tip.git_commit == detached_commit
736                    && repo
737                        .refs()
738                        .get_thread(&ThreadName::new(&tip.branch))
739                        .ok()
740                        .flatten()
741                        .is_some()
742            })
743    {
744        return heddle_action(["thread", "switch", tip.branch.as_str()]);
745    }
746    "heddle thread list".to_string()
747}
748pub fn build_plain_git_verification_probe(
749    start: &Path,
750) -> anyhow::Result<Option<PlainGitVerificationProbe>> {
751    Ok(
752        heddle_core::verify::build_plain_git_verification_probe_with_machine_contract(
753            start,
754            &MachineContractInput::from_coverage(machine_contract_coverage()),
755        )?,
756    )
757}
758pub fn action_template(action: &str) -> Option<ActionTemplate> {
759    recommended_action_template(action)
760}
761pub fn action_templates(commands: &[String]) -> Vec<ActionTemplate> {
762    commands
763        .iter()
764        .filter_map(|command| action_template(command))
765        .collect()
766}
767pub fn machine_contract_coverage() -> MachineContractCoverage {
768    static COVERAGE: OnceLock<MachineContractCoverage> = OnceLock::new();
769    COVERAGE
770        .get_or_init(build_machine_contract_coverage)
771        .clone()
772}
773
774fn build_machine_contract_coverage() -> MachineContractCoverage {
775    const EXAMPLE_LIMIT: usize = 8;
776    let catalog = build_command_catalog();
777    let commands = catalog.commands;
778    let mut json_commands_total: usize = 0;
779    let mut json_commands_with_schema: usize = 0;
780    let mut json_commands_with_accepted_opaque_schema: usize = 0;
781    let mut verified_scope_json_commands_total: usize = 0;
782    let mut verified_scope_json_commands_with_schema: usize = 0;
783    let mut verified_scope_json_commands_with_accepted_opaque_schema: usize = 0;
784    let mut catalog_mutating_commands_total: usize = 0;
785    let mut mutating_commands_total: usize = 0;
786    let mut mutating_commands_with_schema: usize = 0;
787    let mut mutating_commands_with_accepted_opaque_schema: usize = 0;
788    let mut verified_scope_mutating_commands_total: usize = 0;
789    let mut verified_scope_mutating_commands_with_schema: usize = 0;
790    let mut verified_scope_mutating_commands_with_accepted_opaque_schema: usize = 0;
791    let mut schema_verbs = BTreeSet::new();
792    let mut documented_schema_verbs = BTreeSet::new();
793    let opaque_schema_verb_set: BTreeSet<&str> = opaque_schema_verbs().iter().copied().collect();
794    let mut supports_op_id_total: usize = 0;
795    let mut jsonl_commands_total: usize = 0;
796    let mut missing_schema_examples = Vec::new();
797    let mut missing_mutating_schema_examples = Vec::new();
798    let mut verified_scope_missing_schema_examples = Vec::new();
799    let mut verified_scope_accepted_opaque_schema_examples = Vec::new();
800    let mut advanced_scope_accepted_opaque_schema_examples = Vec::new();
801    let mut accepted_opaque_schema_examples = Vec::new();
802
803    for command in &commands {
804        let is_verified_scope = machine_contract_verified_scope(command);
805        let has_concrete_schema = command
806            .schema_verbs
807            .iter()
808            .any(|verb| !opaque_schema_verb_set.contains(verb.as_str()));
809        let has_accepted_opaque_schema = command
810            .schema_verbs
811            .iter()
812            .any(|verb| opaque_schema_verb_set.contains(verb.as_str()));
813        if command.mutates {
814            catalog_mutating_commands_total += 1;
815        }
816        if command.supports_json {
817            json_commands_total += 1;
818            if has_concrete_schema {
819                json_commands_with_schema += 1;
820            } else if has_accepted_opaque_schema {
821                json_commands_with_accepted_opaque_schema += 1;
822                if accepted_opaque_schema_examples.len() < EXAMPLE_LIMIT {
823                    accepted_opaque_schema_examples.push(command.display.clone());
824                }
825                if !is_verified_scope
826                    && advanced_scope_accepted_opaque_schema_examples.len() < EXAMPLE_LIMIT
827                {
828                    advanced_scope_accepted_opaque_schema_examples.push(command.display.clone());
829                }
830            } else if missing_schema_examples.len() < EXAMPLE_LIMIT {
831                missing_schema_examples.push(command.display.clone());
832            }
833            if is_verified_scope {
834                verified_scope_json_commands_total += 1;
835                if has_concrete_schema {
836                    verified_scope_json_commands_with_schema += 1;
837                } else if has_accepted_opaque_schema {
838                    verified_scope_json_commands_with_accepted_opaque_schema += 1;
839                    if verified_scope_accepted_opaque_schema_examples.len() < EXAMPLE_LIMIT {
840                        verified_scope_accepted_opaque_schema_examples
841                            .push(command.display.clone());
842                    }
843                } else if verified_scope_missing_schema_examples.len() < EXAMPLE_LIMIT {
844                    verified_scope_missing_schema_examples.push(command.display.clone());
845                }
846            }
847        }
848        if command.mutates && command.supports_json {
849            mutating_commands_total += 1;
850            if has_concrete_schema {
851                mutating_commands_with_schema += 1;
852            } else if has_accepted_opaque_schema {
853                mutating_commands_with_accepted_opaque_schema += 1;
854            } else if missing_mutating_schema_examples.len() < EXAMPLE_LIMIT {
855                missing_mutating_schema_examples.push(command.display.clone());
856            }
857            if is_verified_scope {
858                verified_scope_mutating_commands_total += 1;
859                if has_concrete_schema {
860                    verified_scope_mutating_commands_with_schema += 1;
861                } else if has_accepted_opaque_schema {
862                    verified_scope_mutating_commands_with_accepted_opaque_schema += 1;
863                }
864            }
865        }
866        if command.supports_op_id {
867            supports_op_id_total += 1;
868        }
869        if command.json_kind == "jsonl" || command.json_kind == "json_or_jsonl" {
870            jsonl_commands_total += 1;
871        }
872        schema_verbs.extend(command.schema_verbs.iter().map(String::as_str));
873        documented_schema_verbs.extend(command.documented_schema_verbs.iter().map(String::as_str));
874    }
875    schema_verbs.insert("error");
876    documented_schema_verbs.insert("error");
877
878    let json_commands_without_schema = json_commands_total
879        .saturating_sub(json_commands_with_schema)
880        .saturating_sub(json_commands_with_accepted_opaque_schema);
881    let mutating_commands_without_schema = mutating_commands_total
882        .saturating_sub(mutating_commands_with_schema)
883        .saturating_sub(mutating_commands_with_accepted_opaque_schema);
884    let verified_scope_json_commands_without_schema = verified_scope_json_commands_total
885        .saturating_sub(verified_scope_json_commands_with_schema)
886        .saturating_sub(verified_scope_json_commands_with_accepted_opaque_schema);
887    let verified_scope_mutating_commands_without_schema = verified_scope_mutating_commands_total
888        .saturating_sub(verified_scope_mutating_commands_with_schema)
889        .saturating_sub(verified_scope_mutating_commands_with_accepted_opaque_schema);
890    let advanced_scope_json_commands_total =
891        json_commands_total.saturating_sub(verified_scope_json_commands_total);
892    let advanced_scope_json_commands_with_accepted_opaque_schema =
893        json_commands_with_accepted_opaque_schema
894            .saturating_sub(verified_scope_json_commands_with_accepted_opaque_schema);
895    let advanced_scope_mutating_commands_total =
896        mutating_commands_total.saturating_sub(verified_scope_mutating_commands_total);
897    let advanced_scope_mutating_commands_with_accepted_opaque_schema =
898        mutating_commands_with_accepted_opaque_schema
899            .saturating_sub(verified_scope_mutating_commands_with_accepted_opaque_schema);
900    let undocumented_schema_examples: Vec<String> = schema_verbs
901        .difference(&documented_schema_verbs)
902        .take(EXAMPLE_LIMIT)
903        .map(|verb| (*verb).to_string())
904        .collect();
905    let accepted_opaque_schema_verbs: BTreeSet<&str> = schema_verbs
906        .intersection(&opaque_schema_verb_set)
907        .copied()
908        .collect();
909    let schema_verbs_total = schema_verbs.len();
910    let documented_schema_verbs_total = documented_schema_verbs.len();
911    let undocumented_schema_verbs_total = schema_verbs.difference(&documented_schema_verbs).count();
912    let opaque_schema_verbs_total = accepted_opaque_schema_verbs.len();
913    let accepted_opaque_schema_verbs_total = accepted_opaque_schema_verbs.len();
914    let unaccepted_opaque_schema_verbs_total = 0;
915    let unaccepted_opaque_schema_examples = Vec::new();
916    let status = if verified_scope_json_commands_without_schema == 0
917        && verified_scope_mutating_commands_without_schema == 0
918        && verified_scope_json_commands_with_accepted_opaque_schema == 0
919        && verified_scope_mutating_commands_with_accepted_opaque_schema == 0
920        && undocumented_schema_verbs_total == 0
921    {
922        "available".to_string()
923    } else if verified_scope_json_commands_without_schema == 0
924        && verified_scope_mutating_commands_without_schema == 0
925        && undocumented_schema_verbs_total == 0
926        && unaccepted_opaque_schema_verbs_total == 0
927    {
928        "available_with_opaque_schemas".to_string()
929    } else if verified_scope_json_commands_without_schema == 0
930        && verified_scope_mutating_commands_without_schema == 0
931        && unaccepted_opaque_schema_verbs_total == 0
932    {
933        "available_with_doc_gaps".to_string()
934    } else {
935        "available_with_schema_gaps".to_string()
936    };
937    let summary = if status == "available" {
938        if accepted_opaque_schema_verbs_total == 0 {
939            format!(
940                "{} command(s), {} JSON command(s), verified everyday/agent machine surface has concrete schemas",
941                commands.len(),
942                json_commands_total
943            )
944        } else {
945            format!(
946                "{} command(s), {} JSON command(s), {} mutating command(s), {} mutating JSON command(s); verified everyday/agent machine surface has {} concrete schema-backed JSON command(s); advanced/internal/admin surfaces carry {} accepted opaque schema(s) outside clean verification",
947                commands.len(),
948                json_commands_total,
949                catalog_mutating_commands_total,
950                mutating_commands_total,
951                verified_scope_json_commands_with_schema,
952                accepted_opaque_schema_verbs_total
953            )
954        }
955    } else if status == "available_with_opaque_schemas" {
956        format!(
957            "{} command(s), {} JSON command(s), verified everyday/agent machine surface has {} concrete schema-backed and {} accepted opaque schema-backed command(s)",
958            commands.len(),
959            json_commands_total,
960            verified_scope_json_commands_with_schema,
961            verified_scope_json_commands_with_accepted_opaque_schema
962        )
963    } else if status == "available_with_doc_gaps" {
964        format!(
965            "{} command(s), {} JSON command(s), {} concrete schema-backed and {} accepted opaque; {} runtime schema verb(s) need documented samples",
966            commands.len(),
967            json_commands_total,
968            json_commands_with_schema,
969            json_commands_with_accepted_opaque_schema,
970            undocumented_schema_verbs_total
971        )
972    } else {
973        format!(
974            "{} command(s), {} JSON command(s), {} concrete schema-backed, {} accepted opaque, {} missing schemas ({} mutating)",
975            commands.len(),
976            json_commands_total,
977            json_commands_with_schema,
978            json_commands_with_accepted_opaque_schema,
979            json_commands_without_schema,
980            mutating_commands_without_schema
981        )
982    };
983
984    MachineContractCoverage {
985        status,
986        verified_scope: "everyday_and_agent".to_string(),
987        advanced_scope: "advanced_internal_admin".to_string(),
988        summary,
989        catalog_commands_total: commands.len(),
990        catalog_mutating_commands_total,
991        json_commands_total,
992        json_mutating_commands_total: mutating_commands_total,
993        json_commands_with_schema,
994        json_commands_with_accepted_opaque_schema,
995        json_commands_without_schema,
996        verified_scope_json_commands_total,
997        verified_scope_json_commands_with_schema,
998        verified_scope_json_commands_with_accepted_opaque_schema,
999        verified_scope_json_commands_without_schema,
1000        advanced_scope_json_commands_total,
1001        advanced_scope_json_commands_with_accepted_opaque_schema,
1002        mutating_commands_total,
1003        mutating_commands_with_schema,
1004        mutating_commands_with_accepted_opaque_schema,
1005        mutating_commands_without_schema,
1006        verified_scope_mutating_commands_total,
1007        verified_scope_mutating_commands_with_schema,
1008        verified_scope_mutating_commands_with_accepted_opaque_schema,
1009        verified_scope_mutating_commands_without_schema,
1010        advanced_scope_mutating_commands_total,
1011        advanced_scope_mutating_commands_with_accepted_opaque_schema,
1012        schema_verbs_total,
1013        documented_schema_verbs_total,
1014        undocumented_schema_verbs_total,
1015        opaque_schema_verbs_total,
1016        accepted_opaque_schema_verbs_total,
1017        unaccepted_opaque_schema_verbs_total,
1018        supports_op_id_total,
1019        jsonl_commands_total,
1020        missing_schema_examples,
1021        missing_mutating_schema_examples,
1022        verified_scope_missing_schema_examples,
1023        verified_scope_accepted_opaque_schema_examples,
1024        advanced_scope_accepted_opaque_schema_examples,
1025        accepted_opaque_schema_examples,
1026        unaccepted_opaque_schema_examples,
1027        undocumented_schema_examples,
1028    }
1029}
1030fn machine_contract_verified_scope(command: &super::command_catalog::CommandCatalogEntry) -> bool {
1031    command.help_visibility == "everyday"
1032        || matches!(
1033            command.path.first().map(String::as_str),
1034            Some("agent" | "schemas")
1035        )
1036}
1037#[derive(Debug, Clone, PartialEq, Eq)]
1038pub struct RemoteDriftDecision {
1039    pub status: &'static str,
1040    pub verified_as_clean: bool,
1041    pub primary_action: Option<String>,
1042    pub recovery_commands: Vec<String>,
1043    pub requires_clean_worktree: bool,
1044}
1045pub fn remote_drift_decision(
1046    repo: &Repository,
1047    remote: &GitRemoteTrackingStatus,
1048) -> RemoteDriftDecision {
1049    let status = remote_tracking_status(remote);
1050    match status {
1051        "clean" => RemoteDriftDecision {
1052            status,
1053            verified_as_clean: true,
1054            primary_action: None,
1055            recovery_commands: Vec::new(),
1056            requires_clean_worktree: false,
1057        },
1058        "remote_untracked" => RemoteDriftDecision {
1059            status,
1060            verified_as_clean: true,
1061            primary_action: remote_tracking_next_action_for(remote, repo.source_authority()),
1062            recovery_commands: Vec::new(),
1063            requires_clean_worktree: false,
1064        },
1065        "remote_ahead" => RemoteDriftDecision {
1066            status,
1067            verified_as_clean: true,
1068            primary_action: remote_tracking_next_action_for(remote, repo.source_authority()),
1069            recovery_commands: Vec::new(),
1070            requires_clean_worktree: false,
1071        },
1072        "remote_contains_undone_checkpoint" => RemoteDriftDecision {
1073            status,
1074            verified_as_clean: false,
1075            primary_action: remote_tracking_next_action_for(remote, repo.source_authority()),
1076            recovery_commands: vec![
1077                remote_tracking_next_action_for(remote, repo.source_authority())
1078                    .expect("undone checkpoint has a source recovery action"),
1079                core_heddle_action(["undo", "--redo"]),
1080            ],
1081            requires_clean_worktree: true,
1082        },
1083        "remote_behind" => RemoteDriftDecision {
1084            status,
1085            verified_as_clean: false,
1086            primary_action: remote_tracking_next_action_for(remote, repo.source_authority()),
1087            recovery_commands: remote_tracking_next_action_for(remote, repo.source_authority())
1088                .into_iter()
1089                .collect(),
1090            requires_clean_worktree: true,
1091        },
1092        "remote_diverged" => {
1093            let upstream = remote.upstream.trim();
1094            if upstream.is_empty() {
1095                return RemoteDriftDecision {
1096                    status,
1097                    verified_as_clean: false,
1098                    primary_action: Some("heddle remote list".to_string()),
1099                    recovery_commands: vec![
1100                        "heddle remote list".to_string(),
1101                        "heddle pull <remote> --thread <thread>".to_string(),
1102                    ],
1103                    requires_clean_worktree: false,
1104                };
1105            }
1106            let import = canonical_git_import_ref_command(upstream);
1107            let reconcile = canonical_git_repair_ref_preview_command(None, upstream);
1108            let imported = upstream_thread_matches_current_git_tip(repo, upstream);
1109            RemoteDriftDecision {
1110                status,
1111                verified_as_clean: false,
1112                primary_action: Some(if imported {
1113                    reconcile.clone()
1114                } else {
1115                    import.clone()
1116                }),
1117                recovery_commands: if imported {
1118                    vec![reconcile]
1119                } else {
1120                    vec![import, reconcile]
1121                },
1122                requires_clean_worktree: false,
1123            }
1124        }
1125        _ => RemoteDriftDecision {
1126            status,
1127            verified_as_clean: false,
1128            primary_action: Some("heddle verify".to_string()),
1129            recovery_commands: vec!["heddle verify".to_string()],
1130            requires_clean_worktree: false,
1131        },
1132    }
1133}
1134fn upstream_thread_matches_current_git_tip(repo: &Repository, upstream: &str) -> bool {
1135    let Some(thread_tip) = repo
1136        .refs()
1137        .get_thread(&ThreadName::new(upstream))
1138        .ok()
1139        .flatten()
1140    else {
1141        return false;
1142    };
1143    repo.git_overlay_mapped_state_for_branch(upstream)
1144        .or(Ok(None))
1145        .and_then(|mapped| {
1146            if mapped.is_some() {
1147                Ok(mapped)
1148            } else {
1149                repo.git_overlay_mapped_state_for_remote_tracking_ref(upstream)
1150            }
1151        })
1152        .ok()
1153        .flatten()
1154        .is_some_and(|mapped_tip| mapped_tip == thread_tip)
1155}
1156pub fn remote_drift_primary_action(repo: &Repository) -> Option<String> {
1157    repo.git_remote_tracking_status()
1158        .ok()
1159        .flatten()
1160        .and_then(|remote| remote_drift_decision(repo, &remote).primary_action)
1161}
1162pub fn remote_tracking_with_verification_action(
1163    mut remote: GitRemoteTrackingStatus,
1164    trust: &RepositoryVerificationState,
1165) -> GitRemoteTrackingStatus {
1166    let remote_status = remote_tracking_status(&remote);
1167    if trust.status == remote_status && !trust.recommended_action.trim().is_empty() {
1168        remote.next_action = trust.recommended_action.clone();
1169    }
1170    remote
1171}
1172
1173#[cfg(test)]
1174mod tests;