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