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