1use std::path::{Path, PathBuf};
9
10use ito_config::types::ItoConfig;
11
12#[cfg(feature = "coordination-branch")]
13use crate::coordination_worktree::repair_current_worktree_coordination_links;
14use crate::errors::{CoreError, CoreResult};
15use crate::implementation_readiness::{
16 ReadinessPhase, ReadinessReport, ReadinessRequest, evaluate_execute_from_prepare,
17 evaluate_readiness,
18};
19use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
20use crate::repo_paths::{ResolvedEnv, ResolvedWorktreePaths, WorktreeFeature, WorktreeSelector};
21use crate::worktree_init;
22
23#[cfg(not(feature = "coordination-branch"))]
24fn repair_current_worktree_coordination_links(
25 _project_root: &Path,
26 _ito_path: &Path,
27 _config: &ItoConfig,
28) -> CoreResult<()> {
29 Ok(())
30}
31
32const INIT_MARKER: &str = "ito-initialized";
38
39pub fn ensure_worktree(
57 change_id: &str,
58 config: &ItoConfig,
59 env: &ResolvedEnv,
60 worktree_paths: &ResolvedWorktreePaths,
61 cwd: &Path,
62) -> CoreResult<PathBuf> {
63 let runner = SystemProcessRunner;
64 ensure_worktree_with_runner(
65 &runner,
66 &SystemWorktreeReadiness,
67 change_id,
68 config,
69 env,
70 worktree_paths,
71 cwd,
72 )
73}
74
75pub(crate) trait WorktreeReadinessEvaluator {
76 fn evaluate(&self, request: &ReadinessRequest, config: &ItoConfig) -> ReadinessReport;
77
78 fn execute_from_prepare(
79 &self,
80 prepare: &ReadinessReport,
81 request: &ReadinessRequest,
82 config: &ItoConfig,
83 ) -> ReadinessReport;
84}
85
86struct SystemWorktreeReadiness;
87
88impl WorktreeReadinessEvaluator for SystemWorktreeReadiness {
89 fn evaluate(&self, request: &ReadinessRequest, config: &ItoConfig) -> ReadinessReport {
90 evaluate_readiness(request, config)
91 }
92
93 fn execute_from_prepare(
94 &self,
95 prepare: &ReadinessReport,
96 request: &ReadinessRequest,
97 config: &ItoConfig,
98 ) -> ReadinessReport {
99 evaluate_execute_from_prepare(prepare, request, config)
100 }
101}
102
103pub(crate) fn ensure_worktree_with_runner(
105 runner: &dyn ProcessRunner,
106 readiness: &dyn WorktreeReadinessEvaluator,
107 change_id: &str,
108 config: &ItoConfig,
109 env: &ResolvedEnv,
110 worktree_paths: &ResolvedWorktreePaths,
111 cwd: &Path,
112) -> CoreResult<PathBuf> {
113 validate_change_id(change_id)?;
115
116 let WorktreeFeature::Enabled = worktree_paths.feature else {
118 return Ok(cwd.to_path_buf());
119 };
120
121 let selector = WorktreeSelector::Change(change_id.to_string());
123 let worktree_path = worktree_paths.path_for_selector(&selector).ok_or_else(|| {
124 CoreError::validation(format!(
125 "Cannot resolve worktree path for change '{change_id}'.\n\
126 Worktrees are enabled but the worktrees root could not be determined.\n\
127 Fix: check 'worktrees.strategy' and 'worktrees.layout' in .ito/config.json.",
128 ))
129 })?;
130
131 let existing_checkout = worktree_path.join(".git").exists();
132 let prepare_report = if existing_checkout {
133 let request = ReadinessRequest::new(change_id, ReadinessPhase::Execute, &env.project_root)
134 .with_current_checkout(&worktree_path);
135 let report = readiness.evaluate(&request, config);
136 require_ready(&report)?;
137 None
138 } else {
139 let request = ReadinessRequest::new(change_id, ReadinessPhase::Prepare, &env.project_root);
140 let report = readiness.evaluate(&request, config);
141 require_ready(&report)?;
142 Some(report)
143 };
144
145 if worktree_path.is_dir() {
151 let git_entry = worktree_path.join(".git");
152 let has_git = git_entry.exists();
153 let ito_path = worktree_path.join(".ito");
154 let has_marker = has_git && {
155 resolve_gitdir(&git_entry)
156 .map(|gitdir| gitdir.join(INIT_MARKER).exists())
157 .unwrap_or(false)
158 };
159 if has_git && has_marker {
160 repair_current_worktree_coordination_links(&env.project_root, &ito_path, config)?;
161 return Ok(worktree_path);
162 }
163 if has_git {
166 repair_current_worktree_coordination_links(&env.project_root, &ito_path, config)?;
167 let source_root = worktree_paths.main_worktree_root.as_deref().unwrap_or(cwd);
168 worktree_init::init_worktree_with_runner(
169 runner,
170 source_root,
171 &worktree_path,
172 &config.worktrees,
173 )?;
174 write_init_marker(&worktree_path)?;
175 return Ok(worktree_path);
176 }
177 }
178
179 if let Some(parent) = worktree_path.parent() {
181 std::fs::create_dir_all(parent).map_err(|err| {
182 CoreError::io(
183 format!(
184 "Cannot create worktrees directory '{}'.\n\
185 Fix: ensure the path is writable.",
186 parent.display(),
187 ),
188 err,
189 )
190 })?;
191 }
192
193 let prepare_report = prepare_report.expect("absent worktree has prepare readiness");
195 let authority_oid = prepare_report
196 .authority
197 .oid
198 .as_deref()
199 .expect("successful prepare readiness contains authority OID");
200 let creation_state = WorktreeCreationState {
201 target_preexisted: worktree_path.exists(),
202 branch_preexisted: local_branch_exists(runner, &env.project_root, change_id)?,
203 };
204 let worktrunk_config =
205 FileSnapshot::capture(env.ito_root.join("worktrunk").join("worktree-path.toml"))?;
206 if let Err(error) = create_change_worktree(
207 runner,
208 &env.project_root,
209 &env.ito_root,
210 change_id,
211 authority_oid,
212 &worktree_path,
213 ) {
214 return rollback_creation_failure(
215 runner,
216 &env.project_root,
217 change_id,
218 &worktree_path,
219 creation_state,
220 worktrunk_config,
221 error,
222 );
223 }
224
225 let initialize = || -> CoreResult<()> {
226 let execute_request =
227 ReadinessRequest::new(change_id, ReadinessPhase::Execute, &env.project_root)
228 .with_current_checkout(&worktree_path);
229 let execute_report =
230 readiness.execute_from_prepare(&prepare_report, &execute_request, config);
231 require_ready(&execute_report)?;
232
233 let source_root = worktree_paths.main_worktree_root.as_deref().unwrap_or(cwd);
234 let ito_path = worktree_path.join(".ito");
235 repair_current_worktree_coordination_links(&env.project_root, &ito_path, config)?;
236 worktree_init::init_worktree_with_runner(
237 runner,
238 source_root,
239 &worktree_path,
240 &config.worktrees,
241 )?;
242 write_init_marker(&worktree_path)
243 };
244 if let Err(error) = initialize() {
245 return rollback_creation_failure(
246 runner,
247 &env.project_root,
248 change_id,
249 &worktree_path,
250 creation_state,
251 worktrunk_config,
252 error,
253 );
254 }
255
256 Ok(worktree_path)
257}
258
259#[derive(Clone, Copy)]
260struct WorktreeCreationState {
261 target_preexisted: bool,
262 branch_preexisted: bool,
263}
264
265fn rollback_creation_failure(
266 runner: &dyn ProcessRunner,
267 project_root: &Path,
268 change_id: &str,
269 worktree_path: &Path,
270 creation_state: WorktreeCreationState,
271 worktrunk_config: FileSnapshot,
272 error: CoreError,
273) -> CoreResult<PathBuf> {
274 let cleanup = rollback_created_worktree(
275 runner,
276 project_root,
277 change_id,
278 worktree_path,
279 creation_state,
280 );
281 combine_creation_failure(error, cleanup, worktrunk_config.restore())
282}
283
284fn combine_creation_failure(
285 error: CoreError,
286 cleanup: CoreResult<()>,
287 config_restore: CoreResult<()>,
288) -> CoreResult<PathBuf> {
289 let mut rollback_errors = Vec::new();
290 if let Err(cleanup_error) = cleanup {
291 rollback_errors.push(cleanup_error.to_string());
292 }
293 if let Err(restore_error) = config_restore {
294 rollback_errors.push(restore_error.to_string());
295 }
296 if rollback_errors.is_empty() {
297 return Err(error);
298 }
299 Err(CoreError::process(format!(
300 "{error}\nRollback also failed: {}",
301 rollback_errors.join("; ")
302 )))
303}
304
305fn require_ready(report: &ReadinessReport) -> CoreResult<()> {
306 if report.ready {
307 return Ok(());
308 }
309 let failures = report
310 .conditions
311 .iter()
312 .filter(|condition| !condition.passed)
313 .map(|condition| {
314 let remediation = condition
315 .remediation
316 .as_deref()
317 .map(|value| format!(" Fix: {value}"))
318 .unwrap_or_default();
319 format!("{}: {}{remediation}", condition.code, condition.message)
320 })
321 .collect::<Vec<_>>()
322 .join("\n");
323 Err(CoreError::validation(format!(
324 "Change '{}' is not ready for {}.\n{}",
325 report.change_id,
326 match report.phase {
327 ReadinessPhase::Prepare => "worktree preparation",
328 ReadinessPhase::Execute => "implementation execution",
329 },
330 failures
331 )))
332}
333
334struct FileSnapshot {
335 path: PathBuf,
336 contents: Option<Vec<u8>>,
337 parent_existed: bool,
338}
339
340impl FileSnapshot {
341 fn capture(path: PathBuf) -> CoreResult<Self> {
342 let parent_existed = path.parent().map(Path::exists).unwrap_or(false);
343 let contents = match std::fs::read(&path) {
344 Ok(contents) => Some(contents),
345 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
346 Err(error) => {
347 return Err(CoreError::io(
348 format!("Cannot snapshot '{}'.", path.display()),
349 error,
350 ));
351 }
352 };
353 Ok(Self {
354 path,
355 contents,
356 parent_existed,
357 })
358 }
359
360 fn restore(self) -> CoreResult<()> {
361 match self.contents {
362 Some(contents) => std::fs::write(&self.path, contents).map_err(|error| {
363 CoreError::io(format!("Cannot restore '{}'.", self.path.display()), error)
364 })?,
365 None => match std::fs::remove_file(&self.path) {
366 Ok(()) => {}
367 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
368 Err(error) => {
369 return Err(CoreError::io(
370 format!("Cannot remove generated '{}'.", self.path.display()),
371 error,
372 ));
373 }
374 },
375 }
376 if !self.parent_existed
377 && let Some(parent) = self.path.parent()
378 {
379 match std::fs::remove_dir(parent) {
380 Ok(()) => {}
381 Err(error)
382 if matches!(
383 error.kind(),
384 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
385 ) => {}
386 Err(error) => {
387 return Err(CoreError::io(
388 format!("Cannot remove generated directory '{}'.", parent.display()),
389 error,
390 ));
391 }
392 }
393 }
394 Ok(())
395 }
396}
397
398fn rollback_created_worktree(
399 runner: &dyn ProcessRunner,
400 project_root: &Path,
401 change_id: &str,
402 target_path: &Path,
403 creation_state: WorktreeCreationState,
404) -> CoreResult<()> {
405 let project = project_root.to_string_lossy().to_string();
406 if !creation_state.target_preexisted && target_path.exists() {
407 let target = target_path.to_string_lossy().to_string();
408 let remove = runner
409 .run(
410 &ProcessRequest::new("git")
411 .args(["-C", &project, "worktree", "remove", "--force", &target]),
412 )
413 .map_err(|error| CoreError::process(format!("Cannot roll back worktree: {error}")))?;
414 if !remove.success {
415 return Err(CoreError::process(format!(
416 "Cannot roll back worktree '{}': {}",
417 target_path.display(),
418 remove.stderr.trim()
419 )));
420 }
421 }
422
423 if creation_state.branch_preexisted || !local_branch_exists(runner, project_root, change_id)? {
424 return Ok(());
425 }
426 let branch = runner
427 .run(&ProcessRequest::new("git").args(["-C", &project, "branch", "-D", change_id]))
428 .map_err(|error| CoreError::process(format!("Cannot roll back branch: {error}")))?;
429 if !branch.success {
430 return Err(CoreError::process(format!(
431 "Cannot roll back branch '{change_id}': {}",
432 branch.stderr.trim()
433 )));
434 }
435 Ok(())
436}
437
438fn local_branch_exists(
439 runner: &dyn ProcessRunner,
440 project_root: &Path,
441 change_id: &str,
442) -> CoreResult<bool> {
443 let project = project_root.to_string_lossy().to_string();
444 let branch_ref = format!("refs/heads/{change_id}");
445 let output = runner
446 .run(&ProcessRequest::new("git").args([
447 "-C",
448 &project,
449 "show-ref",
450 "--verify",
451 "--quiet",
452 &branch_ref,
453 ]))
454 .map_err(|error| CoreError::process(format!("Cannot inspect worktree branch: {error}")))?;
455 match output.exit_code {
456 0 => Ok(true),
457 1 => Ok(false),
458 _ => Err(CoreError::process(format!(
459 "Cannot inspect branch '{change_id}' before worktree creation: {}",
460 output.stderr.trim()
461 ))),
462 }
463}
464
465fn resolve_gitdir(git_entry: &Path) -> Option<PathBuf> {
481 if git_entry.is_dir() {
482 return Some(git_entry.to_path_buf());
483 }
484
485 let content = std::fs::read_to_string(git_entry).ok()?;
487 let line = content.lines().next()?;
488 let pointer = line.strip_prefix("gitdir:")?;
489 let pointer = pointer.trim();
490
491 if pointer.is_empty() {
492 return None;
493 }
494
495 let parent = git_entry.parent()?;
501 let gitdir = parent.join(pointer);
502 gitdir.canonicalize().ok()
503}
504
505fn write_init_marker(worktree_path: &Path) -> CoreResult<()> {
510 let git_entry = worktree_path.join(".git");
511 let gitdir = resolve_gitdir(&git_entry).ok_or_else(|| {
512 CoreError::validation(format!(
513 "Cannot resolve gitdir for worktree at '{}'.\n\
514 Fix: ensure the worktree has a valid .git file or directory.",
515 worktree_path.display(),
516 ))
517 })?;
518
519 let marker_path = gitdir.join(INIT_MARKER);
520 std::fs::write(&marker_path, "initialized\n").map_err(|err| {
521 CoreError::io(
522 format!(
523 "Cannot write initialization marker at '{}'.\n\
524 Fix: ensure the gitdir path is writable.",
525 marker_path.display(),
526 ),
527 err,
528 )
529 })
530}
531
532fn validate_change_id(change_id: &str) -> CoreResult<()> {
541 if change_id.is_empty() {
542 return Err(CoreError::validation(
543 "Change ID must not be empty.\n\
544 Fix: provide a valid change ID (e.g. '012-05_my-change').",
545 ));
546 }
547 if change_id.starts_with('-') {
548 return Err(CoreError::validation(format!(
549 "Change ID '{change_id}' must not start with '-'.\n\
550 A leading dash could be misinterpreted as a git flag.\n\
551 Fix: use a change ID that starts with an alphanumeric character.",
552 )));
553 }
554 if change_id.contains("..") {
555 return Err(CoreError::validation(format!(
556 "Change ID '{change_id}' must not contain '..'.\n\
557 This could enable path traversal.\n\
558 Fix: use a change ID without '..' components.",
559 )));
560 }
561 if change_id.contains('/') || change_id.contains('\\') || change_id.contains('\0') {
562 return Err(CoreError::validation(format!(
563 "Change ID '{change_id}' contains invalid characters (/, \\, or NUL).\n\
564 Fix: use a change ID with only alphanumeric characters, dashes, and underscores.",
565 )));
566 }
567 Ok(())
568}
569
570fn create_change_worktree(
572 runner: &dyn ProcessRunner,
573 project_root: &Path,
574 ito_root: &Path,
575 change_id: &str,
576 base_oid: &str,
577 target_path: &Path,
578) -> CoreResult<()> {
579 let config_path = write_worktrunk_path_config(ito_root, target_path)?;
580 let config_arg = config_path.to_string_lossy().to_string();
581 let project_root_arg = project_root.to_string_lossy().to_string();
582
583 let request = ProcessRequest::new("wt")
584 .args([
585 "--config",
586 &config_arg,
587 "-C",
588 &project_root_arg,
589 "--yes",
590 "switch",
591 "--create",
592 change_id,
593 "--base",
594 base_oid,
595 ])
596 .current_dir(project_root);
597
598 let output = runner.run(&request).map_err(|err| {
599 CoreError::process(format!(
600 "Cannot create worktree for change '{change_id}' at '{target}'.\n\
601 Worktrunk command failed to run: {err}\n\
602 Requested change: {change_id}\n\
603 Verified authority OID: {base_oid}\n\
604 Fix: install Worktrunk and ensure `wt` is available on PATH, then retry the guarded Ito worktree command.",
605 target = target_path.display(),
606 ))
607 })?;
608
609 if output.success {
610 return Ok(());
611 }
612
613 let detail = if !output.stderr.trim().is_empty() {
614 output.stderr.trim().to_string()
615 } else if !output.stdout.trim().is_empty() {
616 output.stdout.trim().to_string()
617 } else {
618 "no command output".to_string()
619 };
620
621 Err(CoreError::process(format!(
622 "Cannot create worktree for change '{change_id}' at '{target}'.\n\
623 Worktrunk reported: {detail}\n\
624 Requested change: {change_id}\n\
625 Verified authority OID: {base_oid}\n\
626 Fix: ensure Worktrunk can access verified commit '{base_oid}', the target path is free, and the local Worktrunk path config points at the Ito worktree root.",
627 target = target_path.display(),
628 )))
629}
630
631fn write_worktrunk_path_config(ito_root: &Path, target_path: &Path) -> CoreResult<PathBuf> {
632 let parent = target_path.parent().ok_or_else(|| {
633 CoreError::validation(format!(
634 "Cannot derive Worktrunk path config for '{}'.\n\
635 Fix: configure worktrees so the target path has a parent directory.",
636 target_path.display(),
637 ))
638 })?;
639
640 let config_dir = ito_root.join("worktrunk");
641 std::fs::create_dir_all(&config_dir).map_err(|err| {
642 CoreError::io(
643 format!(
644 "Cannot create Worktrunk config directory '{}'.\n\
645 Fix: ensure the Ito directory is writable.",
646 config_dir.display(),
647 ),
648 err,
649 )
650 })?;
651
652 let config_path = config_dir.join("worktree-path.toml");
653 let template = parent.join("{{ branch | sanitize }}");
654 let contents = format!(
655 "worktree-path = \"{}\"\n",
656 template
657 .to_string_lossy()
658 .replace('\\', "\\\\")
659 .replace('"', "\\\"")
660 );
661
662 std::fs::write(&config_path, contents).map_err(|err| {
663 CoreError::io(
664 format!(
665 "Cannot write Worktrunk path config '{}'.\n\
666 Fix: ensure the Ito directory is writable.",
667 config_path.display(),
668 ),
669 err,
670 )
671 })?;
672
673 Ok(config_path)
674}
675
676#[cfg(test)]
677#[path = "worktree_ensure_tests.rs"]
678mod worktree_ensure_tests;