Skip to main content

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