Skip to main content

ito_core/
worktree_init.rs

1//! Change worktree initialization: file copy-over, setup command execution,
2//! and include-pattern resolution.
3//!
4//! Resolves include patterns from two sources:
5//!
6//! 1. `worktrees.init.include` in the Ito config — a list of glob patterns.
7//! 2. `.worktree-include` file at the repo root — one glob per line, `#`-prefixed
8//!    comment lines and blank lines are ignored.
9//!
10//! The union of both sources is used. Patterns are expanded against the source
11//! worktree root and matched files are copied into the destination worktree.
12//!
13//! After file copy, an optional setup command (or list of commands) from
14//! `worktrees.init.setup` is executed inside the new worktree.
15
16use 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
25/// Name of the optional include-file at the repo root.
26const WORKTREE_INCLUDE_FILE: &str = ".worktree-include";
27
28/// Resolve all include patterns from config and the `.worktree-include` file,
29/// then expand them against `source_root` and return the set of matching
30/// relative paths (deduplicated, sorted).
31///
32/// Missing `.worktree-include` file is not an error — only config patterns are
33/// used. Empty patterns are silently ignored.
34pub 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
42/// Parse a `.worktree-include` file into a list of non-empty, non-comment lines.
43///
44/// Lines starting with `#` (after trimming) and blank lines are ignored.
45pub 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
54/// Copy matched include files from `source_root` into `dest_root`, preserving
55/// relative paths.
56///
57/// Destination files that already exist are **skipped** to preserve any edits
58/// the user may have made (e.g. during partial-init recovery).  Missing source
59/// files are also silently skipped.
60///
61/// Returns the list of files that were actually copied.
62pub 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        // Skip files that already exist in the destination to preserve user edits.
79        if dst.exists() {
80            continue;
81        }
82
83        // Ensure parent directory exists in the destination.
84        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
117/// Initialize a new change worktree: copy include files, then run setup commands.
118///
119/// This is the full initialization sequence called after a worktree is created:
120///
121/// 1. Copy include files from `source_root` into `dest_root` (idempotent).
122/// 2. Run setup commands from `config.init.setup` in `dest_root` (if configured).
123///
124/// Coordination symlink wiring is handled separately by the caller because it
125/// requires the `.ito/` path context which this function does not own.
126///
127/// # Errors
128///
129/// Returns [`CoreError`] if file copy fails or a setup command exits non-zero.
130pub 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
139/// Testable inner implementation of [`init_worktree`].
140pub(crate) fn init_worktree_with_runner(
141    runner: &dyn ProcessRunner,
142    source_root: &Path,
143    dest_root: &Path,
144    config: &WorktreesConfig,
145) -> CoreResult<()> {
146    // Step 1: Copy include files.
147    copy_include_files(&config.init, source_root, dest_root)?;
148
149    // Step 2: Run setup commands (if any).
150    run_setup_with_runner(runner, dest_root, config)?;
151
152    Ok(())
153}
154
155/// Run the configured setup command(s) inside `worktree_root`.
156///
157/// Each command is executed as `sh -c <command>` with `worktree_root` as the
158/// working directory. Commands run in order; if any exits non-zero, subsequent
159/// commands are skipped and an error is returned.
160///
161/// Returns `Ok(())` when no setup is configured or all commands succeed.
162pub 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
167/// Testable inner implementation of [`run_worktree_setup`].
168pub(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
228// ── Internal helpers ─────────────────────────────────────────────────────────
229
230/// Collect patterns from both config and the `.worktree-include` file.
231fn 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
254/// Expand glob patterns against `source_root` and return deduplicated relative paths.
255///
256/// All resolved paths are canonicalized and verified to remain under
257/// `source_root`. Patterns that resolve outside the source root (e.g. via
258/// `..` components) are silently skipped to prevent path-traversal attacks.
259fn expand_globs(patterns: &[String], source_root: &Path) -> CoreResult<Vec<PathBuf>> {
260    let mut result = BTreeSet::new();
261
262    // Canonicalize source_root so that symlink-based escapes are caught.
263    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            // Only include files, not directories.
305            if !path.is_file() {
306                continue;
307            }
308
309            // Canonicalize the matched path and verify it is under source_root.
310            // This prevents path-traversal via `..` or symlinks.
311            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;