ito_core/
worktree_init.rs1use std::collections::BTreeSet;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use ito_config::types::{WorktreeInitConfig, WorktreesConfig};
21
22use crate::errors::{CoreError, CoreResult};
23use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
24
25const WORKTREE_INCLUDE_FILE: &str = ".worktree-include";
27
28pub fn resolve_include_files(
35 config: &WorktreeInitConfig,
36 source_root: &Path,
37) -> CoreResult<Vec<PathBuf>> {
38 let patterns = collect_patterns(config, source_root)?;
39 expand_globs(&patterns, source_root)
40}
41
42pub fn parse_worktree_include_file(content: &str) -> Vec<String> {
46 content
47 .lines()
48 .map(str::trim)
49 .filter(|line| !line.is_empty() && !line.starts_with('#'))
50 .map(String::from)
51 .collect()
52}
53
54pub fn copy_include_files(
63 config: &WorktreeInitConfig,
64 source_root: &Path,
65 dest_root: &Path,
66) -> CoreResult<Vec<PathBuf>> {
67 let relative_paths = resolve_include_files(config, source_root)?;
68 let mut copied = Vec::new();
69
70 for rel_path in &relative_paths {
71 let src = source_root.join(rel_path);
72 let dst = dest_root.join(rel_path);
73
74 if !src.exists() {
75 continue;
76 }
77
78 if dst.exists() {
80 continue;
81 }
82
83 if let Some(parent) = dst.parent() {
85 fs::create_dir_all(parent).map_err(|err| {
86 CoreError::io(
87 format!(
88 "Cannot create directory '{}' in worktree '{}' for include file '{}'.\n\
89 Fix: ensure the worktree path is writable.",
90 parent.display(),
91 dest_root.display(),
92 rel_path.display(),
93 ),
94 err,
95 )
96 })?;
97 }
98
99 fs::copy(&src, &dst).map_err(|err| {
100 CoreError::io(
101 format!(
102 "Cannot copy '{}' to '{}' during worktree initialization.\n\
103 Fix: ensure the source file is readable and the destination is writable.",
104 src.display(),
105 dst.display(),
106 ),
107 err,
108 )
109 })?;
110
111 copied.push(rel_path.clone());
112 }
113
114 Ok(copied)
115}
116
117pub fn init_worktree(
133 source_root: &Path,
134 dest_root: &Path,
135 config: &WorktreesConfig,
136) -> CoreResult<()> {
137 let runner = SystemProcessRunner;
138 init_worktree_with_runner(&runner, source_root, dest_root, config)
139}
140
141pub(crate) fn init_worktree_with_runner(
143 runner: &dyn ProcessRunner,
144 source_root: &Path,
145 dest_root: &Path,
146 config: &WorktreesConfig,
147) -> CoreResult<()> {
148 copy_include_files(&config.init, source_root, dest_root)?;
150
151 run_setup_with_runner(runner, dest_root, config)?;
153
154 Ok(())
155}
156
157pub fn run_worktree_setup(worktree_root: &Path, config: &WorktreesConfig) -> CoreResult<()> {
165 let runner = SystemProcessRunner;
166 run_setup_with_runner(&runner, worktree_root, config)
167}
168
169pub(crate) fn run_setup_with_runner(
171 runner: &dyn ProcessRunner,
172 worktree_root: &Path,
173 config: &WorktreesConfig,
174) -> CoreResult<()> {
175 let Some(setup) = &config.init.setup else {
176 return Ok(());
177 };
178
179 if setup.is_empty() {
180 return Ok(());
181 }
182
183 let commands = setup.as_commands();
184
185 let (shell, flag) = if cfg!(windows) {
186 ("cmd", "/C")
187 } else {
188 ("sh", "-c")
189 };
190
191 for cmd in &commands {
192 let request = ProcessRequest::new(shell)
193 .arg(flag)
194 .arg(*cmd)
195 .current_dir(worktree_root);
196
197 let output = runner.run(&request).map_err(|err| {
198 CoreError::process(format!(
199 "Cannot run worktree setup command '{}' in '{}'.\n\
200 Process failed to start: {err}\n\
201 Fix: ensure the command exists and the worktree path is accessible.",
202 cmd,
203 worktree_root.display(),
204 ))
205 })?;
206
207 if !output.success {
208 let detail = if !output.stderr.trim().is_empty() {
209 output.stderr.trim().to_string()
210 } else if !output.stdout.trim().is_empty() {
211 output.stdout.trim().to_string()
212 } else {
213 format!("exit code {}", output.exit_code)
214 };
215
216 return Err(CoreError::process(format!(
217 "Worktree setup command failed: '{}'\n\
218 Working directory: {}\n\
219 Output: {detail}\n\
220 Fix: verify the command works when run manually in that directory.",
221 cmd,
222 worktree_root.display(),
223 )));
224 }
225 }
226
227 Ok(())
228}
229
230fn collect_patterns(config: &WorktreeInitConfig, source_root: &Path) -> CoreResult<Vec<String>> {
234 let mut patterns: Vec<String> = config.include.clone();
235
236 let include_path = source_root.join(WORKTREE_INCLUDE_FILE);
237 if include_path.is_file() {
238 let content = fs::read_to_string(&include_path).map_err(|err| {
239 CoreError::io(
240 format!(
241 "Cannot read '{}' in '{}'.\n\
242 Fix: ensure the file is readable or remove it.",
243 WORKTREE_INCLUDE_FILE,
244 source_root.display(),
245 ),
246 err,
247 )
248 })?;
249 let file_patterns = parse_worktree_include_file(&content);
250 patterns.extend(file_patterns);
251 }
252
253 Ok(patterns)
254}
255
256fn expand_globs(patterns: &[String], source_root: &Path) -> CoreResult<Vec<PathBuf>> {
262 let mut result = BTreeSet::new();
263
264 let canonical_root = source_root.canonicalize().map_err(|err| {
266 CoreError::io(
267 format!(
268 "Cannot canonicalize source root '{}'.\n\
269 Fix: ensure the directory exists and is readable.",
270 source_root.display(),
271 ),
272 err,
273 )
274 })?;
275
276 for pattern in patterns {
277 if pattern.is_empty() {
278 continue;
279 }
280
281 let escaped_root = glob::Pattern::escape(&source_root.to_string_lossy());
282 let full_pattern = format!("{escaped_root}/{pattern}");
283
284 let entries = glob::glob(&full_pattern).map_err(|err| {
285 CoreError::validation(format!(
286 "Invalid glob pattern '{}' in worktree include configuration.\n\
287 Pattern error: {err}\n\
288 Fix: use valid glob syntax (e.g. '*.env', '.envrc', 'config/*.toml').",
289 pattern,
290 ))
291 })?;
292
293 for entry in entries {
294 let path = entry.map_err(|err| {
295 CoreError::io(
296 format!(
297 "Error reading filesystem while expanding glob '{}'.\n\
298 Fix: ensure the source directory '{}' is readable.",
299 pattern,
300 source_root.display(),
301 ),
302 err.into_error(),
303 )
304 })?;
305
306 if !path.is_file() {
308 continue;
309 }
310
311 let Ok(canonical_path) = path.canonicalize() else {
314 continue;
315 };
316 let Ok(rel) = canonical_path.strip_prefix(&canonical_root) else {
317 continue;
318 };
319
320 result.insert(rel.to_path_buf());
321 }
322 }
323
324 Ok(result.into_iter().collect())
325}
326
327#[cfg(test)]
328#[path = "worktree_init_tests.rs"]
329mod worktree_init_tests;