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