ito_core/
worktree_validate.rs1use crate::repo_paths::{ResolvedWorktreePaths, WorktreeFeature, WorktreeSelector};
4use std::path::{Component, Path, PathBuf};
5
6#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
8#[serde(rename_all = "camelCase")]
9pub struct WorktreeValidation {
10 pub status: WorktreeValidationStatus,
12 pub change_id: String,
14 pub current_path: PathBuf,
16 pub expected_path: Option<PathBuf>,
18 pub message: String,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum WorktreeValidationStatus {
26 Ok,
28 Disabled,
30 MainCheckout,
32 Mismatch,
34}
35
36pub 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 ¤t_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, ¤t_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(¤t_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 ¤t_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#[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;