Skip to main content

heddle_core/
status.rs

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