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(
131 source_root: &Path,
132 dest_root: &Path,
133 config: &WorktreesConfig,
134) -> CoreResult<()> {
135 let runner = SystemProcessRunner;
136 init_worktree_with_runner(&runner, source_root, dest_root, config)
137}
138
139pub(crate) fn init_worktree_with_runner(
141 runner: &dyn ProcessRunner,
142 source_root: &Path,
143 dest_root: &Path,
144 config: &WorktreesConfig,
145) -> CoreResult<()> {
146 copy_include_files(&config.init, source_root, dest_root)?;
148
149 run_setup_with_runner(runner, dest_root, config)?;
151
152 Ok(())
153}
154
155pub fn run_worktree_setup(worktree_root: &Path, config: &WorktreesConfig) -> CoreResult<()> {
163 let runner = SystemProcessRunner;
164 run_setup_with_runner(&runner, worktree_root, config)
165}
166
167pub(crate) fn run_setup_with_runner(
169 runner: &dyn ProcessRunner,
170 worktree_root: &Path,
171 config: &WorktreesConfig,
172) -> CoreResult<()> {
173 let Some(setup) = &config.init.setup else {
174 return Ok(());
175 };
176
177 if setup.is_empty() {
178 return Ok(());
179 }
180
181 let commands = setup.as_commands();
182
183 let (shell, flag) = if cfg!(windows) {
184 ("cmd", "/C")
185 } else {
186 ("sh", "-c")
187 };
188
189 for cmd in &commands {
190 let request = ProcessRequest::new(shell)
191 .arg(flag)
192 .arg(*cmd)
193 .current_dir(worktree_root);
194
195 let output = runner.run(&request).map_err(|err| {
196 CoreError::process(format!(
197 "Cannot run worktree setup command '{}' in '{}'.\n\
198 Process failed to start: {err}\n\
199 Fix: ensure the command exists and the worktree path is accessible.",
200 cmd,
201 worktree_root.display(),
202 ))
203 })?;
204
205 if !output.success {
206 let detail = if !output.stderr.trim().is_empty() {
207 output.stderr.trim().to_string()
208 } else if !output.stdout.trim().is_empty() {
209 output.stdout.trim().to_string()
210 } else {
211 format!("exit code {}", output.exit_code)
212 };
213
214 return Err(CoreError::process(format!(
215 "Worktree setup command failed: '{}'\n\
216 Working directory: {}\n\
217 Output: {detail}\n\
218 Fix: verify the command works when run manually in that directory.",
219 cmd,
220 worktree_root.display(),
221 )));
222 }
223 }
224
225 Ok(())
226}
227
228fn collect_patterns(config: &WorktreeInitConfig, source_root: &Path) -> CoreResult<Vec<String>> {
232 let mut patterns: Vec<String> = config.include.clone();
233
234 let include_path = source_root.join(WORKTREE_INCLUDE_FILE);
235 if include_path.is_file() {
236 let content = fs::read_to_string(&include_path).map_err(|err| {
237 CoreError::io(
238 format!(
239 "Cannot read '{}' in '{}'.\n\
240 Fix: ensure the file is readable or remove it.",
241 WORKTREE_INCLUDE_FILE,
242 source_root.display(),
243 ),
244 err,
245 )
246 })?;
247 let file_patterns = parse_worktree_include_file(&content);
248 patterns.extend(file_patterns);
249 }
250
251 Ok(patterns)
252}
253
254fn expand_globs(patterns: &[String], source_root: &Path) -> CoreResult<Vec<PathBuf>> {
260 let mut result = BTreeSet::new();
261
262 let canonical_root = source_root.canonicalize().map_err(|err| {
264 CoreError::io(
265 format!(
266 "Cannot canonicalize source root '{}'.\n\
267 Fix: ensure the directory exists and is readable.",
268 source_root.display(),
269 ),
270 err,
271 )
272 })?;
273
274 for pattern in patterns {
275 if pattern.is_empty() {
276 continue;
277 }
278
279 let escaped_root = glob::Pattern::escape(&source_root.to_string_lossy());
280 let full_pattern = format!("{escaped_root}/{pattern}");
281
282 let entries = glob::glob(&full_pattern).map_err(|err| {
283 CoreError::validation(format!(
284 "Invalid glob pattern '{}' in worktree include configuration.\n\
285 Pattern error: {err}\n\
286 Fix: use valid glob syntax (e.g. '*.env', '.envrc', 'config/*.toml').",
287 pattern,
288 ))
289 })?;
290
291 for entry in entries {
292 let path = entry.map_err(|err| {
293 CoreError::io(
294 format!(
295 "Error reading filesystem while expanding glob '{}'.\n\
296 Fix: ensure the source directory '{}' is readable.",
297 pattern,
298 source_root.display(),
299 ),
300 err.into_error(),
301 )
302 })?;
303
304 if !path.is_file() {
306 continue;
307 }
308
309 let Ok(canonical_path) = path.canonicalize() else {
312 continue;
313 };
314 let Ok(rel) = canonical_path.strip_prefix(&canonical_root) else {
315 continue;
316 };
317
318 result.insert(rel.to_path_buf());
319 }
320 }
321
322 Ok(result.into_iter().collect())
323}
324
325#[cfg(test)]
326#[path = "worktree_init_tests.rs"]
327mod worktree_init_tests;