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