1use std::{
5 collections::{BTreeMap, BTreeSet},
6 path::{Path, PathBuf},
7 time::Instant,
8};
9
10use ::objects::{HeddleError, error::Result, worktree::WorktreeStatus};
11use repo::{
12 Repository, Thread, ThreadManager, describe_thread_advice, discover_heddle_root,
13 refresh_thread_freshness,
14};
15use schemars::JsonSchema;
16use serde::{Serialize, Serializer};
17use sley::{Repository as SleyRepository, ShortStatusOptions, StatusUntrackedMode, StreamControl};
18
19use crate::{
20 ExecutionContext, HeddleReport, MachineOutputKind, OnboardingFacts, OutputDiscriminator,
21 ReportContract, plan_repository_onboarding, schema_for_report,
22 source_authority::{SourceAction, SourceAuthorityActions},
23 status::{
24 RepositoryVerificationHealth, build_repository_verification_health_with_worktree_status,
25 default_remote_name, git_default_remote_name_from_repo,
26 next_action::remote_tracking_status,
27 },
28};
29
30#[derive(Clone)]
31pub struct VerifyOptions {
32 pub start_path: Option<PathBuf>,
33 pub machine_contract_input: MachineContractInput,
34 pub action_audience: ActionAudience,
35 pub provenance: bool,
36}
37
38impl VerifyOptions {
39 pub fn new() -> Self {
40 Self {
41 start_path: None,
42 machine_contract_input: MachineContractInput::default(),
43 action_audience: ActionAudience::Human,
44 provenance: false,
45 }
46 }
47
48 pub fn with_start_path(mut self, start_path: impl Into<PathBuf>) -> Self {
49 self.start_path = Some(start_path.into());
50 self
51 }
52
53 pub fn with_machine_contract_input(mut self, input: MachineContractInput) -> Self {
54 self.machine_contract_input = input;
55 self
56 }
57
58 pub fn with_action_audience(mut self, audience: ActionAudience) -> Self {
59 self.action_audience = audience;
60 self
61 }
62
63 pub fn with_provenance(mut self, provenance: bool) -> Self {
64 self.provenance = provenance;
65 self
66 }
67}
68
69impl Default for VerifyOptions {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75#[derive(Debug, Clone, Copy, Serialize, JsonSchema, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub enum ActionAudience {
78 Human,
79 Agent,
80 Script,
81}
82
83#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
84pub struct MachineContractInput {
85 pub coverage: MachineContractCoverage,
86}
87
88impl MachineContractInput {
89 pub fn from_coverage(coverage: MachineContractCoverage) -> Self {
90 Self { coverage }
91 }
92}
93
94impl Default for MachineContractInput {
95 fn default() -> Self {
96 Self {
97 coverage: MachineContractCoverage::not_checked(),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
103pub struct VerifyReport {
104 pub output_kind: &'static str,
105 pub clean: bool,
106 pub repository_label: String,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub repository_context: Option<RepositoryContextInfo>,
109 #[serde(rename = "verification")]
110 pub trust: RepositoryVerificationState,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub provenance: Option<crate::ProvenanceReport>,
113 #[serde(skip)]
114 #[schemars(skip)]
115 pub profile: VerifyProfile,
116}
117
118impl VerifyReport {
119 pub const CONTRACT: ReportContract = ReportContract {
120 schema_name: "verify",
121 machine_output_kind: MachineOutputKind::Json,
122 output_discriminator: Some(OutputDiscriminator {
123 field: "output_kind",
124 value: "verify",
125 }),
126 schema: schema_for_report::<VerifyReport>,
127 };
128}
129
130impl HeddleReport for VerifyReport {
131 const CONTRACT: ReportContract = VerifyReport::CONTRACT;
132}
133
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
135pub struct VerifyProfile {
136 pub plain_git_probe_ms: u128,
137 pub repo_open_ms: u128,
138 pub verification_ms: u128,
139}
140
141#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
142pub struct RepositoryContextInfo {
143 pub kind: String,
144 pub parent_repository: Option<String>,
145 pub target_thread: Option<String>,
146 pub parent_thread: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
150pub struct RepositoryPresentation {
151 pub label: String,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub context: Option<RepositoryContextInfo>,
154}
155
156#[derive(Debug, Serialize, JsonSchema)]
157pub struct PlainGitVerifyProbe {
158 #[schemars(with = "String")]
159 pub root: PathBuf,
160 pub git_branch: Option<String>,
161 #[serde(skip)]
162 #[schemars(skip)]
163 pub changes: WorktreeStatus,
164 #[serde(rename = "verification")]
166 pub trust: RepositoryVerificationState,
167}
168
169#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
170pub struct ActionTemplate {
171 pub action: String,
172 pub argv_template: Vec<String>,
173 pub required_inputs: Vec<String>,
174 pub agent_may_fill: bool,
181}
182
183#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
184pub struct RepositoryVerificationState {
185 #[serde(rename = "verified")]
186 pub verified: bool,
187 pub status: String,
188 pub repository_mode: String,
189 pub heddle_initialized: bool,
190 pub git_branch: Option<String>,
191 pub heddle_thread: Option<String>,
192 pub worktree_dirty: bool,
193 pub worktree_state: String,
194 pub import_state: String,
195 pub mapping_state: String,
196 pub remote_drift: String,
197 pub active_operation: Option<String>,
198 pub default_remote: Option<String>,
199 pub clone_verification: String,
200 pub machine_contract: String,
201 pub machine_contract_coverage: MachineContractCoverage,
202 pub workflow_status: String,
203 pub workflow_summary: String,
204 pub summary: String,
205 #[serde(serialize_with = "serialize_empty_action_as_null")]
206 #[schemars(with = "Option<String>")]
207 pub recommended_action: String,
208 pub recommended_action_template: Option<ActionTemplate>,
209 pub recovery_commands: Vec<String>,
210 pub recovery_action_templates: Vec<ActionTemplate>,
211 pub checks: Vec<VerificationCheck>,
212}
213
214pub fn serialize_empty_action_as_null<S>(
215 action: &String,
216 serializer: S,
217) -> std::result::Result<S::Ok, S::Error>
218where
219 S: Serializer,
220{
221 if action.is_empty() {
222 serializer.serialize_none()
223 } else {
224 serializer.serialize_some(action)
225 }
226}
227
228#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
229pub struct MachineContractCoverage {
230 pub status: String,
231 #[serde(rename = "verified_scope")]
232 pub verified_scope: String,
233 pub advanced_scope: String,
234 pub summary: String,
235 pub catalog_commands_total: usize,
236 pub catalog_mutating_commands_total: usize,
237 pub json_commands_total: usize,
238 pub json_mutating_commands_total: usize,
239 pub json_commands_with_schema: usize,
240 pub json_commands_with_accepted_opaque_schema: usize,
241 pub json_commands_without_schema: usize,
242 #[serde(rename = "verified_scope_json_commands_total")]
243 pub verified_scope_json_commands_total: usize,
244 #[serde(rename = "verified_scope_json_commands_with_schema")]
245 pub verified_scope_json_commands_with_schema: usize,
246 #[serde(rename = "verified_scope_json_commands_with_accepted_opaque_schema")]
247 pub verified_scope_json_commands_with_accepted_opaque_schema: usize,
248 #[serde(rename = "verified_scope_json_commands_without_schema")]
249 pub verified_scope_json_commands_without_schema: usize,
250 pub advanced_scope_json_commands_total: usize,
251 pub advanced_scope_json_commands_with_accepted_opaque_schema: usize,
252 pub mutating_commands_total: usize,
253 pub mutating_commands_with_schema: usize,
254 pub mutating_commands_with_accepted_opaque_schema: usize,
255 pub mutating_commands_without_schema: usize,
256 #[serde(rename = "verified_scope_mutating_commands_total")]
257 pub verified_scope_mutating_commands_total: usize,
258 #[serde(rename = "verified_scope_mutating_commands_with_schema")]
259 pub verified_scope_mutating_commands_with_schema: usize,
260 #[serde(rename = "verified_scope_mutating_commands_with_accepted_opaque_schema")]
261 pub verified_scope_mutating_commands_with_accepted_opaque_schema: usize,
262 #[serde(rename = "verified_scope_mutating_commands_without_schema")]
263 pub verified_scope_mutating_commands_without_schema: usize,
264 pub advanced_scope_mutating_commands_total: usize,
265 pub advanced_scope_mutating_commands_with_accepted_opaque_schema: usize,
266 pub schema_verbs_total: usize,
267 pub documented_schema_verbs_total: usize,
268 pub undocumented_schema_verbs_total: usize,
269 pub opaque_schema_verbs_total: usize,
270 pub accepted_opaque_schema_verbs_total: usize,
271 pub unaccepted_opaque_schema_verbs_total: usize,
272 pub supports_op_id_total: usize,
273 pub jsonl_commands_total: usize,
274 pub missing_schema_examples: Vec<String>,
275 pub missing_mutating_schema_examples: Vec<String>,
276 pub verified_scope_missing_schema_examples: Vec<String>,
277 pub verified_scope_accepted_opaque_schema_examples: Vec<String>,
278 pub advanced_scope_accepted_opaque_schema_examples: Vec<String>,
279 pub accepted_opaque_schema_examples: Vec<String>,
280 pub unaccepted_opaque_schema_examples: Vec<String>,
281 pub undocumented_schema_examples: Vec<String>,
282}
283
284impl MachineContractCoverage {
285 pub fn not_checked() -> Self {
286 Self {
287 status: "not_checked".to_string(),
288 verified_scope: "not_checked".to_string(),
289 advanced_scope: "not_checked".to_string(),
290 summary: "Machine-contract proof was not supplied by this embedder".to_string(),
291 catalog_commands_total: 0,
292 catalog_mutating_commands_total: 0,
293 json_commands_total: 0,
294 json_mutating_commands_total: 0,
295 json_commands_with_schema: 0,
296 json_commands_with_accepted_opaque_schema: 0,
297 json_commands_without_schema: 0,
298 verified_scope_json_commands_total: 0,
299 verified_scope_json_commands_with_schema: 0,
300 verified_scope_json_commands_with_accepted_opaque_schema: 0,
301 verified_scope_json_commands_without_schema: 0,
302 advanced_scope_json_commands_total: 0,
303 advanced_scope_json_commands_with_accepted_opaque_schema: 0,
304 mutating_commands_total: 0,
305 mutating_commands_with_schema: 0,
306 mutating_commands_with_accepted_opaque_schema: 0,
307 mutating_commands_without_schema: 0,
308 verified_scope_mutating_commands_total: 0,
309 verified_scope_mutating_commands_with_schema: 0,
310 verified_scope_mutating_commands_with_accepted_opaque_schema: 0,
311 verified_scope_mutating_commands_without_schema: 0,
312 advanced_scope_mutating_commands_total: 0,
313 advanced_scope_mutating_commands_with_accepted_opaque_schema: 0,
314 schema_verbs_total: 0,
315 documented_schema_verbs_total: 0,
316 undocumented_schema_verbs_total: 0,
317 opaque_schema_verbs_total: 0,
318 accepted_opaque_schema_verbs_total: 0,
319 unaccepted_opaque_schema_verbs_total: 0,
320 supports_op_id_total: 0,
321 jsonl_commands_total: 0,
322 missing_schema_examples: Vec::new(),
323 missing_mutating_schema_examples: Vec::new(),
324 verified_scope_missing_schema_examples: Vec::new(),
325 verified_scope_accepted_opaque_schema_examples: Vec::new(),
326 advanced_scope_accepted_opaque_schema_examples: Vec::new(),
327 accepted_opaque_schema_examples: Vec::new(),
328 unaccepted_opaque_schema_examples: Vec::new(),
329 undocumented_schema_examples: Vec::new(),
330 }
331 }
332}
333
334#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
335pub struct VerificationCheck {
336 pub name: String,
337 pub status: String,
338 pub clean: bool,
339 pub summary: String,
340 pub recommended_action: Option<String>,
341 pub recommended_action_template: Option<ActionTemplate>,
342 pub recovery_commands: Vec<String>,
343 pub recovery_action_templates: Vec<ActionTemplate>,
344 #[serde(default)]
345 pub details: BTreeMap<String, String>,
346}
347
348pub fn build_plain_git_verification_probe(start: &Path) -> Result<Option<PlainGitVerifyProbe>> {
349 build_plain_git_verification_probe_with_machine_contract(
350 start,
351 &MachineContractInput::default(),
352 )
353}
354
355pub fn build_plain_git_verification_probe_with_machine_contract(
356 start: &Path,
357 machine_contract_input: &MachineContractInput,
358) -> Result<Option<PlainGitVerifyProbe>> {
359 if discover_heddle_root(start).is_some() {
364 return Ok(None);
365 }
366 let git_repo = match SleyRepository::open_from_environment(start) {
367 Ok(repo) => repo,
368 Err(_) => return Ok(None),
369 };
370 let Some(workdir) = git_repo.workdir() else {
371 return Ok(None);
372 };
373 let root = workdir
374 .canonicalize()
375 .unwrap_or_else(|_| workdir.to_path_buf());
376 if root.join(".heddle").exists() {
377 return Ok(None);
378 }
379
380 let git_branch = plain_git_current_branch(&git_repo);
381 let git_branches = plain_git_local_branches(&git_repo);
382 let git_tags = plain_git_local_tags(&git_repo);
383 let changes = plain_git_worktree_status(&git_repo)?;
384 let onboarding = plan_repository_onboarding(OnboardingFacts {
385 git_worktree: true,
386 git_has_commits: git_repo.head().ok().and_then(|head| head.oid).is_some(),
387 heddle_mode: None,
388 });
389
390 let default_remote = git_default_remote_name_from_repo(&git_repo);
391 let setup_action = onboarding
392 .recommended_command()
393 .expect("plain Git without Heddle metadata requires onboarding")
394 .to_string();
395 let recovery_commands = vec![setup_action.clone()];
396 let machine_contract_coverage = machine_contract_input.coverage.clone();
397 let mut details = BTreeMap::new();
398 details.insert("path".to_string(), root.display().to_string());
399 if let Some(branch) = &git_branch {
400 details.insert("git_branch".to_string(), branch.clone());
401 }
402 if let Some(remote) = &default_remote {
403 details.insert("default_remote".to_string(), remote.clone());
404 }
405 details.insert(
406 "git_branch_count".to_string(),
407 git_branches.len().to_string(),
408 );
409 details.insert("git_tag_count".to_string(), git_tags.len().to_string());
410 details.insert(
411 "onboarding_state".to_string(),
412 onboarding.state.as_str().to_string(),
413 );
414
415 let mut checks = vec![
416 VerificationCheck {
417 name: "Git".to_string(),
418 status: "present".to_string(),
419 clean: true,
420 summary: "plain Git repository found".to_string(),
421 recommended_action: None,
422 recommended_action_template: None,
423 recovery_commands: Vec::new(),
424 recovery_action_templates: Vec::new(),
425 details,
426 },
427 VerificationCheck {
428 name: "Heddle".to_string(),
429 status: "needs_init".to_string(),
430 clean: false,
431 summary: "Heddle data is not initialized".to_string(),
432 recommended_action: Some(setup_action.clone()),
433 recommended_action_template: action_template(&setup_action),
434 recovery_commands: recovery_commands.clone(),
435 recovery_action_templates: action_templates(&recovery_commands),
436 details: BTreeMap::new(),
437 },
438 VerificationCheck {
439 name: "Mapping".to_string(),
440 status: "git_backed".to_string(),
441 clean: true,
442 summary: onboarding.storage_summary().to_string(),
443 recommended_action: None,
444 recommended_action_template: None,
445 recovery_commands: Vec::new(),
446 recovery_action_templates: Vec::new(),
447 details: BTreeMap::new(),
448 },
449 ];
450 checks.push(verification_check(
451 "Worktree",
452 changes.is_clean(),
453 if changes.is_clean() {
454 "clean"
455 } else {
456 "dirty_worktree"
457 },
458 if changes.is_clean() {
459 "Git worktree is clean"
460 } else {
461 "Git worktree has uncommitted changes"
462 },
463 None,
464 Vec::new(),
465 ));
466 checks.push(verification_check(
467 "Remote",
468 false,
469 "unknown",
470 "remote drift is checked after Heddle initialization",
471 None,
472 Vec::new(),
473 ));
474 checks.push(verification_check(
475 "Operation",
476 true,
477 "clean",
478 "no Heddle operation in progress",
479 None,
480 Vec::new(),
481 ));
482 checks.push(verification_check(
483 "Workflow",
484 false,
485 "not_checked",
486 "workflow readiness is checked after Heddle initialization",
487 None,
488 Vec::new(),
489 ));
490 checks.push(machine_contract_verification_check(
491 &machine_contract_coverage,
492 ));
493 checks.push(verification_check(
494 "Clone",
495 true,
496 "not_applicable",
497 "clone verification is not applicable to this checkout",
498 None,
499 Vec::new(),
500 ));
501
502 let trust = RepositoryVerificationState {
503 verified: false,
504 status: "needs_init".to_string(),
505 repository_mode: "plain-git".to_string(),
506 heddle_initialized: false,
507 git_branch: git_branch.clone(),
508 heddle_thread: None,
509 worktree_dirty: !changes.is_clean(),
510 worktree_state: if changes.is_clean() { "clean" } else { "dirty" }.to_string(),
511 import_state: "git_backed".to_string(),
512 mapping_state: "git_backed".to_string(),
513 remote_drift: "unknown".to_string(),
514 active_operation: None,
515 default_remote,
516 clone_verification: "not_applicable".to_string(),
517 machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
518 machine_contract_coverage,
519 workflow_status: "not_checked".to_string(),
520 workflow_summary: "workflow readiness is checked after Heddle initialization".to_string(),
521 summary: "Git repository has not been initialized for Heddle".to_string(),
522 recommended_action: setup_action.clone(),
523 recommended_action_template: action_template(&setup_action),
524 recovery_commands: recovery_commands.clone(),
525 recovery_action_templates: action_templates(&recovery_commands),
526 checks,
527 };
528 Ok(Some(PlainGitVerifyProbe {
529 root,
530 git_branch,
531 changes,
532 trust,
533 }))
534}
535
536fn plain_git_current_branch(git_repo: &SleyRepository) -> Option<String> {
537 git_repo.head().ok()?.branch_name().map(str::to_string)
538}
539
540fn plain_git_local_branches(git_repo: &SleyRepository) -> Vec<String> {
541 let Ok(branches) = git_repo.references().list_refs() else {
542 return Vec::new();
543 };
544 let mut names = branches
545 .into_iter()
546 .filter_map(|branch| branch.name.strip_prefix("refs/heads/").map(str::to_string))
547 .filter(|branch| !branch.trim().is_empty())
548 .collect::<Vec<_>>();
549 names.sort();
550 names.dedup();
551 names
552}
553
554fn plain_git_local_tags(git_repo: &SleyRepository) -> Vec<String> {
555 let Ok(tags) = git_repo.references().list_refs() else {
556 return Vec::new();
557 };
558 let mut names = tags
559 .into_iter()
560 .filter_map(|tag| tag.name.strip_prefix("refs/tags/").map(str::to_string))
561 .filter(|tag| !tag.trim().is_empty())
562 .collect::<Vec<_>>();
563 names.sort();
564 names.dedup();
565 names
566}
567
568fn plain_git_worktree_status(git_repo: &SleyRepository) -> Result<WorktreeStatus> {
569 let mut added = BTreeSet::new();
570 let mut modified = BTreeSet::new();
571 let mut deleted = BTreeSet::new();
572 git_repo
573 .stream_short_status_with_options(
574 ShortStatusOptions {
575 untracked_mode: StatusUntrackedMode::All,
576 ..ShortStatusOptions::default()
577 },
578 |entry| {
579 let path = PathBuf::from(String::from_utf8_lossy(entry.path).into_owned());
580 if entry.index == b'?' && entry.worktree == b'?' {
581 added.insert(path);
582 } else if entry.index == b'D' || entry.worktree == b'D' {
583 deleted.insert(path);
584 } else if entry.index == b'A'
585 || entry.index == b'R'
586 || entry.index == b'C'
587 || entry.head_oid.is_none()
588 {
589 added.insert(path);
590 } else {
591 modified.insert(path);
592 }
593 Ok(StreamControl::Continue)
594 },
595 )
596 .map_err(|error| HeddleError::Config(error.to_string()))?;
597
598 for path in &added {
599 modified.remove(path);
600 }
601 for path in &deleted {
602 modified.remove(path);
603 }
604
605 Ok(WorktreeStatus {
606 modified: modified.into_iter().collect(),
607 added: added.into_iter().collect(),
608 deleted: deleted.into_iter().collect(),
609 })
610}
611
612pub fn build_repository_verification_state(
613 repo: &Repository,
614) -> Result<RepositoryVerificationState> {
615 build_repository_verification_state_with_machine_contract(
616 repo,
617 &MachineContractInput::default(),
618 )
619}
620
621pub fn build_repository_verification_state_with_machine_contract(
622 repo: &Repository,
623 machine_contract_input: &MachineContractInput,
624) -> Result<RepositoryVerificationState> {
625 let worktree_status = if repo.capability() == repo::RepositoryCapability::GitOverlay {
626 repo.git_overlay_worktree_status()
627 } else {
628 native_worktree_status(repo)
629 };
630 let health = build_repository_verification_health_with_worktree_status(repo, &worktree_status);
631 Ok(
632 build_repository_verification_state_with_worktree_status_and_machine_contract(
633 repo,
634 health,
635 &worktree_status,
636 machine_contract_input,
637 ),
638 )
639}
640
641fn native_worktree_status(repo: &Repository) -> Result<Option<WorktreeStatus>> {
642 let Some(state) = repo.current_state()? else {
643 return Ok(Some(WorktreeStatus::default()));
644 };
645 let tree = repo.require_tree(&state.tree)?;
646 repo.compare_worktree_cached(&tree).map(Some)
647}
648
649pub fn build_repository_verification_state_with_worktree_status(
650 repo: &Repository,
651 health: RepositoryVerificationHealth,
652 worktree_status: &Result<Option<WorktreeStatus>>,
653) -> RepositoryVerificationState {
654 build_repository_verification_state_with_worktree_status_and_machine_contract(
655 repo,
656 health,
657 worktree_status,
658 &MachineContractInput::default(),
659 )
660}
661
662pub fn build_repository_verification_state_with_worktree_status_and_machine_contract(
663 repo: &Repository,
664 health: RepositoryVerificationHealth,
665 worktree_status: &Result<Option<WorktreeStatus>>,
666 machine_contract_input: &MachineContractInput,
667) -> RepositoryVerificationState {
668 let git_branch = repo.git_overlay_current_branch().ok().flatten();
669 let heddle_thread = repo.current_lane().ok().flatten();
670 let active_operation = repo.operation_status().ok().flatten().map(|operation| {
671 format!(
672 "{} {} ({})",
673 operation.scope, operation.kind, operation.state
674 )
675 });
676 let remote_drift = repo
677 .git_remote_tracking_status()
678 .ok()
679 .flatten()
680 .map(|remote| remote_tracking_status(&remote).to_string())
681 .unwrap_or_else(|| "clean".to_string());
682 let is_git_overlay = repo.capability() == repo::RepositoryCapability::GitOverlay;
683 let import_state = health
684 .checks
685 .iter()
686 .find(|check| check.name == "import" && check.status != "clean")
687 .or_else(|| health.checks.iter().find(|check| check.name == "import"))
688 .map(|check| check.status.clone())
689 .unwrap_or_else(|| {
690 if is_git_overlay {
691 "git_backed".to_string()
692 } else {
693 "clean".to_string()
694 }
695 });
696 let mapping_state = health
697 .checks
698 .iter()
699 .find(|check| {
700 matches!(check.name.as_str(), "head_mapping" | "tag_mapping")
701 && !verification_status_is_clean(&check.status)
702 })
703 .or_else(|| {
704 health
705 .checks
706 .iter()
707 .find(|check| check.name == "head_mapping")
708 })
709 .map(|check| check.status.clone())
710 .unwrap_or_else(|| {
711 if is_git_overlay {
712 "git_backed".to_string()
713 } else {
714 "clean".to_string()
715 }
716 });
717 let git_worktree_dirty = matches!(
718 worktree_status,
719 Ok(Some(status)) if !status.is_clean()
720 );
721 let worktree_dirty = git_worktree_dirty
722 || health.checks.iter().any(|check| {
723 matches!(check.name.as_str(), "worktree" | "heddle_worktree") && check.status != "clean"
724 });
725 let machine_contract_coverage = machine_contract_input.coverage.clone();
726 let machine_contract_clean = machine_contract_is_clean(&machine_contract_coverage);
727 let mut recovery_commands = health.recovery_commands.clone();
728 let remote_action = remote_sync_action(&health, repo.source_authority());
729 let (workflow_status, workflow_summary) = workflow_status(repo, heddle_thread.as_deref());
730 let workflow_action = if health.clean && workflow_status == "ready" {
731 workflow_primary_action(repo)
732 } else {
733 None
734 };
735 if health.clean && !machine_contract_clean {
736 recovery_commands.push("heddle help --output json".to_string());
737 }
738 let recommended_action = if health.clean {
739 if !machine_contract_clean {
740 "heddle help --output json".to_string()
741 } else {
742 workflow_action
743 .clone()
744 .or_else(|| remote_action.clone())
745 .unwrap_or_default()
746 }
747 } else {
748 recovery_commands.first().cloned().unwrap_or_default()
749 };
750 let checks = verification_checks_from_health(
751 &health,
752 &machine_contract_coverage,
753 is_git_overlay,
754 &workflow_status,
755 &workflow_summary,
756 workflow_action.as_deref(),
757 repo.source_authority(),
758 );
759 RepositoryVerificationState {
760 verified: health.clean && machine_contract_clean,
761 status: if health.clean && !machine_contract_clean {
762 "machine_contract_gaps".to_string()
763 } else {
764 health.status.clone()
765 },
766 repository_mode: repo.capability_label().to_string(),
767 heddle_initialized: true,
768 git_branch,
769 heddle_thread,
770 worktree_dirty,
771 worktree_state: if worktree_dirty { "dirty" } else { "clean" }.to_string(),
772 import_state,
773 mapping_state,
774 remote_drift,
775 active_operation,
776 default_remote: default_remote_name(repo),
777 clone_verification: if repo.capability() == repo::RepositoryCapability::GitOverlay {
778 if health.clean {
779 "verified"
780 } else if matches!(
781 health.status.as_str(),
782 "dirty_worktree" | "needs_checkpoint"
783 ) {
784 "not_checked"
785 } else {
786 "blocked"
787 }
788 } else {
789 "not_applicable"
790 }
791 .to_string(),
792 machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
793 machine_contract_coverage,
794 workflow_status,
795 workflow_summary,
796 summary: health.summary,
797 recommended_action: recommended_action.clone(),
798 recommended_action_template: action_template(&recommended_action),
799 recovery_commands: recovery_commands.clone(),
800 recovery_action_templates: action_templates(&recovery_commands),
801 checks,
802 }
803}
804
805fn verification_checks_from_health(
806 health: &RepositoryVerificationHealth,
807 coverage: &MachineContractCoverage,
808 is_git_overlay: bool,
809 workflow_status: &str,
810 workflow_summary: &str,
811 workflow_action: Option<&str>,
812 source_authority: repo::RepositorySourceAuthority,
813) -> Vec<VerificationCheck> {
814 let mut checks = vec![
815 git_verification_check(is_git_overlay),
816 verification_check(
817 "Heddle",
818 true,
819 "clean",
820 "Heddle data is initialized",
821 None,
822 Vec::new(),
823 ),
824 mapping_verification_check(health, is_git_overlay),
825 worktree_verification_check(health),
826 remote_verification_check(health, source_authority),
827 operation_verification_check(health),
828 workflow_verification_check(health, workflow_status, workflow_summary, workflow_action),
829 ];
830 checks.push(machine_contract_verification_check(coverage));
831 checks.push(clone_verification_check(health, is_git_overlay));
832 checks
833}
834
835fn machine_contract_verification_check(coverage: &MachineContractCoverage) -> VerificationCheck {
836 let mut details = BTreeMap::new();
837 details.insert("coverage_status".to_string(), coverage.status.clone());
838 details.insert("coverage_summary".to_string(), coverage.summary.clone());
839 details.insert(
840 "verified_scope".to_string(),
841 coverage.verified_scope.clone(),
842 );
843 details.insert(
844 "advanced_scope".to_string(),
845 coverage.advanced_scope.clone(),
846 );
847 details.insert(
848 "catalog_commands_total".to_string(),
849 coverage.catalog_commands_total.to_string(),
850 );
851 details.insert(
852 "json_commands_total".to_string(),
853 coverage.json_commands_total.to_string(),
854 );
855 details.insert(
856 "json_commands_with_schema".to_string(),
857 coverage.json_commands_with_schema.to_string(),
858 );
859 details.insert(
860 "json_commands_without_schema".to_string(),
861 coverage.json_commands_without_schema.to_string(),
862 );
863 details.insert(
864 "json_commands_with_accepted_opaque_schema".to_string(),
865 coverage
866 .json_commands_with_accepted_opaque_schema
867 .to_string(),
868 );
869 details.insert(
870 "verified_scope_json_commands_total".to_string(),
871 coverage.verified_scope_json_commands_total.to_string(),
872 );
873 let mut check = verification_check(
874 "Machine contract",
875 machine_contract_is_clean(coverage),
876 machine_contract_status(coverage),
877 &coverage.summary,
878 (!machine_contract_is_clean(coverage)).then(|| "heddle help --output json".to_string()),
879 if machine_contract_is_clean(coverage) {
880 Vec::new()
881 } else {
882 vec!["heddle help --output json".to_string()]
883 },
884 );
885 check.details = details;
886 check
887}
888
889fn git_verification_check(is_git_overlay: bool) -> VerificationCheck {
890 if is_git_overlay {
891 verification_check(
892 "Git",
893 true,
894 "clean",
895 "Git overlay repository is present",
896 None,
897 Vec::new(),
898 )
899 } else {
900 verification_check(
901 "Git",
902 true,
903 "not_applicable",
904 "Heddle-native repository is running in non-overlay mode",
905 None,
906 Vec::new(),
907 )
908 }
909}
910
911fn mapping_verification_check(
912 health: &RepositoryVerificationHealth,
913 is_git_overlay: bool,
914) -> VerificationCheck {
915 if !is_git_overlay {
916 return verification_check(
917 "Mapping",
918 true,
919 "not_applicable",
920 "native Heddle refs do not require Git Projection Mapping",
921 None,
922 Vec::new(),
923 );
924 }
925 if let Some(check) = health
926 .checks
927 .iter()
928 .find(|check| check.name == "head_mapping" && !verification_status_is_clean(&check.status))
929 {
930 return verification_check_from_health("Mapping", check, health);
931 }
932 if let Some(check) = find_health_check(health, "import")
933 && check.status != "clean"
934 {
935 return verification_check_from_health("Mapping", check, health);
936 }
937 if let Some(check) = find_health_check(health, "tag_mapping")
938 && check.status != "clean"
939 {
940 return verification_check_from_health("Mapping", check, health);
941 }
942 if let Some(check) = find_health_check(health, "head_mapping") {
943 if check.status == "git_backed" && health.status == "dirty_worktree" {
944 return verification_check(
945 "Mapping",
946 true,
947 "clean",
948 "Git-backed branch mapping is not blocking verification",
949 None,
950 Vec::new(),
951 );
952 }
953 return verification_check_from_health("Mapping", check, health);
954 }
955 verification_check(
956 "Mapping",
957 true,
958 "clean",
959 "Git branch tips map to imported Heddle state",
960 None,
961 Vec::new(),
962 )
963}
964
965fn worktree_verification_check(health: &RepositoryVerificationHealth) -> VerificationCheck {
966 for name in ["worktree", "heddle_worktree"] {
967 if let Some(check) = find_health_check(health, name)
968 && check.status != "clean"
969 {
970 return verification_check_from_health("Worktree", check, health);
971 }
972 }
973 for name in ["worktree", "heddle_worktree"] {
974 if let Some(check) = find_health_check(health, name) {
975 return verification_check_from_health("Worktree", check, health);
976 }
977 }
978 if !health.clean {
979 return verification_check(
980 "Worktree",
981 false,
982 "not_checked",
983 "worktree agreement is checked after the primary verification blocker is resolved",
984 health.recovery_commands.first().cloned(),
985 health.recovery_commands.clone(),
986 );
987 }
988 verification_check(
989 "Worktree",
990 true,
991 "clean",
992 "worktree has no uncommitted Git/Heddle disagreement",
993 None,
994 Vec::new(),
995 )
996}
997
998fn remote_verification_check(
999 health: &RepositoryVerificationHealth,
1000 source_authority: repo::RepositorySourceAuthority,
1001) -> VerificationCheck {
1002 if let Some(check) = find_health_check(health, "remote_tracking") {
1003 if matches!(check.status.as_str(), "remote_ahead" | "remote_untracked") {
1004 let mut remote_check = verification_check(
1005 "Remote",
1006 true,
1007 &check.status,
1008 &check.summary,
1009 remote_sync_action(health, source_authority),
1010 Vec::new(),
1011 );
1012 remote_check.details = check.details.clone();
1013 return remote_check;
1014 }
1015 return verification_check_from_health("Remote", check, health);
1016 }
1017 verification_check(
1018 "Remote",
1019 true,
1020 "clean",
1021 "remote tracking has no blocking drift",
1022 None,
1023 Vec::new(),
1024 )
1025}
1026
1027fn operation_verification_check(health: &RepositoryVerificationHealth) -> VerificationCheck {
1028 if let Some(check) = find_health_check(health, "operation") {
1029 return verification_check_from_health("Operation", check, health);
1030 }
1031 verification_check(
1032 "Operation",
1033 true,
1034 "clean",
1035 "no Git or Heddle operation in progress",
1036 None,
1037 Vec::new(),
1038 )
1039}
1040
1041fn workflow_verification_check(
1042 health: &RepositoryVerificationHealth,
1043 workflow_status: &str,
1044 workflow_summary: &str,
1045 workflow_action: Option<&str>,
1046) -> VerificationCheck {
1047 if let Some(check) = find_health_check(health, "thread_integration_metadata")
1048 && check.status != "clean"
1049 {
1050 return verification_check_from_health("Workflow", check, health);
1051 }
1052 if !health.clean {
1053 return verification_check(
1054 "Workflow",
1055 false,
1056 "blocked",
1057 "workflow readiness is checked after the primary verification blocker is resolved",
1058 health.recovery_commands.first().cloned(),
1059 health.recovery_commands.clone(),
1060 );
1061 }
1062 let recommended_action = (workflow_status == "ready")
1068 .then(|| workflow_action.map(str::to_string))
1069 .flatten();
1070 verification_check(
1071 "Workflow",
1072 true,
1073 workflow_status,
1074 workflow_summary,
1075 recommended_action,
1076 Vec::new(),
1077 )
1078}
1079
1080fn clone_verification_check(
1081 health: &RepositoryVerificationHealth,
1082 is_git_overlay: bool,
1083) -> VerificationCheck {
1084 if !is_git_overlay {
1085 return verification_check(
1086 "Clone",
1087 true,
1088 "not_applicable",
1089 "native Heddle state is the checkout authority",
1090 None,
1091 Vec::new(),
1092 );
1093 }
1094 if health.clean {
1095 return verification_check(
1096 "Clone",
1097 true,
1098 "verified",
1099 "Git checkout and Heddle mapping agree",
1100 None,
1101 Vec::new(),
1102 );
1103 }
1104 if matches!(
1105 health.status.as_str(),
1106 "dirty_worktree" | "needs_checkpoint"
1107 ) {
1108 return verification_check(
1109 "Clone",
1110 true,
1111 "not_checked",
1112 "clone verification waits for a clean worktree",
1113 None,
1114 Vec::new(),
1115 );
1116 }
1117 verification_check(
1118 "Clone",
1119 false,
1120 "blocked",
1121 "clone verification is blocked until verification checks agree",
1122 health.recovery_commands.first().cloned(),
1123 health.recovery_commands.clone(),
1124 )
1125}
1126
1127fn verification_check_from_health(
1128 name: &str,
1129 health_check: &crate::status::RepositoryVerificationCheck,
1130 health: &RepositoryVerificationHealth,
1131) -> VerificationCheck {
1132 let recommended_action = (!verification_status_is_clean(&health_check.status))
1133 .then(|| health.recovery_commands.first().cloned())
1134 .flatten();
1135 let recovery_commands = if recommended_action.is_some() {
1136 health.recovery_commands.clone()
1137 } else {
1138 Vec::new()
1139 };
1140 let mut check = verification_check(
1141 name,
1142 verification_status_is_clean(&health_check.status),
1143 &health_check.status,
1144 &health_check.summary,
1145 recommended_action,
1146 recovery_commands,
1147 );
1148 check.details = health_check.details.clone();
1149 check
1150}
1151
1152fn remote_sync_action(
1153 health: &RepositoryVerificationHealth,
1154 source_authority: repo::RepositorySourceAuthority,
1155) -> Option<String> {
1156 find_health_check(health, "remote_tracking").and_then(|check| {
1157 matches!(check.status.as_str(), "remote_ahead" | "remote_untracked")
1158 .then(|| SourceAuthorityActions::new(source_authority).display(SourceAction::Push))
1159 })
1160}
1161
1162fn find_health_check<'a>(
1163 health: &'a RepositoryVerificationHealth,
1164 name: &str,
1165) -> Option<&'a crate::status::RepositoryVerificationCheck> {
1166 health.checks.iter().find(|check| check.name == name)
1167}
1168
1169fn verification_status_is_clean(status: &str) -> bool {
1170 matches!(
1171 status,
1172 "clean"
1173 | "available"
1174 | "git_backed"
1175 | "not_applicable"
1176 | "verified"
1177 | "remote_ahead"
1178 | "remote_untracked"
1179 )
1180}
1181
1182fn workflow_status(repo: &Repository, current_thread: Option<&str>) -> (String, String) {
1183 let ready_threads = ThreadManager::new(repo.heddle_dir())
1184 .list()
1185 .unwrap_or_default()
1186 .into_iter()
1187 .filter(|thread| thread.state == repo::ThreadState::Ready)
1188 .collect::<Vec<_>>();
1189 if ready_threads.is_empty() {
1190 return (
1191 "clean".to_string(),
1192 "no ready thread actions require attention".to_string(),
1193 );
1194 }
1195 let opened_from_dedicated_checkout = repo
1205 .heddle_dir()
1206 .parent()
1207 .is_some_and(|main_root| main_root != repo.root());
1208 let all_target_another_thread = ready_threads.iter().all(|thread| {
1209 let actionable = thread
1210 .target_thread
1211 .as_deref()
1212 .map(|target| current_thread == Some(target) || opened_from_dedicated_checkout)
1213 .unwrap_or(true);
1214 !actionable
1215 });
1216 if all_target_another_thread {
1217 return (
1218 "clean".to_string(),
1219 "ready thread actions target another thread".to_string(),
1220 );
1221 }
1222 (
1223 "ready".to_string(),
1224 "ready thread actions are waiting to land".to_string(),
1225 )
1226}
1227
1228fn workflow_primary_action(repo: &Repository) -> Option<String> {
1229 let current_thread = repo.current_lane().ok().flatten();
1230 let opened_from_dedicated_checkout = repo
1231 .heddle_dir()
1232 .parent()
1233 .is_some_and(|main_root| main_root != repo.root());
1234 ThreadManager::new(repo.heddle_dir())
1235 .list()
1236 .ok()?
1237 .into_iter()
1238 .filter(|thread| thread.state == repo::ThreadState::Ready)
1239 .find_map(|mut thread| {
1240 let _ = refresh_thread_freshness(repo, &mut thread);
1241 let actionable = thread
1242 .target_thread
1243 .as_deref()
1244 .map(|target| {
1245 current_thread.as_deref() == Some(target) || opened_from_dedicated_checkout
1246 })
1247 .unwrap_or(true);
1248 if !actionable {
1249 return None;
1250 }
1251 let advice = describe_thread_advice(&thread, false, 0, false);
1252 (!advice.recommended_action.trim().is_empty()).then_some(advice.recommended_action)
1253 })
1254}
1255
1256fn verification_check(
1257 name: &str,
1258 clean: bool,
1259 status: &str,
1260 summary: &str,
1261 recommended_action: Option<String>,
1262 recovery_commands: Vec<String>,
1263) -> VerificationCheck {
1264 VerificationCheck {
1265 name: name.to_string(),
1266 status: status.to_string(),
1267 clean,
1268 summary: summary.to_string(),
1269 recommended_action: recommended_action.clone(),
1270 recommended_action_template: recommended_action.as_deref().and_then(action_template),
1271 recovery_action_templates: action_templates(&recovery_commands),
1272 recovery_commands,
1273 details: BTreeMap::new(),
1274 }
1275}
1276
1277pub fn action_template(action: &str) -> Option<ActionTemplate> {
1278 let trimmed = action.trim();
1279 if trimmed.is_empty() {
1280 return None;
1281 }
1282 recommended_action_templates()
1283 .iter()
1284 .find(|template| template.action == trimmed)
1285 .cloned()
1286 .or_else(|| concrete_action_template(trimmed))
1287}
1288
1289pub fn action_templates(commands: &[String]) -> Vec<ActionTemplate> {
1290 commands
1291 .iter()
1292 .filter_map(|command| action_template(command))
1293 .collect()
1294}
1295
1296fn concrete_action_template(action: &str) -> Option<ActionTemplate> {
1297 if action.contains("...") || (action.contains('<') && action.contains('>')) {
1298 return None;
1299 }
1300 let argv = split_action(action).ok()?;
1301 matches!(argv.first().map(String::as_str), Some("heddle" | "git")).then(|| ActionTemplate {
1302 action: action.to_string(),
1303 argv_template: normalize_heddle_argv(argv),
1304 required_inputs: Vec::new(),
1305 agent_may_fill: false,
1306 })
1307}
1308
1309fn recommended_action_templates() -> Vec<ActionTemplate> {
1310 [
1311 (
1312 "heddle capture -m \"...\"",
1313 &["heddle", "capture", "-m", "<message>"][..],
1314 &["message"][..],
1315 true,
1316 ),
1317 ("heddle init", &["heddle", "init"][..], &[][..], false),
1318 (
1319 "heddle init --principal-name <name> --principal-email <email>",
1320 &[
1321 "heddle",
1322 "init",
1323 "--principal-name",
1324 "<name>",
1325 "--principal-email",
1326 "<email>",
1327 ][..],
1328 &["name", "email"][..],
1329 true,
1330 ),
1331 (
1332 "heddle ready -m \"...\"",
1333 &["heddle", "ready", "-m", "<message>"][..],
1334 &["message"][..],
1335 true,
1336 ),
1337 ("heddle status", &["heddle", "status"][..], &[][..], false),
1338 (
1339 "heddle thread switch <branch>",
1340 &["heddle", "switch", "<branch>"][..],
1341 &["branch"][..],
1342 false,
1343 ),
1344 ("heddle verify", &["heddle", "verify"][..], &[][..], false),
1345 ("heddle doctor", &["heddle", "doctor"][..], &[][..], false),
1346 (
1347 "heddle help --output json",
1348 &["heddle", "help", "--output", "json"][..],
1349 &[][..],
1350 false,
1351 ),
1352 ]
1353 .into_iter()
1354 .map(
1355 |(action, argv_template, required_inputs, agent_may_fill)| ActionTemplate {
1356 action: action.to_string(),
1357 argv_template: normalize_heddle_argv(
1358 argv_template.iter().map(|arg| (*arg).to_string()).collect(),
1359 ),
1360 required_inputs: required_inputs
1361 .iter()
1362 .map(|input| (*input).to_string())
1363 .collect(),
1364 agent_may_fill,
1365 },
1366 )
1367 .collect()
1368}
1369
1370fn normalize_heddle_argv(mut argv: Vec<String>) -> Vec<String> {
1371 if argv.first().is_some_and(|first| first == "heddle") {
1372 argv[0] = heddle_argv0();
1373 }
1374 argv
1375}
1376
1377fn heddle_argv0() -> String {
1378 match std::env::current_exe() {
1379 Ok(path) => {
1380 let file_name = path.file_name().and_then(|name| name.to_str());
1381 if matches!(file_name, Some("heddle") | Some("heddle.exe")) {
1382 path.display().to_string()
1383 } else {
1384 "heddle".to_string()
1385 }
1386 }
1387 Err(_) => "heddle".to_string(),
1388 }
1389}
1390
1391fn split_action(action: &str) -> std::result::Result<Vec<String>, String> {
1392 let mut args = Vec::new();
1393 let mut current = String::new();
1394 let mut chars = action.chars().peekable();
1395 let mut in_single_quote = false;
1396 let mut in_double_quote = false;
1397 while let Some(ch) = chars.next() {
1398 match (ch, in_single_quote, in_double_quote) {
1399 ('\'', false, false) => in_single_quote = true,
1400 ('\'', true, false) => in_single_quote = false,
1401 ('"', false, false) => in_double_quote = true,
1402 ('"', false, true) => in_double_quote = false,
1403 ('\\', false, _) => match chars.next() {
1404 Some(next) => current.push(next),
1405 None => current.push('\\'),
1406 },
1407 (ch, false, false) if ch.is_whitespace() => {
1408 if !current.is_empty() {
1409 args.push(std::mem::take(&mut current));
1410 }
1411 }
1412 (ch, _, _) => current.push(ch),
1413 }
1414 }
1415 if in_single_quote || in_double_quote {
1416 return Err("unterminated quote".to_string());
1417 }
1418 if !current.is_empty() {
1419 args.push(current);
1420 }
1421 Ok(args)
1422}
1423
1424fn machine_contract_is_clean(coverage: &MachineContractCoverage) -> bool {
1425 if matches!(coverage.status.as_str(), "not_checked" | "not_applicable") {
1426 return true;
1427 }
1428 coverage.verified_scope_json_commands_without_schema == 0
1429 && coverage.verified_scope_mutating_commands_without_schema == 0
1430 && coverage.undocumented_schema_verbs_total == 0
1431 && coverage.unaccepted_opaque_schema_verbs_total == 0
1432}
1433
1434pub fn machine_contract_status(coverage: &MachineContractCoverage) -> &'static str {
1435 match coverage.status.as_str() {
1436 "not_checked" => "not_checked",
1437 "not_applicable" => "not_applicable",
1438 _ if machine_contract_is_clean(coverage) => "available",
1439 _ => "available_with_schema_gaps",
1440 }
1441}
1442
1443pub fn verify(ctx: &ExecutionContext, opts: VerifyOptions) -> Result<VerifyReport> {
1444 let fallback;
1445 let start = if let Some(start) = opts.start_path.as_deref() {
1446 start
1447 } else if let Some(start) = ctx.start_path() {
1448 start
1449 } else {
1450 fallback = std::env::current_dir().map_err(HeddleError::Io)?;
1451 fallback.as_path()
1452 };
1453
1454 let mut profile = VerifyProfile::default();
1458 let opened;
1459 let repo = if let Some(repo) = ctx.repo() {
1460 repo
1461 } else {
1462 let probe_start = Instant::now();
1463 let plain_git_probe = build_plain_git_verification_probe_with_machine_contract(
1464 start,
1465 &opts.machine_contract_input,
1466 )?;
1467 profile.plain_git_probe_ms = probe_start.elapsed().as_millis();
1468
1469 if let Some(probe) = plain_git_probe {
1470 return Ok(VerifyReport {
1471 output_kind: "verify",
1472 clean: probe.trust.verified,
1473 repository_label: repository_mode_label("plain-git", "git-only"),
1474 repository_context: None,
1475 trust: probe.trust,
1476 provenance: None,
1477 profile,
1478 });
1479 }
1480
1481 let repo_open_start = Instant::now();
1482 opened = Repository::open(start)?;
1483 profile.repo_open_ms = repo_open_start.elapsed().as_millis();
1484 &opened
1485 };
1486 let verification_start = Instant::now();
1487 let trust = build_repository_verification_state_with_machine_contract(
1488 repo,
1489 &opts.machine_contract_input,
1490 )?;
1491 let provenance = opts
1492 .provenance
1493 .then(|| crate::verify_repository_provenance(repo))
1494 .transpose()?;
1495 profile.verification_ms = verification_start.elapsed().as_millis();
1496 let presentation = repository_presentation(repo, None, None);
1497 Ok(VerifyReport {
1498 output_kind: "verify",
1499 clean: trust.verified && provenance.as_ref().is_none_or(|report| report.clean),
1500 repository_label: presentation.label,
1501 repository_context: presentation.context,
1502 trust,
1503 provenance,
1504 profile,
1505 })
1506}
1507
1508pub fn repository_mode_label(capability: &str, storage_model: &str) -> String {
1512 if capability == "git-overlay" || storage_model == "git+heddle-sidecar" {
1513 "Git + Heddle".to_string()
1514 } else if capability == "plain-git" || storage_model == "git-only" {
1515 "Git repo (setup needed)".to_string()
1516 } else if capability == "native"
1517 || capability == "native-heddle"
1518 || storage_model == "heddle-native"
1519 {
1520 "Heddle native".to_string()
1521 } else {
1522 capability.to_string()
1523 }
1524}
1525
1526pub fn repository_presentation(
1531 repo: &Repository,
1532 target_thread: Option<&str>,
1533 parent_thread: Option<&str>,
1534) -> RepositoryPresentation {
1535 if let Some(parent_root) = managed_git_overlay_parent_root(repo) {
1536 let thread = current_child_thread(repo);
1537 let target_thread = target_thread.map(ToString::to_string).or_else(|| {
1538 thread
1539 .as_ref()
1540 .and_then(|thread| thread.target_thread.clone())
1541 });
1542 let parent_thread = parent_thread.map(ToString::to_string).or_else(|| {
1543 thread
1544 .as_ref()
1545 .and_then(|thread| thread.parent_thread.clone())
1546 });
1547 return RepositoryPresentation {
1548 label: "Git + Heddle isolated checkout".to_string(),
1549 context: Some(RepositoryContextInfo {
1550 kind: "git-overlay-isolated-checkout".to_string(),
1551 parent_repository: Some(parent_root.display().to_string()),
1552 target_thread,
1553 parent_thread,
1554 }),
1555 };
1556 }
1557
1558 RepositoryPresentation {
1559 label: repository_mode_label(repo.capability_label(), repo.storage_model_label()),
1560 context: None,
1561 }
1562}
1563
1564fn managed_git_overlay_parent_root(repo: &Repository) -> Option<PathBuf> {
1565 let parent_root = repo.heddle_dir().parent()?;
1566 if paths_equal(parent_root, repo.root()) {
1567 return None;
1568 }
1569 parent_root
1570 .join(".git")
1571 .exists()
1572 .then(|| parent_root.to_path_buf())
1573}
1574
1575fn current_child_thread(repo: &Repository) -> Option<Thread> {
1576 let manager = ThreadManager::new(repo.heddle_dir());
1577 if let Ok(Some(thread)) = manager.find_by_execution_root(repo.root()) {
1578 return Some(thread);
1579 }
1580 let lane = repo.current_lane().ok().flatten()?;
1581 manager.find_by_thread(&lane).ok().flatten()
1582}
1583
1584fn paths_equal(left: &Path, right: &Path) -> bool {
1585 let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
1586 let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
1587 left == right
1588}
1589
1590pub fn dirty_path_count(status: &WorktreeStatus) -> usize {
1591 status.modified.len() + status.added.len() + status.deleted.len()
1592}
1593
1594#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1597pub enum RepositorySetupActionKind {
1598 Init,
1599 Adopt,
1600 GitImport,
1601 Other,
1602}
1603
1604#[derive(Debug, Clone, PartialEq, Eq)]
1609pub struct RepositorySetupGuidance {
1610 pub setup_line: String,
1611 pub effect: String,
1612}
1613
1614pub fn repository_setup_action_kind(action: &str) -> RepositorySetupActionKind {
1616 if action == "heddle init" {
1617 RepositorySetupActionKind::Init
1618 } else if action.starts_with("heddle import local") {
1619 RepositorySetupActionKind::Adopt
1620 } else if action.starts_with("heddle bridge git import") {
1621 RepositorySetupActionKind::GitImport
1622 } else {
1623 RepositorySetupActionKind::Other
1624 }
1625}
1626
1627pub fn repository_setup_guidance(
1629 trust: &RepositoryVerificationState,
1630) -> Option<RepositorySetupGuidance> {
1631 if !matches!(trust.status.as_str(), "needs_init" | "needs_import") {
1632 return None;
1633 }
1634 let action = trust.recommended_action.trim();
1635 if action.is_empty() {
1636 return None;
1637 }
1638 let kind = repository_setup_action_kind(action);
1639 let setup_line = match kind {
1640 RepositorySetupActionKind::Init => {
1641 format!("Git repo detected; initialize Heddle with {action}")
1642 }
1643 RepositorySetupActionKind::Adopt => {
1644 format!("Git repo detected; connect this branch with {action}")
1645 }
1646 RepositorySetupActionKind::GitImport => {
1647 format!("Git history not imported; import it with {action}")
1648 }
1649 RepositorySetupActionKind::Other => {
1650 format!("Run {action} to clear the primary setup blocker")
1651 }
1652 };
1653 let worktree_tail = if trust.worktree_state == "clean" {
1654 "and the Git worktree stays clean"
1655 } else {
1656 "and existing Git worktree changes stay untouched"
1657 };
1658 let effect = match kind {
1659 RepositorySetupActionKind::Init => format!(
1660 ".heddle metadata will be created; Git commits stay in Git storage, {worktree_tail}."
1661 ),
1662 RepositorySetupActionKind::Adopt
1663 if trust.repository_mode == "plain-git" && !trust.heddle_initialized =>
1664 {
1665 format!(".heddle metadata will be created, Git history imported, {worktree_tail}.")
1666 }
1667 RepositorySetupActionKind::Adopt => {
1668 format!(".heddle metadata is present; adoption imports Git history {worktree_tail}.")
1669 }
1670 RepositorySetupActionKind::GitImport => {
1671 format!(".heddle metadata is present; Git history import runs {worktree_tail}.")
1672 }
1673 RepositorySetupActionKind::Other => {
1674 format!("The recommended setup command runs {worktree_tail}.")
1675 }
1676 };
1677 Some(RepositorySetupGuidance { setup_line, effect })
1678}
1679
1680#[cfg(test)]
1681mod open_amortization_tests {
1682 use super::*;
1683 use crate::ExecutionContext;
1684
1685 #[test]
1686 fn verify_uses_injected_repo_without_reopening_start_path() {
1687 let temp = tempfile::tempdir().expect("temp repo");
1688 Repository::init_default(temp.path()).expect("init repo");
1689 let repo = Repository::open(temp.path()).expect("open repo");
1690 let bogus = temp.path().join("not-a-repo-start");
1692 let ctx = ExecutionContext::builder()
1693 .start_path(&bogus)
1694 .repo(repo)
1695 .build();
1696
1697 let report = verify(&ctx, VerifyOptions::new().with_start_path(&bogus))
1698 .expect("verify with injected repo must not re-open start_path");
1699
1700 assert_eq!(report.output_kind, "verify");
1701 assert_eq!(
1702 report.profile.repo_open_ms, 0,
1703 "injected repo must report zero facade open cost"
1704 );
1705 assert_eq!(
1706 report.profile.plain_git_probe_ms, 0,
1707 "injected heddle repo must skip plain-git probe"
1708 );
1709 assert!(report.trust.heddle_initialized);
1710 }
1711}
1712
1713#[cfg(test)]
1714mod setup_guidance_tests {
1715 use super::*;
1716
1717 fn bare_verification_state(
1718 status: &str,
1719 recommended_action: &str,
1720 ) -> RepositoryVerificationState {
1721 RepositoryVerificationState {
1722 verified: false,
1723 status: status.to_string(),
1724 repository_mode: "plain-git".to_string(),
1725 heddle_initialized: false,
1726 git_branch: Some("main".to_string()),
1727 heddle_thread: None,
1728 worktree_dirty: false,
1729 worktree_state: "clean".to_string(),
1730 import_state: "needs_import".to_string(),
1731 mapping_state: "needs_import".to_string(),
1732 remote_drift: "not_checked".to_string(),
1733 active_operation: None,
1734 default_remote: None,
1735 clone_verification: "not_applicable".to_string(),
1736 machine_contract: "not_checked".to_string(),
1737 machine_contract_coverage: MachineContractCoverage::not_checked(),
1738 workflow_status: "not_checked".to_string(),
1739 workflow_summary: String::new(),
1740 summary: status.to_string(),
1741 recommended_action: recommended_action.to_string(),
1742 recommended_action_template: None,
1743 recovery_commands: vec![recommended_action.to_string()],
1744 recovery_action_templates: Vec::new(),
1745 checks: Vec::new(),
1746 }
1747 }
1748
1749 #[test]
1750 fn repository_setup_guidance_distinguishes_init_from_adopt() {
1751 let mut init = bare_verification_state("needs_init", "heddle init");
1752 init.import_state = "git_backed".to_string();
1753 init.mapping_state = "git_backed".to_string();
1754
1755 let guidance = repository_setup_guidance(&init).expect("init guidance");
1756 assert!(guidance.setup_line.contains("initialize Heddle"));
1757 assert!(guidance.setup_line.contains("heddle init"));
1758 assert!(guidance.effect.contains("Git commits stay in Git storage"));
1759
1760 let mut convert = bare_verification_state("needs_import", "heddle import local --ref main");
1761 convert.repository_mode = "git-overlay".to_string();
1762 convert.heddle_initialized = true;
1763
1764 let guidance = repository_setup_guidance(&convert).expect("conversion guidance");
1765 assert!(
1766 guidance
1767 .setup_line
1768 .contains("connect this branch with heddle import local --ref main")
1769 );
1770 assert!(guidance.effect.contains("adoption imports Git history"));
1771 }
1772
1773 #[test]
1774 fn repository_setup_guidance_skips_non_setup_statuses() {
1775 let state = bare_verification_state("dirty_worktree", "heddle capture -m \"...\"");
1776 assert!(repository_setup_guidance(&state).is_none());
1777 }
1778}