1use 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
94pub 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#[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
115pub 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
152pub 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
169pub 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#[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}
238pub 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, ¤t.state_id) != "git_behind_heddle" {
390 return Ok(false);
391 }
392 Ok(repo
393 .latest_git_checkpoint_for_state(¤t.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}
520pub 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 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;