Skip to main content

mj_controller/controller/
worktree.rs

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