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