Skip to main content

mj_controller/hel_controller/
worktree.rs

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