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    /// Where the converted workspace fetches from and pushes to. An isolated
602    /// workspace always clones from a network remote, so the checkout's own
603    /// remote becomes the converted session's provenance.
604    pub(super) source: mj_core::remote_git::NetworkGitSource,
605    pub(super) bundle_id: String,
606    /// Set when the configuration does not already describe this checkout.
607    pub(super) new_bundle: Option<ProjectBundle>,
608    /// Removed once the target holds the checkout, and only then.
609    pub(super) retire: Option<ManagedWorktree>,
610}
611
612/// Resolve where a raw session's checkout lives and which bundle will stand in
613/// for it. Reads Git; changes nothing.
614pub(super) fn plan_raw_to_workspace(
615    session: &SessionRecord,
616    config: &Config,
617    executor: &impl CommandExecutor,
618) -> Result<RawToWorkspaceConversion> {
619    let project_directory = session
620        .project_directory
621        .as_deref()
622        .context("a raw session has no project directory")?;
623    // The checkpoint describes the session's directory as if it were the
624    // repository root, so only a whole checkout can move. Each branch checks
625    // this against paths from one domain: the record's own paths for a managed
626    // worktree, Git's canonical paths for an inspected checkout — the record
627    // may reach the same checkout through a symlink (macOS temp directories).
628    let (checkout, repository, retire) = match &session.managed_worktree {
629        Some(worktree) => {
630            ensure!(
631                worktree.worktree_root == project_directory,
632                "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
633                project_directory.display()
634            );
635            (
636                worktree.worktree_root.clone(),
637                worktree.source_repository.clone(),
638                Some(worktree.clone()),
639            )
640        }
641        None => {
642            let inspection =
643                inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
644            ensure!(
645                inspection.source_project_directory == inspection.source_repository,
646                "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
647                project_directory.display()
648            );
649            let repository = canonical_repository(&inspection.source_repository)?;
650            (inspection.source_repository, repository, None)
651        }
652    };
653    // The archive names the session's directory as the repository destination,
654    // and the restored harness session points at that path inside the target.
655    // The bundle has to put the checkout in the same place.
656    let destination = PathBuf::from(
657        project_directory
658            .file_name()
659            .context("a raw project directory cannot be the filesystem root")?,
660    );
661    let (bundle_id, new_bundle) =
662        converted_raw_bundle(config, &session.bundle_id, &repository, &destination);
663    // An isolated workspace is always a fresh network clone, so a checkout
664    // with no network remote cannot become one. Resolve it here, while nothing
665    // has changed yet, and say what to do about it.
666    let source = mj_core::remote_git::resolve_local_repository(&checkout, executor).with_context(
667        || {
668            format!(
669                "{} has no network Git remote; add one (for example `git remote add origin <url>`) or resume this session on a bare target",
670                checkout.display()
671            )
672        },
673    )?;
674    Ok(RawToWorkspaceConversion {
675        checkout,
676        repository,
677        source,
678        bundle_id,
679        new_bundle,
680        retire,
681    })
682}
683
684/// The bundle a converted raw session references: one the configuration already
685/// has for exactly this checkout, or a new one for the caller to install.
686/// Reusing a match keeps a retried conversion from piling up bundles.
687fn converted_raw_bundle(
688    config: &Config,
689    session_bundle_id: &str,
690    repository: &Path,
691    destination: &Path,
692) -> (String, Option<ProjectBundle>) {
693    let describes_checkout = |bundle: &ProjectBundle| {
694        bundle.repositories.len() == 1
695            && bundle.repositories[0].github.is_none()
696            && bundle.repositories[0].local.as_deref() == Some(repository)
697            && bundle.repositories[0].destination == destination
698    };
699    if config
700        .bundles
701        .get(session_bundle_id)
702        .is_some_and(describes_checkout)
703    {
704        return (session_bundle_id.to_owned(), None);
705    }
706    if let Some((id, _)) = config
707        .bundles
708        .iter()
709        .find(|(_, bundle)| describes_checkout(bundle))
710    {
711        return (id.clone(), None);
712    }
713    let name = repository
714        .file_name()
715        .map(|name| name.to_string_lossy().into_owned())
716        .unwrap_or_default();
717    let id = crate::import::unique_bundle_id(config, &crate::import::setup_style_id(&name));
718    let bundle = ProjectBundle {
719        primary_repo: id.clone(),
720        repositories: vec![mj_core::config::ProjectRepository {
721            id: id.clone(),
722            github: None,
723            local: Some(repository.to_path_buf()),
724            destination: destination.to_path_buf(),
725            git_ref: None,
726        }],
727    };
728    (id, Some(bundle))
729}
730
731/// The repository id a converted raw session's archive uses. A raw checkpoint
732/// has always described the session's directory as one repository.
733const RAW_CONVERSION_REPOSITORY_ID: &str = "project";
734
735/// Snapshot the host checkout as the repository content an isolated workspace
736/// arrives with: commits that are on no origin ref, plus staged, unstaged, and
737/// untracked work.
738///
739/// The metadata carries the checkout's own network remote, so the container
740/// clones real provenance and its later checkpoints behave like any other
741/// workspace session's.
742pub(super) fn raw_checkout_snapshot(
743    checkout: &Path,
744    source: &mj_core::remote_git::NetworkGitSource,
745    destination: &Path,
746    git: &dyn mj_checkpoint::archive::GitCommandRunner,
747) -> Result<mj_checkpoint::archive::RepositorySnapshot> {
748    // Bundling "everything not on origin" only works when origin refs exist:
749    // every bundle prerequisite then sits on the remote the container clones.
750    mj_checkpoint::checkpoint::repair_origin_refs(git, checkout, RAW_CONVERSION_REPOSITORY_ID)?;
751    mj_checkpoint::checkpoint::reject_dirty_submodules(git, checkout)
752        .with_context(|| format!("checkout {}", checkout.display()))?;
753    let mut snapshot = mj_checkpoint::archive::collect_git_snapshot(
754        git,
755        checkout,
756        &mj_checkpoint::archive::GitCollectionSpec {
757            id: RAW_CONVERSION_REPOSITORY_ID.to_owned(),
758            relative_destination: destination.to_path_buf(),
759            history: mj_checkpoint::archive::GitHistoryMode::SessionDelta,
760            origin_override: None,
761        },
762    )
763    .with_context(|| format!("snapshot the checkout at {}", checkout.display()))?;
764    // The resolved remote, not whatever `origin` happens to be: the checkout's
765    // branch may track another remote. Credentials stay out of the archive.
766    snapshot.metadata.origin =
767        mj_checkpoint::archive::redact_origin_credentials(&source.fetch_url)?;
768    snapshot.metadata.push_urls = source
769        .push_urls
770        .iter()
771        .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
772        .collect::<Result<Vec<_>>>()?;
773    snapshot.metadata.remote_workspace = true;
774    snapshot.metadata.base_commit = origin_boundary_commit(git, checkout)?
775        .unwrap_or_else(|| snapshot.metadata.head_commit.clone());
776    Ok(snapshot)
777}
778
779/// The newest commit the checkout shares with `origin`, which is where a
780/// converted workspace measures its own session delta from. `None` when HEAD
781/// is already on an origin ref, leaving no boundary to report.
782fn origin_boundary_commit(
783    git: &dyn mj_checkpoint::archive::GitCommandRunner,
784    checkout: &Path,
785) -> Result<Option<String>> {
786    let listed = git_runner_stdout(
787        git,
788        checkout,
789        [
790            "rev-list",
791            "--boundary",
792            "HEAD",
793            "--not",
794            "--remotes=origin",
795        ],
796        "list commits outside origin",
797    )?;
798    // `--boundary` marks the excluded parents of the listed commits with `-`,
799    // and lists them after the commits themselves.
800    Ok(listed
801        .lines()
802        .filter_map(|line| line.strip_prefix('-'))
803        .map(|commit| commit.trim().to_owned())
804        .find(|commit| !commit.is_empty()))
805}
806
807fn git_runner_stdout(
808    git: &dyn mj_checkpoint::archive::GitCommandRunner,
809    repository: &Path,
810    args: impl IntoIterator<Item = impl AsRef<str>>,
811    purpose: &str,
812) -> Result<String> {
813    let output = git.run(
814        repository,
815        &mj_checkpoint::archive::GitCommand {
816            arguments: args
817                .into_iter()
818                .map(|argument| std::ffi::OsString::from(argument.as_ref()))
819                .collect(),
820            stdin: Vec::new(),
821            env: Vec::new(),
822        },
823    )?;
824    command_stdout(
825        CommandOutput {
826            status: output.status,
827            stdout: output.stdout,
828            stderr: output.stderr,
829        },
830        purpose,
831    )
832}
833
834/// Describe a raw-to-workspace conversion for a person to confirm. Reads Git
835/// and asks the remote for its default branch; changes nothing.
836pub(super) fn raw_conversion_preview(
837    session: &SessionRecord,
838    conversion: &RawToWorkspaceConversion,
839    executor: &impl CommandExecutor,
840) -> Result<mj_core::state::RawConversionPreview> {
841    let checkout = conversion.checkout.as_path();
842    // A dirty submodule cannot be captured, so say so now rather than failing
843    // after the session has been stopped.
844    reject_dirty_submodules_in_checkout(executor, checkout)?;
845    let default_branch = mj_core::remote_git::default_branch(&conversion.source, executor)?;
846    let position = read_checkout_position(executor, &ManagedWorktreeTarget::Local, checkout)?;
847    let unpushed_commits = unpushed_commit_count(executor, checkout)?;
848    let dirty = dirty_file_counts(executor, checkout)?;
849    // The archive names the session's own directory, which is where the
850    // restored harness session looks for its files inside the target.
851    let directory = session
852        .project_directory
853        .as_deref()
854        .context("a raw session has no project directory")?
855        .file_name()
856        .context("a raw project directory cannot be the filesystem root")?;
857    Ok(mj_core::state::RawConversionPreview {
858        checkout: checkout.to_path_buf(),
859        destination: PathBuf::from(mj_core::targets::CONTAINER_WORKSPACE).join(directory),
860        branch: position.branch,
861        fetch_url: conversion.source.fetch_url.clone(),
862        push_urls: conversion.source.push_urls.clone(),
863        default_branch,
864        unpushed_commits,
865        staged_files: dirty.staged_files,
866        unstaged_files: dirty.unstaged_files,
867        untracked_files: dirty.untracked_files,
868        untracked_bytes: untracked_bytes(executor, checkout)?,
869        host_checkout_retained: conversion.retire.is_none(),
870    })
871}
872
873fn reject_dirty_submodules_in_checkout(
874    executor: &impl CommandExecutor,
875    checkout: &Path,
876) -> Result<()> {
877    let listed = managed_git_stdout(
878        executor,
879        &ManagedWorktreeTarget::Local,
880        checkout,
881        [
882            "submodule",
883            "foreach",
884            "--recursive",
885            "--quiet",
886            "git status --porcelain",
887        ],
888        "inspect submodules",
889    )?;
890    ensure!(
891        listed.trim().is_empty(),
892        "{} has a dirty submodule, which cannot move into a target; commit or discard the submodule's changes first",
893        checkout.display()
894    );
895    Ok(())
896}
897
898/// Commits the conversion archive has to carry. A checkout whose origin refs
899/// are missing even after a repair fetch reports nothing rather than counting
900/// its entire history as unpushed.
901fn unpushed_commit_count(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
902    if !origin_refs_available(executor, checkout)? {
903        return Ok(0);
904    }
905    let counted = managed_git_stdout(
906        executor,
907        &ManagedWorktreeTarget::Local,
908        checkout,
909        ["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
910        "count commits outside origin",
911    )?;
912    counted
913        .trim()
914        .parse()
915        .with_context(|| format!("parse the commit count {counted:?}"))
916}
917
918fn origin_refs_available(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
919    if origin_refs_listed(executor, checkout)? {
920        return Ok(true);
921    }
922    // A checkout that has never fetched has no origin refs yet. Try once; a
923    // remote that cannot be reached leaves the count unreported, not failed.
924    let fetch = managed_git_command(
925        &ManagedWorktreeTarget::Local,
926        checkout,
927        ["fetch", "origin"],
928        "fetch origin refs",
929    );
930    executor.execute(&fetch)?;
931    origin_refs_listed(executor, checkout)
932}
933
934fn origin_refs_listed(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
935    managed_git_stdout(
936        executor,
937        &ManagedWorktreeTarget::Local,
938        checkout,
939        [
940            "for-each-ref",
941            "--format=%(objectname)",
942            "refs/remotes/origin",
943        ],
944        "list origin refs",
945    )
946    .map(|refs| !refs.trim().is_empty())
947}
948
949#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
950struct DirtyFileCounts {
951    staged_files: u64,
952    unstaged_files: u64,
953    untracked_files: u64,
954}
955
956/// Count what `git status` reports, one entry per path. A rename's second
957/// record names the original path, so it is consumed rather than counted.
958fn dirty_file_counts(executor: &impl CommandExecutor, checkout: &Path) -> Result<DirtyFileCounts> {
959    let command = managed_git_command(
960        &ManagedWorktreeTarget::Local,
961        checkout,
962        ["status", "--porcelain=v1", "-z"],
963        "read checkout status",
964    );
965    let output = executor.execute(&command)?;
966    ensure!(
967        output.status == 0,
968        "read checkout status failed with status {}: {}",
969        output.status,
970        String::from_utf8_lossy(&output.stderr).trim()
971    );
972    let mut counts = DirtyFileCounts::default();
973    let mut records = output
974        .stdout
975        .split(|byte| *byte == 0)
976        .filter(|record| !record.is_empty());
977    while let Some(record) = records.next() {
978        let [index, worktree, ..] = record else {
979            bail!("git status produced a record shorter than its status field");
980        };
981        if *index == b'?' && *worktree == b'?' {
982            counts.untracked_files += 1;
983            continue;
984        }
985        if !matches!(index, b' ' | b'?') {
986            counts.staged_files += 1;
987        }
988        if !matches!(worktree, b' ' | b'?') {
989            counts.unstaged_files += 1;
990        }
991        if *index == b'R' || *index == b'C' || *worktree == b'R' || *worktree == b'C' {
992            records.next();
993        }
994    }
995    Ok(counts)
996}
997
998/// How much untracked content the conversion archive has to carry. `git status`
999/// collapses an untracked directory into one entry, so the bytes come from the
1000/// file list instead.
1001fn untracked_bytes(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1002    let command = managed_git_command(
1003        &ManagedWorktreeTarget::Local,
1004        checkout,
1005        ["ls-files", "--others", "--exclude-standard", "-z"],
1006        "list untracked files",
1007    );
1008    let output = executor.execute(&command)?;
1009    ensure!(
1010        output.status == 0,
1011        "list untracked files failed with status {}: {}",
1012        output.status,
1013        String::from_utf8_lossy(&output.stderr).trim()
1014    );
1015    let mut total = 0;
1016    for record in output
1017        .stdout
1018        .split(|byte| *byte == 0)
1019        .filter(|record| !record.is_empty())
1020    {
1021        let relative = mj_core::path_input::from_git_bytes(record)?;
1022        let path = checkout.join(relative);
1023        // Do not follow links, and tolerate a file the agent removed between
1024        // the listing and this read.
1025        match std::fs::symlink_metadata(&path) {
1026            Ok(metadata) => total += metadata.len(),
1027            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1028            Err(error) => {
1029                return Err(error).with_context(|| format!("measure {}", path.display()));
1030            }
1031        }
1032    }
1033    Ok(total)
1034}
1035
1036/// Where a checkout stands: its head commit and, unless detached, its branch.
1037#[derive(Debug, Clone, PartialEq, Eq)]
1038pub(super) struct CheckoutPosition {
1039    pub(super) head_commit: String,
1040    branch: Option<String>,
1041}
1042
1043fn read_checkout_position(
1044    executor: &impl CommandExecutor,
1045    target: &ManagedWorktreeTarget,
1046    directory: &Path,
1047) -> Result<CheckoutPosition> {
1048    let head_commit = managed_git_stdout(
1049        executor,
1050        target,
1051        directory,
1052        ["rev-parse", "HEAD"],
1053        "resolve checkout head commit",
1054    )?;
1055    let branch_command = managed_git_command(
1056        target,
1057        directory,
1058        ["symbolic-ref", "--quiet", "--short", "HEAD"],
1059        "resolve checkout branch",
1060    );
1061    let branch_output = executor.execute(&branch_command)?;
1062    let branch = match branch_output.status {
1063        0 => Some(
1064            String::from_utf8(branch_output.stdout)
1065                .context("checkout branch was not UTF-8")?
1066                .trim()
1067                .to_owned(),
1068        ),
1069        // A detached head reports no branch rather than failing.
1070        1 | 128 => None,
1071        status => bail!(
1072            "resolve checkout branch failed with status {status}: {}",
1073            String::from_utf8_lossy(&branch_output.stderr).trim()
1074        ),
1075    };
1076    Ok(CheckoutPosition {
1077        head_commit,
1078        branch,
1079    })
1080}
1081
1082/// The commit the session branch was created at, as the base for diffs and
1083/// checkpoint bundles. Prefers the recorded base; sessions created before it
1084/// was recorded fall back to the branch reflog, like `branch_creation_commit`
1085/// in mj-checkpoint. A reflog that has expired leaves only the live head,
1086/// which yields an empty bundle rather than a failed checkpoint.
1087pub(super) fn managed_worktree_base_commit(
1088    worktree: &ManagedWorktree,
1089    executor: &impl CommandExecutor,
1090) -> Result<String> {
1091    if let Some(base) = &worktree.base_commit {
1092        return Ok(base.clone());
1093    }
1094    let reference = format!("refs/heads/{}", worktree.branch);
1095    let reflog_command = managed_git_command(
1096        &worktree.target,
1097        &worktree.source_repository,
1098        ["reflog", "show", "--format=%H", &reference],
1099        "read the session branch reflog",
1100    );
1101    let reflog_output = executor.execute(&reflog_command)?;
1102    if reflog_output.status == 0 {
1103        let text = String::from_utf8(reflog_output.stdout)
1104            .context("the session branch reflog was not UTF-8")?;
1105        // The oldest entry is the branch's creation, so it is where the session
1106        // started.
1107        if let Some(creation) = text.lines().rfind(|line| !line.trim().is_empty()) {
1108            return Ok(creation.trim().to_owned());
1109        }
1110    }
1111    let head = read_checkout_position(executor, &worktree.target, &worktree.worktree_root)?;
1112    tracing::warn!(
1113        branch = %worktree.branch,
1114        "the reflog for this session branch is gone, so its checkpoint bundle will carry no commits"
1115    );
1116    Ok(head.head_commit)
1117}
1118
1119/// Read where a raw session's checkout stands right now, on whichever host
1120/// owns it.
1121pub(super) fn raw_checkout_position(
1122    session: &SessionRecord,
1123    config: &Config,
1124    project_directory: &Path,
1125    executor: &impl CommandExecutor,
1126) -> Result<CheckoutPosition> {
1127    let target = match &session.managed_worktree {
1128        Some(worktree) => worktree.target.clone(),
1129        None => {
1130            let template = config
1131                .targets
1132                .get(&session.target_template_id)
1133                .context("the bare target this session last used is missing")?;
1134            managed_worktree_target(template)?
1135        }
1136    };
1137    read_checkout_position(executor, &target, project_directory)
1138}
1139
1140/// One conversation line for a raw session whose checkout moved on while the
1141/// session was stopped. `None` when the checkout is where the checkpoint left
1142/// it, or when the checkpoint recorded no repository to compare against.
1143///
1144/// This reports; it never reconciles. The working tree is the truth.
1145pub(super) fn raw_checkout_divergence_notice(
1146    directory: &Path,
1147    recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
1148    live: &CheckoutPosition,
1149) -> Option<String> {
1150    let recorded = recorded?;
1151    if recorded.head_commit.is_empty()
1152        || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
1153    {
1154        return None;
1155    }
1156    Some(format!(
1157        "The working tree at {} moved from {} to {} while this session was stopped.",
1158        directory.display(),
1159        checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
1160        checkout_position_text(&live.head_commit, live.branch.as_deref()),
1161    ))
1162}
1163
1164fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
1165    let short = head_commit.get(..12).unwrap_or(head_commit);
1166    match branch {
1167        Some(branch) => format!("{short} ({branch})"),
1168        None => format!("{short} (detached)"),
1169    }
1170}
1171
1172fn inspect_raw_project(
1173    executor: &impl CommandExecutor,
1174    target: &ManagedWorktreeTarget,
1175    selected: &Path,
1176) -> Result<RawProjectInspection> {
1177    let repository = PathBuf::from(managed_git_stdout(
1178        executor,
1179        target,
1180        selected,
1181        ["rev-parse", "--path-format=absolute", "--show-toplevel"],
1182        "resolve raw project repository root",
1183    )?);
1184    let prefix = managed_git_stdout(
1185        executor,
1186        target,
1187        selected,
1188        ["rev-parse", "--show-prefix"],
1189        "resolve raw project relative directory",
1190    )?;
1191    let git_dir = PathBuf::from(managed_git_stdout(
1192        executor,
1193        target,
1194        selected,
1195        ["rev-parse", "--absolute-git-dir"],
1196        "resolve raw project Git directory",
1197    )?);
1198    let common_git_dir = PathBuf::from(managed_git_stdout(
1199        executor,
1200        target,
1201        selected,
1202        ["rev-parse", "--path-format=absolute", "--git-common-dir"],
1203        "resolve raw project common Git directory",
1204    )?);
1205    let branch_command = managed_git_command(
1206        target,
1207        selected,
1208        ["symbolic-ref", "--quiet", "--short", "HEAD"],
1209        "resolve raw project branch",
1210    );
1211    let branch_output = executor.execute(&branch_command)?;
1212    let branch = match branch_output.status {
1213        0 => Some(
1214            String::from_utf8(branch_output.stdout)
1215                .context("raw project branch was not UTF-8")?
1216                .trim()
1217                .to_owned(),
1218        ),
1219        1 | 128 => None,
1220        status => bail!(
1221            "resolve raw project branch failed with status {status}: {}",
1222            String::from_utf8_lossy(&branch_output.stderr).trim()
1223        ),
1224    };
1225    let upstream = match branch {
1226        Some(branch) => {
1227            let reference = format!("refs/heads/{branch}");
1228            let upstream = managed_git_stdout(
1229                executor,
1230                target,
1231                selected,
1232                ["for-each-ref", "--format=%(upstream:short)", &reference],
1233                "resolve raw project upstream",
1234            )?;
1235            (!upstream.is_empty()).then_some(upstream)
1236        }
1237        None => None,
1238    };
1239    Ok(RawProjectInspection {
1240        source_project_directory: repository.join(prefix),
1241        source_repository: repository,
1242        primary_checkout: git_dir == common_git_dir,
1243        upstream,
1244    })
1245}
1246
1247fn ensure_managed_worktree_excluded(
1248    executor: &impl CommandExecutor,
1249    target: &ManagedWorktreeTarget,
1250    repository: &Path,
1251) -> Result<()> {
1252    let check = managed_git_command(
1253        target,
1254        repository,
1255        [
1256            "check-ignore",
1257            "--quiet",
1258            "--no-index",
1259            "--",
1260            ".mj/worktrees/",
1261        ],
1262        "check managed worktree exclusion",
1263    );
1264    let output = executor.execute(&check)?;
1265    match output.status {
1266        0 => return Ok(()),
1267        1 => {}
1268        status => bail!(
1269            "check managed worktree exclusion failed with status {status}: {}",
1270            String::from_utf8_lossy(&output.stderr).trim()
1271        ),
1272    }
1273    let exclude_path = PathBuf::from(managed_git_stdout(
1274        executor,
1275        target,
1276        repository,
1277        [
1278            "rev-parse",
1279            "--path-format=absolute",
1280            "--git-path",
1281            "info/exclude",
1282        ],
1283        "resolve repository-local exclude file",
1284    )?);
1285    const ENTRY: &str = "/.mj/worktrees/";
1286    match target {
1287        ManagedWorktreeTarget::Local => {
1288            use std::io::Write;
1289            let existing = match std::fs::read_to_string(&exclude_path) {
1290                Ok(existing) => existing,
1291                Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1292                Err(error) => return Err(error.into()),
1293            };
1294            if existing.lines().any(|line| line.trim() == ENTRY) {
1295                return Ok(());
1296            }
1297            if let Some(parent) = exclude_path.parent() {
1298                std::fs::create_dir_all(parent)?;
1299            }
1300            let mut file = std::fs::OpenOptions::new()
1301                .create(true)
1302                .append(true)
1303                .open(&exclude_path)
1304                .with_context(|| format!("open {}", exclude_path.display()))?;
1305            if !existing.is_empty() && !existing.ends_with('\n') {
1306                writeln!(file)?;
1307            }
1308            writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
1309        }
1310        ManagedWorktreeTarget::Ssh { .. } => {
1311            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";
1312            let command = managed_target_command(
1313                target,
1314                "sh",
1315                [
1316                    "-c",
1317                    SCRIPT,
1318                    "hel-exclude",
1319                    &exclude_path.to_string_lossy(),
1320                    ENTRY,
1321                ],
1322            )
1323            .purpose("update remote repository-local exclude file");
1324            execute_checked(executor, command)?;
1325        }
1326    }
1327    Ok(())
1328}
1329
1330pub(crate) fn path_exists_on_managed_target(
1331    executor: &impl CommandExecutor,
1332    target: &ManagedWorktreeTarget,
1333    path: &Path,
1334) -> Result<bool> {
1335    match target {
1336        ManagedWorktreeTarget::Local => path
1337            .try_exists()
1338            .with_context(|| format!("check managed project path {}", path.display())),
1339        ManagedWorktreeTarget::Ssh { .. } => {
1340            let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
1341                .purpose("check managed worktree path");
1342            let output = executor.execute(&command)?;
1343            match output.status {
1344                0 => Ok(true),
1345                1 => Ok(false),
1346                status => bail!(
1347                    "check managed worktree path failed with status {status}: {}",
1348                    String::from_utf8_lossy(&output.stderr).trim()
1349                ),
1350            }
1351        }
1352    }
1353}
1354
1355pub(super) fn managed_worktree_checkout_exists(
1356    executor: &impl CommandExecutor,
1357    worktree: &ManagedWorktree,
1358) -> Result<bool> {
1359    path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1360}
1361
1362/// Whether a new managed worktree needs the primary checkout to be clean.
1363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1364pub(super) enum PrimaryCheckoutRequirement {
1365    /// A new raw session starts from the primary checkout's HEAD, so work that
1366    /// is only in its working tree would be silently left behind.
1367    Clean,
1368    /// A session moving out of its target replaces the worktree's contents from
1369    /// its checkpoint, so the primary checkout's own changes are beside the
1370    /// point.
1371    Any,
1372}
1373
1374pub(super) fn create_managed_worktree(
1375    executor: &impl CommandExecutor,
1376    worktree: &ManagedWorktree,
1377    upstream: Option<&str>,
1378    requirement: PrimaryCheckoutRequirement,
1379) -> Result<()> {
1380    ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
1381    if requirement == PrimaryCheckoutRequirement::Clean {
1382        let status = managed_git_stdout(
1383            executor,
1384            &worktree.target,
1385            &worktree.source_repository,
1386            ["status", "--porcelain=v1", "--untracked-files=all"],
1387            "inspect primary checkout changes",
1388        )?;
1389        if !status.is_empty() {
1390            let paths = status.lines().take(20).collect::<Vec<_>>().join("\n  ");
1391            bail!(
1392                "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n  {paths}"
1393            );
1394        }
1395    }
1396    let parent = worktree
1397        .worktree_root
1398        .parent()
1399        .context("managed worktree root has no parent")?;
1400    execute_checked(
1401        executor,
1402        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1403            .purpose("create managed worktree directory"),
1404    )?;
1405    execute_checked(
1406        executor,
1407        managed_git_command(
1408            &worktree.target,
1409            &worktree.source_repository,
1410            [
1411                "worktree",
1412                "add",
1413                "-b",
1414                &worktree.branch,
1415                &worktree.worktree_root.to_string_lossy(),
1416                "HEAD",
1417            ],
1418            "create managed raw-session worktree",
1419        ),
1420    )?;
1421    if let Some(upstream) = upstream {
1422        execute_checked(
1423            executor,
1424            managed_git_command(
1425                &worktree.target,
1426                &worktree.worktree_root,
1427                ["branch", "--set-upstream-to", upstream, &worktree.branch],
1428                "set managed worktree branch upstream",
1429            ),
1430        )?;
1431    }
1432    Ok(())
1433}
1434
1435/// Recreate a retired checkout from the session branch. Returns whether this
1436/// call created it, so a failed resume can put the session back into its
1437/// stopped, checkout-free state.
1438pub(super) fn restore_managed_worktree(
1439    executor: &impl CommandExecutor,
1440    worktree: &ManagedWorktree,
1441) -> Result<bool> {
1442    if managed_worktree_checkout_exists(executor, worktree)? {
1443        return Ok(false);
1444    }
1445    ensure!(
1446        path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
1447        "managed worktree source repository is unavailable: {}",
1448        worktree.source_repository.display()
1449    );
1450    let branch_ref = format!("refs/heads/{}", worktree.branch);
1451    let check = managed_git_command(
1452        &worktree.target,
1453        &worktree.source_repository,
1454        ["show-ref", "--verify", "--quiet", &branch_ref],
1455        "check retired managed worktree branch",
1456    );
1457    let output = executor.execute(&check)?;
1458    match output.status {
1459        0 => {}
1460        1 => bail!(
1461            "managed worktree branch is unavailable: {}",
1462            worktree.branch
1463        ),
1464        status => bail!(
1465            "check retired managed worktree branch failed with status {status}: {}",
1466            String::from_utf8_lossy(&output.stderr).trim()
1467        ),
1468    }
1469    // A remote bare target may already have removed the checkout directory.
1470    // Prune its stale registration before adding the retained branch again.
1471    execute_checked(
1472        executor,
1473        managed_git_command(
1474            &worktree.target,
1475            &worktree.source_repository,
1476            ["worktree", "prune"],
1477            "prune retired managed worktree metadata",
1478        ),
1479    )?;
1480    let parent = worktree
1481        .worktree_root
1482        .parent()
1483        .context("managed worktree root has no parent")?;
1484    execute_checked(
1485        executor,
1486        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1487            .purpose("recreate managed worktree directory"),
1488    )?;
1489    execute_checked(
1490        executor,
1491        managed_git_command(
1492            &worktree.target,
1493            &worktree.source_repository,
1494            [
1495                "worktree",
1496                "add",
1497                "--",
1498                &worktree.worktree_root.to_string_lossy(),
1499                &worktree.branch,
1500            ],
1501            "restore managed raw-session worktree",
1502        ),
1503    )?;
1504    Ok(true)
1505}
1506
1507fn ensure_managed_worktree_available(
1508    executor: &impl CommandExecutor,
1509    worktree: &ManagedWorktree,
1510) -> Result<()> {
1511    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1512        bail!(
1513            "managed worktree path already exists: {}",
1514            worktree.worktree_root.display()
1515        );
1516    }
1517    let branch_ref = format!("refs/heads/{}", worktree.branch);
1518    let check = managed_git_command(
1519        &worktree.target,
1520        &worktree.source_repository,
1521        ["show-ref", "--verify", "--quiet", &branch_ref],
1522        "check managed worktree branch availability",
1523    );
1524    let output = executor.execute(&check)?;
1525    match output.status {
1526        0 => bail!(
1527            "managed worktree branch already exists: {}",
1528            worktree.branch
1529        ),
1530        1 => Ok(()),
1531        status => bail!(
1532            "check managed worktree branch availability failed with status {status}: {}",
1533            String::from_utf8_lossy(&output.stderr).trim()
1534        ),
1535    }
1536}
1537
1538/// Check whether the deterministic branch left by this session's earlier
1539/// raw-to-workspace move can be reattached. A branch with this session's id is
1540/// session-owned, but an active checkout elsewhere is still a collision: the
1541/// restore must not make one branch belong to two worktrees.
1542fn retained_managed_worktree_branch_available(
1543    executor: &impl CommandExecutor,
1544    worktree: &ManagedWorktree,
1545) -> Result<bool> {
1546    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1547        bail!(
1548            "managed worktree path already exists: {}",
1549            worktree.worktree_root.display()
1550        );
1551    }
1552    let branch_ref = format!("refs/heads/{}", worktree.branch);
1553    let check = managed_git_command(
1554        &worktree.target,
1555        &worktree.source_repository,
1556        ["show-ref", "--verify", "--quiet", &branch_ref],
1557        "check retained managed worktree branch",
1558    );
1559    let output = executor.execute(&check)?;
1560    match output.status {
1561        1 => Ok(false),
1562        0 => {
1563            let worktrees = managed_git_stdout(
1564                executor,
1565                &worktree.target,
1566                &worktree.source_repository,
1567                ["worktree", "list", "--porcelain", "-z"],
1568                "check retained managed worktree checkout",
1569            )?;
1570            let branch_field = format!("branch {branch_ref}");
1571            if worktrees.split('\0').any(|field| field == branch_field) {
1572                bail!(
1573                    "managed worktree branch is still checked out: {}",
1574                    worktree.branch
1575                );
1576            }
1577            Ok(true)
1578        }
1579        status => bail!(
1580            "check retained managed worktree branch failed with status {status}: {}",
1581            String::from_utf8_lossy(&output.stderr).trim()
1582        ),
1583    }
1584}
1585
1586/// Preserve the ref that a return-to-local restore is about to reset. The
1587/// retained `mj/<session>` branch is the source-recovery point; keeping a
1588/// second ref makes a later commit on that branch recoverable as well.
1589pub(super) fn preserve_retained_managed_worktree_branch(
1590    executor: &impl CommandExecutor,
1591    worktree: &ManagedWorktree,
1592) -> Result<String> {
1593    let session_id = worktree
1594        .branch
1595        .strip_prefix("mj/")
1596        .context("managed worktree branch is not session-owned")?;
1597    let branch_ref = format!("refs/heads/{}", worktree.branch);
1598    let tip = managed_git_stdout(
1599        executor,
1600        &worktree.target,
1601        &worktree.source_repository,
1602        ["rev-parse", "--verify", &branch_ref],
1603        "read retained managed worktree branch tip",
1604    )?;
1605    let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
1606    let existing = managed_git_command(
1607        &worktree.target,
1608        &worktree.source_repository,
1609        ["show-ref", "--verify", "--quiet", &recovery_ref],
1610        "check retained managed worktree recovery ref",
1611    );
1612    let output = executor.execute(&existing)?;
1613    match output.status {
1614        0 => {
1615            let existing_tip = managed_git_stdout(
1616                executor,
1617                &worktree.target,
1618                &worktree.source_repository,
1619                ["rev-parse", "--verify", &recovery_ref],
1620                "verify retained managed worktree recovery ref",
1621            )?;
1622            ensure!(
1623                existing_tip == tip,
1624                "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
1625            );
1626            Ok(recovery_ref)
1627        }
1628        1 => {
1629            execute_checked(
1630                executor,
1631                managed_git_command(
1632                    &worktree.target,
1633                    &worktree.source_repository,
1634                    ["update-ref", &recovery_ref, &tip],
1635                    "preserve retained managed worktree branch",
1636                ),
1637            )?;
1638            Ok(recovery_ref)
1639        }
1640        status => bail!(
1641            "check retained managed worktree recovery ref failed with status {status}: {}",
1642            String::from_utf8_lossy(&output.stderr).trim()
1643        ),
1644    }
1645}
1646
1647/// Remove a managed worktree's checkout and keep its branch.
1648///
1649/// A session that moved into a target still checkpoints as a delta against
1650/// `hel/<session>`, so deleting that branch could let the commits those deltas
1651/// depend on be collected. The checkout itself is dirty by design; its dirty
1652/// state has already been carried into the target.
1653pub(super) fn retire_managed_worktree(
1654    executor: &impl CommandExecutor,
1655    worktree: &ManagedWorktree,
1656) -> Result<()> {
1657    if !remove_managed_worktree_checkout(executor, worktree)? {
1658        return Ok(());
1659    }
1660    remove_empty_managed_worktree_directories(executor, worktree)
1661}
1662
1663/// Remove the checkout and prune its metadata. Returns whether the repository
1664/// is still there to act on at all.
1665fn remove_managed_worktree_checkout(
1666    executor: &impl CommandExecutor,
1667    worktree: &ManagedWorktree,
1668) -> Result<bool> {
1669    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1670        return Ok(false);
1671    }
1672    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1673        execute_checked(
1674            executor,
1675            managed_git_command(
1676                &worktree.target,
1677                &worktree.source_repository,
1678                [
1679                    "worktree",
1680                    "remove",
1681                    "--force",
1682                    &worktree.worktree_root.to_string_lossy(),
1683                ],
1684                "remove managed raw-session worktree",
1685            ),
1686        )?;
1687    }
1688    execute_checked(
1689        executor,
1690        managed_git_command(
1691            &worktree.target,
1692            &worktree.source_repository,
1693            ["worktree", "prune"],
1694            "prune managed worktree metadata",
1695        ),
1696    )?;
1697    Ok(true)
1698}
1699
1700pub(super) fn cleanup_managed_worktree(
1701    executor: &impl CommandExecutor,
1702    worktree: &ManagedWorktree,
1703) -> Result<()> {
1704    if !remove_managed_worktree_checkout(executor, worktree)? {
1705        return Ok(());
1706    }
1707    let branch_ref = format!("refs/heads/{}", worktree.branch);
1708    let check = managed_git_command(
1709        &worktree.target,
1710        &worktree.source_repository,
1711        ["show-ref", "--verify", "--quiet", &branch_ref],
1712        "check managed worktree branch",
1713    );
1714    let output = executor.execute(&check)?;
1715    match output.status {
1716        0 => {
1717            execute_checked(
1718                executor,
1719                managed_git_command(
1720                    &worktree.target,
1721                    &worktree.source_repository,
1722                    ["branch", "-D", "--", &worktree.branch],
1723                    "delete managed raw-session branch",
1724                ),
1725            )?;
1726        }
1727        1 => {}
1728        status => bail!(
1729            "check managed worktree branch failed with status {status}: {}",
1730            String::from_utf8_lossy(&output.stderr).trim()
1731        ),
1732    }
1733    remove_empty_managed_worktree_directories(executor, worktree)
1734}
1735
1736fn remove_empty_managed_worktree_directories(
1737    executor: &impl CommandExecutor,
1738    worktree: &ManagedWorktree,
1739) -> Result<()> {
1740    let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1741    let hel = worktree.source_repository.join(".mj");
1742    match &worktree.target {
1743        ManagedWorktreeTarget::Local => {
1744            for directory in [&worktrees, &hel] {
1745                match std::fs::remove_dir(directory) {
1746                    Ok(()) => {}
1747                    Err(error)
1748                        if matches!(
1749                            error.kind(),
1750                            std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1751                        ) => {}
1752                    Err(error) => return Err(error.into()),
1753                }
1754            }
1755        }
1756        ManagedWorktreeTarget::Ssh { .. } => {
1757            let command = managed_target_command(
1758                &worktree.target,
1759                "rmdir",
1760                ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1761            )
1762            .purpose("remove empty managed worktree directories");
1763            let _ = executor.execute(&command)?;
1764        }
1765    }
1766    Ok(())
1767}
1768
1769#[cfg(test)]
1770mod tests {
1771    use std::cell::RefCell;
1772    use std::collections::BTreeMap;
1773    use std::path::{Path, PathBuf};
1774    use std::process::Command;
1775
1776    use anyhow::Result;
1777
1778    use crate::controller::Controller;
1779    use crate::controller::resume::apply_failed_resume_rollback;
1780    use crate::controller::test_support::{
1781        FIXTURE_FETCH_URL, FixtureRemoteExecutor, checkout_with_network_remote,
1782        checkpoint_test_session, committed_repository, local_bundle, managed_raw_session,
1783        managed_worktree_session, raw_session_on, resume_compatibility_config, ssh_worktree_target,
1784        test_git,
1785    };
1786    use mj_checkpoint::archive::RepositoryMetadata;
1787    use mj_core::config::{
1788        Config, HarnessProfile, ProjectBundle, ProjectRepository, TargetTemplate,
1789    };
1790    use mj_core::state::{ManagedWorktree, ManagedWorktreeTarget, SessionState, State};
1791
1792    use crate::targets::{
1793        CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
1794    };
1795
1796    use super::*;
1797
1798    #[test]
1799    fn worktree_choice_survives_reload_and_controls_creation() {
1800        const CHILD: &str = "MJ_TEST_WORKTREE_CHOICE_CHILD";
1801        if std::env::var_os(CHILD).is_none() {
1802            let directory = tempfile::tempdir().unwrap();
1803            let mut command = Command::new(std::env::current_exe().unwrap());
1804            command.args(["--exact", "controller::worktree::tests::worktree_choice_survives_reload_and_controls_creation", "--nocapture"])
1805                .env(CHILD, "1").env("MJ_DATA_DIR", directory.path()).env("MJ_CONFIG_DIR", directory.path());
1806            let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1807            assert!(
1808                output.status.success(),
1809                "{}\n{}",
1810                String::from_utf8_lossy(&output.stdout),
1811                String::from_utf8_lossy(&output.stderr)
1812            );
1813            return;
1814        }
1815        let _writer = crate::database::install_isolated_test_writer();
1816        let repository = committed_repository();
1817        let root = repository.path().canonicalize().unwrap();
1818        let linked_parent = tempfile::tempdir().unwrap();
1819        let linked = linked_parent.path().join("linked");
1820        test_git(
1821            &root,
1822            &["worktree", "add", "-b", "side", linked.to_str().unwrap()],
1823        );
1824        test_git(&linked, &["branch", "--set-upstream-to=master"]);
1825        std::fs::write(linked.join("nested/file.txt"), "linked commit\n").unwrap();
1826        test_git(&linked, &["commit", "-am", "side commit"]);
1827        let linked = linked.canonicalize().unwrap();
1828        let mut config = Config::default();
1829        config
1830            .targets
1831            .insert("localhost".into(), TargetTemplate::LocalBare);
1832        config.save().unwrap();
1833        let mut controller = Controller {
1834            config,
1835            state: State::default(),
1836        };
1837        assert_eq!(
1838            controller
1839                .managed_worktree_options("localhost", &root, &ProcessExecutor)
1840                .unwrap(),
1841            ManagedWorktreeOptions {
1842                available: true,
1843                default_create: true
1844            }
1845        );
1846        assert_eq!(
1847            controller
1848                .managed_worktree_options("localhost", &linked, &ProcessExecutor)
1849                .unwrap(),
1850            ManagedWorktreeOptions {
1851                available: true,
1852                default_create: false
1853            }
1854        );
1855
1856        // Both creation and first resume of an imported session enter this preparation.
1857        // A dirty source is usable directly, and cleanup must leave its files and branch alone.
1858        std::fs::write(root.join("dirty.txt"), "keep me\n").unwrap();
1859        let selected = root.join("nested");
1860        let mut record = raw_session_on("localhost", selected.to_str().unwrap());
1861        record.create_managed_worktree = Some(false);
1862        crate::database::save_session(&record).unwrap();
1863        for _ in 0..2 {
1864            controller.reload().unwrap();
1865            assert!(
1866                !controller
1867                    .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1868                    .unwrap()
1869            );
1870            assert_eq!(
1871                controller.state.sessions[&record.id]
1872                    .project_directory
1873                    .as_ref(),
1874                Some(&selected)
1875            );
1876            assert!(
1877                controller.state.sessions[&record.id]
1878                    .managed_worktree
1879                    .is_none()
1880            );
1881            controller
1882                .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1883                .unwrap();
1884        }
1885        assert!(root.join("dirty.txt").exists());
1886        assert!(!root.join(".mj/worktrees").exists());
1887        assert_eq!(test_git(&root, &["branch", "--show-current"]), "master");
1888        std::fs::remove_file(root.join("dirty.txt")).unwrap();
1889
1890        // Explicit creation also works from a linked checkout and preserves its HEAD,
1891        // upstream, and selected subdirectory rather than using the main checkout's HEAD.
1892        record.project_directory = Some(linked.join("nested"));
1893        record.create_managed_worktree = None;
1894        crate::database::save_session(&record).unwrap();
1895        controller.reload().unwrap();
1896        assert!(
1897            !controller
1898                .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1899                .unwrap()
1900        );
1901        record.create_managed_worktree = Some(true);
1902        crate::database::save_session(&record).unwrap();
1903        controller.reload().unwrap();
1904        assert!(
1905            controller
1906                .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1907                .unwrap()
1908        );
1909        controller.reload().unwrap();
1910        let managed = controller.state.sessions[&record.id]
1911            .managed_worktree
1912            .clone()
1913            .unwrap();
1914        assert_eq!(
1915            test_git(&managed.worktree_root, &["rev-parse", "HEAD"]),
1916            test_git(&linked, &["rev-parse", "HEAD"])
1917        );
1918        assert_ne!(
1919            test_git(&managed.worktree_root, &["rev-parse", "HEAD"]),
1920            test_git(&root, &["rev-parse", "HEAD"])
1921        );
1922        assert_eq!(
1923            test_git(
1924                &managed.worktree_root,
1925                &["rev-parse", "--abbrev-ref", "@{upstream}"]
1926            ),
1927            "master"
1928        );
1929        assert_eq!(
1930            controller.state.sessions[&record.id].project_directory,
1931            Some(managed.worktree_root.join("nested"))
1932        );
1933        controller
1934            .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1935            .unwrap();
1936        assert!(linked.join("nested/file.txt").exists());
1937        assert!(!managed.worktree_root.exists());
1938
1939        record.project_directory = Some(root.clone());
1940        record.create_managed_worktree = None;
1941        crate::database::save_session(&record).unwrap();
1942        controller.reload().unwrap();
1943        assert!(
1944            controller
1945                .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1946                .unwrap()
1947        );
1948        controller
1949            .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1950            .unwrap();
1951    }
1952
1953    #[test]
1954    fn explicit_worktree_creation_rejects_plain_directories() {
1955        let directory = tempfile::tempdir().unwrap();
1956        let mut record = raw_session_on("localhost", directory.path().to_str().unwrap());
1957        record.create_managed_worktree = Some(true);
1958        let id = record.id.clone();
1959        let mut config = Config::default();
1960        config
1961            .targets
1962            .insert("localhost".into(), TargetTemplate::LocalBare);
1963        let mut controller = Controller {
1964            config,
1965            state: State {
1966                sessions: [(id.clone(), record)].into_iter().collect(),
1967                ..State::default()
1968            },
1969        };
1970        assert_eq!(
1971            controller
1972                .managed_worktree_options("localhost", directory.path(), &ProcessExecutor)
1973                .unwrap(),
1974            ManagedWorktreeOptions::default()
1975        );
1976        let error = controller
1977            .prepare_managed_raw_worktree(&id, &ProcessExecutor)
1978            .unwrap_err();
1979        assert!(error.to_string().contains("requires a Git project"));
1980        assert!(!directory.path().join(".git").exists());
1981    }
1982
1983    #[test]
1984    fn local_bare_validation_accepts_projects_and_plain_directories_but_rejects_missing_paths() {
1985        let project = committed_repository();
1986        let plain = tempfile::tempdir().unwrap();
1987        let mut config = Config::default();
1988        config
1989            .targets
1990            .insert("localhost".into(), TargetTemplate::LocalBare);
1991        let controller = Controller {
1992            config,
1993            state: State::default(),
1994        };
1995        controller
1996            .validate_project_directory("localhost", project.path(), &ProcessExecutor)
1997            .unwrap();
1998        controller
1999            .validate_project_directory("localhost", plain.path(), &ProcessExecutor)
2000            .unwrap();
2001        assert!(
2002            controller
2003                .validate_project_directory(
2004                    "localhost",
2005                    &plain.path().join("missing"),
2006                    &ProcessExecutor
2007                )
2008                .is_err()
2009        );
2010    }
2011
2012    #[test]
2013    fn a_plain_local_directory_starts_without_creating_a_git_worktree() {
2014        let plain = tempfile::tempdir().unwrap();
2015        let session = raw_session_on("localhost", plain.path().to_str().unwrap());
2016        let session_id = session.id.clone();
2017        let mut config = Config::default();
2018        config
2019            .targets
2020            .insert("localhost".into(), TargetTemplate::LocalBare);
2021        let mut controller = Controller {
2022            config,
2023            state: State {
2024                sessions: [(session_id.clone(), session)].into_iter().collect(),
2025                ..State::default()
2026            },
2027        };
2028        assert!(
2029            !controller
2030                .prepare_managed_raw_worktree(&session_id, &ProcessExecutor)
2031                .unwrap()
2032        );
2033        let session = &controller.state.sessions[&session_id];
2034        assert_eq!(session.project_directory.as_deref(), Some(plain.path()));
2035        assert!(session.managed_worktree.is_none());
2036        assert!(!plain.path().join(".git").exists());
2037    }
2038
2039    #[test]
2040    fn raw_linked_worktree_origin_matches_the_configured_github_project() {
2041        struct OriginExecutor;
2042        impl CommandExecutor for OriginExecutor {
2043            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2044                assert_eq!(
2045                    command.args,
2046                    [
2047                        "-C",
2048                        "/mnt/optane/bifrost-fird",
2049                        "config",
2050                        "--get",
2051                        "remote.origin.url",
2052                    ]
2053                );
2054                Ok(CommandOutput {
2055                    status: 0,
2056                    stdout: b"git@github.com:BrokkAi/bifrost-dev.git\n".to_vec(),
2057                    stderr: Vec::new(),
2058                })
2059            }
2060        }
2061
2062        let mut config = Config::default();
2063        config
2064            .targets
2065            .insert("localhost".into(), TargetTemplate::LocalBare);
2066        let session = raw_session_on("localhost", "/mnt/optane/bifrost-fird");
2067        let session_id = session.id.clone();
2068        let controller = Controller {
2069            config,
2070            state: State {
2071                sessions: [(session_id.clone(), session)].into_iter().collect(),
2072                ..State::default()
2073            },
2074        };
2075
2076        let source = controller
2077            .resolve_session_project_source(&session_id, &OriginExecutor)
2078            .unwrap();
2079
2080        assert_eq!(source.key, "github:brokkai/bifrost-dev");
2081        assert_eq!(source.short, "bifrost-dev");
2082        assert_eq!(source.full, "BrokkAi/bifrost-dev");
2083    }
2084    #[test]
2085    fn managed_worktree_origin_uses_source_repository_while_checkout_is_retired() {
2086        struct OriginExecutor;
2087        impl CommandExecutor for OriginExecutor {
2088            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2089                assert_eq!(
2090                    command.args,
2091                    [
2092                        "-C",
2093                        "/home/dev/project",
2094                        "config",
2095                        "--get",
2096                        "remote.origin.url",
2097                    ]
2098                );
2099                Ok(CommandOutput {
2100                    status: 0,
2101                    stdout: b"git@github.com:example/project.git\n".to_vec(),
2102                    stderr: Vec::new(),
2103                })
2104            }
2105        }
2106
2107        let session = managed_raw_session(ManagedWorktreeTarget::Local);
2108        let session_id = session.id.clone();
2109        let controller = Controller {
2110            config: Config::default(),
2111            state: State {
2112                sessions: [(session_id.clone(), session)].into_iter().collect(),
2113                ..State::default()
2114            },
2115        };
2116
2117        let source = controller
2118            .resolve_session_project_source(&session_id, &OriginExecutor)
2119            .unwrap();
2120
2121        assert_eq!(source.key, "github:example/project");
2122    }
2123
2124    #[test]
2125    fn raw_no_origin_uses_the_canonical_main_repository_root() {
2126        struct NoOriginExecutor {
2127            commands: RefCell<Vec<CommandSpec>>,
2128        }
2129        impl CommandExecutor for NoOriginExecutor {
2130            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2131                self.commands.borrow_mut().push(command.clone());
2132                if command.args.iter().any(|argument| argument == "config") {
2133                    return Ok(CommandOutput {
2134                        status: 1,
2135                        stdout: Vec::new(),
2136                        stderr: Vec::new(),
2137                    });
2138                }
2139                let stdout = if command
2140                    .args
2141                    .iter()
2142                    .any(|argument| argument == "--show-toplevel")
2143                {
2144                    "/worktrees/project-side\n"
2145                } else if command
2146                    .args
2147                    .iter()
2148                    .any(|argument| argument == "--git-common-dir")
2149                {
2150                    "/projects/project/.git\n"
2151                } else {
2152                    panic!("unexpected command {:?}", command.args);
2153                };
2154                Ok(CommandOutput {
2155                    status: 0,
2156                    stdout: stdout.as_bytes().to_vec(),
2157                    stderr: Vec::new(),
2158                })
2159            }
2160        }
2161
2162        let mut config = Config::default();
2163        config
2164            .targets
2165            .insert("localhost".into(), TargetTemplate::LocalBare);
2166        let session = raw_session_on("localhost", "/worktrees/project-side");
2167        let session_id = session.id.clone();
2168        let controller = Controller {
2169            config,
2170            state: State {
2171                sessions: [(session_id.clone(), session)].into_iter().collect(),
2172                ..State::default()
2173            },
2174        };
2175        let executor = NoOriginExecutor {
2176            commands: RefCell::new(Vec::new()),
2177        };
2178
2179        let source = controller
2180            .resolve_session_project_source(&session_id, &executor)
2181            .unwrap();
2182
2183        assert_eq!(source.key, "path:/projects/project");
2184        assert_eq!(source.short, "project");
2185        assert_eq!(source.full, "/projects/project");
2186        assert_eq!(executor.commands.borrow().len(), 3);
2187    }
2188
2189    #[test]
2190    fn raw_non_git_directory_keeps_its_local_path_source() {
2191        struct NonGitExecutor {
2192            commands: RefCell<Vec<CommandSpec>>,
2193        }
2194        impl CommandExecutor for NonGitExecutor {
2195            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2196                self.commands.borrow_mut().push(command.clone());
2197                let status = if command.args.iter().any(|argument| argument == "config") {
2198                    1
2199                } else {
2200                    assert!(
2201                        command
2202                            .args
2203                            .iter()
2204                            .any(|argument| argument == "--show-toplevel")
2205                    );
2206                    128
2207                };
2208                Ok(CommandOutput {
2209                    status,
2210                    stdout: Vec::new(),
2211                    stderr: b"fatal: not a git repository\n".to_vec(),
2212                })
2213            }
2214        }
2215
2216        let mut config = Config::default();
2217        config
2218            .targets
2219            .insert("localhost".into(), TargetTemplate::LocalBare);
2220        let session = raw_session_on("localhost", "/scratch/project");
2221        let session_id = session.id.clone();
2222        let controller = Controller {
2223            config,
2224            state: State {
2225                sessions: [(session_id.clone(), session)].into_iter().collect(),
2226                ..State::default()
2227            },
2228        };
2229        let executor = NonGitExecutor {
2230            commands: RefCell::new(Vec::new()),
2231        };
2232
2233        let source = controller
2234            .resolve_session_project_source(&session_id, &executor)
2235            .unwrap();
2236
2237        assert_eq!(source.key, "path:/scratch/project");
2238        assert_eq!(source.full, "/scratch/project");
2239        assert_eq!(executor.commands.borrow().len(), 2);
2240    }
2241
2242    #[test]
2243    fn project_root_lookup_reports_git_failures_instead_of_treating_them_as_non_git() {
2244        struct FailedGit;
2245        impl CommandExecutor for FailedGit {
2246            fn execute(&self, _: &CommandSpec) -> Result<CommandOutput> {
2247                Ok(CommandOutput {
2248                    status: 128,
2249                    stdout: Vec::new(),
2250                    stderr: b"fatal: detected dubious ownership in repository".to_vec(),
2251                })
2252            }
2253        }
2254        let error = resolve_git_root(
2255            &ManagedWorktreeTarget::Local,
2256            Path::new("/project"),
2257            &FailedGit,
2258        )
2259        .unwrap_err();
2260        assert!(error.to_string().contains("dubious ownership"));
2261    }
2262
2263    /// Answers the two Git reads that locate a checkout, and nothing else.
2264    struct CheckoutPositionExecutor {
2265        head_commit: String,
2266        branch: Option<String>,
2267    }
2268    impl CommandExecutor for CheckoutPositionExecutor {
2269        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2270            let stdout = if command.args.iter().any(|argument| argument == "rev-parse") {
2271                self.head_commit.clone()
2272            } else if command
2273                .args
2274                .iter()
2275                .any(|argument| argument == "symbolic-ref")
2276            {
2277                match &self.branch {
2278                    Some(branch) => branch.clone(),
2279                    None => {
2280                        return Ok(CommandOutput {
2281                            status: 1,
2282                            stdout: Vec::new(),
2283                            stderr: Vec::new(),
2284                        });
2285                    }
2286                }
2287            } else {
2288                panic!("unexpected command {:?}", command.args);
2289            };
2290            Ok(CommandOutput {
2291                status: 0,
2292                stdout: format!("{stdout}\n").into_bytes(),
2293                stderr: Vec::new(),
2294            })
2295        }
2296    }
2297    fn recorded_repository(head_commit: &str, branch: Option<&str>) -> RepositoryMetadata {
2298        RepositoryMetadata {
2299            push_urls: Vec::new(),
2300            remote_workspace: false,
2301            id: "project".into(),
2302            relative_destination: PathBuf::from("project"),
2303            origin: "mj-local:project".into(),
2304            base_commit: String::new(),
2305            head_commit: head_commit.into(),
2306            branch: branch.map(str::to_owned),
2307        }
2308    }
2309    #[test]
2310    fn a_raw_checkout_that_moved_while_stopped_gets_a_conversation_line() {
2311        let config = resume_compatibility_config();
2312        let session = managed_raw_session(ManagedWorktreeTarget::Local);
2313        let directory = session.project_directory.clone().unwrap();
2314        let executor = CheckoutPositionExecutor {
2315            head_commit: "b".repeat(40),
2316            branch: Some("mj/0123456789abcdef0123456789abcdef".into()),
2317        };
2318
2319        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
2320        let notice = raw_checkout_divergence_notice(
2321            &directory,
2322            Some(&recorded_repository(&"a".repeat(40), Some("main"))),
2323            &live,
2324        )
2325        .expect("a moved checkout is reported");
2326
2327        assert!(
2328            notice.contains(&directory.display().to_string()),
2329            "{notice}"
2330        );
2331        assert!(notice.contains("aaaaaaaaaaaa (main)"), "{notice}");
2332        assert!(
2333            notice.contains("bbbbbbbbbbbb (mj/0123456789abcdef0123456789abcdef)"),
2334            "{notice}"
2335        );
2336        assert!(
2337            notice.contains("while this session was stopped"),
2338            "{notice}"
2339        );
2340    }
2341    #[test]
2342    fn a_raw_checkout_that_stayed_put_gets_no_conversation_line() {
2343        let config = resume_compatibility_config();
2344        let session = managed_raw_session(ManagedWorktreeTarget::Local);
2345        let directory = session.project_directory.clone().unwrap();
2346        let executor = CheckoutPositionExecutor {
2347            head_commit: "a".repeat(40),
2348            branch: Some("main".into()),
2349        };
2350
2351        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
2352
2353        assert_eq!(
2354            raw_checkout_divergence_notice(
2355                &directory,
2356                Some(&recorded_repository(&"a".repeat(40), Some("main"))),
2357                &live,
2358            ),
2359            None
2360        );
2361    }
2362    #[test]
2363    fn a_checkpoint_without_recorded_git_identity_reports_nothing() {
2364        let live = CheckoutPosition {
2365            head_commit: "b".repeat(40),
2366            branch: None,
2367        };
2368
2369        assert_eq!(
2370            raw_checkout_divergence_notice(Path::new("/home/dev/project"), None, &live),
2371            None
2372        );
2373        assert_eq!(
2374            raw_checkout_divergence_notice(
2375                Path::new("/home/dev/project"),
2376                Some(&recorded_repository("", None)),
2377                &live,
2378            ),
2379            None
2380        );
2381    }
2382    #[test]
2383    fn a_detached_checkout_is_named_as_detached() {
2384        let config = resume_compatibility_config();
2385        let session = managed_raw_session(ManagedWorktreeTarget::Local);
2386        let directory = session.project_directory.clone().unwrap();
2387        let executor = CheckoutPositionExecutor {
2388            head_commit: "c".repeat(40),
2389            branch: None,
2390        };
2391
2392        let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
2393        let notice = raw_checkout_divergence_notice(
2394            &directory,
2395            Some(&recorded_repository(&"a".repeat(40), Some("main"))),
2396            &live,
2397        )
2398        .expect("a moved checkout is reported");
2399
2400        assert!(notice.contains("cccccccccccc (detached)"), "{notice}");
2401    }
2402    #[test]
2403    fn bundle_sessions_resume_on_any_workspace_target() {
2404        let config = resume_compatibility_config();
2405        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2406
2407        assert_eq!(
2408            resume_compatibility(&session, &config, "podman"),
2409            Ok(ResumePlan::InPlace)
2410        );
2411        assert_eq!(
2412            resume_compatibility(&session, &config, "ssh-bare"),
2413            Ok(ResumePlan::InPlace)
2414        );
2415    }
2416    #[test]
2417    fn a_single_local_repository_can_become_a_checkout() {
2418        let mut config = resume_compatibility_config();
2419        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2420        session.bundle_id = "project".into();
2421        config.bundles.insert(
2422            "project".into(),
2423            local_bundle(Path::new("/home/dev/project")),
2424        );
2425
2426        assert_eq!(
2427            resume_compatibility(&session, &config, "local-bare"),
2428            Ok(ResumePlan::WorkspaceToRaw)
2429        );
2430    }
2431    #[test]
2432    fn a_github_project_cannot_become_a_checkout() {
2433        let mut config = resume_compatibility_config();
2434        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2435        session.bundle_id = "project".into();
2436        let mut bundle = local_bundle(Path::new("/home/dev/project"));
2437        bundle.repositories[0].local = None;
2438        bundle.repositories[0].github = Some("example/project".into());
2439        config.bundles.insert("project".into(), bundle);
2440
2441        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2442
2443        assert!(reason.contains("came from GitHub"), "{reason}");
2444        assert!(
2445            reason.contains("resume it on a container, SSH, or EC2 target"),
2446            "{reason}"
2447        );
2448    }
2449    #[test]
2450    fn a_multi_repository_project_cannot_become_a_checkout() {
2451        let mut config = resume_compatibility_config();
2452        let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2453        session.bundle_id = "project".into();
2454        let mut bundle = local_bundle(Path::new("/home/dev/project"));
2455        bundle.repositories.push(ProjectRepository {
2456            id: "tools".into(),
2457            github: None,
2458            local: Some(PathBuf::from("/home/dev/tools")),
2459            destination: PathBuf::from("tools"),
2460            git_ref: None,
2461        });
2462        config.bundles.insert("project".into(), bundle);
2463
2464        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2465
2466        assert!(reason.contains("2 repositories"), "{reason}");
2467        assert!(reason.contains("one checkout"), "{reason}");
2468    }
2469    #[test]
2470    fn bundle_sessions_refuse_a_local_bare_target_with_a_reason() {
2471        let config = resume_compatibility_config();
2472        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2473
2474        let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2475
2476        assert!(reason.contains("created from a project bundle"), "{reason}");
2477        assert!(
2478            reason.contains("resume it on a container, SSH, or EC2 target"),
2479            "{reason}"
2480        );
2481    }
2482    #[test]
2483    fn managed_raw_sessions_resume_on_their_own_worktree_host() {
2484        let config = resume_compatibility_config();
2485
2486        assert_eq!(
2487            resume_compatibility(
2488                &managed_raw_session(ManagedWorktreeTarget::Local),
2489                &config,
2490                "local-bare",
2491            ),
2492            Ok(ResumePlan::InPlace)
2493        );
2494        assert_eq!(
2495            resume_compatibility(
2496                &managed_raw_session(ssh_worktree_target()),
2497                &config,
2498                "ssh-bare",
2499            ),
2500            Ok(ResumePlan::InPlace)
2501        );
2502    }
2503    #[test]
2504    fn managed_raw_sessions_refuse_a_bare_target_on_another_host() {
2505        let config = resume_compatibility_config();
2506
2507        let reason = resume_compatibility(
2508            &managed_raw_session(ManagedWorktreeTarget::Local),
2509            &config,
2510            "ssh-bare",
2511        )
2512        .unwrap_err();
2513        assert!(reason.contains("this machine"), "{reason}");
2514
2515        let reason = resume_compatibility(
2516            &managed_raw_session(ssh_worktree_target()),
2517            &config,
2518            "local-bare",
2519        )
2520        .unwrap_err();
2521        assert!(reason.contains("dev@builder"), "{reason}");
2522    }
2523    #[test]
2524    fn a_whole_local_checkout_can_move_to_an_isolated_target() {
2525        let config = resume_compatibility_config();
2526        for session in [
2527            managed_raw_session(ManagedWorktreeTarget::Local),
2528            raw_session_on("local-bare", "/home/dev/project"),
2529        ] {
2530            assert_eq!(
2531                resume_compatibility(&session, &config, "podman"),
2532                Ok(ResumePlan::RawToWorkspace)
2533            );
2534        }
2535    }
2536    /// Give a checkout the network remote a conversion requires. Planning
2537    /// never contacts it.
2538    fn add_network_remote(checkout: &Path) {
2539        test_git(checkout, &["remote", "add", "origin", FIXTURE_FETCH_URL]);
2540    }
2541
2542    fn fixture_network_source() -> mj_core::remote_git::NetworkGitSource {
2543        mj_core::remote_git::NetworkGitSource {
2544            fetch_url: FIXTURE_FETCH_URL.to_owned(),
2545            push_urls: vec![FIXTURE_FETCH_URL.to_owned()],
2546        }
2547    }
2548
2549    #[test]
2550    fn a_checkout_without_a_network_remote_cannot_be_planned_for_a_target() {
2551        let repository = committed_repository();
2552        let config = resume_compatibility_config();
2553        let session = raw_session_on("local-bare", &repository.path().to_string_lossy());
2554
2555        let error = plan_raw_to_workspace(&session, &config, &ProcessExecutor).unwrap_err();
2556
2557        let detail = format!("{error:#}");
2558        assert!(detail.contains("has no network Git remote"), "{detail}");
2559        assert!(detail.contains("git remote add origin"), "{detail}");
2560        assert!(detail.contains("bare target"), "{detail}");
2561    }
2562
2563    #[test]
2564    fn planning_a_conversion_records_the_checkouts_network_remote() {
2565        let (checkout, _remote_parent, _remote) = checkout_with_network_remote();
2566        let config = resume_compatibility_config();
2567        let session = raw_session_on("local-bare", &checkout.path().to_string_lossy());
2568
2569        let conversion = plan_raw_to_workspace(&session, &config, &ProcessExecutor).unwrap();
2570
2571        assert_eq!(conversion.source.fetch_url, FIXTURE_FETCH_URL);
2572        assert_eq!(conversion.source.push_urls, [FIXTURE_FETCH_URL]);
2573        assert_eq!(conversion.checkout, checkout.path().canonicalize().unwrap());
2574        assert!(conversion.retire.is_none());
2575    }
2576
2577    #[test]
2578    fn a_raw_checkout_snapshot_restores_into_a_fresh_clone_of_its_remote() {
2579        let (checkout, _remote_parent, remote) = checkout_with_network_remote();
2580        let pushed = test_git(checkout.path(), &["rev-parse", "HEAD"]);
2581        // A session commit on top of the pushed base has to travel in the
2582        // snapshot, and its prerequisite has to stay on the remote.
2583        std::fs::write(checkout.path().join("nested/file.txt"), "session commit\n").unwrap();
2584        test_git(checkout.path(), &["commit", "-am", "session commit"]);
2585        let head = test_git(checkout.path(), &["rev-parse", "HEAD"]);
2586        std::fs::write(checkout.path().join("staged.txt"), "staged\n").unwrap();
2587        test_git(checkout.path(), &["add", "staged.txt"]);
2588        std::fs::write(checkout.path().join("nested/file.txt"), "unstaged\n").unwrap();
2589        // Larger than a pipe buffer, so a truncated untracked capture cannot
2590        // pass on a toy fixture.
2591        let untracked = "u".repeat(100 * 1024);
2592        std::fs::write(checkout.path().join("untracked.txt"), &untracked).unwrap();
2593        let source =
2594            mj_core::remote_git::resolve_local_repository(checkout.path(), &ProcessExecutor)
2595                .unwrap();
2596
2597        let snapshot = raw_checkout_snapshot(
2598            checkout.path(),
2599            &source,
2600            Path::new("project"),
2601            &mj_checkpoint::archive::SystemGit,
2602        )
2603        .unwrap();
2604
2605        assert!(snapshot.metadata.remote_workspace);
2606        assert_eq!(snapshot.metadata.origin, FIXTURE_FETCH_URL);
2607        assert_eq!(snapshot.metadata.push_urls, [FIXTURE_FETCH_URL]);
2608        assert_eq!(snapshot.metadata.base_commit, pushed);
2609        assert_eq!(snapshot.metadata.head_commit, head);
2610        assert_eq!(snapshot.metadata.branch.as_deref(), Some("master"));
2611
2612        // A fresh clone of the remote is what the container really gets, so
2613        // every bundle prerequisite has to be reachable from its origin refs.
2614        let fresh_parent = tempfile::tempdir().unwrap();
2615        let fresh = fresh_parent.path().join("workspace");
2616        let output = Command::new("git")
2617            .arg("clone")
2618            .arg(&remote)
2619            .arg(&fresh)
2620            .output()
2621            .unwrap();
2622        assert!(
2623            output.status.success(),
2624            "{}",
2625            String::from_utf8_lossy(&output.stderr)
2626        );
2627        mj_checkpoint::archive::restore_git_snapshot(
2628            &mj_checkpoint::archive::SystemGit,
2629            &fresh,
2630            &snapshot,
2631        )
2632        .unwrap();
2633
2634        assert_eq!(test_git(&fresh, &["rev-parse", "HEAD"]), head);
2635        assert_eq!(test_git(&fresh, &["branch", "--show-current"]), "master");
2636        assert_eq!(
2637            test_git(&fresh, &["diff", "--cached", "--name-only"]),
2638            "staged.txt"
2639        );
2640        assert_eq!(
2641            test_git(&fresh, &["diff", "--name-only"]),
2642            "nested/file.txt"
2643        );
2644        assert_eq!(
2645            std::fs::read_to_string(fresh.join("nested/file.txt")).unwrap(),
2646            "unstaged\n"
2647        );
2648        assert_eq!(
2649            std::fs::read_to_string(fresh.join("untracked.txt")).unwrap(),
2650            untracked
2651        );
2652        assert_eq!(
2653            test_git(&fresh, &["config", "--local", "mj.remoteWorkspace"]),
2654            "true"
2655        );
2656        assert_eq!(
2657            test_git(&fresh, &["config", "--local", "mj.baseCommit"]),
2658            pushed
2659        );
2660    }
2661
2662    #[test]
2663    fn a_conversion_preview_counts_unpushed_commits_and_dirty_files() {
2664        let (checkout, _remote_parent, remote) = checkout_with_network_remote();
2665        std::fs::write(checkout.path().join("nested/file.txt"), "session commit\n").unwrap();
2666        test_git(checkout.path(), &["commit", "-am", "session commit"]);
2667        std::fs::write(checkout.path().join("staged.txt"), "staged\n").unwrap();
2668        test_git(checkout.path(), &["add", "staged.txt"]);
2669        std::fs::write(checkout.path().join("nested/file.txt"), "unstaged\n").unwrap();
2670        let untracked = "u".repeat(100 * 1024);
2671        std::fs::write(checkout.path().join("untracked.txt"), &untracked).unwrap();
2672        let config = resume_compatibility_config();
2673        let session = raw_session_on("local-bare", &checkout.path().to_string_lossy());
2674        let executor = FixtureRemoteExecutor { remote };
2675        let conversion = plan_raw_to_workspace(&session, &config, &executor).unwrap();
2676
2677        let preview = raw_conversion_preview(&session, &conversion, &executor).unwrap();
2678
2679        assert_eq!(preview.fetch_url, FIXTURE_FETCH_URL);
2680        assert_eq!(preview.default_branch, "master");
2681        assert_eq!(preview.branch.as_deref(), Some("master"));
2682        assert_eq!(preview.unpushed_commits, 1);
2683        assert_eq!(preview.staged_files, 1);
2684        assert_eq!(preview.unstaged_files, 1);
2685        assert_eq!(preview.untracked_files, 1);
2686        assert_eq!(preview.untracked_bytes, untracked.len() as u64);
2687        assert!(
2688            preview.host_checkout_retained,
2689            "the user's own checkout stays on this machine"
2690        );
2691        assert_eq!(
2692            preview.destination,
2693            PathBuf::from("/workspace").join(checkout.path().file_name().unwrap())
2694        );
2695    }
2696
2697    #[test]
2698    fn a_conversion_preview_reports_a_managed_worktree_as_not_retained() {
2699        let (checkout, _remote_parent, remote) = checkout_with_network_remote();
2700        let session_id = "0123456789abcdef0123456789abcdef";
2701        let session = managed_worktree_session(checkout.path(), session_id);
2702        let config = resume_compatibility_config();
2703        let executor = FixtureRemoteExecutor { remote };
2704        let conversion = plan_raw_to_workspace(&session, &config, &executor).unwrap();
2705
2706        let preview = raw_conversion_preview(&session, &conversion, &executor).unwrap();
2707
2708        assert!(
2709            !preview.host_checkout_retained,
2710            "a managed worktree is retired by the move"
2711        );
2712        assert_eq!(preview.branch, Some(format!("mj/{session_id}")));
2713        assert_eq!(preview.unpushed_commits, 0);
2714        assert_eq!(preview.staged_files, 0);
2715        assert_eq!(preview.unstaged_files, 0);
2716        assert_eq!(
2717            preview.destination,
2718            PathBuf::from("/workspace").join(session_id)
2719        );
2720    }
2721
2722    #[test]
2723    fn a_conversion_preview_refuses_a_dirty_submodule() {
2724        let (checkout, _remote_parent, _remote) = checkout_with_network_remote();
2725        let submodule = committed_repository();
2726        test_git(
2727            checkout.path(),
2728            &[
2729                "-c",
2730                "protocol.file.allow=always",
2731                "submodule",
2732                "add",
2733                &submodule.path().to_string_lossy(),
2734                "sub",
2735            ],
2736        );
2737        test_git(checkout.path(), &["commit", "-m", "add submodule"]);
2738        std::fs::write(checkout.path().join("sub/nested/file.txt"), "dirty\n").unwrap();
2739        let config = resume_compatibility_config();
2740        let session = raw_session_on("local-bare", &checkout.path().to_string_lossy());
2741        let conversion = plan_raw_to_workspace(&session, &config, &ProcessExecutor).unwrap();
2742
2743        let error = raw_conversion_preview(&session, &conversion, &ProcessExecutor).unwrap_err();
2744
2745        assert!(
2746            format!("{error:#}").contains("dirty submodule"),
2747            "{error:#}"
2748        );
2749    }
2750    #[test]
2751    fn a_raw_checkout_on_an_ssh_host_cannot_convert() {
2752        let config = resume_compatibility_config();
2753
2754        let reason = resume_compatibility(
2755            &managed_raw_session(ssh_worktree_target()),
2756            &config,
2757            "podman",
2758        )
2759        .unwrap_err();
2760        assert!(reason.contains("works directly in"), "{reason}");
2761        assert!(reason.contains("dev@builder"), "{reason}");
2762
2763        let reason = resume_compatibility(
2764            &raw_session_on("ssh-bare", "/srv/project"),
2765            &config,
2766            "podman",
2767        )
2768        .unwrap_err();
2769        assert!(reason.contains("on an SSH host"), "{reason}");
2770    }
2771    #[test]
2772    fn a_session_that_opens_a_subdirectory_of_its_worktree_cannot_convert() {
2773        let config = resume_compatibility_config();
2774        let mut session = managed_raw_session(ManagedWorktreeTarget::Local);
2775        let worktree = session.managed_worktree.as_mut().unwrap();
2776        worktree.source_project_directory = worktree.source_repository.join("crate");
2777        session.project_directory = Some(worktree.worktree_root.join("crate"));
2778
2779        let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
2780
2781        assert!(reason.contains("subdirectory of its checkout"), "{reason}");
2782    }
2783    #[test]
2784    fn unmanaged_raw_sessions_require_the_same_bare_target_kind() {
2785        let config = resume_compatibility_config();
2786        let local = raw_session_on("local-bare", "/home/dev/project");
2787        let remote = raw_session_on("ssh-bare", "/srv/project");
2788
2789        assert_eq!(
2790            resume_compatibility(&local, &config, "local-bare"),
2791            Ok(ResumePlan::InPlace)
2792        );
2793        assert_eq!(
2794            resume_compatibility(&remote, &config, "ssh-bare"),
2795            Ok(ResumePlan::InPlace)
2796        );
2797        for (session, target) in [(&local, "ssh-bare"), (&remote, "local-bare")] {
2798            let reason = resume_compatibility(session, &config, target).unwrap_err();
2799            assert!(reason.contains("directly on its host"), "{reason}");
2800        }
2801    }
2802    #[test]
2803    fn resume_compatibility_names_a_target_that_is_gone() {
2804        let config = resume_compatibility_config();
2805        let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2806
2807        let reason = resume_compatibility(&session, &config, "retired").unwrap_err();
2808
2809        assert!(reason.contains("retired"), "{reason}");
2810    }
2811    #[test]
2812    fn managed_raw_worktree_inherits_upstream_and_cleans_up_owned_artifacts() {
2813        let repository = committed_repository();
2814        let remote_parent = tempfile::tempdir().unwrap();
2815        let remote = remote_parent.path().join("remote.git");
2816        let output = Command::new("git")
2817            .args(["init", "--bare"])
2818            .arg(&remote)
2819            .output()
2820            .unwrap();
2821        assert!(output.status.success());
2822        test_git(
2823            repository.path(),
2824            &["remote", "add", "origin", &remote.to_string_lossy()],
2825        );
2826        test_git(
2827            repository.path(),
2828            &["push", "--set-upstream", "origin", "master"],
2829        );
2830
2831        let target = ManagedWorktreeTarget::Local;
2832        let inspection =
2833            inspect_raw_project(&ProcessExecutor, &target, &repository.path().join("nested"))
2834                .unwrap();
2835        assert!(inspection.primary_checkout);
2836        assert_eq!(inspection.upstream.as_deref(), Some("origin/master"));
2837        // git rev-parse canonicalizes symlinks (macOS tempdirs live behind the
2838        // /var -> /private/var link), so compare against the canonical path.
2839        assert_eq!(
2840            inspection.source_project_directory,
2841            repository.path().canonicalize().unwrap().join("nested")
2842        );
2843
2844        let session_id = "0123456789abcdef0123456789abcdef";
2845        let worktree = ManagedWorktree {
2846            source_project_directory: inspection.source_project_directory,
2847            source_repository: inspection.source_repository,
2848            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2849            branch: format!("mj/{session_id}"),
2850            target,
2851            base_commit: None,
2852        };
2853        create_managed_worktree(
2854            &ProcessExecutor,
2855            &worktree,
2856            inspection.upstream.as_deref(),
2857            PrimaryCheckoutRequirement::Clean,
2858        )
2859        .unwrap();
2860        assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2861        assert_eq!(
2862            test_git(
2863                &worktree.worktree_root,
2864                &[
2865                    "rev-parse",
2866                    "--abbrev-ref",
2867                    "--symbolic-full-name",
2868                    "@{upstream}"
2869                ]
2870            ),
2871            "origin/master"
2872        );
2873        assert_eq!(test_git(repository.path(), &["status", "--porcelain"]), "");
2874        std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2875
2876        cleanup_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2877        assert!(!worktree.worktree_root.exists());
2878        assert!(!repository.path().join(".mj").exists());
2879        let output = Command::new("git")
2880            .arg("-C")
2881            .arg(repository.path())
2882            .args([
2883                "show-ref",
2884                "--verify",
2885                &format!("refs/heads/{}", worktree.branch),
2886            ])
2887            .output()
2888            .unwrap();
2889        assert!(!output.status.success());
2890    }
2891    #[test]
2892    fn retired_worktree_can_be_recreated_from_its_retained_branch() {
2893        let repository = committed_repository();
2894        let session_id = "0123456789abcdef0123456789abcdef";
2895        let session = managed_worktree_session(repository.path(), session_id);
2896        let worktree = session.managed_worktree.unwrap();
2897        // The checkout is dirty by design: its dirty state moved into the target.
2898        std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2899
2900        retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2901
2902        assert!(!worktree.worktree_root.exists());
2903        assert!(!repository.path().join(".mj").exists());
2904        let branch = Command::new("git")
2905            .arg("-C")
2906            .arg(repository.path())
2907            .args([
2908                "show-ref",
2909                "--verify",
2910                &format!("refs/heads/{}", worktree.branch),
2911            ])
2912            .output()
2913            .unwrap();
2914        assert!(
2915            branch.status.success(),
2916            "the session branch must survive: later checkpoints are deltas against it"
2917        );
2918
2919        assert!(restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2920        assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2921        assert!(!restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2922    }
2923    #[test]
2924    fn retiring_a_remote_worktree_prunes_registration_after_target_removed_checkout() {
2925        struct RemoteExecutor {
2926            path_checks: RefCell<usize>,
2927            commands: RefCell<Vec<CommandSpec>>,
2928        }
2929
2930        impl CommandExecutor for RemoteExecutor {
2931            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2932                self.commands.borrow_mut().push(command.clone());
2933                let status = if command.purpose == "check managed worktree path" {
2934                    let mut checks = self.path_checks.borrow_mut();
2935                    let status = i32::from(*checks != 0);
2936                    *checks += 1;
2937                    status
2938                } else {
2939                    0
2940                };
2941                Ok(CommandOutput {
2942                    status,
2943                    stdout: Vec::new(),
2944                    stderr: Vec::new(),
2945                })
2946            }
2947        }
2948
2949        let worktree = ManagedWorktree {
2950            source_project_directory: PathBuf::from("/srv/project"),
2951            source_repository: PathBuf::from("/srv/project"),
2952            worktree_root: PathBuf::from("/srv/project/.mj/worktrees/session"),
2953            branch: "mj/session".into(),
2954            target: ManagedWorktreeTarget::Ssh {
2955                destination: "builder".into(),
2956                ssh_args: Vec::new(),
2957            },
2958            base_commit: None,
2959        };
2960        let executor = RemoteExecutor {
2961            path_checks: RefCell::new(0),
2962            commands: RefCell::new(Vec::new()),
2963        };
2964
2965        retire_managed_worktree(&executor, &worktree).unwrap();
2966
2967        let purposes = executor
2968            .commands
2969            .borrow()
2970            .iter()
2971            .map(|command| command.purpose.clone())
2972            .collect::<Vec<_>>();
2973        assert_eq!(
2974            purposes,
2975            [
2976                "check managed worktree path",
2977                "check managed worktree path",
2978                "prune managed worktree metadata",
2979                "remove empty managed worktree directories",
2980            ]
2981        );
2982    }
2983    #[test]
2984    fn a_managed_conversion_carries_the_session_worktree_not_the_primary_checkout() {
2985        let repository = committed_repository();
2986        add_network_remote(repository.path());
2987        let session_id = "0123456789abcdef0123456789abcdef";
2988        let session = managed_worktree_session(repository.path(), session_id);
2989        let worktree = session.managed_worktree.clone().unwrap();
2990
2991        let conversion =
2992            plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
2993
2994        assert_eq!(conversion.checkout, worktree.worktree_root);
2995        assert_eq!(conversion.repository, repository.path());
2996        assert_eq!(conversion.retire, Some(worktree));
2997        let bundle = conversion.new_bundle.expect("a bundle is synthesized");
2998        assert_eq!(bundle.repositories.len(), 1);
2999        assert_eq!(bundle.primary_repo, bundle.repositories[0].id);
3000        assert_eq!(
3001            bundle.repositories[0].local.as_deref(),
3002            Some(repository.path())
3003        );
3004        assert_eq!(bundle.repositories[0].github, None);
3005        // The archive names the session directory as the repository, and the
3006        // restored harness session points inside the target at that name.
3007        assert_eq!(
3008            bundle.repositories[0].destination,
3009            PathBuf::from(session_id)
3010        );
3011    }
3012    #[test]
3013    fn an_unmanaged_conversion_serves_the_main_repository_behind_a_linked_worktree() {
3014        let repository = committed_repository();
3015        add_network_remote(repository.path());
3016        let session_id = "0123456789abcdef0123456789abcdef";
3017        let linked = managed_worktree_session(repository.path(), session_id);
3018        let checkout = linked.managed_worktree.unwrap().worktree_root;
3019        let mut session = checkpoint_test_session(session_id);
3020        session.state = SessionState::Stopped;
3021        session.target_template_id = "local-bare".into();
3022        session.project_directory = Some(checkout.clone());
3023
3024        let conversion =
3025            plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
3026
3027        assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
3028        assert_eq!(
3029            conversion.repository,
3030            repository.path().canonicalize().unwrap()
3031        );
3032        assert_eq!(conversion.retire, None);
3033    }
3034    /// The recorded project directory may reach the checkout through a
3035    /// symlink, as the system temp directory does on macOS. Git reports
3036    /// canonical paths, so the whole-checkout rule must not compare across
3037    /// the two domains.
3038    #[cfg(unix)]
3039    #[test]
3040    fn an_unmanaged_conversion_accepts_a_checkout_reached_through_a_symlink() {
3041        let repository = committed_repository();
3042        add_network_remote(repository.path());
3043        let session_id = "0123456789abcdef0123456789abcdef";
3044        let linked = managed_worktree_session(repository.path(), session_id);
3045        let checkout = linked.managed_worktree.unwrap().worktree_root;
3046        let alias = tempfile::tempdir().unwrap();
3047        let symlink = alias.path().join("checkout");
3048        std::os::unix::fs::symlink(&checkout, &symlink).unwrap();
3049        let mut session = checkpoint_test_session(session_id);
3050        session.state = SessionState::Stopped;
3051        session.target_template_id = "local-bare".into();
3052        session.project_directory = Some(symlink);
3053
3054        let conversion =
3055            plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
3056
3057        assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
3058        assert_eq!(
3059            conversion.repository,
3060            repository.path().canonicalize().unwrap()
3061        );
3062        assert_eq!(conversion.retire, None);
3063    }
3064    #[test]
3065    fn a_conversion_reuses_a_bundle_that_already_describes_the_checkout() {
3066        let repository = PathBuf::from("/home/dev/project");
3067        let destination = PathBuf::from("project");
3068        let existing = ProjectBundle {
3069            primary_repo: "project".into(),
3070            repositories: vec![ProjectRepository {
3071                id: "project".into(),
3072                github: None,
3073                local: Some(repository.clone()),
3074                destination: destination.clone(),
3075                git_ref: None,
3076            }],
3077        };
3078        let mut config = Config::default();
3079        config.bundles.insert("existing".into(), existing);
3080
3081        assert_eq!(
3082            converted_raw_bundle(&config, "remote-project-abcdef", &repository, &destination),
3083            ("existing".to_owned(), None)
3084        );
3085
3086        // A different destination is a different checkout location inside the
3087        // target, so it cannot stand in for this one.
3088        let (id, synthesized) = converted_raw_bundle(
3089            &config,
3090            "remote-project-abcdef",
3091            &repository,
3092            Path::new("elsewhere"),
3093        );
3094        assert_ne!(id, "existing");
3095        assert_eq!(
3096            synthesized.unwrap().repositories[0].destination,
3097            PathBuf::from("elsewhere")
3098        );
3099    }
3100    #[test]
3101    fn a_converted_record_is_a_valid_bundle_session() {
3102        let session_id = "0123456789abcdef0123456789abcdef";
3103        let mut config = resume_compatibility_config();
3104        let mut record = managed_raw_session(ManagedWorktreeTarget::Local);
3105        record.state = SessionState::Running;
3106        record.target_template_id = "podman".into();
3107        let conversion = RawToWorkspaceConversion {
3108            checkout: record.project_directory.clone().unwrap(),
3109            repository: PathBuf::from("/home/dev/project"),
3110            source: fixture_network_source(),
3111            bundle_id: "project".into(),
3112            new_bundle: Some(ProjectBundle {
3113                primary_repo: "project".into(),
3114                repositories: vec![ProjectRepository {
3115                    id: "project".into(),
3116                    github: None,
3117                    local: Some(PathBuf::from("/home/dev/project")),
3118                    destination: PathBuf::from(session_id),
3119                    git_ref: None,
3120                }],
3121            }),
3122            retire: record.managed_worktree.clone(),
3123        };
3124
3125        config.bundles.insert(
3126            conversion.bundle_id.clone(),
3127            conversion.new_bundle.clone().unwrap(),
3128        );
3129        config.profiles.insert(
3130            record.last_profile.clone(),
3131            HarnessProfile {
3132                enabled: true,
3133                kind: record.harness_kind,
3134                home: PathBuf::from("/profiles/codex"),
3135                environment: BTreeMap::new(),
3136                context_window_bytes: None,
3137            },
3138        );
3139        apply_raw_to_workspace(&mut record, &conversion);
3140
3141        assert_eq!(record.project_directory, None);
3142        assert_eq!(record.managed_worktree, None);
3143        assert_eq!(record.bundle_id, "project");
3144        let state = State {
3145            sessions: BTreeMap::from([(session_id.into(), record)]),
3146            ..State::default()
3147        };
3148        state.validate_against_config(&config).unwrap();
3149    }
3150    #[test]
3151    fn a_session_leaving_its_target_claims_a_worktree_of_its_own_repository() {
3152        let repository = committed_repository();
3153        let session_id = "0123456789abcdef0123456789abcdef";
3154        let mut session = checkpoint_test_session(session_id);
3155        session.state = SessionState::Stopped;
3156        session.bundle_id = "project".into();
3157        let mut config = resume_compatibility_config();
3158        config
3159            .bundles
3160            .insert("project".into(), local_bundle(repository.path()));
3161        let controller = Controller {
3162            config,
3163            state: State {
3164                sessions: BTreeMap::from([(session_id.into(), session.clone())]),
3165                ..State::default()
3166            },
3167        };
3168
3169        let conversion = controller
3170            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
3171            .unwrap();
3172
3173        assert_eq!(
3174            conversion.worktree,
3175            ManagedWorktree {
3176                source_project_directory: repository.path().to_path_buf(),
3177                source_repository: repository.path().to_path_buf(),
3178                worktree_root: repository.path().join(".mj/worktrees").join(session_id),
3179                branch: format!("mj/{session_id}"),
3180                target: ManagedWorktreeTarget::Local,
3181                // The new branch starts at the repository's HEAD, which is
3182                // what an export of this session diffs against.
3183                base_commit: Some(test_git(repository.path(), &["rev-parse", "HEAD"])),
3184            }
3185        );
3186
3187        // The dirty primary checkout is beside the point: the worktree's
3188        // contents come from the checkpoint.
3189        std::fs::write(repository.path().join("dirty.txt"), "primary\n").unwrap();
3190        create_managed_worktree(
3191            &ProcessExecutor,
3192            &conversion.worktree,
3193            None,
3194            PrimaryCheckoutRequirement::Any,
3195        )
3196        .unwrap();
3197        assert!(conversion.worktree.worktree_root.is_dir());
3198
3199        // A second attempt refuses rather than taking over a live worktree.
3200        let error = controller
3201            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
3202            .unwrap_err();
3203        assert!(format!("{error:#}").contains("already exists"), "{error:#}");
3204    }
3205    #[test]
3206    fn a_return_to_local_reuses_its_retained_branch_and_preserves_its_tip() {
3207        let repository = committed_repository();
3208        let session_id = "0123456789abcdef0123456789abcdef";
3209        let branch = format!("mj/{session_id}");
3210        let original_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
3211        test_git(repository.path(), &["branch", &branch]);
3212        let mut config = resume_compatibility_config();
3213        config
3214            .bundles
3215            .insert("project".into(), local_bundle(repository.path()));
3216        let session = checkpoint_test_session(session_id);
3217        let controller = Controller {
3218            config,
3219            state: State {
3220                sessions: BTreeMap::from([(session_id.into(), session.clone())]),
3221                ..State::default()
3222            },
3223        };
3224
3225        let conversion = controller
3226            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
3227            .unwrap();
3228
3229        assert!(conversion.reuse_existing_branch);
3230        assert!(!conversion.worktree.worktree_root.exists());
3231        let recovery_ref =
3232            preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
3233                .unwrap();
3234        assert_eq!(
3235            recovery_ref,
3236            format!("refs/mj/recovery/{session_id}/{original_tip}")
3237        );
3238        assert_eq!(
3239            test_git(
3240                repository.path(),
3241                &["show-ref", "--hash", recovery_ref.as_str()],
3242            ),
3243            original_tip
3244        );
3245        assert_eq!(
3246            preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
3247                .unwrap(),
3248            recovery_ref
3249        );
3250
3251        test_git(repository.path(), &["checkout", &branch]);
3252        test_git(
3253            repository.path(),
3254            &["commit", "--allow-empty", "-m", "later retained tip"],
3255        );
3256        let later_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
3257        test_git(repository.path(), &["checkout", "master"]);
3258        let later_recovery_ref =
3259            preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
3260                .unwrap();
3261        assert_eq!(
3262            later_recovery_ref,
3263            format!("refs/mj/recovery/{session_id}/{later_tip}")
3264        );
3265        assert_ne!(later_recovery_ref, recovery_ref);
3266        assert_eq!(
3267            test_git(
3268                repository.path(),
3269                &["show-ref", "--hash", recovery_ref.as_str()],
3270            ),
3271            original_tip
3272        );
3273    }
3274    #[test]
3275    fn a_return_to_local_rejects_a_retained_branch_checked_out_elsewhere() {
3276        let repository = committed_repository();
3277        let session_id = "0123456789abcdef0123456789abcdef";
3278        let branch = format!("mj/{session_id}");
3279        test_git(repository.path(), &["branch", &branch]);
3280        let elsewhere = repository.path().join("other-worktree");
3281        test_git(
3282            repository.path(),
3283            &["worktree", "add", &elsewhere.to_string_lossy(), &branch],
3284        );
3285        let mut config = resume_compatibility_config();
3286        config
3287            .bundles
3288            .insert("project".into(), local_bundle(repository.path()));
3289        let session = checkpoint_test_session(session_id);
3290        let controller = Controller {
3291            config,
3292            state: State {
3293                sessions: BTreeMap::from([(session_id.into(), session.clone())]),
3294                ..State::default()
3295            },
3296        };
3297
3298        let error = controller
3299            .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
3300            .unwrap_err();
3301
3302        assert!(error.to_string().contains("still checked out"), "{error:#}");
3303        assert!(
3304            !repository
3305                .path()
3306                .join(".mj/worktrees")
3307                .join(session_id)
3308                .exists()
3309        );
3310        assert_eq!(
3311            test_git(repository.path(), &["rev-parse", &branch]),
3312            test_git(repository.path(), &["rev-parse", "HEAD"])
3313        );
3314    }
3315    #[test]
3316    fn a_session_that_left_its_target_is_a_valid_raw_session() {
3317        let session_id = "0123456789abcdef0123456789abcdef";
3318        let repository = PathBuf::from("/home/dev/project");
3319        let mut config = resume_compatibility_config();
3320        config
3321            .bundles
3322            .insert("project".into(), local_bundle(&repository));
3323        config.profiles.insert(
3324            "codex".into(),
3325            HarnessProfile {
3326                enabled: true,
3327                kind: mj_core::config::HarnessKind::Codex,
3328                home: PathBuf::from("/profiles/codex"),
3329                environment: BTreeMap::new(),
3330                context_window_bytes: None,
3331            },
3332        );
3333        let mut record = checkpoint_test_session(session_id);
3334        record.bundle_id = "project".into();
3335        record.target_template_id = "local-bare".into();
3336        let conversion = WorkspaceToRawConversion {
3337            worktree: ManagedWorktree {
3338                source_project_directory: repository.clone(),
3339                source_repository: repository.clone(),
3340                worktree_root: repository.join(".mj/worktrees").join(session_id),
3341                branch: format!("mj/{session_id}"),
3342                target: ManagedWorktreeTarget::Local,
3343                base_commit: None,
3344            },
3345            reuse_existing_branch: false,
3346        };
3347
3348        apply_workspace_to_raw(&mut record, &conversion);
3349
3350        assert_eq!(
3351            record.project_directory.as_deref(),
3352            Some(conversion.worktree.worktree_root.as_path())
3353        );
3354        assert_eq!(record.bundle_id, "project", "the bundle still describes it");
3355        let state = State {
3356            sessions: BTreeMap::from([(session_id.into(), record)]),
3357            ..State::default()
3358        };
3359        state.validate_against_config(&config).unwrap();
3360    }
3361    #[test]
3362    fn a_failed_departure_returns_the_session_to_its_bundle() {
3363        let session_id = "0123456789abcdef0123456789abcdef";
3364        let repository = PathBuf::from("/home/dev/project");
3365        let previous = {
3366            let mut record = checkpoint_test_session(session_id);
3367            record.state = SessionState::Stopped;
3368            record.bundle_id = "project".into();
3369            record
3370        };
3371        let mut converted = previous.clone();
3372        converted.state = SessionState::Provisioning;
3373        apply_workspace_to_raw(
3374            &mut converted,
3375            &WorkspaceToRawConversion {
3376                worktree: ManagedWorktree {
3377                    source_project_directory: repository.clone(),
3378                    source_repository: repository.clone(),
3379                    worktree_root: repository.join(".mj/worktrees").join(session_id),
3380                    branch: format!("mj/{session_id}"),
3381                    target: ManagedWorktreeTarget::Local,
3382                    base_commit: None,
3383                },
3384                reuse_existing_branch: false,
3385            },
3386        );
3387
3388        apply_failed_resume_rollback(&mut converted, &previous, "podman is unavailable", None);
3389
3390        assert_eq!(converted.project_directory, None);
3391        assert_eq!(converted.managed_worktree, None);
3392        assert_eq!(converted.bundle_id, "project");
3393    }
3394    #[test]
3395    fn a_failed_conversion_returns_the_session_to_its_checkout() {
3396        let previous = managed_raw_session(ManagedWorktreeTarget::Local);
3397        let mut converted = previous.clone();
3398        converted.state = SessionState::Provisioning;
3399        converted.target_template_id = "podman".into();
3400        apply_raw_to_workspace(
3401            &mut converted,
3402            &RawToWorkspaceConversion {
3403                checkout: previous.project_directory.clone().unwrap(),
3404                repository: PathBuf::from("/home/dev/project"),
3405                source: fixture_network_source(),
3406                bundle_id: "project".into(),
3407                new_bundle: None,
3408                retire: previous.managed_worktree.clone(),
3409            },
3410        );
3411
3412        let mut cleaned = converted.clone();
3413        apply_failed_resume_rollback(&mut cleaned, &previous, "podman is unavailable", None);
3414        assert_eq!(cleaned.project_directory, previous.project_directory);
3415        assert_eq!(cleaned.managed_worktree, previous.managed_worktree);
3416        assert_eq!(cleaned.bundle_id, previous.bundle_id);
3417
3418        // Even when the leftover target could not be removed, the record must
3419        // describe the checkout it still owns.
3420        let mut stranded = converted;
3421        apply_failed_resume_rollback(
3422            &mut stranded,
3423            &previous,
3424            "podman is unavailable",
3425            Some("podman rm failed".into()),
3426        );
3427        assert_eq!(stranded.state, SessionState::Error);
3428        assert_eq!(stranded.project_directory, previous.project_directory);
3429        assert_eq!(stranded.managed_worktree, previous.managed_worktree);
3430        assert_eq!(stranded.bundle_id, previous.bundle_id);
3431    }
3432    #[test]
3433    fn cancelled_new_session_cleanup_removes_managed_worktree_and_branch() {
3434        let repository = committed_repository();
3435        let session_id = "0123456789abcdef0123456789abcdef";
3436        let worktree = ManagedWorktree {
3437            source_project_directory: repository.path().to_path_buf(),
3438            source_repository: repository.path().to_path_buf(),
3439            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
3440            branch: format!("mj/{session_id}"),
3441            target: ManagedWorktreeTarget::Local,
3442            base_commit: None,
3443        };
3444        create_managed_worktree(
3445            &ProcessExecutor,
3446            &worktree,
3447            None,
3448            PrimaryCheckoutRequirement::Clean,
3449        )
3450        .unwrap();
3451
3452        let mut session = checkpoint_test_session(session_id);
3453        session.project_directory = Some(worktree.worktree_root.clone());
3454        session.managed_worktree = Some(worktree.clone());
3455        let controller = Controller {
3456            config: Config::default(),
3457            state: State {
3458                sessions: BTreeMap::from([(session_id.into(), session)]),
3459                ..State::default()
3460            },
3461        };
3462        let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
3463        let executor = CancellableProcessExecutor::new(cancelled);
3464
3465        controller
3466            .cleanup_new_session_worktree_after_failure(session_id, &executor)
3467            .unwrap();
3468
3469        assert!(!worktree.worktree_root.exists());
3470        assert!(!repository.path().join(".mj").exists());
3471        let branch = Command::new("git")
3472            .arg("-C")
3473            .arg(repository.path())
3474            .args([
3475                "show-ref",
3476                "--verify",
3477                &format!("refs/heads/{}", worktree.branch),
3478            ])
3479            .output()
3480            .unwrap();
3481        assert!(!branch.status.success());
3482    }
3483    #[test]
3484    fn managed_raw_worktree_refuses_dirty_primary_and_skips_existing_worktree() {
3485        let repository = committed_repository();
3486        std::fs::write(repository.path().join("dirty.txt"), "dirty\n").unwrap();
3487        let target = ManagedWorktreeTarget::Local;
3488        let inspection = inspect_raw_project(&ProcessExecutor, &target, repository.path()).unwrap();
3489        let session_id = "fedcba9876543210fedcba9876543210";
3490        let managed = ManagedWorktree {
3491            source_project_directory: inspection.source_project_directory,
3492            source_repository: inspection.source_repository,
3493            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
3494            branch: format!("mj/{session_id}"),
3495            target: target.clone(),
3496            base_commit: None,
3497        };
3498        let error = create_managed_worktree(
3499            &ProcessExecutor,
3500            &managed,
3501            None,
3502            PrimaryCheckoutRequirement::Clean,
3503        )
3504        .unwrap_err();
3505        assert!(error.to_string().contains("uncommitted changes"));
3506        assert!(!managed.worktree_root.exists());
3507
3508        std::fs::remove_file(repository.path().join("dirty.txt")).unwrap();
3509        let existing = repository.path().join("existing-worktree");
3510        test_git(
3511            repository.path(),
3512            &[
3513                "worktree",
3514                "add",
3515                "--detach",
3516                &existing.to_string_lossy(),
3517                "HEAD",
3518            ],
3519        );
3520        let linked = inspect_raw_project(&ProcessExecutor, &target, &existing).unwrap();
3521        assert!(!linked.primary_checkout);
3522    }
3523    #[test]
3524    fn managed_worktree_preflight_preserves_colliding_branch_and_directory() {
3525        let repository = committed_repository();
3526        let target = ManagedWorktreeTarget::Local;
3527        let session_id = "abcdef0123456789abcdef0123456789";
3528        let branch = format!("mj/{session_id}");
3529        test_git(repository.path(), &["branch", &branch]);
3530        let worktree = ManagedWorktree {
3531            source_project_directory: repository.path().to_path_buf(),
3532            source_repository: repository.path().to_path_buf(),
3533            worktree_root: repository.path().join(".mj/worktrees").join(session_id),
3534            branch: branch.clone(),
3535            target,
3536            base_commit: None,
3537        };
3538
3539        let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
3540        assert!(error.to_string().contains("branch already exists"));
3541        assert!(
3542            !test_git(
3543                repository.path(),
3544                &["show-ref", "--verify", &format!("refs/heads/{branch}")]
3545            )
3546            .is_empty()
3547        );
3548        std::fs::create_dir_all(&worktree.worktree_root).unwrap();
3549        let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
3550        assert!(error.to_string().contains("path already exists"));
3551        assert!(worktree.worktree_root.is_dir());
3552    }
3553    #[test]
3554    fn managed_worktree_ssh_commands_preserve_hostile_path_boundaries() {
3555        let target = ManagedWorktreeTarget::Ssh {
3556            destination: "builder".into(),
3557            ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
3558        };
3559        let command = managed_git_command(
3560            &target,
3561            Path::new("/srv/project with ' quote"),
3562            ["worktree", "prune"],
3563            "prune test",
3564        );
3565        assert_eq!(command.program, "ssh");
3566        assert_eq!(&command.args[..3], ["-o", "BatchMode=yes", "builder"]);
3567        assert_eq!(
3568            command.args[3],
3569            "'git' '-C' '/srv/project with '\\'' quote' 'worktree' 'prune'"
3570        );
3571    }
3572}