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