Skip to main content

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