1pub mod next_action;
5pub mod verdict;
6
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 fs,
10 path::{Path, PathBuf},
11 time::Instant,
12};
13
14use chrono::Utc;
15use objects::{
16 HeddleError,
17 error::Result,
18 object::{Principal, State, ThreadName},
19 store::{ActorPresence, ActorPresenceStatus, ActorPresenceStore},
20 worktree::{WorktreeStatus, build_worktree_ignore},
21};
22use refs::Head;
23use repo::{
24 AgentUsageSummary, CommitGraphIndex, GitImportGuidance, GitOverlayBranchTip,
25 GitOverlayOutOfBandCommits, GitRemoteTrackingStatus, RepoConfig, Repository,
26 RepositoryCapability, RepositoryOperationStatus, Thread, ThreadFreshness, ThreadImpactCategory,
27 ThreadManager, ThreadMode, ThreadState, WorktreeCompareProfile,
28 describe_thread_advice_with_initial, is_synthetic_root, refresh_thread_freshness,
29};
30use schemars::JsonSchema;
31use serde::Serialize;
32use serde_json::Value;
33use sley::{
34 Repository as SleyRepository, ShortStatusOptions, ShortStatusRow, StatusUntrackedMode,
35 StreamControl,
36};
37pub use verdict::{
38 StatusCombinedVerdict, combined_verdict_axes, coordination_axis_clean, coordination_label,
39 coordination_severity, health_severity, human_thread_health, resolve_coordination_with_trust,
40 status_combined_verdict,
41};
42
43use self::next_action::{
44 NextActionInput, canonical_git_import_ref_command, canonical_git_repair_ref_preview_command,
45 contextual_thread_action, effective_next_action, heddle_action, non_empty_action,
46 remote_tracking_status,
47};
48use crate::{
49 ActionTemplate, ExecutionContext, HeddleReport, MachineOutputKind, OutputDiscriminator,
50 ReportContract, RepositoryContextInfo, RepositoryVerificationState, VerificationCheck,
51 schema_for_report,
52 source_authority::{SourceAction, SourceAuthorityActions},
53 verify::{
54 MachineContractInput, action_template, action_templates,
55 build_plain_git_verification_probe_with_machine_contract,
56 build_repository_verification_state_with_worktree_status_and_machine_contract,
57 repository_mode_label, serialize_empty_action_as_null,
58 },
59};
60
61#[derive(Clone)]
62pub struct StatusOptions {
63 pub start_path: Option<PathBuf>,
64 pub detail: StatusDetail,
65 pub worktree_status_options: repo::WorktreeStatusOptions,
66 pub machine_contract_input: MachineContractInput,
67}
68
69impl StatusOptions {
70 pub fn new(detail: StatusDetail, worktree_status_options: repo::WorktreeStatusOptions) -> Self {
71 Self {
72 start_path: None,
73 detail,
74 worktree_status_options,
75 machine_contract_input: MachineContractInput::default(),
76 }
77 }
78
79 pub fn with_start_path(mut self, start_path: impl Into<PathBuf>) -> Self {
80 self.start_path = Some(start_path.into());
81 self
82 }
83
84 pub fn with_machine_contract_input(mut self, input: MachineContractInput) -> Self {
85 self.machine_contract_input = input;
86 self
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum StatusDetail {
92 ShortText,
93 CompactMachine,
94 DefaultText,
95 Full,
96}
97
98impl StatusDetail {
99 fn short_path(self) -> bool {
100 matches!(self, Self::ShortText | Self::CompactMachine)
101 }
102
103 fn needs_full_walk(self) -> bool {
104 matches!(self, Self::Full)
105 }
106
107 fn needs_remote_tracking(self) -> bool {
108 matches!(self, Self::ShortText | Self::Full)
109 }
110}
111
112#[derive(Debug, Clone, Serialize, JsonSchema)]
113#[schemars(rename = "StatusSchema")]
114pub struct StatusReport {
115 pub output_kind: &'static str,
116 pub repository_capability: String,
117 pub repository_label: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub repository_context: Option<RepositoryContextInfo>,
120 pub storage_model: String,
121 pub hosted_enabled: bool,
122 #[serde(skip)]
123 #[schemars(skip)]
124 pub validation_capability: RepositoryCapability,
125 #[schemars(with = "Option<serde_json::Value>")]
126 pub operation: Option<RepositoryOperationStatus>,
127 #[schemars(with = "Option<serde_json::Value>")]
128 pub remote_tracking: Option<GitRemoteTrackingStatus>,
129 #[serde(rename = "verification")]
130 pub trust: RepositoryVerificationState,
131 pub git_index: Option<GitIndexPlan>,
132 #[serde(skip)]
133 #[schemars(skip)]
134 pub import_guidance: Option<GitImportGuidanceReport>,
135 #[serde(skip)]
136 #[schemars(skip)]
137 pub verification_health: RepositoryVerificationHealth,
138 pub thread: Option<String>,
139 pub base_state: Option<String>,
140 pub base_root: Option<String>,
141 pub current_state: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub path: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub execution_path: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub session_id: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub heddle_session_id: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub actor: Option<ActorInfo>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub harness: Option<String>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub thinking_level: Option<String>,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 #[schemars(with = "Option<serde_json::Value>")]
158 pub usage_summary: Option<AgentUsageSummary>,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub last_progress_at: Option<String>,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub report_flush_state: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub attach_reason: Option<String>,
165 #[schemars(with = "Option<String>")]
166 pub thread_mode: Option<ThreadMode>,
167 #[schemars(with = "Option<String>")]
168 pub thread_state: Option<ThreadState>,
169 #[schemars(with = "Option<String>")]
170 pub freshness: Option<ThreadFreshness>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub target_thread: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub parent_thread: Option<String>,
175 pub child_threads: Vec<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub task: Option<String>,
178 pub promotion_suggested: bool,
179 #[schemars(with = "Vec<String>")]
180 pub impact_categories: Vec<ThreadImpactCategory>,
181 pub heavy_impact_paths: Vec<String>,
182 #[serde(skip)]
183 #[schemars(skip)]
184 pub changed_paths: Vec<String>,
185 pub changed_path_count: usize,
186 pub worktree_changed_path_count: usize,
187 pub thread_changed_path_count: usize,
188 pub blockers: Vec<String>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub identity_notice: Option<String>,
191 #[serde(serialize_with = "serialize_empty_action_as_null")]
192 #[schemars(with = "Option<String>")]
193 pub recommended_action: String,
194 pub recommended_action_template: Option<ActionTemplate>,
195 pub recovery_commands: Vec<String>,
196 pub recovery_action_templates: Vec<ActionTemplate>,
197 pub thread_health: String,
198 pub coordination_status: CoordinationStatus,
199 #[serde(skip)]
200 #[schemars(skip)]
201 pub coordination_blocked_by_trust: bool,
202 pub is_isolated: bool,
203 pub parallel_threads: Vec<ParallelThreadInfo>,
204 pub state: Option<StateInfo>,
205 pub git_checkpoint: Option<GitCheckpointInfo>,
206 pub changes: ChangesInfo,
207 #[serde(default)]
208 pub materialized_threads: Vec<MaterializedThreadInfo>,
209 #[serde(skip)]
210 #[schemars(skip)]
211 pub profile: StatusProfile,
212}
213
214impl StatusReport {
215 pub const CONTRACT: ReportContract = ReportContract {
216 schema_name: "status",
217 machine_output_kind: MachineOutputKind::JsonOrJsonLines,
218 output_discriminator: Some(OutputDiscriminator {
219 field: "output_kind",
220 value: "status",
221 }),
222 schema: status_report_schema,
223 };
224}
225
226impl HeddleReport for StatusReport {
227 const CONTRACT: ReportContract = StatusReport::CONTRACT;
228}
229
230fn status_report_schema() -> Value {
231 let mut schema = schema_for_report::<StatusReport>();
232 require_schema_field(&mut schema, "recommended_action");
233 replace_property_schema(
234 &mut schema,
235 "thread_mode",
236 serde_json::json!({
237 "anyOf": [
238 {
239 "type": "string",
240 "enum": ["materialized", "virtualized", "solid"]
241 },
242 { "type": "null" }
243 ]
244 }),
245 );
246 schema
247}
248
249fn require_schema_field(schema: &mut Value, field: &str) {
250 let Some(object) = schema.as_object_mut() else {
251 return;
252 };
253 let required = object
254 .entry("required".to_string())
255 .or_insert_with(|| serde_json::json!([]));
256 let Some(required) = required.as_array_mut() else {
257 return;
258 };
259 if !required
260 .iter()
261 .any(|candidate| candidate.as_str() == Some(field))
262 {
263 required.push(Value::String(field.to_string()));
264 }
265}
266
267fn replace_property_schema(schema: &mut Value, field: &str, replacement: Value) {
268 let Some(properties) = schema
269 .get_mut("properties")
270 .and_then(|properties| properties.as_object_mut())
271 else {
272 return;
273 };
274 properties.insert(field.to_string(), replacement);
275}
276
277#[derive(Debug, Clone, Default)]
278pub struct StatusProfile {
279 pub repo_open_ms: u128,
280 pub current_state_ms: u128,
281 pub operation_ms: u128,
282 pub remote_tracking_ms: u128,
283 pub import_hint_ms: u128,
284 pub git_overlay_status_ms: u128,
285 pub verification_ms: u128,
286 pub git_index_ms: u128,
287 pub worktree_status_ms: u128,
288 pub thread_summary_ms: u128,
289 pub parallel_threads_ms: u128,
290 pub late_state_ms: u128,
291 pub materialized_threads_ms: u128,
292 pub advice_ms: u128,
293 pub build_total_ms: u128,
294 pub worktree_profile: Option<WorktreeCompareProfile>,
295}
296
297#[derive(Debug, Clone, Serialize, JsonSchema)]
298pub struct RepositoryVerificationHealth {
299 pub status: String,
300 pub clean: bool,
301 pub summary: String,
302 pub recovery_commands: Vec<String>,
303 pub checks: Vec<RepositoryVerificationCheck>,
304}
305
306#[derive(Debug, Clone, Serialize, JsonSchema)]
307pub struct RepositoryVerificationCheck {
308 pub name: String,
309 pub status: String,
310 pub summary: String,
311 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
312 pub details: std::collections::BTreeMap<String, String>,
313}
314
315pub fn build_repository_verification_health_with_worktree_status(
316 repo: &Repository,
317 worktree_status: &Result<Option<WorktreeStatus>>,
318) -> RepositoryVerificationHealth {
319 let source_actions = SourceAuthorityActions::new(repo.source_authority());
320 if repo.capability() != RepositoryCapability::GitOverlay {
321 match repo.operation_status() {
329 Ok(Some(operation)) => {
330 return RepositoryVerificationHealth {
331 status: "operation_in_progress".to_string(),
332 clean: false,
333 summary: operation.message.clone(),
334 recovery_commands: vec![operation.next_action.clone()],
335 checks: vec![RepositoryVerificationCheck {
336 name: "operation".to_string(),
337 status: "operation_in_progress".to_string(),
338 summary: operation.message,
339 details: Default::default(),
340 }],
341 };
342 }
343 Ok(None) => {}
344 Err(error) => {
345 return degraded_health(
346 vec![RepositoryVerificationCheck {
347 name: "operation".to_string(),
348 status: "degraded".to_string(),
349 summary: error.to_string(),
350 details: Default::default(),
351 }],
352 "Could not inspect in-progress operations",
353 );
354 }
355 }
356 let computed_native_status;
364 let effective_status: &Result<Option<WorktreeStatus>> = match worktree_status {
365 Ok(Some(_)) | Err(_) => worktree_status,
366 Ok(None) => {
367 computed_native_status = native_worktree_status(repo);
368 &computed_native_status
369 }
370 };
371 return match effective_status {
372 Ok(Some(status)) if !status.is_clean() => {
373 let changed = status.modified.len() + status.added.len() + status.deleted.len();
374 let summary = format!(
375 "{changed} Heddle worktree path(s) are not captured in the current state"
376 );
377 RepositoryVerificationHealth {
378 status: "uncaptured".to_string(),
379 clean: false,
380 summary: summary.clone(),
381 recovery_commands: vec![source_actions.display(SourceAction::Capture)],
382 checks: vec![RepositoryVerificationCheck {
383 name: "heddle_worktree".to_string(),
384 status: "uncaptured".to_string(),
385 summary,
386 details: dirty_details(status),
387 }],
388 }
389 }
390 Ok(_) => clean_health(
391 "Heddle-native repository is verified in non-overlay mode",
392 vec![RepositoryVerificationCheck {
393 name: "heddle_worktree".to_string(),
394 status: "clean".to_string(),
395 summary: "Heddle worktree matches the current state".to_string(),
396 details: Default::default(),
397 }],
398 ),
399 Err(error) => degraded_health(
400 vec![RepositoryVerificationCheck {
401 name: "heddle_worktree".to_string(),
402 status: "degraded".to_string(),
403 summary: error.to_string(),
404 details: Default::default(),
405 }],
406 "Could not inspect Heddle worktree status",
407 ),
408 };
409 }
410 if repo.root().join(".heddle/objectstore").is_file() && !repo.root().join(".git").exists() {
411 return clean_health(
412 "Heddle-managed isolated checkout; Git verification belongs to the parent checkout",
413 vec![RepositoryVerificationCheck {
414 name: "worktree".to_string(),
415 status: "clean".to_string(),
416 summary: "No .git directory is present in this isolated checkout".to_string(),
417 details: BTreeMap::new(),
418 }],
419 );
420 }
421
422 let mut checks = Vec::new();
423 match repo.operation_status() {
424 Ok(Some(operation)) => {
425 checks.push(RepositoryVerificationCheck {
426 name: "operation".to_string(),
427 status: "operation_in_progress".to_string(),
428 summary: operation.message.clone(),
429 details: Default::default(),
430 });
431 return RepositoryVerificationHealth {
432 status: "operation_in_progress".to_string(),
433 clean: false,
434 summary: operation.message,
435 recovery_commands: vec![operation.next_action],
436 checks,
437 };
438 }
439 Ok(None) => checks.push(RepositoryVerificationCheck {
440 name: "operation".to_string(),
441 status: "clean".to_string(),
442 summary: "no Git or Heddle operation in progress".to_string(),
443 details: Default::default(),
444 }),
445 Err(error) => {
446 checks.push(RepositoryVerificationCheck {
447 name: "operation".to_string(),
448 status: "degraded".to_string(),
449 summary: error.to_string(),
450 details: Default::default(),
451 });
452 return degraded_health(checks, "Could not inspect in-progress operations");
453 }
454 }
455
456 match repo.git_overlay_head_is_detached() {
457 Ok(true) => {
458 let mut details = BTreeMap::new();
459 if let Ok(Some(commit)) = repo.git_overlay_detached_head_commit() {
460 details.insert("git_commit".to_string(), commit);
461 }
462 checks.push(RepositoryVerificationCheck {
463 name: "head_mapping".to_string(),
464 status: "detached_head".to_string(),
465 summary: "Git HEAD is detached; attach a branch before mutating this Git overlay"
466 .to_string(),
467 details,
468 });
469 return RepositoryVerificationHealth {
470 status: "detached_head".to_string(),
471 clean: false,
472 summary: "Git HEAD is detached; attach a branch before mutating this Git overlay"
473 .to_string(),
474 recovery_commands: detached_head_recovery_commands(repo),
475 checks,
476 };
477 }
478 Ok(false) => {}
479 Err(error) => {
480 checks.push(RepositoryVerificationCheck {
481 name: "head_mapping".to_string(),
482 status: "degraded".to_string(),
483 summary: error.to_string(),
484 details: Default::default(),
485 });
486 return degraded_health(checks, "Could not inspect Git HEAD state");
487 }
488 }
489
490 let import_hint = match repo.git_import_guidance() {
491 Ok(hint) => hint,
492 Err(error) => {
493 checks.push(RepositoryVerificationCheck {
494 name: "import".to_string(),
495 status: "degraded".to_string(),
496 summary: error.to_string(),
497 details: BTreeMap::new(),
498 });
499 return degraded_health(checks, "Could not inspect Git import state");
500 }
501 };
502
503 match current_branch_tip(repo) {
504 Ok(Some(tip))
505 if !tip.history_imported
506 && repo.current_state().ok().flatten().is_some()
507 && import_hint
508 .as_ref()
509 .is_some_and(import_guidance_includes_active_branch) =>
510 {
511 let out_of_band = repo
512 .git_overlay_out_of_band_commits(&tip.git_commit)
513 .ok()
514 .flatten();
515 let out_of_band_clause = out_of_band_commit_clause(out_of_band.as_ref());
516 let mut details = BTreeMap::new();
517 details.insert("git_branch".to_string(), tip.branch.clone());
518 details.insert("git_commit".to_string(), tip.git_commit.clone());
519 if let Some(out_of_band) = &out_of_band {
520 details.insert(
521 "out_of_band_commit_count".to_string(),
522 out_of_band.count.to_string(),
523 );
524 if out_of_band.truncated {
525 details.insert(
526 "out_of_band_commit_count_truncated".to_string(),
527 "true".to_string(),
528 );
529 }
530 }
531 checks.push(RepositoryVerificationCheck {
532 name: "head_mapping".to_string(),
533 status: "git_branch_advanced".to_string(),
534 summary: format!(
535 "Git branch '{}' advanced to commit {} outside Heddle{}",
536 tip.branch, tip.git_commit, out_of_band_clause
537 ),
538 details,
539 });
540 if let Some(hint) = &import_hint
541 && import_guidance_includes_active_branch(hint)
542 {
543 checks.push(RepositoryVerificationCheck {
544 name: "import".to_string(),
545 status: "needs_import".to_string(),
546 summary: format!(
547 "{} Git branch tip(s) still need Heddle import",
548 hint.missing_branch_count
549 ),
550 details: BTreeMap::new(),
551 });
552 }
553 return RepositoryVerificationHealth {
554 status: "git_branch_advanced".to_string(),
555 clean: false,
556 summary: format!(
557 "Git branch '{}' advanced outside Heddle{}; import the new Git tip to restore the mapping",
558 tip.branch, out_of_band_clause
559 ),
560 recovery_commands: vec![canonical_git_import_ref_command(&tip.branch)],
561 checks,
562 };
563 }
564 Ok(Some(tip)) if !tip.history_imported => checks.push(RepositoryVerificationCheck {
565 name: "head_mapping".to_string(),
566 status: "git_backed".to_string(),
567 summary: format!(
568 "Git branch '{}' resolves directly to Git commit {}",
569 tip.branch,
570 short_oid(&tip.git_commit)
571 ),
572 details: BTreeMap::from([
573 ("git_branch".to_string(), tip.branch),
574 ("git_commit".to_string(), tip.git_commit),
575 ]),
576 }),
577 Ok(Some(tip)) => checks.push(RepositoryVerificationCheck {
578 name: "head_mapping".to_string(),
579 status: "clean".to_string(),
580 summary: format!("Git branch '{}' maps to imported Heddle state", tip.branch),
581 details: BTreeMap::new(),
582 }),
583 Ok(None) => checks.push(RepositoryVerificationCheck {
584 name: "head_mapping".to_string(),
585 status: "clean".to_string(),
586 summary: "No attached Git branch to map".to_string(),
587 details: BTreeMap::new(),
588 }),
589 Err(error) => {
590 checks.push(RepositoryVerificationCheck {
591 name: "head_mapping".to_string(),
592 status: "degraded".to_string(),
593 summary: error.to_string(),
594 details: BTreeMap::new(),
595 });
596 return degraded_health(checks, "Could not inspect Git/Heddle branch mapping");
597 }
598 }
599
600 match import_hint {
601 Some(hint) if import_guidance_includes_active_branch(&hint) => {
602 return needs_import(checks, hint);
603 }
604 Some(hint) => checks.push(RepositoryVerificationCheck {
605 name: "import".to_string(),
606 status: "available".to_string(),
607 summary: format!(
608 "{} other Git branch tip(s) are available to import",
609 hint.missing_branch_count
610 ),
611 details: BTreeMap::new(),
612 }),
613 None => checks.push(RepositoryVerificationCheck {
614 name: "import".to_string(),
615 status: "clean".to_string(),
616 summary: "Git refs are read directly from Git storage".to_string(),
617 details: BTreeMap::new(),
618 }),
619 }
620
621 match worktree_status {
622 Ok(Some(status)) if !status.is_clean() => {
623 let changed = status.modified.len() + status.added.len() + status.deleted.len();
624 checks.push(RepositoryVerificationCheck {
625 name: "worktree".to_string(),
626 status: if heddle_worktree_is_clean(repo) {
627 "needs_checkpoint".to_string()
628 } else {
629 "dirty_worktree".to_string()
630 },
631 summary: if heddle_worktree_is_clean(repo) {
632 format!(
633 "{changed} Git worktree path(s) are captured in Heddle but not checkpointed to Git"
634 )
635 } else {
636 format!("{changed} Git worktree path(s) have uncommitted changes")
637 },
638 details: dirty_details(status),
639 });
640 if heddle_worktree_is_clean(repo) {
641 return RepositoryVerificationHealth {
642 status: "needs_checkpoint".to_string(),
643 clean: false,
644 summary: format!(
645 "{changed} Git worktree path(s) are captured in Heddle but not checkpointed to Git"
646 ),
647 recovery_commands: vec![source_actions.display(SourceAction::Commit)],
648 checks,
649 };
650 }
651 RepositoryVerificationHealth {
652 status: "dirty_worktree".to_string(),
653 clean: false,
654 summary: format!("{changed} Git worktree path(s) have uncommitted changes"),
655 recovery_commands: vec![
656 source_actions.display(SourceAction::Capture),
657 source_actions.display(SourceAction::Commit),
658 ],
659 checks,
660 }
661 }
662 Ok(_) => {
663 checks.push(RepositoryVerificationCheck {
664 name: "worktree".to_string(),
665 status: "clean".to_string(),
666 summary: "Git worktree is clean".to_string(),
667 details: Default::default(),
668 });
669 match clean_git_branch_reconcile_check(repo) {
670 Ok(Some(check)) => {
671 let status = check.status.clone();
672 let summary = check.summary.clone();
673 let ref_name = check
674 .details
675 .get("git_branch")
676 .cloned()
677 .unwrap_or_else(|| "<branch>".to_string());
678 let recovery = if status == "needs_checkpoint" {
679 source_actions.display(SourceAction::Commit)
680 } else {
681 canonical_git_repair_ref_preview_command(None, &ref_name)
682 };
683 checks.push(check);
684 return RepositoryVerificationHealth {
685 status,
686 clean: false,
687 summary,
688 recovery_commands: vec![recovery],
689 checks,
690 };
691 }
692 Ok(None) => {}
693 Err(error) => {
694 checks.push(RepositoryVerificationCheck {
695 name: "head_mapping".to_string(),
696 status: "degraded".to_string(),
697 summary: error.to_string(),
698 details: BTreeMap::new(),
699 });
700 return degraded_health(
701 checks,
702 "Could not inspect Git/Heddle branch agreement",
703 );
704 }
705 }
706 if !head_mapping_is_git_backed(&checks)
707 && let Ok(Some(state)) = repo.current_state()
708 && let Ok(tree) = repo.require_tree(&state.tree)
709 && let Ok(status) = repo.compare_worktree_cached_with_options(
710 &tree,
711 &core_worktree_status_options(repo),
712 )
713 && !status.is_clean()
714 {
715 let changed = status.modified.len() + status.added.len() + status.deleted.len();
716 checks.push(RepositoryVerificationCheck {
717 name: "heddle_worktree".to_string(),
718 status: "dirty_worktree".to_string(),
719 summary: format!(
720 "{changed} Heddle worktree path(s) differ from the current state"
721 ),
722 details: dirty_details(&status),
723 });
724 return RepositoryVerificationHealth {
725 status: "dirty_worktree".to_string(),
726 clean: false,
727 summary: format!(
728 "{changed} Heddle worktree path(s) differ from the current state"
729 ),
730 recovery_commands: vec![source_actions.display(SourceAction::Capture)],
731 checks,
732 };
733 }
734 match tag_mapping_check(repo) {
735 Ok(Some(check)) => {
736 let summary = check.summary.clone();
737 let recovery_commands = tag_mapping_recovery_commands(&check);
738 checks.push(check);
739 return RepositoryVerificationHealth {
740 status: "tag_marker_mismatch".to_string(),
741 clean: false,
742 summary,
743 recovery_commands,
744 checks,
745 };
746 }
747 Ok(None) => checks.push(RepositoryVerificationCheck {
748 name: "tag_mapping".to_string(),
749 status: "clean".to_string(),
750 summary: "Git tags visible to this checkout map to Heddle markers".to_string(),
751 details: Default::default(),
752 }),
753 Err(error) => {
754 checks.push(RepositoryVerificationCheck {
755 name: "tag_mapping".to_string(),
756 status: "degraded".to_string(),
757 summary: error.to_string(),
758 details: Default::default(),
759 });
760 return degraded_health(checks, "Could not inspect Git tag mapping");
761 }
762 }
763 match stale_integration_metadata_check(repo) {
764 Ok(Some(check)) => {
765 let summary = check.summary.clone();
766 checks.push(check);
767 return RepositoryVerificationHealth {
768 status: "stale_integration_metadata".to_string(),
769 clean: false,
770 summary,
771 recovery_commands: vec!["heddle thread list".to_string()],
772 checks,
773 };
774 }
775 Ok(None) => checks.push(RepositoryVerificationCheck {
776 name: "thread_integration_metadata".to_string(),
777 status: "clean".to_string(),
778 summary: "merged thread metadata agrees with target history".to_string(),
779 details: BTreeMap::new(),
780 }),
781 Err(error) => {
782 checks.push(RepositoryVerificationCheck {
783 name: "thread_integration_metadata".to_string(),
784 status: "degraded".to_string(),
785 summary: error.to_string(),
786 details: BTreeMap::new(),
787 });
788 return degraded_health(
789 checks,
790 "Could not inspect thread integration metadata",
791 );
792 }
793 }
794 match repo.git_remote_tracking_status() {
795 Ok(Some(remote)) => remote_drift_health(repo, checks, remote),
796 Ok(None) => {
797 checks.push(RepositoryVerificationCheck {
798 name: "remote_tracking".to_string(),
799 status: "clean".to_string(),
800 summary: "No Git upstream drift detected".to_string(),
801 details: Default::default(),
802 });
803 clean_health("Git overlay and Heddle agree", checks)
804 }
805 Err(error) => {
806 checks.push(RepositoryVerificationCheck {
807 name: "remote_tracking".to_string(),
808 status: "degraded".to_string(),
809 summary: error.to_string(),
810 details: Default::default(),
811 });
812 degraded_health(checks, "Could not inspect Git upstream drift")
813 }
814 }
815 }
816 Err(error) => {
817 checks.push(RepositoryVerificationCheck {
818 name: "worktree".to_string(),
819 status: "degraded".to_string(),
820 summary: error.to_string(),
821 details: Default::default(),
822 });
823 degraded_health(checks, "Could not inspect Git overlay worktree")
824 }
825 }
826}
827
828fn needs_import(
829 mut checks: Vec<RepositoryVerificationCheck>,
830 hint: GitImportGuidance,
831) -> RepositoryVerificationHealth {
832 checks.push(RepositoryVerificationCheck {
833 name: "import".to_string(),
834 status: "needs_import".to_string(),
835 summary: format!(
836 "{} Git branch tip(s) still need Heddle import",
837 hint.missing_branch_count
838 ),
839 details: BTreeMap::new(),
840 });
841 RepositoryVerificationHealth {
842 status: "needs_import".to_string(),
843 clean: false,
844 summary: format!(
845 "{} Git branch tip(s) still need Heddle import",
846 hint.missing_branch_count
847 ),
848 recovery_commands: vec![hint.recommended_command],
849 checks,
850 }
851}
852
853fn tag_mapping_check(repo: &Repository) -> anyhow::Result<Option<RepositoryVerificationCheck>> {
854 let mut mismatched = Vec::new();
855 for tip in repo.git_overlay_tag_tips()? {
856 let marker = repo
857 .refs()
858 .get_marker(&objects::object::MarkerName::new(&tip.tag))?;
859 match (marker, tip.mapped_state) {
860 (Some(existing), Some(mapped)) if existing == mapped => {}
861 (Some(existing), Some(mapped)) => mismatched.push(format!(
862 "{} (marker {}; Git tag {})",
863 tip.tag,
864 existing.short(),
865 mapped.short()
866 )),
867 (Some(_), None) | (None, _) => {}
868 }
869 }
870 if mismatched.is_empty() {
871 return Ok(None);
872 }
873 let mut details = BTreeMap::new();
874 details.insert(
875 "mismatched_tag_count".to_string(),
876 mismatched.len().to_string(),
877 );
878 details.insert("mismatched_tags".to_string(), mismatched.join(", "));
879 Ok(Some(RepositoryVerificationCheck {
880 name: "tag_mapping".to_string(),
881 status: "tag_marker_mismatch".to_string(),
882 summary: format!(
883 "{} Git tag marker(s) disagree with Heddle markers: {}",
884 mismatched.len(),
885 mismatched.join(", ")
886 ),
887 details,
888 }))
889}
890
891fn tag_mapping_recovery_commands(check: &RepositoryVerificationCheck) -> Vec<String> {
892 let tags = check
893 .details
894 .get("mismatched_tags")
895 .map(|tags| {
896 tags.split(',')
897 .filter_map(|tag| tag.split_whitespace().next())
898 .filter(|tag| !tag.is_empty())
899 .map(ToString::to_string)
900 .collect::<Vec<_>>()
901 })
902 .unwrap_or_default();
903 if tags.len() == 1 {
904 vec![canonical_git_import_ref_command(&tags[0])]
905 } else {
906 vec!["heddle import git".to_string()]
907 }
908}
909
910fn short_oid(oid: &str) -> &str {
911 oid.get(..12).unwrap_or(oid)
912}
913
914fn current_branch_tip(repo: &Repository) -> anyhow::Result<Option<GitOverlayBranchTip>> {
915 let Some(branch) = repo.git_overlay_current_branch()? else {
916 return Ok(None);
917 };
918 repo.git_overlay_branch_tip(&branch).map_err(Into::into)
919}
920
921fn detached_head_recovery_commands(repo: &Repository) -> Vec<String> {
922 vec![detached_head_primary_recovery(repo)]
923}
924
925fn detached_head_primary_recovery(repo: &Repository) -> String {
926 match repo.refs().read_head() {
927 Ok(Head::Attached { thread }) if !thread.trim().is_empty() => {
928 return if thread.starts_with('-') {
929 heddle_action(["thread", "switch", "--", thread.as_str()])
930 } else {
931 heddle_action(["thread", "switch", thread.as_str()])
932 };
933 }
934 _ => {}
935 }
936 if let Ok(Some(detached_commit)) = repo.git_overlay_detached_head_commit()
937 && let Ok(branch_tips) = repo.git_overlay_branch_tips()
938 && let Some(tip) = branch_tips
939 .iter()
940 .filter(|tip| tip.history_imported)
941 .find(|tip| tip.git_commit == detached_commit)
942 {
943 return heddle_action(["thread", "switch", tip.branch.as_str()]);
944 }
945 "heddle thread switch <branch>".to_string()
946}
947
948fn branch_tip_needs_reconcile(repo: &Repository, tip: &GitOverlayBranchTip) -> bool {
949 let Some(mapped) = tip.mapped_state else {
950 return false;
951 };
952 let Ok(Some(current)) = thread_tip_for_branch(repo, &tip.branch) else {
953 return false;
954 };
955 mapped != current
956}
957
958fn clean_git_branch_reconcile_check(
959 repo: &Repository,
960) -> anyhow::Result<Option<RepositoryVerificationCheck>> {
961 let Some(tip) = current_branch_tip(repo)? else {
962 return Ok(None);
963 };
964 if !tip.history_imported || !branch_tip_needs_reconcile(repo, &tip) {
965 return Ok(None);
966 }
967 let Some(current_change) = thread_tip_for_branch(repo, &tip.branch)? else {
968 return Ok(None);
969 };
970 let Some(mapped) = tip.mapped_state else {
971 return Ok(None);
972 };
973 let relation = mapped_change_relation(repo, &mapped, ¤t_change);
974 if relation == "git_behind_heddle"
975 && repo
976 .latest_git_checkpoint_for_state(¤t_change)?
977 .is_none()
978 && heddle_worktree_is_clean(repo)
979 {
980 let mut details = dirty_details(&WorktreeStatus::default());
981 details.insert("git_branch".to_string(), tip.branch.clone());
982 details.insert("git_commit".to_string(), tip.git_commit.clone());
983 details.insert("git_mapped_state".to_string(), mapped.to_string());
984 details.insert(
985 "heddle_thread_state".to_string(),
986 current_change.to_string(),
987 );
988 details.insert("relation".to_string(), relation.to_string());
989 return Ok(Some(RepositoryVerificationCheck {
990 name: "worktree".to_string(),
991 status: "needs_checkpoint".to_string(),
992 summary: format!(
993 "Heddle state {} is captured but not checkpointed to Git",
994 current_change.short()
995 ),
996 details,
997 }));
998 }
999 let mut details = BTreeMap::new();
1000 details.insert("git_branch".to_string(), tip.branch.clone());
1001 details.insert("git_commit".to_string(), tip.git_commit.clone());
1002 details.insert("git_mapped_state".to_string(), mapped.to_string());
1003 details.insert(
1004 "heddle_thread_state".to_string(),
1005 current_change.to_string(),
1006 );
1007 details.insert("relation".to_string(), relation.to_string());
1008 Ok(Some(RepositoryVerificationCheck {
1009 name: "head_mapping".to_string(),
1010 status: "needs_reconcile".to_string(),
1011 summary: format!(
1012 "Git branch '{}' points at {}, but Heddle thread state is {}; preview the Git/Heddle mapping before saving new work",
1013 tip.branch,
1014 mapped.short(),
1015 current_change.short()
1016 ),
1017 details,
1018 }))
1019}
1020
1021fn thread_tip_for_branch(
1022 repo: &Repository,
1023 branch: &str,
1024) -> Result<Option<objects::object::StateId>> {
1025 repo.refs().get_thread(&ThreadName::new(branch))
1026}
1027
1028fn mapped_change_relation(
1029 repo: &Repository,
1030 git_mapped: &objects::object::StateId,
1031 heddle_current: &objects::object::StateId,
1032) -> &'static str {
1033 let mut graph = CommitGraphIndex::new(repo);
1034 let git_is_ancestor = graph
1035 .is_ancestor(git_mapped, heddle_current)
1036 .unwrap_or(false);
1037 let heddle_is_ancestor = graph
1038 .is_ancestor(heddle_current, git_mapped)
1039 .unwrap_or(false);
1040 match (git_is_ancestor, heddle_is_ancestor) {
1041 (true, false) => "git_behind_heddle",
1042 (false, true) => "git_ahead_of_heddle",
1043 (true, true) => "same",
1044 (false, false) => "diverged",
1045 }
1046}
1047
1048fn head_mapping_is_git_backed(checks: &[RepositoryVerificationCheck]) -> bool {
1049 checks
1050 .iter()
1051 .any(|check| check.name == "head_mapping" && check.status == "git_backed")
1052}
1053
1054fn stale_integration_metadata_check(
1055 repo: &Repository,
1056) -> anyhow::Result<Option<RepositoryVerificationCheck>> {
1057 let manager = ThreadManager::new(repo.heddle_dir());
1058 let mut stale = Vec::new();
1059 let mut graph = CommitGraphIndex::new(repo);
1060
1061 for thread in manager.list()? {
1062 if thread.state != ThreadState::Merged {
1063 continue;
1064 }
1065 let Some(target_thread) = thread.target_thread.as_deref() else {
1066 continue;
1067 };
1068 let Some(target_tip) = repo.refs().get_thread(&ThreadName::new(target_thread))? else {
1069 continue;
1070 };
1071 let candidate = thread
1072 .current_state
1073 .as_deref()
1074 .or(thread.merged_state.as_deref())
1075 .and_then(|state| repo.resolve_state(state).ok().flatten())
1076 .or_else(|| {
1077 repo.refs()
1078 .get_thread(&ThreadName::new(&thread.thread))
1079 .ok()
1080 .flatten()
1081 });
1082 let Some(candidate) = candidate else {
1083 continue;
1084 };
1085 if !graph.is_ancestor(&candidate, &target_tip).unwrap_or(false) {
1086 stale.push(format!(
1087 "{} claims merged into {} at {}, but target is {}",
1088 thread.thread,
1089 target_thread,
1090 candidate.short(),
1091 target_tip.short()
1092 ));
1093 }
1094 }
1095
1096 if stale.is_empty() {
1097 return Ok(None);
1098 }
1099
1100 let mut details = BTreeMap::new();
1101 details.insert("stale_thread_count".to_string(), stale.len().to_string());
1102 details.insert("stale_threads".to_string(), stale.join("; "));
1103 Ok(Some(RepositoryVerificationCheck {
1104 name: "thread_integration_metadata".to_string(),
1105 status: "stale_integration_metadata".to_string(),
1106 summary: format!(
1107 "{} merged thread record(s) are no longer contained in their target history",
1108 stale.len()
1109 ),
1110 details,
1111 }))
1112}
1113
1114fn out_of_band_commit_clause(out_of_band: Option<&GitOverlayOutOfBandCommits>) -> String {
1115 match out_of_band {
1116 Some(out_of_band) if out_of_band.truncated => {
1117 format!(" ({}+ out-of-band git commits detected)", out_of_band.count)
1118 }
1119 Some(out_of_band) if out_of_band.count == 1 => {
1120 " (1 out-of-band git commit detected)".to_string()
1121 }
1122 Some(out_of_band) => format!(" ({} out-of-band git commits detected)", out_of_band.count),
1123 None => String::new(),
1124 }
1125}
1126
1127fn core_worktree_status_options(repo: &Repository) -> repo::WorktreeStatusOptions {
1128 repo::WorktreeStatusOptions {
1129 fsmonitor: repo.config().worktree.fsmonitor.into(),
1130 }
1131}
1132
1133fn native_worktree_status(repo: &Repository) -> Result<Option<WorktreeStatus>> {
1138 let Some(state) = repo.current_state()? else {
1139 return Ok(Some(WorktreeStatus::default()));
1140 };
1141 let tree = repo.require_tree(&state.tree)?;
1142 repo.compare_worktree_cached_with_options(&tree, &core_worktree_status_options(repo))
1143 .map(Some)
1144}
1145
1146pub fn default_remote_name(repo: &Repository) -> Option<String> {
1147 crate::remote::resolved_default_remote_name(repo)
1148 .ok()
1149 .flatten()
1150}
1151
1152pub(crate) fn git_default_remote_name_from_repo(repo: &SleyRepository) -> Option<String> {
1153 let remotes = repo.remote_names().ok()?;
1154 remotes
1155 .iter()
1156 .find(|name| name.as_str() == "origin")
1157 .cloned()
1158 .or_else(|| (remotes.len() == 1).then(|| remotes[0].clone()))
1159}
1160
1161fn heddle_worktree_is_clean(repo: &Repository) -> bool {
1162 let Ok(Some(state)) = repo.current_state() else {
1163 return false;
1164 };
1165 let Ok(tree) = repo.require_tree(&state.tree) else {
1166 return false;
1167 };
1168 repo.compare_worktree_cached_with_options(&tree, &core_worktree_status_options(repo))
1169 .map(|status| status.is_clean())
1170 .unwrap_or(false)
1171}
1172
1173fn remote_drift_health(
1174 repo: &Repository,
1175 mut checks: Vec<RepositoryVerificationCheck>,
1176 remote: GitRemoteTrackingStatus,
1177) -> RepositoryVerificationHealth {
1178 let status = remote_tracking_status(&remote);
1179 let mut details = BTreeMap::new();
1180 details.insert("branch".to_string(), remote.branch.clone());
1181 details.insert("upstream".to_string(), remote.upstream.clone());
1182 details.insert("ahead".to_string(), remote.ahead.to_string());
1183 details.insert("behind".to_string(), remote.behind.to_string());
1184 if let Some(local_oid) = &remote.local_oid {
1185 details.insert("local_oid".to_string(), local_oid.clone());
1186 }
1187 if let Some(upstream_oid) = &remote.upstream_oid {
1188 details.insert("upstream_oid".to_string(), upstream_oid.clone());
1189 }
1190 checks.push(RepositoryVerificationCheck {
1191 name: "remote_tracking".to_string(),
1192 status: status.to_string(),
1193 summary: remote.message.clone(),
1194 details,
1195 });
1196 let recovery_commands = remote_drift_recovery_commands(repo, &remote, status);
1197 if matches!(status, "clean" | "remote_ahead" | "remote_untracked") {
1198 return RepositoryVerificationHealth {
1199 status: "clean".to_string(),
1200 clean: true,
1201 summary: "Git overlay verified".to_string(),
1202 recovery_commands: Vec::new(),
1203 checks,
1204 };
1205 }
1206 RepositoryVerificationHealth {
1207 status: status.to_string(),
1208 clean: false,
1209 summary: remote.message,
1210 recovery_commands,
1211 checks,
1212 }
1213}
1214
1215fn remote_drift_recovery_commands(
1216 repo: &Repository,
1217 remote: &GitRemoteTrackingStatus,
1218 status: &str,
1219) -> Vec<String> {
1220 match status {
1221 "remote_behind" => vec!["heddle pull".to_string()],
1222 "remote_diverged" => {
1223 let upstream = remote.upstream.trim();
1224 if upstream.is_empty() {
1225 return vec!["heddle pull".to_string()];
1226 }
1227 let import = canonical_git_import_ref_command(upstream);
1228 let reconcile = canonical_git_repair_ref_preview_command(None, upstream);
1229 if upstream_thread_matches_current_git_tip(repo, upstream) {
1230 vec![reconcile]
1231 } else {
1232 vec![import, reconcile]
1233 }
1234 }
1235 "remote_contains_undone_checkpoint" => {
1236 vec![
1237 "heddle push --force-with-lease".to_string(),
1238 "heddle undo --redo".to_string(),
1239 ]
1240 }
1241 _ => crate::status::next_action::remote_tracking_next_action_for(
1242 remote,
1243 repo.source_authority(),
1244 )
1245 .into_iter()
1246 .collect(),
1247 }
1248}
1249
1250fn upstream_thread_matches_current_git_tip(repo: &Repository, upstream: &str) -> bool {
1251 let Some(thread_tip) = repo
1252 .refs()
1253 .get_thread(&ThreadName::new(upstream))
1254 .ok()
1255 .flatten()
1256 else {
1257 return false;
1258 };
1259 repo.git_overlay_mapped_state_for_branch(upstream)
1260 .or(Ok(None))
1261 .and_then(|mapped| {
1262 if mapped.is_some() {
1263 Ok(mapped)
1264 } else {
1265 repo.git_overlay_mapped_state_for_remote_tracking_ref(upstream)
1266 }
1267 })
1268 .ok()
1269 .flatten()
1270 .is_some_and(|mapped_tip| mapped_tip == thread_tip)
1271}
1272
1273fn clean_health(
1274 summary: impl Into<String>,
1275 checks: Vec<RepositoryVerificationCheck>,
1276) -> RepositoryVerificationHealth {
1277 RepositoryVerificationHealth {
1278 status: "clean".to_string(),
1279 clean: true,
1280 summary: summary.into(),
1281 recovery_commands: Vec::new(),
1282 checks,
1283 }
1284}
1285
1286fn degraded_health(
1287 checks: Vec<RepositoryVerificationCheck>,
1288 summary: &str,
1289) -> RepositoryVerificationHealth {
1290 RepositoryVerificationHealth {
1291 status: "degraded".to_string(),
1292 clean: false,
1293 summary: summary.to_string(),
1294 recovery_commands: vec!["heddle doctor".to_string()],
1295 checks,
1296 }
1297}
1298
1299fn dirty_details(status: &WorktreeStatus) -> std::collections::BTreeMap<String, String> {
1300 let mut details = std::collections::BTreeMap::new();
1301 let count = status.modified.len() + status.added.len() + status.deleted.len();
1302 details.insert("dirty_path_count".to_string(), count.to_string());
1303 let mut paths = status
1304 .modified
1305 .iter()
1306 .chain(status.added.iter())
1307 .chain(status.deleted.iter())
1308 .map(|path| path.display().to_string())
1309 .collect::<Vec<_>>();
1310 paths.sort();
1311 if !paths.is_empty() {
1312 details.insert("dirty_paths".to_string(), paths.join(", "));
1313 }
1314 details
1315}
1316
1317fn import_guidance_includes_active_branch(hint: &GitImportGuidance) -> bool {
1318 hint.missing_branches
1319 .iter()
1320 .any(|branch| branch == &hint.current_branch)
1321}
1322
1323#[derive(Debug, Clone, Serialize, JsonSchema)]
1324pub struct GitImportGuidanceReport {
1325 pub current_branch: String,
1326 pub missing_branch_count: usize,
1327 pub missing_branches: Vec<String>,
1328 pub recommended_command: String,
1329}
1330
1331impl From<GitImportGuidance> for GitImportGuidanceReport {
1332 fn from(hint: GitImportGuidance) -> Self {
1333 Self {
1334 current_branch: hint.current_branch,
1335 missing_branch_count: hint.missing_branch_count,
1336 missing_branches: hint.missing_branches,
1337 recommended_command: hint.recommended_command,
1338 }
1339 }
1340}
1341
1342#[derive(Debug, Clone, Serialize, JsonSchema)]
1343pub struct GitIndexPlan {
1344 pub commit_mode: &'static str,
1345 pub has_staged_changes: bool,
1346 pub staged_paths: Vec<String>,
1347 pub unstaged_paths: Vec<String>,
1348 pub untracked_paths: Vec<String>,
1349 pub will_commit: Vec<String>,
1350 pub preserved_after_commit: Vec<String>,
1351}
1352
1353#[derive(Default)]
1354struct GitIndexIntent {
1355 staged_paths: Vec<String>,
1356 extra_paths: Vec<String>,
1357}
1358
1359impl GitIndexPlan {
1360 fn from_intent(intent: &GitIndexIntent) -> Self {
1361 let (unstaged_paths, untracked_paths) = split_extra_paths(&intent.extra_paths);
1362 let has_staged_changes = !intent.staged_paths.is_empty();
1363 let mut will_commit = Vec::new();
1364 if has_staged_changes {
1365 will_commit.extend(intent.staged_paths.iter().cloned());
1366 } else {
1367 will_commit.extend(unstaged_paths.iter().cloned());
1368 will_commit.extend(untracked_paths.iter().cloned());
1369 }
1370 let preserved_after_commit = if has_staged_changes {
1371 intent.extra_paths.clone()
1372 } else {
1373 Vec::new()
1374 };
1375 Self {
1376 commit_mode: if has_staged_changes {
1377 "staged_index"
1378 } else {
1379 "worktree"
1380 },
1381 has_staged_changes,
1382 staged_paths: intent.staged_paths.clone(),
1383 unstaged_paths,
1384 untracked_paths,
1385 will_commit,
1386 preserved_after_commit,
1387 }
1388 }
1389}
1390
1391const GIT_MODE_COMMIT: u32 = 0o160000;
1392
1393pub fn git_index_plan_for_repo(repo: &Repository) -> Result<Option<GitIndexPlan>> {
1394 let Some(status) = repo.git_overlay_short_status()? else {
1395 return Ok(None);
1396 };
1397 Ok(git_index_plan_from_short_status(&status))
1398}
1399
1400fn git_index_plan_from_short_status(status: &repo::GitOverlayShortStatus) -> Option<GitIndexPlan> {
1401 status.index_plan_applicable.then(|| {
1402 GitIndexPlan::from_intent(&GitIndexIntent {
1403 staged_paths: status.index_staged_paths.clone(),
1404 extra_paths: status.index_extra_paths.clone(),
1405 })
1406 })
1407}
1408
1409fn load_git_overlay_status_and_index_plan(
1410 repo: &Repository,
1411) -> (Result<Option<WorktreeStatus>>, Option<GitIndexPlan>) {
1412 match repo.git_overlay_short_status() {
1413 Ok(Some(status)) => {
1414 let index = git_index_plan_from_short_status(&status);
1415 (Ok(Some(status.worktree)), index)
1416 }
1417 Ok(None) => (Ok(None), None),
1418 Err(error) => (Err(error), None),
1419 }
1420}
1421
1422pub fn git_index_plan_for_root(root: &Path) -> Result<Option<GitIndexPlan>> {
1425 let git = match SleyRepository::discover(root) {
1426 Ok(git) => git,
1427 Err(_) => return Ok(None),
1428 };
1429 if !git_worktree_matches_root(&git, root) {
1430 return Ok(None);
1431 }
1432 let ignore_patterns = git_ignore_patterns_for_root(root, &git)?;
1433 Ok(Some(GitIndexPlan::from_intent(
1434 &git_index_intent_for_root_with_ignore_and_repo(root, &ignore_patterns, &git)?,
1435 )))
1436}
1437
1438fn git_ignore_patterns_for_root(root: &Path, git: &SleyRepository) -> Result<Vec<String>> {
1439 let mut patterns = Vec::new();
1440 append_ignore_file_patterns(&mut patterns, &root.join(".gitignore"))?;
1441 append_ignore_file_patterns(&mut patterns, &git.git_dir().join("info").join("exclude"))?;
1442 Ok(patterns)
1443}
1444
1445fn append_ignore_file_patterns(patterns: &mut Vec<String>, path: &Path) -> Result<()> {
1446 if !path.exists() {
1447 return Ok(());
1448 }
1449 let contents = fs::read_to_string(path).map_err(|err| {
1450 HeddleError::Config(format!(
1451 "failed to read ignore file {}: {err}",
1452 path.display()
1453 ))
1454 })?;
1455 for line in contents.lines() {
1456 let trimmed = line.trim();
1457 if trimmed.is_empty() || trimmed.starts_with('#') {
1458 continue;
1459 }
1460 if !patterns.iter().any(|pattern| pattern == trimmed) {
1461 patterns.push(trimmed.to_string());
1462 }
1463 }
1464 Ok(())
1465}
1466
1467fn git_worktree_matches_root(git: &SleyRepository, root: &Path) -> bool {
1468 git.workdir()
1469 .is_some_and(|workdir| paths_equal(&workdir, root))
1470}
1471
1472fn split_extra_paths(extra_paths: &[String]) -> (Vec<String>, Vec<String>) {
1473 let mut unstaged_paths = Vec::new();
1474 let mut untracked_paths = Vec::new();
1475 for path in extra_paths {
1476 if let Some(path) = path.strip_prefix("unstaged: ") {
1477 unstaged_paths.push(path.to_string());
1478 } else if let Some(path) = path.strip_prefix("untracked: ") {
1479 untracked_paths.push(path.to_string());
1480 }
1481 }
1482 (unstaged_paths, untracked_paths)
1483}
1484
1485fn git_index_intent_for_root_with_ignore_and_repo(
1486 root: &Path,
1487 ignore_patterns: &[String],
1488 git: &SleyRepository,
1489) -> Result<GitIndexIntent> {
1490 let ignore_matcher = build_worktree_ignore(ignore_patterns);
1491 let mut intent = GitIndexIntent::default();
1492 git.stream_short_status_with_options(
1493 ShortStatusOptions {
1494 untracked_mode: StatusUntrackedMode::All,
1495 ..ShortStatusOptions::default()
1496 },
1497 |entry| {
1498 append_status_row_to_index_intent(&mut intent, &ignore_matcher, entry);
1499 Ok(StreamControl::Continue)
1500 },
1501 )
1502 .map_err(|err| {
1503 HeddleError::Config(format!(
1504 "failed to inspect Git status before commit at {}: {err}",
1505 root.display()
1506 ))
1507 })?;
1508 Ok(intent)
1509}
1510
1511fn append_status_row_to_index_intent(
1512 intent: &mut GitIndexIntent,
1513 ignore_matcher: &objects::worktree::WorktreeIgnoreMatcher,
1514 entry: ShortStatusRow<'_>,
1515) {
1516 let path = String::from_utf8_lossy(entry.path).into_owned();
1517 if path.is_empty() {
1518 return;
1519 }
1520 if entry.index == b'?' && entry.worktree == b'?' {
1521 if !ignore_matcher.is_ignored(Path::new(&path)) {
1522 intent.extra_paths.push(format!("untracked: {path}"));
1523 }
1524 return;
1525 }
1526 if entry.index != b' ' && entry.index != b'!' {
1527 intent.staged_paths.push(path.clone());
1528 }
1529 if entry.worktree != b' '
1530 && entry.worktree != b'!'
1531 && !status_row_is_gitlink_worktree_only(entry)
1532 {
1533 intent.extra_paths.push(format!("unstaged: {path}"));
1534 }
1535}
1536
1537fn status_row_is_gitlink_worktree_only(entry: ShortStatusRow<'_>) -> bool {
1538 entry.index == b' '
1539 && (entry.index_mode == Some(GIT_MODE_COMMIT)
1540 || entry.head_mode == Some(GIT_MODE_COMMIT)
1541 || entry.worktree_mode == Some(GIT_MODE_COMMIT))
1542}
1543
1544#[derive(Debug, Clone, Serialize, JsonSchema)]
1545pub struct MaterializedThreadInfo {
1546 pub name: String,
1547 pub state_id: String,
1548 pub tree_hash_short: String,
1549 pub file_count: usize,
1550 pub stale: bool,
1551}
1552
1553#[derive(Debug, Clone, Serialize, JsonSchema)]
1554pub struct ActorInfo {
1555 #[serde(skip_serializing_if = "Option::is_none")]
1556 pub provider: Option<String>,
1557 #[serde(skip_serializing_if = "Option::is_none")]
1558 pub model: Option<String>,
1559}
1560
1561#[derive(Debug, Clone, Serialize, JsonSchema)]
1562pub struct ParallelThreadInfo {
1563 pub name: String,
1564 pub coordination_status: CoordinationStatus,
1565 pub current_state: Option<String>,
1566}
1567
1568#[derive(Debug, Clone, Serialize, JsonSchema)]
1569pub struct StateInfo {
1570 pub state_id: String,
1571 pub content_hash: String,
1572 pub intent: Option<String>,
1573}
1574
1575#[derive(Debug, Clone, Serialize, JsonSchema)]
1576pub struct GitCheckpointInfo {
1577 pub git_commit: String,
1578 pub committed_at: String,
1579}
1580
1581#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
1582pub struct ChangesInfo {
1583 pub modified: Vec<String>,
1584 pub added: Vec<String>,
1585 pub deleted: Vec<String>,
1586}
1587
1588impl ChangesInfo {
1589 pub fn is_empty(&self) -> bool {
1590 self.modified.is_empty() && self.added.is_empty() && self.deleted.is_empty()
1591 }
1592}
1593
1594#[derive(Debug, Clone, Copy, Serialize, JsonSchema, PartialEq, Eq)]
1595#[serde(rename_all = "kebab-case")]
1596pub enum CoordinationStatus {
1597 Clean,
1598 Ahead,
1599 Diverged,
1600 Blocked,
1601 MergeReady,
1602}
1603
1604impl std::fmt::Display for CoordinationStatus {
1605 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1606 match self {
1607 Self::Clean => write!(f, "clean"),
1608 Self::Ahead => write!(f, "ahead"),
1609 Self::Diverged => write!(f, "diverged"),
1610 Self::Blocked => write!(f, "blocked"),
1611 Self::MergeReady => write!(f, "merge-ready"),
1612 }
1613 }
1614}
1615
1616#[derive(Debug, Clone)]
1617pub struct StatusThreadSummary {
1618 pub name: String,
1619 pub base_state: Option<String>,
1620 pub base_root: Option<String>,
1621 pub current_state: Option<String>,
1622 pub path: Option<String>,
1623 pub execution_path: Option<String>,
1624 pub session_id: Option<String>,
1625 pub heddle_session_id: Option<String>,
1626 pub actor: Option<ActorInfo>,
1627 pub harness: Option<String>,
1628 pub thinking_level: Option<String>,
1629 pub usage_summary: Option<AgentUsageSummary>,
1630 pub last_progress_at: Option<String>,
1631 pub report_flush_state: Option<String>,
1632 pub attach_reason: Option<String>,
1633 pub thread_mode: Option<ThreadMode>,
1634 pub thread_state: Option<ThreadState>,
1635 pub freshness: Option<ThreadFreshness>,
1636 pub target_thread: Option<String>,
1637 pub parent_thread: Option<String>,
1638 pub child_threads: Vec<String>,
1639 pub task: Option<String>,
1640 pub promotion_suggested: bool,
1641 pub impact_categories: Vec<ThreadImpactCategory>,
1642 pub heavy_impact_paths: Vec<String>,
1643 pub changed_paths: Vec<String>,
1644 pub verification_summary: repo::ThreadVerificationSummary,
1645 pub confidence_summary: repo::ThreadConfidenceSummary,
1646 pub integration_policy_result: repo::ThreadIntegrationPolicy,
1647 pub coordination_status: CoordinationStatus,
1648 pub is_current: bool,
1649 pub is_isolated: bool,
1650}
1651
1652pub fn collect_thread_summaries(repo: &Repository) -> Result<Vec<StatusThreadSummary>> {
1653 let thread_refs = repo.refs().list_threads()?;
1654 let current = repo.current_lane()?;
1655 let manager = ThreadManager::new(repo.heddle_dir());
1656 let mut names: BTreeSet<String> = thread_refs.iter().map(ToString::to_string).collect();
1657 names.extend(current.iter().cloned());
1658 names.extend(manager.list()?.into_iter().map(|thread| thread.thread));
1659
1660 let registry_entries = ActorPresenceStore::new(repo.heddle_dir()).list()?;
1664
1665 let mut summaries = Vec::new();
1666 for name in names {
1667 if let Some(summary) = find_thread_summary_with_agents(repo, &name, ®istry_entries)? {
1668 summaries.push(summary);
1669 }
1670 }
1671 let mut children_by_parent = std::collections::BTreeMap::<String, Vec<String>>::new();
1672 for summary in &summaries {
1673 if let Some(parent) = &summary.parent_thread {
1674 children_by_parent
1675 .entry(parent.clone())
1676 .or_default()
1677 .push(summary.name.clone());
1678 }
1679 }
1680 for summary in &mut summaries {
1681 summary.child_threads = children_by_parent
1682 .remove(&summary.name)
1683 .map(|mut children| {
1684 children.sort();
1685 children
1686 })
1687 .unwrap_or_default();
1688 }
1689 summaries.sort_by(|a, b| a.name.cmp(&b.name));
1690 Ok(summaries)
1691}
1692
1693pub fn find_thread_summary_single(
1694 repo: &Repository,
1695 name: &str,
1696) -> Result<Option<StatusThreadSummary>> {
1697 let registry_entries = ActorPresenceStore::new(repo.heddle_dir()).list()?;
1698 find_thread_summary_with_agents(repo, name, ®istry_entries)
1699}
1700
1701fn find_thread_summary_with_agents(
1702 repo: &Repository,
1703 name: &str,
1704 registry_entries: &[ActorPresence],
1705) -> Result<Option<StatusThreadSummary>> {
1706 let current = repo.current_lane()?;
1707 let is_current = current.as_deref() == Some(name);
1708 let manager = ThreadManager::new(repo.heddle_dir());
1709 let thread = manager.find_by_thread(name)?;
1710 let ref_state = repo.refs().get_thread(&ThreadName::new(name))?;
1711 if thread.is_none()
1712 && ref_state.is_none()
1713 && !(is_current && repo.capability() == RepositoryCapability::GitOverlay)
1714 {
1715 return Ok(None);
1716 }
1717 let mut thread =
1718 thread.unwrap_or_else(|| synthetic_thread(repo, name, ref_state.map(|id| id.short())));
1719 let _ = refresh_thread_freshness(repo, &mut thread);
1720 let entries: Vec<&ActorPresence> = registry_entries
1721 .iter()
1722 .filter(|entry| entry.thread == name)
1723 .collect();
1724 Ok(Some(thread_summary_from_thread(
1725 repo,
1726 thread,
1727 is_current,
1728 primary_agent_entry_refs(&entries),
1729 )))
1730}
1731
1732fn synthetic_thread(repo: &Repository, name: &str, current_state: Option<String>) -> Thread {
1733 Thread {
1734 id: name.to_string(),
1735 thread: name.to_string(),
1736 target_thread: None,
1737 parent_thread: None,
1738 mode: ThreadMode::Materialized,
1739 state: ThreadState::Active,
1740 base_state: current_state.clone().unwrap_or_default(),
1741 base_root: String::new(),
1742 current_state,
1743 merged_state: None,
1744 task: None,
1745 execution_path: repo.root().to_path_buf(),
1746 materialized_path: None,
1747 changed_paths: Vec::new(),
1748 impact_categories: Vec::new(),
1749 heavy_impact_paths: Vec::new(),
1750 promotion_suggested: false,
1751 freshness: ThreadFreshness::Unknown,
1752 verification_summary: Default::default(),
1753 confidence_summary: Default::default(),
1754 integration_policy_result: Default::default(),
1755 created_at: Utc::now(),
1756 updated_at: Utc::now(),
1757 ephemeral: None,
1758 auto: false,
1759 shared_target_dir: None,
1760 }
1761}
1762
1763fn thread_summary_from_thread(
1764 repo: &Repository,
1765 thread: Thread,
1766 is_current: bool,
1767 primary: Option<&ActorPresence>,
1768) -> StatusThreadSummary {
1769 let thread_state = thread.state;
1770 let coordination_status = coordination_status_for_thread_state(&thread_state);
1771 let path = thread
1772 .materialized_path
1773 .as_ref()
1774 .map(|path| path.display().to_string())
1775 .or_else(|| {
1776 primary
1777 .and_then(|entry| entry.path.as_ref())
1778 .map(|path| path.display().to_string())
1779 });
1780 let execution_path = if thread.execution_path == repo.root() {
1781 None
1782 } else {
1783 Some(thread.execution_path.display().to_string())
1784 };
1785 let git_backed_tip = is_current
1786 && repo.capability() == RepositoryCapability::GitOverlay
1787 && thread.current_state.is_none();
1788 StatusThreadSummary {
1789 name: thread.thread,
1790 base_state: non_empty(thread.base_state),
1791 base_root: non_empty(thread.base_root),
1792 current_state: thread.current_state,
1793 path,
1794 execution_path,
1795 session_id: primary.map(|entry| entry.session_id.clone()),
1796 heddle_session_id: primary.and_then(|entry| entry.heddle_session_id.clone()),
1797 actor: primary.and_then(|entry| match (&entry.provider, &entry.model) {
1798 (None, None) => None,
1799 (provider, model) => Some(ActorInfo {
1800 provider: provider.clone(),
1801 model: model.clone(),
1802 }),
1803 }),
1804 harness: primary.and_then(|entry| entry.harness.clone()),
1805 thinking_level: primary.and_then(|entry| entry.thinking_level.clone()),
1806 usage_summary: primary.map(|entry| entry.usage_summary.clone()),
1807 last_progress_at: primary
1808 .and_then(|entry| entry.last_progress_at)
1809 .map(|time| time.to_rfc3339()),
1810 report_flush_state: primary.and_then(|entry| entry.report_flush_state.clone()),
1811 attach_reason: primary
1812 .and_then(|entry| entry.attach_reason.clone())
1813 .or_else(|| git_backed_tip.then(|| "using Git-backed branch tip".to_string())),
1814 thread_mode: Some(thread.mode),
1815 thread_state: Some(thread_state),
1816 freshness: Some(thread.freshness),
1817 target_thread: thread.target_thread,
1818 parent_thread: thread.parent_thread,
1819 child_threads: Vec::new(),
1820 task: thread.task,
1821 promotion_suggested: thread.promotion_suggested,
1822 impact_categories: thread.impact_categories,
1823 heavy_impact_paths: thread.heavy_impact_paths,
1824 changed_paths: thread.changed_paths,
1825 verification_summary: thread.verification_summary,
1826 confidence_summary: thread.confidence_summary,
1827 integration_policy_result: thread.integration_policy_result,
1828 coordination_status,
1829 is_current,
1830 is_isolated: thread.materialized_path.is_some(),
1831 }
1832}
1833
1834fn primary_agent_entry_refs<'a>(entries: &[&'a ActorPresence]) -> Option<&'a ActorPresence> {
1835 entries
1836 .iter()
1837 .copied()
1838 .filter(|entry| entry.status == ActorPresenceStatus::Active)
1839 .max_by_key(|entry| entry.started_at)
1840 .or_else(|| entries.iter().copied().max_by_key(|entry| entry.started_at))
1841}
1842
1843fn non_empty(value: String) -> Option<String> {
1844 (!value.is_empty()).then_some(value)
1845}
1846
1847fn coordination_status_for_thread_state(state: &ThreadState) -> CoordinationStatus {
1848 match state {
1849 ThreadState::Blocked => CoordinationStatus::Blocked,
1850 ThreadState::Ready => CoordinationStatus::MergeReady,
1851 ThreadState::Merged | ThreadState::Abandoned => CoordinationStatus::Clean,
1852 ThreadState::Active | ThreadState::Draft | ThreadState::Promoted => {
1853 CoordinationStatus::Clean
1854 }
1855 }
1856}
1857
1858#[derive(Debug, Clone, Serialize, JsonSchema)]
1859pub struct FastShortStatusReport {
1860 pub subject: String,
1861 pub health: String,
1862 pub changes: ChangesInfo,
1863 #[serde(skip)]
1864 #[schemars(skip)]
1865 pub profile: FastShortStatusProfile,
1866}
1867
1868#[derive(Debug, Clone, Copy, Default)]
1869pub struct FastShortStatusProfile {
1870 pub git_discover_ms: u128,
1871 pub config_ms: u128,
1872 pub sley_status_ms: u128,
1873 pub branch_ms: u128,
1874 pub remote_ms: u128,
1875 pub total_ms: u128,
1876}
1877
1878#[derive(Debug, Clone, Serialize, JsonSchema)]
1884pub struct PlainGitStatusReport {
1885 pub output_kind: &'static str,
1886 pub repository_capability: String,
1887 pub repository_label: String,
1888 pub storage_model: String,
1889 pub heddle_initialized: bool,
1890 pub git_branch: Option<String>,
1891 pub path: String,
1892 #[serde(rename = "verification")]
1893 pub trust: RepositoryVerificationState,
1894 #[serde(serialize_with = "serialize_empty_action_as_null")]
1895 #[schemars(with = "Option<String>")]
1896 pub recommended_action: String,
1897 pub recommended_action_template: Option<ActionTemplate>,
1898 pub recovery_commands: Vec<String>,
1899 pub recovery_action_templates: Vec<ActionTemplate>,
1900 pub thread_health: String,
1901 pub changed_path_count: usize,
1902 pub changes: ChangesInfo,
1903 pub git_index: Option<GitIndexPlan>,
1904}
1905
1906pub fn plain_git_status_report(
1910 start: &Path,
1911 machine_contract_input: &MachineContractInput,
1912) -> Result<Option<PlainGitStatusReport>> {
1913 let Some(probe) =
1914 build_plain_git_verification_probe_with_machine_contract(start, machine_contract_input)?
1915 else {
1916 return Ok(None);
1917 };
1918 let changes = changes_from_worktree_status(&probe.changes);
1919 let changed_path_count = probe.changes.change_count();
1920 let trust = probe.trust;
1921 let git_index = git_index_plan_for_root(&probe.root)?;
1922 Ok(Some(PlainGitStatusReport {
1923 output_kind: "status",
1924 repository_capability: "plain-git".to_string(),
1925 repository_label: repository_mode_label("plain-git", "git-only"),
1926 storage_model: "git-only".to_string(),
1927 heddle_initialized: false,
1928 git_branch: probe.git_branch,
1929 path: probe.root.display().to_string(),
1930 recommended_action: trust.recommended_action.clone(),
1931 recommended_action_template: trust.recommended_action_template.clone(),
1932 recovery_commands: trust.recovery_commands.clone(),
1933 recovery_action_templates: trust.recovery_action_templates.clone(),
1934 thread_health: trust.status.clone(),
1935 changed_path_count,
1936 changes,
1937 git_index,
1938 trust,
1939 }))
1940}
1941
1942pub fn status(ctx: &ExecutionContext, opts: StatusOptions) -> Result<StatusReport> {
1943 let fallback;
1944 let start = if let Some(start) = opts.start_path.as_deref() {
1945 start
1946 } else if let Some(start) = ctx.start_path() {
1947 start
1948 } else {
1949 fallback = std::env::current_dir().map_err(HeddleError::Io)?;
1950 fallback.as_path()
1951 };
1952
1953 let opened;
1958 let (repo, repo_open_ms) = if let Some(repo) = ctx.repo() {
1959 (repo, 0)
1960 } else {
1961 let repo_open_start = Instant::now();
1962 opened = Repository::open(start)?;
1963 (&opened, repo_open_start.elapsed().as_millis())
1964 };
1965 let body_start = Instant::now();
1966
1967 let current_state_start = Instant::now();
1968 let current_state = repo.current_state()?;
1969 let current_state_ms = current_state_start.elapsed().as_millis();
1970
1971 let operation_start = Instant::now();
1972 let operation = repo.operation_status()?;
1973 let operation_ms = operation_start.elapsed().as_millis();
1974
1975 let remote_tracking_start = Instant::now();
1976 let remote_tracking = if opts.detail.needs_remote_tracking() {
1977 repo.git_remote_tracking_status().unwrap_or(None)
1978 } else {
1979 None
1980 };
1981 let remote_tracking_ms = remote_tracking_start.elapsed().as_millis();
1982
1983 let import_hint_start = Instant::now();
1984 let import_hint = if opts.detail.short_path() {
1985 None
1986 } else {
1987 repo.git_import_guidance().unwrap_or(None)
1988 };
1989 let import_hint_ms = import_hint_start.elapsed().as_millis();
1990
1991 let git_overlay_status_start = Instant::now();
1992 let (git_worktree_status_result, git_index) = load_git_overlay_status_and_index_plan(repo);
1993 let git_overlay_status_ms = git_overlay_status_start.elapsed().as_millis();
1994
1995 let verification_start = Instant::now();
1996 let verification_health = build_repository_verification_health_with_worktree_status(
1997 repo,
1998 &git_worktree_status_result,
1999 );
2000 let trust = build_repository_verification_state_with_worktree_status_and_machine_contract(
2001 repo,
2002 verification_health.clone(),
2003 &git_worktree_status_result,
2004 &opts.machine_contract_input,
2005 );
2006 let verification_ms = verification_start.elapsed().as_millis();
2007 let remote_tracking =
2008 remote_tracking.map(|remote| remote_tracking_with_verification_action(remote, &trust));
2009
2010 let git_worktree_status = git_worktree_status_result.unwrap_or(None);
2011
2012 let git_index_ms = 0;
2013
2014 let identity_notice = first_capture_identity_notice(ctx, repo, current_state.as_ref())?;
2015 let git_clean_mapping_blocker = matches!(
2016 trust.status.as_str(),
2017 "needs_import" | "needs_reconcile" | "git_branch_advanced"
2018 ) && git_worktree_status
2019 .as_ref()
2020 .is_some_and(WorktreeStatus::is_clean);
2021 let git_backed_mapping = trust.mapping_state == "git_backed";
2022
2023 let worktree_status_start = Instant::now();
2024 let (changes, worktree_profile) = if git_clean_mapping_blocker {
2025 (ChangesInfo::default(), None)
2026 } else if let Some(status) = git_worktree_status.as_ref()
2027 && !status.is_clean()
2028 && trust.status != "needs_checkpoint"
2029 {
2030 (changes_from_worktree_status(status), None)
2031 } else if git_backed_mapping {
2032 (
2033 git_worktree_status
2034 .as_ref()
2035 .map(changes_from_worktree_status)
2036 .unwrap_or_default(),
2037 None,
2038 )
2039 } else if let Some(ref state) = current_state {
2040 let tree = repo.require_tree(&state.tree)?;
2041 let (status, profile) = repo
2042 .compare_worktree_cached_profiled_with_options(&tree, &opts.worktree_status_options)?;
2043 (changes_from_worktree_status(&status), Some(profile))
2044 } else if let Some(status) = git_worktree_status {
2045 (changes_from_worktree_status(&status), None)
2046 } else {
2047 let tree = objects::object::Tree::new();
2048 let (status, profile) = repo
2049 .compare_worktree_cached_profiled_with_options(&tree, &opts.worktree_status_options)?;
2050 let mut changes = changes_from_worktree_status(&status);
2051 changes.modified.clear();
2052 changes.deleted.clear();
2053 (changes, Some(profile))
2054 };
2055 let worktree_status_ms = worktree_status_start.elapsed().as_millis();
2056
2057 if opts.detail.short_path() {
2058 return Ok(build_short_path_report(ShortPathInputs {
2059 repo,
2060 current_state: current_state.as_ref(),
2061 operation,
2062 remote_tracking,
2063 verification_health,
2064 trust,
2065 import_hint,
2066 git_index,
2067 identity_notice,
2068 changes,
2069 profile: StatusProfile {
2070 repo_open_ms,
2071 current_state_ms,
2072 operation_ms,
2073 remote_tracking_ms,
2074 import_hint_ms,
2075 git_overlay_status_ms,
2076 verification_ms,
2077 git_index_ms,
2078 worktree_status_ms,
2079 build_total_ms: body_start.elapsed().as_millis(),
2080 worktree_profile,
2081 ..StatusProfile::default()
2082 },
2083 }));
2084 }
2085
2086 let thread_summary_start = Instant::now();
2087 let track_name = repo.current_lane()?;
2088 let full_thread_summaries = if opts.detail.needs_full_walk() {
2089 Some(collect_thread_summaries(repo)?)
2090 } else {
2091 None
2092 };
2093 let thread_summary = match (track_name.as_deref(), full_thread_summaries.as_ref()) {
2094 (Some(thread), Some(summaries)) => summaries
2095 .iter()
2096 .find(|summary| summary.name == thread)
2097 .cloned(),
2098 (Some(thread), None) => find_thread_summary_single(repo, thread)?,
2099 (None, _) => None,
2100 };
2101 let thread_summary_ms = thread_summary_start.elapsed().as_millis();
2102
2103 let parallel_threads_start = Instant::now();
2104 let parallel_threads = if let Some(summaries) = full_thread_summaries {
2105 summaries
2106 .into_iter()
2107 .filter(|thread| !thread.is_current)
2108 .filter(|thread| {
2109 matches!(
2110 thread.coordination_status,
2111 CoordinationStatus::Ahead
2112 | CoordinationStatus::Blocked
2113 | CoordinationStatus::Diverged
2114 | CoordinationStatus::MergeReady
2115 )
2116 })
2117 .collect::<Vec<_>>()
2118 } else {
2119 Vec::new()
2120 };
2121 let parallel_threads_ms = parallel_threads_start.elapsed().as_millis();
2122
2123 let late_state_start = Instant::now();
2124 let state_info = current_state.as_ref().map(|s| StateInfo {
2125 state_id: s.state_id.short(),
2126 content_hash: s.compute_hash().short(),
2127 intent: s.intent.clone(),
2128 });
2129 let current_state_short = current_state.as_ref().map(|state| state.state_id.short());
2130 let git_checkpoint = if trust.status == "needs_checkpoint" {
2131 None
2132 } else {
2133 current_state
2134 .as_ref()
2135 .and_then(|state| {
2136 repo.latest_git_checkpoint_for_state(&state.state_id)
2137 .ok()
2138 .flatten()
2139 })
2140 .map(|record| GitCheckpointInfo {
2141 git_commit: record.git_commit,
2142 committed_at: record.committed_at,
2143 })
2144 };
2145
2146 let materialized_start = Instant::now();
2147 let materialized_threads = assess_materialized_threads(repo);
2148 let materialized_ms = materialized_start.elapsed().as_millis();
2149 let target_thread = thread_summary
2150 .as_ref()
2151 .and_then(|thread| thread.target_thread.clone());
2152 let parent_thread = thread_summary
2153 .as_ref()
2154 .and_then(|thread| thread.parent_thread.clone());
2155 let presentation =
2156 crate::repository_presentation(repo, target_thread.as_deref(), parent_thread.as_deref());
2157
2158 let output = StatusReport {
2159 output_kind: "status",
2160 repository_capability: repo.capability_label().to_string(),
2161 repository_label: presentation.label,
2162 repository_context: presentation.context,
2163 storage_model: repo.storage_model_label().to_string(),
2164 hosted_enabled: repo.hosted_enabled(),
2165 validation_capability: repo.capability(),
2166 import_guidance: import_hint.clone().map(Into::into),
2167 verification_health: verification_health.clone(),
2168 trust: trust.clone(),
2169 operation,
2170 remote_tracking,
2171 git_index,
2172 thread: track_name.clone(),
2173 base_state: thread_summary
2174 .as_ref()
2175 .and_then(|thread| thread.base_state.clone())
2176 .or_else(|| current_state_short.clone()),
2177 base_root: thread_summary
2178 .as_ref()
2179 .and_then(|thread| thread.base_root.clone()),
2180 current_state: thread_summary
2181 .as_ref()
2182 .and_then(|thread| thread.current_state.clone())
2183 .or_else(|| current_state_short.clone()),
2184 path: thread_summary
2185 .as_ref()
2186 .and_then(|thread| thread.path.clone()),
2187 execution_path: thread_summary
2188 .as_ref()
2189 .and_then(|thread| thread.execution_path.clone()),
2190 session_id: thread_summary
2191 .as_ref()
2192 .and_then(|thread| thread.session_id.clone()),
2193 heddle_session_id: thread_summary
2194 .as_ref()
2195 .and_then(|thread| thread.heddle_session_id.clone()),
2196 actor: thread_summary
2197 .as_ref()
2198 .and_then(|thread| thread.actor.clone()),
2199 harness: thread_summary
2200 .as_ref()
2201 .and_then(|thread| thread.harness.clone()),
2202 thinking_level: thread_summary
2203 .as_ref()
2204 .and_then(|thread| thread.thinking_level.clone()),
2205 usage_summary: thread_summary
2206 .as_ref()
2207 .and_then(|thread| thread.usage_summary.clone()),
2208 last_progress_at: thread_summary
2209 .as_ref()
2210 .and_then(|thread| thread.last_progress_at.clone()),
2211 report_flush_state: thread_summary
2212 .as_ref()
2213 .and_then(|thread| thread.report_flush_state.clone()),
2214 attach_reason: thread_summary
2215 .as_ref()
2216 .and_then(|thread| thread.attach_reason.clone()),
2217 thread_mode: thread_summary
2218 .as_ref()
2219 .and_then(|thread| thread.thread_mode.clone()),
2220 thread_state: thread_summary
2221 .as_ref()
2222 .and_then(|thread| thread.thread_state.clone()),
2223 freshness: thread_summary
2224 .as_ref()
2225 .and_then(|thread| thread.freshness.clone()),
2226 target_thread,
2227 parent_thread,
2228 child_threads: thread_summary
2229 .as_ref()
2230 .map(|thread| thread.child_threads.clone())
2231 .unwrap_or_default(),
2232 task: thread_summary
2233 .as_ref()
2234 .and_then(|thread| thread.task.clone()),
2235 promotion_suggested: thread_summary
2236 .as_ref()
2237 .map(|thread| thread.promotion_suggested)
2238 .unwrap_or(false),
2239 impact_categories: thread_summary
2240 .as_ref()
2241 .map(|thread| thread.impact_categories.clone())
2242 .unwrap_or_default(),
2243 heavy_impact_paths: thread_summary
2244 .as_ref()
2245 .map(|thread| thread.heavy_impact_paths.clone())
2246 .unwrap_or_default(),
2247 changed_paths: Vec::new(),
2248 changed_path_count: thread_summary
2249 .as_ref()
2250 .map(|thread| thread.changed_paths.len())
2251 .unwrap_or_default(),
2252 worktree_changed_path_count: changes_path_count(&changes),
2253 thread_changed_path_count: captured_thread_path_count(thread_summary.as_ref(), &changes),
2254 blockers: Vec::new(),
2255 identity_notice,
2256 recommended_action: String::new(),
2257 recommended_action_template: None,
2258 recovery_commands: trust.recovery_commands.clone(),
2259 recovery_action_templates: trust.recovery_action_templates.clone(),
2260 thread_health: "clean".to_string(),
2261 coordination_status: thread_summary
2262 .as_ref()
2263 .map(|thread| thread.coordination_status)
2264 .unwrap_or(CoordinationStatus::Clean),
2265 coordination_blocked_by_trust: false,
2266 is_isolated: thread_summary
2267 .as_ref()
2268 .map(|thread| thread.is_isolated)
2269 .unwrap_or(false),
2270 parallel_threads: parallel_threads
2271 .into_iter()
2272 .map(|thread| ParallelThreadInfo {
2273 name: thread.name,
2274 coordination_status: thread.coordination_status,
2275 current_state: thread.current_state,
2276 })
2277 .collect(),
2278 state: state_info,
2279 git_checkpoint,
2280 changes,
2281 materialized_threads,
2282 profile: StatusProfile::default(),
2283 };
2284 let late_state_ms = late_state_start.elapsed().as_millis();
2285 let advice_start = Instant::now();
2286 let mut output = apply_status_advice(
2287 repo,
2288 output,
2289 current_state.as_ref(),
2290 &thread_summary,
2291 import_hint,
2292 git_backed_mapping,
2293 );
2294 output.profile = StatusProfile {
2295 repo_open_ms,
2296 current_state_ms,
2297 operation_ms,
2298 remote_tracking_ms,
2299 import_hint_ms,
2300 git_overlay_status_ms,
2301 verification_ms,
2302 git_index_ms,
2303 worktree_status_ms,
2304 thread_summary_ms,
2305 parallel_threads_ms,
2306 late_state_ms,
2307 materialized_threads_ms: materialized_ms,
2308 advice_ms: advice_start.elapsed().as_millis(),
2309 build_total_ms: body_start.elapsed().as_millis(),
2310 worktree_profile,
2311 };
2312 Ok(output)
2313}
2314
2315struct ShortPathInputs<'a> {
2316 repo: &'a Repository,
2317 current_state: Option<&'a State>,
2318 operation: Option<RepositoryOperationStatus>,
2319 remote_tracking: Option<GitRemoteTrackingStatus>,
2320 verification_health: RepositoryVerificationHealth,
2321 trust: RepositoryVerificationState,
2322 import_hint: Option<GitImportGuidance>,
2323 git_index: Option<GitIndexPlan>,
2324 identity_notice: Option<String>,
2325 changes: ChangesInfo,
2326 profile: StatusProfile,
2327}
2328
2329fn build_short_path_report(input: ShortPathInputs<'_>) -> StatusReport {
2330 let recommended_action = effective_next_action(
2331 NextActionInput::default(
2332 input.operation.as_ref(),
2333 input.remote_tracking.as_ref(),
2334 None,
2335 None,
2336 )
2337 .with_source_authority(input.repo.source_authority())
2338 .with_verification(&input.trust),
2339 );
2340 let worktree_clean = input.changes.is_empty();
2341 let recommended_action =
2342 first_save_recommendation(input.repo, input.current_state, worktree_clean)
2343 .unwrap_or(recommended_action);
2344 let presentation = crate::repository_presentation(input.repo, None, None);
2345 let recommended_action_template = action_template(&recommended_action);
2346 let thread = input.repo.current_lane().ok().flatten();
2349 StatusReport {
2350 output_kind: "status",
2351 repository_capability: input.repo.capability_label().to_string(),
2352 repository_label: presentation.label,
2353 repository_context: presentation.context,
2354 storage_model: input.repo.storage_model_label().to_string(),
2355 hosted_enabled: input.repo.hosted_enabled(),
2356 validation_capability: input.repo.capability(),
2357 import_guidance: input.import_hint.map(Into::into),
2358 verification_health: input.verification_health,
2359 trust: input.trust.clone(),
2360 operation: input.operation,
2361 remote_tracking: input.remote_tracking,
2362 git_index: input.git_index,
2363 thread,
2364 base_state: None,
2365 base_root: None,
2366 current_state: input.current_state.map(|state| state.state_id.short()),
2367 path: None,
2368 execution_path: None,
2369 session_id: None,
2370 heddle_session_id: None,
2371 actor: None,
2372 harness: None,
2373 thinking_level: None,
2374 usage_summary: None,
2375 last_progress_at: None,
2376 report_flush_state: None,
2377 attach_reason: None,
2378 thread_mode: None,
2379 thread_state: None,
2380 freshness: None,
2381 target_thread: None,
2382 parent_thread: None,
2383 child_threads: Vec::new(),
2384 task: None,
2385 promotion_suggested: false,
2386 impact_categories: Vec::new(),
2387 heavy_impact_paths: Vec::new(),
2388 changed_paths: changes_paths(&input.changes).into_iter().collect(),
2389 changed_path_count: changes_path_count(&input.changes),
2390 worktree_changed_path_count: changes_path_count(&input.changes),
2391 thread_changed_path_count: 0,
2392 blockers: if input.trust.verified {
2393 Vec::new()
2394 } else {
2395 input
2396 .trust
2397 .checks
2398 .iter()
2399 .filter(|check| {
2400 !check.clean
2401 && check.status != "not_checked"
2402 && !check
2403 .summary
2404 .contains("checked after the primary verification blocker")
2405 })
2406 .map(|check| format!("{}: {}", check.name, check.summary))
2407 .collect()
2408 },
2409 identity_notice: input.identity_notice,
2410 recommended_action_template,
2411 recommended_action,
2412 recovery_commands: input.trust.recovery_commands.clone(),
2413 recovery_action_templates: input.trust.recovery_action_templates.clone(),
2414 thread_health: input.trust.status.clone(),
2415 coordination_status: if input.trust.verified {
2416 CoordinationStatus::Clean
2417 } else {
2418 CoordinationStatus::Blocked
2419 },
2420 coordination_blocked_by_trust: !input.trust.verified,
2421 is_isolated: false,
2422 parallel_threads: Vec::new(),
2423 state: None,
2424 git_checkpoint: None,
2425 changes: input.changes,
2426 materialized_threads: assess_materialized_threads(input.repo),
2427 profile: input.profile,
2428 }
2429}
2430
2431fn apply_status_advice(
2432 repo: &Repository,
2433 output: StatusReport,
2434 current_state: Option<&State>,
2435 thread_summary: &Option<StatusThreadSummary>,
2436 import_hint: Option<GitImportGuidance>,
2437 git_backed_mapping: bool,
2438) -> StatusReport {
2439 let has_changes = !output.changes.is_empty();
2440 let checkpointed_clean = output.git_checkpoint.is_some() && !has_changes;
2441 let thread_stub = output.thread.as_ref().map(|thread| Thread {
2442 id: thread.clone(),
2443 thread: thread.clone(),
2444 target_thread: output.target_thread.clone(),
2445 parent_thread: thread_summary
2446 .as_ref()
2447 .and_then(|thread| thread.parent_thread.clone()),
2448 mode: output
2449 .thread_mode
2450 .clone()
2451 .unwrap_or(ThreadMode::Materialized),
2452 state: output.thread_state.clone().unwrap_or(ThreadState::Active),
2453 base_state: output.base_state.clone().unwrap_or_default(),
2454 base_root: output.base_root.clone().unwrap_or_default(),
2455 current_state: output.current_state.clone(),
2456 merged_state: None,
2457 task: output.task.clone(),
2458 execution_path: output
2459 .execution_path
2460 .as_ref()
2461 .map(PathBuf::from)
2462 .unwrap_or_else(|| repo.root().to_path_buf()),
2463 materialized_path: output.path.as_ref().map(PathBuf::from),
2464 changed_paths: thread_summary
2465 .as_ref()
2466 .map(|thread| thread.changed_paths.clone())
2467 .unwrap_or_default(),
2468 impact_categories: output.impact_categories.clone(),
2469 heavy_impact_paths: output.heavy_impact_paths.clone(),
2470 promotion_suggested: output.promotion_suggested && !checkpointed_clean,
2471 freshness: match output.freshness.clone().unwrap_or(ThreadFreshness::Unknown) {
2472 ThreadFreshness::Unknown if checkpointed_clean => ThreadFreshness::Current,
2473 freshness => freshness,
2474 },
2475 verification_summary: thread_summary
2476 .as_ref()
2477 .map(|thread| thread.verification_summary.clone())
2478 .unwrap_or_default(),
2479 confidence_summary: thread_summary
2480 .as_ref()
2481 .map(|thread| thread.confidence_summary.clone())
2482 .unwrap_or_default(),
2483 integration_policy_result: thread_summary
2484 .as_ref()
2485 .map(|thread| thread.integration_policy_result.clone())
2486 .unwrap_or_default(),
2487 created_at: chrono::Utc::now(),
2488 updated_at: chrono::Utc::now(),
2489 ephemeral: None,
2490 auto: false,
2491 shared_target_dir: None,
2492 });
2493 let initial_state = current_state.map(is_synthetic_root).unwrap_or(true);
2494 let advice = thread_stub.as_ref().map(|thread| {
2495 describe_thread_advice_with_initial(thread, has_changes, 0, false, initial_state)
2496 });
2497 let mut trust = output.trust.clone();
2498 if let Some(operation) = output.operation.as_ref()
2499 && trust.recommended_action != operation.next_action
2500 {
2501 override_trust_recommended_action(&mut trust, operation.next_action.clone());
2502 }
2503 if has_changes
2504 && output.validation_capability != RepositoryCapability::GitOverlay
2505 && output.operation.is_none()
2506 && trust.verified
2507 {
2508 let dirty_paths = changes_paths(&output.changes)
2509 .into_iter()
2510 .collect::<Vec<_>>();
2511 let dirty_summary = format!(
2512 "{} Heddle worktree path(s) are not captured in the current state",
2513 dirty_paths.len()
2514 );
2515 trust.verified = false;
2516 trust.status = "uncaptured".to_string();
2517 trust.worktree_dirty = true;
2518 trust.worktree_state = "dirty".to_string();
2519 trust.summary = dirty_summary.clone();
2520 trust.recommended_action = "heddle capture -m \"...\"".to_string();
2521 trust.recommended_action_template = action_template(&trust.recommended_action);
2522 trust.recovery_commands = vec![trust.recommended_action.clone()];
2523 trust.recovery_action_templates = action_templates(&trust.recovery_commands);
2524 let mut details = BTreeMap::new();
2525 details.insert(
2526 "dirty_path_count".to_string(),
2527 dirty_paths.len().to_string(),
2528 );
2529 if !dirty_paths.is_empty() {
2530 details.insert("dirty_paths".to_string(), dirty_paths.join(", "));
2531 }
2532 let worktree_check = VerificationCheck {
2533 name: "Worktree".to_string(),
2534 status: "uncaptured".to_string(),
2535 clean: false,
2536 summary: dirty_summary,
2537 recommended_action: Some(trust.recommended_action.clone()),
2538 recommended_action_template: trust.recommended_action_template.clone(),
2539 recovery_commands: trust.recovery_commands.clone(),
2540 recovery_action_templates: trust.recovery_action_templates.clone(),
2541 details,
2542 };
2543 if let Some(check) = trust
2544 .checks
2545 .iter_mut()
2546 .find(|check| check.name == "Worktree")
2547 {
2548 *check = worktree_check;
2549 } else {
2550 trust.checks.insert(0, worktree_check);
2551 }
2552 }
2553 if trust.status != "needs_checkpoint"
2554 && let Some(thread) = output.thread.as_deref()
2555 && !trust.recommended_action.is_empty()
2556 {
2557 let contextual = contextual_thread_action(
2558 repo,
2559 thread,
2560 output.target_thread.as_deref(),
2561 &trust.recommended_action,
2562 );
2563 if contextual != trust.recommended_action {
2564 override_trust_recommended_action(&mut trust, contextual);
2565 }
2566 }
2567 let thread_health = advice.as_ref().map(|advice| advice.thread_health.as_str());
2568 let thread_action = advice
2569 .as_ref()
2570 .map(|advice| advice.recommended_action.as_str());
2571 let fallback = if trust.status == "needs_checkpoint" {
2572 non_empty_action(Some(trust.recommended_action.as_str()))
2573 } else {
2574 non_empty_action(thread_action)
2575 .or_else(|| non_empty_action(Some(trust.recommended_action.as_str())))
2576 };
2577 let recommended_action = effective_next_action(
2578 NextActionInput::default(
2579 output.operation.as_ref(),
2580 output.remote_tracking.as_ref(),
2581 import_hint.as_ref(),
2582 fallback,
2583 )
2584 .with_source_authority(repo.source_authority())
2585 .current_thread(thread_health)
2586 .with_verification(&trust),
2587 );
2588 let recommended_action = if trust.status != "needs_checkpoint"
2589 && let Some(thread) = output.thread.as_deref()
2590 {
2591 contextual_thread_action(
2592 repo,
2593 thread,
2594 output.target_thread.as_deref(),
2595 &recommended_action,
2596 )
2597 } else {
2598 recommended_action
2599 };
2600 if trust.verified
2601 && !recommended_action.is_empty()
2602 && trust.recommended_action != recommended_action
2603 {
2604 override_trust_recommended_action(&mut trust, recommended_action.clone());
2605 }
2606 let recommended_action =
2607 if git_backed_mapping && trust.status != "needs_checkpoint" && output.operation.is_none() {
2608 if has_changes {
2609 "heddle capture -m \"...\"".to_string()
2610 } else {
2611 String::new()
2612 }
2613 } else {
2614 if output.operation.is_some() {
2615 recommended_action
2616 } else {
2617 first_save_recommendation(repo, current_state, !has_changes)
2618 .unwrap_or(recommended_action)
2619 }
2620 };
2621 let thread_health = if trust.verified {
2622 if git_backed_mapping {
2623 if has_changes {
2624 "dirty_worktree".to_string()
2625 } else {
2626 "clean".to_string()
2627 }
2628 } else {
2629 advice
2630 .as_ref()
2631 .map(|advice| advice.thread_health.clone())
2632 .unwrap_or_else(|| "clean".to_string())
2633 }
2634 } else {
2635 trust.status.clone()
2636 };
2637 let needs_checkpoint = trust.status == "needs_checkpoint";
2638 let mut trust_blockers = trust
2639 .checks
2640 .iter()
2641 .filter(|check| {
2642 !check.clean
2643 && check.status != "not_checked"
2644 && (check.name != "Clone" || check.status != "blocked")
2645 && !check
2646 .summary
2647 .contains("checked after the primary verification blocker")
2648 })
2649 .map(|check| {
2650 let name = if output.validation_capability != RepositoryCapability::GitOverlay
2651 && check.name == "Worktree"
2652 && check.status == "uncaptured"
2653 {
2654 "Verification"
2655 } else {
2656 check.name.as_str()
2657 };
2658 format!("{name}: {}", check.summary)
2659 })
2660 .collect::<Vec<_>>();
2661 let blocked_by_trust = !trust.verified;
2662 if blocked_by_trust && trust_blockers.is_empty() && !trust.summary.trim().is_empty() {
2663 trust_blockers.push(format!("Verification: {}", trust.summary));
2664 }
2665 let display_thread_summary = (!git_backed_mapping)
2666 .then_some(thread_summary.as_ref())
2667 .flatten();
2668 let worktree_changed_path_count = changes_path_count(&output.changes);
2669 let thread_changed_path_count =
2670 captured_thread_path_count(display_thread_summary, &output.changes);
2671 let (coordination_status, coordination_blocked_by_trust) = resolve_coordination_with_trust(
2672 output.coordination_status,
2673 blocked_by_trust,
2674 needs_checkpoint,
2675 );
2676 let recommended_action_template = action_template(&recommended_action);
2677 StatusReport {
2678 blockers: if blocked_by_trust {
2679 trust_blockers
2680 } else {
2681 advice
2682 .as_ref()
2683 .map(|advice| advice.blockers.clone())
2684 .unwrap_or_default()
2685 },
2686 identity_notice: output.identity_notice,
2687 recommended_action: recommended_action.clone(),
2688 recommended_action_template,
2689 recovery_commands: trust.recovery_commands.clone(),
2690 recovery_action_templates: trust.recovery_action_templates.clone(),
2691 thread_health,
2692 coordination_status,
2693 coordination_blocked_by_trust,
2694 thread_state: output.thread_state,
2695 changed_paths: changed_paths(display_thread_summary, &output.changes),
2696 changed_path_count: if trust.verified {
2697 changed_path_count(display_thread_summary, &output.changes)
2698 } else {
2699 changes_path_count(&output.changes)
2700 },
2701 worktree_changed_path_count,
2702 thread_changed_path_count,
2703 trust,
2704 ..output
2705 }
2706}
2707
2708fn override_trust_recommended_action(trust: &mut RepositoryVerificationState, action: String) {
2709 let template = action_template(&action);
2710 trust.recommended_action = action.clone();
2711 trust.recommended_action_template = template.clone();
2712 if let Some(check) = trust
2713 .checks
2714 .iter_mut()
2715 .find(|check| check.name == "Workflow")
2716 {
2717 check.recommended_action = Some(action);
2718 check.recommended_action_template = template;
2719 }
2720}
2721
2722fn paths_equal(left: &Path, right: &Path) -> bool {
2723 let left = left.canonicalize();
2724 let right = right.canonicalize();
2725 match (left, right) {
2726 (Ok(left), Ok(right)) => left == right,
2727 _ => false,
2728 }
2729}
2730
2731fn first_capture_identity_notice(
2732 ctx: &ExecutionContext,
2733 repo: &Repository,
2734 current_state: Option<&State>,
2735) -> Result<Option<String>> {
2736 if !current_state.map(is_synthetic_root).unwrap_or(true) {
2737 return Ok(None);
2738 }
2739 let principal = resolve_principal(repo, ctx.config())?;
2740 if principal_is_default_unknown(&principal) {
2741 return Ok(Some(
2742 "no principal configured; the first capture would use Unknown <unknown@example.com>. Set HEDDLE_PRINCIPAL_NAME and HEDDLE_PRINCIPAL_EMAIL or run `heddle init --principal-name <name> --principal-email <email>`.".to_string(),
2743 ));
2744 }
2745 Ok(None)
2746}
2747
2748fn resolve_principal(repo: &Repository, user_config: &cli_shared::UserConfig) -> Result<Principal> {
2749 if let Some(principal) = Principal::from_env() {
2750 return Ok(principal);
2751 }
2752 if let Some(config) = &repo.config().principal {
2753 return Ok(Principal::new(&config.name, &config.email));
2754 }
2755 let principal = repo.get_principal()?;
2756 if !principal_is_default_unknown(&principal) {
2757 return Ok(principal);
2758 }
2759 if let Some(config) = &user_config.principal {
2760 return Ok(Principal::new(&config.name, &config.email));
2761 }
2762 Ok(principal)
2763}
2764
2765pub fn principal_is_default_unknown(principal: &Principal) -> bool {
2767 principal.name == "Unknown" && principal.email == "unknown@example.com"
2768}
2769
2770pub fn principal_lacks_accountable_identity(name: &str, email: &str) -> bool {
2772 let name = name.trim();
2773 let email = email.trim();
2774 name.is_empty() || email.is_empty() || (name == "Unknown" && email == "unknown@example.com")
2775}
2776
2777pub fn large_capture_requires_force(
2781 total_changes: usize,
2782 delete_count: usize,
2783 add_count: usize,
2784) -> bool {
2785 total_changes > 100 || delete_count > 25 || add_count > 100
2786}
2787
2788pub fn fast_short_status_report(start: &Path) -> Result<Option<FastShortStatusReport>> {
2789 let total_start = Instant::now();
2790 let discover_start = Instant::now();
2791 let git = match SleyRepository::discover(start) {
2792 Ok(git) => git,
2793 Err(_) => return Ok(None),
2794 };
2795 let Some(workdir) = git.workdir() else {
2796 return Ok(None);
2797 };
2798 let git_discover_ms = discover_start.elapsed().as_millis();
2799
2800 let config_start = Instant::now();
2801 let repo_kind = fast_short_repo_kind(&workdir)?;
2802 if matches!(repo_kind, FastShortRepoKind::Fallback) {
2803 return Ok(None);
2804 }
2805 let config_ms = config_start.elapsed().as_millis();
2806
2807 let status_start = Instant::now();
2808 let changes = fast_sley_changes(&git)?;
2809 let sley_status_ms = status_start.elapsed().as_millis();
2810
2811 let branch_start = Instant::now();
2812 let branch = fast_git_branch(&git)?;
2813 let subject = branch.as_deref().unwrap_or("detached").to_string();
2814 let branch_ms = branch_start.elapsed().as_millis();
2815
2816 let remote_start = Instant::now();
2817 let remote_health = match repo_kind {
2818 FastShortRepoKind::PlainGit | FastShortRepoKind::Fallback => None,
2819 FastShortRepoKind::GitOverlay => branch
2820 .as_deref()
2821 .map(|branch| fast_remote_health(&git, branch))
2822 .transpose()?
2823 .flatten(),
2824 };
2825 let remote_ms = remote_start.elapsed().as_millis();
2826 let health = if changes.is_empty() {
2827 match repo_kind {
2828 FastShortRepoKind::PlainGit => "setup needed".to_string(),
2829 FastShortRepoKind::GitOverlay | FastShortRepoKind::Fallback => {
2830 remote_health.unwrap_or("clean").to_string()
2831 }
2832 }
2833 } else {
2834 String::new()
2835 };
2836 Ok(Some(FastShortStatusReport {
2837 subject,
2838 health,
2839 changes,
2840 profile: FastShortStatusProfile {
2841 git_discover_ms,
2842 config_ms,
2843 sley_status_ms,
2844 branch_ms,
2845 remote_ms,
2846 total_ms: total_start.elapsed().as_millis(),
2847 },
2848 }))
2849}
2850
2851enum FastShortRepoKind {
2852 PlainGit,
2853 GitOverlay,
2854 Fallback,
2855}
2856
2857fn fast_short_repo_kind(workdir: &Path) -> Result<FastShortRepoKind> {
2858 let heddle_dir = workdir.join(".heddle");
2859 if !heddle_dir.exists() {
2860 return Ok(FastShortRepoKind::PlainGit);
2861 }
2862 if heddle_dir.join("objectstore").is_file() {
2863 return Ok(FastShortRepoKind::Fallback);
2864 }
2865 let config_path = heddle_dir.join("config.toml");
2866 if !config_path.is_file() {
2867 return Ok(FastShortRepoKind::Fallback);
2868 }
2869 let config = RepoConfig::load_for_repository(&config_path)?;
2870 Ok(match config.repository.source_authority {
2871 repo::RepositorySourceAuthority::GitOverlay => FastShortRepoKind::GitOverlay,
2872 repo::RepositorySourceAuthority::Native => FastShortRepoKind::Fallback,
2873 })
2874}
2875
2876fn fast_sley_changes(git: &SleyRepository) -> Result<ChangesInfo> {
2877 let mut changes = ChangesInfo::default();
2878 git.stream_short_status_with_options(
2879 ShortStatusOptions {
2880 untracked_mode: StatusUntrackedMode::All,
2881 ..ShortStatusOptions::default()
2882 },
2883 |entry| {
2884 append_fast_status_row(&mut changes, entry);
2885 Ok(StreamControl::Continue)
2886 },
2887 )
2888 .map_err(sley_error)?;
2889 Ok(changes)
2890}
2891
2892fn append_fast_status_row(changes: &mut ChangesInfo, entry: ShortStatusRow<'_>) {
2893 let path = String::from_utf8_lossy(entry.path).into_owned();
2894 if path.is_empty() || ignored_git_overlay_status_path(&path) {
2895 return;
2896 }
2897 if entry.index == b'?' && entry.worktree == b'?' {
2898 changes.added.push(path);
2899 } else if entry.index == b'D' || entry.worktree == b'D' {
2900 changes.deleted.push(path);
2901 } else if entry.index == b'A'
2902 || entry.index == b'R'
2903 || entry.index == b'C'
2904 || entry.head_oid.is_none()
2905 {
2906 changes.added.push(path);
2907 } else {
2908 changes.modified.push(path);
2909 }
2910}
2911
2912fn ignored_git_overlay_status_path(path: &str) -> bool {
2913 path == ".heddle" || path.starts_with(".heddle/")
2914}
2915
2916fn fast_git_branch(git: &SleyRepository) -> Result<Option<String>> {
2917 Ok(git
2918 .head()
2919 .ok()
2920 .and_then(|head| head.branch_name().map(str::to_string)))
2921}
2922
2923fn fast_remote_health(git: &SleyRepository, branch: &str) -> Result<Option<&'static str>> {
2924 let Some(head) = git.head().ok().and_then(|head| head.oid) else {
2925 return Ok(None);
2926 };
2927 if git
2928 .find_reference(&format!("refs/heads/{branch}"))
2929 .map_err(sley_error)?
2930 .is_some()
2931 && let Some(tracking_ref) = fast_configured_tracking_ref(git, branch)?
2932 && let Some(upstream) = fast_rev_parse(git, &tracking_ref)
2933 {
2934 return fast_remote_health_for_pair(git, head, upstream);
2935 }
2936
2937 let remotes = git.remote_names().map_err(sley_error)?;
2938 for remote in &remotes {
2939 if remote.trim().is_empty() {
2940 continue;
2941 }
2942 let remote_ref = format!("refs/remotes/{remote}/{branch}");
2943 let Some(upstream) = fast_rev_parse(git, &remote_ref) else {
2944 continue;
2945 };
2946 if upstream == head {
2947 return Ok(None);
2948 }
2949 return fast_remote_health_for_pair(git, head, upstream);
2950 }
2951
2952 if remotes.is_empty() {
2953 Ok(None)
2954 } else {
2955 Ok(Some("ready to push"))
2956 }
2957}
2958
2959fn fast_configured_tracking_ref(git: &SleyRepository, branch: &str) -> Result<Option<String>> {
2960 let config = git.config_snapshot().map_err(sley_error)?;
2961 let Some(remote) = config.get("branch", Some(branch), "remote") else {
2962 return Ok(None);
2963 };
2964 let Some(merge) = config.get("branch", Some(branch), "merge") else {
2965 return Ok(None);
2966 };
2967 if remote == "." {
2968 return Ok(Some(merge.to_string()));
2969 }
2970 let Some(short) = merge.strip_prefix("refs/heads/") else {
2971 return Ok(None);
2972 };
2973 Ok(Some(format!("refs/remotes/{remote}/{short}")))
2974}
2975
2976fn fast_rev_parse(git: &SleyRepository, rev: &str) -> Option<sley::ObjectId> {
2977 git.rev_parse(rev).ok()
2978}
2979
2980fn fast_remote_health_for_pair(
2981 git: &SleyRepository,
2982 head: sley::ObjectId,
2983 upstream: sley::ObjectId,
2984) -> Result<Option<&'static str>> {
2985 if head == upstream {
2986 return Ok(None);
2987 }
2988 let (ahead, behind) = git
2989 .rev_graph()
2990 .ahead_behind(head, upstream)
2991 .map_err(sley_error)?;
2992 Ok(match (ahead, behind) {
2993 (0, 0) => None,
2994 (_, 0) => Some("ready to push"),
2995 (0, _) => Some("behind upstream"),
2996 _ => Some("remote_diverged"),
2997 })
2998}
2999
3000fn sley_error(err: sley::GitError) -> HeddleError {
3001 HeddleError::Config(err.to_string())
3002}
3003
3004pub fn assess_materialized_threads(repo: &Repository) -> Vec<MaterializedThreadInfo> {
3005 let summaries = match repo::thread_manifest::list_thread_manifests(repo.heddle_dir()) {
3006 Ok(s) => s,
3007 Err(_) => return Vec::new(),
3008 };
3009 summaries
3010 .into_iter()
3011 .map(|summary| {
3012 let stale = match repo.refs().get_thread(&ThreadName::new(&summary.thread)) {
3013 Ok(Some(head)) => head != summary.state_id,
3014 _ => false,
3015 };
3016 let tree_hash = summary.tree_hash.to_string();
3017 MaterializedThreadInfo {
3018 name: summary.thread,
3019 state_id: summary.state_id.short(),
3020 tree_hash_short: tree_hash[..std::cmp::min(12, tree_hash.len())].to_string(),
3021 file_count: summary.file_count,
3022 stale,
3023 }
3024 })
3025 .collect()
3026}
3027
3028pub fn changes_from_worktree_status(status: &WorktreeStatus) -> ChangesInfo {
3029 ChangesInfo {
3030 modified: status
3031 .modified
3032 .iter()
3033 .map(|p| p.display().to_string())
3034 .collect(),
3035 added: status
3036 .added
3037 .iter()
3038 .map(|p| p.display().to_string())
3039 .collect(),
3040 deleted: status
3041 .deleted
3042 .iter()
3043 .map(|p| p.display().to_string())
3044 .collect(),
3045 }
3046}
3047
3048pub fn changes_path_count(changes: &ChangesInfo) -> usize {
3049 changes_paths(changes).len()
3050}
3051
3052pub fn changes_paths(changes: &ChangesInfo) -> BTreeSet<String> {
3053 let mut paths = BTreeSet::new();
3054 paths.extend(changes.modified.iter().cloned());
3055 paths.extend(changes.added.iter().cloned());
3056 paths.extend(changes.deleted.iter().cloned());
3057 paths
3058}
3059
3060fn changed_path_count(thread: Option<&StatusThreadSummary>, changes: &ChangesInfo) -> usize {
3061 let mut paths = BTreeSet::new();
3062 if let Some(thread) = thread {
3063 paths.extend(thread.changed_paths.iter().cloned());
3064 }
3065 paths.extend(changes.modified.iter().cloned());
3066 paths.extend(changes.added.iter().cloned());
3067 paths.extend(changes.deleted.iter().cloned());
3068 paths.len()
3069}
3070
3071fn changed_paths(thread: Option<&StatusThreadSummary>, changes: &ChangesInfo) -> Vec<String> {
3072 let mut paths = BTreeSet::new();
3073 if let Some(thread) = thread {
3074 paths.extend(thread.changed_paths.iter().cloned());
3075 }
3076 paths.extend(changes.modified.iter().cloned());
3077 paths.extend(changes.added.iter().cloned());
3078 paths.extend(changes.deleted.iter().cloned());
3079 paths.into_iter().collect()
3080}
3081
3082fn captured_thread_path_count(
3083 thread: Option<&StatusThreadSummary>,
3084 changes: &ChangesInfo,
3085) -> usize {
3086 let Some(thread) = thread else {
3087 return 0;
3088 };
3089 let dirty_paths = changes_paths(changes);
3090 thread
3091 .changed_paths
3092 .iter()
3093 .filter(|path| !dirty_paths.contains(*path))
3094 .count()
3095}
3096
3097fn first_save_recommendation(
3098 repo: &Repository,
3099 current_state: Option<&State>,
3100 worktree_clean: bool,
3101) -> Option<String> {
3102 if !worktree_clean || repo.capability() != RepositoryCapability::NativeHeddle {
3103 return None;
3104 }
3105 let empty_log = current_state.map(is_synthetic_root).unwrap_or(true);
3106 empty_log.then(|| "heddle capture -m \"...\"".to_string())
3107}
3108
3109fn remote_tracking_with_verification_action(
3110 mut remote: GitRemoteTrackingStatus,
3111 trust: &RepositoryVerificationState,
3112) -> GitRemoteTrackingStatus {
3113 let remote_status = remote_tracking_status(&remote);
3114 if trust.status == remote_status && !trust.recommended_action.trim().is_empty() {
3115 remote.next_action = trust.recommended_action.clone();
3116 }
3117 remote
3118}
3119
3120#[cfg(test)]
3121mod tests {
3122 use super::*;
3123
3124 fn slow_path_bucket(row: &ShortStatusRow<'_>) -> &'static str {
3125 if row.index == b'?' && row.worktree == b'?' {
3126 "added"
3127 } else if row.index == b'D' || row.worktree == b'D' {
3128 "deleted"
3129 } else if row.index == b'A'
3130 || row.index == b'R'
3131 || row.index == b'C'
3132 || row.head_oid.is_none()
3133 {
3134 "added"
3135 } else {
3136 "modified"
3137 }
3138 }
3139
3140 fn fast_path_bucket(row: ShortStatusRow<'_>) -> &'static str {
3141 let mut changes = ChangesInfo::default();
3142 append_fast_status_row(&mut changes, row);
3143 match (
3144 changes.added.len(),
3145 changes.deleted.len(),
3146 changes.modified.len(),
3147 ) {
3148 (1, 0, 0) => "added",
3149 (0, 1, 0) => "deleted",
3150 (0, 0, 1) => "modified",
3151 other => panic!("fast path produced unexpected bucket counts: {other:?}"),
3152 }
3153 }
3154
3155 fn status_row<'a>(
3156 index: u8,
3157 worktree: u8,
3158 path: &'a [u8],
3159 in_head: bool,
3160 ) -> ShortStatusRow<'a> {
3161 ShortStatusRow {
3162 index,
3163 worktree,
3164 path,
3165 head_mode: None,
3166 index_mode: None,
3167 worktree_mode: None,
3168 head_oid: in_head.then(|| sley::ObjectId::null(sley::ObjectFormat::Sha1)),
3169 index_oid: None,
3170 submodule: None,
3171 }
3172 }
3173
3174 #[test]
3175 fn fast_short_status_agrees_with_slow_path_on_ad_rename_copy() {
3176 let cases: &[(u8, u8, bool, &str)] = &[
3177 (b'A', b'D', false, "AD: staged-add then worktree-deleted"),
3178 (b'R', b' ', true, "R: renamed"),
3179 (b'C', b' ', true, "C: copied"),
3180 (b'A', b' ', false, "A: staged add"),
3181 (b'M', b' ', true, "M: modified"),
3182 (b' ', b'M', true, "worktree-modified"),
3183 (b'D', b' ', true, "D: staged delete"),
3184 (b' ', b'D', true, "worktree delete"),
3185 (b'?', b'?', false, "untracked"),
3186 ];
3187 for &(index, worktree, in_head, label) in cases {
3188 let path = label.as_bytes();
3189 let fast = fast_path_bucket(status_row(index, worktree, path, in_head));
3190 let slow = slow_path_bucket(&status_row(index, worktree, path, in_head));
3191 assert_eq!(
3192 fast, slow,
3193 "fast and slow short-status classification disagree for {label}",
3194 );
3195 }
3196 }
3197
3198 #[test]
3199 fn status_uses_injected_repo_without_reopening_start_path() {
3200 let temp = tempfile::tempdir().expect("temp repo");
3201 repo::Repository::init_default(temp.path()).expect("init repo");
3202 let repo = Repository::open(temp.path()).expect("open repo");
3203 let bogus = temp.path().join("not-a-repo-start");
3205 let ctx = ExecutionContext::builder()
3206 .start_path(&bogus)
3207 .repo(repo)
3208 .build();
3209
3210 let report = status(
3211 &ctx,
3212 StatusOptions::new(
3213 StatusDetail::ShortText,
3214 repo::WorktreeStatusOptions::default(),
3215 )
3216 .with_start_path(&bogus),
3217 )
3218 .expect("status with injected repo must not re-open start_path");
3219
3220 assert_eq!(report.output_kind, "status");
3221 assert_eq!(
3222 report.profile.repo_open_ms, 0,
3223 "injected repo must report zero facade open cost"
3224 );
3225 assert!(!report.trust.status.is_empty());
3226 }
3227
3228 #[test]
3229 fn single_short_status_stream_builds_worktree_and_index_plan() {
3230 let temp = tempfile::tempdir().expect("temp");
3231 let root = temp.path();
3232 std::process::Command::new("git")
3233 .args(["init"])
3234 .current_dir(root)
3235 .output()
3236 .expect("git init");
3237 std::fs::write(root.join("tracked.txt"), "v1\n").unwrap();
3238 std::process::Command::new("git")
3239 .args(["add", "tracked.txt"])
3240 .current_dir(root)
3241 .output()
3242 .expect("git add");
3243 std::process::Command::new("git")
3244 .args([
3245 "-c",
3246 "user.email=t@example.com",
3247 "-c",
3248 "user.name=t",
3249 "commit",
3250 "-m",
3251 "init",
3252 ])
3253 .current_dir(root)
3254 .output()
3255 .expect("git commit");
3256 repo::Repository::init_default(root).expect("heddle init");
3257 let repo = repo::Repository::open(root).expect("open");
3258 if repo.capability() != repo::RepositoryCapability::GitOverlay {
3259 return;
3260 }
3261 std::fs::write(root.join("tracked.txt"), "v2\n").unwrap();
3262 std::fs::write(root.join("untracked.txt"), "u\n").unwrap();
3263 std::process::Command::new("git")
3264 .args(["add", "untracked.txt"])
3265 .current_dir(root)
3266 .output()
3267 .expect("stage untracked");
3268 std::fs::write(root.join("untracked.txt"), "u2\n").unwrap();
3269
3270 let snapshot = repo
3271 .git_overlay_short_status()
3272 .expect("short status")
3273 .expect("overlay short status");
3274 assert!(snapshot.index_plan_applicable);
3275 assert!(!snapshot.worktree.is_clean());
3276 assert!(!snapshot.index_staged_paths.is_empty() || !snapshot.index_extra_paths.is_empty());
3277
3278 let (worktree, plan) = super::load_git_overlay_status_and_index_plan(&repo);
3279 let worktree = worktree.expect("worktree ok").expect("some status");
3280 assert_eq!(worktree.modified.len(), snapshot.worktree.modified.len());
3281 assert_eq!(worktree.added.len(), snapshot.worktree.added.len());
3282 assert_eq!(worktree.deleted.len(), snapshot.worktree.deleted.len());
3283 assert!(plan.is_some());
3284 }
3285
3286 #[test]
3287 fn status_default_core_path_produces_complete_embedder_report() {
3288 let temp = tempfile::tempdir().expect("temp repo");
3289 repo::Repository::init_default(temp.path()).expect("init repo");
3290 let ctx = ExecutionContext::builder().start_path(temp.path()).build();
3291
3292 let report = status(
3293 &ctx,
3294 StatusOptions::new(
3295 StatusDetail::DefaultText,
3296 repo::WorktreeStatusOptions::default(),
3297 )
3298 .with_start_path(temp.path()),
3299 )
3300 .expect("core status");
3301
3302 assert_eq!(report.output_kind, "status");
3303 assert!(!report.repository_label.is_empty());
3304 assert!(!report.verification_health.status.is_empty());
3305 assert!(!report.trust.status.is_empty());
3306 assert_eq!(report.trust.machine_contract, "not_checked");
3307 assert_eq!(report.trust.machine_contract_coverage.status, "not_checked");
3308 assert!(
3309 report
3310 .trust
3311 .checks
3312 .iter()
3313 .any(|check| check.name == "Machine contract" && check.status == "not_checked")
3314 );
3315 }
3316
3317 #[test]
3318 fn verify_default_core_path_produces_complete_embedder_report() {
3319 let temp = tempfile::tempdir().expect("temp repo");
3320 repo::Repository::init_default(temp.path()).expect("init repo");
3321 let ctx = ExecutionContext::builder().start_path(temp.path()).build();
3322
3323 let report = crate::verify::verify(
3324 &ctx,
3325 crate::verify::VerifyOptions::new().with_start_path(temp.path()),
3326 )
3327 .expect("core verify");
3328
3329 assert_eq!(report.output_kind, "verify");
3330 assert!(!report.repository_label.is_empty());
3331 assert!(report.trust.heddle_initialized);
3332 assert!(!report.trust.status.is_empty());
3333 assert_eq!(report.trust.machine_contract, "not_checked");
3334 assert_eq!(report.trust.machine_contract_coverage.status, "not_checked");
3335 assert!(
3336 report
3337 .trust
3338 .checks
3339 .iter()
3340 .any(|check| check.name == "Machine contract" && check.status == "not_checked")
3341 );
3342 }
3343
3344 #[test]
3348 fn plain_git_status_serializes_empty_recommended_action_as_null() {
3349 let trust = RepositoryVerificationState {
3350 verified: true,
3351 status: "verified".to_string(),
3352 repository_mode: "plain-git".to_string(),
3353 heddle_initialized: false,
3354 git_branch: Some("main".to_string()),
3355 heddle_thread: None,
3356 worktree_dirty: false,
3357 worktree_state: "clean".to_string(),
3358 import_state: "not_applicable".to_string(),
3359 mapping_state: "not_applicable".to_string(),
3360 remote_drift: "clean".to_string(),
3361 active_operation: None,
3362 default_remote: None,
3363 clone_verification: "not_applicable".to_string(),
3364 machine_contract: "not_checked".to_string(),
3365 machine_contract_coverage: MachineContractInput::default().coverage,
3366 workflow_status: "clean".to_string(),
3367 workflow_summary: "no ready threads are waiting to land".to_string(),
3368 summary: "plain Git repository".to_string(),
3369 recommended_action: String::new(),
3370 recommended_action_template: None,
3371 recovery_commands: Vec::new(),
3372 recovery_action_templates: Vec::new(),
3373 checks: Vec::new(),
3374 };
3375 let output = PlainGitStatusReport {
3376 output_kind: "status",
3377 repository_capability: "plain-git".to_string(),
3378 repository_label: repository_mode_label("plain-git", "git-only"),
3379 storage_model: "git-only".to_string(),
3380 heddle_initialized: false,
3381 git_branch: Some("main".to_string()),
3382 path: "/tmp/repo".to_string(),
3383 recommended_action: trust.recommended_action.clone(),
3384 recommended_action_template: trust.recommended_action_template.clone(),
3385 recovery_commands: trust.recovery_commands.clone(),
3386 recovery_action_templates: trust.recovery_action_templates.clone(),
3387 thread_health: trust.status.clone(),
3388 changed_path_count: 0,
3389 changes: ChangesInfo::default(),
3390 git_index: None,
3391 trust,
3392 };
3393
3394 let value = serde_json::to_value(&output).unwrap();
3395 assert!(value["recommended_action"].is_null());
3396 assert!(value["verification"]["recommended_action"].is_null());
3397 }
3398
3399 #[test]
3400 fn plain_git_status_report_assembles_for_git_only_worktree() {
3401 let temp = tempfile::tempdir().expect("temp dir");
3402 let root = temp.path();
3403 SleyRepository::init(root).expect("init plain git repository");
3404 fs::write(root.join("README"), "hello\n").expect("write file");
3405
3406 let report = plain_git_status_report(root, &MachineContractInput::default())
3407 .expect("plain git status")
3408 .expect("probe present");
3409 assert_eq!(report.output_kind, "status");
3410 assert_eq!(report.repository_capability, "plain-git");
3411 assert_eq!(report.storage_model, "git-only");
3412 assert!(!report.heddle_initialized);
3413 assert!(!report.repository_label.is_empty());
3414 assert!(!report.trust.status.is_empty());
3415 assert!(report.changed_path_count > 0 || !report.changes.is_empty());
3416 }
3417
3418 #[test]
3419 fn plain_git_status_report_skips_heddle_repos() {
3420 let temp = tempfile::tempdir().expect("temp repo");
3421 repo::Repository::init_default(temp.path()).expect("init repo");
3422 let report = plain_git_status_report(temp.path(), &MachineContractInput::default())
3423 .expect("plain git status");
3424 assert!(report.is_none());
3425 }
3426}