Skip to main content

mj_controller/controller/
worktree.rs

1//! Managed worktrees and raw-to-workspace project conversion.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail, ensure};
6
7#[cfg(test)]
8use mj_core::config::ProjectRepository;
9use mj_core::config::{Config, TargetTemplate};
10use mj_core::local_git::canonical_repository;
11use mj_core::repository::{ProjectBundleSnapshot, RepositoryIdentity};
12use mj_core::state::{
13    ManagedCheckoutKind, ManagedWorktree, ManagedWorktreeOptions, ManagedWorktreeTarget,
14    ProjectSourceIdentity, SessionRecord,
15};
16
17use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
18pub(crate) use mj_client::target::managed_worktree_target;
19pub use mj_client::target::{ResumePlan, resume_compatibility, resume_compatibility_with_checkout};
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        if session.project.is_some() {
140            return Ok(session.project_source(&self.config));
141        }
142        let checkout = self.state.checkout(session_id)?;
143        let Some(directory) = checkout.project_directory() else {
144            return Ok(session.project_source(&self.config));
145        };
146        let (target, origin_directory) = match checkout.effective() {
147            // The source repository is the durable owner of a linked
148            // worktree's shared Git configuration and remains available while
149            // a stopped session's checkout is retired.
150            mj_core::state::Checkout::ManagedWorktree { worktree, .. } => (
151                worktree.target.clone(),
152                worktree.source_repository.as_path(),
153            ),
154            mj_core::state::Checkout::Attached { .. } => (
155                managed_worktree_target(
156                    self.config
157                        .targets
158                        .get(&session.target_template_id)
159                        .with_context(|| {
160                            format!(
161                                "session {session_id} target {:?} is no longer configured",
162                                session.target_template_id
163                            )
164                        })?,
165                )?,
166                directory,
167            ),
168            mj_core::state::Checkout::ManagedWorkspace => {
169                return Ok(session.project_source(&self.config));
170            }
171            mj_core::state::Checkout::Borrowed { .. } => {
172                unreachable!("effective checkout resolves borrowing")
173            }
174        };
175        let output = executor.execute(&managed_git_command(
176            &target,
177            origin_directory,
178            ["config", "--get", "remote.origin.url"],
179            "resolve project Git origin",
180        ))?;
181        match output.status {
182            0 => {
183                let origin =
184                    String::from_utf8(output.stdout).context("project Git origin was not UTF-8")?;
185                if let Some(identity) = ProjectSourceIdentity::git_remote(origin.trim()) {
186                    return Ok(identity);
187                }
188            }
189            // Git uses 1 when no origin is configured.
190            1 => {}
191            status => bail!(
192                "resolve project Git origin failed with status {status}: {}",
193                String::from_utf8_lossy(&output.stderr).trim()
194            ),
195        }
196        let root = resolve_git_root(&target, origin_directory, executor)?
197            .unwrap_or_else(|| origin_directory.to_path_buf());
198        let remote = match &target {
199            ManagedWorktreeTarget::Local => None,
200            ManagedWorktreeTarget::Ssh { destination, .. } => Some(destination.as_str()),
201        };
202        Ok(ProjectSourceIdentity::path(&root, remote))
203    }
204
205    /// Resolve the checkout a bundle session is moving into, and check that it
206    /// is free, before the session record names it.
207    pub(super) fn plan_workspace_to_raw(
208        &self,
209        session: &SessionRecord,
210        target_id: &str,
211        executor: &impl CommandExecutor,
212    ) -> Result<WorkspaceToRawConversion> {
213        let bundle = session
214            .project_bundle(&self.config)
215            .context("session bundle is missing")?;
216        let [repository] = bundle.repositories.as_slice() else {
217            bail!("a checkout holds exactly one repository");
218        };
219        let source = repository
220            .local
221            .as_deref()
222            .context("only a repository already on this machine can become a checkout")?;
223        self.validate_project_directory(target_id, source, executor)
224            .context("this session's repository is unavailable")?;
225        let mut worktree = ManagedWorktree {
226            kind: Default::default(),
227            source_project_directory: source.to_path_buf(),
228            source_repository: source.to_path_buf(),
229            worktree_root: source.join(".mj").join("worktrees").join(&session.id),
230            branch: format!("mj/{}", session.id),
231            target: managed_worktree_target(
232                self.config
233                    .targets
234                    .get(target_id)
235                    .with_context(|| format!("unknown target template {target_id:?}"))?,
236            )?,
237            base_commit: None,
238        };
239        let reuse_existing_branch =
240            retained_managed_worktree_branch_available(executor, &worktree)?;
241        if !reuse_existing_branch {
242            let (branch, remote_branch) = managed_clone_starting_branch(
243                executor,
244                &worktree.target,
245                source,
246                session.launch_branch.as_deref(),
247            )?;
248            worktree.kind = ManagedCheckoutKind::Clone;
249            worktree.worktree_root = source.join(".mj").join("clones").join(&session.id);
250            worktree.branch = branch.clone();
251            worktree.base_commit = Some(managed_git_stdout(
252                executor,
253                &worktree.target,
254                source,
255                [
256                    "rev-parse",
257                    "--verify",
258                    &format!(
259                        "{}^{{commit}}",
260                        if remote_branch {
261                            format!("refs/remotes/origin/{branch}")
262                        } else {
263                            format!("refs/heads/{branch}")
264                        }
265                    ),
266                ],
267                "resolve converted checkout source commit",
268            )?);
269        }
270        if !reuse_existing_branch {
271            ensure_managed_worktree_available(executor, &worktree)?;
272        }
273        Ok(WorkspaceToRawConversion {
274            worktree,
275            reuse_existing_branch,
276        })
277    }
278
279    pub(super) fn prepare_managed_raw_worktree(
280        &mut self,
281        session_id: &str,
282        executor: &impl CommandExecutor,
283    ) -> Result<bool> {
284        let session = self
285            .state
286            .sessions
287            .get(session_id)
288            .with_context(|| format!("unknown session {session_id}"))?
289            .clone();
290        let checkout = self.state.checkout(session_id)?;
291        let selected = match checkout {
292            mj_core::state::Checkout::Attached { path } => Some(path.to_path_buf()),
293            mj_core::state::Checkout::ManagedWorktree { .. }
294            | mj_core::state::Checkout::ManagedWorkspace
295            | mj_core::state::Checkout::Borrowed { .. } => None,
296        };
297        drop(checkout);
298        let Some(selected) = selected else {
299            return Ok(false);
300        };
301        if session.create_managed_worktree == Some(false) {
302            return Ok(false);
303        }
304        let template = self
305            .config
306            .targets
307            .get(&session.target_template_id)
308            .context("raw session target template disappeared during provisioning")?;
309        if matches!(template, TargetTemplate::SshBare { .. }) {
310            self.validate_project_directory(&session.target_template_id, &selected, executor)?;
311        }
312        let target = managed_worktree_target(template)?;
313        if matches!(target, ManagedWorktreeTarget::Local)
314            && local_project_repository(&selected, executor)?.is_none()
315        {
316            // A requested launch base asks for the same worktree an explicit
317            // request does, so it must fail here rather than launch without
318            // one and silently ignore the base.
319            ensure!(
320                session.create_managed_worktree != Some(true) && session.launch_base.is_none(),
321                "managed worktree creation requires a Git project"
322            );
323            return Ok(false);
324        }
325        let inspection = inspect_raw_project(executor, &target, &selected)?;
326        if !inspection.primary_checkout
327            && session.create_managed_worktree != Some(true)
328            && session.launch_base.is_none()
329        {
330            return Ok(false);
331        }
332        let relative_directory = inspection
333            .source_project_directory
334            .strip_prefix(&inspection.source_repository)
335            .context("raw project directory is outside its repository")?
336            .to_path_buf();
337        let worktree_root = inspection
338            .source_repository
339            .join(".mj")
340            .join("clones")
341            .join(session_id);
342        // The worktree branch is created from the repository's HEAD, or from
343        // the requested launch base, so record that commit as the session base
344        // rather than rediscovering it later.
345        let (branch, remote_branch) = managed_clone_starting_branch(
346            executor,
347            &target,
348            &inspection.source_repository,
349            session.launch_branch.as_deref(),
350        )?;
351        let base_commit = match session.launch_base.as_deref() {
352            Some(revision) => managed_git_stdout(
353                executor,
354                &target,
355                &inspection.source_repository,
356                [
357                    "rev-parse",
358                    "--verify",
359                    "--end-of-options",
360                    &format!("{revision}^{{commit}}"),
361                ],
362                "resolve the launch base",
363            )?
364            .trim()
365            .to_owned(),
366            None => managed_git_stdout(
367                executor,
368                &target,
369                &inspection.source_repository,
370                [
371                    "rev-parse",
372                    "--verify",
373                    &format!(
374                        "{}^{{commit}}",
375                        if remote_branch {
376                            format!("refs/remotes/origin/{branch}")
377                        } else {
378                            format!("refs/heads/{branch}")
379                        }
380                    ),
381                ],
382                "resolve selected branch tip",
383            )?,
384        };
385        let managed = ManagedWorktree {
386            kind: ManagedCheckoutKind::Clone,
387            source_project_directory: inspection.source_project_directory,
388            source_repository: inspection.source_repository,
389            worktree_root: worktree_root.clone(),
390            branch,
391            target,
392            base_commit: Some(base_commit),
393        };
394        ensure_managed_worktree_available(executor, &managed)?;
395        let record = self.state.sessions.get_mut(session_id).unwrap();
396        record.project_directory = Some(worktree_root.join(relative_directory));
397        record.managed_worktree = Some(managed.clone());
398        record.updated_at = now();
399        self.persist_session_state(session_id)?;
400        create_managed_worktree(
401            executor,
402            &managed,
403            inspection.upstream.as_deref(),
404            PrimaryCheckoutRequirement::Clean,
405        )?;
406        Ok(true)
407    }
408
409    pub(super) fn cleanup_new_session_worktree_after_failure(
410        &self,
411        session_id: &str,
412        executor: &impl CommandExecutor,
413    ) -> Result<()> {
414        if !self.state.sessions.contains_key(session_id) {
415            return Ok(());
416        }
417        let mj_core::state::Checkout::ManagedWorktree { worktree, .. } =
418            self.state.checkout(session_id)?
419        else {
420            return Ok(());
421        };
422        // A session that never started has a branch Mjolnir just created and
423        // nobody has worked on, so the rollback takes the branch too.
424        cleanup_managed_worktree(executor, worktree, BranchDisposition::Delete)
425    }
426}
427
428/// Reuse the same Git configuration resolver on a remote bare host.
429pub(crate) struct RemoteGitExecutor<'a, E> {
430    pub(crate) executor: &'a E,
431    pub(crate) ssh: SshTarget,
432}
433
434impl<E: CommandExecutor> CommandExecutor for RemoteGitExecutor<'_, E> {
435    fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
436        let mut arguments = vec!["env".to_owned()];
437        arguments.extend(
438            command
439                .env
440                .iter()
441                .map(|(key, value)| format!("{key}={value}")),
442        );
443        arguments.push(command.program.clone());
444        arguments.extend(command.args.clone());
445        self.executor
446            .execute(&crate::targets::ssh_command(&self.ssh, arguments).purpose(&command.purpose))
447    }
448
449    fn cancellation_requested(&self) -> bool {
450        self.executor.cancellation_requested()
451    }
452}
453
454#[derive(Debug, Clone, PartialEq, Eq)]
455struct RawProjectInspection {
456    source_project_directory: PathBuf,
457    source_repository: PathBuf,
458    primary_checkout: bool,
459    upstream: Option<String>,
460}
461
462fn managed_clone_starting_branch(
463    executor: &impl CommandExecutor,
464    target: &ManagedWorktreeTarget,
465    repository: &Path,
466    selected: Option<&str>,
467) -> Result<(String, bool)> {
468    if let Some(branch) = selected {
469        let format = executor.execute(&managed_git_command(
470            target,
471            repository,
472            ["check-ref-format", "--branch", branch],
473            "validate selected branch",
474        ))?;
475        ensure!(format.status == 0, "invalid selected Git branch {branch:?}");
476        for (reference, remote) in [
477            (format!("refs/heads/{branch}"), false),
478            (format!("refs/remotes/origin/{branch}"), true),
479        ] {
480            let present = executor.execute(&managed_git_command(
481                target,
482                repository,
483                ["show-ref", "--verify", "--quiet", &reference],
484                "find selected branch",
485            ))?;
486            match present.status {
487                0 => return Ok((branch.to_owned(), remote)),
488                1 => {}
489                status => bail!("find selected branch failed with status {status}"),
490            }
491        }
492        bail!("selected branch {branch:?} is unavailable in the source repository");
493    }
494    let remote_head = managed_git_command(
495        target,
496        repository,
497        [
498            "symbolic-ref",
499            "--quiet",
500            "--short",
501            "refs/remotes/origin/HEAD",
502        ],
503        "resolve origin default branch",
504    );
505    let output = executor.execute(&remote_head)?;
506    match output.status {
507        0 => {
508            let reference = String::from_utf8(output.stdout)?;
509            let branch = reference
510                .trim()
511                .strip_prefix("origin/")
512                .context("origin/HEAD does not name an origin branch")?;
513            ensure!(!branch.is_empty(), "origin/HEAD has no branch");
514            Ok((branch.to_owned(), true))
515        }
516        1 => {
517            let origin = executor.execute(&managed_git_command(
518                target,
519                repository,
520                ["config", "--get", "remote.origin.url"],
521                "inspect origin remote",
522            ))?;
523            if origin.status == 0 {
524                let remote = managed_git_stdout(
525                    executor,
526                    target,
527                    repository,
528                    ["ls-remote", "--symref", "origin", "HEAD"],
529                    "resolve remote default branch",
530                )?;
531                let branch = remote
532                    .lines()
533                    .find_map(|line| {
534                        line.strip_prefix("ref: refs/heads/")?
535                            .strip_suffix("\tHEAD")
536                    })
537                    .context("origin did not advertise a default branch")?;
538                let cached = executor.execute(&managed_git_command(
539                    target,
540                    repository,
541                    [
542                        "show-ref",
543                        "--verify",
544                        "--quiet",
545                        &format!("refs/remotes/origin/{branch}"),
546                    ],
547                    "find remote default branch in source",
548                ))?;
549                ensure!(
550                    cached.status == 0,
551                    "origin default branch {branch:?} is not in the source repository; fetch it before starting a session"
552                );
553                return Ok((branch.to_owned(), true));
554            }
555            ensure!(
556                origin.status == 1,
557                "inspect origin remote failed with status {}",
558                origin.status
559            );
560            managed_git_stdout(
561                executor,
562                target,
563                repository,
564                ["symbolic-ref", "--quiet", "--short", "HEAD"],
565                "resolve source checkout branch",
566            )
567            .map(|branch| (branch, false))
568            .context("source checkout is detached; select a starting branch explicitly")
569        }
570        status => bail!(
571            "resolve origin default branch failed with status {status}: {}",
572            String::from_utf8_lossy(&output.stderr).trim()
573        ),
574    }
575}
576
577fn managed_target_ssh(target: &ManagedWorktreeTarget) -> Option<SshTarget> {
578    match target {
579        ManagedWorktreeTarget::Local => None,
580        ManagedWorktreeTarget::Ssh {
581            destination,
582            ssh_args,
583        } => Some(SshTarget {
584            destination: destination.clone(),
585            ssh_args: ssh_args.clone(),
586        }),
587    }
588}
589
590pub(super) fn managed_target_command(
591    target: &ManagedWorktreeTarget,
592    program: &str,
593    args: impl IntoIterator<Item = impl AsRef<str>>,
594) -> CommandSpec {
595    let args = args
596        .into_iter()
597        .map(|arg| arg.as_ref().to_owned())
598        .collect::<Vec<_>>();
599    match managed_target_ssh(target) {
600        None => CommandSpec::new(program, args),
601        Some(ssh) => {
602            let mut remote = vec![program.to_owned()];
603            remote.extend(args);
604            crate::targets::ssh_command(&ssh, remote)
605        }
606    }
607}
608
609pub(super) fn managed_git_command(
610    target: &ManagedWorktreeTarget,
611    directory: &Path,
612    args: impl IntoIterator<Item = impl AsRef<str>>,
613    purpose: impl Into<String>,
614) -> CommandSpec {
615    let mut command_args = vec!["-C".to_owned(), directory.to_string_lossy().into_owned()];
616    command_args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
617    managed_target_command(target, "git", command_args).purpose(purpose)
618}
619
620fn command_stdout(output: CommandOutput, purpose: &str) -> Result<String> {
621    if output.status != 0 {
622        bail!(
623            "{purpose} failed with status {}: {}",
624            output.status,
625            String::from_utf8_lossy(&output.stderr).trim()
626        );
627    }
628    let stdout = String::from_utf8(output.stdout)
629        .with_context(|| format!("{purpose} produced non-UTF-8 output"))?;
630    Ok(stdout.trim_end_matches(['\r', '\n']).to_owned())
631}
632
633fn managed_git_stdout(
634    executor: &impl CommandExecutor,
635    target: &ManagedWorktreeTarget,
636    directory: &Path,
637    args: impl IntoIterator<Item = impl AsRef<str>>,
638    purpose: &str,
639) -> Result<String> {
640    let command = managed_git_command(target, directory, args, purpose);
641    command_stdout(executor.execute(&command)?, purpose)
642}
643
644/// Resolve a checkout's stable repository root, collapsing linked worktrees
645/// onto the main worktree when Git exposes the shared `.git` directory.
646fn resolve_git_root(
647    target: &ManagedWorktreeTarget,
648    directory: &Path,
649    executor: &impl CommandExecutor,
650) -> Result<Option<PathBuf>> {
651    // The expected non-repository diagnostic must be stable across locales;
652    // every other Git failure remains an error.
653    let args = [
654        "-C".to_owned(),
655        directory.to_string_lossy().into_owned(),
656        "rev-parse".into(),
657        "--show-toplevel".into(),
658    ];
659    let top_level = match target {
660        ManagedWorktreeTarget::Local => {
661            let mut command = CommandSpec::new("git", args);
662            command.env.insert("LC_ALL".into(), "C".into());
663            command
664        }
665        ManagedWorktreeTarget::Ssh { .. } => managed_target_command(
666            target,
667            "env",
668            ["LC_ALL=C".to_owned(), "git".into()]
669                .into_iter()
670                .chain(args),
671        ),
672    }
673    .purpose("resolve project Git root");
674    let output = executor.execute(&top_level)?;
675    if output.status != 0 {
676        if output.status == 128
677            && String::from_utf8_lossy(&output.stderr).starts_with("fatal: not a git repository")
678        {
679            return Ok(None);
680        }
681        bail!(
682            "resolve project Git root failed with status {}: {}",
683            output.status,
684            String::from_utf8_lossy(&output.stderr).trim()
685        );
686    }
687    let root = PathBuf::from(
688        String::from_utf8(output.stdout)
689            .context("project Git root was not UTF-8")?
690            .trim_end_matches(['\r', '\n']),
691    );
692    if !root.is_absolute() {
693        bail!(
694            "resolve project Git root returned a non-absolute path: {}",
695            root.display()
696        );
697    }
698
699    let prefix = managed_git_stdout(
700        executor,
701        target,
702        directory,
703        ["rev-parse", "--show-prefix"],
704        "resolve project relative directory",
705    )?;
706    // Relative Git results resolve against Git's own spelling of the
707    // directory, which `root` shares, not the caller's possibly symlinked one.
708    let common = mj_core::local_git::resolve_git_path(
709        &root.join(prefix),
710        &managed_git_stdout(
711            executor,
712            target,
713            directory,
714            ["rev-parse", "--git-common-dir"],
715            "resolve project Git common directory",
716        )?,
717    )?;
718    if common.file_name() == Some(std::ffi::OsStr::new(".git"))
719        && let Some(main_root) = common.parent()
720    {
721        return Ok(Some(main_root.to_path_buf()));
722    }
723    Ok(Some(root))
724}
725
726/// Inspect a local launch directory using the same Git error handling and
727/// linked-worktree identity as existing sessions.
728pub fn local_project_repository(
729    directory: &Path,
730    executor: &impl CommandExecutor,
731) -> Result<Option<PathBuf>> {
732    resolve_git_root(&ManagedWorktreeTarget::Local, directory, executor)
733}
734
735/// Which checkout each still-empty target repository is seeded from, or `None`
736/// when this connect must not seed at all. A converting resume carries the
737/// session's own checkout; every other seed comes from the bundle's local path.
738/// Reshape a raw session's record for the workspace target it is moving into.
739pub(super) fn apply_raw_to_workspace(
740    record: &mut SessionRecord,
741    conversion: &RawToWorkspaceConversion,
742) -> Result<()> {
743    let project = record
744        .project
745        .get_or_insert_with(|| conversion.project.clone());
746    ensure!(
747        project.bundle == conversion.project.bundle
748            && project.identities == conversion.project.identities,
749        "the accepted project changed while preparing the checkout transition"
750    );
751    project
752        .network_sources
753        .insert(conversion.repository_id.clone(), conversion.source.clone());
754    record.project_directory = None;
755    record.managed_worktree = None;
756    Ok(())
757}
758
759/// A resume that changes how a session is represented, resolved before the
760/// session record or the configuration changes.
761#[derive(Debug, Clone, PartialEq, Eq)]
762pub(super) enum ResumeConversion {
763    RawToWorkspace(Box<RawToWorkspaceConversion>),
764    WorkspaceToRaw(WorkspaceToRawConversion),
765}
766
767impl ResumeConversion {
768    pub(super) fn raw_to_workspace(&self) -> Option<&RawToWorkspaceConversion> {
769        match self {
770            Self::RawToWorkspace(conversion) => Some(conversion),
771            Self::WorkspaceToRaw(_) => None,
772        }
773    }
774
775    pub(super) fn workspace_to_raw(&self) -> Option<&WorkspaceToRawConversion> {
776        match self {
777            Self::WorkspaceToRaw(conversion) => Some(conversion),
778            Self::RawToWorkspace(_) => None,
779        }
780    }
781}
782
783/// Everything a workspace-to-raw resume needs. The worktree does not exist yet:
784/// the record names it first, so a failure cleans it up through the same path
785/// as a new raw session's.
786#[derive(Debug, Clone, PartialEq, Eq)]
787pub(super) struct WorkspaceToRawConversion {
788    pub(super) worktree: ManagedWorktree,
789    /// The first move retires this session's checkout but deliberately keeps
790    /// its `mj/<session>` branch for source recovery. Reattach that branch on
791    /// the return move instead of trying to create it a second time.
792    pub(super) reuse_existing_branch: bool,
793}
794
795/// Reshape a bundle session's record for the checkout it is moving into. The
796/// bundle stays: it still describes the repository the checkout came from.
797pub(super) fn apply_workspace_to_raw(
798    record: &mut SessionRecord,
799    conversion: &WorkspaceToRawConversion,
800) {
801    record.project_directory = Some(conversion.worktree.worktree_root.clone());
802    record.managed_worktree = Some(conversion.worktree.clone());
803}
804
805/// Everything a raw-to-workspace transition needs before the session record
806/// changes. The accepted bundle remains the session's bundle.
807#[derive(Debug, Clone, PartialEq, Eq)]
808pub(super) struct RawToWorkspaceConversion {
809    /// The checkout whose branch, head commit, and dirty state move into the
810    /// target. For a managed session this is the session's own worktree, not
811    /// the user's primary checkout.
812    pub(super) checkout: PathBuf,
813    /// The repository in the accepted bundle that corresponds to this checkout.
814    pub(super) repository_id: String,
815    /// The accepted repository's target layout.
816    pub(super) destination: PathBuf,
817    /// The accepted bundle snapshot, retained if the older record had only a
818    /// configuration-backed definition.
819    pub(super) project: ProjectBundleSnapshot,
820    /// The network source the workspace target will use. Configured GitHub
821    /// sources remain authoritative; local-source bundles use the live
822    /// checkout's resolved remote.
823    pub(super) source: mj_core::remote_git::NetworkGitSource,
824    /// Removed once the target holds the checkout, and only then.
825    pub(super) retire: Option<ManagedWorktree>,
826}
827
828/// Resolve a raw session's accepted project and plan its checkout transition.
829/// Reads Git; changes neither the record nor configuration.
830pub(super) fn plan_raw_to_workspace_for_session(
831    session: &SessionRecord,
832    checkout: &mj_core::state::Checkout<'_>,
833    config: &Config,
834    executor: &impl CommandExecutor,
835) -> Result<RawToWorkspaceConversion> {
836    let project = accepted_project_snapshot(session, config, executor)?;
837    plan_raw_to_workspace_with_checkout(checkout, &project, executor)
838}
839
840fn accepted_project_snapshot(
841    session: &SessionRecord,
842    config: &Config,
843    executor: &impl CommandExecutor,
844) -> Result<ProjectBundleSnapshot> {
845    if let Some(project) = &session.project {
846        return Ok(project.clone());
847    }
848    let bundle = session
849        .project_bundle(config)
850        .context("raw session has no accepted project bundle")?;
851    crate::project_catalog::snapshot(bundle, executor, false)
852}
853
854/// Resolve where a raw session's checkout lives and which accepted bundle
855/// repository it represents. Reads Git; changes nothing.
856#[cfg(test)]
857pub(super) fn plan_raw_to_workspace(
858    session: &SessionRecord,
859    executor: &impl CommandExecutor,
860) -> Result<RawToWorkspaceConversion> {
861    let checkout = session.checkout();
862    let project = if let Some(project) = &session.project {
863        project.clone()
864    } else {
865        let project_directory = checkout
866            .project_directory()
867            .context("a raw session has no project directory")?;
868        let repository = match &checkout {
869            mj_core::state::Checkout::ManagedWorktree { worktree, .. } => {
870                worktree.source_repository.clone()
871            }
872            mj_core::state::Checkout::Attached { .. }
873            | mj_core::state::Checkout::Borrowed { .. } => {
874                inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?
875                    .source_repository
876            }
877            mj_core::state::Checkout::ManagedWorkspace => {
878                bail!("a bundle workspace cannot be converted as a raw checkout")
879            }
880        };
881        let repository = canonical_repository(&repository)?;
882        let destination = PathBuf::from(
883            project_directory
884                .file_name()
885                .context("a raw project directory cannot be the filesystem root")?,
886        );
887        crate::project_catalog::snapshot(
888            &mj_core::config::ProjectBundle {
889                primary_repo: "project".to_owned(),
890                repositories: vec![ProjectRepository {
891                    id: "project".to_owned(),
892                    github: None,
893                    local: Some(repository),
894                    destination,
895                    git_ref: None,
896                }],
897            },
898            executor,
899            false,
900        )?
901    };
902    plan_raw_to_workspace_with_checkout(&checkout, &project, executor)
903}
904
905pub(super) fn plan_raw_to_workspace_with_checkout(
906    checkout: &mj_core::state::Checkout<'_>,
907    project: &ProjectBundleSnapshot,
908    executor: &impl CommandExecutor,
909) -> Result<RawToWorkspaceConversion> {
910    let project_directory = checkout
911        .project_directory()
912        .context("a raw session has no project directory")?;
913    // The checkpoint describes the session's directory as if it were the
914    // repository root, so only a whole checkout can move. Each branch checks
915    // this against paths from one domain: the record's own paths for a managed
916    // worktree, Git's canonical paths for an inspected checkout — the record
917    // may reach the same checkout through a symlink (macOS temp directories).
918    let (checkout, source_repository, retire) = match checkout {
919        mj_core::state::Checkout::ManagedWorktree { worktree, .. } => {
920            ensure!(
921                worktree.worktree_root == project_directory,
922                "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
923                project_directory.display()
924            );
925            (
926                worktree.worktree_root.clone(),
927                canonical_repository(&worktree.source_repository)?,
928                Some((**worktree).clone()),
929            )
930        }
931        mj_core::state::Checkout::Attached { .. } | mj_core::state::Checkout::Borrowed { .. } => {
932            let inspection =
933                inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
934            ensure!(
935                inspection.source_project_directory == inspection.source_repository,
936                "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
937                project_directory.display()
938            );
939            let source_repository = canonical_repository(&inspection.source_repository)?;
940            (inspection.source_project_directory, source_repository, None)
941        }
942        mj_core::state::Checkout::ManagedWorkspace => {
943            bail!("a bundle workspace cannot be converted as a raw checkout")
944        }
945    };
946    ensure!(
947        project.bundle.repositories.len() == 1,
948        "a raw checkout can only transition with a single-repository bundle"
949    );
950    project.key()?;
951    let repository = project
952        .bundle
953        .repositories
954        .iter()
955        .find(|repository| repository.id == project.bundle.primary_repo)
956        .context("accepted project primary repository is missing")?;
957    let source = if repository.github.is_some() {
958        mj_core::remote_git::resolve_repository(repository, executor)?
959    } else {
960        mj_core::remote_git::resolve_local_repository(&checkout, executor).with_context(|| {
961            format!(
962                "bundle repository {:?} has no network source for {}: add one (for example `git remote add origin <url>`) or resume this session on a bare target",
963                repository.id,
964                checkout.display()
965            )
966        })?
967    };
968    let source_identity = RepositoryIdentity::from_remote(&source.fetch_url)
969        .context("resolved checkout source has no repository identity")?;
970    let accepted_identity = project
971        .identities
972        .get(&repository.id)
973        .context("accepted project repository identity is missing")?;
974    ensure!(
975        accepted_identity == &source_identity
976            || matches!(accepted_identity, RepositoryIdentity::Local(path) if path == &source_repository),
977        "resolved checkout source does not match repository {:?} in the accepted bundle",
978        repository.id
979    );
980    Ok(RawToWorkspaceConversion {
981        checkout,
982        repository_id: repository.id.clone(),
983        destination: repository.destination.clone(),
984        project: project.clone(),
985        source,
986        retire,
987    })
988}
989
990/// Snapshot the host checkout as the repository content an isolated workspace
991/// arrives with: commits that are on no origin ref, plus staged, unstaged, and
992/// untracked work.
993///
994/// The metadata carries the checkout's own network remote, so the container
995/// clones real provenance and its later checkpoints behave like any other
996/// workspace session's.
997pub(super) fn raw_checkout_snapshot(
998    checkout: &Path,
999    repository_id: &str,
1000    source: &mj_core::remote_git::NetworkGitSource,
1001    destination: &Path,
1002    git: &dyn mj_checkpoint::archive::GitCommandRunner,
1003    managed_clone: bool,
1004) -> Result<mj_checkpoint::archive::RepositorySnapshot> {
1005    // Bundling "everything not on origin" only works when origin refs exist:
1006    // every bundle prerequisite then sits on the remote the container clones.
1007    mj_checkpoint::checkpoint::repair_origin_refs(git, checkout, repository_id)?;
1008    reject_dirty_submodules_for_move(git, checkout)?;
1009    let boundary = origin_boundary_commit(git, checkout)?;
1010    let history = if managed_clone {
1011        mj_checkpoint::archive::GitHistoryMode::CloneFrom(
1012            boundary
1013                .clone()
1014                .context("managed clone has no origin boundary commit")?,
1015        )
1016    } else {
1017        mj_checkpoint::archive::GitHistoryMode::SessionDelta
1018    };
1019    let mut snapshot = mj_checkpoint::archive::collect_git_snapshot(
1020        git,
1021        checkout,
1022        &mj_checkpoint::archive::GitCollectionSpec {
1023            id: repository_id.to_owned(),
1024            relative_destination: destination.to_path_buf(),
1025            history,
1026            origin_override: None,
1027        },
1028    )
1029    .with_context(|| format!("snapshot the checkout at {}", checkout.display()))?;
1030    // The resolved remote, not whatever `origin` happens to be: the checkout's
1031    // branch may track another remote. Credentials stay out of the archive.
1032    snapshot.metadata.origin =
1033        mj_checkpoint::archive::redact_origin_credentials(&source.fetch_url)?;
1034    snapshot.metadata.push_urls = source
1035        .push_urls
1036        .iter()
1037        .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
1038        .collect::<Result<Vec<_>>>()?;
1039    snapshot.metadata.remote_workspace = true;
1040    snapshot.metadata.base_commit =
1041        boundary.unwrap_or_else(|| snapshot.metadata.head_commit.clone());
1042    Ok(snapshot)
1043}
1044
1045/// The newest commit the checkout shares with `origin`, which is where a
1046/// converted workspace measures its own session delta from. `None` when HEAD
1047/// is already on an origin ref, leaving no boundary to report.
1048fn origin_boundary_commit(
1049    git: &dyn mj_checkpoint::archive::GitCommandRunner,
1050    checkout: &Path,
1051) -> Result<Option<String>> {
1052    let listed = git_runner_stdout(
1053        git,
1054        checkout,
1055        [
1056            "rev-list",
1057            "--boundary",
1058            "HEAD",
1059            "--not",
1060            "--remotes=origin",
1061        ],
1062        "list commits outside origin",
1063    )?;
1064    // `--boundary` marks the excluded parents of the listed commits with `-`,
1065    // and lists them after the commits themselves.
1066    Ok(listed
1067        .lines()
1068        .filter_map(|line| line.strip_prefix('-'))
1069        .map(|commit| commit.trim().to_owned())
1070        .find(|commit| !commit.is_empty()))
1071}
1072
1073fn git_runner_stdout(
1074    git: &dyn mj_checkpoint::archive::GitCommandRunner,
1075    repository: &Path,
1076    args: impl IntoIterator<Item = impl AsRef<str>>,
1077    purpose: &str,
1078) -> Result<String> {
1079    let output = git.run(
1080        repository,
1081        &mj_checkpoint::archive::GitCommand {
1082            arguments: args
1083                .into_iter()
1084                .map(|argument| std::ffi::OsString::from(argument.as_ref()))
1085                .collect(),
1086            stdin: Vec::new(),
1087            env: Vec::new(),
1088        },
1089    )?;
1090    command_stdout(
1091        CommandOutput {
1092            status: output.status,
1093            stdout: output.stdout,
1094            stderr: output.stderr,
1095        },
1096        purpose,
1097    )
1098}
1099
1100/// Describe a raw-to-workspace conversion for a person to confirm. Reads Git
1101/// and asks the remote for its default branch; changes nothing.
1102#[cfg(test)]
1103pub(super) fn raw_conversion_preview(
1104    session: &SessionRecord,
1105    conversion: &RawToWorkspaceConversion,
1106    executor: &(impl CommandExecutor + Sync),
1107) -> Result<mj_core::state::RawConversionPreview> {
1108    raw_conversion_preview_with_checkout(session, conversion, executor)
1109}
1110
1111pub(super) fn raw_conversion_preview_with_checkout(
1112    session: &SessionRecord,
1113    conversion: &RawToWorkspaceConversion,
1114    executor: &(impl CommandExecutor + Sync),
1115) -> Result<mj_core::state::RawConversionPreview> {
1116    let checkout = conversion.checkout.as_path();
1117    // A dirty submodule cannot be captured, so say so now rather than failing
1118    // after the session has been stopped.
1119    reject_dirty_submodules_for_move(&ExecutorGit(executor), checkout)?;
1120    let default_branch = mj_core::remote_git::default_branch(&conversion.source, executor)?;
1121    let position = read_checkout_position(executor, &ManagedWorktreeTarget::Local, checkout)?;
1122    let unpushed_commits = unpushed_commit_count(executor, checkout)?;
1123    let dirty = dirty_file_counts(executor, checkout)?;
1124    // A raw session has no container, so the move builds it one and the
1125    // checkout lands in the per-session workspace this preview names. A session
1126    // that predates per-session workspaces and still records none keeps the
1127    // shared one only if it already has a container, which a raw session never
1128    // does.
1129    let container_workspace = match session.container_workspace.clone() {
1130        Some(workspace) => workspace,
1131        None => mj_core::targets::new_container_workspace(&session.id)?,
1132    };
1133    Ok(mj_core::state::RawConversionPreview {
1134        checkout: checkout.to_path_buf(),
1135        destination: container_workspace.join(&conversion.destination),
1136        branch: position.branch,
1137        fetch_url: conversion.source.fetch_url.clone(),
1138        push_urls: conversion.source.push_urls.clone(),
1139        default_branch,
1140        unpushed_commits,
1141        staged_files: dirty.staged_files,
1142        unstaged_files: dirty.unstaged_files,
1143        untracked_files: dirty.untracked_files,
1144        untracked_bytes: untracked_bytes(executor, checkout)?,
1145        host_checkout_retained: conversion.retire.is_none(),
1146    })
1147}
1148
1149/// The conversion snapshot carries each gitlink as the commit it points to,
1150/// so uncommitted work in a submodule would not arrive. A gitlink with no
1151/// `.gitmodules` entry never blocks the move; it is logged because its files
1152/// stay behind.
1153fn reject_dirty_submodules_for_move(
1154    git: &dyn mj_checkpoint::archive::GitCommandRunner,
1155    checkout: &Path,
1156) -> Result<()> {
1157    let inspection = mj_checkpoint::checkpoint::inspect_submodules(git, checkout)
1158        .with_context(|| format!("checkout {}", checkout.display()))?;
1159    for gitlink in &inspection.unregistered {
1160        tracing::warn!(
1161            checkout = %checkout.display(),
1162            gitlink = %gitlink.display(),
1163            "gitlink has no .gitmodules entry; the move carries the commit it points to, not its files"
1164        );
1165    }
1166    if let Some(dirty) = inspection.dirty_summary() {
1167        bail!(
1168            "{}: {dirty}, which cannot move into a target; commit or stash them first",
1169            checkout.display()
1170        );
1171    }
1172    Ok(())
1173}
1174
1175/// Runs the checkpoint library's submodule inspection in a local checkout
1176/// through a controller executor, so it keeps the caller's cancellation and
1177/// deadline.
1178struct ExecutorGit<'a, E>(&'a E);
1179
1180impl<E: CommandExecutor + Sync> mj_checkpoint::archive::GitCommandRunner for ExecutorGit<'_, E> {
1181    fn run(
1182        &self,
1183        repository: &Path,
1184        command: &mj_checkpoint::archive::GitCommand,
1185    ) -> Result<mj_checkpoint::archive::GitOutput> {
1186        ensure!(
1187            command.stdin.is_empty() && command.env.is_empty(),
1188            "an executor Git command takes no standard input or extra environment"
1189        );
1190        let output = self.0.execute(&managed_git_command(
1191            &ManagedWorktreeTarget::Local,
1192            repository,
1193            command
1194                .arguments
1195                .iter()
1196                .map(|argument| argument.to_string_lossy().into_owned()),
1197            "inspect submodules",
1198        ))?;
1199        Ok(mj_checkpoint::archive::GitOutput {
1200            status: output.status,
1201            stdout: output.stdout,
1202            stderr: output.stderr,
1203        })
1204    }
1205}
1206
1207/// Commits the conversion archive has to carry. A checkout whose origin refs
1208/// are missing even after a repair fetch reports nothing rather than counting
1209/// its entire history as unpushed.
1210fn unpushed_commit_count(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1211    if !origin_refs_available(executor, checkout)? {
1212        return Ok(0);
1213    }
1214    let counted = managed_git_stdout(
1215        executor,
1216        &ManagedWorktreeTarget::Local,
1217        checkout,
1218        ["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
1219        "count commits outside origin",
1220    )?;
1221    counted
1222        .trim()
1223        .parse()
1224        .with_context(|| format!("parse the commit count {counted:?}"))
1225}
1226
1227fn origin_refs_available(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
1228    if origin_refs_listed(executor, checkout)? {
1229        return Ok(true);
1230    }
1231    // A checkout that has never fetched has no origin refs yet. Try once; a
1232    // remote that cannot be reached leaves the count unreported, not failed.
1233    let fetch = managed_git_command(
1234        &ManagedWorktreeTarget::Local,
1235        checkout,
1236        ["fetch", "origin"],
1237        "fetch origin refs",
1238    );
1239    executor.execute(&fetch)?;
1240    origin_refs_listed(executor, checkout)
1241}
1242
1243fn origin_refs_listed(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
1244    managed_git_stdout(
1245        executor,
1246        &ManagedWorktreeTarget::Local,
1247        checkout,
1248        [
1249            "for-each-ref",
1250            "--format=%(objectname)",
1251            "refs/remotes/origin",
1252        ],
1253        "list origin refs",
1254    )
1255    .map(|refs| !refs.trim().is_empty())
1256}
1257
1258#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1259struct DirtyFileCounts {
1260    staged_files: u64,
1261    unstaged_files: u64,
1262    untracked_files: u64,
1263}
1264
1265/// Count what `git status` reports, one entry per path. A rename's second
1266/// record names the original path, so it is consumed rather than counted.
1267fn dirty_file_counts(executor: &impl CommandExecutor, checkout: &Path) -> Result<DirtyFileCounts> {
1268    let command = managed_git_command(
1269        &ManagedWorktreeTarget::Local,
1270        checkout,
1271        ["status", "--porcelain=v1", "-z"],
1272        "read checkout status",
1273    );
1274    let output = executor.execute(&command)?;
1275    ensure!(
1276        output.status == 0,
1277        "read checkout status failed with status {}: {}",
1278        output.status,
1279        String::from_utf8_lossy(&output.stderr).trim()
1280    );
1281    let mut counts = DirtyFileCounts::default();
1282    let mut records = output
1283        .stdout
1284        .split(|byte| *byte == 0)
1285        .filter(|record| !record.is_empty());
1286    while let Some(record) = records.next() {
1287        let [index, worktree, ..] = record else {
1288            bail!("git status produced a record shorter than its status field");
1289        };
1290        if *index == b'?' && *worktree == b'?' {
1291            counts.untracked_files += 1;
1292            continue;
1293        }
1294        if !matches!(index, b' ' | b'?') {
1295            counts.staged_files += 1;
1296        }
1297        if !matches!(worktree, b' ' | b'?') {
1298            counts.unstaged_files += 1;
1299        }
1300        if *index == b'R' || *index == b'C' || *worktree == b'R' || *worktree == b'C' {
1301            records.next();
1302        }
1303    }
1304    Ok(counts)
1305}
1306
1307/// How much untracked content the conversion archive has to carry. `git status`
1308/// collapses an untracked directory into one entry, so the bytes come from the
1309/// file list instead.
1310fn untracked_bytes(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1311    let command = managed_git_command(
1312        &ManagedWorktreeTarget::Local,
1313        checkout,
1314        ["ls-files", "--others", "--exclude-standard", "-z"],
1315        "list untracked files",
1316    );
1317    let output = executor.execute(&command)?;
1318    ensure!(
1319        output.status == 0,
1320        "list untracked files failed with status {}: {}",
1321        output.status,
1322        String::from_utf8_lossy(&output.stderr).trim()
1323    );
1324    let mut total = 0;
1325    for record in output
1326        .stdout
1327        .split(|byte| *byte == 0)
1328        .filter(|record| !record.is_empty())
1329    {
1330        let relative = mj_core::path_input::from_git_bytes(record)?;
1331        let path = checkout.join(relative);
1332        // Do not follow links, and tolerate a file the agent removed between
1333        // the listing and this read.
1334        match std::fs::symlink_metadata(&path) {
1335            Ok(metadata) => total += metadata.len(),
1336            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1337            Err(error) => {
1338                return Err(error).with_context(|| format!("measure {}", path.display()));
1339            }
1340        }
1341    }
1342    Ok(total)
1343}
1344
1345/// Where a checkout stands: its head commit and, unless detached, its branch.
1346#[derive(Debug, Clone, PartialEq, Eq)]
1347pub(super) struct CheckoutPosition {
1348    pub(super) head_commit: String,
1349    branch: Option<String>,
1350}
1351
1352fn read_checkout_position(
1353    executor: &impl CommandExecutor,
1354    target: &ManagedWorktreeTarget,
1355    directory: &Path,
1356) -> Result<CheckoutPosition> {
1357    let head_commit = managed_git_stdout(
1358        executor,
1359        target,
1360        directory,
1361        ["rev-parse", "HEAD"],
1362        "resolve checkout head commit",
1363    )?;
1364    let branch_command = managed_git_command(
1365        target,
1366        directory,
1367        ["symbolic-ref", "--quiet", "--short", "HEAD"],
1368        "resolve checkout branch",
1369    );
1370    let branch_output = executor.execute(&branch_command)?;
1371    let branch = match branch_output.status {
1372        0 => Some(
1373            String::from_utf8(branch_output.stdout)
1374                .context("checkout branch was not UTF-8")?
1375                .trim()
1376                .to_owned(),
1377        ),
1378        // A detached head reports no branch rather than failing.
1379        1 | 128 => None,
1380        status => bail!(
1381            "resolve checkout branch failed with status {status}: {}",
1382            String::from_utf8_lossy(&branch_output.stderr).trim()
1383        ),
1384    };
1385    Ok(CheckoutPosition {
1386        head_commit,
1387        branch,
1388    })
1389}
1390
1391/// The commit the session branch was created at, as the base for diffs and
1392/// checkpoint bundles. Prefers the recorded base; sessions created before it
1393/// was recorded fall back to the branch reflog, like `branch_creation_commit`
1394/// in mj-checkpoint. A reflog that has expired leaves only the live head,
1395/// which yields an empty bundle rather than a failed checkpoint.
1396pub(super) fn managed_worktree_base_commit(
1397    worktree: &ManagedWorktree,
1398    executor: &impl CommandExecutor,
1399) -> Result<String> {
1400    if let Some(base) = &worktree.base_commit {
1401        return Ok(base.clone());
1402    }
1403    let reference = format!("refs/heads/{}", worktree.branch);
1404    let reflog_command = managed_git_command(
1405        &worktree.target,
1406        &worktree.source_repository,
1407        ["reflog", "show", "--format=%H", &reference],
1408        "read the session branch reflog",
1409    );
1410    let reflog_output = executor.execute(&reflog_command)?;
1411    if reflog_output.status == 0 {
1412        let text = String::from_utf8(reflog_output.stdout)
1413            .context("the session branch reflog was not UTF-8")?;
1414        // The oldest entry is the branch's creation, so it is where the session
1415        // started.
1416        if let Some(creation) = text.lines().rfind(|line| !line.trim().is_empty()) {
1417            return Ok(creation.trim().to_owned());
1418        }
1419    }
1420    let head = read_checkout_position(executor, &worktree.target, &worktree.worktree_root)?;
1421    tracing::warn!(
1422        branch = %worktree.branch,
1423        "the reflog for this session branch is gone, so its checkpoint bundle will carry no commits"
1424    );
1425    Ok(head.head_commit)
1426}
1427
1428/// Read where a raw session's checkout stands right now, on whichever host
1429/// owns it.
1430#[cfg(test)]
1431pub(super) fn raw_checkout_position(
1432    session: &SessionRecord,
1433    config: &Config,
1434    project_directory: &Path,
1435    executor: &impl CommandExecutor,
1436) -> Result<CheckoutPosition> {
1437    let checkout = match session.checkout() {
1438        checkout @ mj_core::state::Checkout::ManagedWorktree { .. } => checkout,
1439        _ => mj_core::state::Checkout::Attached {
1440            path: project_directory,
1441        },
1442    };
1443    raw_checkout_position_with_checkout(session, &checkout, config, project_directory, executor)
1444}
1445
1446pub(super) fn raw_checkout_position_with_checkout(
1447    session: &SessionRecord,
1448    checkout: &mj_core::state::Checkout<'_>,
1449    config: &Config,
1450    project_directory: &Path,
1451    executor: &impl CommandExecutor,
1452) -> Result<CheckoutPosition> {
1453    let target = match checkout.effective() {
1454        mj_core::state::Checkout::ManagedWorktree { worktree, .. } => worktree.target.clone(),
1455        mj_core::state::Checkout::Attached { .. } => {
1456            let runtime = session.target_runtime_settings(config)?;
1457            match (&*runtime.kind, &runtime.connection) {
1458                ("local-bare", mj_core::state::TargetConnection::Local) => {
1459                    ManagedWorktreeTarget::Local
1460                }
1461                ("ssh-bare", mj_core::state::TargetConnection::Ssh { ssh }) => {
1462                    let ssh = targets::SshTarget::from(ssh);
1463                    ManagedWorktreeTarget::Ssh {
1464                        destination: ssh.destination,
1465                        ssh_args: ssh.ssh_args,
1466                    }
1467                }
1468                _ => bail!("the session's recorded target is not a bare checkout"),
1469            }
1470        }
1471        mj_core::state::Checkout::ManagedWorkspace | mj_core::state::Checkout::Borrowed { .. } => {
1472            bail!("the session's recorded target is not a bare checkout")
1473        }
1474    };
1475    read_checkout_position(executor, &target, project_directory)
1476}
1477
1478/// One conversation line for a raw session whose checkout moved on while the
1479/// session was stopped. `None` when the checkout is where the checkpoint left
1480/// it, or when the checkpoint recorded no repository to compare against.
1481///
1482/// This reports; it never reconciles. The working tree is the truth.
1483pub(super) fn raw_checkout_divergence_notice(
1484    directory: &Path,
1485    recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
1486    live: &CheckoutPosition,
1487) -> Option<String> {
1488    let recorded = recorded?;
1489    if recorded.head_commit.is_empty()
1490        || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
1491    {
1492        return None;
1493    }
1494    Some(format!(
1495        "The working tree at {} moved from {} to {} while this session was stopped.",
1496        directory.display(),
1497        checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
1498        checkout_position_text(&live.head_commit, live.branch.as_deref()),
1499    ))
1500}
1501
1502fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
1503    let short = head_commit.get(..12).unwrap_or(head_commit);
1504    match branch {
1505        Some(branch) => format!("{short} ({branch})"),
1506        None => format!("{short} (detached)"),
1507    }
1508}
1509
1510fn inspect_raw_project(
1511    executor: &impl CommandExecutor,
1512    target: &ManagedWorktreeTarget,
1513    selected: &Path,
1514) -> Result<RawProjectInspection> {
1515    let repository = PathBuf::from(managed_git_stdout(
1516        executor,
1517        target,
1518        selected,
1519        ["rev-parse", "--show-toplevel"],
1520        "resolve raw project repository root",
1521    )?);
1522    let prefix = managed_git_stdout(
1523        executor,
1524        target,
1525        selected,
1526        ["rev-parse", "--show-prefix"],
1527        "resolve raw project relative directory",
1528    )?;
1529    let git_dir = PathBuf::from(managed_git_stdout(
1530        executor,
1531        target,
1532        selected,
1533        ["rev-parse", "--absolute-git-dir"],
1534        "resolve raw project Git directory",
1535    )?);
1536    // Git prints `--absolute-git-dir` with symlinks resolved, so resolve the
1537    // relative common directory against Git's spelling of `selected` too.
1538    let common_git_dir = mj_core::local_git::resolve_git_path(
1539        &repository.join(&prefix),
1540        &managed_git_stdout(
1541            executor,
1542            target,
1543            selected,
1544            ["rev-parse", "--git-common-dir"],
1545            "resolve raw project common Git directory",
1546        )?,
1547    )?;
1548    let branch_command = managed_git_command(
1549        target,
1550        selected,
1551        ["symbolic-ref", "--quiet", "--short", "HEAD"],
1552        "resolve raw project branch",
1553    );
1554    let branch_output = executor.execute(&branch_command)?;
1555    let branch = match branch_output.status {
1556        0 => Some(
1557            String::from_utf8(branch_output.stdout)
1558                .context("raw project branch was not UTF-8")?
1559                .trim()
1560                .to_owned(),
1561        ),
1562        1 | 128 => None,
1563        status => bail!(
1564            "resolve raw project branch failed with status {status}: {}",
1565            String::from_utf8_lossy(&branch_output.stderr).trim()
1566        ),
1567    };
1568    let upstream = match branch {
1569        Some(branch) => {
1570            let reference = format!("refs/heads/{branch}");
1571            let upstream = managed_git_stdout(
1572                executor,
1573                target,
1574                selected,
1575                ["for-each-ref", "--format=%(upstream:short)", &reference],
1576                "resolve raw project upstream",
1577            )?;
1578            (!upstream.is_empty()).then_some(upstream)
1579        }
1580        None => None,
1581    };
1582    Ok(RawProjectInspection {
1583        source_project_directory: repository.join(prefix),
1584        source_repository: repository,
1585        primary_checkout: git_dir == common_git_dir,
1586        upstream,
1587    })
1588}
1589
1590fn ensure_managed_worktree_excluded(
1591    executor: &impl CommandExecutor,
1592    target: &ManagedWorktreeTarget,
1593    repository: &Path,
1594    kind: ManagedCheckoutKind,
1595) -> Result<()> {
1596    let (path, entry) = match kind {
1597        ManagedCheckoutKind::Worktree => (".mj/worktrees/", "/.mj/worktrees/"),
1598        ManagedCheckoutKind::Clone => (".mj/clones/", "/.mj/clones/"),
1599    };
1600    let check = managed_git_command(
1601        target,
1602        repository,
1603        ["check-ignore", "--quiet", "--no-index", "--", path],
1604        "check managed worktree exclusion",
1605    );
1606    let output = executor.execute(&check)?;
1607    match output.status {
1608        0 => return Ok(()),
1609        1 => {}
1610        status => bail!(
1611            "check managed worktree exclusion failed with status {status}: {}",
1612            String::from_utf8_lossy(&output.stderr).trim()
1613        ),
1614    }
1615    let exclude_path = mj_core::local_git::resolve_git_path(
1616        repository,
1617        &managed_git_stdout(
1618            executor,
1619            target,
1620            repository,
1621            ["rev-parse", "--git-path", "info/exclude"],
1622            "resolve repository-local exclude file",
1623        )?,
1624    )?;
1625    match target {
1626        ManagedWorktreeTarget::Local => {
1627            use std::io::Write;
1628            let existing = match std::fs::read_to_string(&exclude_path) {
1629                Ok(existing) => existing,
1630                Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1631                Err(error) => return Err(error.into()),
1632            };
1633            if existing.lines().any(|line| line.trim() == entry) {
1634                return Ok(());
1635            }
1636            if let Some(parent) = exclude_path.parent() {
1637                std::fs::create_dir_all(parent)?;
1638            }
1639            let mut file = std::fs::OpenOptions::new()
1640                .create(true)
1641                .append(true)
1642                .open(&exclude_path)
1643                .with_context(|| format!("open {}", exclude_path.display()))?;
1644            if !existing.is_empty() && !existing.ends_with('\n') {
1645                writeln!(file)?;
1646            }
1647            writeln!(file, "# Mjolnir managed checkouts\n{entry}")?;
1648        }
1649        ManagedWorktreeTarget::Ssh { .. } => {
1650            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";
1651            let command = managed_target_command(
1652                target,
1653                "sh",
1654                [
1655                    "-c",
1656                    SCRIPT,
1657                    "hel-exclude",
1658                    &exclude_path.to_string_lossy(),
1659                    entry,
1660                ],
1661            )
1662            .purpose("update remote repository-local exclude file");
1663            execute_checked(executor, command)?;
1664        }
1665    }
1666    Ok(())
1667}
1668
1669pub(crate) fn path_exists_on_managed_target(
1670    executor: &impl CommandExecutor,
1671    target: &ManagedWorktreeTarget,
1672    path: &Path,
1673) -> Result<bool> {
1674    match target {
1675        ManagedWorktreeTarget::Local => path
1676            .try_exists()
1677            .with_context(|| format!("check managed project path {}", path.display())),
1678        ManagedWorktreeTarget::Ssh { .. } => {
1679            let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
1680                .purpose("check managed worktree path");
1681            let output = executor.execute(&command)?;
1682            match output.status {
1683                0 => Ok(true),
1684                1 => Ok(false),
1685                status => bail!(
1686                    "check managed worktree path failed with status {status}: {}",
1687                    String::from_utf8_lossy(&output.stderr).trim()
1688                ),
1689            }
1690        }
1691    }
1692}
1693
1694pub(super) fn managed_worktree_checkout_exists(
1695    executor: &impl CommandExecutor,
1696    worktree: &ManagedWorktree,
1697) -> Result<bool> {
1698    path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1699}
1700
1701/// Whether a managed worktree's checkout holds work that removing it would
1702/// destroy. A checkout that is already gone holds nothing.
1703///
1704/// This asks the session's own worktree the porcelain question
1705/// [`create_managed_worktree`] asks of the primary checkout.
1706pub(super) fn managed_worktree_checkout_is_dirty(
1707    executor: &impl CommandExecutor,
1708    worktree: &ManagedWorktree,
1709) -> Result<bool> {
1710    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1711        return Ok(false);
1712    }
1713    let status = managed_git_stdout(
1714        executor,
1715        &worktree.target,
1716        &worktree.worktree_root,
1717        ["status", "--porcelain=v1", "--untracked-files=all"],
1718        "inspect managed worktree changes",
1719    )?;
1720    Ok(!status.is_empty())
1721}
1722
1723/// Whether a new managed worktree needs the primary checkout to be clean.
1724#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1725pub(super) enum PrimaryCheckoutRequirement {
1726    /// A new raw session starts from the primary checkout's HEAD, so work that
1727    /// is only in its working tree would be silently left behind.
1728    Clean,
1729    /// A session moving out of its target replaces the worktree's contents from
1730    /// its checkpoint, so the primary checkout's own changes are beside the
1731    /// point.
1732    Any,
1733}
1734
1735pub(super) fn create_managed_worktree(
1736    executor: &impl CommandExecutor,
1737    worktree: &ManagedWorktree,
1738    upstream: Option<&str>,
1739    requirement: PrimaryCheckoutRequirement,
1740) -> Result<()> {
1741    ensure_managed_worktree_excluded(
1742        executor,
1743        &worktree.target,
1744        &worktree.source_repository,
1745        worktree.kind,
1746    )?;
1747    if worktree.kind == ManagedCheckoutKind::Clone {
1748        return create_managed_clone(executor, worktree);
1749    }
1750    if requirement == PrimaryCheckoutRequirement::Clean {
1751        let status = managed_git_stdout(
1752            executor,
1753            &worktree.target,
1754            &worktree.source_repository,
1755            ["status", "--porcelain=v1", "--untracked-files=all"],
1756            "inspect primary checkout changes",
1757        )?;
1758        if !status.is_empty() {
1759            let paths = status.lines().take(20).collect::<Vec<_>>().join("\n  ");
1760            bail!(
1761                "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n  {paths}"
1762            );
1763        }
1764    }
1765    let parent = worktree
1766        .worktree_root
1767        .parent()
1768        .context("managed worktree root has no parent")?;
1769    execute_checked(
1770        executor,
1771        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1772            .purpose("create managed worktree directory"),
1773    )?;
1774    execute_checked(
1775        executor,
1776        managed_git_command(
1777            &worktree.target,
1778            &worktree.source_repository,
1779            [
1780                "worktree",
1781                "add",
1782                "-b",
1783                &worktree.branch,
1784                &worktree.worktree_root.to_string_lossy(),
1785                worktree.base_commit.as_deref().unwrap_or("HEAD"),
1786            ],
1787            "create managed raw-session worktree",
1788        ),
1789    )?;
1790    if let Some(upstream) = upstream {
1791        execute_checked(
1792            executor,
1793            managed_git_command(
1794                &worktree.target,
1795                &worktree.worktree_root,
1796                ["branch", "--set-upstream-to", upstream, &worktree.branch],
1797                "set managed worktree branch upstream",
1798            ),
1799        )?;
1800    }
1801    Ok(())
1802}
1803
1804fn create_managed_clone(executor: &impl CommandExecutor, checkout: &ManagedWorktree) -> Result<()> {
1805    let parent = checkout
1806        .worktree_root
1807        .parent()
1808        .context("managed clone has no parent")?;
1809    let staging = checkout.worktree_root.with_extension("provisioning");
1810    ensure!(
1811        !path_exists_on_managed_target(executor, &checkout.target, &staging)?
1812            && !path_exists_on_managed_target(executor, &checkout.target, &checkout.worktree_root)?,
1813        "managed clone path is already occupied: {}",
1814        checkout.worktree_root.display()
1815    );
1816    execute_checked(
1817        executor,
1818        managed_target_command(&checkout.target, "mkdir", ["-p", &parent.to_string_lossy()])
1819            .purpose("create managed clone parent"),
1820    )?;
1821    let create = (|| -> Result<()> {
1822        execute_checked(
1823            executor,
1824            managed_target_command(
1825                &checkout.target,
1826                "git",
1827                [
1828                    "clone",
1829                    "--local",
1830                    "--dissociate",
1831                    "--no-checkout",
1832                    "--",
1833                    &checkout.source_repository.to_string_lossy(),
1834                    &staging.to_string_lossy(),
1835                ],
1836            )
1837            .purpose("seed independent managed clone"),
1838        )?;
1839        let origin = executor.execute(&managed_git_command(
1840            &checkout.target,
1841            &checkout.source_repository,
1842            ["config", "--get", "remote.origin.url"],
1843            "read source origin URL",
1844        ))?;
1845        execute_checked(
1846            executor,
1847            managed_git_command(
1848                &checkout.target,
1849                &staging,
1850                ["remote", "remove", "origin"],
1851                "discard local seed as clone remote",
1852            ),
1853        )?;
1854        match origin.status {
1855            0 => {
1856                let url = String::from_utf8(origin.stdout)?;
1857                execute_checked(
1858                    executor,
1859                    managed_git_command(
1860                        &checkout.target,
1861                        &staging,
1862                        ["remote", "add", "origin", url.trim()],
1863                        "set clone fetch and push remote",
1864                    ),
1865                )?;
1866                copy_clone_push_configuration(executor, checkout, &staging)?;
1867                copy_source_origin_refs(executor, checkout, &staging)?;
1868            }
1869            1 => {}
1870            status => bail!(
1871                "read source origin URL failed with status {status}: {}",
1872                String::from_utf8_lossy(&origin.stderr).trim()
1873            ),
1874        }
1875        copy_clone_local_git_preferences(executor, checkout, &staging)?;
1876        execute_checked(
1877            executor,
1878            managed_git_command(
1879                &checkout.target,
1880                &staging,
1881                [
1882                    "switch",
1883                    "--no-track",
1884                    "-C",
1885                    &checkout.branch,
1886                    checkout
1887                        .base_commit
1888                        .as_deref()
1889                        .context("managed clone has no launch commit")?,
1890                ],
1891                "select managed clone starting branch",
1892            ),
1893        )?;
1894        if origin.status == 0 {
1895            execute_checked(
1896                executor,
1897                managed_git_command(
1898                    &checkout.target,
1899                    &staging,
1900                    [
1901                        "config",
1902                        "--local",
1903                        &format!("branch.{}.remote", checkout.branch),
1904                        "origin",
1905                    ],
1906                    "set clone branch push remote",
1907                ),
1908            )?;
1909            execute_checked(
1910                executor,
1911                managed_git_command(
1912                    &checkout.target,
1913                    &staging,
1914                    [
1915                        "config",
1916                        "--local",
1917                        &format!("branch.{}.merge", checkout.branch),
1918                        &format!("refs/heads/{}", checkout.branch),
1919                    ],
1920                    "set clone branch tracking name",
1921                ),
1922            )?;
1923        }
1924        execute_checked(
1925            executor,
1926            managed_target_command(
1927                &checkout.target,
1928                "mv",
1929                [
1930                    "--",
1931                    &staging.to_string_lossy(),
1932                    &checkout.worktree_root.to_string_lossy(),
1933                ],
1934            )
1935            .purpose("publish managed clone checkout"),
1936        )?;
1937        Ok(())
1938    })();
1939    if create.is_err() && path_exists_on_managed_target(executor, &checkout.target, &staging)? {
1940        execute_checked(
1941            executor,
1942            managed_target_command(
1943                &checkout.target,
1944                "rm",
1945                ["-rf", "--", &staging.to_string_lossy()],
1946            )
1947            .purpose("remove failed managed clone staging directory"),
1948        )?;
1949    }
1950    create
1951}
1952
1953fn copy_clone_push_configuration(
1954    executor: &impl CommandExecutor,
1955    checkout: &ManagedWorktree,
1956    staging: &Path,
1957) -> Result<()> {
1958    let output = executor.execute(&managed_git_command(
1959        &checkout.target,
1960        &checkout.source_repository,
1961        ["config", "--local", "--get-all", "remote.origin.pushurl"],
1962        "read source push destinations",
1963    ))?;
1964    match output.status {
1965        0 => {
1966            for url in String::from_utf8(output.stdout)?
1967                .lines()
1968                .filter(|line| !line.is_empty())
1969            {
1970                execute_checked(
1971                    executor,
1972                    managed_git_command(
1973                        &checkout.target,
1974                        staging,
1975                        ["remote", "set-url", "--push", "--add", "origin", url],
1976                        "preserve clone push destination",
1977                    ),
1978                )?;
1979            }
1980        }
1981        1 => {}
1982        status => bail!("read source push destinations failed with status {status}"),
1983    }
1984    Ok(())
1985}
1986
1987fn copy_source_origin_refs(
1988    executor: &impl CommandExecutor,
1989    checkout: &ManagedWorktree,
1990    staging: &Path,
1991) -> Result<()> {
1992    let refs = managed_git_stdout(
1993        executor,
1994        &checkout.target,
1995        &checkout.source_repository,
1996        [
1997            "for-each-ref",
1998            "--format=%(refname) %(objectname)",
1999            "refs/remotes/origin",
2000        ],
2001        "read cached origin branches",
2002    )?;
2003    for line in refs.lines() {
2004        let (name, oid) = line
2005            .split_once(' ')
2006            .context("malformed source remote ref")?;
2007        if name == "refs/remotes/origin/HEAD" {
2008            continue;
2009        }
2010        execute_checked(
2011            executor,
2012            managed_git_command(
2013                &checkout.target,
2014                staging,
2015                ["update-ref", name, oid],
2016                "preserve cached origin branch",
2017            ),
2018        )?;
2019    }
2020    Ok(())
2021}
2022
2023fn copy_clone_local_git_preferences(
2024    executor: &impl CommandExecutor,
2025    checkout: &ManagedWorktree,
2026    staging: &Path,
2027) -> Result<()> {
2028    let config = executor.execute(&managed_git_command(
2029        &checkout.target,
2030        &checkout.source_repository,
2031        ["config", "--local", "--null", "--list"],
2032        "read source Git preferences",
2033    ))?;
2034    ensure!(
2035        config.status == 0,
2036        "read source Git preferences failed with status {}",
2037        config.status
2038    );
2039    for entry in config
2040        .stdout
2041        .split(|byte| *byte == 0)
2042        .filter(|entry| !entry.is_empty())
2043    {
2044        let Some(split) = entry.iter().position(|byte| *byte == b'\n') else {
2045            bail!("source Git configuration contains a malformed entry");
2046        };
2047        let key = std::str::from_utf8(&entry[..split])?;
2048        if !clone_local_preference(key) {
2049            continue;
2050        }
2051        let value = std::str::from_utf8(&entry[split + 1..])?;
2052        execute_checked(
2053            executor,
2054            managed_git_command(
2055                &checkout.target,
2056                staging,
2057                ["config", "--local", "--add", key, value],
2058                "preserve Git identity and local preferences",
2059            ),
2060        )?;
2061    }
2062    let source_exclude = mj_core::local_git::resolve_git_path(
2063        &checkout.source_repository,
2064        &managed_git_stdout(
2065            executor,
2066            &checkout.target,
2067            &checkout.source_repository,
2068            ["rev-parse", "--git-path", "info/exclude"],
2069            "locate source Git exclusions",
2070        )?,
2071    )?;
2072    if path_exists_on_managed_target(executor, &checkout.target, &source_exclude)? {
2073        let clone_exclude = mj_core::local_git::resolve_git_path(
2074            staging,
2075            &managed_git_stdout(
2076                executor,
2077                &checkout.target,
2078                staging,
2079                ["rev-parse", "--git-path", "info/exclude"],
2080                "locate clone Git exclusions",
2081            )?,
2082        )?;
2083        execute_checked(
2084            executor,
2085            managed_target_command(
2086                &checkout.target,
2087                "cp",
2088                [
2089                    "--",
2090                    &source_exclude.to_string_lossy(),
2091                    &clone_exclude.to_string_lossy(),
2092                ],
2093            )
2094            .purpose("preserve source Git exclusions"),
2095        )?;
2096    }
2097    Ok(())
2098}
2099
2100fn clone_local_preference(key: &str) -> bool {
2101    key.starts_with("user.")
2102        || key.starts_with("commit.")
2103        || key.starts_with("gpg.")
2104        || key.starts_with("credential.")
2105        || key.starts_with("url.")
2106        || key.starts_with("push.")
2107        || matches!(
2108            key,
2109            "core.hookspath" | "core.excludesfile" | "core.attributesfile" | "core.sshcommand"
2110        )
2111}
2112
2113/// Recreate a retired checkout from the session branch. Returns whether this
2114/// call created it, so a failed resume can put the session back into its
2115/// stopped, checkout-free state.
2116pub(super) fn restore_managed_worktree(
2117    executor: &impl CommandExecutor,
2118    worktree: &ManagedWorktree,
2119) -> Result<bool> {
2120    if managed_worktree_checkout_exists(executor, worktree)? {
2121        return Ok(false);
2122    }
2123    if worktree.kind == ManagedCheckoutKind::Clone {
2124        create_managed_worktree(executor, worktree, None, PrimaryCheckoutRequirement::Any)?;
2125        return Ok(true);
2126    }
2127    ensure!(
2128        path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
2129        "managed worktree source repository is unavailable: {}",
2130        worktree.source_repository.display()
2131    );
2132    let branch_ref = format!("refs/heads/{}", worktree.branch);
2133    let check = managed_git_command(
2134        &worktree.target,
2135        &worktree.source_repository,
2136        ["show-ref", "--verify", "--quiet", &branch_ref],
2137        "check retired managed worktree branch",
2138    );
2139    let output = executor.execute(&check)?;
2140    match output.status {
2141        0 => {}
2142        1 => bail!(
2143            "managed worktree branch is unavailable: {}",
2144            worktree.branch
2145        ),
2146        status => bail!(
2147            "check retired managed worktree branch failed with status {status}: {}",
2148            String::from_utf8_lossy(&output.stderr).trim()
2149        ),
2150    }
2151    // A remote bare target may already have removed the checkout directory.
2152    // Prune its stale registration before adding the retained branch again.
2153    execute_checked(
2154        executor,
2155        managed_git_command(
2156            &worktree.target,
2157            &worktree.source_repository,
2158            ["worktree", "prune"],
2159            "prune retired managed worktree metadata",
2160        ),
2161    )?;
2162    let parent = worktree
2163        .worktree_root
2164        .parent()
2165        .context("managed worktree root has no parent")?;
2166    execute_checked(
2167        executor,
2168        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
2169            .purpose("recreate managed worktree directory"),
2170    )?;
2171    execute_checked(
2172        executor,
2173        managed_git_command(
2174            &worktree.target,
2175            &worktree.source_repository,
2176            [
2177                "worktree",
2178                "add",
2179                "--",
2180                &worktree.worktree_root.to_string_lossy(),
2181                &worktree.branch,
2182            ],
2183            "restore managed raw-session worktree",
2184        ),
2185    )?;
2186    Ok(true)
2187}
2188
2189fn ensure_managed_worktree_available(
2190    executor: &impl CommandExecutor,
2191    worktree: &ManagedWorktree,
2192) -> Result<()> {
2193    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2194        bail!(
2195            "managed worktree path already exists: {}",
2196            worktree.worktree_root.display()
2197        );
2198    }
2199    if worktree.kind == ManagedCheckoutKind::Clone {
2200        return Ok(());
2201    }
2202    let branch_ref = format!("refs/heads/{}", worktree.branch);
2203    let check = managed_git_command(
2204        &worktree.target,
2205        &worktree.source_repository,
2206        ["show-ref", "--verify", "--quiet", &branch_ref],
2207        "check managed worktree branch availability",
2208    );
2209    let output = executor.execute(&check)?;
2210    match output.status {
2211        0 => bail!(
2212            "managed worktree branch already exists: {}",
2213            worktree.branch
2214        ),
2215        1 => Ok(()),
2216        status => bail!(
2217            "check managed worktree branch availability failed with status {status}: {}",
2218            String::from_utf8_lossy(&output.stderr).trim()
2219        ),
2220    }
2221}
2222
2223/// Check whether the deterministic branch left by this session's earlier
2224/// raw-to-workspace move can be reattached. A branch with this session's id is
2225/// session-owned, but an active checkout elsewhere is still a collision: the
2226/// restore must not make one branch belong to two worktrees.
2227fn retained_managed_worktree_branch_available(
2228    executor: &impl CommandExecutor,
2229    worktree: &ManagedWorktree,
2230) -> Result<bool> {
2231    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2232        bail!(
2233            "managed worktree path already exists: {}",
2234            worktree.worktree_root.display()
2235        );
2236    }
2237    let branch_ref = format!("refs/heads/{}", worktree.branch);
2238    let check = managed_git_command(
2239        &worktree.target,
2240        &worktree.source_repository,
2241        ["show-ref", "--verify", "--quiet", &branch_ref],
2242        "check retained managed worktree branch",
2243    );
2244    let output = executor.execute(&check)?;
2245    match output.status {
2246        1 => Ok(false),
2247        0 => {
2248            let worktrees = managed_git_stdout(
2249                executor,
2250                &worktree.target,
2251                &worktree.source_repository,
2252                ["worktree", "list", "--porcelain", "-z"],
2253                "check retained managed worktree checkout",
2254            )?;
2255            let branch_field = format!("branch {branch_ref}");
2256            if worktrees.split('\0').any(|field| field == branch_field) {
2257                bail!(
2258                    "managed worktree branch is still checked out: {}",
2259                    worktree.branch
2260                );
2261            }
2262            Ok(true)
2263        }
2264        status => bail!(
2265            "check retained managed worktree branch failed with status {status}: {}",
2266            String::from_utf8_lossy(&output.stderr).trim()
2267        ),
2268    }
2269}
2270
2271/// Preserve the ref that a return-to-local restore is about to reset. The
2272/// retained `mj/<session>` branch is the source-recovery point; keeping a
2273/// second ref makes a later commit on that branch recoverable as well.
2274pub(super) fn preserve_retained_managed_worktree_branch(
2275    executor: &impl CommandExecutor,
2276    worktree: &ManagedWorktree,
2277) -> Result<String> {
2278    let session_id = worktree
2279        .branch
2280        .strip_prefix("mj/")
2281        .context("managed worktree branch is not session-owned")?;
2282    let branch_ref = format!("refs/heads/{}", worktree.branch);
2283    let tip = managed_git_stdout(
2284        executor,
2285        &worktree.target,
2286        &worktree.source_repository,
2287        ["rev-parse", "--verify", &branch_ref],
2288        "read retained managed worktree branch tip",
2289    )?;
2290    let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
2291    let existing = managed_git_command(
2292        &worktree.target,
2293        &worktree.source_repository,
2294        ["show-ref", "--verify", "--quiet", &recovery_ref],
2295        "check retained managed worktree recovery ref",
2296    );
2297    let output = executor.execute(&existing)?;
2298    match output.status {
2299        0 => {
2300            let existing_tip = managed_git_stdout(
2301                executor,
2302                &worktree.target,
2303                &worktree.source_repository,
2304                ["rev-parse", "--verify", &recovery_ref],
2305                "verify retained managed worktree recovery ref",
2306            )?;
2307            ensure!(
2308                existing_tip == tip,
2309                "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
2310            );
2311            Ok(recovery_ref)
2312        }
2313        1 => {
2314            execute_checked(
2315                executor,
2316                managed_git_command(
2317                    &worktree.target,
2318                    &worktree.source_repository,
2319                    ["update-ref", &recovery_ref, &tip],
2320                    "preserve retained managed worktree branch",
2321                ),
2322            )?;
2323            Ok(recovery_ref)
2324        }
2325        status => bail!(
2326            "check retained managed worktree recovery ref failed with status {status}: {}",
2327            String::from_utf8_lossy(&output.stderr).trim()
2328        ),
2329    }
2330}
2331
2332/// Remove a managed worktree's checkout and keep its branch.
2333///
2334/// A session that moved into a target still checkpoints as a delta against
2335/// `hel/<session>`, so deleting that branch could let the commits those deltas
2336/// depend on be collected. The checkout itself is dirty by design; its dirty
2337/// state has already been carried into the target.
2338pub(super) fn retire_managed_worktree(
2339    executor: &impl CommandExecutor,
2340    worktree: &ManagedWorktree,
2341) -> Result<()> {
2342    if worktree.kind == ManagedCheckoutKind::Clone {
2343        let base = worktree
2344            .base_commit
2345            .as_deref()
2346            .context("managed clone has no source commit")?;
2347        execute_checked(
2348            executor,
2349            managed_git_command(
2350                &worktree.target,
2351                &worktree.source_repository,
2352                ["cat-file", "-e", &format!("{base}^{{commit}}")],
2353                "verify clone recovery prerequisite in source repository",
2354            ),
2355        )?;
2356    }
2357    cleanup_managed_worktree(executor, worktree, BranchDisposition::Keep)
2358}
2359
2360/// Remove the checkout and prune its metadata. Returns whether the repository
2361/// is still there to act on at all.
2362///
2363/// Every caller has stopped the session's worker first, so once the checkout
2364/// is gone nothing can build in it again, and mbx is told to drop its build
2365/// state for that path.
2366fn remove_managed_worktree_checkout(
2367    executor: &impl CommandExecutor,
2368    worktree: &ManagedWorktree,
2369) -> Result<bool> {
2370    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
2371        return Ok(false);
2372    }
2373    let release = || {
2374        super::mbx::release::BuildStateRelease::managed_checkout(
2375            managed_target_ssh(&worktree.target),
2376            &worktree.worktree_root,
2377        )
2378        .run(executor);
2379    };
2380    if worktree.kind == ManagedCheckoutKind::Clone {
2381        if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2382            execute_checked(
2383                executor,
2384                managed_target_command(
2385                    &worktree.target,
2386                    "rm",
2387                    ["-rf", "--", &worktree.worktree_root.to_string_lossy()],
2388                )
2389                .purpose("remove managed clone after its worker stopped"),
2390            )?;
2391            release();
2392        }
2393        return Ok(true);
2394    }
2395    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2396        execute_checked(
2397            executor,
2398            managed_git_command(
2399                &worktree.target,
2400                &worktree.source_repository,
2401                [
2402                    "worktree",
2403                    "remove",
2404                    "--force",
2405                    &worktree.worktree_root.to_string_lossy(),
2406                ],
2407                "remove managed raw-session worktree",
2408            ),
2409        )?;
2410        release();
2411    }
2412    execute_checked(
2413        executor,
2414        managed_git_command(
2415            &worktree.target,
2416            &worktree.source_repository,
2417            ["worktree", "prune"],
2418            "prune managed worktree metadata",
2419        ),
2420    )?;
2421    Ok(true)
2422}
2423
2424/// Whether the session branch is contained in a branch that is not a Mjolnir
2425/// session branch, so deleting it loses no commits. `Ok(None)` means the
2426/// source repository is gone and there is nothing to answer about.
2427///
2428/// This is git's own meaning of "merged": the branch tip is an ancestor of
2429/// another ref. A squash merge or a rebase rewrites the commits, so it does
2430/// not count and the branch is kept.
2431fn managed_branch_is_merged(
2432    executor: &impl CommandExecutor,
2433    worktree: &ManagedWorktree,
2434) -> Result<Option<bool>> {
2435    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
2436        return Ok(None);
2437    }
2438    let branch_ref = format!("refs/heads/{}", worktree.branch);
2439    let refs = managed_git_stdout(
2440        executor,
2441        &worktree.target,
2442        &worktree.source_repository,
2443        [
2444            "for-each-ref",
2445            "--contains",
2446            &branch_ref,
2447            "--format=%(refname)",
2448            "refs/heads",
2449            "refs/remotes",
2450        ],
2451        "list the branches containing a managed worktree branch",
2452    )?;
2453    Ok(Some(refs.lines().any(containing_ref_is_not_a_session)))
2454}
2455
2456/// A ref that proves the session branch's commits live somewhere else: any
2457/// branch outside `refs/heads/mj/`, including a remote-tracking branch, since
2458/// work merged upstream and fetched is merged. A remote's symbolic `HEAD` is
2459/// not a branch of its own and never counts.
2460fn containing_ref_is_not_a_session(reference: &str) -> bool {
2461    let reference = reference.trim();
2462    let remote_head = reference.starts_with("refs/remotes/") && reference.ends_with("/HEAD");
2463    !reference.is_empty() && !reference.starts_with("refs/heads/mj/") && !remote_head
2464}
2465
2466/// Remove a managed worktree's checkout, and its branch only when the caller
2467/// asks for that. The branch can hold work the user still wants, so deleting
2468/// it is always an explicit decision; see [`BranchDisposition`].
2469pub(super) fn cleanup_managed_worktree(
2470    executor: &impl CommandExecutor,
2471    worktree: &ManagedWorktree,
2472    branch: BranchDisposition,
2473) -> Result<()> {
2474    if !remove_managed_worktree_checkout(executor, worktree)? {
2475        return Ok(());
2476    }
2477    if worktree.kind == ManagedCheckoutKind::Clone {
2478        return remove_empty_managed_worktree_directories(executor, worktree);
2479    }
2480    if branch == BranchDisposition::Keep {
2481        return remove_empty_managed_worktree_directories(executor, worktree);
2482    }
2483    let branch_ref = format!("refs/heads/{}", worktree.branch);
2484    let check = managed_git_command(
2485        &worktree.target,
2486        &worktree.source_repository,
2487        ["show-ref", "--verify", "--quiet", &branch_ref],
2488        "check managed worktree branch",
2489    );
2490    let output = executor.execute(&check)?;
2491    let present = match output.status {
2492        0 => true,
2493        1 => false,
2494        status => bail!(
2495            "check managed worktree branch failed with status {status}: {}",
2496            String::from_utf8_lossy(&output.stderr).trim()
2497        ),
2498    };
2499    let delete = match branch {
2500        BranchDisposition::Delete => present,
2501        BranchDisposition::DeleteIfMerged if present => {
2502            let merged = managed_branch_is_merged(executor, worktree)?;
2503            let delete = merged == Some(true);
2504            tracing::info!(
2505                branch = %worktree.branch,
2506                delete,
2507                reason = match merged {
2508                    Some(true) => "another branch already contains its commits",
2509                    Some(false) => "it holds commits no other branch contains",
2510                    None => "its repository is gone",
2511                },
2512                "archiving decided what to do with a session branch"
2513            );
2514            delete
2515        }
2516        BranchDisposition::DeleteIfMerged | BranchDisposition::Keep => false,
2517    };
2518    if delete {
2519        execute_checked(
2520            executor,
2521            managed_git_command(
2522                &worktree.target,
2523                &worktree.source_repository,
2524                ["branch", "-D", "--", &worktree.branch],
2525                "delete managed raw-session branch",
2526            ),
2527        )?;
2528    }
2529    remove_empty_managed_worktree_directories(executor, worktree)
2530}
2531
2532fn remove_empty_managed_worktree_directories(
2533    executor: &impl CommandExecutor,
2534    worktree: &ManagedWorktree,
2535) -> Result<()> {
2536    let worktrees = worktree
2537        .source_repository
2538        .join(".mj")
2539        .join(match worktree.kind {
2540            ManagedCheckoutKind::Worktree => "worktrees",
2541            ManagedCheckoutKind::Clone => "clones",
2542        });
2543    let hel = worktree.source_repository.join(".mj");
2544    match &worktree.target {
2545        ManagedWorktreeTarget::Local => {
2546            for directory in [&worktrees, &hel] {
2547                match std::fs::remove_dir(directory) {
2548                    Ok(()) => {}
2549                    Err(error)
2550                        if matches!(
2551                            error.kind(),
2552                            std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
2553                        ) => {}
2554                    Err(error) => return Err(error.into()),
2555                }
2556            }
2557        }
2558        ManagedWorktreeTarget::Ssh { .. } => {
2559            let command = managed_target_command(
2560                &worktree.target,
2561                "rmdir",
2562                ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
2563            )
2564            .purpose("remove empty managed worktree directories");
2565            let _ = executor.execute(&command)?;
2566        }
2567    }
2568    Ok(())
2569}
2570
2571#[cfg(test)]
2572mod tests;