Skip to main content

mj_controller/hel_controller/
worktree.rs

1//! Managed worktrees and raw-to-workspace project conversion.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use anyhow::{Context, Result, bail, ensure};
7
8use hel::hel_config::{HelConfig, ProjectBundle, TargetTemplate, is_bare_project_target};
9use hel::hel_local_git::canonical_repository;
10use hel::hel_state::{
11    ManagedWorktree, ManagedWorktreeTarget, ProjectSourceIdentity, SessionRecord,
12};
13use hel::hel_targets::{
14    self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
15};
16
17use super::{Controller, backend_ssh, execute_checked, now, ssh_command_spec};
18
19impl Controller {
20    /// Verify a bare project before leaving the project-directory dialog.
21    pub fn validate_project_directory(
22        &self,
23        target_id: &str,
24        directory: &Path,
25        executor: &impl CommandExecutor,
26    ) -> Result<()> {
27        let target = self
28            .config
29            .targets
30            .get(target_id)
31            .with_context(|| format!("unknown target template {target_id:?}"))?;
32        match target {
33            TargetTemplate::LocalBare => {
34                ensure!(
35                    directory.is_dir(),
36                    "project directory does not exist or is not a directory"
37                );
38                let output = executor.execute(
39                    &CommandSpec::new(
40                        "git",
41                        [
42                            "-C",
43                            &directory.to_string_lossy(),
44                            "rev-parse",
45                            "--verify",
46                            "HEAD",
47                        ],
48                    )
49                    .purpose("verify local bare Git project"),
50                )?;
51                ensure!(
52                    output.status == 0
53                        && !String::from_utf8_lossy(&output.stdout).trim().is_empty(),
54                    "project directory has no valid Git HEAD: {}",
55                    String::from_utf8_lossy(&output.stderr).trim()
56                );
57                Ok(())
58            }
59            TargetTemplate::SshBare { ssh, .. } => {
60                hel_targets::validate_bare_project_directory(&backend_ssh(ssh), directory, executor)
61            }
62            _ => bail!("project directory validation requires a bare target"),
63        }
64    }
65
66    /// Resolves a session's canonical project without doing process work on a
67    /// UI loop. Raw checkouts use their Git origin, which joins sibling linked
68    /// worktrees to configured bundles from the same repository.
69    pub fn resolve_session_project_source(
70        &self,
71        session_id: &str,
72        executor: &impl CommandExecutor,
73    ) -> Result<ProjectSourceIdentity> {
74        let session = self
75            .state
76            .sessions
77            .get(session_id)
78            .with_context(|| format!("unknown session {session_id}"))?;
79        let Some(directory) = session.project_directory.as_deref() else {
80            return Ok(session.project_source(&self.config));
81        };
82        let (target, origin_directory) = match &session.managed_worktree {
83            // The source repository is the durable owner of a linked
84            // worktree's shared Git configuration and remains available while
85            // a stopped session's checkout is retired.
86            Some(worktree) => (
87                worktree.target.clone(),
88                worktree.source_repository.as_path(),
89            ),
90            None => (
91                managed_worktree_target(
92                    self.config
93                        .targets
94                        .get(&session.target_template_id)
95                        .with_context(|| {
96                            format!(
97                                "session {session_id} target {:?} is no longer configured",
98                                session.target_template_id
99                            )
100                        })?,
101                )?,
102                directory,
103            ),
104        };
105        let output = executor.execute(&managed_git_command(
106            &target,
107            origin_directory,
108            ["config", "--get", "remote.origin.url"],
109            "resolve project Git origin",
110        ))?;
111        match output.status {
112            0 => {
113                let origin =
114                    String::from_utf8(output.stdout).context("project Git origin was not UTF-8")?;
115                Ok(ProjectSourceIdentity::git_remote(origin.trim())
116                    .unwrap_or_else(|| session.project_source(&self.config)))
117            }
118            // Git uses 1 when the repository has no origin configured.
119            1 => Ok(session.project_source(&self.config)),
120            status => bail!(
121                "resolve project Git origin failed with status {status}: {}",
122                String::from_utf8_lossy(&output.stderr).trim()
123            ),
124        }
125    }
126
127    /// Resolve the checkout a bundle session is moving into, and check that it
128    /// is free, before the session record names it.
129    pub(super) fn plan_workspace_to_raw(
130        &self,
131        session: &SessionRecord,
132        target_id: &str,
133        executor: &impl CommandExecutor,
134    ) -> Result<WorkspaceToRawConversion> {
135        let bundle = self
136            .config
137            .bundles
138            .get(&session.bundle_id)
139            .context("session bundle is missing")?;
140        let [repository] = bundle.repositories.as_slice() else {
141            bail!("a checkout holds exactly one repository");
142        };
143        let source = repository
144            .local
145            .as_deref()
146            .context("only a repository already on this machine can become a checkout")?;
147        self.validate_project_directory(target_id, source, executor)
148            .context("this session's repository is unavailable")?;
149        let worktree = ManagedWorktree {
150            source_project_directory: source.to_path_buf(),
151            source_repository: source.to_path_buf(),
152            worktree_root: source.join(".mj").join("worktrees").join(&session.id),
153            branch: format!("mj/{}", session.id),
154            target: managed_worktree_target(
155                self.config
156                    .targets
157                    .get(target_id)
158                    .with_context(|| format!("unknown target template {target_id:?}"))?,
159            )?,
160        };
161        ensure_managed_worktree_available(executor, &worktree)?;
162        Ok(WorkspaceToRawConversion { worktree })
163    }
164
165    pub(super) fn prepare_managed_raw_worktree(
166        &mut self,
167        session_id: &str,
168        executor: &impl CommandExecutor,
169    ) -> Result<bool> {
170        let session = self
171            .state
172            .sessions
173            .get(session_id)
174            .with_context(|| format!("unknown session {session_id}"))?
175            .clone();
176        let Some(selected) = session.project_directory.as_deref() else {
177            return Ok(false);
178        };
179        if session.managed_worktree.is_some() {
180            return Ok(false);
181        }
182        let template = self
183            .config
184            .targets
185            .get(&session.target_template_id)
186            .context("raw session target template disappeared during provisioning")?;
187        let target = managed_worktree_target(template)?;
188        let inspection = inspect_raw_project(executor, &target, selected)?;
189        if !inspection.primary_checkout {
190            return Ok(false);
191        }
192        let relative_directory = inspection
193            .source_project_directory
194            .strip_prefix(&inspection.source_repository)
195            .context("raw project directory is outside its repository")?
196            .to_path_buf();
197        let worktree_root = inspection
198            .source_repository
199            .join(".mj")
200            .join("worktrees")
201            .join(session_id);
202        let managed = ManagedWorktree {
203            source_project_directory: inspection.source_project_directory,
204            source_repository: inspection.source_repository,
205            worktree_root: worktree_root.clone(),
206            branch: format!("mj/{session_id}"),
207            target,
208        };
209        ensure_managed_worktree_available(executor, &managed)?;
210        let record = self.state.sessions.get_mut(session_id).unwrap();
211        record.project_directory = Some(worktree_root.join(relative_directory));
212        record.managed_worktree = Some(managed.clone());
213        record.updated_at = now();
214        self.persist_session_state(session_id)?;
215        create_managed_worktree(
216            executor,
217            &managed,
218            inspection.upstream.as_deref(),
219            PrimaryCheckoutRequirement::Clean,
220        )?;
221        Ok(true)
222    }
223
224    fn cleanup_new_session_worktree(
225        &self,
226        session_id: &str,
227        executor: &impl CommandExecutor,
228    ) -> Result<()> {
229        let Some(worktree) = self
230            .state
231            .sessions
232            .get(session_id)
233            .and_then(|session| session.managed_worktree.as_ref())
234        else {
235            return Ok(());
236        };
237        cleanup_managed_worktree(executor, worktree)
238    }
239
240    pub(super) fn cleanup_new_session_worktree_after_failure(
241        &self,
242        session_id: &str,
243        executor: &impl CommandExecutor,
244    ) -> Result<()> {
245        if executor.cancellation_requested() {
246            let cleanup_executor =
247                CancellableProcessExecutor::with_timeout(Duration::from_secs(15));
248            self.cleanup_new_session_worktree(session_id, &cleanup_executor)
249        } else {
250            self.cleanup_new_session_worktree(session_id, executor)
251        }
252    }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256struct RawProjectInspection {
257    source_project_directory: PathBuf,
258    source_repository: PathBuf,
259    primary_checkout: bool,
260    upstream: Option<String>,
261}
262
263pub(super) fn managed_worktree_target(template: &TargetTemplate) -> Result<ManagedWorktreeTarget> {
264    match template {
265        TargetTemplate::LocalBare => Ok(ManagedWorktreeTarget::Local),
266        TargetTemplate::SshBare { ssh, .. } => {
267            let ssh = backend_ssh(ssh);
268            Ok(ManagedWorktreeTarget::Ssh {
269                destination: ssh.destination,
270                ssh_args: ssh.ssh_args,
271            })
272        }
273        _ => bail!("managed raw worktrees require a bare target"),
274    }
275}
276
277fn managed_target_ssh(target: &ManagedWorktreeTarget) -> Option<SshTarget> {
278    match target {
279        ManagedWorktreeTarget::Local => None,
280        ManagedWorktreeTarget::Ssh {
281            destination,
282            ssh_args,
283        } => Some(SshTarget {
284            destination: destination.clone(),
285            ssh_args: ssh_args.clone(),
286        }),
287    }
288}
289
290fn managed_target_command(
291    target: &ManagedWorktreeTarget,
292    program: &str,
293    args: impl IntoIterator<Item = impl AsRef<str>>,
294) -> CommandSpec {
295    let args = args
296        .into_iter()
297        .map(|arg| arg.as_ref().to_owned())
298        .collect::<Vec<_>>();
299    match managed_target_ssh(target) {
300        None => CommandSpec::new(program, args),
301        Some(ssh) => {
302            let mut remote = vec![program.to_owned()];
303            remote.extend(args);
304            ssh_command_spec(&ssh, remote)
305        }
306    }
307}
308
309fn managed_git_command(
310    target: &ManagedWorktreeTarget,
311    directory: &Path,
312    args: impl IntoIterator<Item = impl AsRef<str>>,
313    purpose: impl Into<String>,
314) -> CommandSpec {
315    let mut command_args = vec!["-C".to_owned(), directory.to_string_lossy().into_owned()];
316    command_args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
317    managed_target_command(target, "git", command_args).purpose(purpose)
318}
319
320fn command_stdout(output: CommandOutput, purpose: &str) -> Result<String> {
321    if output.status != 0 {
322        bail!(
323            "{purpose} failed with status {}: {}",
324            output.status,
325            String::from_utf8_lossy(&output.stderr).trim()
326        );
327    }
328    let stdout = String::from_utf8(output.stdout)
329        .with_context(|| format!("{purpose} produced non-UTF-8 output"))?;
330    Ok(stdout.trim_end_matches(['\r', '\n']).to_owned())
331}
332
333fn managed_git_stdout(
334    executor: &impl CommandExecutor,
335    target: &ManagedWorktreeTarget,
336    directory: &Path,
337    args: impl IntoIterator<Item = impl AsRef<str>>,
338    purpose: &str,
339) -> Result<String> {
340    let command = managed_git_command(target, directory, args, purpose);
341    command_stdout(executor.execute(&command)?, purpose)
342}
343
344/// Which checkout each still-empty target repository is seeded from, or `None`
345/// when this connect must not seed at all. A converting resume carries the
346/// session's own checkout; every other seed comes from the bundle's local path.
347/// Reshape a raw session's record for the workspace target it is moving into.
348pub(super) fn apply_raw_to_workspace(
349    record: &mut SessionRecord,
350    conversion: &RawToWorkspaceConversion,
351) {
352    record.project_directory = None;
353    record.managed_worktree = None;
354    record.bundle_id.clone_from(&conversion.bundle_id);
355}
356
357/// A resume that changes how a session is represented, resolved before the
358/// session record or the configuration changes.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub(super) enum ResumeConversion {
361    RawToWorkspace(RawToWorkspaceConversion),
362    WorkspaceToRaw(WorkspaceToRawConversion),
363}
364
365impl ResumeConversion {
366    pub(super) fn raw_to_workspace(&self) -> Option<&RawToWorkspaceConversion> {
367        match self {
368            Self::RawToWorkspace(conversion) => Some(conversion),
369            Self::WorkspaceToRaw(_) => None,
370        }
371    }
372
373    pub(super) fn workspace_to_raw(&self) -> Option<&WorkspaceToRawConversion> {
374        match self {
375            Self::WorkspaceToRaw(conversion) => Some(conversion),
376            Self::RawToWorkspace(_) => None,
377        }
378    }
379}
380
381/// Everything a workspace-to-raw resume needs. The worktree does not exist yet:
382/// the record names it first, so a failure cleans it up through the same path
383/// as a new raw session's.
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub(super) struct WorkspaceToRawConversion {
386    pub(super) worktree: ManagedWorktree,
387}
388
389/// Reshape a bundle session's record for the checkout it is moving into. The
390/// bundle stays: it still describes the repository the checkout came from.
391pub(super) fn apply_workspace_to_raw(
392    record: &mut SessionRecord,
393    conversion: &WorkspaceToRawConversion,
394) {
395    record.project_directory = Some(conversion.worktree.worktree_root.clone());
396    record.managed_worktree = Some(conversion.worktree.clone());
397}
398
399/// Everything a raw-to-workspace resume needs, resolved before the session
400/// record or the configuration changes.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub(super) struct RawToWorkspaceConversion {
403    /// The checkout whose branch, head commit, and dirty state move into the
404    /// target. For a managed session this is the session's own worktree, not
405    /// the user's primary checkout.
406    pub(super) checkout: PathBuf,
407    /// The repository the Git proxy serves, and the bundle's `local:` path.
408    pub(super) repository: PathBuf,
409    pub(super) bundle_id: String,
410    /// Set when the configuration does not already describe this checkout.
411    pub(super) new_bundle: Option<ProjectBundle>,
412    /// Removed once the target holds the checkout, and only then.
413    pub(super) retire: Option<ManagedWorktree>,
414}
415
416/// Resolve where a raw session's checkout lives and which bundle will stand in
417/// for it. Reads Git; changes nothing.
418pub(super) fn plan_raw_to_workspace(
419    session: &SessionRecord,
420    config: &HelConfig,
421    executor: &impl CommandExecutor,
422) -> Result<RawToWorkspaceConversion> {
423    let project_directory = session
424        .project_directory
425        .as_deref()
426        .context("a raw session has no project directory")?;
427    // The checkpoint describes the session's directory as if it were the
428    // repository root, so only a whole checkout can move. Each branch checks
429    // this against paths from one domain: the record's own paths for a managed
430    // worktree, Git's canonical paths for an inspected checkout — the record
431    // may reach the same checkout through a symlink (macOS temp directories).
432    let (checkout, repository, retire) = match &session.managed_worktree {
433        Some(worktree) => {
434            ensure!(
435                worktree.worktree_root == project_directory,
436                "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
437                project_directory.display()
438            );
439            (
440                worktree.worktree_root.clone(),
441                worktree.source_repository.clone(),
442                Some(worktree.clone()),
443            )
444        }
445        None => {
446            let inspection =
447                inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
448            ensure!(
449                inspection.source_project_directory == inspection.source_repository,
450                "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
451                project_directory.display()
452            );
453            let repository = canonical_repository(&inspection.source_repository)?;
454            (inspection.source_repository, repository, None)
455        }
456    };
457    // The archive names the session's directory as the repository destination,
458    // and the restored harness session points at that path inside the target.
459    // The bundle has to put the checkout in the same place.
460    let destination = PathBuf::from(
461        project_directory
462            .file_name()
463            .context("a raw project directory cannot be the filesystem root")?,
464    );
465    let (bundle_id, new_bundle) =
466        converted_raw_bundle(config, &session.bundle_id, &repository, &destination);
467    Ok(RawToWorkspaceConversion {
468        checkout,
469        repository,
470        bundle_id,
471        new_bundle,
472        retire,
473    })
474}
475
476/// The bundle a converted raw session references: one the configuration already
477/// has for exactly this checkout, or a new one for the caller to install.
478/// Reusing a match keeps a retried conversion from piling up bundles.
479fn converted_raw_bundle(
480    config: &HelConfig,
481    session_bundle_id: &str,
482    repository: &Path,
483    destination: &Path,
484) -> (String, Option<ProjectBundle>) {
485    let describes_checkout = |bundle: &ProjectBundle| {
486        bundle.repositories.len() == 1
487            && bundle.repositories[0].github.is_none()
488            && bundle.repositories[0].local.as_deref() == Some(repository)
489            && bundle.repositories[0].destination == destination
490    };
491    if config
492        .bundles
493        .get(session_bundle_id)
494        .is_some_and(describes_checkout)
495    {
496        return (session_bundle_id.to_owned(), None);
497    }
498    if let Some((id, _)) = config
499        .bundles
500        .iter()
501        .find(|(_, bundle)| describes_checkout(bundle))
502    {
503        return (id.clone(), None);
504    }
505    let name = repository
506        .file_name()
507        .map(|name| name.to_string_lossy().into_owned())
508        .unwrap_or_default();
509    let id = crate::hel_import::unique_bundle_id(config, &crate::hel_import::setup_style_id(&name));
510    let bundle = ProjectBundle {
511        primary_repo: id.clone(),
512        repositories: vec![hel::hel_config::ProjectRepository {
513            id: id.clone(),
514            github: None,
515            local: Some(repository.to_path_buf()),
516            destination: destination.to_path_buf(),
517            git_ref: None,
518        }],
519    };
520    (id, Some(bundle))
521}
522
523/// Where a checkout stands: its head commit and, unless detached, its branch.
524#[derive(Debug, Clone, PartialEq, Eq)]
525pub(super) struct CheckoutPosition {
526    head_commit: String,
527    branch: Option<String>,
528}
529
530fn read_checkout_position(
531    executor: &impl CommandExecutor,
532    target: &ManagedWorktreeTarget,
533    directory: &Path,
534) -> Result<CheckoutPosition> {
535    let head_commit = managed_git_stdout(
536        executor,
537        target,
538        directory,
539        ["rev-parse", "HEAD"],
540        "resolve checkout head commit",
541    )?;
542    let branch_command = managed_git_command(
543        target,
544        directory,
545        ["symbolic-ref", "--quiet", "--short", "HEAD"],
546        "resolve checkout branch",
547    );
548    let branch_output = executor.execute(&branch_command)?;
549    let branch = match branch_output.status {
550        0 => Some(
551            String::from_utf8(branch_output.stdout)
552                .context("checkout branch was not UTF-8")?
553                .trim()
554                .to_owned(),
555        ),
556        // A detached head reports no branch rather than failing.
557        1 | 128 => None,
558        status => bail!(
559            "resolve checkout branch failed with status {status}: {}",
560            String::from_utf8_lossy(&branch_output.stderr).trim()
561        ),
562    };
563    Ok(CheckoutPosition {
564        head_commit,
565        branch,
566    })
567}
568
569/// Read where a raw session's checkout stands right now, on whichever host
570/// owns it.
571pub(super) fn raw_checkout_position(
572    session: &SessionRecord,
573    config: &HelConfig,
574    project_directory: &Path,
575    executor: &impl CommandExecutor,
576) -> Result<CheckoutPosition> {
577    let target = match &session.managed_worktree {
578        Some(worktree) => worktree.target.clone(),
579        None => {
580            let template = config
581                .targets
582                .get(&session.target_template_id)
583                .context("the bare target this session last used is missing")?;
584            managed_worktree_target(template)?
585        }
586    };
587    read_checkout_position(executor, &target, project_directory)
588}
589
590/// One conversation line for a raw session whose checkout moved on while the
591/// session was stopped. `None` when the checkout is where the checkpoint left
592/// it, or when the checkpoint recorded no repository to compare against.
593///
594/// This reports; it never reconciles. The working tree is the truth.
595pub(super) fn raw_checkout_divergence_notice(
596    directory: &Path,
597    recorded: Option<&hel::hel_archive::RepositoryMetadata>,
598    live: &CheckoutPosition,
599) -> Option<String> {
600    let recorded = recorded?;
601    if recorded.head_commit.is_empty()
602        || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
603    {
604        return None;
605    }
606    Some(format!(
607        "The working tree at {} moved from {} to {} while this session was stopped.",
608        directory.display(),
609        checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
610        checkout_position_text(&live.head_commit, live.branch.as_deref()),
611    ))
612}
613
614fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
615    let short = head_commit.get(..12).unwrap_or(head_commit);
616    match branch {
617        Some(branch) => format!("{short} ({branch})"),
618        None => format!("{short} (detached)"),
619    }
620}
621
622fn inspect_raw_project(
623    executor: &impl CommandExecutor,
624    target: &ManagedWorktreeTarget,
625    selected: &Path,
626) -> Result<RawProjectInspection> {
627    let repository = PathBuf::from(managed_git_stdout(
628        executor,
629        target,
630        selected,
631        ["rev-parse", "--path-format=absolute", "--show-toplevel"],
632        "resolve raw project repository root",
633    )?);
634    let prefix = managed_git_stdout(
635        executor,
636        target,
637        selected,
638        ["rev-parse", "--show-prefix"],
639        "resolve raw project relative directory",
640    )?;
641    let git_dir = PathBuf::from(managed_git_stdout(
642        executor,
643        target,
644        selected,
645        ["rev-parse", "--absolute-git-dir"],
646        "resolve raw project Git directory",
647    )?);
648    let common_git_dir = PathBuf::from(managed_git_stdout(
649        executor,
650        target,
651        selected,
652        ["rev-parse", "--path-format=absolute", "--git-common-dir"],
653        "resolve raw project common Git directory",
654    )?);
655    let branch_command = managed_git_command(
656        target,
657        selected,
658        ["symbolic-ref", "--quiet", "--short", "HEAD"],
659        "resolve raw project branch",
660    );
661    let branch_output = executor.execute(&branch_command)?;
662    let branch = match branch_output.status {
663        0 => Some(
664            String::from_utf8(branch_output.stdout)
665                .context("raw project branch was not UTF-8")?
666                .trim()
667                .to_owned(),
668        ),
669        1 | 128 => None,
670        status => bail!(
671            "resolve raw project branch failed with status {status}: {}",
672            String::from_utf8_lossy(&branch_output.stderr).trim()
673        ),
674    };
675    let upstream = match branch {
676        Some(branch) => {
677            let reference = format!("refs/heads/{branch}");
678            let upstream = managed_git_stdout(
679                executor,
680                target,
681                selected,
682                ["for-each-ref", "--format=%(upstream:short)", &reference],
683                "resolve raw project upstream",
684            )?;
685            (!upstream.is_empty()).then_some(upstream)
686        }
687        None => None,
688    };
689    Ok(RawProjectInspection {
690        source_project_directory: repository.join(prefix),
691        source_repository: repository,
692        primary_checkout: git_dir == common_git_dir,
693        upstream,
694    })
695}
696
697fn ensure_managed_worktree_excluded(
698    executor: &impl CommandExecutor,
699    target: &ManagedWorktreeTarget,
700    repository: &Path,
701) -> Result<()> {
702    let check = managed_git_command(
703        target,
704        repository,
705        [
706            "check-ignore",
707            "--quiet",
708            "--no-index",
709            "--",
710            ".mj/worktrees/",
711        ],
712        "check managed worktree exclusion",
713    );
714    let output = executor.execute(&check)?;
715    match output.status {
716        0 => return Ok(()),
717        1 => {}
718        status => bail!(
719            "check managed worktree exclusion failed with status {status}: {}",
720            String::from_utf8_lossy(&output.stderr).trim()
721        ),
722    }
723    let exclude_path = PathBuf::from(managed_git_stdout(
724        executor,
725        target,
726        repository,
727        [
728            "rev-parse",
729            "--path-format=absolute",
730            "--git-path",
731            "info/exclude",
732        ],
733        "resolve repository-local exclude file",
734    )?);
735    const ENTRY: &str = "/.mj/worktrees/";
736    match target {
737        ManagedWorktreeTarget::Local => {
738            use std::io::Write;
739            let existing = match std::fs::read_to_string(&exclude_path) {
740                Ok(existing) => existing,
741                Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
742                Err(error) => return Err(error.into()),
743            };
744            if existing.lines().any(|line| line.trim() == ENTRY) {
745                return Ok(());
746            }
747            if let Some(parent) = exclude_path.parent() {
748                std::fs::create_dir_all(parent)?;
749            }
750            let mut file = std::fs::OpenOptions::new()
751                .create(true)
752                .append(true)
753                .open(&exclude_path)
754                .with_context(|| format!("open {}", exclude_path.display()))?;
755            if !existing.is_empty() && !existing.ends_with('\n') {
756                writeln!(file)?;
757            }
758            writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
759        }
760        ManagedWorktreeTarget::Ssh { .. } => {
761            const SCRIPT: &str = "set -eu\nexclude=$1\nentry=$2\nmkdir -p \"$(dirname \"$exclude\")\"\ntouch \"$exclude\"\nif ! grep -Fqx \"$entry\" \"$exclude\"; then\n  if [ -s \"$exclude\" ] && [ \"$(tail -c 1 \"$exclude\" | wc -l)\" -eq 0 ]; then printf '\\n' >>\"$exclude\"; fi\n  printf '# Hel managed worktrees\\n%s\\n' \"$entry\" >>\"$exclude\"\nfi";
762            let command = managed_target_command(
763                target,
764                "sh",
765                [
766                    "-c",
767                    SCRIPT,
768                    "hel-exclude",
769                    &exclude_path.to_string_lossy(),
770                    ENTRY,
771                ],
772            )
773            .purpose("update remote repository-local exclude file");
774            execute_checked(executor, command)?;
775        }
776    }
777    Ok(())
778}
779
780fn path_exists_on_managed_target(
781    executor: &impl CommandExecutor,
782    target: &ManagedWorktreeTarget,
783    path: &Path,
784) -> Result<bool> {
785    match target {
786        ManagedWorktreeTarget::Local => Ok(path.exists()),
787        ManagedWorktreeTarget::Ssh { .. } => {
788            let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
789                .purpose("check managed worktree path");
790            let output = executor.execute(&command)?;
791            match output.status {
792                0 => Ok(true),
793                1 => Ok(false),
794                status => bail!(
795                    "check managed worktree path failed with status {status}: {}",
796                    String::from_utf8_lossy(&output.stderr).trim()
797                ),
798            }
799        }
800    }
801}
802
803pub(super) fn managed_worktree_checkout_exists(
804    executor: &impl CommandExecutor,
805    worktree: &ManagedWorktree,
806) -> Result<bool> {
807    path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
808}
809
810/// Whether a new managed worktree needs the primary checkout to be clean.
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812pub(super) enum PrimaryCheckoutRequirement {
813    /// A new raw session starts from the primary checkout's HEAD, so work that
814    /// is only in its working tree would be silently left behind.
815    Clean,
816    /// A session moving out of its target replaces the worktree's contents from
817    /// its checkpoint, so the primary checkout's own changes are beside the
818    /// point.
819    Any,
820}
821
822pub(super) fn create_managed_worktree(
823    executor: &impl CommandExecutor,
824    worktree: &ManagedWorktree,
825    upstream: Option<&str>,
826    requirement: PrimaryCheckoutRequirement,
827) -> Result<()> {
828    ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
829    if requirement == PrimaryCheckoutRequirement::Clean {
830        let status = managed_git_stdout(
831            executor,
832            &worktree.target,
833            &worktree.source_repository,
834            ["status", "--porcelain=v1", "--untracked-files=all"],
835            "inspect primary checkout changes",
836        )?;
837        if !status.is_empty() {
838            let paths = status.lines().take(20).collect::<Vec<_>>().join("\n  ");
839            bail!(
840                "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n  {paths}"
841            );
842        }
843    }
844    let parent = worktree
845        .worktree_root
846        .parent()
847        .context("managed worktree root has no parent")?;
848    execute_checked(
849        executor,
850        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
851            .purpose("create managed worktree directory"),
852    )?;
853    execute_checked(
854        executor,
855        managed_git_command(
856            &worktree.target,
857            &worktree.source_repository,
858            [
859                "worktree",
860                "add",
861                "-b",
862                &worktree.branch,
863                &worktree.worktree_root.to_string_lossy(),
864                "HEAD",
865            ],
866            "create managed raw-session worktree",
867        ),
868    )?;
869    if let Some(upstream) = upstream {
870        execute_checked(
871            executor,
872            managed_git_command(
873                &worktree.target,
874                &worktree.worktree_root,
875                ["branch", "--set-upstream-to", upstream, &worktree.branch],
876                "set managed worktree branch upstream",
877            ),
878        )?;
879    }
880    Ok(())
881}
882
883/// Recreate a retired checkout from the session branch. Returns whether this
884/// call created it, so a failed resume can put the session back into its
885/// stopped, checkout-free state.
886pub(super) fn restore_managed_worktree(
887    executor: &impl CommandExecutor,
888    worktree: &ManagedWorktree,
889) -> Result<bool> {
890    if managed_worktree_checkout_exists(executor, worktree)? {
891        return Ok(false);
892    }
893    ensure!(
894        path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
895        "managed worktree source repository is unavailable: {}",
896        worktree.source_repository.display()
897    );
898    let branch_ref = format!("refs/heads/{}", worktree.branch);
899    let check = managed_git_command(
900        &worktree.target,
901        &worktree.source_repository,
902        ["show-ref", "--verify", "--quiet", &branch_ref],
903        "check retired managed worktree branch",
904    );
905    let output = executor.execute(&check)?;
906    match output.status {
907        0 => {}
908        1 => bail!(
909            "managed worktree branch is unavailable: {}",
910            worktree.branch
911        ),
912        status => bail!(
913            "check retired managed worktree branch failed with status {status}: {}",
914            String::from_utf8_lossy(&output.stderr).trim()
915        ),
916    }
917    // A remote bare target may already have removed the checkout directory.
918    // Prune its stale registration before adding the retained branch again.
919    execute_checked(
920        executor,
921        managed_git_command(
922            &worktree.target,
923            &worktree.source_repository,
924            ["worktree", "prune"],
925            "prune retired managed worktree metadata",
926        ),
927    )?;
928    let parent = worktree
929        .worktree_root
930        .parent()
931        .context("managed worktree root has no parent")?;
932    execute_checked(
933        executor,
934        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
935            .purpose("recreate managed worktree directory"),
936    )?;
937    execute_checked(
938        executor,
939        managed_git_command(
940            &worktree.target,
941            &worktree.source_repository,
942            [
943                "worktree",
944                "add",
945                "--",
946                &worktree.worktree_root.to_string_lossy(),
947                &worktree.branch,
948            ],
949            "restore managed raw-session worktree",
950        ),
951    )?;
952    Ok(true)
953}
954
955fn ensure_managed_worktree_available(
956    executor: &impl CommandExecutor,
957    worktree: &ManagedWorktree,
958) -> Result<()> {
959    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
960        bail!(
961            "managed worktree path already exists: {}",
962            worktree.worktree_root.display()
963        );
964    }
965    let branch_ref = format!("refs/heads/{}", worktree.branch);
966    let check = managed_git_command(
967        &worktree.target,
968        &worktree.source_repository,
969        ["show-ref", "--verify", "--quiet", &branch_ref],
970        "check managed worktree branch availability",
971    );
972    let output = executor.execute(&check)?;
973    match output.status {
974        0 => bail!(
975            "managed worktree branch already exists: {}",
976            worktree.branch
977        ),
978        1 => Ok(()),
979        status => bail!(
980            "check managed worktree branch availability failed with status {status}: {}",
981            String::from_utf8_lossy(&output.stderr).trim()
982        ),
983    }
984}
985
986/// Remove a managed worktree's checkout and keep its branch.
987///
988/// A session that moved into a target still checkpoints as a delta against
989/// `hel/<session>`, so deleting that branch could let the commits those deltas
990/// depend on be collected. The checkout itself is dirty by design; its dirty
991/// state has already been carried into the target.
992pub(super) fn retire_managed_worktree(
993    executor: &impl CommandExecutor,
994    worktree: &ManagedWorktree,
995) -> Result<()> {
996    if !remove_managed_worktree_checkout(executor, worktree)? {
997        return Ok(());
998    }
999    remove_empty_managed_worktree_directories(executor, worktree)
1000}
1001
1002/// Remove the checkout and prune its metadata. Returns whether the repository
1003/// is still there to act on at all.
1004fn remove_managed_worktree_checkout(
1005    executor: &impl CommandExecutor,
1006    worktree: &ManagedWorktree,
1007) -> Result<bool> {
1008    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1009        return Ok(false);
1010    }
1011    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1012        execute_checked(
1013            executor,
1014            managed_git_command(
1015                &worktree.target,
1016                &worktree.source_repository,
1017                [
1018                    "worktree",
1019                    "remove",
1020                    "--force",
1021                    &worktree.worktree_root.to_string_lossy(),
1022                ],
1023                "remove managed raw-session worktree",
1024            ),
1025        )?;
1026    }
1027    execute_checked(
1028        executor,
1029        managed_git_command(
1030            &worktree.target,
1031            &worktree.source_repository,
1032            ["worktree", "prune"],
1033            "prune managed worktree metadata",
1034        ),
1035    )?;
1036    Ok(true)
1037}
1038
1039pub(super) fn cleanup_managed_worktree(
1040    executor: &impl CommandExecutor,
1041    worktree: &ManagedWorktree,
1042) -> Result<()> {
1043    if !remove_managed_worktree_checkout(executor, worktree)? {
1044        return Ok(());
1045    }
1046    let branch_ref = format!("refs/heads/{}", worktree.branch);
1047    let check = managed_git_command(
1048        &worktree.target,
1049        &worktree.source_repository,
1050        ["show-ref", "--verify", "--quiet", &branch_ref],
1051        "check managed worktree branch",
1052    );
1053    let output = executor.execute(&check)?;
1054    match output.status {
1055        0 => {
1056            execute_checked(
1057                executor,
1058                managed_git_command(
1059                    &worktree.target,
1060                    &worktree.source_repository,
1061                    ["branch", "-D", "--", &worktree.branch],
1062                    "delete managed raw-session branch",
1063                ),
1064            )?;
1065        }
1066        1 => {}
1067        status => bail!(
1068            "check managed worktree branch failed with status {status}: {}",
1069            String::from_utf8_lossy(&output.stderr).trim()
1070        ),
1071    }
1072    remove_empty_managed_worktree_directories(executor, worktree)
1073}
1074
1075fn remove_empty_managed_worktree_directories(
1076    executor: &impl CommandExecutor,
1077    worktree: &ManagedWorktree,
1078) -> Result<()> {
1079    let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1080    let hel = worktree.source_repository.join(".mj");
1081    match &worktree.target {
1082        ManagedWorktreeTarget::Local => {
1083            for directory in [&worktrees, &hel] {
1084                match std::fs::remove_dir(directory) {
1085                    Ok(()) => {}
1086                    Err(error)
1087                        if matches!(
1088                            error.kind(),
1089                            std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1090                        ) => {}
1091                    Err(error) => return Err(error.into()),
1092                }
1093            }
1094        }
1095        ManagedWorktreeTarget::Ssh { .. } => {
1096            let command = managed_target_command(
1097                &worktree.target,
1098                "rmdir",
1099                ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1100            )
1101            .purpose("remove empty managed worktree directories");
1102            let _ = executor.execute(&command)?;
1103        }
1104    }
1105    Ok(())
1106}
1107
1108/// Why a bundle session cannot resume on a local bare target. A bare target has
1109/// no managed workspace to restore the bundle into.
1110const BUNDLE_ON_LOCAL_BARE: &str = "this session was created from a project bundle; a local bare target only hosts raw project sessions — resume it on a container, SSH, or EC2 target";
1111
1112/// What a resume has to do to the session record before it provisions.
1113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1114pub enum ResumePlan {
1115    /// Keep the session in the representation it already has.
1116    InPlace,
1117    /// Move a raw checkout session into a workspace target as a bundle session.
1118    RawToWorkspace,
1119    /// Move a bundle session out of its workspace into a raw local worktree.
1120    WorkspaceToRaw,
1121}
1122
1123/// Whether `session` may resume on `target_id`, and what the resume must do to
1124/// the session record. The error is shown to the person choosing the target, so
1125/// it says where the session is tied down and what to pick instead.
1126///
1127/// This decides representation only. It performs no I/O, so it can run on every
1128/// row of a target picker.
1129pub fn resume_compatibility(
1130    session: &SessionRecord,
1131    config: &HelConfig,
1132    target_id: &str,
1133) -> Result<ResumePlan, String> {
1134    let Some(target) = config.targets.get(target_id) else {
1135        return Err(format!("target {target_id} is no longer configured"));
1136    };
1137    let Some(project_directory) = &session.project_directory else {
1138        if matches!(target, TargetTemplate::LocalBare) {
1139            return workspace_to_raw_compatibility(session, config);
1140        }
1141        return Ok(ResumePlan::InPlace);
1142    };
1143    let directory = project_directory.display();
1144    let Some(worktree) = &session.managed_worktree else {
1145        let Some(previous) = config.targets.get(&session.target_template_id) else {
1146            return Err(
1147                "the bare target this session last used is no longer configured".to_owned(),
1148            );
1149        };
1150        if is_bare_project_target(target) {
1151            if matches!(previous, TargetTemplate::LocalBare)
1152                == matches!(target, TargetTemplate::LocalBare)
1153            {
1154                return Ok(ResumePlan::InPlace);
1155            }
1156            return Err(format!(
1157                "this session opens {directory} directly on its host; resume it on the same kind of bare target"
1158            ));
1159        }
1160        // Only a checkout on this machine can be carried into a target: the
1161        // Git proxy serves controller-side paths.
1162        if matches!(previous, TargetTemplate::LocalBare) {
1163            return Ok(ResumePlan::RawToWorkspace);
1164        }
1165        return Err(format!(
1166            "this session opens {directory} on an SSH host; resume it on a bare target there"
1167        ));
1168    };
1169    match managed_worktree_target(target) {
1170        Ok(resume_target) if resume_target == worktree.target => Ok(ResumePlan::InPlace),
1171        Ok(_) => Err(format!(
1172            "this session's working tree lives on {}; resume it there",
1173            managed_worktree_location(&worktree.target)
1174        )),
1175        Err(_) if worktree.target != ManagedWorktreeTarget::Local => Err(format!(
1176            "this session works directly in {directory} on {}; resume it on a bare target there",
1177            managed_worktree_location(&worktree.target)
1178        )),
1179        // The checkout moves into the target, dirty state and all. Only a whole
1180        // checkout can move: the checkpoint describes the session's directory as
1181        // if it were the repository root.
1182        Err(_) if Some(&worktree.worktree_root) == session.project_directory.as_ref() => {
1183            Ok(ResumePlan::RawToWorkspace)
1184        }
1185        Err(_) => Err(format!(
1186            "this session opens {directory}, a subdirectory of its checkout; resume it on a bare target"
1187        )),
1188    }
1189}
1190
1191/// Whether a bundle session can leave its workspace for a checkout on this
1192/// machine. Only a single repository already on this machine can become one.
1193fn workspace_to_raw_compatibility(
1194    session: &SessionRecord,
1195    config: &HelConfig,
1196) -> Result<ResumePlan, String> {
1197    let Some(bundle) = config.bundles.get(&session.bundle_id) else {
1198        return Err(BUNDLE_ON_LOCAL_BARE.to_owned());
1199    };
1200    let [repository] = bundle.repositories.as_slice() else {
1201        return Err(format!(
1202            "this session's project has {} repositories; a local bare target holds one checkout — resume it on a container, SSH, or EC2 target",
1203            bundle.repositories.len()
1204        ));
1205    };
1206    if repository.local.is_none() {
1207        return Err(
1208            "this session's project came from GitHub; resume it on a container, SSH, or EC2 target"
1209                .to_owned(),
1210        );
1211    }
1212    Ok(ResumePlan::WorkspaceToRaw)
1213}
1214
1215/// Where a managed worktree's checkout physically lives, in words a user reads.
1216fn managed_worktree_location(target: &ManagedWorktreeTarget) -> String {
1217    match target {
1218        ManagedWorktreeTarget::Local => "this machine".to_owned(),
1219        ManagedWorktreeTarget::Ssh { destination, .. } => destination.clone(),
1220    }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use std::cell::RefCell;
1226    use std::collections::BTreeMap;
1227    use std::path::{Path, PathBuf};
1228    use std::process::Command;
1229
1230    use anyhow::Result;
1231
1232    use crate::hel_controller::Controller;
1233    use crate::hel_controller::resume::apply_failed_resume_rollback;
1234    use crate::hel_controller::test_support::{
1235        checkpoint_test_session, committed_repository, local_bundle, managed_raw_session,
1236        managed_worktree_session, raw_session_on, resume_compatibility_config, ssh_worktree_target,
1237        test_git,
1238    };
1239    use hel::hel_archive::RepositoryMetadata;
1240    use hel::hel_config::{
1241        HarnessProfile, HelConfig, ProjectBundle, ProjectRepository, TargetTemplate,
1242    };
1243    use hel::hel_state::{HelState, ManagedWorktree, ManagedWorktreeTarget, SessionState};
1244    use hel::hel_targets::{
1245        CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
1246    };
1247
1248    use super::*;
1249
1250    #[test]
1251    fn local_bare_project_validation_runs_git_in_the_selected_directory() {
1252        struct GitExecutor {
1253            commands: RefCell<Vec<CommandSpec>>,
1254        }
1255        impl CommandExecutor for GitExecutor {
1256            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1257                self.commands.borrow_mut().push(command.clone());
1258                Ok(CommandOutput {
1259                    status: 0,
1260                    stdout: b"true\n".to_vec(),
1261                    stderr: Vec::new(),
1262                })
1263            }
1264        }
1265
1266        let project = tempfile::tempdir().unwrap();
1267        let mut config = HelConfig::default();
1268        config
1269            .targets
1270            .insert("localhost".into(), TargetTemplate::LocalBare);
1271        let controller = Controller {
1272            config,
1273            state: HelState::default(),
1274        };
1275        let executor = GitExecutor {
1276            commands: RefCell::new(Vec::new()),
1277        };
1278
1279        controller
1280            .validate_project_directory("localhost", project.path(), &executor)
1281            .unwrap();
1282        let commands = executor.commands.borrow();
1283        assert_eq!(commands.len(), 1);
1284        assert_eq!(commands[0].program, "git");
1285        assert_eq!(commands[0].args[0], "-C");
1286        assert_eq!(commands[0].args[1], project.path().to_string_lossy());
1287        assert_eq!(commands[0].args[2..], ["rev-parse", "--verify", "HEAD"]);
1288    }
1289
1290    #[test]
1291    fn raw_linked_worktree_origin_matches_the_configured_github_project() {
1292        struct OriginExecutor;
1293        impl CommandExecutor for OriginExecutor {
1294            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1295                assert_eq!(
1296                    command.args,
1297                    [
1298                        "-C",
1299                        "/mnt/optane/bifrost-fird",
1300                        "config",
1301                        "--get",
1302                        "remote.origin.url",
1303                    ]
1304                );
1305                Ok(CommandOutput {
1306                    status: 0,
1307                    stdout: b"git@github.com:BrokkAi/bifrost-dev.git\n".to_vec(),
1308                    stderr: Vec::new(),
1309                })
1310            }
1311        }
1312
1313        let mut config = HelConfig::default();
1314        config
1315            .targets
1316            .insert("localhost".into(), TargetTemplate::LocalBare);
1317        let session = raw_session_on("localhost", "/mnt/optane/bifrost-fird");
1318        let session_id = session.id.clone();
1319        let controller = Controller {
1320            config,
1321            state: HelState {
1322                sessions: [(session_id.clone(), session)].into_iter().collect(),
1323                ..HelState::default()
1324            },
1325        };
1326
1327        let source = controller
1328            .resolve_session_project_source(&session_id, &OriginExecutor)
1329            .unwrap();
1330
1331        assert_eq!(source.key, "github:brokkai/bifrost-dev");
1332        assert_eq!(source.short, "bifrost-dev");
1333        assert_eq!(source.full, "BrokkAi/bifrost-dev");
1334    }
1335    #[test]
1336    fn managed_worktree_origin_uses_source_repository_while_checkout_is_retired() {
1337        struct OriginExecutor;
1338        impl CommandExecutor for OriginExecutor {
1339            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1340                assert_eq!(
1341                    command.args,
1342                    [
1343                        "-C",
1344                        "/home/dev/project",
1345                        "config",
1346                        "--get",
1347                        "remote.origin.url",
1348                    ]
1349                );
1350                Ok(CommandOutput {
1351                    status: 0,
1352                    stdout: b"git@github.com:example/project.git\n".to_vec(),
1353                    stderr: Vec::new(),
1354                })
1355            }
1356        }
1357
1358        let session = managed_raw_session(ManagedWorktreeTarget::Local);
1359        let session_id = session.id.clone();
1360        let controller = Controller {
1361            config: HelConfig::default(),
1362            state: HelState {
1363                sessions: [(session_id.clone(), session)].into_iter().collect(),
1364                ..HelState::default()
1365            },
1366        };
1367
1368        let source = controller
1369            .resolve_session_project_source(&session_id, &OriginExecutor)
1370            .unwrap();
1371
1372        assert_eq!(source.key, "github:example/project");
1373    }
1374
1375    /// Answers the two Git reads that locate a checkout, and nothing else.
1376    struct CheckoutPositionExecutor {
1377        head_commit: String,
1378        branch: Option<String>,
1379    }
1380    impl CommandExecutor for CheckoutPositionExecutor {
1381        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1382            let stdout = if command.args.iter().any(|argument| argument == "rev-parse") {
1383                self.head_commit.clone()
1384            } else if command
1385                .args
1386                .iter()
1387                .any(|argument| argument == "symbolic-ref")
1388            {
1389                match &self.branch {
1390                    Some(branch) => branch.clone(),
1391                    None => {
1392                        return Ok(CommandOutput {
1393                            status: 1,
1394                            stdout: Vec::new(),
1395                            stderr: Vec::new(),
1396                        });
1397                    }
1398                }
1399            } else {
1400                panic!("unexpected command {:?}", command.args);
1401            };
1402            Ok(CommandOutput {
1403                status: 0,
1404                stdout: format!("{stdout}\n").into_bytes(),
1405                stderr: Vec::new(),
1406            })
1407        }
1408    }
1409    fn recorded_repository(head_commit: &str, branch: Option<&str>) -> RepositoryMetadata {
1410        RepositoryMetadata {
1411            id: "project".into(),
1412            relative_destination: PathBuf::from("project"),
1413            origin: "mj-local:project".into(),
1414            base_commit: String::new(),
1415            head_commit: head_commit.into(),
1416            branch: branch.map(str::to_owned),
1417        }
1418    }
1419    #[test]
1420    fn a_raw_checkout_that_moved_while_stopped_gets_a_conversation_line() {
1421        let config = resume_compatibility_config();
1422        let session = managed_raw_session(ManagedWorktreeTarget::Local);
1423        let directory = session.project_directory.clone().unwrap();
1424        let executor = CheckoutPositionExecutor {
1425            head_commit: "b".repeat(40),
1426            branch: Some("mj/0123456789abcdef0123456789abcdef".into()),
1427        };
1428
1429        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1430        let notice = raw_checkout_divergence_notice(
1431            &directory,
1432            Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1433            &live,
1434        )
1435        .expect("a moved checkout is reported");
1436
1437        assert!(
1438            notice.contains(&directory.display().to_string()),
1439            "{notice}"
1440        );
1441        assert!(notice.contains("aaaaaaaaaaaa (main)"), "{notice}");
1442        assert!(
1443            notice.contains("bbbbbbbbbbbb (mj/0123456789abcdef0123456789abcdef)"),
1444            "{notice}"
1445        );
1446        assert!(
1447            notice.contains("while this session was stopped"),
1448            "{notice}"
1449        );
1450    }
1451    #[test]
1452    fn a_raw_checkout_that_stayed_put_gets_no_conversation_line() {
1453        let config = resume_compatibility_config();
1454        let session = managed_raw_session(ManagedWorktreeTarget::Local);
1455        let directory = session.project_directory.clone().unwrap();
1456        let executor = CheckoutPositionExecutor {
1457            head_commit: "a".repeat(40),
1458            branch: Some("main".into()),
1459        };
1460
1461        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1462
1463        assert_eq!(
1464            raw_checkout_divergence_notice(
1465                &directory,
1466                Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1467                &live,
1468            ),
1469            None
1470        );
1471    }
1472    #[test]
1473    fn a_checkpoint_without_recorded_git_identity_reports_nothing() {
1474        let live = CheckoutPosition {
1475            head_commit: "b".repeat(40),
1476            branch: None,
1477        };
1478
1479        assert_eq!(
1480            raw_checkout_divergence_notice(Path::new("/home/dev/project"), None, &live),
1481            None
1482        );
1483        assert_eq!(
1484            raw_checkout_divergence_notice(
1485                Path::new("/home/dev/project"),
1486                Some(&recorded_repository("", None)),
1487                &live,
1488            ),
1489            None
1490        );
1491    }
1492    #[test]
1493    fn a_detached_checkout_is_named_as_detached() {
1494        let config = resume_compatibility_config();
1495        let session = managed_raw_session(ManagedWorktreeTarget::Local);
1496        let directory = session.project_directory.clone().unwrap();
1497        let executor = CheckoutPositionExecutor {
1498            head_commit: "c".repeat(40),
1499            branch: None,
1500        };
1501
1502        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1503        let notice = raw_checkout_divergence_notice(
1504            &directory,
1505            Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1506            &live,
1507        )
1508        .expect("a moved checkout is reported");
1509
1510        assert!(notice.contains("cccccccccccc (detached)"), "{notice}");
1511    }
1512    #[test]
1513    fn bundle_sessions_resume_on_any_workspace_target() {
1514        let config = resume_compatibility_config();
1515        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1516
1517        assert_eq!(
1518            resume_compatibility(&session, &config, "podman"),
1519            Ok(ResumePlan::InPlace)
1520        );
1521        assert_eq!(
1522            resume_compatibility(&session, &config, "ssh-bare"),
1523            Ok(ResumePlan::InPlace)
1524        );
1525    }
1526    #[test]
1527    fn a_single_local_repository_can_become_a_checkout() {
1528        let mut config = resume_compatibility_config();
1529        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1530        session.bundle_id = "project".into();
1531        config.bundles.insert(
1532            "project".into(),
1533            local_bundle(Path::new("/home/dev/project")),
1534        );
1535
1536        assert_eq!(
1537            resume_compatibility(&session, &config, "local-bare"),
1538            Ok(ResumePlan::WorkspaceToRaw)
1539        );
1540    }
1541    #[test]
1542    fn a_github_project_cannot_become_a_checkout() {
1543        let mut config = resume_compatibility_config();
1544        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1545        session.bundle_id = "project".into();
1546        let mut bundle = local_bundle(Path::new("/home/dev/project"));
1547        bundle.repositories[0].local = None;
1548        bundle.repositories[0].github = Some("example/project".into());
1549        config.bundles.insert("project".into(), bundle);
1550
1551        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1552
1553        assert!(reason.contains("came from GitHub"), "{reason}");
1554        assert!(
1555            reason.contains("resume it on a container, SSH, or EC2 target"),
1556            "{reason}"
1557        );
1558    }
1559    #[test]
1560    fn a_multi_repository_project_cannot_become_a_checkout() {
1561        let mut config = resume_compatibility_config();
1562        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1563        session.bundle_id = "project".into();
1564        let mut bundle = local_bundle(Path::new("/home/dev/project"));
1565        bundle.repositories.push(ProjectRepository {
1566            id: "tools".into(),
1567            github: None,
1568            local: Some(PathBuf::from("/home/dev/tools")),
1569            destination: PathBuf::from("tools"),
1570            git_ref: None,
1571        });
1572        config.bundles.insert("project".into(), bundle);
1573
1574        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1575
1576        assert!(reason.contains("2 repositories"), "{reason}");
1577        assert!(reason.contains("one checkout"), "{reason}");
1578    }
1579    #[test]
1580    fn bundle_sessions_refuse_a_local_bare_target_with_a_reason() {
1581        let config = resume_compatibility_config();
1582        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1583
1584        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1585
1586        assert!(reason.contains("created from a project bundle"), "{reason}");
1587        assert!(
1588            reason.contains("resume it on a container, SSH, or EC2 target"),
1589            "{reason}"
1590        );
1591    }
1592    #[test]
1593    fn managed_raw_sessions_resume_on_their_own_worktree_host() {
1594        let config = resume_compatibility_config();
1595
1596        assert_eq!(
1597            resume_compatibility(
1598                &managed_raw_session(ManagedWorktreeTarget::Local),
1599                &config,
1600                "local-bare",
1601            ),
1602            Ok(ResumePlan::InPlace)
1603        );
1604        assert_eq!(
1605            resume_compatibility(
1606                &managed_raw_session(ssh_worktree_target()),
1607                &config,
1608                "ssh-bare",
1609            ),
1610            Ok(ResumePlan::InPlace)
1611        );
1612    }
1613    #[test]
1614    fn managed_raw_sessions_refuse_a_bare_target_on_another_host() {
1615        let config = resume_compatibility_config();
1616
1617        let reason = resume_compatibility(
1618            &managed_raw_session(ManagedWorktreeTarget::Local),
1619            &config,
1620            "ssh-bare",
1621        )
1622        .unwrap_err();
1623        assert!(reason.contains("this machine"), "{reason}");
1624
1625        let reason = resume_compatibility(
1626            &managed_raw_session(ssh_worktree_target()),
1627            &config,
1628            "local-bare",
1629        )
1630        .unwrap_err();
1631        assert!(reason.contains("dev@builder"), "{reason}");
1632    }
1633    #[test]
1634    fn a_local_raw_checkout_converts_when_it_resumes_on_a_container() {
1635        let config = resume_compatibility_config();
1636
1637        assert_eq!(
1638            resume_compatibility(
1639                &managed_raw_session(ManagedWorktreeTarget::Local),
1640                &config,
1641                "podman",
1642            ),
1643            Ok(ResumePlan::RawToWorkspace)
1644        );
1645        assert_eq!(
1646            resume_compatibility(
1647                &raw_session_on("local-bare", "/home/dev/project"),
1648                &config,
1649                "podman",
1650            ),
1651            Ok(ResumePlan::RawToWorkspace)
1652        );
1653    }
1654    #[test]
1655    fn a_raw_checkout_on_an_ssh_host_cannot_convert() {
1656        let config = resume_compatibility_config();
1657
1658        let reason = resume_compatibility(
1659            &managed_raw_session(ssh_worktree_target()),
1660            &config,
1661            "podman",
1662        )
1663        .unwrap_err();
1664        assert!(reason.contains("works directly in"), "{reason}");
1665        assert!(reason.contains("dev@builder"), "{reason}");
1666
1667        let reason = resume_compatibility(
1668            &raw_session_on("ssh-bare", "/srv/project"),
1669            &config,
1670            "podman",
1671        )
1672        .unwrap_err();
1673        assert!(reason.contains("on an SSH host"), "{reason}");
1674    }
1675    #[test]
1676    fn a_session_that_opens_a_subdirectory_of_its_worktree_cannot_convert() {
1677        let config = resume_compatibility_config();
1678        let mut session = managed_raw_session(ManagedWorktreeTarget::Local);
1679        let worktree = session.managed_worktree.as_mut().unwrap();
1680        worktree.source_project_directory = worktree.source_repository.join("crate");
1681        session.project_directory = Some(worktree.worktree_root.join("crate"));
1682
1683        let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
1684
1685        assert!(reason.contains("subdirectory of its checkout"), "{reason}");
1686    }
1687    #[test]
1688    fn unmanaged_raw_sessions_require_the_same_bare_target_kind() {
1689        let config = resume_compatibility_config();
1690        let local = raw_session_on("local-bare", "/home/dev/project");
1691        let remote = raw_session_on("ssh-bare", "/srv/project");
1692
1693        assert_eq!(
1694            resume_compatibility(&local, &config, "local-bare"),
1695            Ok(ResumePlan::InPlace)
1696        );
1697        assert_eq!(
1698            resume_compatibility(&remote, &config, "ssh-bare"),
1699            Ok(ResumePlan::InPlace)
1700        );
1701        for (session, target) in [(&local, "ssh-bare"), (&remote, "local-bare")] {
1702            let reason = resume_compatibility(session, &config, target).unwrap_err();
1703            assert!(reason.contains("directly on its host"), "{reason}");
1704        }
1705    }
1706    #[test]
1707    fn resume_compatibility_names_a_target_that_is_gone() {
1708        let config = resume_compatibility_config();
1709        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1710
1711        let reason = resume_compatibility(&session, &config, "retired").unwrap_err();
1712
1713        assert!(reason.contains("retired"), "{reason}");
1714    }
1715    #[test]
1716    fn managed_raw_worktree_inherits_upstream_and_cleans_up_owned_artifacts() {
1717        let repository = committed_repository();
1718        let remote_parent = tempfile::tempdir().unwrap();
1719        let remote = remote_parent.path().join("remote.git");
1720        let output = Command::new("git")
1721            .args(["init", "--bare"])
1722            .arg(&remote)
1723            .output()
1724            .unwrap();
1725        assert!(output.status.success());
1726        test_git(
1727            repository.path(),
1728            &["remote", "add", "origin", &remote.to_string_lossy()],
1729        );
1730        test_git(
1731            repository.path(),
1732            &["push", "--set-upstream", "origin", "master"],
1733        );
1734
1735        let target = ManagedWorktreeTarget::Local;
1736        let inspection =
1737            inspect_raw_project(&ProcessExecutor, &target, &repository.path().join("nested"))
1738                .unwrap();
1739        assert!(inspection.primary_checkout);
1740        assert_eq!(inspection.upstream.as_deref(), Some("origin/master"));
1741        // git rev-parse canonicalizes symlinks (macOS tempdirs live behind the
1742        // /var -> /private/var link), so compare against the canonical path.
1743        assert_eq!(
1744            inspection.source_project_directory,
1745            repository.path().canonicalize().unwrap().join("nested")
1746        );
1747
1748        let session_id = "0123456789abcdef0123456789abcdef";
1749        let worktree = ManagedWorktree {
1750            source_project_directory: inspection.source_project_directory,
1751            source_repository: inspection.source_repository,
1752            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
1753            branch: format!("mj/{session_id}"),
1754            target,
1755        };
1756        create_managed_worktree(
1757            &ProcessExecutor,
1758            &worktree,
1759            inspection.upstream.as_deref(),
1760            PrimaryCheckoutRequirement::Clean,
1761        )
1762        .unwrap();
1763        assert!(worktree.worktree_root.join("nested/file.txt").is_file());
1764        assert_eq!(
1765            test_git(
1766                &worktree.worktree_root,
1767                &[
1768                    "rev-parse",
1769                    "--abbrev-ref",
1770                    "--symbolic-full-name",
1771                    "@{upstream}"
1772                ]
1773            ),
1774            "origin/master"
1775        );
1776        assert_eq!(test_git(repository.path(), &["status", "--porcelain"]), "");
1777        std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
1778
1779        cleanup_managed_worktree(&ProcessExecutor, &worktree).unwrap();
1780        assert!(!worktree.worktree_root.exists());
1781        assert!(!repository.path().join(".mj").exists());
1782        let output = Command::new("git")
1783            .arg("-C")
1784            .arg(repository.path())
1785            .args([
1786                "show-ref",
1787                "--verify",
1788                &format!("refs/heads/{}", worktree.branch),
1789            ])
1790            .output()
1791            .unwrap();
1792        assert!(!output.status.success());
1793    }
1794    #[test]
1795    fn retired_worktree_can_be_recreated_from_its_retained_branch() {
1796        let repository = committed_repository();
1797        let session_id = "0123456789abcdef0123456789abcdef";
1798        let session = managed_worktree_session(repository.path(), session_id);
1799        let worktree = session.managed_worktree.unwrap();
1800        // The checkout is dirty by design: its dirty state moved into the target.
1801        std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
1802
1803        retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
1804
1805        assert!(!worktree.worktree_root.exists());
1806        assert!(!repository.path().join(".mj").exists());
1807        let branch = Command::new("git")
1808            .arg("-C")
1809            .arg(repository.path())
1810            .args([
1811                "show-ref",
1812                "--verify",
1813                &format!("refs/heads/{}", worktree.branch),
1814            ])
1815            .output()
1816            .unwrap();
1817        assert!(
1818            branch.status.success(),
1819            "the session branch must survive: later checkpoints are deltas against it"
1820        );
1821
1822        assert!(restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
1823        assert!(worktree.worktree_root.join("nested/file.txt").is_file());
1824        assert!(!restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
1825    }
1826    #[test]
1827    fn retiring_a_remote_worktree_prunes_registration_after_target_removed_checkout() {
1828        struct RemoteExecutor {
1829            path_checks: RefCell<usize>,
1830            commands: RefCell<Vec<CommandSpec>>,
1831        }
1832
1833        impl CommandExecutor for RemoteExecutor {
1834            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1835                self.commands.borrow_mut().push(command.clone());
1836                let status = if command.purpose == "check managed worktree path" {
1837                    let mut checks = self.path_checks.borrow_mut();
1838                    let status = i32::from(*checks != 0);
1839                    *checks += 1;
1840                    status
1841                } else {
1842                    0
1843                };
1844                Ok(CommandOutput {
1845                    status,
1846                    stdout: Vec::new(),
1847                    stderr: Vec::new(),
1848                })
1849            }
1850        }
1851
1852        let worktree = ManagedWorktree {
1853            source_project_directory: PathBuf::from("/srv/project"),
1854            source_repository: PathBuf::from("/srv/project"),
1855            worktree_root: PathBuf::from("/srv/project/.mj/worktrees/session"),
1856            branch: "mj/session".into(),
1857            target: ManagedWorktreeTarget::Ssh {
1858                destination: "builder".into(),
1859                ssh_args: Vec::new(),
1860            },
1861        };
1862        let executor = RemoteExecutor {
1863            path_checks: RefCell::new(0),
1864            commands: RefCell::new(Vec::new()),
1865        };
1866
1867        retire_managed_worktree(&executor, &worktree).unwrap();
1868
1869        let purposes = executor
1870            .commands
1871            .borrow()
1872            .iter()
1873            .map(|command| command.purpose.clone())
1874            .collect::<Vec<_>>();
1875        assert_eq!(
1876            purposes,
1877            [
1878                "check managed worktree path",
1879                "check managed worktree path",
1880                "prune managed worktree metadata",
1881                "remove empty managed worktree directories",
1882            ]
1883        );
1884    }
1885    #[test]
1886    fn a_managed_conversion_carries_the_session_worktree_not_the_primary_checkout() {
1887        let repository = committed_repository();
1888        let session_id = "0123456789abcdef0123456789abcdef";
1889        let session = managed_worktree_session(repository.path(), session_id);
1890        let worktree = session.managed_worktree.clone().unwrap();
1891
1892        let conversion =
1893            plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
1894
1895        assert_eq!(conversion.checkout, worktree.worktree_root);
1896        assert_eq!(conversion.repository, repository.path());
1897        assert_eq!(conversion.retire, Some(worktree));
1898        let bundle = conversion.new_bundle.expect("a bundle is synthesized");
1899        assert_eq!(bundle.repositories.len(), 1);
1900        assert_eq!(bundle.primary_repo, bundle.repositories[0].id);
1901        assert_eq!(
1902            bundle.repositories[0].local.as_deref(),
1903            Some(repository.path())
1904        );
1905        assert_eq!(bundle.repositories[0].github, None);
1906        // The archive names the session directory as the repository, and the
1907        // restored harness session points inside the target at that name.
1908        assert_eq!(
1909            bundle.repositories[0].destination,
1910            PathBuf::from(session_id)
1911        );
1912    }
1913    #[test]
1914    fn an_unmanaged_conversion_serves_the_main_repository_behind_a_linked_worktree() {
1915        let repository = committed_repository();
1916        let session_id = "0123456789abcdef0123456789abcdef";
1917        let linked = managed_worktree_session(repository.path(), session_id);
1918        let checkout = linked.managed_worktree.unwrap().worktree_root;
1919        let mut session = checkpoint_test_session(session_id);
1920        session.state = SessionState::Stopped;
1921        session.target_template_id = "local-bare".into();
1922        session.project_directory = Some(checkout.clone());
1923
1924        let conversion =
1925            plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
1926
1927        assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
1928        assert_eq!(
1929            conversion.repository,
1930            repository.path().canonicalize().unwrap()
1931        );
1932        assert_eq!(conversion.retire, None);
1933    }
1934    /// The recorded project directory may reach the checkout through a
1935    /// symlink, as the system temp directory does on macOS. Git reports
1936    /// canonical paths, so the whole-checkout rule must not compare across
1937    /// the two domains.
1938    #[cfg(unix)]
1939    #[test]
1940    fn an_unmanaged_conversion_accepts_a_checkout_reached_through_a_symlink() {
1941        let repository = committed_repository();
1942        let session_id = "0123456789abcdef0123456789abcdef";
1943        let linked = managed_worktree_session(repository.path(), session_id);
1944        let checkout = linked.managed_worktree.unwrap().worktree_root;
1945        let alias = tempfile::tempdir().unwrap();
1946        let symlink = alias.path().join("checkout");
1947        std::os::unix::fs::symlink(&checkout, &symlink).unwrap();
1948        let mut session = checkpoint_test_session(session_id);
1949        session.state = SessionState::Stopped;
1950        session.target_template_id = "local-bare".into();
1951        session.project_directory = Some(symlink);
1952
1953        let conversion =
1954            plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
1955
1956        assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
1957        assert_eq!(
1958            conversion.repository,
1959            repository.path().canonicalize().unwrap()
1960        );
1961        assert_eq!(conversion.retire, None);
1962    }
1963    #[test]
1964    fn a_conversion_reuses_a_bundle_that_already_describes_the_checkout() {
1965        let repository = PathBuf::from("/home/dev/project");
1966        let destination = PathBuf::from("project");
1967        let existing = ProjectBundle {
1968            primary_repo: "project".into(),
1969            repositories: vec![ProjectRepository {
1970                id: "project".into(),
1971                github: None,
1972                local: Some(repository.clone()),
1973                destination: destination.clone(),
1974                git_ref: None,
1975            }],
1976        };
1977        let mut config = HelConfig::default();
1978        config.bundles.insert("existing".into(), existing);
1979
1980        assert_eq!(
1981            converted_raw_bundle(&config, "remote-project-abcdef", &repository, &destination),
1982            ("existing".to_owned(), None)
1983        );
1984
1985        // A different destination is a different checkout location inside the
1986        // target, so it cannot stand in for this one.
1987        let (id, synthesized) = converted_raw_bundle(
1988            &config,
1989            "remote-project-abcdef",
1990            &repository,
1991            Path::new("elsewhere"),
1992        );
1993        assert_ne!(id, "existing");
1994        assert_eq!(
1995            synthesized.unwrap().repositories[0].destination,
1996            PathBuf::from("elsewhere")
1997        );
1998    }
1999    #[test]
2000    fn a_converted_record_is_a_valid_bundle_session() {
2001        let session_id = "0123456789abcdef0123456789abcdef";
2002        let mut config = resume_compatibility_config();
2003        let mut record = managed_raw_session(ManagedWorktreeTarget::Local);
2004        record.state = SessionState::Running;
2005        record.target_template_id = "podman".into();
2006        let conversion = RawToWorkspaceConversion {
2007            checkout: record.project_directory.clone().unwrap(),
2008            repository: PathBuf::from("/home/dev/project"),
2009            bundle_id: "project".into(),
2010            new_bundle: Some(ProjectBundle {
2011                primary_repo: "project".into(),
2012                repositories: vec![ProjectRepository {
2013                    id: "project".into(),
2014                    github: None,
2015                    local: Some(PathBuf::from("/home/dev/project")),
2016                    destination: PathBuf::from(session_id),
2017                    git_ref: None,
2018                }],
2019            }),
2020            retire: record.managed_worktree.clone(),
2021        };
2022
2023        config.bundles.insert(
2024            conversion.bundle_id.clone(),
2025            conversion.new_bundle.clone().unwrap(),
2026        );
2027        config.profiles.insert(
2028            record.last_profile.clone(),
2029            HarnessProfile {
2030                kind: record.harness_kind,
2031                home: PathBuf::from("/profiles/codex"),
2032                executable: None,
2033                environment: BTreeMap::new(),
2034                context_window_bytes: None,
2035            },
2036        );
2037        apply_raw_to_workspace(&mut record, &conversion);
2038
2039        assert_eq!(record.project_directory, None);
2040        assert_eq!(record.managed_worktree, None);
2041        assert_eq!(record.bundle_id, "project");
2042        let state = HelState {
2043            sessions: BTreeMap::from([(session_id.into(), record)]),
2044            ..HelState::default()
2045        };
2046        state.validate_against_config(&config).unwrap();
2047    }
2048    #[test]
2049    fn a_session_leaving_its_target_claims_a_worktree_of_its_own_repository() {
2050        let repository = committed_repository();
2051        let session_id = "0123456789abcdef0123456789abcdef";
2052        let mut session = checkpoint_test_session(session_id);
2053        session.state = SessionState::Stopped;
2054        session.bundle_id = "project".into();
2055        let mut config = resume_compatibility_config();
2056        config
2057            .bundles
2058            .insert("project".into(), local_bundle(repository.path()));
2059        let controller = Controller {
2060            config,
2061            state: HelState {
2062                sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2063                ..HelState::default()
2064            },
2065        };
2066
2067        let conversion = controller
2068            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2069            .unwrap();
2070
2071        assert_eq!(
2072            conversion.worktree,
2073            ManagedWorktree {
2074                source_project_directory: repository.path().to_path_buf(),
2075                source_repository: repository.path().to_path_buf(),
2076                worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2077                branch: format!("mj/{session_id}"),
2078                target: ManagedWorktreeTarget::Local,
2079            }
2080        );
2081
2082        // The dirty primary checkout is beside the point: the worktree's
2083        // contents come from the checkpoint.
2084        std::fs::write(repository.path().join("dirty.txt"), "primary\n").unwrap();
2085        create_managed_worktree(
2086            &ProcessExecutor,
2087            &conversion.worktree,
2088            None,
2089            PrimaryCheckoutRequirement::Any,
2090        )
2091        .unwrap();
2092        assert!(conversion.worktree.worktree_root.is_dir());
2093
2094        // A second attempt refuses rather than taking over a live worktree.
2095        let error = controller
2096            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2097            .unwrap_err();
2098        assert!(format!("{error:#}").contains("already exists"), "{error:#}");
2099    }
2100    #[test]
2101    fn a_session_that_left_its_target_is_a_valid_raw_session() {
2102        let session_id = "0123456789abcdef0123456789abcdef";
2103        let repository = PathBuf::from("/home/dev/project");
2104        let mut config = resume_compatibility_config();
2105        config
2106            .bundles
2107            .insert("project".into(), local_bundle(&repository));
2108        config.profiles.insert(
2109            "codex".into(),
2110            HarnessProfile {
2111                kind: hel::hel_config::HarnessKind::Codex,
2112                home: PathBuf::from("/profiles/codex"),
2113                executable: None,
2114                environment: BTreeMap::new(),
2115                context_window_bytes: None,
2116            },
2117        );
2118        let mut record = checkpoint_test_session(session_id);
2119        record.bundle_id = "project".into();
2120        record.target_template_id = "local-bare".into();
2121        let conversion = WorkspaceToRawConversion {
2122            worktree: ManagedWorktree {
2123                source_project_directory: repository.clone(),
2124                source_repository: repository.clone(),
2125                worktree_root: repository.join(".mj/worktrees").join(session_id),
2126                branch: format!("mj/{session_id}"),
2127                target: ManagedWorktreeTarget::Local,
2128            },
2129        };
2130
2131        apply_workspace_to_raw(&mut record, &conversion);
2132
2133        assert_eq!(
2134            record.project_directory.as_deref(),
2135            Some(conversion.worktree.worktree_root.as_path())
2136        );
2137        assert_eq!(record.bundle_id, "project", "the bundle still describes it");
2138        let state = HelState {
2139            sessions: BTreeMap::from([(session_id.into(), record)]),
2140            ..HelState::default()
2141        };
2142        state.validate_against_config(&config).unwrap();
2143    }
2144    #[test]
2145    fn a_failed_departure_returns_the_session_to_its_bundle() {
2146        let session_id = "0123456789abcdef0123456789abcdef";
2147        let repository = PathBuf::from("/home/dev/project");
2148        let previous = {
2149            let mut record = checkpoint_test_session(session_id);
2150            record.state = SessionState::Stopped;
2151            record.bundle_id = "project".into();
2152            record
2153        };
2154        let mut converted = previous.clone();
2155        converted.state = SessionState::Provisioning;
2156        apply_workspace_to_raw(
2157            &mut converted,
2158            &WorkspaceToRawConversion {
2159                worktree: ManagedWorktree {
2160                    source_project_directory: repository.clone(),
2161                    source_repository: repository.clone(),
2162                    worktree_root: repository.join(".mj/worktrees").join(session_id),
2163                    branch: format!("mj/{session_id}"),
2164                    target: ManagedWorktreeTarget::Local,
2165                },
2166            },
2167        );
2168
2169        apply_failed_resume_rollback(&mut converted, &previous, "podman is unavailable", None);
2170
2171        assert_eq!(converted.project_directory, None);
2172        assert_eq!(converted.managed_worktree, None);
2173        assert_eq!(converted.bundle_id, "project");
2174    }
2175    #[test]
2176    fn a_failed_conversion_returns_the_session_to_its_checkout() {
2177        let previous = managed_raw_session(ManagedWorktreeTarget::Local);
2178        let mut converted = previous.clone();
2179        converted.state = SessionState::Provisioning;
2180        converted.target_template_id = "podman".into();
2181        apply_raw_to_workspace(
2182            &mut converted,
2183            &RawToWorkspaceConversion {
2184                checkout: previous.project_directory.clone().unwrap(),
2185                repository: PathBuf::from("/home/dev/project"),
2186                bundle_id: "project".into(),
2187                new_bundle: None,
2188                retire: previous.managed_worktree.clone(),
2189            },
2190        );
2191
2192        let mut cleaned = converted.clone();
2193        apply_failed_resume_rollback(&mut cleaned, &previous, "podman is unavailable", None);
2194        assert_eq!(cleaned.project_directory, previous.project_directory);
2195        assert_eq!(cleaned.managed_worktree, previous.managed_worktree);
2196        assert_eq!(cleaned.bundle_id, previous.bundle_id);
2197
2198        // Even when the leftover target could not be removed, the record must
2199        // describe the checkout it still owns.
2200        let mut stranded = converted;
2201        apply_failed_resume_rollback(
2202            &mut stranded,
2203            &previous,
2204            "podman is unavailable",
2205            Some("podman rm failed".into()),
2206        );
2207        assert_eq!(stranded.state, SessionState::Error);
2208        assert_eq!(stranded.project_directory, previous.project_directory);
2209        assert_eq!(stranded.managed_worktree, previous.managed_worktree);
2210        assert_eq!(stranded.bundle_id, previous.bundle_id);
2211    }
2212    #[test]
2213    fn cancelled_new_session_cleanup_removes_managed_worktree_and_branch() {
2214        let repository = committed_repository();
2215        let session_id = "0123456789abcdef0123456789abcdef";
2216        let worktree = ManagedWorktree {
2217            source_project_directory: repository.path().to_path_buf(),
2218            source_repository: repository.path().to_path_buf(),
2219            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2220            branch: format!("mj/{session_id}"),
2221            target: ManagedWorktreeTarget::Local,
2222        };
2223        create_managed_worktree(
2224            &ProcessExecutor,
2225            &worktree,
2226            None,
2227            PrimaryCheckoutRequirement::Clean,
2228        )
2229        .unwrap();
2230
2231        let mut session = checkpoint_test_session(session_id);
2232        session.project_directory = Some(worktree.worktree_root.clone());
2233        session.managed_worktree = Some(worktree.clone());
2234        let controller = Controller {
2235            config: HelConfig::default(),
2236            state: HelState {
2237                sessions: BTreeMap::from([(session_id.into(), session)]),
2238                ..HelState::default()
2239            },
2240        };
2241        let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2242        let executor = CancellableProcessExecutor::new(cancelled);
2243
2244        controller
2245            .cleanup_new_session_worktree_after_failure(session_id, &executor)
2246            .unwrap();
2247
2248        assert!(!worktree.worktree_root.exists());
2249        assert!(!repository.path().join(".mj").exists());
2250        let branch = Command::new("git")
2251            .arg("-C")
2252            .arg(repository.path())
2253            .args([
2254                "show-ref",
2255                "--verify",
2256                &format!("refs/heads/{}", worktree.branch),
2257            ])
2258            .output()
2259            .unwrap();
2260        assert!(!branch.status.success());
2261    }
2262    #[test]
2263    fn managed_raw_worktree_refuses_dirty_primary_and_skips_existing_worktree() {
2264        let repository = committed_repository();
2265        std::fs::write(repository.path().join("dirty.txt"), "dirty\n").unwrap();
2266        let target = ManagedWorktreeTarget::Local;
2267        let inspection = inspect_raw_project(&ProcessExecutor, &target, repository.path()).unwrap();
2268        let session_id = "fedcba9876543210fedcba9876543210";
2269        let managed = ManagedWorktree {
2270            source_project_directory: inspection.source_project_directory,
2271            source_repository: inspection.source_repository,
2272            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2273            branch: format!("mj/{session_id}"),
2274            target: target.clone(),
2275        };
2276        let error = create_managed_worktree(
2277            &ProcessExecutor,
2278            &managed,
2279            None,
2280            PrimaryCheckoutRequirement::Clean,
2281        )
2282        .unwrap_err();
2283        assert!(error.to_string().contains("uncommitted changes"));
2284        assert!(!managed.worktree_root.exists());
2285
2286        std::fs::remove_file(repository.path().join("dirty.txt")).unwrap();
2287        let existing = repository.path().join("existing-worktree");
2288        test_git(
2289            repository.path(),
2290            &[
2291                "worktree",
2292                "add",
2293                "--detach",
2294                &existing.to_string_lossy(),
2295                "HEAD",
2296            ],
2297        );
2298        let linked = inspect_raw_project(&ProcessExecutor, &target, &existing).unwrap();
2299        assert!(!linked.primary_checkout);
2300    }
2301    #[test]
2302    fn managed_worktree_preflight_preserves_colliding_branch_and_directory() {
2303        let repository = committed_repository();
2304        let target = ManagedWorktreeTarget::Local;
2305        let session_id = "abcdef0123456789abcdef0123456789";
2306        let branch = format!("mj/{session_id}");
2307        test_git(repository.path(), &["branch", &branch]);
2308        let worktree = ManagedWorktree {
2309            source_project_directory: repository.path().to_path_buf(),
2310            source_repository: repository.path().to_path_buf(),
2311            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2312            branch: branch.clone(),
2313            target,
2314        };
2315
2316        let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2317        assert!(error.to_string().contains("branch already exists"));
2318        assert!(
2319            !test_git(
2320                repository.path(),
2321                &["show-ref", "--verify", &format!("refs/heads/{branch}")]
2322            )
2323            .is_empty()
2324        );
2325        std::fs::create_dir_all(&worktree.worktree_root).unwrap();
2326        let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2327        assert!(error.to_string().contains("path already exists"));
2328        assert!(worktree.worktree_root.is_dir());
2329    }
2330    #[test]
2331    fn managed_worktree_ssh_commands_preserve_hostile_path_boundaries() {
2332        let target = ManagedWorktreeTarget::Ssh {
2333            destination: "builder".into(),
2334            ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2335        };
2336        let command = managed_git_command(
2337            &target,
2338            Path::new("/srv/project with ' quote"),
2339            ["worktree", "prune"],
2340            "prune test",
2341        );
2342        assert_eq!(command.program, "ssh");
2343        assert_eq!(&command.args[..3], ["-o", "BatchMode=yes", "builder"]);
2344        assert_eq!(
2345            command.args[3],
2346            "'git' '-C' '/srv/project with '\\'' quote' 'worktree' 'prune'"
2347        );
2348    }
2349}