Skip to main content

heddle_core/
status.rs

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