Skip to main content

ito_core/
worktree_ensure.rs

1//! Change worktree ensure: verify or create the correct worktree for a change.
2//!
3//! The `ensure_worktree` function is the primary entrypoint used by
4//! `ito worktree ensure --change <id>`. It resolves the expected path, creates
5//! the worktree if absent, runs initialization (file copy + setup), and returns
6//! the resolved absolute path.
7
8use 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
32/// Marker file written into the worktree's gitdir after successful initialization.
33///
34/// For linked worktrees, `.git` is a file containing `gitdir: <path>`.  The
35/// marker is placed inside that resolved gitdir directory so it never appears
36/// as an untracked file in `git status`.
37const INIT_MARKER: &str = "ito-initialized";
38
39/// Ensure the correct change worktree exists and is initialized.
40///
41/// Returns the resolved absolute path to the worktree.
42///
43/// # Behaviour
44///
45/// 1. When `worktrees.enabled` is `false`, returns `cwd` (the current working
46///    directory passed in).
47/// 2. Derives the expected worktree path from the configured strategy and layout.
48/// 3. Existing worktrees must pass execute readiness before reuse or setup.
49/// 4. New worktrees must pass prepare readiness, are created from the captured
50///    authority OID, and must pass execute readiness before initialization.
51///
52/// # Errors
53///
54/// Returns [`CoreError`] if path resolution fails, git worktree creation fails,
55/// or initialization fails.
56pub 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
103/// Testable inner implementation of [`ensure_worktree`].
104pub(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 to prevent path traversal and git flag injection.
114    validate_change_id(change_id)?;
115
116    // When worktrees are disabled, work in the current directory.
117    let WorktreeFeature::Enabled = worktree_paths.feature else {
118        return Ok(cwd.to_path_buf());
119    };
120
121    // Resolve the expected path for this change.
122    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 the worktree exists and was fully initialized, return it without
146    // re-init. We check for a `.git` file/dir (present in all git worktrees)
147    // AND our `ito-initialized` marker inside the gitdir (proves init
148    // completed without polluting `git status`). If the directory exists but
149    // lacks the marker, it was partially initialized and we re-run init.
150    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 the directory exists with .git but no marker, re-run init.
164        // If no .git at all, fall through to creation (the dir is stale).
165        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    // Create the parent directory if needed.
180    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    // Create the Worktrunk-managed worktree from the captured authority OID.
194    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
465/// Resolve the actual gitdir path for a worktree.
466///
467/// For a regular (main) worktree, `.git` is a directory and is returned as-is.
468/// For a linked worktree, `.git` is a file whose first line has the form
469/// `gitdir: <path>` — the `<path>` is resolved relative to the worktree root
470/// and returned.
471///
472/// Returns `None` if the `.git` entry does not exist, cannot be read, does
473/// not contain a valid `gitdir:` pointer, or the resolved path does not
474/// exist on disk.
475///
476/// For linked worktrees the pointer is resolved relative to the directory
477/// containing the `.git` file and then canonicalized to eliminate `..`
478/// segments and symlinks.  This prevents a crafted `gitdir:` value from
479/// escaping the repository tree.
480fn resolve_gitdir(git_entry: &Path) -> Option<PathBuf> {
481    if git_entry.is_dir() {
482        return Some(git_entry.to_path_buf());
483    }
484
485    // Linked worktree: `.git` is a file containing `gitdir: <path>`.
486    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    // Resolve relative to the directory that contains the `.git` file,
496    // then canonicalize so `..` segments and symlinks cannot escape.
497    // `canonicalize` returns `Err` when the target does not exist — that
498    // is fine because a legitimate linked-worktree gitdir always exists
499    // by the time `ensure_worktree` runs.
500    let parent = git_entry.parent()?;
501    let gitdir = parent.join(pointer);
502    gitdir.canonicalize().ok()
503}
504
505/// Write the initialization marker into the worktree's gitdir.
506///
507/// The marker is placed inside the resolved gitdir (not the working tree root)
508/// so it never appears as an untracked file in `git status`.
509fn 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
532/// Validate that a change ID is safe to use as a branch name and path segment.
533///
534/// Rejects IDs that:
535/// - Are empty
536/// - Start with `-` (could be interpreted as git flags)
537/// - Contain `..` (path traversal)
538/// - Contain path separators (`/` or `\`)
539/// - Contain NUL bytes
540fn 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
570/// Create a Worktrunk-managed worktree for a change.
571fn 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;