Skip to main content

ito_core/
worktree_validate.rs

1//! Read-only validation of whether the current checkout matches an expected change worktree.
2
3use crate::repo_paths::{ResolvedWorktreePaths, WorktreeFeature, WorktreeSelector};
4use std::path::{Component, Path, PathBuf};
5
6/// Machine-readable worktree validation result for humans and hook callers.
7#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
8#[serde(rename_all = "camelCase")]
9pub struct WorktreeValidation {
10    /// Validation status.
11    pub status: WorktreeValidationStatus,
12    /// Change ID that was validated.
13    pub change_id: String,
14    /// Current checkout/worktree path that was inspected.
15    pub current_path: PathBuf,
16    /// Expected dedicated change worktree path when worktrees are enabled.
17    pub expected_path: Option<PathBuf>,
18    /// Human-readable explanation of the outcome.
19    pub message: String,
20}
21
22/// Distinct outcomes for worktree validation.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum WorktreeValidationStatus {
26    /// Current checkout is acceptable for the requested change.
27    Ok,
28    /// Worktree validation is disabled by configuration.
29    Disabled,
30    /// Current checkout is the main/control worktree and must not be used.
31    MainCheckout,
32    /// Current checkout is outside main/control, but path/branch does not match the change.
33    Mismatch,
34}
35
36/// Validate that the current checkout is an acceptable worktree for `change_id`.
37///
38/// This is the read-only validator intended for CLI commands and harness/plugin
39/// hooks that need to distinguish:
40/// - hard failures on the main/control checkout,
41/// - advisory mismatches outside the main/control checkout, and
42/// - disabled validation when worktrees are off in config.
43pub fn validate_change_worktree(
44    change_id: &str,
45    current_path: &Path,
46    worktree_paths: &ResolvedWorktreePaths,
47    current_branch: Option<&str>,
48) -> WorktreeValidation {
49    let expected_path =
50        worktree_paths.path_for_selector(&WorktreeSelector::Change(change_id.to_string()));
51
52    let current_path = current_path.to_path_buf();
53
54    let WorktreeFeature::Enabled = worktree_paths.feature else {
55        return WorktreeValidation {
56            status: WorktreeValidationStatus::Disabled,
57            change_id: change_id.to_string(),
58            current_path,
59            expected_path,
60            message:
61                "Worktree validation is disabled by configuration (`worktrees.enabled=false`)."
62                    .to_string(),
63        };
64    };
65
66    if is_main_checkout(
67        &current_path,
68        worktree_paths.main_worktree_root.as_deref(),
69        worktree_paths.worktrees_root.as_deref(),
70    ) {
71        let message = main_checkout_message(change_id, &current_path, expected_path.as_deref());
72        return WorktreeValidation {
73            status: WorktreeValidationStatus::MainCheckout,
74            change_id: change_id.to_string(),
75            current_path,
76            expected_path,
77            message,
78        };
79    }
80
81    if path_or_branch_matches_change_id(&current_path, current_branch, change_id) {
82        return WorktreeValidation {
83            status: WorktreeValidationStatus::Ok,
84            change_id: change_id.to_string(),
85            current_path,
86            expected_path,
87            message: format!("Current worktree is valid for change '{change_id}'."),
88        };
89    }
90
91    let message = mismatch_message(
92        change_id,
93        &current_path,
94        current_branch,
95        expected_path.as_deref(),
96    );
97
98    WorktreeValidation {
99        status: WorktreeValidationStatus::Mismatch,
100        change_id: change_id.to_string(),
101        current_path,
102        expected_path,
103        message,
104    }
105}
106
107/// True when `current_path` is the configured main / control checkout.
108///
109/// `main_worktree_root` is the configured main worktree root (typically
110/// `<base>/<default_branch>` for `bare_control_siblings`, or the project
111/// root itself for in-checkout strategies). `worktrees_root` is the
112/// directory that holds change worktrees; paths under it are NOT classified
113/// as "main".
114///
115/// `pub(crate)` so other repo-validation rules in [`crate::validate_repo`]
116/// can reuse the same definition without re-implementing it. Promote to
117/// `pub` only when an external consumer needs it.
118#[must_use]
119pub(crate) fn is_main_checkout(
120    current_path: &Path,
121    main_worktree_root: Option<&Path>,
122    worktrees_root: Option<&Path>,
123) -> bool {
124    let Some(main_root) = main_worktree_root else {
125        return false;
126    };
127
128    if current_path == main_root {
129        return true;
130    }
131
132    current_path.starts_with(main_root)
133        && worktrees_root
134            .map(|root| !current_path.starts_with(root))
135            .unwrap_or(true)
136}
137
138fn path_or_branch_matches_change_id(
139    current_path: &Path,
140    current_branch: Option<&str>,
141    change_id: &str,
142) -> bool {
143    current_branch
144        .map(|branch| branch_starts_with_change_id(branch, change_id))
145        .unwrap_or(false)
146        || current_path
147            .components()
148            .filter_map(|component| match component {
149                Component::Normal(segment) => segment.to_str(),
150                Component::Prefix(_)
151                | Component::RootDir
152                | Component::CurDir
153                | Component::ParentDir => None,
154            })
155            .any(|segment| segment_starts_with_change_id(segment, change_id))
156}
157
158fn branch_starts_with_change_id(branch: &str, change_id: &str) -> bool {
159    segment_starts_with_change_id(branch, change_id)
160}
161
162fn segment_starts_with_change_id(segment: &str, change_id: &str) -> bool {
163    segment == change_id
164        || segment.starts_with(&format!("{change_id}-"))
165        || segment.starts_with(&format!("{change_id}_"))
166}
167
168fn main_checkout_message(
169    change_id: &str,
170    current_path: &Path,
171    expected_path: Option<&Path>,
172) -> String {
173    match expected_path {
174        Some(expected_path) => format!(
175            "Current checkout '{}' is the main/control worktree. Change work for '{change_id}' must run from a dedicated change worktree, such as '{}'.",
176            current_path.display(),
177            expected_path.display()
178        ),
179        None => format!(
180            "Current checkout '{}' is the main/control worktree. Change work for '{change_id}' must run from a dedicated change worktree.",
181            current_path.display()
182        ),
183    }
184}
185
186fn mismatch_message(
187    change_id: &str,
188    current_path: &Path,
189    current_branch: Option<&str>,
190    expected_path: Option<&Path>,
191) -> String {
192    let branch_note = current_branch
193        .filter(|branch| !branch.trim().is_empty())
194        .map(|branch| format!(" Current branch: '{branch}'."))
195        .unwrap_or_default();
196
197    let expected_note = expected_path
198        .map(|expected| format!(" Expected worktree: '{}'.", expected.display()))
199        .unwrap_or_default();
200
201    format!(
202        "Current checkout '{}' does not match change '{change_id}'. The branch or worktree path should include the full change ID.{branch_note}{expected_note}",
203        current_path.display()
204    )
205}
206
207#[cfg(test)]
208#[path = "worktree_validate_tests.rs"]
209mod tests;