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 doctor schemas --output json".to_string());
737 }
738 let recommended_action = if health.clean {
739 if !machine_contract_clean {
740 "heddle doctor schemas --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))
879 .then(|| "heddle doctor schemas --output json".to_string()),
880 if machine_contract_is_clean(coverage) {
881 Vec::new()
882 } else {
883 vec!["heddle doctor schemas --output json".to_string()]
884 },
885 );
886 check.details = details;
887 check
888}
889
890fn git_verification_check(is_git_overlay: bool) -> VerificationCheck {
891 if is_git_overlay {
892 verification_check(
893 "Git",
894 true,
895 "clean",
896 "Git overlay repository is present",
897 None,
898 Vec::new(),
899 )
900 } else {
901 verification_check(
902 "Git",
903 true,
904 "not_applicable",
905 "Heddle-native repository is running in non-overlay mode",
906 None,
907 Vec::new(),
908 )
909 }
910}
911
912fn mapping_verification_check(
913 health: &RepositoryVerificationHealth,
914 is_git_overlay: bool,
915) -> VerificationCheck {
916 if !is_git_overlay {
917 return verification_check(
918 "Mapping",
919 true,
920 "not_applicable",
921 "native Heddle refs do not require Git Projection Mapping",
922 None,
923 Vec::new(),
924 );
925 }
926 if let Some(check) = health
927 .checks
928 .iter()
929 .find(|check| check.name == "head_mapping" && !verification_status_is_clean(&check.status))
930 {
931 return verification_check_from_health("Mapping", check, health);
932 }
933 if let Some(check) = find_health_check(health, "import")
934 && check.status != "clean"
935 {
936 return verification_check_from_health("Mapping", check, health);
937 }
938 if let Some(check) = find_health_check(health, "tag_mapping")
939 && check.status != "clean"
940 {
941 return verification_check_from_health("Mapping", check, health);
942 }
943 if let Some(check) = find_health_check(health, "head_mapping") {
944 if check.status == "git_backed" && health.status == "dirty_worktree" {
945 return verification_check(
946 "Mapping",
947 true,
948 "clean",
949 "Git-backed branch mapping is not blocking verification",
950 None,
951 Vec::new(),
952 );
953 }
954 return verification_check_from_health("Mapping", check, health);
955 }
956 verification_check(
957 "Mapping",
958 true,
959 "clean",
960 "Git branch tips map to imported Heddle state",
961 None,
962 Vec::new(),
963 )
964}
965
966fn worktree_verification_check(health: &RepositoryVerificationHealth) -> VerificationCheck {
967 for name in ["worktree", "heddle_worktree"] {
968 if let Some(check) = find_health_check(health, name)
969 && check.status != "clean"
970 {
971 return verification_check_from_health("Worktree", check, health);
972 }
973 }
974 for name in ["worktree", "heddle_worktree"] {
975 if let Some(check) = find_health_check(health, name) {
976 return verification_check_from_health("Worktree", check, health);
977 }
978 }
979 if !health.clean {
980 return verification_check(
981 "Worktree",
982 false,
983 "not_checked",
984 "worktree agreement is checked after the primary verification blocker is resolved",
985 health.recovery_commands.first().cloned(),
986 health.recovery_commands.clone(),
987 );
988 }
989 verification_check(
990 "Worktree",
991 true,
992 "clean",
993 "worktree has no uncommitted Git/Heddle disagreement",
994 None,
995 Vec::new(),
996 )
997}
998
999fn remote_verification_check(
1000 health: &RepositoryVerificationHealth,
1001 source_authority: repo::RepositorySourceAuthority,
1002) -> VerificationCheck {
1003 if let Some(check) = find_health_check(health, "remote_tracking") {
1004 if matches!(check.status.as_str(), "remote_ahead" | "remote_untracked") {
1005 let mut remote_check = verification_check(
1006 "Remote",
1007 true,
1008 &check.status,
1009 &check.summary,
1010 remote_sync_action(health, source_authority),
1011 Vec::new(),
1012 );
1013 remote_check.details = check.details.clone();
1014 return remote_check;
1015 }
1016 return verification_check_from_health("Remote", check, health);
1017 }
1018 verification_check(
1019 "Remote",
1020 true,
1021 "clean",
1022 "remote tracking has no blocking drift",
1023 None,
1024 Vec::new(),
1025 )
1026}
1027
1028fn operation_verification_check(health: &RepositoryVerificationHealth) -> VerificationCheck {
1029 if let Some(check) = find_health_check(health, "operation") {
1030 return verification_check_from_health("Operation", check, health);
1031 }
1032 verification_check(
1033 "Operation",
1034 true,
1035 "clean",
1036 "no Git or Heddle operation in progress",
1037 None,
1038 Vec::new(),
1039 )
1040}
1041
1042fn workflow_verification_check(
1043 health: &RepositoryVerificationHealth,
1044 workflow_status: &str,
1045 workflow_summary: &str,
1046 workflow_action: Option<&str>,
1047) -> VerificationCheck {
1048 if let Some(check) = find_health_check(health, "thread_integration_metadata")
1049 && check.status != "clean"
1050 {
1051 return verification_check_from_health("Workflow", check, health);
1052 }
1053 if !health.clean {
1054 return verification_check(
1055 "Workflow",
1056 false,
1057 "blocked",
1058 "workflow readiness is checked after the primary verification blocker is resolved",
1059 health.recovery_commands.first().cloned(),
1060 health.recovery_commands.clone(),
1061 );
1062 }
1063 let recommended_action = (workflow_status == "ready")
1069 .then(|| workflow_action.map(str::to_string))
1070 .flatten();
1071 verification_check(
1072 "Workflow",
1073 true,
1074 workflow_status,
1075 workflow_summary,
1076 recommended_action,
1077 Vec::new(),
1078 )
1079}
1080
1081fn clone_verification_check(
1082 health: &RepositoryVerificationHealth,
1083 is_git_overlay: bool,
1084) -> VerificationCheck {
1085 if !is_git_overlay {
1086 return verification_check(
1087 "Clone",
1088 true,
1089 "not_applicable",
1090 "native Heddle state is the checkout authority",
1091 None,
1092 Vec::new(),
1093 );
1094 }
1095 if health.clean {
1096 return verification_check(
1097 "Clone",
1098 true,
1099 "verified",
1100 "Git checkout and Heddle mapping agree",
1101 None,
1102 Vec::new(),
1103 );
1104 }
1105 if matches!(
1106 health.status.as_str(),
1107 "dirty_worktree" | "needs_checkpoint"
1108 ) {
1109 return verification_check(
1110 "Clone",
1111 true,
1112 "not_checked",
1113 "clone verification waits for a clean worktree",
1114 None,
1115 Vec::new(),
1116 );
1117 }
1118 verification_check(
1119 "Clone",
1120 false,
1121 "blocked",
1122 "clone verification is blocked until verification checks agree",
1123 health.recovery_commands.first().cloned(),
1124 health.recovery_commands.clone(),
1125 )
1126}
1127
1128fn verification_check_from_health(
1129 name: &str,
1130 health_check: &crate::status::RepositoryVerificationCheck,
1131 health: &RepositoryVerificationHealth,
1132) -> VerificationCheck {
1133 let recommended_action = (!verification_status_is_clean(&health_check.status))
1134 .then(|| health.recovery_commands.first().cloned())
1135 .flatten();
1136 let recovery_commands = if recommended_action.is_some() {
1137 health.recovery_commands.clone()
1138 } else {
1139 Vec::new()
1140 };
1141 let mut check = verification_check(
1142 name,
1143 verification_status_is_clean(&health_check.status),
1144 &health_check.status,
1145 &health_check.summary,
1146 recommended_action,
1147 recovery_commands,
1148 );
1149 check.details = health_check.details.clone();
1150 check
1151}
1152
1153fn remote_sync_action(
1154 health: &RepositoryVerificationHealth,
1155 source_authority: repo::RepositorySourceAuthority,
1156) -> Option<String> {
1157 find_health_check(health, "remote_tracking").and_then(|check| {
1158 matches!(check.status.as_str(), "remote_ahead" | "remote_untracked")
1159 .then(|| SourceAuthorityActions::new(source_authority).display(SourceAction::Push))
1160 })
1161}
1162
1163fn find_health_check<'a>(
1164 health: &'a RepositoryVerificationHealth,
1165 name: &str,
1166) -> Option<&'a crate::status::RepositoryVerificationCheck> {
1167 health.checks.iter().find(|check| check.name == name)
1168}
1169
1170fn verification_status_is_clean(status: &str) -> bool {
1171 matches!(
1172 status,
1173 "clean"
1174 | "available"
1175 | "git_backed"
1176 | "not_applicable"
1177 | "verified"
1178 | "remote_ahead"
1179 | "remote_untracked"
1180 )
1181}
1182
1183fn workflow_status(repo: &Repository, current_thread: Option<&str>) -> (String, String) {
1184 let ready_threads = ThreadManager::new(repo.heddle_dir())
1185 .list()
1186 .unwrap_or_default()
1187 .into_iter()
1188 .filter(|thread| thread.state == repo::ThreadState::Ready)
1189 .collect::<Vec<_>>();
1190 if ready_threads.is_empty() {
1191 return (
1192 "clean".to_string(),
1193 "no ready thread actions require attention".to_string(),
1194 );
1195 }
1196 let opened_from_dedicated_checkout = repo
1206 .heddle_dir()
1207 .parent()
1208 .is_some_and(|main_root| main_root != repo.root());
1209 let all_target_another_thread = ready_threads.iter().all(|thread| {
1210 let actionable = thread
1211 .target_thread
1212 .as_deref()
1213 .map(|target| current_thread == Some(target) || opened_from_dedicated_checkout)
1214 .unwrap_or(true);
1215 !actionable
1216 });
1217 if all_target_another_thread {
1218 return (
1219 "clean".to_string(),
1220 "ready thread actions target another thread".to_string(),
1221 );
1222 }
1223 (
1224 "ready".to_string(),
1225 "ready thread actions are waiting to land".to_string(),
1226 )
1227}
1228
1229fn workflow_primary_action(repo: &Repository) -> Option<String> {
1230 let current_thread = repo.current_lane().ok().flatten();
1231 let opened_from_dedicated_checkout = repo
1232 .heddle_dir()
1233 .parent()
1234 .is_some_and(|main_root| main_root != repo.root());
1235 ThreadManager::new(repo.heddle_dir())
1236 .list()
1237 .ok()?
1238 .into_iter()
1239 .filter(|thread| thread.state == repo::ThreadState::Ready)
1240 .find_map(|mut thread| {
1241 let _ = refresh_thread_freshness(repo, &mut thread);
1242 let actionable = thread
1243 .target_thread
1244 .as_deref()
1245 .map(|target| {
1246 current_thread.as_deref() == Some(target) || opened_from_dedicated_checkout
1247 })
1248 .unwrap_or(true);
1249 if !actionable {
1250 return None;
1251 }
1252 let advice = describe_thread_advice(&thread, false, 0, false);
1253 (!advice.recommended_action.trim().is_empty()).then_some(advice.recommended_action)
1254 })
1255}
1256
1257fn verification_check(
1258 name: &str,
1259 clean: bool,
1260 status: &str,
1261 summary: &str,
1262 recommended_action: Option<String>,
1263 recovery_commands: Vec<String>,
1264) -> VerificationCheck {
1265 VerificationCheck {
1266 name: name.to_string(),
1267 status: status.to_string(),
1268 clean,
1269 summary: summary.to_string(),
1270 recommended_action: recommended_action.clone(),
1271 recommended_action_template: recommended_action.as_deref().and_then(action_template),
1272 recovery_action_templates: action_templates(&recovery_commands),
1273 recovery_commands,
1274 details: BTreeMap::new(),
1275 }
1276}
1277
1278pub fn action_template(action: &str) -> Option<ActionTemplate> {
1279 let trimmed = action.trim();
1280 if trimmed.is_empty() {
1281 return None;
1282 }
1283 recommended_action_templates()
1284 .iter()
1285 .find(|template| template.action == trimmed)
1286 .cloned()
1287 .or_else(|| concrete_action_template(trimmed))
1288}
1289
1290pub fn action_templates(commands: &[String]) -> Vec<ActionTemplate> {
1291 commands
1292 .iter()
1293 .filter_map(|command| action_template(command))
1294 .collect()
1295}
1296
1297fn concrete_action_template(action: &str) -> Option<ActionTemplate> {
1298 if action.contains("...") || (action.contains('<') && action.contains('>')) {
1299 return None;
1300 }
1301 let argv = split_action(action).ok()?;
1302 matches!(argv.first().map(String::as_str), Some("heddle" | "git")).then(|| ActionTemplate {
1303 action: action.to_string(),
1304 argv_template: normalize_heddle_argv(argv),
1305 required_inputs: Vec::new(),
1306 agent_may_fill: false,
1307 })
1308}
1309
1310fn recommended_action_templates() -> Vec<ActionTemplate> {
1311 [
1312 (
1313 "heddle capture -m \"...\"",
1314 &["heddle", "capture", "-m", "<message>"][..],
1315 &["message"][..],
1316 true,
1317 ),
1318 (
1319 "heddle commit -m \"...\"",
1320 &["heddle", "commit", "-m", "<message>"][..],
1321 &["message"][..],
1322 true,
1323 ),
1324 ("heddle init", &["heddle", "init"][..], &[][..], false),
1325 (
1326 "heddle init --principal-name <name> --principal-email <email>",
1327 &[
1328 "heddle",
1329 "init",
1330 "--principal-name",
1331 "<name>",
1332 "--principal-email",
1333 "<email>",
1334 ][..],
1335 &["name", "email"][..],
1336 true,
1337 ),
1338 (
1339 "heddle ready -m \"...\"",
1340 &["heddle", "ready", "-m", "<message>"][..],
1341 &["message"][..],
1342 true,
1343 ),
1344 ("heddle status", &["heddle", "status"][..], &[][..], false),
1345 (
1346 "heddle thread switch <branch>",
1347 &["heddle", "switch", "<branch>"][..],
1348 &["branch"][..],
1349 false,
1350 ),
1351 ("heddle verify", &["heddle", "verify"][..], &[][..], false),
1352 ("heddle doctor", &["heddle", "doctor"][..], &[][..], false),
1353 (
1354 "heddle doctor schemas --output json",
1355 &["heddle", "doctor", "schemas", "--output", "json"][..],
1356 &[][..],
1357 false,
1358 ),
1359 ]
1360 .into_iter()
1361 .map(
1362 |(action, argv_template, required_inputs, agent_may_fill)| ActionTemplate {
1363 action: action.to_string(),
1364 argv_template: normalize_heddle_argv(
1365 argv_template.iter().map(|arg| (*arg).to_string()).collect(),
1366 ),
1367 required_inputs: required_inputs
1368 .iter()
1369 .map(|input| (*input).to_string())
1370 .collect(),
1371 agent_may_fill,
1372 },
1373 )
1374 .collect()
1375}
1376
1377fn normalize_heddle_argv(mut argv: Vec<String>) -> Vec<String> {
1378 if argv.first().is_some_and(|first| first == "heddle") {
1379 argv[0] = heddle_argv0();
1380 }
1381 argv
1382}
1383
1384fn heddle_argv0() -> String {
1385 match std::env::current_exe() {
1386 Ok(path) => {
1387 let file_name = path.file_name().and_then(|name| name.to_str());
1388 if matches!(file_name, Some("heddle") | Some("heddle.exe")) {
1389 path.display().to_string()
1390 } else {
1391 "heddle".to_string()
1392 }
1393 }
1394 Err(_) => "heddle".to_string(),
1395 }
1396}
1397
1398fn split_action(action: &str) -> std::result::Result<Vec<String>, String> {
1399 let mut args = Vec::new();
1400 let mut current = String::new();
1401 let mut chars = action.chars().peekable();
1402 let mut in_single_quote = false;
1403 let mut in_double_quote = false;
1404 while let Some(ch) = chars.next() {
1405 match (ch, in_single_quote, in_double_quote) {
1406 ('\'', false, false) => in_single_quote = true,
1407 ('\'', true, false) => in_single_quote = false,
1408 ('"', false, false) => in_double_quote = true,
1409 ('"', false, true) => in_double_quote = false,
1410 ('\\', false, _) => match chars.next() {
1411 Some(next) => current.push(next),
1412 None => current.push('\\'),
1413 },
1414 (ch, false, false) if ch.is_whitespace() => {
1415 if !current.is_empty() {
1416 args.push(std::mem::take(&mut current));
1417 }
1418 }
1419 (ch, _, _) => current.push(ch),
1420 }
1421 }
1422 if in_single_quote || in_double_quote {
1423 return Err("unterminated quote".to_string());
1424 }
1425 if !current.is_empty() {
1426 args.push(current);
1427 }
1428 Ok(args)
1429}
1430
1431fn machine_contract_is_clean(coverage: &MachineContractCoverage) -> bool {
1432 if matches!(coverage.status.as_str(), "not_checked" | "not_applicable") {
1433 return true;
1434 }
1435 coverage.verified_scope_json_commands_without_schema == 0
1436 && coverage.verified_scope_mutating_commands_without_schema == 0
1437 && coverage.undocumented_schema_verbs_total == 0
1438 && coverage.unaccepted_opaque_schema_verbs_total == 0
1439}
1440
1441pub fn machine_contract_status(coverage: &MachineContractCoverage) -> &'static str {
1442 match coverage.status.as_str() {
1443 "not_checked" => "not_checked",
1444 "not_applicable" => "not_applicable",
1445 _ if machine_contract_is_clean(coverage) => "available",
1446 _ => "available_with_schema_gaps",
1447 }
1448}
1449
1450pub fn verify(ctx: &ExecutionContext, opts: VerifyOptions) -> Result<VerifyReport> {
1451 let fallback;
1452 let start = if let Some(start) = opts.start_path.as_deref() {
1453 start
1454 } else if let Some(start) = ctx.start_path() {
1455 start
1456 } else {
1457 fallback = std::env::current_dir().map_err(HeddleError::Io)?;
1458 fallback.as_path()
1459 };
1460
1461 let mut profile = VerifyProfile::default();
1465 let opened;
1466 let repo = if let Some(repo) = ctx.repo() {
1467 repo
1468 } else {
1469 let probe_start = Instant::now();
1470 let plain_git_probe = build_plain_git_verification_probe_with_machine_contract(
1471 start,
1472 &opts.machine_contract_input,
1473 )?;
1474 profile.plain_git_probe_ms = probe_start.elapsed().as_millis();
1475
1476 if let Some(probe) = plain_git_probe {
1477 return Ok(VerifyReport {
1478 output_kind: "verify",
1479 clean: probe.trust.verified,
1480 repository_label: repository_mode_label("plain-git", "git-only"),
1481 repository_context: None,
1482 trust: probe.trust,
1483 provenance: None,
1484 profile,
1485 });
1486 }
1487
1488 let repo_open_start = Instant::now();
1489 opened = Repository::open(start)?;
1490 profile.repo_open_ms = repo_open_start.elapsed().as_millis();
1491 &opened
1492 };
1493 let verification_start = Instant::now();
1494 let trust = build_repository_verification_state_with_machine_contract(
1495 repo,
1496 &opts.machine_contract_input,
1497 )?;
1498 let provenance = opts
1499 .provenance
1500 .then(|| crate::verify_repository_provenance(repo))
1501 .transpose()?;
1502 profile.verification_ms = verification_start.elapsed().as_millis();
1503 let presentation = repository_presentation(repo, None, None);
1504 Ok(VerifyReport {
1505 output_kind: "verify",
1506 clean: trust.verified && provenance.as_ref().is_none_or(|report| report.clean),
1507 repository_label: presentation.label,
1508 repository_context: presentation.context,
1509 trust,
1510 provenance,
1511 profile,
1512 })
1513}
1514
1515pub fn repository_mode_label(capability: &str, storage_model: &str) -> String {
1519 if capability == "git-overlay" || storage_model == "git+heddle-sidecar" {
1520 "Git + Heddle".to_string()
1521 } else if capability == "plain-git" || storage_model == "git-only" {
1522 "Git repo (setup needed)".to_string()
1523 } else if capability == "native"
1524 || capability == "native-heddle"
1525 || storage_model == "heddle-native"
1526 {
1527 "Heddle native".to_string()
1528 } else {
1529 capability.to_string()
1530 }
1531}
1532
1533pub fn repository_presentation(
1538 repo: &Repository,
1539 target_thread: Option<&str>,
1540 parent_thread: Option<&str>,
1541) -> RepositoryPresentation {
1542 if let Some(parent_root) = managed_git_overlay_parent_root(repo) {
1543 let thread = current_child_thread(repo);
1544 let target_thread = target_thread.map(ToString::to_string).or_else(|| {
1545 thread
1546 .as_ref()
1547 .and_then(|thread| thread.target_thread.clone())
1548 });
1549 let parent_thread = parent_thread.map(ToString::to_string).or_else(|| {
1550 thread
1551 .as_ref()
1552 .and_then(|thread| thread.parent_thread.clone())
1553 });
1554 return RepositoryPresentation {
1555 label: "Git + Heddle isolated checkout".to_string(),
1556 context: Some(RepositoryContextInfo {
1557 kind: "git-overlay-isolated-checkout".to_string(),
1558 parent_repository: Some(parent_root.display().to_string()),
1559 target_thread,
1560 parent_thread,
1561 }),
1562 };
1563 }
1564
1565 RepositoryPresentation {
1566 label: repository_mode_label(repo.capability_label(), repo.storage_model_label()),
1567 context: None,
1568 }
1569}
1570
1571fn managed_git_overlay_parent_root(repo: &Repository) -> Option<PathBuf> {
1572 let parent_root = repo.heddle_dir().parent()?;
1573 if paths_equal(parent_root, repo.root()) {
1574 return None;
1575 }
1576 parent_root
1577 .join(".git")
1578 .exists()
1579 .then(|| parent_root.to_path_buf())
1580}
1581
1582fn current_child_thread(repo: &Repository) -> Option<Thread> {
1583 let manager = ThreadManager::new(repo.heddle_dir());
1584 if let Ok(Some(thread)) = manager.find_by_execution_root(repo.root()) {
1585 return Some(thread);
1586 }
1587 let lane = repo.current_lane().ok().flatten()?;
1588 manager.find_by_thread(&lane).ok().flatten()
1589}
1590
1591fn paths_equal(left: &Path, right: &Path) -> bool {
1592 let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
1593 let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
1594 left == right
1595}
1596
1597pub fn dirty_path_count(status: &WorktreeStatus) -> usize {
1598 status.modified.len() + status.added.len() + status.deleted.len()
1599}
1600
1601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1604pub enum RepositorySetupActionKind {
1605 Init,
1606 Adopt,
1607 GitImport,
1608 Other,
1609}
1610
1611#[derive(Debug, Clone, PartialEq, Eq)]
1616pub struct RepositorySetupGuidance {
1617 pub setup_line: String,
1618 pub effect: String,
1619}
1620
1621pub fn repository_setup_action_kind(action: &str) -> RepositorySetupActionKind {
1623 if action == "heddle init" {
1624 RepositorySetupActionKind::Init
1625 } else if action.starts_with("heddle adopt") {
1626 RepositorySetupActionKind::Adopt
1627 } else if action.starts_with("heddle bridge git import") {
1628 RepositorySetupActionKind::GitImport
1629 } else {
1630 RepositorySetupActionKind::Other
1631 }
1632}
1633
1634pub fn repository_setup_guidance(
1636 trust: &RepositoryVerificationState,
1637) -> Option<RepositorySetupGuidance> {
1638 if !matches!(trust.status.as_str(), "needs_init" | "needs_import") {
1639 return None;
1640 }
1641 let action = trust.recommended_action.trim();
1642 if action.is_empty() {
1643 return None;
1644 }
1645 let kind = repository_setup_action_kind(action);
1646 let setup_line = match kind {
1647 RepositorySetupActionKind::Init => {
1648 format!("Git repo detected; initialize Heddle with {action}")
1649 }
1650 RepositorySetupActionKind::Adopt => {
1651 format!("Git repo detected; connect this branch with {action}")
1652 }
1653 RepositorySetupActionKind::GitImport => {
1654 format!("Git history not imported; import it with {action}")
1655 }
1656 RepositorySetupActionKind::Other => {
1657 format!("Run {action} to clear the primary setup blocker")
1658 }
1659 };
1660 let worktree_tail = if trust.worktree_state == "clean" {
1661 "and the Git worktree stays clean"
1662 } else {
1663 "and existing Git worktree changes stay untouched"
1664 };
1665 let effect = match kind {
1666 RepositorySetupActionKind::Init => format!(
1667 ".heddle metadata will be created; Git commits stay in Git storage, {worktree_tail}."
1668 ),
1669 RepositorySetupActionKind::Adopt
1670 if trust.repository_mode == "plain-git" && !trust.heddle_initialized =>
1671 {
1672 format!(".heddle metadata will be created, Git history imported, {worktree_tail}.")
1673 }
1674 RepositorySetupActionKind::Adopt => {
1675 format!(".heddle metadata is present; adoption imports Git history {worktree_tail}.")
1676 }
1677 RepositorySetupActionKind::GitImport => {
1678 format!(".heddle metadata is present; Git history import runs {worktree_tail}.")
1679 }
1680 RepositorySetupActionKind::Other => {
1681 format!("The recommended setup command runs {worktree_tail}.")
1682 }
1683 };
1684 Some(RepositorySetupGuidance { setup_line, effect })
1685}
1686
1687#[cfg(test)]
1688mod open_amortization_tests {
1689 use super::*;
1690 use crate::ExecutionContext;
1691
1692 #[test]
1693 fn verify_uses_injected_repo_without_reopening_start_path() {
1694 let temp = tempfile::tempdir().expect("temp repo");
1695 Repository::init_default(temp.path()).expect("init repo");
1696 let repo = Repository::open(temp.path()).expect("open repo");
1697 let bogus = temp.path().join("not-a-repo-start");
1699 let ctx = ExecutionContext::builder()
1700 .start_path(&bogus)
1701 .repo(repo)
1702 .build();
1703
1704 let report = verify(&ctx, VerifyOptions::new().with_start_path(&bogus))
1705 .expect("verify with injected repo must not re-open start_path");
1706
1707 assert_eq!(report.output_kind, "verify");
1708 assert_eq!(
1709 report.profile.repo_open_ms, 0,
1710 "injected repo must report zero facade open cost"
1711 );
1712 assert_eq!(
1713 report.profile.plain_git_probe_ms, 0,
1714 "injected heddle repo must skip plain-git probe"
1715 );
1716 assert!(report.trust.heddle_initialized);
1717 }
1718}
1719
1720#[cfg(test)]
1721mod setup_guidance_tests {
1722 use super::*;
1723
1724 fn bare_verification_state(
1725 status: &str,
1726 recommended_action: &str,
1727 ) -> RepositoryVerificationState {
1728 RepositoryVerificationState {
1729 verified: false,
1730 status: status.to_string(),
1731 repository_mode: "plain-git".to_string(),
1732 heddle_initialized: false,
1733 git_branch: Some("main".to_string()),
1734 heddle_thread: None,
1735 worktree_dirty: false,
1736 worktree_state: "clean".to_string(),
1737 import_state: "needs_import".to_string(),
1738 mapping_state: "needs_import".to_string(),
1739 remote_drift: "not_checked".to_string(),
1740 active_operation: None,
1741 default_remote: None,
1742 clone_verification: "not_applicable".to_string(),
1743 machine_contract: "not_checked".to_string(),
1744 machine_contract_coverage: MachineContractCoverage::not_checked(),
1745 workflow_status: "not_checked".to_string(),
1746 workflow_summary: String::new(),
1747 summary: status.to_string(),
1748 recommended_action: recommended_action.to_string(),
1749 recommended_action_template: None,
1750 recovery_commands: vec![recommended_action.to_string()],
1751 recovery_action_templates: Vec::new(),
1752 checks: Vec::new(),
1753 }
1754 }
1755
1756 #[test]
1757 fn repository_setup_guidance_distinguishes_init_from_adopt() {
1758 let mut init = bare_verification_state("needs_init", "heddle init");
1759 init.import_state = "git_backed".to_string();
1760 init.mapping_state = "git_backed".to_string();
1761
1762 let guidance = repository_setup_guidance(&init).expect("init guidance");
1763 assert!(guidance.setup_line.contains("initialize Heddle"));
1764 assert!(guidance.setup_line.contains("heddle init"));
1765 assert!(guidance.effect.contains("Git commits stay in Git storage"));
1766
1767 let mut convert = bare_verification_state("needs_import", "heddle adopt --ref main");
1768 convert.repository_mode = "git-overlay".to_string();
1769 convert.heddle_initialized = true;
1770
1771 let guidance = repository_setup_guidance(&convert).expect("conversion guidance");
1772 assert!(
1773 guidance
1774 .setup_line
1775 .contains("connect this branch with heddle adopt --ref main")
1776 );
1777 assert!(guidance.effect.contains("adoption imports Git history"));
1778 }
1779
1780 #[test]
1781 fn repository_setup_guidance_skips_non_setup_statuses() {
1782 let state = bare_verification_state("dirty_worktree", "heddle capture -m \"...\"");
1783 assert!(repository_setup_guidance(&state).is_none());
1784 }
1785}