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