Skip to main content

mj_controller/controller/
worktree.rs

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