1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
//! Change worktree initialization: file copy-over, setup command execution,
//! and include-pattern resolution.
//!
//! Resolves include patterns from two sources:
//!
//! 1. `worktrees.init.include` in the Ito config — a list of glob patterns.
//! 2. `.worktree-include` file at the repo root — one glob per line, `#`-prefixed
//! comment lines and blank lines are ignored.
//!
//! The union of both sources is used. Patterns are expanded against the source
//! worktree root and matched files are copied into the destination worktree.
//!
//! After file copy, an optional setup command (or list of commands) from
//! `worktrees.init.setup` is executed inside the new worktree.
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use ito_config::types::{WorktreeInitConfig, WorktreesConfig};
use crate::errors::{CoreError, CoreResult};
use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
/// Name of the optional include-file at the repo root.
const WORKTREE_INCLUDE_FILE: &str = ".worktree-include";
/// Resolve all include patterns from config and the `.worktree-include` file,
/// then expand them against `source_root` and return the set of matching
/// relative paths (deduplicated, sorted).
///
/// Missing `.worktree-include` file is not an error — only config patterns are
/// used. Empty patterns are silently ignored.
pub fn resolve_include_files(
config: &WorktreeInitConfig,
source_root: &Path,
) -> CoreResult<Vec<PathBuf>> {
let patterns = collect_patterns(config, source_root)?;
expand_globs(&patterns, source_root)
}
/// Parse a `.worktree-include` file into a list of non-empty, non-comment lines.
///
/// Lines starting with `#` (after trimming) and blank lines are ignored.
pub fn parse_worktree_include_file(content: &str) -> Vec<String> {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(String::from)
.collect()
}
/// Copy matched include files from `source_root` into `dest_root`, preserving
/// relative paths.
///
/// Destination files that already exist are **skipped** to preserve any edits
/// the user may have made (e.g. during partial-init recovery). Missing source
/// files are also silently skipped.
///
/// Returns the list of files that were actually copied.
pub fn copy_include_files(
config: &WorktreeInitConfig,
source_root: &Path,
dest_root: &Path,
) -> CoreResult<Vec<PathBuf>> {
let relative_paths = resolve_include_files(config, source_root)?;
let mut copied = Vec::new();
for rel_path in &relative_paths {
let src = source_root.join(rel_path);
let dst = dest_root.join(rel_path);
if !src.exists() {
continue;
}
// Skip files that already exist in the destination to preserve user edits.
if dst.exists() {
continue;
}
// Ensure parent directory exists in the destination.
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(|err| {
CoreError::io(
format!(
"Cannot create directory '{}' in worktree '{}' for include file '{}'.\n\
Fix: ensure the worktree path is writable.",
parent.display(),
dest_root.display(),
rel_path.display(),
),
err,
)
})?;
}
fs::copy(&src, &dst).map_err(|err| {
CoreError::io(
format!(
"Cannot copy '{}' to '{}' during worktree initialization.\n\
Fix: ensure the source file is readable and the destination is writable.",
src.display(),
dst.display(),
),
err,
)
})?;
copied.push(rel_path.clone());
}
Ok(copied)
}
/// Initialize a new change worktree: copy include files, then run setup commands.
///
/// This is the full initialization sequence called after a worktree is created:
///
/// 1. Copy include files from `source_root` into `dest_root` (idempotent).
/// 2. Run setup commands from `config.init.setup` in `dest_root` (if configured).
///
/// Coordination symlink wiring is handled separately by the caller because it
/// requires the `.ito/` path context which this function does not own. Today
/// that means `ito worktree ensure` wires new and partially initialized
/// worktrees, while `ito init --update` can repair the current worktree.
///
/// # Errors
///
/// Returns [`CoreError`] if file copy fails or a setup command exits non-zero.
pub fn init_worktree(
source_root: &Path,
dest_root: &Path,
config: &WorktreesConfig,
) -> CoreResult<()> {
let runner = SystemProcessRunner;
init_worktree_with_runner(&runner, source_root, dest_root, config)
}
/// Testable inner implementation of [`init_worktree`].
pub(crate) fn init_worktree_with_runner(
runner: &dyn ProcessRunner,
source_root: &Path,
dest_root: &Path,
config: &WorktreesConfig,
) -> CoreResult<()> {
// Step 1: Copy include files.
copy_include_files(&config.init, source_root, dest_root)?;
// Step 2: Run setup commands (if any).
run_setup_with_runner(runner, dest_root, config)?;
Ok(())
}
/// Run the configured setup command(s) inside `worktree_root`.
///
/// Each command is executed as `sh -c <command>` with `worktree_root` as the
/// working directory. Commands run in order; if any exits non-zero, subsequent
/// commands are skipped and an error is returned.
///
/// Returns `Ok(())` when no setup is configured or all commands succeed.
pub fn run_worktree_setup(worktree_root: &Path, config: &WorktreesConfig) -> CoreResult<()> {
let runner = SystemProcessRunner;
run_setup_with_runner(&runner, worktree_root, config)
}
/// Testable inner implementation of [`run_worktree_setup`].
pub(crate) fn run_setup_with_runner(
runner: &dyn ProcessRunner,
worktree_root: &Path,
config: &WorktreesConfig,
) -> CoreResult<()> {
let Some(setup) = &config.init.setup else {
return Ok(());
};
if setup.is_empty() {
return Ok(());
}
let commands = setup.as_commands();
let (shell, flag) = if cfg!(windows) {
("cmd", "/C")
} else {
("sh", "-c")
};
for cmd in &commands {
let request = ProcessRequest::new(shell)
.arg(flag)
.arg(*cmd)
.current_dir(worktree_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot run worktree setup command '{}' in '{}'.\n\
Process failed to start: {err}\n\
Fix: ensure the command exists and the worktree path is accessible.",
cmd,
worktree_root.display(),
))
})?;
if !output.success {
let detail = if !output.stderr.trim().is_empty() {
output.stderr.trim().to_string()
} else if !output.stdout.trim().is_empty() {
output.stdout.trim().to_string()
} else {
format!("exit code {}", output.exit_code)
};
return Err(CoreError::process(format!(
"Worktree setup command failed: '{}'\n\
Working directory: {}\n\
Output: {detail}\n\
Fix: verify the command works when run manually in that directory.",
cmd,
worktree_root.display(),
)));
}
}
Ok(())
}
// ── Internal helpers ─────────────────────────────────────────────────────────
/// Collect patterns from both config and the `.worktree-include` file.
fn collect_patterns(config: &WorktreeInitConfig, source_root: &Path) -> CoreResult<Vec<String>> {
let mut patterns: Vec<String> = config.include.clone();
let include_path = source_root.join(WORKTREE_INCLUDE_FILE);
if include_path.is_file() {
let content = fs::read_to_string(&include_path).map_err(|err| {
CoreError::io(
format!(
"Cannot read '{}' in '{}'.\n\
Fix: ensure the file is readable or remove it.",
WORKTREE_INCLUDE_FILE,
source_root.display(),
),
err,
)
})?;
let file_patterns = parse_worktree_include_file(&content);
patterns.extend(file_patterns);
}
Ok(patterns)
}
/// Expand glob patterns against `source_root` and return deduplicated relative paths.
///
/// All resolved paths are canonicalized and verified to remain under
/// `source_root`. Patterns that resolve outside the source root (e.g. via
/// `..` components) are silently skipped to prevent path-traversal attacks.
fn expand_globs(patterns: &[String], source_root: &Path) -> CoreResult<Vec<PathBuf>> {
let mut result = BTreeSet::new();
// Canonicalize source_root so that symlink-based escapes are caught.
let canonical_root = source_root.canonicalize().map_err(|err| {
CoreError::io(
format!(
"Cannot canonicalize source root '{}'.\n\
Fix: ensure the directory exists and is readable.",
source_root.display(),
),
err,
)
})?;
for pattern in patterns {
if pattern.is_empty() {
continue;
}
let escaped_root = glob::Pattern::escape(&source_root.to_string_lossy());
let full_pattern = format!("{escaped_root}/{pattern}");
let entries = glob::glob(&full_pattern).map_err(|err| {
CoreError::validation(format!(
"Invalid glob pattern '{}' in worktree include configuration.\n\
Pattern error: {err}\n\
Fix: use valid glob syntax (e.g. '*.env', '.envrc', 'config/*.toml').",
pattern,
))
})?;
for entry in entries {
let path = entry.map_err(|err| {
CoreError::io(
format!(
"Error reading filesystem while expanding glob '{}'.\n\
Fix: ensure the source directory '{}' is readable.",
pattern,
source_root.display(),
),
err.into_error(),
)
})?;
// Only include files, not directories.
if !path.is_file() {
continue;
}
// Canonicalize the matched path and verify it is under source_root.
// This prevents path-traversal via `..` or symlinks.
let Ok(canonical_path) = path.canonicalize() else {
continue;
};
let Ok(rel) = canonical_path.strip_prefix(&canonical_root) else {
continue;
};
result.insert(rel.to_path_buf());
}
}
Ok(result.into_iter().collect())
}
#[cfg(test)]
#[path = "worktree_init_tests.rs"]
mod worktree_init_tests;