Skip to main content

mj_controller/controller/
worktree.rs

1//! Managed worktrees and raw-to-workspace project conversion.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use anyhow::{Context, Result, bail, ensure};
7
8use mj_core::config::{Config, ProjectBundle, TargetTemplate};
9use mj_core::local_git::canonical_repository;
10use mj_core::state::{
11    ManagedWorktree, ManagedWorktreeOptions, ManagedWorktreeTarget, ProjectSourceIdentity,
12    SessionRecord,
13};
14
15use crate::targets::{
16    self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
17};
18pub(super) use mj_client::target::managed_worktree_target;
19pub use mj_client::target::{ResumePlan, resume_compatibility};
20
21use super::{BranchDisposition, Controller, execute_checked, now};
22
23impl Controller {
24    /// Inspect in a supervised worker, never on a UI event loop.
25    pub fn managed_worktree_options(
26        &self,
27        target_id: &str,
28        directory: &Path,
29        executor: &impl CommandExecutor,
30    ) -> Result<ManagedWorktreeOptions> {
31        let template = self
32            .config
33            .targets
34            .get(target_id)
35            .with_context(|| format!("unknown target template {target_id:?}"))?;
36        if !mj_core::config::is_bare_project_target(template) {
37            return Ok(ManagedWorktreeOptions::default());
38        }
39        let target = managed_worktree_target(template)?;
40        if matches!(target, ManagedWorktreeTarget::Local)
41            && local_project_repository(directory, executor)?.is_none()
42        {
43            return Ok(ManagedWorktreeOptions::default());
44        }
45        let inspection = inspect_raw_project(executor, &target, directory)?;
46        Ok(ManagedWorktreeOptions {
47            available: true,
48            default_create: inspection.primary_checkout,
49        })
50    }
51
52    /// Resolve first so validation, review, and launch use the same path.
53    pub fn resolve_project_directory(
54        &self,
55        target_id: &str,
56        directory: &Path,
57        executor: &impl CommandExecutor,
58    ) -> Result<PathBuf> {
59        mj_core::path_input::validate_absolute_input(directory)?;
60        let directory = self.resolve_input_path(target_id, directory, executor)?;
61        self.validate_project_directory(target_id, &directory, executor)?;
62        Ok(directory)
63    }
64
65    /// Verify a bare project before leaving the project-directory dialog.
66    pub fn validate_project_directory(
67        &self,
68        target_id: &str,
69        directory: &Path,
70        executor: &impl CommandExecutor,
71    ) -> Result<()> {
72        let target = self
73            .config
74            .targets
75            .get(target_id)
76            .with_context(|| format!("unknown target template {target_id:?}"))?;
77        match target {
78            TargetTemplate::LocalBare => {
79                ensure!(
80                    directory.is_dir(),
81                    "project directory does not exist or is not a directory"
82                );
83                if local_project_repository(directory, executor)?.is_none() {
84                    return Ok(());
85                }
86                let output = executor.execute(
87                    &CommandSpec::new(
88                        "git",
89                        [
90                            "-C",
91                            &directory.to_string_lossy(),
92                            "rev-parse",
93                            "--verify",
94                            "HEAD",
95                        ],
96                    )
97                    .purpose("verify local bare Git project"),
98                )?;
99                ensure!(
100                    output.status == 0
101                        && !String::from_utf8_lossy(&output.stdout).trim().is_empty(),
102                    "project directory has no valid Git HEAD: {}",
103                    String::from_utf8_lossy(&output.stderr).trim()
104                );
105                Ok(())
106            }
107            TargetTemplate::SshBare { ssh, .. } => {
108                targets::validate_bare_project_directory(
109                    &SshTarget::from(ssh),
110                    directory,
111                    executor,
112                )?;
113                mj_core::remote_git::resolve_local_repository(
114                    directory,
115                    &RemoteGitExecutor {
116                        executor,
117                        ssh: SshTarget::from(ssh),
118                    },
119                )?;
120                Ok(())
121            }
122            _ => bail!("project directory validation requires a bare target"),
123        }
124    }
125
126    /// Resolves a session's canonical project without doing process work on a
127    /// UI loop. Raw checkouts use their Git origin when available, then their
128    /// canonical Git root or local directory.
129    pub fn resolve_session_project_source(
130        &self,
131        session_id: &str,
132        executor: &impl CommandExecutor,
133    ) -> Result<ProjectSourceIdentity> {
134        let session = self
135            .state
136            .sessions
137            .get(session_id)
138            .with_context(|| format!("unknown session {session_id}"))?;
139        let Some(directory) = session.project_directory.as_deref() else {
140            return Ok(session.project_source(&self.config));
141        };
142        let (target, origin_directory) = match &session.managed_worktree {
143            // The source repository is the durable owner of a linked
144            // worktree's shared Git configuration and remains available while
145            // a stopped session's checkout is retired.
146            Some(worktree) => (
147                worktree.target.clone(),
148                worktree.source_repository.as_path(),
149            ),
150            None => (
151                managed_worktree_target(
152                    self.config
153                        .targets
154                        .get(&session.target_template_id)
155                        .with_context(|| {
156                            format!(
157                                "session {session_id} target {:?} is no longer configured",
158                                session.target_template_id
159                            )
160                        })?,
161                )?,
162                directory,
163            ),
164        };
165        let output = executor.execute(&managed_git_command(
166            &target,
167            origin_directory,
168            ["config", "--get", "remote.origin.url"],
169            "resolve project Git origin",
170        ))?;
171        match output.status {
172            0 => {
173                let origin =
174                    String::from_utf8(output.stdout).context("project Git origin was not UTF-8")?;
175                if let Some(identity) = ProjectSourceIdentity::git_remote(origin.trim()) {
176                    return Ok(identity);
177                }
178            }
179            // Git uses 1 when no origin is configured.
180            1 => {}
181            status => bail!(
182                "resolve project Git origin failed with status {status}: {}",
183                String::from_utf8_lossy(&output.stderr).trim()
184            ),
185        }
186        let root = resolve_git_root(&target, origin_directory, executor)?
187            .unwrap_or_else(|| origin_directory.to_path_buf());
188        let remote = match &target {
189            ManagedWorktreeTarget::Local => None,
190            ManagedWorktreeTarget::Ssh { destination, .. } => Some(destination.as_str()),
191        };
192        Ok(ProjectSourceIdentity::path(&root, remote))
193    }
194
195    /// Resolve the checkout a bundle session is moving into, and check that it
196    /// is free, before the session record names it.
197    pub(super) fn plan_workspace_to_raw(
198        &self,
199        session: &SessionRecord,
200        target_id: &str,
201        executor: &impl CommandExecutor,
202    ) -> Result<WorkspaceToRawConversion> {
203        let bundle = self
204            .config
205            .bundles
206            .get(&session.bundle_id)
207            .context("session bundle is missing")?;
208        let [repository] = bundle.repositories.as_slice() else {
209            bail!("a checkout holds exactly one repository");
210        };
211        let source = repository
212            .local
213            .as_deref()
214            .context("only a repository already on this machine can become a checkout")?;
215        self.validate_project_directory(target_id, source, executor)
216            .context("this session's repository is unavailable")?;
217        let mut worktree = ManagedWorktree {
218            source_project_directory: source.to_path_buf(),
219            source_repository: source.to_path_buf(),
220            worktree_root: source.join(".mj").join("worktrees").join(&session.id),
221            branch: format!("mj/{}", session.id),
222            target: managed_worktree_target(
223                self.config
224                    .targets
225                    .get(target_id)
226                    .with_context(|| format!("unknown target template {target_id:?}"))?,
227            )?,
228            base_commit: None,
229        };
230        let reuse_existing_branch =
231            retained_managed_worktree_branch_available(executor, &worktree)?;
232        // A fresh branch starts at the repository's HEAD, so that is what an
233        // export diffs against. A retained branch already carries the session's
234        // commits; its own creation point is what its reflog names.
235        if !reuse_existing_branch {
236            worktree.base_commit =
237                Some(read_checkout_position(executor, &worktree.target, source)?.head_commit);
238        }
239        if !reuse_existing_branch {
240            ensure_managed_worktree_available(executor, &worktree)?;
241        }
242        Ok(WorkspaceToRawConversion {
243            worktree,
244            reuse_existing_branch,
245        })
246    }
247
248    pub(super) fn prepare_managed_raw_worktree(
249        &mut self,
250        session_id: &str,
251        executor: &impl CommandExecutor,
252    ) -> Result<bool> {
253        let session = self
254            .state
255            .sessions
256            .get(session_id)
257            .with_context(|| format!("unknown session {session_id}"))?
258            .clone();
259        let Some(selected) = session.project_directory.as_deref() else {
260            return Ok(false);
261        };
262        if session.managed_worktree.is_some() {
263            return Ok(false);
264        }
265        if session.create_managed_worktree == Some(false) {
266            return Ok(false);
267        }
268        let template = self
269            .config
270            .targets
271            .get(&session.target_template_id)
272            .context("raw session target template disappeared during provisioning")?;
273        if matches!(template, TargetTemplate::SshBare { .. }) {
274            self.validate_project_directory(&session.target_template_id, selected, executor)?;
275        }
276        let target = managed_worktree_target(template)?;
277        if matches!(target, ManagedWorktreeTarget::Local)
278            && local_project_repository(selected, executor)?.is_none()
279        {
280            // A requested launch base asks for the same worktree an explicit
281            // request does, so it must fail here rather than launch without
282            // one and silently ignore the base.
283            ensure!(
284                session.create_managed_worktree != Some(true) && session.launch_base.is_none(),
285                "managed worktree creation requires a Git project"
286            );
287            return Ok(false);
288        }
289        let inspection = inspect_raw_project(executor, &target, selected)?;
290        if !inspection.primary_checkout
291            && session.create_managed_worktree != Some(true)
292            && session.launch_base.is_none()
293        {
294            return Ok(false);
295        }
296        let relative_directory = inspection
297            .source_project_directory
298            .strip_prefix(&inspection.source_repository)
299            .context("raw project directory is outside its repository")?
300            .to_path_buf();
301        let worktree_root = inspection
302            .source_repository
303            .join(".mj")
304            .join("worktrees")
305            .join(session_id);
306        // The worktree branch is created from the repository's HEAD, or from
307        // the requested launch base, so record that commit as the session base
308        // rather than rediscovering it later.
309        let base_commit = match session.launch_base.as_deref() {
310            Some(revision) => managed_git_stdout(
311                executor,
312                &target,
313                &inspection.source_repository,
314                [
315                    "rev-parse",
316                    "--verify",
317                    "--end-of-options",
318                    &format!("{revision}^{{commit}}"),
319                ],
320                "resolve the launch base",
321            )?
322            .trim()
323            .to_owned(),
324            None => {
325                read_checkout_position(executor, &target, &inspection.source_repository)?
326                    .head_commit
327            }
328        };
329        let managed = ManagedWorktree {
330            source_project_directory: inspection.source_project_directory,
331            source_repository: inspection.source_repository,
332            worktree_root: worktree_root.clone(),
333            branch: format!("mj/{session_id}"),
334            target,
335            base_commit: Some(base_commit),
336        };
337        ensure_managed_worktree_available(executor, &managed)?;
338        let record = self.state.sessions.get_mut(session_id).unwrap();
339        record.project_directory = Some(worktree_root.join(relative_directory));
340        record.managed_worktree = Some(managed.clone());
341        record.updated_at = now();
342        self.persist_session_state(session_id)?;
343        create_managed_worktree(
344            executor,
345            &managed,
346            inspection.upstream.as_deref(),
347            PrimaryCheckoutRequirement::Clean,
348        )?;
349        Ok(true)
350    }
351
352    fn cleanup_new_session_worktree(
353        &self,
354        session_id: &str,
355        executor: &impl CommandExecutor,
356    ) -> Result<()> {
357        let Some(worktree) = self
358            .state
359            .sessions
360            .get(session_id)
361            .and_then(|session| session.managed_worktree.as_ref())
362        else {
363            return Ok(());
364        };
365        // A session that never started has a branch Mjolnir just created and
366        // nobody has worked on, so the rollback takes the branch too.
367        cleanup_managed_worktree(executor, worktree, BranchDisposition::Delete)
368    }
369
370    pub(super) fn cleanup_new_session_worktree_after_failure(
371        &self,
372        session_id: &str,
373        executor: &impl CommandExecutor,
374    ) -> Result<()> {
375        if executor.cancellation_requested() {
376            let cleanup_executor =
377                CancellableProcessExecutor::with_timeout(Duration::from_secs(15));
378            self.cleanup_new_session_worktree(session_id, &cleanup_executor)
379        } else {
380            self.cleanup_new_session_worktree(session_id, executor)
381        }
382    }
383}
384
385/// Reuse the same Git configuration resolver on a remote bare host.
386struct RemoteGitExecutor<'a, E> {
387    executor: &'a E,
388    ssh: SshTarget,
389}
390
391impl<E: CommandExecutor> CommandExecutor for RemoteGitExecutor<'_, E> {
392    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
393        let mut arguments = vec!["env".to_owned()];
394        arguments.extend(
395            command
396                .env
397                .iter()
398                .map(|(key, value)| format!("{key}={value}")),
399        );
400        arguments.push(command.program.clone());
401        arguments.extend(command.args.clone());
402        self.executor
403            .execute(&crate::targets::ssh_command(&self.ssh, arguments).purpose(&command.purpose))
404    }
405
406    fn cancellation_requested(&self) -> bool {
407        self.executor.cancellation_requested()
408    }
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
412struct RawProjectInspection {
413    source_project_directory: PathBuf,
414    source_repository: PathBuf,
415    primary_checkout: bool,
416    upstream: Option<String>,
417}
418
419fn managed_target_ssh(target: &ManagedWorktreeTarget) -> Option<SshTarget> {
420    match target {
421        ManagedWorktreeTarget::Local => None,
422        ManagedWorktreeTarget::Ssh {
423            destination,
424            ssh_args,
425        } => Some(SshTarget {
426            destination: destination.clone(),
427            ssh_args: ssh_args.clone(),
428        }),
429    }
430}
431
432fn managed_target_command(
433    target: &ManagedWorktreeTarget,
434    program: &str,
435    args: impl IntoIterator<Item = impl AsRef<str>>,
436) -> CommandSpec {
437    let args = args
438        .into_iter()
439        .map(|arg| arg.as_ref().to_owned())
440        .collect::<Vec<_>>();
441    match managed_target_ssh(target) {
442        None => CommandSpec::new(program, args),
443        Some(ssh) => {
444            let mut remote = vec![program.to_owned()];
445            remote.extend(args);
446            crate::targets::ssh_command(&ssh, remote)
447        }
448    }
449}
450
451fn managed_git_command(
452    target: &ManagedWorktreeTarget,
453    directory: &Path,
454    args: impl IntoIterator<Item = impl AsRef<str>>,
455    purpose: impl Into<String>,
456) -> CommandSpec {
457    let mut command_args = vec!["-C".to_owned(), directory.to_string_lossy().into_owned()];
458    command_args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
459    managed_target_command(target, "git", command_args).purpose(purpose)
460}
461
462fn command_stdout(output: CommandOutput, purpose: &str) -> Result<String> {
463    if output.status != 0 {
464        bail!(
465            "{purpose} failed with status {}: {}",
466            output.status,
467            String::from_utf8_lossy(&output.stderr).trim()
468        );
469    }
470    let stdout = String::from_utf8(output.stdout)
471        .with_context(|| format!("{purpose} produced non-UTF-8 output"))?;
472    Ok(stdout.trim_end_matches(['\r', '\n']).to_owned())
473}
474
475fn managed_git_stdout(
476    executor: &impl CommandExecutor,
477    target: &ManagedWorktreeTarget,
478    directory: &Path,
479    args: impl IntoIterator<Item = impl AsRef<str>>,
480    purpose: &str,
481) -> Result<String> {
482    let command = managed_git_command(target, directory, args, purpose);
483    command_stdout(executor.execute(&command)?, purpose)
484}
485
486/// Resolve a checkout's stable repository root, collapsing linked worktrees
487/// onto the main worktree when Git exposes the shared `.git` directory.
488fn resolve_git_root(
489    target: &ManagedWorktreeTarget,
490    directory: &Path,
491    executor: &impl CommandExecutor,
492) -> Result<Option<PathBuf>> {
493    // The expected non-repository diagnostic must be stable across locales;
494    // every other Git failure remains an error.
495    let args = [
496        "-C".to_owned(),
497        directory.to_string_lossy().into_owned(),
498        "rev-parse".into(),
499        "--path-format=absolute".into(),
500        "--show-toplevel".into(),
501    ];
502    let top_level = match target {
503        ManagedWorktreeTarget::Local => {
504            let mut command = CommandSpec::new("git", args);
505            command.env.insert("LC_ALL".into(), "C".into());
506            command
507        }
508        ManagedWorktreeTarget::Ssh { .. } => managed_target_command(
509            target,
510            "env",
511            ["LC_ALL=C".to_owned(), "git".into()]
512                .into_iter()
513                .chain(args),
514        ),
515    }
516    .purpose("resolve project Git root");
517    let output = executor.execute(&top_level)?;
518    if output.status != 0 {
519        if output.status == 128
520            && String::from_utf8_lossy(&output.stderr).starts_with("fatal: not a git repository")
521        {
522            return Ok(None);
523        }
524        bail!(
525            "resolve project Git root failed with status {}: {}",
526            output.status,
527            String::from_utf8_lossy(&output.stderr).trim()
528        );
529    }
530    let root = PathBuf::from(
531        String::from_utf8(output.stdout)
532            .context("project Git root was not UTF-8")?
533            .trim_end_matches(['\r', '\n']),
534    );
535    if root.as_os_str().is_empty() {
536        bail!("resolve project Git root returned an empty path");
537    }
538
539    let common = PathBuf::from(managed_git_stdout(
540        executor,
541        target,
542        directory,
543        ["rev-parse", "--path-format=absolute", "--git-common-dir"],
544        "resolve project Git common directory",
545    )?);
546    if common.file_name() == Some(std::ffi::OsStr::new(".git"))
547        && let Some(main_root) = common.parent()
548    {
549        return Ok(Some(main_root.to_path_buf()));
550    }
551    Ok(Some(root))
552}
553
554/// Inspect a local launch directory using the same Git error handling and
555/// linked-worktree identity as existing sessions.
556pub fn local_project_repository(
557    directory: &Path,
558    executor: &impl CommandExecutor,
559) -> Result<Option<PathBuf>> {
560    resolve_git_root(&ManagedWorktreeTarget::Local, directory, executor)
561}
562
563/// Which checkout each still-empty target repository is seeded from, or `None`
564/// when this connect must not seed at all. A converting resume carries the
565/// session's own checkout; every other seed comes from the bundle's local path.
566/// Reshape a raw session's record for the workspace target it is moving into.
567pub(super) fn apply_raw_to_workspace(
568    record: &mut SessionRecord,
569    conversion: &RawToWorkspaceConversion,
570) {
571    record.project_directory = None;
572    record.managed_worktree = None;
573    record.bundle_id.clone_from(&conversion.bundle_id);
574}
575
576/// A resume that changes how a session is represented, resolved before the
577/// session record or the configuration changes.
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub(super) enum ResumeConversion {
580    RawToWorkspace(RawToWorkspaceConversion),
581    WorkspaceToRaw(WorkspaceToRawConversion),
582}
583
584impl ResumeConversion {
585    pub(super) fn raw_to_workspace(&self) -> Option<&RawToWorkspaceConversion> {
586        match self {
587            Self::RawToWorkspace(conversion) => Some(conversion),
588            Self::WorkspaceToRaw(_) => None,
589        }
590    }
591
592    pub(super) fn workspace_to_raw(&self) -> Option<&WorkspaceToRawConversion> {
593        match self {
594            Self::WorkspaceToRaw(conversion) => Some(conversion),
595            Self::RawToWorkspace(_) => None,
596        }
597    }
598}
599
600/// Everything a workspace-to-raw resume needs. The worktree does not exist yet:
601/// the record names it first, so a failure cleans it up through the same path
602/// as a new raw session's.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub(super) struct WorkspaceToRawConversion {
605    pub(super) worktree: ManagedWorktree,
606    /// The first move retires this session's checkout but deliberately keeps
607    /// its `mj/<session>` branch for source recovery. Reattach that branch on
608    /// the return move instead of trying to create it a second time.
609    pub(super) reuse_existing_branch: bool,
610}
611
612/// Reshape a bundle session's record for the checkout it is moving into. The
613/// bundle stays: it still describes the repository the checkout came from.
614pub(super) fn apply_workspace_to_raw(
615    record: &mut SessionRecord,
616    conversion: &WorkspaceToRawConversion,
617) {
618    record.project_directory = Some(conversion.worktree.worktree_root.clone());
619    record.managed_worktree = Some(conversion.worktree.clone());
620}
621
622/// Everything a raw-to-workspace resume needs, resolved before the session
623/// record or the configuration changes.
624#[derive(Debug, Clone, PartialEq, Eq)]
625pub(super) struct RawToWorkspaceConversion {
626    /// The checkout whose branch, head commit, and dirty state move into the
627    /// target. For a managed session this is the session's own worktree, not
628    /// the user's primary checkout.
629    pub(super) checkout: PathBuf,
630    /// The source repository represented by the bundle's local path.
631    pub(super) repository: PathBuf,
632    /// Where the converted workspace fetches from and pushes to. An isolated
633    /// workspace always clones from a network remote, so the checkout's own
634    /// remote becomes the converted session's provenance.
635    pub(super) source: mj_core::remote_git::NetworkGitSource,
636    pub(super) bundle_id: String,
637    /// Set when the configuration does not already describe this checkout.
638    pub(super) new_bundle: Option<ProjectBundle>,
639    /// Removed once the target holds the checkout, and only then.
640    pub(super) retire: Option<ManagedWorktree>,
641}
642
643/// Resolve where a raw session's checkout lives and which bundle will stand in
644/// for it. Reads Git; changes nothing.
645pub(super) fn plan_raw_to_workspace(
646    session: &SessionRecord,
647    config: &Config,
648    executor: &impl CommandExecutor,
649) -> Result<RawToWorkspaceConversion> {
650    let project_directory = session
651        .project_directory
652        .as_deref()
653        .context("a raw session has no project directory")?;
654    // The checkpoint describes the session's directory as if it were the
655    // repository root, so only a whole checkout can move. Each branch checks
656    // this against paths from one domain: the record's own paths for a managed
657    // worktree, Git's canonical paths for an inspected checkout — the record
658    // may reach the same checkout through a symlink (macOS temp directories).
659    let (checkout, repository, retire) = match &session.managed_worktree {
660        Some(worktree) => {
661            ensure!(
662                worktree.worktree_root == project_directory,
663                "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
664                project_directory.display()
665            );
666            (
667                worktree.worktree_root.clone(),
668                worktree.source_repository.clone(),
669                Some(worktree.clone()),
670            )
671        }
672        None => {
673            let inspection =
674                inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
675            ensure!(
676                inspection.source_project_directory == inspection.source_repository,
677                "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
678                project_directory.display()
679            );
680            let repository = canonical_repository(&inspection.source_repository)?;
681            (inspection.source_repository, repository, None)
682        }
683    };
684    // The archive names the session's directory as the repository destination,
685    // and the restored harness session points at that path inside the target.
686    // The bundle has to put the checkout in the same place.
687    let destination = PathBuf::from(
688        project_directory
689            .file_name()
690            .context("a raw project directory cannot be the filesystem root")?,
691    );
692    let (bundle_id, new_bundle) =
693        converted_raw_bundle(config, &session.bundle_id, &repository, &destination);
694    // An isolated workspace is always a fresh network clone, so a checkout
695    // with no network remote cannot become one. Resolve it here, while nothing
696    // has changed yet, and say what to do about it.
697    let source = mj_core::remote_git::resolve_local_repository(&checkout, executor).with_context(
698        || {
699            format!(
700                "{} has no network Git remote; add one (for example `git remote add origin <url>`) or resume this session on a bare target",
701                checkout.display()
702            )
703        },
704    )?;
705    Ok(RawToWorkspaceConversion {
706        checkout,
707        repository,
708        source,
709        bundle_id,
710        new_bundle,
711        retire,
712    })
713}
714
715/// The bundle a converted raw session references: one the configuration already
716/// has for exactly this checkout, or a new one for the caller to install.
717/// Reusing a match keeps a retried conversion from piling up bundles.
718fn converted_raw_bundle(
719    config: &Config,
720    session_bundle_id: &str,
721    repository: &Path,
722    destination: &Path,
723) -> (String, Option<ProjectBundle>) {
724    let describes_checkout = |bundle: &ProjectBundle| {
725        bundle.repositories.len() == 1
726            && bundle.repositories[0].github.is_none()
727            && bundle.repositories[0].local.as_deref() == Some(repository)
728            && bundle.repositories[0].destination == destination
729    };
730    if config
731        .bundles
732        .get(session_bundle_id)
733        .is_some_and(describes_checkout)
734    {
735        return (session_bundle_id.to_owned(), None);
736    }
737    if let Some((id, _)) = config
738        .bundles
739        .iter()
740        .find(|(_, bundle)| describes_checkout(bundle))
741    {
742        return (id.clone(), None);
743    }
744    let name = repository
745        .file_name()
746        .map(|name| name.to_string_lossy().into_owned())
747        .unwrap_or_default();
748    let id = crate::import::unique_bundle_id(config, &crate::import::setup_style_id(&name));
749    let bundle = ProjectBundle {
750        primary_repo: id.clone(),
751        repositories: vec![mj_core::config::ProjectRepository {
752            id: id.clone(),
753            github: None,
754            local: Some(repository.to_path_buf()),
755            destination: destination.to_path_buf(),
756            git_ref: None,
757        }],
758    };
759    (id, Some(bundle))
760}
761
762/// The repository id a converted raw session's archive uses. A raw checkpoint
763/// has always described the session's directory as one repository.
764const RAW_CONVERSION_REPOSITORY_ID: &str = "project";
765
766/// Snapshot the host checkout as the repository content an isolated workspace
767/// arrives with: commits that are on no origin ref, plus staged, unstaged, and
768/// untracked work.
769///
770/// The metadata carries the checkout's own network remote, so the container
771/// clones real provenance and its later checkpoints behave like any other
772/// workspace session's.
773pub(super) fn raw_checkout_snapshot(
774    checkout: &Path,
775    source: &mj_core::remote_git::NetworkGitSource,
776    destination: &Path,
777    git: &dyn mj_checkpoint::archive::GitCommandRunner,
778) -> Result<mj_checkpoint::archive::RepositorySnapshot> {
779    // Bundling "everything not on origin" only works when origin refs exist:
780    // every bundle prerequisite then sits on the remote the container clones.
781    mj_checkpoint::checkpoint::repair_origin_refs(git, checkout, RAW_CONVERSION_REPOSITORY_ID)?;
782    mj_checkpoint::checkpoint::reject_dirty_submodules(git, checkout)
783        .with_context(|| format!("checkout {}", checkout.display()))?;
784    let mut snapshot = mj_checkpoint::archive::collect_git_snapshot(
785        git,
786        checkout,
787        &mj_checkpoint::archive::GitCollectionSpec {
788            id: RAW_CONVERSION_REPOSITORY_ID.to_owned(),
789            relative_destination: destination.to_path_buf(),
790            history: mj_checkpoint::archive::GitHistoryMode::SessionDelta,
791            origin_override: None,
792        },
793    )
794    .with_context(|| format!("snapshot the checkout at {}", checkout.display()))?;
795    // The resolved remote, not whatever `origin` happens to be: the checkout's
796    // branch may track another remote. Credentials stay out of the archive.
797    snapshot.metadata.origin =
798        mj_checkpoint::archive::redact_origin_credentials(&source.fetch_url)?;
799    snapshot.metadata.push_urls = source
800        .push_urls
801        .iter()
802        .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
803        .collect::<Result<Vec<_>>>()?;
804    snapshot.metadata.remote_workspace = true;
805    snapshot.metadata.base_commit = origin_boundary_commit(git, checkout)?
806        .unwrap_or_else(|| snapshot.metadata.head_commit.clone());
807    Ok(snapshot)
808}
809
810/// The newest commit the checkout shares with `origin`, which is where a
811/// converted workspace measures its own session delta from. `None` when HEAD
812/// is already on an origin ref, leaving no boundary to report.
813fn origin_boundary_commit(
814    git: &dyn mj_checkpoint::archive::GitCommandRunner,
815    checkout: &Path,
816) -> Result<Option<String>> {
817    let listed = git_runner_stdout(
818        git,
819        checkout,
820        [
821            "rev-list",
822            "--boundary",
823            "HEAD",
824            "--not",
825            "--remotes=origin",
826        ],
827        "list commits outside origin",
828    )?;
829    // `--boundary` marks the excluded parents of the listed commits with `-`,
830    // and lists them after the commits themselves.
831    Ok(listed
832        .lines()
833        .filter_map(|line| line.strip_prefix('-'))
834        .map(|commit| commit.trim().to_owned())
835        .find(|commit| !commit.is_empty()))
836}
837
838fn git_runner_stdout(
839    git: &dyn mj_checkpoint::archive::GitCommandRunner,
840    repository: &Path,
841    args: impl IntoIterator<Item = impl AsRef<str>>,
842    purpose: &str,
843) -> Result<String> {
844    let output = git.run(
845        repository,
846        &mj_checkpoint::archive::GitCommand {
847            arguments: args
848                .into_iter()
849                .map(|argument| std::ffi::OsString::from(argument.as_ref()))
850                .collect(),
851            stdin: Vec::new(),
852            env: Vec::new(),
853        },
854    )?;
855    command_stdout(
856        CommandOutput {
857            status: output.status,
858            stdout: output.stdout,
859            stderr: output.stderr,
860        },
861        purpose,
862    )
863}
864
865/// Describe a raw-to-workspace conversion for a person to confirm. Reads Git
866/// and asks the remote for its default branch; changes nothing.
867pub(super) fn raw_conversion_preview(
868    session: &SessionRecord,
869    conversion: &RawToWorkspaceConversion,
870    executor: &impl CommandExecutor,
871) -> Result<mj_core::state::RawConversionPreview> {
872    let checkout = conversion.checkout.as_path();
873    // A dirty submodule cannot be captured, so say so now rather than failing
874    // after the session has been stopped.
875    reject_dirty_submodules_in_checkout(executor, checkout)?;
876    let default_branch = mj_core::remote_git::default_branch(&conversion.source, executor)?;
877    let position = read_checkout_position(executor, &ManagedWorktreeTarget::Local, checkout)?;
878    let unpushed_commits = unpushed_commit_count(executor, checkout)?;
879    let dirty = dirty_file_counts(executor, checkout)?;
880    // The archive names the session's own directory, which is where the
881    // restored harness session looks for its files inside the target.
882    let directory = session
883        .project_directory
884        .as_deref()
885        .context("a raw session has no project directory")?
886        .file_name()
887        .context("a raw project directory cannot be the filesystem root")?;
888    // A raw session has no container, so the move builds it one and the
889    // checkout lands in the per-session workspace this preview names. A session
890    // that predates per-session workspaces and still records none keeps the
891    // shared one only if it already has a container, which a raw session never
892    // does.
893    let container_workspace = match session.container_workspace.clone() {
894        Some(workspace) => workspace,
895        None => mj_core::targets::new_container_workspace(&session.id)?,
896    };
897    Ok(mj_core::state::RawConversionPreview {
898        checkout: checkout.to_path_buf(),
899        destination: container_workspace.join(directory),
900        branch: position.branch,
901        fetch_url: conversion.source.fetch_url.clone(),
902        push_urls: conversion.source.push_urls.clone(),
903        default_branch,
904        unpushed_commits,
905        staged_files: dirty.staged_files,
906        unstaged_files: dirty.unstaged_files,
907        untracked_files: dirty.untracked_files,
908        untracked_bytes: untracked_bytes(executor, checkout)?,
909        host_checkout_retained: conversion.retire.is_none(),
910    })
911}
912
913fn reject_dirty_submodules_in_checkout(
914    executor: &impl CommandExecutor,
915    checkout: &Path,
916) -> Result<()> {
917    let listed = managed_git_stdout(
918        executor,
919        &ManagedWorktreeTarget::Local,
920        checkout,
921        [
922            "submodule",
923            "foreach",
924            "--recursive",
925            "--quiet",
926            "git status --porcelain",
927        ],
928        "inspect submodules",
929    )?;
930    ensure!(
931        listed.trim().is_empty(),
932        "{} has a dirty submodule, which cannot move into a target; commit or discard the submodule's changes first",
933        checkout.display()
934    );
935    Ok(())
936}
937
938/// Commits the conversion archive has to carry. A checkout whose origin refs
939/// are missing even after a repair fetch reports nothing rather than counting
940/// its entire history as unpushed.
941fn unpushed_commit_count(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
942    if !origin_refs_available(executor, checkout)? {
943        return Ok(0);
944    }
945    let counted = managed_git_stdout(
946        executor,
947        &ManagedWorktreeTarget::Local,
948        checkout,
949        ["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
950        "count commits outside origin",
951    )?;
952    counted
953        .trim()
954        .parse()
955        .with_context(|| format!("parse the commit count {counted:?}"))
956}
957
958fn origin_refs_available(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
959    if origin_refs_listed(executor, checkout)? {
960        return Ok(true);
961    }
962    // A checkout that has never fetched has no origin refs yet. Try once; a
963    // remote that cannot be reached leaves the count unreported, not failed.
964    let fetch = managed_git_command(
965        &ManagedWorktreeTarget::Local,
966        checkout,
967        ["fetch", "origin"],
968        "fetch origin refs",
969    );
970    executor.execute(&fetch)?;
971    origin_refs_listed(executor, checkout)
972}
973
974fn origin_refs_listed(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
975    managed_git_stdout(
976        executor,
977        &ManagedWorktreeTarget::Local,
978        checkout,
979        [
980            "for-each-ref",
981            "--format=%(objectname)",
982            "refs/remotes/origin",
983        ],
984        "list origin refs",
985    )
986    .map(|refs| !refs.trim().is_empty())
987}
988
989#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
990struct DirtyFileCounts {
991    staged_files: u64,
992    unstaged_files: u64,
993    untracked_files: u64,
994}
995
996/// Count what `git status` reports, one entry per path. A rename's second
997/// record names the original path, so it is consumed rather than counted.
998fn dirty_file_counts(executor: &impl CommandExecutor, checkout: &Path) -> Result<DirtyFileCounts> {
999    let command = managed_git_command(
1000        &ManagedWorktreeTarget::Local,
1001        checkout,
1002        ["status", "--porcelain=v1", "-z"],
1003        "read checkout status",
1004    );
1005    let output = executor.execute(&command)?;
1006    ensure!(
1007        output.status == 0,
1008        "read checkout status failed with status {}: {}",
1009        output.status,
1010        String::from_utf8_lossy(&output.stderr).trim()
1011    );
1012    let mut counts = DirtyFileCounts::default();
1013    let mut records = output
1014        .stdout
1015        .split(|byte| *byte == 0)
1016        .filter(|record| !record.is_empty());
1017    while let Some(record) = records.next() {
1018        let [index, worktree, ..] = record else {
1019            bail!("git status produced a record shorter than its status field");
1020        };
1021        if *index == b'?' && *worktree == b'?' {
1022            counts.untracked_files += 1;
1023            continue;
1024        }
1025        if !matches!(index, b' ' | b'?') {
1026            counts.staged_files += 1;
1027        }
1028        if !matches!(worktree, b' ' | b'?') {
1029            counts.unstaged_files += 1;
1030        }
1031        if *index == b'R' || *index == b'C' || *worktree == b'R' || *worktree == b'C' {
1032            records.next();
1033        }
1034    }
1035    Ok(counts)
1036}
1037
1038/// How much untracked content the conversion archive has to carry. `git status`
1039/// collapses an untracked directory into one entry, so the bytes come from the
1040/// file list instead.
1041fn untracked_bytes(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1042    let command = managed_git_command(
1043        &ManagedWorktreeTarget::Local,
1044        checkout,
1045        ["ls-files", "--others", "--exclude-standard", "-z"],
1046        "list untracked files",
1047    );
1048    let output = executor.execute(&command)?;
1049    ensure!(
1050        output.status == 0,
1051        "list untracked files failed with status {}: {}",
1052        output.status,
1053        String::from_utf8_lossy(&output.stderr).trim()
1054    );
1055    let mut total = 0;
1056    for record in output
1057        .stdout
1058        .split(|byte| *byte == 0)
1059        .filter(|record| !record.is_empty())
1060    {
1061        let relative = mj_core::path_input::from_git_bytes(record)?;
1062        let path = checkout.join(relative);
1063        // Do not follow links, and tolerate a file the agent removed between
1064        // the listing and this read.
1065        match std::fs::symlink_metadata(&path) {
1066            Ok(metadata) => total += metadata.len(),
1067            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1068            Err(error) => {
1069                return Err(error).with_context(|| format!("measure {}", path.display()));
1070            }
1071        }
1072    }
1073    Ok(total)
1074}
1075
1076/// Where a checkout stands: its head commit and, unless detached, its branch.
1077#[derive(Debug, Clone, PartialEq, Eq)]
1078pub(super) struct CheckoutPosition {
1079    pub(super) head_commit: String,
1080    branch: Option<String>,
1081}
1082
1083fn read_checkout_position(
1084    executor: &impl CommandExecutor,
1085    target: &ManagedWorktreeTarget,
1086    directory: &Path,
1087) -> Result<CheckoutPosition> {
1088    let head_commit = managed_git_stdout(
1089        executor,
1090        target,
1091        directory,
1092        ["rev-parse", "HEAD"],
1093        "resolve checkout head commit",
1094    )?;
1095    let branch_command = managed_git_command(
1096        target,
1097        directory,
1098        ["symbolic-ref", "--quiet", "--short", "HEAD"],
1099        "resolve checkout branch",
1100    );
1101    let branch_output = executor.execute(&branch_command)?;
1102    let branch = match branch_output.status {
1103        0 => Some(
1104            String::from_utf8(branch_output.stdout)
1105                .context("checkout branch was not UTF-8")?
1106                .trim()
1107                .to_owned(),
1108        ),
1109        // A detached head reports no branch rather than failing.
1110        1 | 128 => None,
1111        status => bail!(
1112            "resolve checkout branch failed with status {status}: {}",
1113            String::from_utf8_lossy(&branch_output.stderr).trim()
1114        ),
1115    };
1116    Ok(CheckoutPosition {
1117        head_commit,
1118        branch,
1119    })
1120}
1121
1122/// The commit the session branch was created at, as the base for diffs and
1123/// checkpoint bundles. Prefers the recorded base; sessions created before it
1124/// was recorded fall back to the branch reflog, like `branch_creation_commit`
1125/// in mj-checkpoint. A reflog that has expired leaves only the live head,
1126/// which yields an empty bundle rather than a failed checkpoint.
1127pub(super) fn managed_worktree_base_commit(
1128    worktree: &ManagedWorktree,
1129    executor: &impl CommandExecutor,
1130) -> Result<String> {
1131    if let Some(base) = &worktree.base_commit {
1132        return Ok(base.clone());
1133    }
1134    let reference = format!("refs/heads/{}", worktree.branch);
1135    let reflog_command = managed_git_command(
1136        &worktree.target,
1137        &worktree.source_repository,
1138        ["reflog", "show", "--format=%H", &reference],
1139        "read the session branch reflog",
1140    );
1141    let reflog_output = executor.execute(&reflog_command)?;
1142    if reflog_output.status == 0 {
1143        let text = String::from_utf8(reflog_output.stdout)
1144            .context("the session branch reflog was not UTF-8")?;
1145        // The oldest entry is the branch's creation, so it is where the session
1146        // started.
1147        if let Some(creation) = text.lines().rfind(|line| !line.trim().is_empty()) {
1148            return Ok(creation.trim().to_owned());
1149        }
1150    }
1151    let head = read_checkout_position(executor, &worktree.target, &worktree.worktree_root)?;
1152    tracing::warn!(
1153        branch = %worktree.branch,
1154        "the reflog for this session branch is gone, so its checkpoint bundle will carry no commits"
1155    );
1156    Ok(head.head_commit)
1157}
1158
1159/// Read where a raw session's checkout stands right now, on whichever host
1160/// owns it.
1161pub(super) fn raw_checkout_position(
1162    session: &SessionRecord,
1163    config: &Config,
1164    project_directory: &Path,
1165    executor: &impl CommandExecutor,
1166) -> Result<CheckoutPosition> {
1167    let target = match &session.managed_worktree {
1168        Some(worktree) => worktree.target.clone(),
1169        None => {
1170            let template = config
1171                .targets
1172                .get(&session.target_template_id)
1173                .context("the bare target this session last used is missing")?;
1174            managed_worktree_target(template)?
1175        }
1176    };
1177    read_checkout_position(executor, &target, project_directory)
1178}
1179
1180/// One conversation line for a raw session whose checkout moved on while the
1181/// session was stopped. `None` when the checkout is where the checkpoint left
1182/// it, or when the checkpoint recorded no repository to compare against.
1183///
1184/// This reports; it never reconciles. The working tree is the truth.
1185pub(super) fn raw_checkout_divergence_notice(
1186    directory: &Path,
1187    recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
1188    live: &CheckoutPosition,
1189) -> Option<String> {
1190    let recorded = recorded?;
1191    if recorded.head_commit.is_empty()
1192        || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
1193    {
1194        return None;
1195    }
1196    Some(format!(
1197        "The working tree at {} moved from {} to {} while this session was stopped.",
1198        directory.display(),
1199        checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
1200        checkout_position_text(&live.head_commit, live.branch.as_deref()),
1201    ))
1202}
1203
1204fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
1205    let short = head_commit.get(..12).unwrap_or(head_commit);
1206    match branch {
1207        Some(branch) => format!("{short} ({branch})"),
1208        None => format!("{short} (detached)"),
1209    }
1210}
1211
1212fn inspect_raw_project(
1213    executor: &impl CommandExecutor,
1214    target: &ManagedWorktreeTarget,
1215    selected: &Path,
1216) -> Result<RawProjectInspection> {
1217    let repository = PathBuf::from(managed_git_stdout(
1218        executor,
1219        target,
1220        selected,
1221        ["rev-parse", "--path-format=absolute", "--show-toplevel"],
1222        "resolve raw project repository root",
1223    )?);
1224    let prefix = managed_git_stdout(
1225        executor,
1226        target,
1227        selected,
1228        ["rev-parse", "--show-prefix"],
1229        "resolve raw project relative directory",
1230    )?;
1231    let git_dir = PathBuf::from(managed_git_stdout(
1232        executor,
1233        target,
1234        selected,
1235        ["rev-parse", "--absolute-git-dir"],
1236        "resolve raw project Git directory",
1237    )?);
1238    let common_git_dir = PathBuf::from(managed_git_stdout(
1239        executor,
1240        target,
1241        selected,
1242        ["rev-parse", "--path-format=absolute", "--git-common-dir"],
1243        "resolve raw project common Git directory",
1244    )?);
1245    let branch_command = managed_git_command(
1246        target,
1247        selected,
1248        ["symbolic-ref", "--quiet", "--short", "HEAD"],
1249        "resolve raw project branch",
1250    );
1251    let branch_output = executor.execute(&branch_command)?;
1252    let branch = match branch_output.status {
1253        0 => Some(
1254            String::from_utf8(branch_output.stdout)
1255                .context("raw project branch was not UTF-8")?
1256                .trim()
1257                .to_owned(),
1258        ),
1259        1 | 128 => None,
1260        status => bail!(
1261            "resolve raw project branch failed with status {status}: {}",
1262            String::from_utf8_lossy(&branch_output.stderr).trim()
1263        ),
1264    };
1265    let upstream = match branch {
1266        Some(branch) => {
1267            let reference = format!("refs/heads/{branch}");
1268            let upstream = managed_git_stdout(
1269                executor,
1270                target,
1271                selected,
1272                ["for-each-ref", "--format=%(upstream:short)", &reference],
1273                "resolve raw project upstream",
1274            )?;
1275            (!upstream.is_empty()).then_some(upstream)
1276        }
1277        None => None,
1278    };
1279    Ok(RawProjectInspection {
1280        source_project_directory: repository.join(prefix),
1281        source_repository: repository,
1282        primary_checkout: git_dir == common_git_dir,
1283        upstream,
1284    })
1285}
1286
1287fn ensure_managed_worktree_excluded(
1288    executor: &impl CommandExecutor,
1289    target: &ManagedWorktreeTarget,
1290    repository: &Path,
1291) -> Result<()> {
1292    let check = managed_git_command(
1293        target,
1294        repository,
1295        [
1296            "check-ignore",
1297            "--quiet",
1298            "--no-index",
1299            "--",
1300            ".mj/worktrees/",
1301        ],
1302        "check managed worktree exclusion",
1303    );
1304    let output = executor.execute(&check)?;
1305    match output.status {
1306        0 => return Ok(()),
1307        1 => {}
1308        status => bail!(
1309            "check managed worktree exclusion failed with status {status}: {}",
1310            String::from_utf8_lossy(&output.stderr).trim()
1311        ),
1312    }
1313    let exclude_path = PathBuf::from(managed_git_stdout(
1314        executor,
1315        target,
1316        repository,
1317        [
1318            "rev-parse",
1319            "--path-format=absolute",
1320            "--git-path",
1321            "info/exclude",
1322        ],
1323        "resolve repository-local exclude file",
1324    )?);
1325    const ENTRY: &str = "/.mj/worktrees/";
1326    match target {
1327        ManagedWorktreeTarget::Local => {
1328            use std::io::Write;
1329            let existing = match std::fs::read_to_string(&exclude_path) {
1330                Ok(existing) => existing,
1331                Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1332                Err(error) => return Err(error.into()),
1333            };
1334            if existing.lines().any(|line| line.trim() == ENTRY) {
1335                return Ok(());
1336            }
1337            if let Some(parent) = exclude_path.parent() {
1338                std::fs::create_dir_all(parent)?;
1339            }
1340            let mut file = std::fs::OpenOptions::new()
1341                .create(true)
1342                .append(true)
1343                .open(&exclude_path)
1344                .with_context(|| format!("open {}", exclude_path.display()))?;
1345            if !existing.is_empty() && !existing.ends_with('\n') {
1346                writeln!(file)?;
1347            }
1348            writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
1349        }
1350        ManagedWorktreeTarget::Ssh { .. } => {
1351            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";
1352            let command = managed_target_command(
1353                target,
1354                "sh",
1355                [
1356                    "-c",
1357                    SCRIPT,
1358                    "hel-exclude",
1359                    &exclude_path.to_string_lossy(),
1360                    ENTRY,
1361                ],
1362            )
1363            .purpose("update remote repository-local exclude file");
1364            execute_checked(executor, command)?;
1365        }
1366    }
1367    Ok(())
1368}
1369
1370pub(crate) fn path_exists_on_managed_target(
1371    executor: &impl CommandExecutor,
1372    target: &ManagedWorktreeTarget,
1373    path: &Path,
1374) -> Result<bool> {
1375    match target {
1376        ManagedWorktreeTarget::Local => path
1377            .try_exists()
1378            .with_context(|| format!("check managed project path {}", path.display())),
1379        ManagedWorktreeTarget::Ssh { .. } => {
1380            let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
1381                .purpose("check managed worktree path");
1382            let output = executor.execute(&command)?;
1383            match output.status {
1384                0 => Ok(true),
1385                1 => Ok(false),
1386                status => bail!(
1387                    "check managed worktree path failed with status {status}: {}",
1388                    String::from_utf8_lossy(&output.stderr).trim()
1389                ),
1390            }
1391        }
1392    }
1393}
1394
1395pub(super) fn managed_worktree_checkout_exists(
1396    executor: &impl CommandExecutor,
1397    worktree: &ManagedWorktree,
1398) -> Result<bool> {
1399    path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1400}
1401
1402/// Whether a managed worktree's checkout holds work that removing it would
1403/// destroy. A checkout that is already gone holds nothing.
1404///
1405/// This asks the session's own worktree the porcelain question
1406/// [`create_managed_worktree`] asks of the primary checkout.
1407pub(super) fn managed_worktree_checkout_is_dirty(
1408    executor: &impl CommandExecutor,
1409    worktree: &ManagedWorktree,
1410) -> Result<bool> {
1411    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1412        return Ok(false);
1413    }
1414    let status = managed_git_stdout(
1415        executor,
1416        &worktree.target,
1417        &worktree.worktree_root,
1418        ["status", "--porcelain=v1", "--untracked-files=all"],
1419        "inspect managed worktree changes",
1420    )?;
1421    Ok(!status.is_empty())
1422}
1423
1424/// Whether a new managed worktree needs the primary checkout to be clean.
1425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1426pub(super) enum PrimaryCheckoutRequirement {
1427    /// A new raw session starts from the primary checkout's HEAD, so work that
1428    /// is only in its working tree would be silently left behind.
1429    Clean,
1430    /// A session moving out of its target replaces the worktree's contents from
1431    /// its checkpoint, so the primary checkout's own changes are beside the
1432    /// point.
1433    Any,
1434}
1435
1436pub(super) fn create_managed_worktree(
1437    executor: &impl CommandExecutor,
1438    worktree: &ManagedWorktree,
1439    upstream: Option<&str>,
1440    requirement: PrimaryCheckoutRequirement,
1441) -> Result<()> {
1442    ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
1443    if requirement == PrimaryCheckoutRequirement::Clean {
1444        let status = managed_git_stdout(
1445            executor,
1446            &worktree.target,
1447            &worktree.source_repository,
1448            ["status", "--porcelain=v1", "--untracked-files=all"],
1449            "inspect primary checkout changes",
1450        )?;
1451        if !status.is_empty() {
1452            let paths = status.lines().take(20).collect::<Vec<_>>().join("\n  ");
1453            bail!(
1454                "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n  {paths}"
1455            );
1456        }
1457    }
1458    let parent = worktree
1459        .worktree_root
1460        .parent()
1461        .context("managed worktree root has no parent")?;
1462    execute_checked(
1463        executor,
1464        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1465            .purpose("create managed worktree directory"),
1466    )?;
1467    execute_checked(
1468        executor,
1469        managed_git_command(
1470            &worktree.target,
1471            &worktree.source_repository,
1472            [
1473                "worktree",
1474                "add",
1475                "-b",
1476                &worktree.branch,
1477                &worktree.worktree_root.to_string_lossy(),
1478                worktree.base_commit.as_deref().unwrap_or("HEAD"),
1479            ],
1480            "create managed raw-session worktree",
1481        ),
1482    )?;
1483    if let Some(upstream) = upstream {
1484        execute_checked(
1485            executor,
1486            managed_git_command(
1487                &worktree.target,
1488                &worktree.worktree_root,
1489                ["branch", "--set-upstream-to", upstream, &worktree.branch],
1490                "set managed worktree branch upstream",
1491            ),
1492        )?;
1493    }
1494    Ok(())
1495}
1496
1497/// Recreate a retired checkout from the session branch. Returns whether this
1498/// call created it, so a failed resume can put the session back into its
1499/// stopped, checkout-free state.
1500pub(super) fn restore_managed_worktree(
1501    executor: &impl CommandExecutor,
1502    worktree: &ManagedWorktree,
1503) -> Result<bool> {
1504    if managed_worktree_checkout_exists(executor, worktree)? {
1505        return Ok(false);
1506    }
1507    ensure!(
1508        path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
1509        "managed worktree source repository is unavailable: {}",
1510        worktree.source_repository.display()
1511    );
1512    let branch_ref = format!("refs/heads/{}", worktree.branch);
1513    let check = managed_git_command(
1514        &worktree.target,
1515        &worktree.source_repository,
1516        ["show-ref", "--verify", "--quiet", &branch_ref],
1517        "check retired managed worktree branch",
1518    );
1519    let output = executor.execute(&check)?;
1520    match output.status {
1521        0 => {}
1522        1 => bail!(
1523            "managed worktree branch is unavailable: {}",
1524            worktree.branch
1525        ),
1526        status => bail!(
1527            "check retired managed worktree branch failed with status {status}: {}",
1528            String::from_utf8_lossy(&output.stderr).trim()
1529        ),
1530    }
1531    // A remote bare target may already have removed the checkout directory.
1532    // Prune its stale registration before adding the retained branch again.
1533    execute_checked(
1534        executor,
1535        managed_git_command(
1536            &worktree.target,
1537            &worktree.source_repository,
1538            ["worktree", "prune"],
1539            "prune retired managed worktree metadata",
1540        ),
1541    )?;
1542    let parent = worktree
1543        .worktree_root
1544        .parent()
1545        .context("managed worktree root has no parent")?;
1546    execute_checked(
1547        executor,
1548        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1549            .purpose("recreate managed worktree directory"),
1550    )?;
1551    execute_checked(
1552        executor,
1553        managed_git_command(
1554            &worktree.target,
1555            &worktree.source_repository,
1556            [
1557                "worktree",
1558                "add",
1559                "--",
1560                &worktree.worktree_root.to_string_lossy(),
1561                &worktree.branch,
1562            ],
1563            "restore managed raw-session worktree",
1564        ),
1565    )?;
1566    Ok(true)
1567}
1568
1569fn ensure_managed_worktree_available(
1570    executor: &impl CommandExecutor,
1571    worktree: &ManagedWorktree,
1572) -> Result<()> {
1573    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1574        bail!(
1575            "managed worktree path already exists: {}",
1576            worktree.worktree_root.display()
1577        );
1578    }
1579    let branch_ref = format!("refs/heads/{}", worktree.branch);
1580    let check = managed_git_command(
1581        &worktree.target,
1582        &worktree.source_repository,
1583        ["show-ref", "--verify", "--quiet", &branch_ref],
1584        "check managed worktree branch availability",
1585    );
1586    let output = executor.execute(&check)?;
1587    match output.status {
1588        0 => bail!(
1589            "managed worktree branch already exists: {}",
1590            worktree.branch
1591        ),
1592        1 => Ok(()),
1593        status => bail!(
1594            "check managed worktree branch availability failed with status {status}: {}",
1595            String::from_utf8_lossy(&output.stderr).trim()
1596        ),
1597    }
1598}
1599
1600/// Check whether the deterministic branch left by this session's earlier
1601/// raw-to-workspace move can be reattached. A branch with this session's id is
1602/// session-owned, but an active checkout elsewhere is still a collision: the
1603/// restore must not make one branch belong to two worktrees.
1604fn retained_managed_worktree_branch_available(
1605    executor: &impl CommandExecutor,
1606    worktree: &ManagedWorktree,
1607) -> Result<bool> {
1608    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1609        bail!(
1610            "managed worktree path already exists: {}",
1611            worktree.worktree_root.display()
1612        );
1613    }
1614    let branch_ref = format!("refs/heads/{}", worktree.branch);
1615    let check = managed_git_command(
1616        &worktree.target,
1617        &worktree.source_repository,
1618        ["show-ref", "--verify", "--quiet", &branch_ref],
1619        "check retained managed worktree branch",
1620    );
1621    let output = executor.execute(&check)?;
1622    match output.status {
1623        1 => Ok(false),
1624        0 => {
1625            let worktrees = managed_git_stdout(
1626                executor,
1627                &worktree.target,
1628                &worktree.source_repository,
1629                ["worktree", "list", "--porcelain", "-z"],
1630                "check retained managed worktree checkout",
1631            )?;
1632            let branch_field = format!("branch {branch_ref}");
1633            if worktrees.split('\0').any(|field| field == branch_field) {
1634                bail!(
1635                    "managed worktree branch is still checked out: {}",
1636                    worktree.branch
1637                );
1638            }
1639            Ok(true)
1640        }
1641        status => bail!(
1642            "check retained managed worktree branch failed with status {status}: {}",
1643            String::from_utf8_lossy(&output.stderr).trim()
1644        ),
1645    }
1646}
1647
1648/// Preserve the ref that a return-to-local restore is about to reset. The
1649/// retained `mj/<session>` branch is the source-recovery point; keeping a
1650/// second ref makes a later commit on that branch recoverable as well.
1651pub(super) fn preserve_retained_managed_worktree_branch(
1652    executor: &impl CommandExecutor,
1653    worktree: &ManagedWorktree,
1654) -> Result<String> {
1655    let session_id = worktree
1656        .branch
1657        .strip_prefix("mj/")
1658        .context("managed worktree branch is not session-owned")?;
1659    let branch_ref = format!("refs/heads/{}", worktree.branch);
1660    let tip = managed_git_stdout(
1661        executor,
1662        &worktree.target,
1663        &worktree.source_repository,
1664        ["rev-parse", "--verify", &branch_ref],
1665        "read retained managed worktree branch tip",
1666    )?;
1667    let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
1668    let existing = managed_git_command(
1669        &worktree.target,
1670        &worktree.source_repository,
1671        ["show-ref", "--verify", "--quiet", &recovery_ref],
1672        "check retained managed worktree recovery ref",
1673    );
1674    let output = executor.execute(&existing)?;
1675    match output.status {
1676        0 => {
1677            let existing_tip = managed_git_stdout(
1678                executor,
1679                &worktree.target,
1680                &worktree.source_repository,
1681                ["rev-parse", "--verify", &recovery_ref],
1682                "verify retained managed worktree recovery ref",
1683            )?;
1684            ensure!(
1685                existing_tip == tip,
1686                "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
1687            );
1688            Ok(recovery_ref)
1689        }
1690        1 => {
1691            execute_checked(
1692                executor,
1693                managed_git_command(
1694                    &worktree.target,
1695                    &worktree.source_repository,
1696                    ["update-ref", &recovery_ref, &tip],
1697                    "preserve retained managed worktree branch",
1698                ),
1699            )?;
1700            Ok(recovery_ref)
1701        }
1702        status => bail!(
1703            "check retained managed worktree recovery ref failed with status {status}: {}",
1704            String::from_utf8_lossy(&output.stderr).trim()
1705        ),
1706    }
1707}
1708
1709/// Remove a managed worktree's checkout and keep its branch.
1710///
1711/// A session that moved into a target still checkpoints as a delta against
1712/// `hel/<session>`, so deleting that branch could let the commits those deltas
1713/// depend on be collected. The checkout itself is dirty by design; its dirty
1714/// state has already been carried into the target.
1715pub(super) fn retire_managed_worktree(
1716    executor: &impl CommandExecutor,
1717    worktree: &ManagedWorktree,
1718) -> Result<()> {
1719    cleanup_managed_worktree(executor, worktree, BranchDisposition::Keep)
1720}
1721
1722/// Remove the checkout and prune its metadata. Returns whether the repository
1723/// is still there to act on at all.
1724fn remove_managed_worktree_checkout(
1725    executor: &impl CommandExecutor,
1726    worktree: &ManagedWorktree,
1727) -> Result<bool> {
1728    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1729        return Ok(false);
1730    }
1731    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1732        execute_checked(
1733            executor,
1734            managed_git_command(
1735                &worktree.target,
1736                &worktree.source_repository,
1737                [
1738                    "worktree",
1739                    "remove",
1740                    "--force",
1741                    &worktree.worktree_root.to_string_lossy(),
1742                ],
1743                "remove managed raw-session worktree",
1744            ),
1745        )?;
1746    }
1747    execute_checked(
1748        executor,
1749        managed_git_command(
1750            &worktree.target,
1751            &worktree.source_repository,
1752            ["worktree", "prune"],
1753            "prune managed worktree metadata",
1754        ),
1755    )?;
1756    Ok(true)
1757}
1758
1759/// Whether the session branch is contained in a branch that is not a Mjolnir
1760/// session branch, so deleting it loses no commits. `Ok(None)` means the
1761/// source repository is gone and there is nothing to answer about.
1762///
1763/// This is git's own meaning of "merged": the branch tip is an ancestor of
1764/// another ref. A squash merge or a rebase rewrites the commits, so it does
1765/// not count and the branch is kept.
1766fn managed_branch_is_merged(
1767    executor: &impl CommandExecutor,
1768    worktree: &ManagedWorktree,
1769) -> Result<Option<bool>> {
1770    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1771        return Ok(None);
1772    }
1773    let branch_ref = format!("refs/heads/{}", worktree.branch);
1774    let refs = managed_git_stdout(
1775        executor,
1776        &worktree.target,
1777        &worktree.source_repository,
1778        [
1779            "for-each-ref",
1780            "--contains",
1781            &branch_ref,
1782            "--format=%(refname)",
1783            "refs/heads",
1784            "refs/remotes",
1785        ],
1786        "list the branches containing a managed worktree branch",
1787    )?;
1788    Ok(Some(refs.lines().any(containing_ref_is_not_a_session)))
1789}
1790
1791/// A ref that proves the session branch's commits live somewhere else: any
1792/// branch outside `refs/heads/mj/`, including a remote-tracking branch, since
1793/// work merged upstream and fetched is merged. A remote's symbolic `HEAD` is
1794/// not a branch of its own and never counts.
1795fn containing_ref_is_not_a_session(reference: &str) -> bool {
1796    let reference = reference.trim();
1797    let remote_head = reference.starts_with("refs/remotes/") && reference.ends_with("/HEAD");
1798    !reference.is_empty() && !reference.starts_with("refs/heads/mj/") && !remote_head
1799}
1800
1801/// Remove a managed worktree's checkout, and its branch only when the caller
1802/// asks for that. The branch can hold work the user still wants, so deleting
1803/// it is always an explicit decision; see [`BranchDisposition`].
1804pub(super) fn cleanup_managed_worktree(
1805    executor: &impl CommandExecutor,
1806    worktree: &ManagedWorktree,
1807    branch: BranchDisposition,
1808) -> Result<()> {
1809    if !remove_managed_worktree_checkout(executor, worktree)? {
1810        return Ok(());
1811    }
1812    if branch == BranchDisposition::Keep {
1813        return remove_empty_managed_worktree_directories(executor, worktree);
1814    }
1815    let branch_ref = format!("refs/heads/{}", worktree.branch);
1816    let check = managed_git_command(
1817        &worktree.target,
1818        &worktree.source_repository,
1819        ["show-ref", "--verify", "--quiet", &branch_ref],
1820        "check managed worktree branch",
1821    );
1822    let output = executor.execute(&check)?;
1823    let present = match output.status {
1824        0 => true,
1825        1 => false,
1826        status => bail!(
1827            "check managed worktree branch failed with status {status}: {}",
1828            String::from_utf8_lossy(&output.stderr).trim()
1829        ),
1830    };
1831    let delete = match branch {
1832        BranchDisposition::Delete => present,
1833        BranchDisposition::DeleteIfMerged if present => {
1834            let merged = managed_branch_is_merged(executor, worktree)?;
1835            let delete = merged == Some(true);
1836            tracing::info!(
1837                branch = %worktree.branch,
1838                delete,
1839                reason = match merged {
1840                    Some(true) => "another branch already contains its commits",
1841                    Some(false) => "it holds commits no other branch contains",
1842                    None => "its repository is gone",
1843                },
1844                "archiving decided what to do with a session branch"
1845            );
1846            delete
1847        }
1848        BranchDisposition::DeleteIfMerged | BranchDisposition::Keep => false,
1849    };
1850    if delete {
1851        execute_checked(
1852            executor,
1853            managed_git_command(
1854                &worktree.target,
1855                &worktree.source_repository,
1856                ["branch", "-D", "--", &worktree.branch],
1857                "delete managed raw-session branch",
1858            ),
1859        )?;
1860    }
1861    remove_empty_managed_worktree_directories(executor, worktree)
1862}
1863
1864fn remove_empty_managed_worktree_directories(
1865    executor: &impl CommandExecutor,
1866    worktree: &ManagedWorktree,
1867) -> Result<()> {
1868    let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1869    let hel = worktree.source_repository.join(".mj");
1870    match &worktree.target {
1871        ManagedWorktreeTarget::Local => {
1872            for directory in [&worktrees, &hel] {
1873                match std::fs::remove_dir(directory) {
1874                    Ok(()) => {}
1875                    Err(error)
1876                        if matches!(
1877                            error.kind(),
1878                            std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1879                        ) => {}
1880                    Err(error) => return Err(error.into()),
1881                }
1882            }
1883        }
1884        ManagedWorktreeTarget::Ssh { .. } => {
1885            let command = managed_target_command(
1886                &worktree.target,
1887                "rmdir",
1888                ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1889            )
1890            .purpose("remove empty managed worktree directories");
1891            let _ = executor.execute(&command)?;
1892        }
1893    }
1894    Ok(())
1895}
1896
1897#[cfg(test)]
1898mod tests;