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