Skip to main content

mj_controller/controller/
worktree.rs

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