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