Skip to main content

mj_controller/hel_controller/
worktree.rs

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