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