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