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/// Read where a raw session's checkout stands right now, on whichever host
762/// owns it.
763pub(super) fn raw_checkout_position(
764    session: &SessionRecord,
765    config: &Config,
766    project_directory: &Path,
767    executor: &impl CommandExecutor,
768) -> Result<CheckoutPosition> {
769    let target = match &session.managed_worktree {
770        Some(worktree) => worktree.target.clone(),
771        None => {
772            let template = config
773                .targets
774                .get(&session.target_template_id)
775                .context("the bare target this session last used is missing")?;
776            managed_worktree_target(template)?
777        }
778    };
779    read_checkout_position(executor, &target, project_directory)
780}
781
782/// One conversation line for a raw session whose checkout moved on while the
783/// session was stopped. `None` when the checkout is where the checkpoint left
784/// it, or when the checkpoint recorded no repository to compare against.
785///
786/// This reports; it never reconciles. The working tree is the truth.
787pub(super) fn raw_checkout_divergence_notice(
788    directory: &Path,
789    recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
790    live: &CheckoutPosition,
791) -> Option<String> {
792    let recorded = recorded?;
793    if recorded.head_commit.is_empty()
794        || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
795    {
796        return None;
797    }
798    Some(format!(
799        "The working tree at {} moved from {} to {} while this session was stopped.",
800        directory.display(),
801        checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
802        checkout_position_text(&live.head_commit, live.branch.as_deref()),
803    ))
804}
805
806fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
807    let short = head_commit.get(..12).unwrap_or(head_commit);
808    match branch {
809        Some(branch) => format!("{short} ({branch})"),
810        None => format!("{short} (detached)"),
811    }
812}
813
814fn inspect_raw_project(
815    executor: &impl CommandExecutor,
816    target: &ManagedWorktreeTarget,
817    selected: &Path,
818) -> Result<RawProjectInspection> {
819    let repository = PathBuf::from(managed_git_stdout(
820        executor,
821        target,
822        selected,
823        ["rev-parse", "--path-format=absolute", "--show-toplevel"],
824        "resolve raw project repository root",
825    )?);
826    let prefix = managed_git_stdout(
827        executor,
828        target,
829        selected,
830        ["rev-parse", "--show-prefix"],
831        "resolve raw project relative directory",
832    )?;
833    let git_dir = PathBuf::from(managed_git_stdout(
834        executor,
835        target,
836        selected,
837        ["rev-parse", "--absolute-git-dir"],
838        "resolve raw project Git directory",
839    )?);
840    let common_git_dir = PathBuf::from(managed_git_stdout(
841        executor,
842        target,
843        selected,
844        ["rev-parse", "--path-format=absolute", "--git-common-dir"],
845        "resolve raw project common Git directory",
846    )?);
847    let branch_command = managed_git_command(
848        target,
849        selected,
850        ["symbolic-ref", "--quiet", "--short", "HEAD"],
851        "resolve raw project branch",
852    );
853    let branch_output = executor.execute(&branch_command)?;
854    let branch = match branch_output.status {
855        0 => Some(
856            String::from_utf8(branch_output.stdout)
857                .context("raw project branch was not UTF-8")?
858                .trim()
859                .to_owned(),
860        ),
861        1 | 128 => None,
862        status => bail!(
863            "resolve raw project branch failed with status {status}: {}",
864            String::from_utf8_lossy(&branch_output.stderr).trim()
865        ),
866    };
867    let upstream = match branch {
868        Some(branch) => {
869            let reference = format!("refs/heads/{branch}");
870            let upstream = managed_git_stdout(
871                executor,
872                target,
873                selected,
874                ["for-each-ref", "--format=%(upstream:short)", &reference],
875                "resolve raw project upstream",
876            )?;
877            (!upstream.is_empty()).then_some(upstream)
878        }
879        None => None,
880    };
881    Ok(RawProjectInspection {
882        source_project_directory: repository.join(prefix),
883        source_repository: repository,
884        primary_checkout: git_dir == common_git_dir,
885        upstream,
886    })
887}
888
889fn ensure_managed_worktree_excluded(
890    executor: &impl CommandExecutor,
891    target: &ManagedWorktreeTarget,
892    repository: &Path,
893) -> Result<()> {
894    let check = managed_git_command(
895        target,
896        repository,
897        [
898            "check-ignore",
899            "--quiet",
900            "--no-index",
901            "--",
902            ".mj/worktrees/",
903        ],
904        "check managed worktree exclusion",
905    );
906    let output = executor.execute(&check)?;
907    match output.status {
908        0 => return Ok(()),
909        1 => {}
910        status => bail!(
911            "check managed worktree exclusion failed with status {status}: {}",
912            String::from_utf8_lossy(&output.stderr).trim()
913        ),
914    }
915    let exclude_path = PathBuf::from(managed_git_stdout(
916        executor,
917        target,
918        repository,
919        [
920            "rev-parse",
921            "--path-format=absolute",
922            "--git-path",
923            "info/exclude",
924        ],
925        "resolve repository-local exclude file",
926    )?);
927    const ENTRY: &str = "/.mj/worktrees/";
928    match target {
929        ManagedWorktreeTarget::Local => {
930            use std::io::Write;
931            let existing = match std::fs::read_to_string(&exclude_path) {
932                Ok(existing) => existing,
933                Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
934                Err(error) => return Err(error.into()),
935            };
936            if existing.lines().any(|line| line.trim() == ENTRY) {
937                return Ok(());
938            }
939            if let Some(parent) = exclude_path.parent() {
940                std::fs::create_dir_all(parent)?;
941            }
942            let mut file = std::fs::OpenOptions::new()
943                .create(true)
944                .append(true)
945                .open(&exclude_path)
946                .with_context(|| format!("open {}", exclude_path.display()))?;
947            if !existing.is_empty() && !existing.ends_with('\n') {
948                writeln!(file)?;
949            }
950            writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
951        }
952        ManagedWorktreeTarget::Ssh { .. } => {
953            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";
954            let command = managed_target_command(
955                target,
956                "sh",
957                [
958                    "-c",
959                    SCRIPT,
960                    "hel-exclude",
961                    &exclude_path.to_string_lossy(),
962                    ENTRY,
963                ],
964            )
965            .purpose("update remote repository-local exclude file");
966            execute_checked(executor, command)?;
967        }
968    }
969    Ok(())
970}
971
972pub(crate) fn path_exists_on_managed_target(
973    executor: &impl CommandExecutor,
974    target: &ManagedWorktreeTarget,
975    path: &Path,
976) -> Result<bool> {
977    match target {
978        ManagedWorktreeTarget::Local => path
979            .try_exists()
980            .with_context(|| format!("check managed project path {}", path.display())),
981        ManagedWorktreeTarget::Ssh { .. } => {
982            let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
983                .purpose("check managed worktree path");
984            let output = executor.execute(&command)?;
985            match output.status {
986                0 => Ok(true),
987                1 => Ok(false),
988                status => bail!(
989                    "check managed worktree path failed with status {status}: {}",
990                    String::from_utf8_lossy(&output.stderr).trim()
991                ),
992            }
993        }
994    }
995}
996
997pub(super) fn managed_worktree_checkout_exists(
998    executor: &impl CommandExecutor,
999    worktree: &ManagedWorktree,
1000) -> Result<bool> {
1001    path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1002}
1003
1004/// Whether a new managed worktree needs the primary checkout to be clean.
1005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1006pub(super) enum PrimaryCheckoutRequirement {
1007    /// A new raw session starts from the primary checkout's HEAD, so work that
1008    /// is only in its working tree would be silently left behind.
1009    Clean,
1010    /// A session moving out of its target replaces the worktree's contents from
1011    /// its checkpoint, so the primary checkout's own changes are beside the
1012    /// point.
1013    Any,
1014}
1015
1016pub(super) fn create_managed_worktree(
1017    executor: &impl CommandExecutor,
1018    worktree: &ManagedWorktree,
1019    upstream: Option<&str>,
1020    requirement: PrimaryCheckoutRequirement,
1021) -> Result<()> {
1022    ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
1023    if requirement == PrimaryCheckoutRequirement::Clean {
1024        let status = managed_git_stdout(
1025            executor,
1026            &worktree.target,
1027            &worktree.source_repository,
1028            ["status", "--porcelain=v1", "--untracked-files=all"],
1029            "inspect primary checkout changes",
1030        )?;
1031        if !status.is_empty() {
1032            let paths = status.lines().take(20).collect::<Vec<_>>().join("\n  ");
1033            bail!(
1034                "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n  {paths}"
1035            );
1036        }
1037    }
1038    let parent = worktree
1039        .worktree_root
1040        .parent()
1041        .context("managed worktree root has no parent")?;
1042    execute_checked(
1043        executor,
1044        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1045            .purpose("create managed worktree directory"),
1046    )?;
1047    execute_checked(
1048        executor,
1049        managed_git_command(
1050            &worktree.target,
1051            &worktree.source_repository,
1052            [
1053                "worktree",
1054                "add",
1055                "-b",
1056                &worktree.branch,
1057                &worktree.worktree_root.to_string_lossy(),
1058                "HEAD",
1059            ],
1060            "create managed raw-session worktree",
1061        ),
1062    )?;
1063    if let Some(upstream) = upstream {
1064        execute_checked(
1065            executor,
1066            managed_git_command(
1067                &worktree.target,
1068                &worktree.worktree_root,
1069                ["branch", "--set-upstream-to", upstream, &worktree.branch],
1070                "set managed worktree branch upstream",
1071            ),
1072        )?;
1073    }
1074    Ok(())
1075}
1076
1077/// Recreate a retired checkout from the session branch. Returns whether this
1078/// call created it, so a failed resume can put the session back into its
1079/// stopped, checkout-free state.
1080pub(super) fn restore_managed_worktree(
1081    executor: &impl CommandExecutor,
1082    worktree: &ManagedWorktree,
1083) -> Result<bool> {
1084    if managed_worktree_checkout_exists(executor, worktree)? {
1085        return Ok(false);
1086    }
1087    ensure!(
1088        path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
1089        "managed worktree source repository is unavailable: {}",
1090        worktree.source_repository.display()
1091    );
1092    let branch_ref = format!("refs/heads/{}", worktree.branch);
1093    let check = managed_git_command(
1094        &worktree.target,
1095        &worktree.source_repository,
1096        ["show-ref", "--verify", "--quiet", &branch_ref],
1097        "check retired managed worktree branch",
1098    );
1099    let output = executor.execute(&check)?;
1100    match output.status {
1101        0 => {}
1102        1 => bail!(
1103            "managed worktree branch is unavailable: {}",
1104            worktree.branch
1105        ),
1106        status => bail!(
1107            "check retired managed worktree branch failed with status {status}: {}",
1108            String::from_utf8_lossy(&output.stderr).trim()
1109        ),
1110    }
1111    // A remote bare target may already have removed the checkout directory.
1112    // Prune its stale registration before adding the retained branch again.
1113    execute_checked(
1114        executor,
1115        managed_git_command(
1116            &worktree.target,
1117            &worktree.source_repository,
1118            ["worktree", "prune"],
1119            "prune retired managed worktree metadata",
1120        ),
1121    )?;
1122    let parent = worktree
1123        .worktree_root
1124        .parent()
1125        .context("managed worktree root has no parent")?;
1126    execute_checked(
1127        executor,
1128        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1129            .purpose("recreate managed worktree directory"),
1130    )?;
1131    execute_checked(
1132        executor,
1133        managed_git_command(
1134            &worktree.target,
1135            &worktree.source_repository,
1136            [
1137                "worktree",
1138                "add",
1139                "--",
1140                &worktree.worktree_root.to_string_lossy(),
1141                &worktree.branch,
1142            ],
1143            "restore managed raw-session worktree",
1144        ),
1145    )?;
1146    Ok(true)
1147}
1148
1149fn ensure_managed_worktree_available(
1150    executor: &impl CommandExecutor,
1151    worktree: &ManagedWorktree,
1152) -> Result<()> {
1153    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1154        bail!(
1155            "managed worktree path already exists: {}",
1156            worktree.worktree_root.display()
1157        );
1158    }
1159    let branch_ref = format!("refs/heads/{}", worktree.branch);
1160    let check = managed_git_command(
1161        &worktree.target,
1162        &worktree.source_repository,
1163        ["show-ref", "--verify", "--quiet", &branch_ref],
1164        "check managed worktree branch availability",
1165    );
1166    let output = executor.execute(&check)?;
1167    match output.status {
1168        0 => bail!(
1169            "managed worktree branch already exists: {}",
1170            worktree.branch
1171        ),
1172        1 => Ok(()),
1173        status => bail!(
1174            "check managed worktree branch availability failed with status {status}: {}",
1175            String::from_utf8_lossy(&output.stderr).trim()
1176        ),
1177    }
1178}
1179
1180/// Check whether the deterministic branch left by this session's earlier
1181/// raw-to-workspace move can be reattached. A branch with this session's id is
1182/// session-owned, but an active checkout elsewhere is still a collision: the
1183/// restore must not make one branch belong to two worktrees.
1184fn retained_managed_worktree_branch_available(
1185    executor: &impl CommandExecutor,
1186    worktree: &ManagedWorktree,
1187) -> Result<bool> {
1188    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1189        bail!(
1190            "managed worktree path already exists: {}",
1191            worktree.worktree_root.display()
1192        );
1193    }
1194    let branch_ref = format!("refs/heads/{}", worktree.branch);
1195    let check = managed_git_command(
1196        &worktree.target,
1197        &worktree.source_repository,
1198        ["show-ref", "--verify", "--quiet", &branch_ref],
1199        "check retained managed worktree branch",
1200    );
1201    let output = executor.execute(&check)?;
1202    match output.status {
1203        1 => Ok(false),
1204        0 => {
1205            let worktrees = managed_git_stdout(
1206                executor,
1207                &worktree.target,
1208                &worktree.source_repository,
1209                ["worktree", "list", "--porcelain", "-z"],
1210                "check retained managed worktree checkout",
1211            )?;
1212            let branch_field = format!("branch {branch_ref}");
1213            if worktrees.split('\0').any(|field| field == branch_field) {
1214                bail!(
1215                    "managed worktree branch is still checked out: {}",
1216                    worktree.branch
1217                );
1218            }
1219            Ok(true)
1220        }
1221        status => bail!(
1222            "check retained managed worktree branch failed with status {status}: {}",
1223            String::from_utf8_lossy(&output.stderr).trim()
1224        ),
1225    }
1226}
1227
1228/// Preserve the ref that a return-to-local restore is about to reset. The
1229/// retained `mj/<session>` branch is the source-recovery point; keeping a
1230/// second ref makes a later commit on that branch recoverable as well.
1231pub(super) fn preserve_retained_managed_worktree_branch(
1232    executor: &impl CommandExecutor,
1233    worktree: &ManagedWorktree,
1234) -> Result<String> {
1235    let session_id = worktree
1236        .branch
1237        .strip_prefix("mj/")
1238        .context("managed worktree branch is not session-owned")?;
1239    let branch_ref = format!("refs/heads/{}", worktree.branch);
1240    let tip = managed_git_stdout(
1241        executor,
1242        &worktree.target,
1243        &worktree.source_repository,
1244        ["rev-parse", "--verify", &branch_ref],
1245        "read retained managed worktree branch tip",
1246    )?;
1247    let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
1248    let existing = managed_git_command(
1249        &worktree.target,
1250        &worktree.source_repository,
1251        ["show-ref", "--verify", "--quiet", &recovery_ref],
1252        "check retained managed worktree recovery ref",
1253    );
1254    let output = executor.execute(&existing)?;
1255    match output.status {
1256        0 => {
1257            let existing_tip = managed_git_stdout(
1258                executor,
1259                &worktree.target,
1260                &worktree.source_repository,
1261                ["rev-parse", "--verify", &recovery_ref],
1262                "verify retained managed worktree recovery ref",
1263            )?;
1264            ensure!(
1265                existing_tip == tip,
1266                "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
1267            );
1268            Ok(recovery_ref)
1269        }
1270        1 => {
1271            execute_checked(
1272                executor,
1273                managed_git_command(
1274                    &worktree.target,
1275                    &worktree.source_repository,
1276                    ["update-ref", &recovery_ref, &tip],
1277                    "preserve retained managed worktree branch",
1278                ),
1279            )?;
1280            Ok(recovery_ref)
1281        }
1282        status => bail!(
1283            "check retained managed worktree recovery ref failed with status {status}: {}",
1284            String::from_utf8_lossy(&output.stderr).trim()
1285        ),
1286    }
1287}
1288
1289/// Remove a managed worktree's checkout and keep its branch.
1290///
1291/// A session that moved into a target still checkpoints as a delta against
1292/// `hel/<session>`, so deleting that branch could let the commits those deltas
1293/// depend on be collected. The checkout itself is dirty by design; its dirty
1294/// state has already been carried into the target.
1295pub(super) fn retire_managed_worktree(
1296    executor: &impl CommandExecutor,
1297    worktree: &ManagedWorktree,
1298) -> Result<()> {
1299    if !remove_managed_worktree_checkout(executor, worktree)? {
1300        return Ok(());
1301    }
1302    remove_empty_managed_worktree_directories(executor, worktree)
1303}
1304
1305/// Remove the checkout and prune its metadata. Returns whether the repository
1306/// is still there to act on at all.
1307fn remove_managed_worktree_checkout(
1308    executor: &impl CommandExecutor,
1309    worktree: &ManagedWorktree,
1310) -> Result<bool> {
1311    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1312        return Ok(false);
1313    }
1314    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1315        execute_checked(
1316            executor,
1317            managed_git_command(
1318                &worktree.target,
1319                &worktree.source_repository,
1320                [
1321                    "worktree",
1322                    "remove",
1323                    "--force",
1324                    &worktree.worktree_root.to_string_lossy(),
1325                ],
1326                "remove managed raw-session worktree",
1327            ),
1328        )?;
1329    }
1330    execute_checked(
1331        executor,
1332        managed_git_command(
1333            &worktree.target,
1334            &worktree.source_repository,
1335            ["worktree", "prune"],
1336            "prune managed worktree metadata",
1337        ),
1338    )?;
1339    Ok(true)
1340}
1341
1342pub(super) fn cleanup_managed_worktree(
1343    executor: &impl CommandExecutor,
1344    worktree: &ManagedWorktree,
1345) -> Result<()> {
1346    if !remove_managed_worktree_checkout(executor, worktree)? {
1347        return Ok(());
1348    }
1349    let branch_ref = format!("refs/heads/{}", worktree.branch);
1350    let check = managed_git_command(
1351        &worktree.target,
1352        &worktree.source_repository,
1353        ["show-ref", "--verify", "--quiet", &branch_ref],
1354        "check managed worktree branch",
1355    );
1356    let output = executor.execute(&check)?;
1357    match output.status {
1358        0 => {
1359            execute_checked(
1360                executor,
1361                managed_git_command(
1362                    &worktree.target,
1363                    &worktree.source_repository,
1364                    ["branch", "-D", "--", &worktree.branch],
1365                    "delete managed raw-session branch",
1366                ),
1367            )?;
1368        }
1369        1 => {}
1370        status => bail!(
1371            "check managed worktree branch failed with status {status}: {}",
1372            String::from_utf8_lossy(&output.stderr).trim()
1373        ),
1374    }
1375    remove_empty_managed_worktree_directories(executor, worktree)
1376}
1377
1378fn remove_empty_managed_worktree_directories(
1379    executor: &impl CommandExecutor,
1380    worktree: &ManagedWorktree,
1381) -> Result<()> {
1382    let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1383    let hel = worktree.source_repository.join(".mj");
1384    match &worktree.target {
1385        ManagedWorktreeTarget::Local => {
1386            for directory in [&worktrees, &hel] {
1387                match std::fs::remove_dir(directory) {
1388                    Ok(()) => {}
1389                    Err(error)
1390                        if matches!(
1391                            error.kind(),
1392                            std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1393                        ) => {}
1394                    Err(error) => return Err(error.into()),
1395                }
1396            }
1397        }
1398        ManagedWorktreeTarget::Ssh { .. } => {
1399            let command = managed_target_command(
1400                &worktree.target,
1401                "rmdir",
1402                ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1403            )
1404            .purpose("remove empty managed worktree directories");
1405            let _ = executor.execute(&command)?;
1406        }
1407    }
1408    Ok(())
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413    use std::cell::RefCell;
1414    use std::collections::BTreeMap;
1415    use std::path::{Path, PathBuf};
1416    use std::process::Command;
1417
1418    use anyhow::Result;
1419
1420    use crate::controller::Controller;
1421    use crate::controller::resume::apply_failed_resume_rollback;
1422    use crate::controller::test_support::{
1423        checkpoint_test_session, committed_repository, local_bundle, managed_raw_session,
1424        managed_worktree_session, raw_session_on, resume_compatibility_config, ssh_worktree_target,
1425        test_git,
1426    };
1427    use mj_checkpoint::archive::RepositoryMetadata;
1428    use mj_core::config::{
1429        Config, HarnessProfile, ProjectBundle, ProjectRepository, TargetTemplate,
1430    };
1431    use mj_core::state::{ManagedWorktree, ManagedWorktreeTarget, SessionState, State};
1432
1433    use crate::targets::{
1434        CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
1435    };
1436
1437    use super::*;
1438
1439    #[test]
1440    fn worktree_choice_survives_reload_and_controls_creation() {
1441        const CHILD: &str = "MJ_TEST_WORKTREE_CHOICE_CHILD";
1442        if std::env::var_os(CHILD).is_none() {
1443            let directory = tempfile::tempdir().unwrap();
1444            let mut command = Command::new(std::env::current_exe().unwrap());
1445            command.args(["--exact", "controller::worktree::tests::worktree_choice_survives_reload_and_controls_creation", "--nocapture"])
1446                .env(CHILD, "1").env("MJ_DATA_DIR", directory.path()).env("MJ_CONFIG_DIR", directory.path());
1447            let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1448            assert!(
1449                output.status.success(),
1450                "{}\n{}",
1451                String::from_utf8_lossy(&output.stdout),
1452                String::from_utf8_lossy(&output.stderr)
1453            );
1454            return;
1455        }
1456        let _writer = crate::database::install_isolated_test_writer();
1457        let repository = committed_repository();
1458        let root = repository.path().canonicalize().unwrap();
1459        let linked_parent = tempfile::tempdir().unwrap();
1460        let linked = linked_parent.path().join("linked");
1461        test_git(
1462            &root,
1463            &["worktree", "add", "-b", "side", linked.to_str().unwrap()],
1464        );
1465        test_git(&linked, &["branch", "--set-upstream-to=master"]);
1466        std::fs::write(linked.join("nested/file.txt"), "linked commit\n").unwrap();
1467        test_git(&linked, &["commit", "-am", "side commit"]);
1468        let linked = linked.canonicalize().unwrap();
1469        let mut config = Config::default();
1470        config
1471            .targets
1472            .insert("localhost".into(), TargetTemplate::LocalBare);
1473        config.save().unwrap();
1474        let mut controller = Controller {
1475            config,
1476            state: State::default(),
1477        };
1478        assert_eq!(
1479            controller
1480                .managed_worktree_options("localhost", &root, &ProcessExecutor)
1481                .unwrap(),
1482            ManagedWorktreeOptions {
1483                available: true,
1484                default_create: true
1485            }
1486        );
1487        assert_eq!(
1488            controller
1489                .managed_worktree_options("localhost", &linked, &ProcessExecutor)
1490                .unwrap(),
1491            ManagedWorktreeOptions {
1492                available: true,
1493                default_create: false
1494            }
1495        );
1496
1497        // Both creation and first resume of an imported session enter this preparation.
1498        // A dirty source is usable directly, and cleanup must leave its files and branch alone.
1499        std::fs::write(root.join("dirty.txt"), "keep me\n").unwrap();
1500        let selected = root.join("nested");
1501        let mut record = raw_session_on("localhost", selected.to_str().unwrap());
1502        record.create_managed_worktree = Some(false);
1503        crate::database::save_session(&record).unwrap();
1504        for _ in 0..2 {
1505            controller.reload().unwrap();
1506            assert!(
1507                !controller
1508                    .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1509                    .unwrap()
1510            );
1511            assert_eq!(
1512                controller.state.sessions[&record.id]
1513                    .project_directory
1514                    .as_ref(),
1515                Some(&selected)
1516            );
1517            assert!(
1518                controller.state.sessions[&record.id]
1519                    .managed_worktree
1520                    .is_none()
1521            );
1522            controller
1523                .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1524                .unwrap();
1525        }
1526        assert!(root.join("dirty.txt").exists());
1527        assert!(!root.join(".mj/worktrees").exists());
1528        assert_eq!(test_git(&root, &["branch", "--show-current"]), "master");
1529        std::fs::remove_file(root.join("dirty.txt")).unwrap();
1530
1531        // Explicit creation also works from a linked checkout and preserves its HEAD,
1532        // upstream, and selected subdirectory rather than using the main checkout's HEAD.
1533        record.project_directory = Some(linked.join("nested"));
1534        record.create_managed_worktree = None;
1535        crate::database::save_session(&record).unwrap();
1536        controller.reload().unwrap();
1537        assert!(
1538            !controller
1539                .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1540                .unwrap()
1541        );
1542        record.create_managed_worktree = Some(true);
1543        crate::database::save_session(&record).unwrap();
1544        controller.reload().unwrap();
1545        assert!(
1546            controller
1547                .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1548                .unwrap()
1549        );
1550        controller.reload().unwrap();
1551        let managed = controller.state.sessions[&record.id]
1552            .managed_worktree
1553            .clone()
1554            .unwrap();
1555        assert_eq!(
1556            test_git(&managed.worktree_root, &["rev-parse", "HEAD"]),
1557            test_git(&linked, &["rev-parse", "HEAD"])
1558        );
1559        assert_ne!(
1560            test_git(&managed.worktree_root, &["rev-parse", "HEAD"]),
1561            test_git(&root, &["rev-parse", "HEAD"])
1562        );
1563        assert_eq!(
1564            test_git(
1565                &managed.worktree_root,
1566                &["rev-parse", "--abbrev-ref", "@{upstream}"]
1567            ),
1568            "master"
1569        );
1570        assert_eq!(
1571            controller.state.sessions[&record.id].project_directory,
1572            Some(managed.worktree_root.join("nested"))
1573        );
1574        controller
1575            .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1576            .unwrap();
1577        assert!(linked.join("nested/file.txt").exists());
1578        assert!(!managed.worktree_root.exists());
1579
1580        record.project_directory = Some(root.clone());
1581        record.create_managed_worktree = None;
1582        crate::database::save_session(&record).unwrap();
1583        controller.reload().unwrap();
1584        assert!(
1585            controller
1586                .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1587                .unwrap()
1588        );
1589        controller
1590            .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1591            .unwrap();
1592    }
1593
1594    #[test]
1595    fn explicit_worktree_creation_rejects_plain_directories() {
1596        let directory = tempfile::tempdir().unwrap();
1597        let mut record = raw_session_on("localhost", directory.path().to_str().unwrap());
1598        record.create_managed_worktree = Some(true);
1599        let id = record.id.clone();
1600        let mut config = Config::default();
1601        config
1602            .targets
1603            .insert("localhost".into(), TargetTemplate::LocalBare);
1604        let mut controller = Controller {
1605            config,
1606            state: State {
1607                sessions: [(id.clone(), record)].into_iter().collect(),
1608                ..State::default()
1609            },
1610        };
1611        assert_eq!(
1612            controller
1613                .managed_worktree_options("localhost", directory.path(), &ProcessExecutor)
1614                .unwrap(),
1615            ManagedWorktreeOptions::default()
1616        );
1617        let error = controller
1618            .prepare_managed_raw_worktree(&id, &ProcessExecutor)
1619            .unwrap_err();
1620        assert!(error.to_string().contains("requires a Git project"));
1621        assert!(!directory.path().join(".git").exists());
1622    }
1623
1624    #[test]
1625    fn local_bare_validation_accepts_projects_and_plain_directories_but_rejects_missing_paths() {
1626        let project = committed_repository();
1627        let plain = tempfile::tempdir().unwrap();
1628        let mut config = Config::default();
1629        config
1630            .targets
1631            .insert("localhost".into(), TargetTemplate::LocalBare);
1632        let controller = Controller {
1633            config,
1634            state: State::default(),
1635        };
1636        controller
1637            .validate_project_directory("localhost", project.path(), &ProcessExecutor)
1638            .unwrap();
1639        controller
1640            .validate_project_directory("localhost", plain.path(), &ProcessExecutor)
1641            .unwrap();
1642        assert!(
1643            controller
1644                .validate_project_directory(
1645                    "localhost",
1646                    &plain.path().join("missing"),
1647                    &ProcessExecutor
1648                )
1649                .is_err()
1650        );
1651    }
1652
1653    #[test]
1654    fn a_plain_local_directory_starts_without_creating_a_git_worktree() {
1655        let plain = tempfile::tempdir().unwrap();
1656        let session = raw_session_on("localhost", plain.path().to_str().unwrap());
1657        let session_id = session.id.clone();
1658        let mut config = Config::default();
1659        config
1660            .targets
1661            .insert("localhost".into(), TargetTemplate::LocalBare);
1662        let mut controller = Controller {
1663            config,
1664            state: State {
1665                sessions: [(session_id.clone(), session)].into_iter().collect(),
1666                ..State::default()
1667            },
1668        };
1669        assert!(
1670            !controller
1671                .prepare_managed_raw_worktree(&session_id, &ProcessExecutor)
1672                .unwrap()
1673        );
1674        let session = &controller.state.sessions[&session_id];
1675        assert_eq!(session.project_directory.as_deref(), Some(plain.path()));
1676        assert!(session.managed_worktree.is_none());
1677        assert!(!plain.path().join(".git").exists());
1678    }
1679
1680    #[test]
1681    fn raw_linked_worktree_origin_matches_the_configured_github_project() {
1682        struct OriginExecutor;
1683        impl CommandExecutor for OriginExecutor {
1684            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1685                assert_eq!(
1686                    command.args,
1687                    [
1688                        "-C",
1689                        "/mnt/optane/bifrost-fird",
1690                        "config",
1691                        "--get",
1692                        "remote.origin.url",
1693                    ]
1694                );
1695                Ok(CommandOutput {
1696                    status: 0,
1697                    stdout: b"git@github.com:BrokkAi/bifrost-dev.git\n".to_vec(),
1698                    stderr: Vec::new(),
1699                })
1700            }
1701        }
1702
1703        let mut config = Config::default();
1704        config
1705            .targets
1706            .insert("localhost".into(), TargetTemplate::LocalBare);
1707        let session = raw_session_on("localhost", "/mnt/optane/bifrost-fird");
1708        let session_id = session.id.clone();
1709        let controller = Controller {
1710            config,
1711            state: State {
1712                sessions: [(session_id.clone(), session)].into_iter().collect(),
1713                ..State::default()
1714            },
1715        };
1716
1717        let source = controller
1718            .resolve_session_project_source(&session_id, &OriginExecutor)
1719            .unwrap();
1720
1721        assert_eq!(source.key, "github:brokkai/bifrost-dev");
1722        assert_eq!(source.short, "bifrost-dev");
1723        assert_eq!(source.full, "BrokkAi/bifrost-dev");
1724    }
1725    #[test]
1726    fn managed_worktree_origin_uses_source_repository_while_checkout_is_retired() {
1727        struct OriginExecutor;
1728        impl CommandExecutor for OriginExecutor {
1729            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1730                assert_eq!(
1731                    command.args,
1732                    [
1733                        "-C",
1734                        "/home/dev/project",
1735                        "config",
1736                        "--get",
1737                        "remote.origin.url",
1738                    ]
1739                );
1740                Ok(CommandOutput {
1741                    status: 0,
1742                    stdout: b"git@github.com:example/project.git\n".to_vec(),
1743                    stderr: Vec::new(),
1744                })
1745            }
1746        }
1747
1748        let session = managed_raw_session(ManagedWorktreeTarget::Local);
1749        let session_id = session.id.clone();
1750        let controller = Controller {
1751            config: Config::default(),
1752            state: State {
1753                sessions: [(session_id.clone(), session)].into_iter().collect(),
1754                ..State::default()
1755            },
1756        };
1757
1758        let source = controller
1759            .resolve_session_project_source(&session_id, &OriginExecutor)
1760            .unwrap();
1761
1762        assert_eq!(source.key, "github:example/project");
1763    }
1764
1765    #[test]
1766    fn raw_no_origin_uses_the_canonical_main_repository_root() {
1767        struct NoOriginExecutor {
1768            commands: RefCell<Vec<CommandSpec>>,
1769        }
1770        impl CommandExecutor for NoOriginExecutor {
1771            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1772                self.commands.borrow_mut().push(command.clone());
1773                if command.args.iter().any(|argument| argument == "config") {
1774                    return Ok(CommandOutput {
1775                        status: 1,
1776                        stdout: Vec::new(),
1777                        stderr: Vec::new(),
1778                    });
1779                }
1780                let stdout = if command
1781                    .args
1782                    .iter()
1783                    .any(|argument| argument == "--show-toplevel")
1784                {
1785                    "/worktrees/project-side\n"
1786                } else if command
1787                    .args
1788                    .iter()
1789                    .any(|argument| argument == "--git-common-dir")
1790                {
1791                    "/projects/project/.git\n"
1792                } else {
1793                    panic!("unexpected command {:?}", command.args);
1794                };
1795                Ok(CommandOutput {
1796                    status: 0,
1797                    stdout: stdout.as_bytes().to_vec(),
1798                    stderr: Vec::new(),
1799                })
1800            }
1801        }
1802
1803        let mut config = Config::default();
1804        config
1805            .targets
1806            .insert("localhost".into(), TargetTemplate::LocalBare);
1807        let session = raw_session_on("localhost", "/worktrees/project-side");
1808        let session_id = session.id.clone();
1809        let controller = Controller {
1810            config,
1811            state: State {
1812                sessions: [(session_id.clone(), session)].into_iter().collect(),
1813                ..State::default()
1814            },
1815        };
1816        let executor = NoOriginExecutor {
1817            commands: RefCell::new(Vec::new()),
1818        };
1819
1820        let source = controller
1821            .resolve_session_project_source(&session_id, &executor)
1822            .unwrap();
1823
1824        assert_eq!(source.key, "path:/projects/project");
1825        assert_eq!(source.short, "project");
1826        assert_eq!(source.full, "/projects/project");
1827        assert_eq!(executor.commands.borrow().len(), 3);
1828    }
1829
1830    #[test]
1831    fn raw_non_git_directory_keeps_its_local_path_source() {
1832        struct NonGitExecutor {
1833            commands: RefCell<Vec<CommandSpec>>,
1834        }
1835        impl CommandExecutor for NonGitExecutor {
1836            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1837                self.commands.borrow_mut().push(command.clone());
1838                let status = if command.args.iter().any(|argument| argument == "config") {
1839                    1
1840                } else {
1841                    assert!(
1842                        command
1843                            .args
1844                            .iter()
1845                            .any(|argument| argument == "--show-toplevel")
1846                    );
1847                    128
1848                };
1849                Ok(CommandOutput {
1850                    status,
1851                    stdout: Vec::new(),
1852                    stderr: b"fatal: not a git repository\n".to_vec(),
1853                })
1854            }
1855        }
1856
1857        let mut config = Config::default();
1858        config
1859            .targets
1860            .insert("localhost".into(), TargetTemplate::LocalBare);
1861        let session = raw_session_on("localhost", "/scratch/project");
1862        let session_id = session.id.clone();
1863        let controller = Controller {
1864            config,
1865            state: State {
1866                sessions: [(session_id.clone(), session)].into_iter().collect(),
1867                ..State::default()
1868            },
1869        };
1870        let executor = NonGitExecutor {
1871            commands: RefCell::new(Vec::new()),
1872        };
1873
1874        let source = controller
1875            .resolve_session_project_source(&session_id, &executor)
1876            .unwrap();
1877
1878        assert_eq!(source.key, "path:/scratch/project");
1879        assert_eq!(source.full, "/scratch/project");
1880        assert_eq!(executor.commands.borrow().len(), 2);
1881    }
1882
1883    #[test]
1884    fn project_root_lookup_reports_git_failures_instead_of_treating_them_as_non_git() {
1885        struct FailedGit;
1886        impl CommandExecutor for FailedGit {
1887            fn execute(&self, _: &CommandSpec) -> Result<CommandOutput> {
1888                Ok(CommandOutput {
1889                    status: 128,
1890                    stdout: Vec::new(),
1891                    stderr: b"fatal: detected dubious ownership in repository".to_vec(),
1892                })
1893            }
1894        }
1895        let error = resolve_git_root(
1896            &ManagedWorktreeTarget::Local,
1897            Path::new("/project"),
1898            &FailedGit,
1899        )
1900        .unwrap_err();
1901        assert!(error.to_string().contains("dubious ownership"));
1902    }
1903
1904    /// Answers the two Git reads that locate a checkout, and nothing else.
1905    struct CheckoutPositionExecutor {
1906        head_commit: String,
1907        branch: Option<String>,
1908    }
1909    impl CommandExecutor for CheckoutPositionExecutor {
1910        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1911            let stdout = if command.args.iter().any(|argument| argument == "rev-parse") {
1912                self.head_commit.clone()
1913            } else if command
1914                .args
1915                .iter()
1916                .any(|argument| argument == "symbolic-ref")
1917            {
1918                match &self.branch {
1919                    Some(branch) => branch.clone(),
1920                    None => {
1921                        return Ok(CommandOutput {
1922                            status: 1,
1923                            stdout: Vec::new(),
1924                            stderr: Vec::new(),
1925                        });
1926                    }
1927                }
1928            } else {
1929                panic!("unexpected command {:?}", command.args);
1930            };
1931            Ok(CommandOutput {
1932                status: 0,
1933                stdout: format!("{stdout}\n").into_bytes(),
1934                stderr: Vec::new(),
1935            })
1936        }
1937    }
1938    fn recorded_repository(head_commit: &str, branch: Option<&str>) -> RepositoryMetadata {
1939        RepositoryMetadata {
1940            push_urls: Vec::new(),
1941            remote_workspace: false,
1942            id: "project".into(),
1943            relative_destination: PathBuf::from("project"),
1944            origin: "mj-local:project".into(),
1945            base_commit: String::new(),
1946            head_commit: head_commit.into(),
1947            branch: branch.map(str::to_owned),
1948        }
1949    }
1950    #[test]
1951    fn a_raw_checkout_that_moved_while_stopped_gets_a_conversation_line() {
1952        let config = resume_compatibility_config();
1953        let session = managed_raw_session(ManagedWorktreeTarget::Local);
1954        let directory = session.project_directory.clone().unwrap();
1955        let executor = CheckoutPositionExecutor {
1956            head_commit: "b".repeat(40),
1957            branch: Some("mj/0123456789abcdef0123456789abcdef".into()),
1958        };
1959
1960        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1961        let notice = raw_checkout_divergence_notice(
1962            &directory,
1963            Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1964            &live,
1965        )
1966        .expect("a moved checkout is reported");
1967
1968        assert!(
1969            notice.contains(&directory.display().to_string()),
1970            "{notice}"
1971        );
1972        assert!(notice.contains("aaaaaaaaaaaa (main)"), "{notice}");
1973        assert!(
1974            notice.contains("bbbbbbbbbbbb (mj/0123456789abcdef0123456789abcdef)"),
1975            "{notice}"
1976        );
1977        assert!(
1978            notice.contains("while this session was stopped"),
1979            "{notice}"
1980        );
1981    }
1982    #[test]
1983    fn a_raw_checkout_that_stayed_put_gets_no_conversation_line() {
1984        let config = resume_compatibility_config();
1985        let session = managed_raw_session(ManagedWorktreeTarget::Local);
1986        let directory = session.project_directory.clone().unwrap();
1987        let executor = CheckoutPositionExecutor {
1988            head_commit: "a".repeat(40),
1989            branch: Some("main".into()),
1990        };
1991
1992        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1993
1994        assert_eq!(
1995            raw_checkout_divergence_notice(
1996                &directory,
1997                Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1998                &live,
1999            ),
2000            None
2001        );
2002    }
2003    #[test]
2004    fn a_checkpoint_without_recorded_git_identity_reports_nothing() {
2005        let live = CheckoutPosition {
2006            head_commit: "b".repeat(40),
2007            branch: None,
2008        };
2009
2010        assert_eq!(
2011            raw_checkout_divergence_notice(Path::new("/home/dev/project"), None, &live),
2012            None
2013        );
2014        assert_eq!(
2015            raw_checkout_divergence_notice(
2016                Path::new("/home/dev/project"),
2017                Some(&recorded_repository("", None)),
2018                &live,
2019            ),
2020            None
2021        );
2022    }
2023    #[test]
2024    fn a_detached_checkout_is_named_as_detached() {
2025        let config = resume_compatibility_config();
2026        let session = managed_raw_session(ManagedWorktreeTarget::Local);
2027        let directory = session.project_directory.clone().unwrap();
2028        let executor = CheckoutPositionExecutor {
2029            head_commit: "c".repeat(40),
2030            branch: None,
2031        };
2032
2033        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
2034        let notice = raw_checkout_divergence_notice(
2035            &directory,
2036            Some(&recorded_repository(&"a".repeat(40), Some("main"))),
2037            &live,
2038        )
2039        .expect("a moved checkout is reported");
2040
2041        assert!(notice.contains("cccccccccccc (detached)"), "{notice}");
2042    }
2043    #[test]
2044    fn bundle_sessions_resume_on_any_workspace_target() {
2045        let config = resume_compatibility_config();
2046        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2047
2048        assert_eq!(
2049            resume_compatibility(&session, &config, "podman"),
2050            Ok(ResumePlan::InPlace)
2051        );
2052        assert_eq!(
2053            resume_compatibility(&session, &config, "ssh-bare"),
2054            Ok(ResumePlan::InPlace)
2055        );
2056    }
2057    #[test]
2058    fn a_single_local_repository_can_become_a_checkout() {
2059        let mut config = resume_compatibility_config();
2060        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2061        session.bundle_id = "project".into();
2062        config.bundles.insert(
2063            "project".into(),
2064            local_bundle(Path::new("/home/dev/project")),
2065        );
2066
2067        assert_eq!(
2068            resume_compatibility(&session, &config, "local-bare"),
2069            Ok(ResumePlan::WorkspaceToRaw)
2070        );
2071    }
2072    #[test]
2073    fn a_github_project_cannot_become_a_checkout() {
2074        let mut config = resume_compatibility_config();
2075        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2076        session.bundle_id = "project".into();
2077        let mut bundle = local_bundle(Path::new("/home/dev/project"));
2078        bundle.repositories[0].local = None;
2079        bundle.repositories[0].github = Some("example/project".into());
2080        config.bundles.insert("project".into(), bundle);
2081
2082        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2083
2084        assert!(reason.contains("came from GitHub"), "{reason}");
2085        assert!(
2086            reason.contains("resume it on a container, SSH, or EC2 target"),
2087            "{reason}"
2088        );
2089    }
2090    #[test]
2091    fn a_multi_repository_project_cannot_become_a_checkout() {
2092        let mut config = resume_compatibility_config();
2093        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2094        session.bundle_id = "project".into();
2095        let mut bundle = local_bundle(Path::new("/home/dev/project"));
2096        bundle.repositories.push(ProjectRepository {
2097            id: "tools".into(),
2098            github: None,
2099            local: Some(PathBuf::from("/home/dev/tools")),
2100            destination: PathBuf::from("tools"),
2101            git_ref: None,
2102        });
2103        config.bundles.insert("project".into(), bundle);
2104
2105        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2106
2107        assert!(reason.contains("2 repositories"), "{reason}");
2108        assert!(reason.contains("one checkout"), "{reason}");
2109    }
2110    #[test]
2111    fn bundle_sessions_refuse_a_local_bare_target_with_a_reason() {
2112        let config = resume_compatibility_config();
2113        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2114
2115        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2116
2117        assert!(reason.contains("created from a project bundle"), "{reason}");
2118        assert!(
2119            reason.contains("resume it on a container, SSH, or EC2 target"),
2120            "{reason}"
2121        );
2122    }
2123    #[test]
2124    fn managed_raw_sessions_resume_on_their_own_worktree_host() {
2125        let config = resume_compatibility_config();
2126
2127        assert_eq!(
2128            resume_compatibility(
2129                &managed_raw_session(ManagedWorktreeTarget::Local),
2130                &config,
2131                "local-bare",
2132            ),
2133            Ok(ResumePlan::InPlace)
2134        );
2135        assert_eq!(
2136            resume_compatibility(
2137                &managed_raw_session(ssh_worktree_target()),
2138                &config,
2139                "ssh-bare",
2140            ),
2141            Ok(ResumePlan::InPlace)
2142        );
2143    }
2144    #[test]
2145    fn managed_raw_sessions_refuse_a_bare_target_on_another_host() {
2146        let config = resume_compatibility_config();
2147
2148        let reason = resume_compatibility(
2149            &managed_raw_session(ManagedWorktreeTarget::Local),
2150            &config,
2151            "ssh-bare",
2152        )
2153        .unwrap_err();
2154        assert!(reason.contains("this machine"), "{reason}");
2155
2156        let reason = resume_compatibility(
2157            &managed_raw_session(ssh_worktree_target()),
2158            &config,
2159            "local-bare",
2160        )
2161        .unwrap_err();
2162        assert!(reason.contains("dev@builder"), "{reason}");
2163    }
2164    #[test]
2165    fn raw_checkpoints_cannot_move_to_an_isolated_target() {
2166        let config = resume_compatibility_config();
2167        for session in [
2168            managed_raw_session(ManagedWorktreeTarget::Local),
2169            raw_session_on("local-bare", "/home/dev/project"),
2170        ] {
2171            let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
2172            assert!(reason.contains("network repository provenance"), "{reason}");
2173            assert!(reason.contains("bare target"), "{reason}");
2174        }
2175    }
2176    #[test]
2177    fn a_raw_checkout_on_an_ssh_host_cannot_convert() {
2178        let config = resume_compatibility_config();
2179
2180        let reason = resume_compatibility(
2181            &managed_raw_session(ssh_worktree_target()),
2182            &config,
2183            "podman",
2184        )
2185        .unwrap_err();
2186        assert!(reason.contains("works directly in"), "{reason}");
2187        assert!(reason.contains("dev@builder"), "{reason}");
2188
2189        let reason = resume_compatibility(
2190            &raw_session_on("ssh-bare", "/srv/project"),
2191            &config,
2192            "podman",
2193        )
2194        .unwrap_err();
2195        assert!(reason.contains("on an SSH host"), "{reason}");
2196    }
2197    #[test]
2198    fn a_session_that_opens_a_subdirectory_of_its_worktree_cannot_convert() {
2199        let config = resume_compatibility_config();
2200        let mut session = managed_raw_session(ManagedWorktreeTarget::Local);
2201        let worktree = session.managed_worktree.as_mut().unwrap();
2202        worktree.source_project_directory = worktree.source_repository.join("crate");
2203        session.project_directory = Some(worktree.worktree_root.join("crate"));
2204
2205        let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
2206
2207        assert!(reason.contains("subdirectory of its checkout"), "{reason}");
2208    }
2209    #[test]
2210    fn unmanaged_raw_sessions_require_the_same_bare_target_kind() {
2211        let config = resume_compatibility_config();
2212        let local = raw_session_on("local-bare", "/home/dev/project");
2213        let remote = raw_session_on("ssh-bare", "/srv/project");
2214
2215        assert_eq!(
2216            resume_compatibility(&local, &config, "local-bare"),
2217            Ok(ResumePlan::InPlace)
2218        );
2219        assert_eq!(
2220            resume_compatibility(&remote, &config, "ssh-bare"),
2221            Ok(ResumePlan::InPlace)
2222        );
2223        for (session, target) in [(&local, "ssh-bare"), (&remote, "local-bare")] {
2224            let reason = resume_compatibility(session, &config, target).unwrap_err();
2225            assert!(reason.contains("directly on its host"), "{reason}");
2226        }
2227    }
2228    #[test]
2229    fn resume_compatibility_names_a_target_that_is_gone() {
2230        let config = resume_compatibility_config();
2231        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2232
2233        let reason = resume_compatibility(&session, &config, "retired").unwrap_err();
2234
2235        assert!(reason.contains("retired"), "{reason}");
2236    }
2237    #[test]
2238    fn managed_raw_worktree_inherits_upstream_and_cleans_up_owned_artifacts() {
2239        let repository = committed_repository();
2240        let remote_parent = tempfile::tempdir().unwrap();
2241        let remote = remote_parent.path().join("remote.git");
2242        let output = Command::new("git")
2243            .args(["init", "--bare"])
2244            .arg(&remote)
2245            .output()
2246            .unwrap();
2247        assert!(output.status.success());
2248        test_git(
2249            repository.path(),
2250            &["remote", "add", "origin", &remote.to_string_lossy()],
2251        );
2252        test_git(
2253            repository.path(),
2254            &["push", "--set-upstream", "origin", "master"],
2255        );
2256
2257        let target = ManagedWorktreeTarget::Local;
2258        let inspection =
2259            inspect_raw_project(&ProcessExecutor, &target, &repository.path().join("nested"))
2260                .unwrap();
2261        assert!(inspection.primary_checkout);
2262        assert_eq!(inspection.upstream.as_deref(), Some("origin/master"));
2263        // git rev-parse canonicalizes symlinks (macOS tempdirs live behind the
2264        // /var -> /private/var link), so compare against the canonical path.
2265        assert_eq!(
2266            inspection.source_project_directory,
2267            repository.path().canonicalize().unwrap().join("nested")
2268        );
2269
2270        let session_id = "0123456789abcdef0123456789abcdef";
2271        let worktree = ManagedWorktree {
2272            source_project_directory: inspection.source_project_directory,
2273            source_repository: inspection.source_repository,
2274            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2275            branch: format!("mj/{session_id}"),
2276            target,
2277            base_commit: None,
2278        };
2279        create_managed_worktree(
2280            &ProcessExecutor,
2281            &worktree,
2282            inspection.upstream.as_deref(),
2283            PrimaryCheckoutRequirement::Clean,
2284        )
2285        .unwrap();
2286        assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2287        assert_eq!(
2288            test_git(
2289                &worktree.worktree_root,
2290                &[
2291                    "rev-parse",
2292                    "--abbrev-ref",
2293                    "--symbolic-full-name",
2294                    "@{upstream}"
2295                ]
2296            ),
2297            "origin/master"
2298        );
2299        assert_eq!(test_git(repository.path(), &["status", "--porcelain"]), "");
2300        std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2301
2302        cleanup_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2303        assert!(!worktree.worktree_root.exists());
2304        assert!(!repository.path().join(".mj").exists());
2305        let output = Command::new("git")
2306            .arg("-C")
2307            .arg(repository.path())
2308            .args([
2309                "show-ref",
2310                "--verify",
2311                &format!("refs/heads/{}", worktree.branch),
2312            ])
2313            .output()
2314            .unwrap();
2315        assert!(!output.status.success());
2316    }
2317    #[test]
2318    fn retired_worktree_can_be_recreated_from_its_retained_branch() {
2319        let repository = committed_repository();
2320        let session_id = "0123456789abcdef0123456789abcdef";
2321        let session = managed_worktree_session(repository.path(), session_id);
2322        let worktree = session.managed_worktree.unwrap();
2323        // The checkout is dirty by design: its dirty state moved into the target.
2324        std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2325
2326        retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2327
2328        assert!(!worktree.worktree_root.exists());
2329        assert!(!repository.path().join(".mj").exists());
2330        let branch = Command::new("git")
2331            .arg("-C")
2332            .arg(repository.path())
2333            .args([
2334                "show-ref",
2335                "--verify",
2336                &format!("refs/heads/{}", worktree.branch),
2337            ])
2338            .output()
2339            .unwrap();
2340        assert!(
2341            branch.status.success(),
2342            "the session branch must survive: later checkpoints are deltas against it"
2343        );
2344
2345        assert!(restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2346        assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2347        assert!(!restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2348    }
2349    #[test]
2350    fn retiring_a_remote_worktree_prunes_registration_after_target_removed_checkout() {
2351        struct RemoteExecutor {
2352            path_checks: RefCell<usize>,
2353            commands: RefCell<Vec<CommandSpec>>,
2354        }
2355
2356        impl CommandExecutor for RemoteExecutor {
2357            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2358                self.commands.borrow_mut().push(command.clone());
2359                let status = if command.purpose == "check managed worktree path" {
2360                    let mut checks = self.path_checks.borrow_mut();
2361                    let status = i32::from(*checks != 0);
2362                    *checks += 1;
2363                    status
2364                } else {
2365                    0
2366                };
2367                Ok(CommandOutput {
2368                    status,
2369                    stdout: Vec::new(),
2370                    stderr: Vec::new(),
2371                })
2372            }
2373        }
2374
2375        let worktree = ManagedWorktree {
2376            source_project_directory: PathBuf::from("/srv/project"),
2377            source_repository: PathBuf::from("/srv/project"),
2378            worktree_root: PathBuf::from("/srv/project/.mj/worktrees/session"),
2379            branch: "mj/session".into(),
2380            target: ManagedWorktreeTarget::Ssh {
2381                destination: "builder".into(),
2382                ssh_args: Vec::new(),
2383            },
2384            base_commit: None,
2385        };
2386        let executor = RemoteExecutor {
2387            path_checks: RefCell::new(0),
2388            commands: RefCell::new(Vec::new()),
2389        };
2390
2391        retire_managed_worktree(&executor, &worktree).unwrap();
2392
2393        let purposes = executor
2394            .commands
2395            .borrow()
2396            .iter()
2397            .map(|command| command.purpose.clone())
2398            .collect::<Vec<_>>();
2399        assert_eq!(
2400            purposes,
2401            [
2402                "check managed worktree path",
2403                "check managed worktree path",
2404                "prune managed worktree metadata",
2405                "remove empty managed worktree directories",
2406            ]
2407        );
2408    }
2409    #[test]
2410    fn a_managed_conversion_carries_the_session_worktree_not_the_primary_checkout() {
2411        let repository = committed_repository();
2412        let session_id = "0123456789abcdef0123456789abcdef";
2413        let session = managed_worktree_session(repository.path(), session_id);
2414        let worktree = session.managed_worktree.clone().unwrap();
2415
2416        let conversion =
2417            plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
2418
2419        assert_eq!(conversion.checkout, worktree.worktree_root);
2420        assert_eq!(conversion.repository, repository.path());
2421        assert_eq!(conversion.retire, Some(worktree));
2422        let bundle = conversion.new_bundle.expect("a bundle is synthesized");
2423        assert_eq!(bundle.repositories.len(), 1);
2424        assert_eq!(bundle.primary_repo, bundle.repositories[0].id);
2425        assert_eq!(
2426            bundle.repositories[0].local.as_deref(),
2427            Some(repository.path())
2428        );
2429        assert_eq!(bundle.repositories[0].github, None);
2430        // The archive names the session directory as the repository, and the
2431        // restored harness session points inside the target at that name.
2432        assert_eq!(
2433            bundle.repositories[0].destination,
2434            PathBuf::from(session_id)
2435        );
2436    }
2437    #[test]
2438    fn an_unmanaged_conversion_serves_the_main_repository_behind_a_linked_worktree() {
2439        let repository = committed_repository();
2440        let session_id = "0123456789abcdef0123456789abcdef";
2441        let linked = managed_worktree_session(repository.path(), session_id);
2442        let checkout = linked.managed_worktree.unwrap().worktree_root;
2443        let mut session = checkpoint_test_session(session_id);
2444        session.state = SessionState::Stopped;
2445        session.target_template_id = "local-bare".into();
2446        session.project_directory = Some(checkout.clone());
2447
2448        let conversion =
2449            plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
2450
2451        assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
2452        assert_eq!(
2453            conversion.repository,
2454            repository.path().canonicalize().unwrap()
2455        );
2456        assert_eq!(conversion.retire, None);
2457    }
2458    /// The recorded project directory may reach the checkout through a
2459    /// symlink, as the system temp directory does on macOS. Git reports
2460    /// canonical paths, so the whole-checkout rule must not compare across
2461    /// the two domains.
2462    #[cfg(unix)]
2463    #[test]
2464    fn an_unmanaged_conversion_accepts_a_checkout_reached_through_a_symlink() {
2465        let repository = committed_repository();
2466        let session_id = "0123456789abcdef0123456789abcdef";
2467        let linked = managed_worktree_session(repository.path(), session_id);
2468        let checkout = linked.managed_worktree.unwrap().worktree_root;
2469        let alias = tempfile::tempdir().unwrap();
2470        let symlink = alias.path().join("checkout");
2471        std::os::unix::fs::symlink(&checkout, &symlink).unwrap();
2472        let mut session = checkpoint_test_session(session_id);
2473        session.state = SessionState::Stopped;
2474        session.target_template_id = "local-bare".into();
2475        session.project_directory = Some(symlink);
2476
2477        let conversion =
2478            plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
2479
2480        assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
2481        assert_eq!(
2482            conversion.repository,
2483            repository.path().canonicalize().unwrap()
2484        );
2485        assert_eq!(conversion.retire, None);
2486    }
2487    #[test]
2488    fn a_conversion_reuses_a_bundle_that_already_describes_the_checkout() {
2489        let repository = PathBuf::from("/home/dev/project");
2490        let destination = PathBuf::from("project");
2491        let existing = ProjectBundle {
2492            primary_repo: "project".into(),
2493            repositories: vec![ProjectRepository {
2494                id: "project".into(),
2495                github: None,
2496                local: Some(repository.clone()),
2497                destination: destination.clone(),
2498                git_ref: None,
2499            }],
2500        };
2501        let mut config = Config::default();
2502        config.bundles.insert("existing".into(), existing);
2503
2504        assert_eq!(
2505            converted_raw_bundle(&config, "remote-project-abcdef", &repository, &destination),
2506            ("existing".to_owned(), None)
2507        );
2508
2509        // A different destination is a different checkout location inside the
2510        // target, so it cannot stand in for this one.
2511        let (id, synthesized) = converted_raw_bundle(
2512            &config,
2513            "remote-project-abcdef",
2514            &repository,
2515            Path::new("elsewhere"),
2516        );
2517        assert_ne!(id, "existing");
2518        assert_eq!(
2519            synthesized.unwrap().repositories[0].destination,
2520            PathBuf::from("elsewhere")
2521        );
2522    }
2523    #[test]
2524    fn a_converted_record_is_a_valid_bundle_session() {
2525        let session_id = "0123456789abcdef0123456789abcdef";
2526        let mut config = resume_compatibility_config();
2527        let mut record = managed_raw_session(ManagedWorktreeTarget::Local);
2528        record.state = SessionState::Running;
2529        record.target_template_id = "podman".into();
2530        let conversion = RawToWorkspaceConversion {
2531            checkout: record.project_directory.clone().unwrap(),
2532            repository: PathBuf::from("/home/dev/project"),
2533            bundle_id: "project".into(),
2534            new_bundle: Some(ProjectBundle {
2535                primary_repo: "project".into(),
2536                repositories: vec![ProjectRepository {
2537                    id: "project".into(),
2538                    github: None,
2539                    local: Some(PathBuf::from("/home/dev/project")),
2540                    destination: PathBuf::from(session_id),
2541                    git_ref: None,
2542                }],
2543            }),
2544            retire: record.managed_worktree.clone(),
2545        };
2546
2547        config.bundles.insert(
2548            conversion.bundle_id.clone(),
2549            conversion.new_bundle.clone().unwrap(),
2550        );
2551        config.profiles.insert(
2552            record.last_profile.clone(),
2553            HarnessProfile {
2554                enabled: true,
2555                kind: record.harness_kind,
2556                home: PathBuf::from("/profiles/codex"),
2557                environment: BTreeMap::new(),
2558                context_window_bytes: None,
2559            },
2560        );
2561        apply_raw_to_workspace(&mut record, &conversion);
2562
2563        assert_eq!(record.project_directory, None);
2564        assert_eq!(record.managed_worktree, None);
2565        assert_eq!(record.bundle_id, "project");
2566        let state = State {
2567            sessions: BTreeMap::from([(session_id.into(), record)]),
2568            ..State::default()
2569        };
2570        state.validate_against_config(&config).unwrap();
2571    }
2572    #[test]
2573    fn a_session_leaving_its_target_claims_a_worktree_of_its_own_repository() {
2574        let repository = committed_repository();
2575        let session_id = "0123456789abcdef0123456789abcdef";
2576        let mut session = checkpoint_test_session(session_id);
2577        session.state = SessionState::Stopped;
2578        session.bundle_id = "project".into();
2579        let mut config = resume_compatibility_config();
2580        config
2581            .bundles
2582            .insert("project".into(), local_bundle(repository.path()));
2583        let controller = Controller {
2584            config,
2585            state: State {
2586                sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2587                ..State::default()
2588            },
2589        };
2590
2591        let conversion = controller
2592            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2593            .unwrap();
2594
2595        assert_eq!(
2596            conversion.worktree,
2597            ManagedWorktree {
2598                source_project_directory: repository.path().to_path_buf(),
2599                source_repository: repository.path().to_path_buf(),
2600                worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2601                branch: format!("mj/{session_id}"),
2602                target: ManagedWorktreeTarget::Local,
2603                // The new branch starts at the repository's HEAD, which is
2604                // what an export of this session diffs against.
2605                base_commit: Some(test_git(repository.path(), &["rev-parse", "HEAD"])),
2606            }
2607        );
2608
2609        // The dirty primary checkout is beside the point: the worktree's
2610        // contents come from the checkpoint.
2611        std::fs::write(repository.path().join("dirty.txt"), "primary\n").unwrap();
2612        create_managed_worktree(
2613            &ProcessExecutor,
2614            &conversion.worktree,
2615            None,
2616            PrimaryCheckoutRequirement::Any,
2617        )
2618        .unwrap();
2619        assert!(conversion.worktree.worktree_root.is_dir());
2620
2621        // A second attempt refuses rather than taking over a live worktree.
2622        let error = controller
2623            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2624            .unwrap_err();
2625        assert!(format!("{error:#}").contains("already exists"), "{error:#}");
2626    }
2627    #[test]
2628    fn a_return_to_local_reuses_its_retained_branch_and_preserves_its_tip() {
2629        let repository = committed_repository();
2630        let session_id = "0123456789abcdef0123456789abcdef";
2631        let branch = format!("mj/{session_id}");
2632        let original_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
2633        test_git(repository.path(), &["branch", &branch]);
2634        let mut config = resume_compatibility_config();
2635        config
2636            .bundles
2637            .insert("project".into(), local_bundle(repository.path()));
2638        let session = checkpoint_test_session(session_id);
2639        let controller = Controller {
2640            config,
2641            state: State {
2642                sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2643                ..State::default()
2644            },
2645        };
2646
2647        let conversion = controller
2648            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2649            .unwrap();
2650
2651        assert!(conversion.reuse_existing_branch);
2652        assert!(!conversion.worktree.worktree_root.exists());
2653        let recovery_ref =
2654            preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2655                .unwrap();
2656        assert_eq!(
2657            recovery_ref,
2658            format!("refs/mj/recovery/{session_id}/{original_tip}")
2659        );
2660        assert_eq!(
2661            test_git(
2662                repository.path(),
2663                &["show-ref", "--hash", recovery_ref.as_str()],
2664            ),
2665            original_tip
2666        );
2667        assert_eq!(
2668            preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2669                .unwrap(),
2670            recovery_ref
2671        );
2672
2673        test_git(repository.path(), &["checkout", &branch]);
2674        test_git(
2675            repository.path(),
2676            &["commit", "--allow-empty", "-m", "later retained tip"],
2677        );
2678        let later_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
2679        test_git(repository.path(), &["checkout", "master"]);
2680        let later_recovery_ref =
2681            preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2682                .unwrap();
2683        assert_eq!(
2684            later_recovery_ref,
2685            format!("refs/mj/recovery/{session_id}/{later_tip}")
2686        );
2687        assert_ne!(later_recovery_ref, recovery_ref);
2688        assert_eq!(
2689            test_git(
2690                repository.path(),
2691                &["show-ref", "--hash", recovery_ref.as_str()],
2692            ),
2693            original_tip
2694        );
2695    }
2696    #[test]
2697    fn a_return_to_local_rejects_a_retained_branch_checked_out_elsewhere() {
2698        let repository = committed_repository();
2699        let session_id = "0123456789abcdef0123456789abcdef";
2700        let branch = format!("mj/{session_id}");
2701        test_git(repository.path(), &["branch", &branch]);
2702        let elsewhere = repository.path().join("other-worktree");
2703        test_git(
2704            repository.path(),
2705            &["worktree", "add", &elsewhere.to_string_lossy(), &branch],
2706        );
2707        let mut config = resume_compatibility_config();
2708        config
2709            .bundles
2710            .insert("project".into(), local_bundle(repository.path()));
2711        let session = checkpoint_test_session(session_id);
2712        let controller = Controller {
2713            config,
2714            state: State {
2715                sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2716                ..State::default()
2717            },
2718        };
2719
2720        let error = controller
2721            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2722            .unwrap_err();
2723
2724        assert!(error.to_string().contains("still checked out"), "{error:#}");
2725        assert!(
2726            !repository
2727                .path()
2728                .join(".mj/worktrees")
2729                .join(session_id)
2730                .exists()
2731        );
2732        assert_eq!(
2733            test_git(repository.path(), &["rev-parse", &branch]),
2734            test_git(repository.path(), &["rev-parse", "HEAD"])
2735        );
2736    }
2737    #[test]
2738    fn a_session_that_left_its_target_is_a_valid_raw_session() {
2739        let session_id = "0123456789abcdef0123456789abcdef";
2740        let repository = PathBuf::from("/home/dev/project");
2741        let mut config = resume_compatibility_config();
2742        config
2743            .bundles
2744            .insert("project".into(), local_bundle(&repository));
2745        config.profiles.insert(
2746            "codex".into(),
2747            HarnessProfile {
2748                enabled: true,
2749                kind: mj_core::config::HarnessKind::Codex,
2750                home: PathBuf::from("/profiles/codex"),
2751                environment: BTreeMap::new(),
2752                context_window_bytes: None,
2753            },
2754        );
2755        let mut record = checkpoint_test_session(session_id);
2756        record.bundle_id = "project".into();
2757        record.target_template_id = "local-bare".into();
2758        let conversion = WorkspaceToRawConversion {
2759            worktree: ManagedWorktree {
2760                source_project_directory: repository.clone(),
2761                source_repository: repository.clone(),
2762                worktree_root: repository.join(".mj/worktrees").join(session_id),
2763                branch: format!("mj/{session_id}"),
2764                target: ManagedWorktreeTarget::Local,
2765                base_commit: None,
2766            },
2767            reuse_existing_branch: false,
2768        };
2769
2770        apply_workspace_to_raw(&mut record, &conversion);
2771
2772        assert_eq!(
2773            record.project_directory.as_deref(),
2774            Some(conversion.worktree.worktree_root.as_path())
2775        );
2776        assert_eq!(record.bundle_id, "project", "the bundle still describes it");
2777        let state = State {
2778            sessions: BTreeMap::from([(session_id.into(), record)]),
2779            ..State::default()
2780        };
2781        state.validate_against_config(&config).unwrap();
2782    }
2783    #[test]
2784    fn a_failed_departure_returns_the_session_to_its_bundle() {
2785        let session_id = "0123456789abcdef0123456789abcdef";
2786        let repository = PathBuf::from("/home/dev/project");
2787        let previous = {
2788            let mut record = checkpoint_test_session(session_id);
2789            record.state = SessionState::Stopped;
2790            record.bundle_id = "project".into();
2791            record
2792        };
2793        let mut converted = previous.clone();
2794        converted.state = SessionState::Provisioning;
2795        apply_workspace_to_raw(
2796            &mut converted,
2797            &WorkspaceToRawConversion {
2798                worktree: ManagedWorktree {
2799                    source_project_directory: repository.clone(),
2800                    source_repository: repository.clone(),
2801                    worktree_root: repository.join(".mj/worktrees").join(session_id),
2802                    branch: format!("mj/{session_id}"),
2803                    target: ManagedWorktreeTarget::Local,
2804                    base_commit: None,
2805                },
2806                reuse_existing_branch: false,
2807            },
2808        );
2809
2810        apply_failed_resume_rollback(&mut converted, &previous, "podman is unavailable", None);
2811
2812        assert_eq!(converted.project_directory, None);
2813        assert_eq!(converted.managed_worktree, None);
2814        assert_eq!(converted.bundle_id, "project");
2815    }
2816    #[test]
2817    fn a_failed_conversion_returns_the_session_to_its_checkout() {
2818        let previous = managed_raw_session(ManagedWorktreeTarget::Local);
2819        let mut converted = previous.clone();
2820        converted.state = SessionState::Provisioning;
2821        converted.target_template_id = "podman".into();
2822        apply_raw_to_workspace(
2823            &mut converted,
2824            &RawToWorkspaceConversion {
2825                checkout: previous.project_directory.clone().unwrap(),
2826                repository: PathBuf::from("/home/dev/project"),
2827                bundle_id: "project".into(),
2828                new_bundle: None,
2829                retire: previous.managed_worktree.clone(),
2830            },
2831        );
2832
2833        let mut cleaned = converted.clone();
2834        apply_failed_resume_rollback(&mut cleaned, &previous, "podman is unavailable", None);
2835        assert_eq!(cleaned.project_directory, previous.project_directory);
2836        assert_eq!(cleaned.managed_worktree, previous.managed_worktree);
2837        assert_eq!(cleaned.bundle_id, previous.bundle_id);
2838
2839        // Even when the leftover target could not be removed, the record must
2840        // describe the checkout it still owns.
2841        let mut stranded = converted;
2842        apply_failed_resume_rollback(
2843            &mut stranded,
2844            &previous,
2845            "podman is unavailable",
2846            Some("podman rm failed".into()),
2847        );
2848        assert_eq!(stranded.state, SessionState::Error);
2849        assert_eq!(stranded.project_directory, previous.project_directory);
2850        assert_eq!(stranded.managed_worktree, previous.managed_worktree);
2851        assert_eq!(stranded.bundle_id, previous.bundle_id);
2852    }
2853    #[test]
2854    fn cancelled_new_session_cleanup_removes_managed_worktree_and_branch() {
2855        let repository = committed_repository();
2856        let session_id = "0123456789abcdef0123456789abcdef";
2857        let worktree = ManagedWorktree {
2858            source_project_directory: repository.path().to_path_buf(),
2859            source_repository: repository.path().to_path_buf(),
2860            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2861            branch: format!("mj/{session_id}"),
2862            target: ManagedWorktreeTarget::Local,
2863            base_commit: None,
2864        };
2865        create_managed_worktree(
2866            &ProcessExecutor,
2867            &worktree,
2868            None,
2869            PrimaryCheckoutRequirement::Clean,
2870        )
2871        .unwrap();
2872
2873        let mut session = checkpoint_test_session(session_id);
2874        session.project_directory = Some(worktree.worktree_root.clone());
2875        session.managed_worktree = Some(worktree.clone());
2876        let controller = Controller {
2877            config: Config::default(),
2878            state: State {
2879                sessions: BTreeMap::from([(session_id.into(), session)]),
2880                ..State::default()
2881            },
2882        };
2883        let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2884        let executor = CancellableProcessExecutor::new(cancelled);
2885
2886        controller
2887            .cleanup_new_session_worktree_after_failure(session_id, &executor)
2888            .unwrap();
2889
2890        assert!(!worktree.worktree_root.exists());
2891        assert!(!repository.path().join(".mj").exists());
2892        let branch = Command::new("git")
2893            .arg("-C")
2894            .arg(repository.path())
2895            .args([
2896                "show-ref",
2897                "--verify",
2898                &format!("refs/heads/{}", worktree.branch),
2899            ])
2900            .output()
2901            .unwrap();
2902        assert!(!branch.status.success());
2903    }
2904    #[test]
2905    fn managed_raw_worktree_refuses_dirty_primary_and_skips_existing_worktree() {
2906        let repository = committed_repository();
2907        std::fs::write(repository.path().join("dirty.txt"), "dirty\n").unwrap();
2908        let target = ManagedWorktreeTarget::Local;
2909        let inspection = inspect_raw_project(&ProcessExecutor, &target, repository.path()).unwrap();
2910        let session_id = "fedcba9876543210fedcba9876543210";
2911        let managed = ManagedWorktree {
2912            source_project_directory: inspection.source_project_directory,
2913            source_repository: inspection.source_repository,
2914            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2915            branch: format!("mj/{session_id}"),
2916            target: target.clone(),
2917            base_commit: None,
2918        };
2919        let error = create_managed_worktree(
2920            &ProcessExecutor,
2921            &managed,
2922            None,
2923            PrimaryCheckoutRequirement::Clean,
2924        )
2925        .unwrap_err();
2926        assert!(error.to_string().contains("uncommitted changes"));
2927        assert!(!managed.worktree_root.exists());
2928
2929        std::fs::remove_file(repository.path().join("dirty.txt")).unwrap();
2930        let existing = repository.path().join("existing-worktree");
2931        test_git(
2932            repository.path(),
2933            &[
2934                "worktree",
2935                "add",
2936                "--detach",
2937                &existing.to_string_lossy(),
2938                "HEAD",
2939            ],
2940        );
2941        let linked = inspect_raw_project(&ProcessExecutor, &target, &existing).unwrap();
2942        assert!(!linked.primary_checkout);
2943    }
2944    #[test]
2945    fn managed_worktree_preflight_preserves_colliding_branch_and_directory() {
2946        let repository = committed_repository();
2947        let target = ManagedWorktreeTarget::Local;
2948        let session_id = "abcdef0123456789abcdef0123456789";
2949        let branch = format!("mj/{session_id}");
2950        test_git(repository.path(), &["branch", &branch]);
2951        let worktree = ManagedWorktree {
2952            source_project_directory: repository.path().to_path_buf(),
2953            source_repository: repository.path().to_path_buf(),
2954            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2955            branch: branch.clone(),
2956            target,
2957            base_commit: None,
2958        };
2959
2960        let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2961        assert!(error.to_string().contains("branch already exists"));
2962        assert!(
2963            !test_git(
2964                repository.path(),
2965                &["show-ref", "--verify", &format!("refs/heads/{branch}")]
2966            )
2967            .is_empty()
2968        );
2969        std::fs::create_dir_all(&worktree.worktree_root).unwrap();
2970        let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2971        assert!(error.to_string().contains("path already exists"));
2972        assert!(worktree.worktree_root.is_dir());
2973    }
2974    #[test]
2975    fn managed_worktree_ssh_commands_preserve_hostile_path_boundaries() {
2976        let target = ManagedWorktreeTarget::Ssh {
2977            destination: "builder".into(),
2978            ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2979        };
2980        let command = managed_git_command(
2981            &target,
2982            Path::new("/srv/project with ' quote"),
2983            ["worktree", "prune"],
2984            "prune test",
2985        );
2986        assert_eq!(command.program, "ssh");
2987        assert_eq!(&command.args[..3], ["-o", "BatchMode=yes", "builder"]);
2988        assert_eq!(
2989            command.args[3],
2990            "'git' '-C' '/srv/project with '\\'' quote' 'worktree' 'prune'"
2991        );
2992    }
2993}