Skip to main content

mj_controller/hel_controller/
worktree.rs

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