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    reject_dirty_submodules_for_move(git, checkout)?;
943    let boundary = origin_boundary_commit(git, checkout)?;
944    let history = if managed_clone {
945        mj_checkpoint::archive::GitHistoryMode::CloneFrom(
946            boundary
947                .clone()
948                .context("managed clone has no origin boundary commit")?,
949        )
950    } else {
951        mj_checkpoint::archive::GitHistoryMode::SessionDelta
952    };
953    let mut snapshot = mj_checkpoint::archive::collect_git_snapshot(
954        git,
955        checkout,
956        &mj_checkpoint::archive::GitCollectionSpec {
957            id: RAW_CONVERSION_REPOSITORY_ID.to_owned(),
958            relative_destination: destination.to_path_buf(),
959            history,
960            origin_override: None,
961        },
962    )
963    .with_context(|| format!("snapshot the checkout at {}", checkout.display()))?;
964    // The resolved remote, not whatever `origin` happens to be: the checkout's
965    // branch may track another remote. Credentials stay out of the archive.
966    snapshot.metadata.origin =
967        mj_checkpoint::archive::redact_origin_credentials(&source.fetch_url)?;
968    snapshot.metadata.push_urls = source
969        .push_urls
970        .iter()
971        .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
972        .collect::<Result<Vec<_>>>()?;
973    snapshot.metadata.remote_workspace = true;
974    snapshot.metadata.base_commit =
975        boundary.unwrap_or_else(|| snapshot.metadata.head_commit.clone());
976    Ok(snapshot)
977}
978
979/// The newest commit the checkout shares with `origin`, which is where a
980/// converted workspace measures its own session delta from. `None` when HEAD
981/// is already on an origin ref, leaving no boundary to report.
982fn origin_boundary_commit(
983    git: &dyn mj_checkpoint::archive::GitCommandRunner,
984    checkout: &Path,
985) -> Result<Option<String>> {
986    let listed = git_runner_stdout(
987        git,
988        checkout,
989        [
990            "rev-list",
991            "--boundary",
992            "HEAD",
993            "--not",
994            "--remotes=origin",
995        ],
996        "list commits outside origin",
997    )?;
998    // `--boundary` marks the excluded parents of the listed commits with `-`,
999    // and lists them after the commits themselves.
1000    Ok(listed
1001        .lines()
1002        .filter_map(|line| line.strip_prefix('-'))
1003        .map(|commit| commit.trim().to_owned())
1004        .find(|commit| !commit.is_empty()))
1005}
1006
1007fn git_runner_stdout(
1008    git: &dyn mj_checkpoint::archive::GitCommandRunner,
1009    repository: &Path,
1010    args: impl IntoIterator<Item = impl AsRef<str>>,
1011    purpose: &str,
1012) -> Result<String> {
1013    let output = git.run(
1014        repository,
1015        &mj_checkpoint::archive::GitCommand {
1016            arguments: args
1017                .into_iter()
1018                .map(|argument| std::ffi::OsString::from(argument.as_ref()))
1019                .collect(),
1020            stdin: Vec::new(),
1021            env: Vec::new(),
1022        },
1023    )?;
1024    command_stdout(
1025        CommandOutput {
1026            status: output.status,
1027            stdout: output.stdout,
1028            stderr: output.stderr,
1029        },
1030        purpose,
1031    )
1032}
1033
1034/// Describe a raw-to-workspace conversion for a person to confirm. Reads Git
1035/// and asks the remote for its default branch; changes nothing.
1036pub(super) fn raw_conversion_preview(
1037    session: &SessionRecord,
1038    conversion: &RawToWorkspaceConversion,
1039    executor: &(impl CommandExecutor + Sync),
1040) -> Result<mj_core::state::RawConversionPreview> {
1041    let checkout = conversion.checkout.as_path();
1042    // A dirty submodule cannot be captured, so say so now rather than failing
1043    // after the session has been stopped.
1044    reject_dirty_submodules_for_move(&ExecutorGit(executor), checkout)?;
1045    let default_branch = mj_core::remote_git::default_branch(&conversion.source, executor)?;
1046    let position = read_checkout_position(executor, &ManagedWorktreeTarget::Local, checkout)?;
1047    let unpushed_commits = unpushed_commit_count(executor, checkout)?;
1048    let dirty = dirty_file_counts(executor, checkout)?;
1049    // The archive names the session's own directory, which is where the
1050    // restored harness session looks for its files inside the target.
1051    let directory = session
1052        .project_directory
1053        .as_deref()
1054        .context("a raw session has no project directory")?
1055        .file_name()
1056        .context("a raw project directory cannot be the filesystem root")?;
1057    // A raw session has no container, so the move builds it one and the
1058    // checkout lands in the per-session workspace this preview names. A session
1059    // that predates per-session workspaces and still records none keeps the
1060    // shared one only if it already has a container, which a raw session never
1061    // does.
1062    let container_workspace = match session.container_workspace.clone() {
1063        Some(workspace) => workspace,
1064        None => mj_core::targets::new_container_workspace(&session.id)?,
1065    };
1066    Ok(mj_core::state::RawConversionPreview {
1067        checkout: checkout.to_path_buf(),
1068        destination: container_workspace.join(directory),
1069        branch: position.branch,
1070        fetch_url: conversion.source.fetch_url.clone(),
1071        push_urls: conversion.source.push_urls.clone(),
1072        default_branch,
1073        unpushed_commits,
1074        staged_files: dirty.staged_files,
1075        unstaged_files: dirty.unstaged_files,
1076        untracked_files: dirty.untracked_files,
1077        untracked_bytes: untracked_bytes(executor, checkout)?,
1078        host_checkout_retained: conversion.retire.is_none(),
1079    })
1080}
1081
1082/// The conversion snapshot carries each gitlink as the commit it points to,
1083/// so uncommitted work in a submodule would not arrive. A gitlink with no
1084/// `.gitmodules` entry never blocks the move; it is logged because its files
1085/// stay behind.
1086fn reject_dirty_submodules_for_move(
1087    git: &dyn mj_checkpoint::archive::GitCommandRunner,
1088    checkout: &Path,
1089) -> Result<()> {
1090    let inspection = mj_checkpoint::checkpoint::inspect_submodules(git, checkout)
1091        .with_context(|| format!("checkout {}", checkout.display()))?;
1092    for gitlink in &inspection.unregistered {
1093        tracing::warn!(
1094            checkout = %checkout.display(),
1095            gitlink = %gitlink.display(),
1096            "gitlink has no .gitmodules entry; the move carries the commit it points to, not its files"
1097        );
1098    }
1099    if let Some(dirty) = inspection.dirty_summary() {
1100        bail!(
1101            "{}: {dirty}, which cannot move into a target; commit or stash them first",
1102            checkout.display()
1103        );
1104    }
1105    Ok(())
1106}
1107
1108/// Runs the checkpoint library's submodule inspection in a local checkout
1109/// through a controller executor, so it keeps the caller's cancellation and
1110/// deadline.
1111struct ExecutorGit<'a, E>(&'a E);
1112
1113impl<E: CommandExecutor + Sync> mj_checkpoint::archive::GitCommandRunner for ExecutorGit<'_, E> {
1114    fn run(
1115        &self,
1116        repository: &Path,
1117        command: &mj_checkpoint::archive::GitCommand,
1118    ) -> Result<mj_checkpoint::archive::GitOutput> {
1119        ensure!(
1120            command.stdin.is_empty() && command.env.is_empty(),
1121            "an executor Git command takes no standard input or extra environment"
1122        );
1123        let output = self.0.execute(&managed_git_command(
1124            &ManagedWorktreeTarget::Local,
1125            repository,
1126            command
1127                .arguments
1128                .iter()
1129                .map(|argument| argument.to_string_lossy().into_owned()),
1130            "inspect submodules",
1131        ))?;
1132        Ok(mj_checkpoint::archive::GitOutput {
1133            status: output.status,
1134            stdout: output.stdout,
1135            stderr: output.stderr,
1136        })
1137    }
1138}
1139
1140/// Commits the conversion archive has to carry. A checkout whose origin refs
1141/// are missing even after a repair fetch reports nothing rather than counting
1142/// its entire history as unpushed.
1143fn unpushed_commit_count(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1144    if !origin_refs_available(executor, checkout)? {
1145        return Ok(0);
1146    }
1147    let counted = managed_git_stdout(
1148        executor,
1149        &ManagedWorktreeTarget::Local,
1150        checkout,
1151        ["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
1152        "count commits outside origin",
1153    )?;
1154    counted
1155        .trim()
1156        .parse()
1157        .with_context(|| format!("parse the commit count {counted:?}"))
1158}
1159
1160fn origin_refs_available(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
1161    if origin_refs_listed(executor, checkout)? {
1162        return Ok(true);
1163    }
1164    // A checkout that has never fetched has no origin refs yet. Try once; a
1165    // remote that cannot be reached leaves the count unreported, not failed.
1166    let fetch = managed_git_command(
1167        &ManagedWorktreeTarget::Local,
1168        checkout,
1169        ["fetch", "origin"],
1170        "fetch origin refs",
1171    );
1172    executor.execute(&fetch)?;
1173    origin_refs_listed(executor, checkout)
1174}
1175
1176fn origin_refs_listed(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
1177    managed_git_stdout(
1178        executor,
1179        &ManagedWorktreeTarget::Local,
1180        checkout,
1181        [
1182            "for-each-ref",
1183            "--format=%(objectname)",
1184            "refs/remotes/origin",
1185        ],
1186        "list origin refs",
1187    )
1188    .map(|refs| !refs.trim().is_empty())
1189}
1190
1191#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1192struct DirtyFileCounts {
1193    staged_files: u64,
1194    unstaged_files: u64,
1195    untracked_files: u64,
1196}
1197
1198/// Count what `git status` reports, one entry per path. A rename's second
1199/// record names the original path, so it is consumed rather than counted.
1200fn dirty_file_counts(executor: &impl CommandExecutor, checkout: &Path) -> Result<DirtyFileCounts> {
1201    let command = managed_git_command(
1202        &ManagedWorktreeTarget::Local,
1203        checkout,
1204        ["status", "--porcelain=v1", "-z"],
1205        "read checkout status",
1206    );
1207    let output = executor.execute(&command)?;
1208    ensure!(
1209        output.status == 0,
1210        "read checkout status failed with status {}: {}",
1211        output.status,
1212        String::from_utf8_lossy(&output.stderr).trim()
1213    );
1214    let mut counts = DirtyFileCounts::default();
1215    let mut records = output
1216        .stdout
1217        .split(|byte| *byte == 0)
1218        .filter(|record| !record.is_empty());
1219    while let Some(record) = records.next() {
1220        let [index, worktree, ..] = record else {
1221            bail!("git status produced a record shorter than its status field");
1222        };
1223        if *index == b'?' && *worktree == b'?' {
1224            counts.untracked_files += 1;
1225            continue;
1226        }
1227        if !matches!(index, b' ' | b'?') {
1228            counts.staged_files += 1;
1229        }
1230        if !matches!(worktree, b' ' | b'?') {
1231            counts.unstaged_files += 1;
1232        }
1233        if *index == b'R' || *index == b'C' || *worktree == b'R' || *worktree == b'C' {
1234            records.next();
1235        }
1236    }
1237    Ok(counts)
1238}
1239
1240/// How much untracked content the conversion archive has to carry. `git status`
1241/// collapses an untracked directory into one entry, so the bytes come from the
1242/// file list instead.
1243fn untracked_bytes(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1244    let command = managed_git_command(
1245        &ManagedWorktreeTarget::Local,
1246        checkout,
1247        ["ls-files", "--others", "--exclude-standard", "-z"],
1248        "list untracked files",
1249    );
1250    let output = executor.execute(&command)?;
1251    ensure!(
1252        output.status == 0,
1253        "list untracked files failed with status {}: {}",
1254        output.status,
1255        String::from_utf8_lossy(&output.stderr).trim()
1256    );
1257    let mut total = 0;
1258    for record in output
1259        .stdout
1260        .split(|byte| *byte == 0)
1261        .filter(|record| !record.is_empty())
1262    {
1263        let relative = mj_core::path_input::from_git_bytes(record)?;
1264        let path = checkout.join(relative);
1265        // Do not follow links, and tolerate a file the agent removed between
1266        // the listing and this read.
1267        match std::fs::symlink_metadata(&path) {
1268            Ok(metadata) => total += metadata.len(),
1269            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1270            Err(error) => {
1271                return Err(error).with_context(|| format!("measure {}", path.display()));
1272            }
1273        }
1274    }
1275    Ok(total)
1276}
1277
1278/// Where a checkout stands: its head commit and, unless detached, its branch.
1279#[derive(Debug, Clone, PartialEq, Eq)]
1280pub(super) struct CheckoutPosition {
1281    pub(super) head_commit: String,
1282    branch: Option<String>,
1283}
1284
1285fn read_checkout_position(
1286    executor: &impl CommandExecutor,
1287    target: &ManagedWorktreeTarget,
1288    directory: &Path,
1289) -> Result<CheckoutPosition> {
1290    let head_commit = managed_git_stdout(
1291        executor,
1292        target,
1293        directory,
1294        ["rev-parse", "HEAD"],
1295        "resolve checkout head commit",
1296    )?;
1297    let branch_command = managed_git_command(
1298        target,
1299        directory,
1300        ["symbolic-ref", "--quiet", "--short", "HEAD"],
1301        "resolve checkout branch",
1302    );
1303    let branch_output = executor.execute(&branch_command)?;
1304    let branch = match branch_output.status {
1305        0 => Some(
1306            String::from_utf8(branch_output.stdout)
1307                .context("checkout branch was not UTF-8")?
1308                .trim()
1309                .to_owned(),
1310        ),
1311        // A detached head reports no branch rather than failing.
1312        1 | 128 => None,
1313        status => bail!(
1314            "resolve checkout branch failed with status {status}: {}",
1315            String::from_utf8_lossy(&branch_output.stderr).trim()
1316        ),
1317    };
1318    Ok(CheckoutPosition {
1319        head_commit,
1320        branch,
1321    })
1322}
1323
1324/// The commit the session branch was created at, as the base for diffs and
1325/// checkpoint bundles. Prefers the recorded base; sessions created before it
1326/// was recorded fall back to the branch reflog, like `branch_creation_commit`
1327/// in mj-checkpoint. A reflog that has expired leaves only the live head,
1328/// which yields an empty bundle rather than a failed checkpoint.
1329pub(super) fn managed_worktree_base_commit(
1330    worktree: &ManagedWorktree,
1331    executor: &impl CommandExecutor,
1332) -> Result<String> {
1333    if let Some(base) = &worktree.base_commit {
1334        return Ok(base.clone());
1335    }
1336    let reference = format!("refs/heads/{}", worktree.branch);
1337    let reflog_command = managed_git_command(
1338        &worktree.target,
1339        &worktree.source_repository,
1340        ["reflog", "show", "--format=%H", &reference],
1341        "read the session branch reflog",
1342    );
1343    let reflog_output = executor.execute(&reflog_command)?;
1344    if reflog_output.status == 0 {
1345        let text = String::from_utf8(reflog_output.stdout)
1346            .context("the session branch reflog was not UTF-8")?;
1347        // The oldest entry is the branch's creation, so it is where the session
1348        // started.
1349        if let Some(creation) = text.lines().rfind(|line| !line.trim().is_empty()) {
1350            return Ok(creation.trim().to_owned());
1351        }
1352    }
1353    let head = read_checkout_position(executor, &worktree.target, &worktree.worktree_root)?;
1354    tracing::warn!(
1355        branch = %worktree.branch,
1356        "the reflog for this session branch is gone, so its checkpoint bundle will carry no commits"
1357    );
1358    Ok(head.head_commit)
1359}
1360
1361/// Read where a raw session's checkout stands right now, on whichever host
1362/// owns it.
1363pub(super) fn raw_checkout_position(
1364    session: &SessionRecord,
1365    config: &Config,
1366    project_directory: &Path,
1367    executor: &impl CommandExecutor,
1368) -> Result<CheckoutPosition> {
1369    let target = match &session.managed_worktree {
1370        Some(worktree) => worktree.target.clone(),
1371        None => {
1372            let runtime = session.target_runtime_settings(config)?;
1373            match (&*runtime.kind, &runtime.connection) {
1374                ("local-bare", mj_core::state::TargetConnection::Local) => {
1375                    ManagedWorktreeTarget::Local
1376                }
1377                ("ssh-bare", mj_core::state::TargetConnection::Ssh { ssh }) => {
1378                    let ssh = targets::SshTarget::from(ssh);
1379                    ManagedWorktreeTarget::Ssh {
1380                        destination: ssh.destination,
1381                        ssh_args: ssh.ssh_args,
1382                    }
1383                }
1384                _ => bail!("the session's recorded target is not a bare checkout"),
1385            }
1386        }
1387    };
1388    read_checkout_position(executor, &target, project_directory)
1389}
1390
1391/// One conversation line for a raw session whose checkout moved on while the
1392/// session was stopped. `None` when the checkout is where the checkpoint left
1393/// it, or when the checkpoint recorded no repository to compare against.
1394///
1395/// This reports; it never reconciles. The working tree is the truth.
1396pub(super) fn raw_checkout_divergence_notice(
1397    directory: &Path,
1398    recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
1399    live: &CheckoutPosition,
1400) -> Option<String> {
1401    let recorded = recorded?;
1402    if recorded.head_commit.is_empty()
1403        || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
1404    {
1405        return None;
1406    }
1407    Some(format!(
1408        "The working tree at {} moved from {} to {} while this session was stopped.",
1409        directory.display(),
1410        checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
1411        checkout_position_text(&live.head_commit, live.branch.as_deref()),
1412    ))
1413}
1414
1415fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
1416    let short = head_commit.get(..12).unwrap_or(head_commit);
1417    match branch {
1418        Some(branch) => format!("{short} ({branch})"),
1419        None => format!("{short} (detached)"),
1420    }
1421}
1422
1423fn inspect_raw_project(
1424    executor: &impl CommandExecutor,
1425    target: &ManagedWorktreeTarget,
1426    selected: &Path,
1427) -> Result<RawProjectInspection> {
1428    let repository = PathBuf::from(managed_git_stdout(
1429        executor,
1430        target,
1431        selected,
1432        ["rev-parse", "--path-format=absolute", "--show-toplevel"],
1433        "resolve raw project repository root",
1434    )?);
1435    let prefix = managed_git_stdout(
1436        executor,
1437        target,
1438        selected,
1439        ["rev-parse", "--show-prefix"],
1440        "resolve raw project relative directory",
1441    )?;
1442    let git_dir = PathBuf::from(managed_git_stdout(
1443        executor,
1444        target,
1445        selected,
1446        ["rev-parse", "--absolute-git-dir"],
1447        "resolve raw project Git directory",
1448    )?);
1449    let common_git_dir = PathBuf::from(managed_git_stdout(
1450        executor,
1451        target,
1452        selected,
1453        ["rev-parse", "--path-format=absolute", "--git-common-dir"],
1454        "resolve raw project common Git directory",
1455    )?);
1456    let branch_command = managed_git_command(
1457        target,
1458        selected,
1459        ["symbolic-ref", "--quiet", "--short", "HEAD"],
1460        "resolve raw project branch",
1461    );
1462    let branch_output = executor.execute(&branch_command)?;
1463    let branch = match branch_output.status {
1464        0 => Some(
1465            String::from_utf8(branch_output.stdout)
1466                .context("raw project branch was not UTF-8")?
1467                .trim()
1468                .to_owned(),
1469        ),
1470        1 | 128 => None,
1471        status => bail!(
1472            "resolve raw project branch failed with status {status}: {}",
1473            String::from_utf8_lossy(&branch_output.stderr).trim()
1474        ),
1475    };
1476    let upstream = match branch {
1477        Some(branch) => {
1478            let reference = format!("refs/heads/{branch}");
1479            let upstream = managed_git_stdout(
1480                executor,
1481                target,
1482                selected,
1483                ["for-each-ref", "--format=%(upstream:short)", &reference],
1484                "resolve raw project upstream",
1485            )?;
1486            (!upstream.is_empty()).then_some(upstream)
1487        }
1488        None => None,
1489    };
1490    Ok(RawProjectInspection {
1491        source_project_directory: repository.join(prefix),
1492        source_repository: repository,
1493        primary_checkout: git_dir == common_git_dir,
1494        upstream,
1495    })
1496}
1497
1498fn ensure_managed_worktree_excluded(
1499    executor: &impl CommandExecutor,
1500    target: &ManagedWorktreeTarget,
1501    repository: &Path,
1502    kind: ManagedCheckoutKind,
1503) -> Result<()> {
1504    let (path, entry) = match kind {
1505        ManagedCheckoutKind::Worktree => (".mj/worktrees/", "/.mj/worktrees/"),
1506        ManagedCheckoutKind::Clone => (".mj/clones/", "/.mj/clones/"),
1507    };
1508    let check = managed_git_command(
1509        target,
1510        repository,
1511        ["check-ignore", "--quiet", "--no-index", "--", path],
1512        "check managed worktree exclusion",
1513    );
1514    let output = executor.execute(&check)?;
1515    match output.status {
1516        0 => return Ok(()),
1517        1 => {}
1518        status => bail!(
1519            "check managed worktree exclusion failed with status {status}: {}",
1520            String::from_utf8_lossy(&output.stderr).trim()
1521        ),
1522    }
1523    let exclude_path = PathBuf::from(managed_git_stdout(
1524        executor,
1525        target,
1526        repository,
1527        [
1528            "rev-parse",
1529            "--path-format=absolute",
1530            "--git-path",
1531            "info/exclude",
1532        ],
1533        "resolve repository-local exclude file",
1534    )?);
1535    match target {
1536        ManagedWorktreeTarget::Local => {
1537            use std::io::Write;
1538            let existing = match std::fs::read_to_string(&exclude_path) {
1539                Ok(existing) => existing,
1540                Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1541                Err(error) => return Err(error.into()),
1542            };
1543            if existing.lines().any(|line| line.trim() == entry) {
1544                return Ok(());
1545            }
1546            if let Some(parent) = exclude_path.parent() {
1547                std::fs::create_dir_all(parent)?;
1548            }
1549            let mut file = std::fs::OpenOptions::new()
1550                .create(true)
1551                .append(true)
1552                .open(&exclude_path)
1553                .with_context(|| format!("open {}", exclude_path.display()))?;
1554            if !existing.is_empty() && !existing.ends_with('\n') {
1555                writeln!(file)?;
1556            }
1557            writeln!(file, "# Mjolnir managed checkouts\n{entry}")?;
1558        }
1559        ManagedWorktreeTarget::Ssh { .. } => {
1560            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";
1561            let command = managed_target_command(
1562                target,
1563                "sh",
1564                [
1565                    "-c",
1566                    SCRIPT,
1567                    "hel-exclude",
1568                    &exclude_path.to_string_lossy(),
1569                    entry,
1570                ],
1571            )
1572            .purpose("update remote repository-local exclude file");
1573            execute_checked(executor, command)?;
1574        }
1575    }
1576    Ok(())
1577}
1578
1579pub(crate) fn path_exists_on_managed_target(
1580    executor: &impl CommandExecutor,
1581    target: &ManagedWorktreeTarget,
1582    path: &Path,
1583) -> Result<bool> {
1584    match target {
1585        ManagedWorktreeTarget::Local => path
1586            .try_exists()
1587            .with_context(|| format!("check managed project path {}", path.display())),
1588        ManagedWorktreeTarget::Ssh { .. } => {
1589            let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
1590                .purpose("check managed worktree path");
1591            let output = executor.execute(&command)?;
1592            match output.status {
1593                0 => Ok(true),
1594                1 => Ok(false),
1595                status => bail!(
1596                    "check managed worktree path failed with status {status}: {}",
1597                    String::from_utf8_lossy(&output.stderr).trim()
1598                ),
1599            }
1600        }
1601    }
1602}
1603
1604pub(super) fn managed_worktree_checkout_exists(
1605    executor: &impl CommandExecutor,
1606    worktree: &ManagedWorktree,
1607) -> Result<bool> {
1608    path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1609}
1610
1611/// Whether a managed worktree's checkout holds work that removing it would
1612/// destroy. A checkout that is already gone holds nothing.
1613///
1614/// This asks the session's own worktree the porcelain question
1615/// [`create_managed_worktree`] asks of the primary checkout.
1616pub(super) fn managed_worktree_checkout_is_dirty(
1617    executor: &impl CommandExecutor,
1618    worktree: &ManagedWorktree,
1619) -> Result<bool> {
1620    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1621        return Ok(false);
1622    }
1623    let status = managed_git_stdout(
1624        executor,
1625        &worktree.target,
1626        &worktree.worktree_root,
1627        ["status", "--porcelain=v1", "--untracked-files=all"],
1628        "inspect managed worktree changes",
1629    )?;
1630    Ok(!status.is_empty())
1631}
1632
1633/// Whether a new managed worktree needs the primary checkout to be clean.
1634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1635pub(super) enum PrimaryCheckoutRequirement {
1636    /// A new raw session starts from the primary checkout's HEAD, so work that
1637    /// is only in its working tree would be silently left behind.
1638    Clean,
1639    /// A session moving out of its target replaces the worktree's contents from
1640    /// its checkpoint, so the primary checkout's own changes are beside the
1641    /// point.
1642    Any,
1643}
1644
1645pub(super) fn create_managed_worktree(
1646    executor: &impl CommandExecutor,
1647    worktree: &ManagedWorktree,
1648    upstream: Option<&str>,
1649    requirement: PrimaryCheckoutRequirement,
1650) -> Result<()> {
1651    ensure_managed_worktree_excluded(
1652        executor,
1653        &worktree.target,
1654        &worktree.source_repository,
1655        worktree.kind,
1656    )?;
1657    if worktree.kind == ManagedCheckoutKind::Clone {
1658        return create_managed_clone(executor, worktree);
1659    }
1660    if requirement == PrimaryCheckoutRequirement::Clean {
1661        let status = managed_git_stdout(
1662            executor,
1663            &worktree.target,
1664            &worktree.source_repository,
1665            ["status", "--porcelain=v1", "--untracked-files=all"],
1666            "inspect primary checkout changes",
1667        )?;
1668        if !status.is_empty() {
1669            let paths = status.lines().take(20).collect::<Vec<_>>().join("\n  ");
1670            bail!(
1671                "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n  {paths}"
1672            );
1673        }
1674    }
1675    let parent = worktree
1676        .worktree_root
1677        .parent()
1678        .context("managed worktree root has no parent")?;
1679    execute_checked(
1680        executor,
1681        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1682            .purpose("create managed worktree directory"),
1683    )?;
1684    execute_checked(
1685        executor,
1686        managed_git_command(
1687            &worktree.target,
1688            &worktree.source_repository,
1689            [
1690                "worktree",
1691                "add",
1692                "-b",
1693                &worktree.branch,
1694                &worktree.worktree_root.to_string_lossy(),
1695                worktree.base_commit.as_deref().unwrap_or("HEAD"),
1696            ],
1697            "create managed raw-session worktree",
1698        ),
1699    )?;
1700    if let Some(upstream) = upstream {
1701        execute_checked(
1702            executor,
1703            managed_git_command(
1704                &worktree.target,
1705                &worktree.worktree_root,
1706                ["branch", "--set-upstream-to", upstream, &worktree.branch],
1707                "set managed worktree branch upstream",
1708            ),
1709        )?;
1710    }
1711    Ok(())
1712}
1713
1714fn create_managed_clone(executor: &impl CommandExecutor, checkout: &ManagedWorktree) -> Result<()> {
1715    let parent = checkout
1716        .worktree_root
1717        .parent()
1718        .context("managed clone has no parent")?;
1719    let staging = checkout.worktree_root.with_extension("provisioning");
1720    ensure!(
1721        !path_exists_on_managed_target(executor, &checkout.target, &staging)?
1722            && !path_exists_on_managed_target(executor, &checkout.target, &checkout.worktree_root)?,
1723        "managed clone path is already occupied: {}",
1724        checkout.worktree_root.display()
1725    );
1726    execute_checked(
1727        executor,
1728        managed_target_command(&checkout.target, "mkdir", ["-p", &parent.to_string_lossy()])
1729            .purpose("create managed clone parent"),
1730    )?;
1731    let create = (|| -> Result<()> {
1732        execute_checked(
1733            executor,
1734            managed_target_command(
1735                &checkout.target,
1736                "git",
1737                [
1738                    "clone",
1739                    "--local",
1740                    "--dissociate",
1741                    "--no-checkout",
1742                    "--",
1743                    &checkout.source_repository.to_string_lossy(),
1744                    &staging.to_string_lossy(),
1745                ],
1746            )
1747            .purpose("seed independent managed clone"),
1748        )?;
1749        let origin = executor.execute(&managed_git_command(
1750            &checkout.target,
1751            &checkout.source_repository,
1752            ["config", "--get", "remote.origin.url"],
1753            "read source origin URL",
1754        ))?;
1755        execute_checked(
1756            executor,
1757            managed_git_command(
1758                &checkout.target,
1759                &staging,
1760                ["remote", "remove", "origin"],
1761                "discard local seed as clone remote",
1762            ),
1763        )?;
1764        match origin.status {
1765            0 => {
1766                let url = String::from_utf8(origin.stdout)?;
1767                execute_checked(
1768                    executor,
1769                    managed_git_command(
1770                        &checkout.target,
1771                        &staging,
1772                        ["remote", "add", "origin", url.trim()],
1773                        "set clone fetch and push remote",
1774                    ),
1775                )?;
1776                copy_clone_push_configuration(executor, checkout, &staging)?;
1777                copy_source_origin_refs(executor, checkout, &staging)?;
1778            }
1779            1 => {}
1780            status => bail!(
1781                "read source origin URL failed with status {status}: {}",
1782                String::from_utf8_lossy(&origin.stderr).trim()
1783            ),
1784        }
1785        copy_clone_local_git_preferences(executor, checkout, &staging)?;
1786        execute_checked(
1787            executor,
1788            managed_git_command(
1789                &checkout.target,
1790                &staging,
1791                [
1792                    "switch",
1793                    "--no-track",
1794                    "-C",
1795                    &checkout.branch,
1796                    checkout
1797                        .base_commit
1798                        .as_deref()
1799                        .context("managed clone has no launch commit")?,
1800                ],
1801                "select managed clone starting branch",
1802            ),
1803        )?;
1804        if origin.status == 0 {
1805            execute_checked(
1806                executor,
1807                managed_git_command(
1808                    &checkout.target,
1809                    &staging,
1810                    [
1811                        "config",
1812                        "--local",
1813                        &format!("branch.{}.remote", checkout.branch),
1814                        "origin",
1815                    ],
1816                    "set clone branch push remote",
1817                ),
1818            )?;
1819            execute_checked(
1820                executor,
1821                managed_git_command(
1822                    &checkout.target,
1823                    &staging,
1824                    [
1825                        "config",
1826                        "--local",
1827                        &format!("branch.{}.merge", checkout.branch),
1828                        &format!("refs/heads/{}", checkout.branch),
1829                    ],
1830                    "set clone branch tracking name",
1831                ),
1832            )?;
1833        }
1834        execute_checked(
1835            executor,
1836            managed_target_command(
1837                &checkout.target,
1838                "mv",
1839                [
1840                    "--",
1841                    &staging.to_string_lossy(),
1842                    &checkout.worktree_root.to_string_lossy(),
1843                ],
1844            )
1845            .purpose("publish managed clone checkout"),
1846        )?;
1847        Ok(())
1848    })();
1849    if create.is_err() && path_exists_on_managed_target(executor, &checkout.target, &staging)? {
1850        execute_checked(
1851            executor,
1852            managed_target_command(
1853                &checkout.target,
1854                "rm",
1855                ["-rf", "--", &staging.to_string_lossy()],
1856            )
1857            .purpose("remove failed managed clone staging directory"),
1858        )?;
1859    }
1860    create
1861}
1862
1863fn copy_clone_push_configuration(
1864    executor: &impl CommandExecutor,
1865    checkout: &ManagedWorktree,
1866    staging: &Path,
1867) -> Result<()> {
1868    let output = executor.execute(&managed_git_command(
1869        &checkout.target,
1870        &checkout.source_repository,
1871        ["config", "--local", "--get-all", "remote.origin.pushurl"],
1872        "read source push destinations",
1873    ))?;
1874    match output.status {
1875        0 => {
1876            for url in String::from_utf8(output.stdout)?
1877                .lines()
1878                .filter(|line| !line.is_empty())
1879            {
1880                execute_checked(
1881                    executor,
1882                    managed_git_command(
1883                        &checkout.target,
1884                        staging,
1885                        ["remote", "set-url", "--push", "--add", "origin", url],
1886                        "preserve clone push destination",
1887                    ),
1888                )?;
1889            }
1890        }
1891        1 => {}
1892        status => bail!("read source push destinations failed with status {status}"),
1893    }
1894    Ok(())
1895}
1896
1897fn copy_source_origin_refs(
1898    executor: &impl CommandExecutor,
1899    checkout: &ManagedWorktree,
1900    staging: &Path,
1901) -> Result<()> {
1902    let refs = managed_git_stdout(
1903        executor,
1904        &checkout.target,
1905        &checkout.source_repository,
1906        [
1907            "for-each-ref",
1908            "--format=%(refname) %(objectname)",
1909            "refs/remotes/origin",
1910        ],
1911        "read cached origin branches",
1912    )?;
1913    for line in refs.lines() {
1914        let (name, oid) = line
1915            .split_once(' ')
1916            .context("malformed source remote ref")?;
1917        if name == "refs/remotes/origin/HEAD" {
1918            continue;
1919        }
1920        execute_checked(
1921            executor,
1922            managed_git_command(
1923                &checkout.target,
1924                staging,
1925                ["update-ref", name, oid],
1926                "preserve cached origin branch",
1927            ),
1928        )?;
1929    }
1930    Ok(())
1931}
1932
1933fn copy_clone_local_git_preferences(
1934    executor: &impl CommandExecutor,
1935    checkout: &ManagedWorktree,
1936    staging: &Path,
1937) -> Result<()> {
1938    let config = executor.execute(&managed_git_command(
1939        &checkout.target,
1940        &checkout.source_repository,
1941        ["config", "--local", "--null", "--list"],
1942        "read source Git preferences",
1943    ))?;
1944    ensure!(
1945        config.status == 0,
1946        "read source Git preferences failed with status {}",
1947        config.status
1948    );
1949    for entry in config
1950        .stdout
1951        .split(|byte| *byte == 0)
1952        .filter(|entry| !entry.is_empty())
1953    {
1954        let Some(split) = entry.iter().position(|byte| *byte == b'\n') else {
1955            bail!("source Git configuration contains a malformed entry");
1956        };
1957        let key = std::str::from_utf8(&entry[..split])?;
1958        if !clone_local_preference(key) {
1959            continue;
1960        }
1961        let value = std::str::from_utf8(&entry[split + 1..])?;
1962        execute_checked(
1963            executor,
1964            managed_git_command(
1965                &checkout.target,
1966                staging,
1967                ["config", "--local", "--add", key, value],
1968                "preserve Git identity and local preferences",
1969            ),
1970        )?;
1971    }
1972    let source_exclude = PathBuf::from(managed_git_stdout(
1973        executor,
1974        &checkout.target,
1975        &checkout.source_repository,
1976        [
1977            "rev-parse",
1978            "--path-format=absolute",
1979            "--git-path",
1980            "info/exclude",
1981        ],
1982        "locate source Git exclusions",
1983    )?);
1984    if path_exists_on_managed_target(executor, &checkout.target, &source_exclude)? {
1985        let clone_exclude = PathBuf::from(managed_git_stdout(
1986            executor,
1987            &checkout.target,
1988            staging,
1989            [
1990                "rev-parse",
1991                "--path-format=absolute",
1992                "--git-path",
1993                "info/exclude",
1994            ],
1995            "locate clone Git exclusions",
1996        )?);
1997        execute_checked(
1998            executor,
1999            managed_target_command(
2000                &checkout.target,
2001                "cp",
2002                [
2003                    "--",
2004                    &source_exclude.to_string_lossy(),
2005                    &clone_exclude.to_string_lossy(),
2006                ],
2007            )
2008            .purpose("preserve source Git exclusions"),
2009        )?;
2010    }
2011    Ok(())
2012}
2013
2014fn clone_local_preference(key: &str) -> bool {
2015    key.starts_with("user.")
2016        || key.starts_with("commit.")
2017        || key.starts_with("gpg.")
2018        || key.starts_with("credential.")
2019        || key.starts_with("url.")
2020        || key.starts_with("push.")
2021        || matches!(
2022            key,
2023            "core.hookspath" | "core.excludesfile" | "core.attributesfile" | "core.sshcommand"
2024        )
2025}
2026
2027/// Recreate a retired checkout from the session branch. Returns whether this
2028/// call created it, so a failed resume can put the session back into its
2029/// stopped, checkout-free state.
2030pub(super) fn restore_managed_worktree(
2031    executor: &impl CommandExecutor,
2032    worktree: &ManagedWorktree,
2033) -> Result<bool> {
2034    if managed_worktree_checkout_exists(executor, worktree)? {
2035        return Ok(false);
2036    }
2037    if worktree.kind == ManagedCheckoutKind::Clone {
2038        create_managed_worktree(executor, worktree, None, PrimaryCheckoutRequirement::Any)?;
2039        return Ok(true);
2040    }
2041    ensure!(
2042        path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
2043        "managed worktree source repository is unavailable: {}",
2044        worktree.source_repository.display()
2045    );
2046    let branch_ref = format!("refs/heads/{}", worktree.branch);
2047    let check = managed_git_command(
2048        &worktree.target,
2049        &worktree.source_repository,
2050        ["show-ref", "--verify", "--quiet", &branch_ref],
2051        "check retired managed worktree branch",
2052    );
2053    let output = executor.execute(&check)?;
2054    match output.status {
2055        0 => {}
2056        1 => bail!(
2057            "managed worktree branch is unavailable: {}",
2058            worktree.branch
2059        ),
2060        status => bail!(
2061            "check retired managed worktree branch failed with status {status}: {}",
2062            String::from_utf8_lossy(&output.stderr).trim()
2063        ),
2064    }
2065    // A remote bare target may already have removed the checkout directory.
2066    // Prune its stale registration before adding the retained branch again.
2067    execute_checked(
2068        executor,
2069        managed_git_command(
2070            &worktree.target,
2071            &worktree.source_repository,
2072            ["worktree", "prune"],
2073            "prune retired managed worktree metadata",
2074        ),
2075    )?;
2076    let parent = worktree
2077        .worktree_root
2078        .parent()
2079        .context("managed worktree root has no parent")?;
2080    execute_checked(
2081        executor,
2082        managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
2083            .purpose("recreate managed worktree directory"),
2084    )?;
2085    execute_checked(
2086        executor,
2087        managed_git_command(
2088            &worktree.target,
2089            &worktree.source_repository,
2090            [
2091                "worktree",
2092                "add",
2093                "--",
2094                &worktree.worktree_root.to_string_lossy(),
2095                &worktree.branch,
2096            ],
2097            "restore managed raw-session worktree",
2098        ),
2099    )?;
2100    Ok(true)
2101}
2102
2103fn ensure_managed_worktree_available(
2104    executor: &impl CommandExecutor,
2105    worktree: &ManagedWorktree,
2106) -> Result<()> {
2107    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2108        bail!(
2109            "managed worktree path already exists: {}",
2110            worktree.worktree_root.display()
2111        );
2112    }
2113    if worktree.kind == ManagedCheckoutKind::Clone {
2114        return Ok(());
2115    }
2116    let branch_ref = format!("refs/heads/{}", worktree.branch);
2117    let check = managed_git_command(
2118        &worktree.target,
2119        &worktree.source_repository,
2120        ["show-ref", "--verify", "--quiet", &branch_ref],
2121        "check managed worktree branch availability",
2122    );
2123    let output = executor.execute(&check)?;
2124    match output.status {
2125        0 => bail!(
2126            "managed worktree branch already exists: {}",
2127            worktree.branch
2128        ),
2129        1 => Ok(()),
2130        status => bail!(
2131            "check managed worktree branch availability failed with status {status}: {}",
2132            String::from_utf8_lossy(&output.stderr).trim()
2133        ),
2134    }
2135}
2136
2137/// Check whether the deterministic branch left by this session's earlier
2138/// raw-to-workspace move can be reattached. A branch with this session's id is
2139/// session-owned, but an active checkout elsewhere is still a collision: the
2140/// restore must not make one branch belong to two worktrees.
2141fn retained_managed_worktree_branch_available(
2142    executor: &impl CommandExecutor,
2143    worktree: &ManagedWorktree,
2144) -> Result<bool> {
2145    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2146        bail!(
2147            "managed worktree path already exists: {}",
2148            worktree.worktree_root.display()
2149        );
2150    }
2151    let branch_ref = format!("refs/heads/{}", worktree.branch);
2152    let check = managed_git_command(
2153        &worktree.target,
2154        &worktree.source_repository,
2155        ["show-ref", "--verify", "--quiet", &branch_ref],
2156        "check retained managed worktree branch",
2157    );
2158    let output = executor.execute(&check)?;
2159    match output.status {
2160        1 => Ok(false),
2161        0 => {
2162            let worktrees = managed_git_stdout(
2163                executor,
2164                &worktree.target,
2165                &worktree.source_repository,
2166                ["worktree", "list", "--porcelain", "-z"],
2167                "check retained managed worktree checkout",
2168            )?;
2169            let branch_field = format!("branch {branch_ref}");
2170            if worktrees.split('\0').any(|field| field == branch_field) {
2171                bail!(
2172                    "managed worktree branch is still checked out: {}",
2173                    worktree.branch
2174                );
2175            }
2176            Ok(true)
2177        }
2178        status => bail!(
2179            "check retained managed worktree branch failed with status {status}: {}",
2180            String::from_utf8_lossy(&output.stderr).trim()
2181        ),
2182    }
2183}
2184
2185/// Preserve the ref that a return-to-local restore is about to reset. The
2186/// retained `mj/<session>` branch is the source-recovery point; keeping a
2187/// second ref makes a later commit on that branch recoverable as well.
2188pub(super) fn preserve_retained_managed_worktree_branch(
2189    executor: &impl CommandExecutor,
2190    worktree: &ManagedWorktree,
2191) -> Result<String> {
2192    let session_id = worktree
2193        .branch
2194        .strip_prefix("mj/")
2195        .context("managed worktree branch is not session-owned")?;
2196    let branch_ref = format!("refs/heads/{}", worktree.branch);
2197    let tip = managed_git_stdout(
2198        executor,
2199        &worktree.target,
2200        &worktree.source_repository,
2201        ["rev-parse", "--verify", &branch_ref],
2202        "read retained managed worktree branch tip",
2203    )?;
2204    let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
2205    let existing = managed_git_command(
2206        &worktree.target,
2207        &worktree.source_repository,
2208        ["show-ref", "--verify", "--quiet", &recovery_ref],
2209        "check retained managed worktree recovery ref",
2210    );
2211    let output = executor.execute(&existing)?;
2212    match output.status {
2213        0 => {
2214            let existing_tip = managed_git_stdout(
2215                executor,
2216                &worktree.target,
2217                &worktree.source_repository,
2218                ["rev-parse", "--verify", &recovery_ref],
2219                "verify retained managed worktree recovery ref",
2220            )?;
2221            ensure!(
2222                existing_tip == tip,
2223                "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
2224            );
2225            Ok(recovery_ref)
2226        }
2227        1 => {
2228            execute_checked(
2229                executor,
2230                managed_git_command(
2231                    &worktree.target,
2232                    &worktree.source_repository,
2233                    ["update-ref", &recovery_ref, &tip],
2234                    "preserve retained managed worktree branch",
2235                ),
2236            )?;
2237            Ok(recovery_ref)
2238        }
2239        status => bail!(
2240            "check retained managed worktree recovery ref failed with status {status}: {}",
2241            String::from_utf8_lossy(&output.stderr).trim()
2242        ),
2243    }
2244}
2245
2246/// Remove a managed worktree's checkout and keep its branch.
2247///
2248/// A session that moved into a target still checkpoints as a delta against
2249/// `hel/<session>`, so deleting that branch could let the commits those deltas
2250/// depend on be collected. The checkout itself is dirty by design; its dirty
2251/// state has already been carried into the target.
2252pub(super) fn retire_managed_worktree(
2253    executor: &impl CommandExecutor,
2254    worktree: &ManagedWorktree,
2255) -> Result<()> {
2256    if worktree.kind == ManagedCheckoutKind::Clone {
2257        let base = worktree
2258            .base_commit
2259            .as_deref()
2260            .context("managed clone has no source commit")?;
2261        execute_checked(
2262            executor,
2263            managed_git_command(
2264                &worktree.target,
2265                &worktree.source_repository,
2266                ["cat-file", "-e", &format!("{base}^{{commit}}")],
2267                "verify clone recovery prerequisite in source repository",
2268            ),
2269        )?;
2270    }
2271    cleanup_managed_worktree(executor, worktree, BranchDisposition::Keep)
2272}
2273
2274/// Remove the checkout and prune its metadata. Returns whether the repository
2275/// is still there to act on at all.
2276fn remove_managed_worktree_checkout(
2277    executor: &impl CommandExecutor,
2278    worktree: &ManagedWorktree,
2279) -> Result<bool> {
2280    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
2281        return Ok(false);
2282    }
2283    if worktree.kind == ManagedCheckoutKind::Clone {
2284        if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2285            execute_checked(
2286                executor,
2287                managed_target_command(
2288                    &worktree.target,
2289                    "rm",
2290                    ["-rf", "--", &worktree.worktree_root.to_string_lossy()],
2291                )
2292                .purpose("remove managed clone after its worker stopped"),
2293            )?;
2294        }
2295        return Ok(true);
2296    }
2297    if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2298        execute_checked(
2299            executor,
2300            managed_git_command(
2301                &worktree.target,
2302                &worktree.source_repository,
2303                [
2304                    "worktree",
2305                    "remove",
2306                    "--force",
2307                    &worktree.worktree_root.to_string_lossy(),
2308                ],
2309                "remove managed raw-session worktree",
2310            ),
2311        )?;
2312    }
2313    execute_checked(
2314        executor,
2315        managed_git_command(
2316            &worktree.target,
2317            &worktree.source_repository,
2318            ["worktree", "prune"],
2319            "prune managed worktree metadata",
2320        ),
2321    )?;
2322    Ok(true)
2323}
2324
2325/// Whether the session branch is contained in a branch that is not a Mjolnir
2326/// session branch, so deleting it loses no commits. `Ok(None)` means the
2327/// source repository is gone and there is nothing to answer about.
2328///
2329/// This is git's own meaning of "merged": the branch tip is an ancestor of
2330/// another ref. A squash merge or a rebase rewrites the commits, so it does
2331/// not count and the branch is kept.
2332fn managed_branch_is_merged(
2333    executor: &impl CommandExecutor,
2334    worktree: &ManagedWorktree,
2335) -> Result<Option<bool>> {
2336    if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
2337        return Ok(None);
2338    }
2339    let branch_ref = format!("refs/heads/{}", worktree.branch);
2340    let refs = managed_git_stdout(
2341        executor,
2342        &worktree.target,
2343        &worktree.source_repository,
2344        [
2345            "for-each-ref",
2346            "--contains",
2347            &branch_ref,
2348            "--format=%(refname)",
2349            "refs/heads",
2350            "refs/remotes",
2351        ],
2352        "list the branches containing a managed worktree branch",
2353    )?;
2354    Ok(Some(refs.lines().any(containing_ref_is_not_a_session)))
2355}
2356
2357/// A ref that proves the session branch's commits live somewhere else: any
2358/// branch outside `refs/heads/mj/`, including a remote-tracking branch, since
2359/// work merged upstream and fetched is merged. A remote's symbolic `HEAD` is
2360/// not a branch of its own and never counts.
2361fn containing_ref_is_not_a_session(reference: &str) -> bool {
2362    let reference = reference.trim();
2363    let remote_head = reference.starts_with("refs/remotes/") && reference.ends_with("/HEAD");
2364    !reference.is_empty() && !reference.starts_with("refs/heads/mj/") && !remote_head
2365}
2366
2367/// Remove a managed worktree's checkout, and its branch only when the caller
2368/// asks for that. The branch can hold work the user still wants, so deleting
2369/// it is always an explicit decision; see [`BranchDisposition`].
2370pub(super) fn cleanup_managed_worktree(
2371    executor: &impl CommandExecutor,
2372    worktree: &ManagedWorktree,
2373    branch: BranchDisposition,
2374) -> Result<()> {
2375    if !remove_managed_worktree_checkout(executor, worktree)? {
2376        return Ok(());
2377    }
2378    if worktree.kind == ManagedCheckoutKind::Clone {
2379        return remove_empty_managed_worktree_directories(executor, worktree);
2380    }
2381    if branch == BranchDisposition::Keep {
2382        return remove_empty_managed_worktree_directories(executor, worktree);
2383    }
2384    let branch_ref = format!("refs/heads/{}", worktree.branch);
2385    let check = managed_git_command(
2386        &worktree.target,
2387        &worktree.source_repository,
2388        ["show-ref", "--verify", "--quiet", &branch_ref],
2389        "check managed worktree branch",
2390    );
2391    let output = executor.execute(&check)?;
2392    let present = match output.status {
2393        0 => true,
2394        1 => false,
2395        status => bail!(
2396            "check managed worktree branch failed with status {status}: {}",
2397            String::from_utf8_lossy(&output.stderr).trim()
2398        ),
2399    };
2400    let delete = match branch {
2401        BranchDisposition::Delete => present,
2402        BranchDisposition::DeleteIfMerged if present => {
2403            let merged = managed_branch_is_merged(executor, worktree)?;
2404            let delete = merged == Some(true);
2405            tracing::info!(
2406                branch = %worktree.branch,
2407                delete,
2408                reason = match merged {
2409                    Some(true) => "another branch already contains its commits",
2410                    Some(false) => "it holds commits no other branch contains",
2411                    None => "its repository is gone",
2412                },
2413                "archiving decided what to do with a session branch"
2414            );
2415            delete
2416        }
2417        BranchDisposition::DeleteIfMerged | BranchDisposition::Keep => false,
2418    };
2419    if delete {
2420        execute_checked(
2421            executor,
2422            managed_git_command(
2423                &worktree.target,
2424                &worktree.source_repository,
2425                ["branch", "-D", "--", &worktree.branch],
2426                "delete managed raw-session branch",
2427            ),
2428        )?;
2429    }
2430    remove_empty_managed_worktree_directories(executor, worktree)
2431}
2432
2433fn remove_empty_managed_worktree_directories(
2434    executor: &impl CommandExecutor,
2435    worktree: &ManagedWorktree,
2436) -> Result<()> {
2437    let worktrees = worktree
2438        .source_repository
2439        .join(".mj")
2440        .join(match worktree.kind {
2441            ManagedCheckoutKind::Worktree => "worktrees",
2442            ManagedCheckoutKind::Clone => "clones",
2443        });
2444    let hel = worktree.source_repository.join(".mj");
2445    match &worktree.target {
2446        ManagedWorktreeTarget::Local => {
2447            for directory in [&worktrees, &hel] {
2448                match std::fs::remove_dir(directory) {
2449                    Ok(()) => {}
2450                    Err(error)
2451                        if matches!(
2452                            error.kind(),
2453                            std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
2454                        ) => {}
2455                    Err(error) => return Err(error.into()),
2456                }
2457            }
2458        }
2459        ManagedWorktreeTarget::Ssh { .. } => {
2460            let command = managed_target_command(
2461                &worktree.target,
2462                "rmdir",
2463                ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
2464            )
2465            .purpose("remove empty managed worktree directories");
2466            let _ = executor.execute(&command)?;
2467        }
2468    }
2469    Ok(())
2470}
2471
2472#[cfg(test)]
2473mod tests;