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