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