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. Today
126/// that means `ito worktree ensure` wires new and partially initialized
127/// worktrees, while `ito init --update` can repair the current worktree.
128///
129/// # Errors
130///
131/// Returns [`CoreError`] if file copy fails or a setup command exits non-zero.
132pub 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
141/// Testable inner implementation of [`init_worktree`].
142pub(crate) fn init_worktree_with_runner(
143    runner: &dyn ProcessRunner,
144    source_root: &Path,
145    dest_root: &Path,
146    config: &WorktreesConfig,
147) -> CoreResult<()> {
148    // Step 1: Copy include files.
149    copy_include_files(&config.init, source_root, dest_root)?;
150
151    // Step 2: Run setup commands (if any).
152    run_setup_with_runner(runner, dest_root, config)?;
153
154    Ok(())
155}
156
157/// Run the configured setup command(s) inside `worktree_root`.
158///
159/// Each command is executed as `sh -c <command>` with `worktree_root` as the
160/// working directory. Commands run in order; if any exits non-zero, subsequent
161/// commands are skipped and an error is returned.
162///
163/// Returns `Ok(())` when no setup is configured or all commands succeed.
164pub 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
169/// Testable inner implementation of [`run_worktree_setup`].
170pub(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
230// ── Internal helpers ─────────────────────────────────────────────────────────
231
232/// Collect patterns from both config and the `.worktree-include` file.
233fn 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
256/// Expand glob patterns against `source_root` and return deduplicated relative paths.
257///
258/// All resolved paths are canonicalized and verified to remain under
259/// `source_root`. Patterns that resolve outside the source root (e.g. via
260/// `..` components) are silently skipped to prevent path-traversal attacks.
261fn expand_globs(patterns: &[String], source_root: &Path) -> CoreResult<Vec<PathBuf>> {
262    let mut result = BTreeSet::new();
263
264    // Canonicalize source_root so that symlink-based escapes are caught.
265    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            // Only include files, not directories.
307            if !path.is_file() {
308                continue;
309            }
310
311            // Canonicalize the matched path and verify it is under source_root.
312            // This prevents path-traversal via `..` or symlinks.
313            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;