1use 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, backend_ssh, execute_checked, now, ssh_command_spec};
22
23impl Controller {
24 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 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 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(&backend_ssh(ssh), directory, executor)?;
109 mj_core::remote_git::resolve_local_repository(
110 directory,
111 &RemoteGitExecutor {
112 executor,
113 ssh: backend_ssh(ssh),
114 },
115 )?;
116 Ok(())
117 }
118 _ => bail!("project directory validation requires a bare target"),
119 }
120 }
121
122 pub fn resolve_session_project_source(
126 &self,
127 session_id: &str,
128 executor: &impl CommandExecutor,
129 ) -> Result<ProjectSourceIdentity> {
130 let session = self
131 .state
132 .sessions
133 .get(session_id)
134 .with_context(|| format!("unknown session {session_id}"))?;
135 let Some(directory) = session.project_directory.as_deref() else {
136 return Ok(session.project_source(&self.config));
137 };
138 let (target, origin_directory) = match &session.managed_worktree {
139 Some(worktree) => (
143 worktree.target.clone(),
144 worktree.source_repository.as_path(),
145 ),
146 None => (
147 managed_worktree_target(
148 self.config
149 .targets
150 .get(&session.target_template_id)
151 .with_context(|| {
152 format!(
153 "session {session_id} target {:?} is no longer configured",
154 session.target_template_id
155 )
156 })?,
157 )?,
158 directory,
159 ),
160 };
161 let output = executor.execute(&managed_git_command(
162 &target,
163 origin_directory,
164 ["config", "--get", "remote.origin.url"],
165 "resolve project Git origin",
166 ))?;
167 match output.status {
168 0 => {
169 let origin =
170 String::from_utf8(output.stdout).context("project Git origin was not UTF-8")?;
171 if let Some(identity) = ProjectSourceIdentity::git_remote(origin.trim()) {
172 return Ok(identity);
173 }
174 }
175 1 => {}
177 status => bail!(
178 "resolve project Git origin failed with status {status}: {}",
179 String::from_utf8_lossy(&output.stderr).trim()
180 ),
181 }
182 let root = resolve_git_root(&target, origin_directory, executor)?
183 .unwrap_or_else(|| origin_directory.to_path_buf());
184 let remote = match &target {
185 ManagedWorktreeTarget::Local => None,
186 ManagedWorktreeTarget::Ssh { destination, .. } => Some(destination.as_str()),
187 };
188 Ok(ProjectSourceIdentity::path(&root, remote))
189 }
190
191 pub(super) fn plan_workspace_to_raw(
194 &self,
195 session: &SessionRecord,
196 target_id: &str,
197 executor: &impl CommandExecutor,
198 ) -> Result<WorkspaceToRawConversion> {
199 let bundle = self
200 .config
201 .bundles
202 .get(&session.bundle_id)
203 .context("session bundle is missing")?;
204 let [repository] = bundle.repositories.as_slice() else {
205 bail!("a checkout holds exactly one repository");
206 };
207 let source = repository
208 .local
209 .as_deref()
210 .context("only a repository already on this machine can become a checkout")?;
211 self.validate_project_directory(target_id, source, executor)
212 .context("this session's repository is unavailable")?;
213 let mut worktree = ManagedWorktree {
214 source_project_directory: source.to_path_buf(),
215 source_repository: source.to_path_buf(),
216 worktree_root: source.join(".mj").join("worktrees").join(&session.id),
217 branch: format!("mj/{}", session.id),
218 target: managed_worktree_target(
219 self.config
220 .targets
221 .get(target_id)
222 .with_context(|| format!("unknown target template {target_id:?}"))?,
223 )?,
224 base_commit: None,
225 };
226 let reuse_existing_branch =
227 retained_managed_worktree_branch_available(executor, &worktree)?;
228 if !reuse_existing_branch {
232 worktree.base_commit =
233 Some(read_checkout_position(executor, &worktree.target, source)?.head_commit);
234 }
235 if !reuse_existing_branch {
236 ensure_managed_worktree_available(executor, &worktree)?;
237 }
238 Ok(WorkspaceToRawConversion {
239 worktree,
240 reuse_existing_branch,
241 })
242 }
243
244 pub(super) fn prepare_managed_raw_worktree(
245 &mut self,
246 session_id: &str,
247 executor: &impl CommandExecutor,
248 ) -> Result<bool> {
249 let session = self
250 .state
251 .sessions
252 .get(session_id)
253 .with_context(|| format!("unknown session {session_id}"))?
254 .clone();
255 let Some(selected) = session.project_directory.as_deref() else {
256 return Ok(false);
257 };
258 if session.managed_worktree.is_some() {
259 return Ok(false);
260 }
261 if session.create_managed_worktree == Some(false) {
262 return Ok(false);
263 }
264 let template = self
265 .config
266 .targets
267 .get(&session.target_template_id)
268 .context("raw session target template disappeared during provisioning")?;
269 if matches!(template, TargetTemplate::SshBare { .. }) {
270 self.validate_project_directory(&session.target_template_id, selected, executor)?;
271 }
272 let target = managed_worktree_target(template)?;
273 if matches!(target, ManagedWorktreeTarget::Local)
274 && local_project_repository(selected, executor)?.is_none()
275 {
276 ensure!(
277 session.create_managed_worktree != Some(true),
278 "managed worktree creation requires a Git project"
279 );
280 return Ok(false);
281 }
282 let inspection = inspect_raw_project(executor, &target, selected)?;
283 if !inspection.primary_checkout && session.create_managed_worktree != Some(true) {
284 return Ok(false);
285 }
286 let relative_directory = inspection
287 .source_project_directory
288 .strip_prefix(&inspection.source_repository)
289 .context("raw project directory is outside its repository")?
290 .to_path_buf();
291 let worktree_root = inspection
292 .source_repository
293 .join(".mj")
294 .join("worktrees")
295 .join(session_id);
296 let base_commit =
299 read_checkout_position(executor, &target, &inspection.source_repository)?.head_commit;
300 let managed = ManagedWorktree {
301 source_project_directory: inspection.source_project_directory,
302 source_repository: inspection.source_repository,
303 worktree_root: worktree_root.clone(),
304 branch: format!("mj/{session_id}"),
305 target,
306 base_commit: Some(base_commit),
307 };
308 ensure_managed_worktree_available(executor, &managed)?;
309 let record = self.state.sessions.get_mut(session_id).unwrap();
310 record.project_directory = Some(worktree_root.join(relative_directory));
311 record.managed_worktree = Some(managed.clone());
312 record.updated_at = now();
313 self.persist_session_state(session_id)?;
314 create_managed_worktree(
315 executor,
316 &managed,
317 inspection.upstream.as_deref(),
318 PrimaryCheckoutRequirement::Clean,
319 )?;
320 Ok(true)
321 }
322
323 fn cleanup_new_session_worktree(
324 &self,
325 session_id: &str,
326 executor: &impl CommandExecutor,
327 ) -> Result<()> {
328 let Some(worktree) = self
329 .state
330 .sessions
331 .get(session_id)
332 .and_then(|session| session.managed_worktree.as_ref())
333 else {
334 return Ok(());
335 };
336 cleanup_managed_worktree(executor, worktree)
337 }
338
339 pub(super) fn cleanup_new_session_worktree_after_failure(
340 &self,
341 session_id: &str,
342 executor: &impl CommandExecutor,
343 ) -> Result<()> {
344 if executor.cancellation_requested() {
345 let cleanup_executor =
346 CancellableProcessExecutor::with_timeout(Duration::from_secs(15));
347 self.cleanup_new_session_worktree(session_id, &cleanup_executor)
348 } else {
349 self.cleanup_new_session_worktree(session_id, executor)
350 }
351 }
352}
353
354struct RemoteGitExecutor<'a, E> {
356 executor: &'a E,
357 ssh: SshTarget,
358}
359
360impl<E: CommandExecutor> CommandExecutor for RemoteGitExecutor<'_, E> {
361 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
362 let mut arguments = vec!["env".to_owned()];
363 arguments.extend(
364 command
365 .env
366 .iter()
367 .map(|(key, value)| format!("{key}={value}")),
368 );
369 arguments.push(command.program.clone());
370 arguments.extend(command.args.clone());
371 self.executor
372 .execute(&ssh_command_spec(&self.ssh, arguments).purpose(&command.purpose))
373 }
374
375 fn cancellation_requested(&self) -> bool {
376 self.executor.cancellation_requested()
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381struct RawProjectInspection {
382 source_project_directory: PathBuf,
383 source_repository: PathBuf,
384 primary_checkout: bool,
385 upstream: Option<String>,
386}
387
388fn managed_target_ssh(target: &ManagedWorktreeTarget) -> Option<SshTarget> {
389 match target {
390 ManagedWorktreeTarget::Local => None,
391 ManagedWorktreeTarget::Ssh {
392 destination,
393 ssh_args,
394 } => Some(SshTarget {
395 destination: destination.clone(),
396 ssh_args: ssh_args.clone(),
397 }),
398 }
399}
400
401fn managed_target_command(
402 target: &ManagedWorktreeTarget,
403 program: &str,
404 args: impl IntoIterator<Item = impl AsRef<str>>,
405) -> CommandSpec {
406 let args = args
407 .into_iter()
408 .map(|arg| arg.as_ref().to_owned())
409 .collect::<Vec<_>>();
410 match managed_target_ssh(target) {
411 None => CommandSpec::new(program, args),
412 Some(ssh) => {
413 let mut remote = vec![program.to_owned()];
414 remote.extend(args);
415 ssh_command_spec(&ssh, remote)
416 }
417 }
418}
419
420fn managed_git_command(
421 target: &ManagedWorktreeTarget,
422 directory: &Path,
423 args: impl IntoIterator<Item = impl AsRef<str>>,
424 purpose: impl Into<String>,
425) -> CommandSpec {
426 let mut command_args = vec!["-C".to_owned(), directory.to_string_lossy().into_owned()];
427 command_args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
428 managed_target_command(target, "git", command_args).purpose(purpose)
429}
430
431fn command_stdout(output: CommandOutput, purpose: &str) -> Result<String> {
432 if output.status != 0 {
433 bail!(
434 "{purpose} failed with status {}: {}",
435 output.status,
436 String::from_utf8_lossy(&output.stderr).trim()
437 );
438 }
439 let stdout = String::from_utf8(output.stdout)
440 .with_context(|| format!("{purpose} produced non-UTF-8 output"))?;
441 Ok(stdout.trim_end_matches(['\r', '\n']).to_owned())
442}
443
444fn managed_git_stdout(
445 executor: &impl CommandExecutor,
446 target: &ManagedWorktreeTarget,
447 directory: &Path,
448 args: impl IntoIterator<Item = impl AsRef<str>>,
449 purpose: &str,
450) -> Result<String> {
451 let command = managed_git_command(target, directory, args, purpose);
452 command_stdout(executor.execute(&command)?, purpose)
453}
454
455fn resolve_git_root(
458 target: &ManagedWorktreeTarget,
459 directory: &Path,
460 executor: &impl CommandExecutor,
461) -> Result<Option<PathBuf>> {
462 let args = [
465 "-C".to_owned(),
466 directory.to_string_lossy().into_owned(),
467 "rev-parse".into(),
468 "--path-format=absolute".into(),
469 "--show-toplevel".into(),
470 ];
471 let top_level = match target {
472 ManagedWorktreeTarget::Local => {
473 let mut command = CommandSpec::new("git", args);
474 command.env.insert("LC_ALL".into(), "C".into());
475 command
476 }
477 ManagedWorktreeTarget::Ssh { .. } => managed_target_command(
478 target,
479 "env",
480 ["LC_ALL=C".to_owned(), "git".into()]
481 .into_iter()
482 .chain(args),
483 ),
484 }
485 .purpose("resolve project Git root");
486 let output = executor.execute(&top_level)?;
487 if output.status != 0 {
488 if output.status == 128
489 && String::from_utf8_lossy(&output.stderr).starts_with("fatal: not a git repository")
490 {
491 return Ok(None);
492 }
493 bail!(
494 "resolve project Git root failed with status {}: {}",
495 output.status,
496 String::from_utf8_lossy(&output.stderr).trim()
497 );
498 }
499 let root = PathBuf::from(
500 String::from_utf8(output.stdout)
501 .context("project Git root was not UTF-8")?
502 .trim_end_matches(['\r', '\n']),
503 );
504 if root.as_os_str().is_empty() {
505 bail!("resolve project Git root returned an empty path");
506 }
507
508 let common = PathBuf::from(managed_git_stdout(
509 executor,
510 target,
511 directory,
512 ["rev-parse", "--path-format=absolute", "--git-common-dir"],
513 "resolve project Git common directory",
514 )?);
515 if common.file_name() == Some(std::ffi::OsStr::new(".git"))
516 && let Some(main_root) = common.parent()
517 {
518 return Ok(Some(main_root.to_path_buf()));
519 }
520 Ok(Some(root))
521}
522
523pub fn local_project_repository(
526 directory: &Path,
527 executor: &impl CommandExecutor,
528) -> Result<Option<PathBuf>> {
529 resolve_git_root(&ManagedWorktreeTarget::Local, directory, executor)
530}
531
532pub(super) fn apply_raw_to_workspace(
537 record: &mut SessionRecord,
538 conversion: &RawToWorkspaceConversion,
539) {
540 record.project_directory = None;
541 record.managed_worktree = None;
542 record.bundle_id.clone_from(&conversion.bundle_id);
543}
544
545#[derive(Debug, Clone, PartialEq, Eq)]
548pub(super) enum ResumeConversion {
549 RawToWorkspace(RawToWorkspaceConversion),
550 WorkspaceToRaw(WorkspaceToRawConversion),
551}
552
553impl ResumeConversion {
554 pub(super) fn raw_to_workspace(&self) -> Option<&RawToWorkspaceConversion> {
555 match self {
556 Self::RawToWorkspace(conversion) => Some(conversion),
557 Self::WorkspaceToRaw(_) => None,
558 }
559 }
560
561 pub(super) fn workspace_to_raw(&self) -> Option<&WorkspaceToRawConversion> {
562 match self {
563 Self::WorkspaceToRaw(conversion) => Some(conversion),
564 Self::RawToWorkspace(_) => None,
565 }
566 }
567}
568
569#[derive(Debug, Clone, PartialEq, Eq)]
573pub(super) struct WorkspaceToRawConversion {
574 pub(super) worktree: ManagedWorktree,
575 pub(super) reuse_existing_branch: bool,
579}
580
581pub(super) fn apply_workspace_to_raw(
584 record: &mut SessionRecord,
585 conversion: &WorkspaceToRawConversion,
586) {
587 record.project_directory = Some(conversion.worktree.worktree_root.clone());
588 record.managed_worktree = Some(conversion.worktree.clone());
589}
590
591#[derive(Debug, Clone, PartialEq, Eq)]
594pub(super) struct RawToWorkspaceConversion {
595 pub(super) checkout: PathBuf,
599 pub(super) repository: PathBuf,
601 pub(super) source: mj_core::remote_git::NetworkGitSource,
605 pub(super) bundle_id: String,
606 pub(super) new_bundle: Option<ProjectBundle>,
608 pub(super) retire: Option<ManagedWorktree>,
610}
611
612pub(super) fn plan_raw_to_workspace(
615 session: &SessionRecord,
616 config: &Config,
617 executor: &impl CommandExecutor,
618) -> Result<RawToWorkspaceConversion> {
619 let project_directory = session
620 .project_directory
621 .as_deref()
622 .context("a raw session has no project directory")?;
623 let (checkout, repository, retire) = match &session.managed_worktree {
629 Some(worktree) => {
630 ensure!(
631 worktree.worktree_root == project_directory,
632 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
633 project_directory.display()
634 );
635 (
636 worktree.worktree_root.clone(),
637 worktree.source_repository.clone(),
638 Some(worktree.clone()),
639 )
640 }
641 None => {
642 let inspection =
643 inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
644 ensure!(
645 inspection.source_project_directory == inspection.source_repository,
646 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
647 project_directory.display()
648 );
649 let repository = canonical_repository(&inspection.source_repository)?;
650 (inspection.source_repository, repository, None)
651 }
652 };
653 let destination = PathBuf::from(
657 project_directory
658 .file_name()
659 .context("a raw project directory cannot be the filesystem root")?,
660 );
661 let (bundle_id, new_bundle) =
662 converted_raw_bundle(config, &session.bundle_id, &repository, &destination);
663 let source = mj_core::remote_git::resolve_local_repository(&checkout, executor).with_context(
667 || {
668 format!(
669 "{} has no network Git remote; add one (for example `git remote add origin <url>`) or resume this session on a bare target",
670 checkout.display()
671 )
672 },
673 )?;
674 Ok(RawToWorkspaceConversion {
675 checkout,
676 repository,
677 source,
678 bundle_id,
679 new_bundle,
680 retire,
681 })
682}
683
684fn converted_raw_bundle(
688 config: &Config,
689 session_bundle_id: &str,
690 repository: &Path,
691 destination: &Path,
692) -> (String, Option<ProjectBundle>) {
693 let describes_checkout = |bundle: &ProjectBundle| {
694 bundle.repositories.len() == 1
695 && bundle.repositories[0].github.is_none()
696 && bundle.repositories[0].local.as_deref() == Some(repository)
697 && bundle.repositories[0].destination == destination
698 };
699 if config
700 .bundles
701 .get(session_bundle_id)
702 .is_some_and(describes_checkout)
703 {
704 return (session_bundle_id.to_owned(), None);
705 }
706 if let Some((id, _)) = config
707 .bundles
708 .iter()
709 .find(|(_, bundle)| describes_checkout(bundle))
710 {
711 return (id.clone(), None);
712 }
713 let name = repository
714 .file_name()
715 .map(|name| name.to_string_lossy().into_owned())
716 .unwrap_or_default();
717 let id = crate::import::unique_bundle_id(config, &crate::import::setup_style_id(&name));
718 let bundle = ProjectBundle {
719 primary_repo: id.clone(),
720 repositories: vec![mj_core::config::ProjectRepository {
721 id: id.clone(),
722 github: None,
723 local: Some(repository.to_path_buf()),
724 destination: destination.to_path_buf(),
725 git_ref: None,
726 }],
727 };
728 (id, Some(bundle))
729}
730
731const RAW_CONVERSION_REPOSITORY_ID: &str = "project";
734
735pub(super) fn raw_checkout_snapshot(
743 checkout: &Path,
744 source: &mj_core::remote_git::NetworkGitSource,
745 destination: &Path,
746 git: &dyn mj_checkpoint::archive::GitCommandRunner,
747) -> Result<mj_checkpoint::archive::RepositorySnapshot> {
748 mj_checkpoint::checkpoint::repair_origin_refs(git, checkout, RAW_CONVERSION_REPOSITORY_ID)?;
751 mj_checkpoint::checkpoint::reject_dirty_submodules(git, checkout)
752 .with_context(|| format!("checkout {}", checkout.display()))?;
753 let mut snapshot = mj_checkpoint::archive::collect_git_snapshot(
754 git,
755 checkout,
756 &mj_checkpoint::archive::GitCollectionSpec {
757 id: RAW_CONVERSION_REPOSITORY_ID.to_owned(),
758 relative_destination: destination.to_path_buf(),
759 history: mj_checkpoint::archive::GitHistoryMode::SessionDelta,
760 origin_override: None,
761 },
762 )
763 .with_context(|| format!("snapshot the checkout at {}", checkout.display()))?;
764 snapshot.metadata.origin =
767 mj_checkpoint::archive::redact_origin_credentials(&source.fetch_url)?;
768 snapshot.metadata.push_urls = source
769 .push_urls
770 .iter()
771 .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
772 .collect::<Result<Vec<_>>>()?;
773 snapshot.metadata.remote_workspace = true;
774 snapshot.metadata.base_commit = origin_boundary_commit(git, checkout)?
775 .unwrap_or_else(|| snapshot.metadata.head_commit.clone());
776 Ok(snapshot)
777}
778
779fn origin_boundary_commit(
783 git: &dyn mj_checkpoint::archive::GitCommandRunner,
784 checkout: &Path,
785) -> Result<Option<String>> {
786 let listed = git_runner_stdout(
787 git,
788 checkout,
789 [
790 "rev-list",
791 "--boundary",
792 "HEAD",
793 "--not",
794 "--remotes=origin",
795 ],
796 "list commits outside origin",
797 )?;
798 Ok(listed
801 .lines()
802 .filter_map(|line| line.strip_prefix('-'))
803 .map(|commit| commit.trim().to_owned())
804 .find(|commit| !commit.is_empty()))
805}
806
807fn git_runner_stdout(
808 git: &dyn mj_checkpoint::archive::GitCommandRunner,
809 repository: &Path,
810 args: impl IntoIterator<Item = impl AsRef<str>>,
811 purpose: &str,
812) -> Result<String> {
813 let output = git.run(
814 repository,
815 &mj_checkpoint::archive::GitCommand {
816 arguments: args
817 .into_iter()
818 .map(|argument| std::ffi::OsString::from(argument.as_ref()))
819 .collect(),
820 stdin: Vec::new(),
821 env: Vec::new(),
822 },
823 )?;
824 command_stdout(
825 CommandOutput {
826 status: output.status,
827 stdout: output.stdout,
828 stderr: output.stderr,
829 },
830 purpose,
831 )
832}
833
834pub(super) fn raw_conversion_preview(
837 session: &SessionRecord,
838 conversion: &RawToWorkspaceConversion,
839 executor: &impl CommandExecutor,
840) -> Result<mj_core::state::RawConversionPreview> {
841 let checkout = conversion.checkout.as_path();
842 reject_dirty_submodules_in_checkout(executor, checkout)?;
845 let default_branch = mj_core::remote_git::default_branch(&conversion.source, executor)?;
846 let position = read_checkout_position(executor, &ManagedWorktreeTarget::Local, checkout)?;
847 let unpushed_commits = unpushed_commit_count(executor, checkout)?;
848 let dirty = dirty_file_counts(executor, checkout)?;
849 let directory = session
852 .project_directory
853 .as_deref()
854 .context("a raw session has no project directory")?
855 .file_name()
856 .context("a raw project directory cannot be the filesystem root")?;
857 Ok(mj_core::state::RawConversionPreview {
858 checkout: checkout.to_path_buf(),
859 destination: PathBuf::from(mj_core::targets::CONTAINER_WORKSPACE).join(directory),
860 branch: position.branch,
861 fetch_url: conversion.source.fetch_url.clone(),
862 push_urls: conversion.source.push_urls.clone(),
863 default_branch,
864 unpushed_commits,
865 staged_files: dirty.staged_files,
866 unstaged_files: dirty.unstaged_files,
867 untracked_files: dirty.untracked_files,
868 untracked_bytes: untracked_bytes(executor, checkout)?,
869 host_checkout_retained: conversion.retire.is_none(),
870 })
871}
872
873fn reject_dirty_submodules_in_checkout(
874 executor: &impl CommandExecutor,
875 checkout: &Path,
876) -> Result<()> {
877 let listed = managed_git_stdout(
878 executor,
879 &ManagedWorktreeTarget::Local,
880 checkout,
881 [
882 "submodule",
883 "foreach",
884 "--recursive",
885 "--quiet",
886 "git status --porcelain",
887 ],
888 "inspect submodules",
889 )?;
890 ensure!(
891 listed.trim().is_empty(),
892 "{} has a dirty submodule, which cannot move into a target; commit or discard the submodule's changes first",
893 checkout.display()
894 );
895 Ok(())
896}
897
898fn unpushed_commit_count(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
902 if !origin_refs_available(executor, checkout)? {
903 return Ok(0);
904 }
905 let counted = managed_git_stdout(
906 executor,
907 &ManagedWorktreeTarget::Local,
908 checkout,
909 ["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
910 "count commits outside origin",
911 )?;
912 counted
913 .trim()
914 .parse()
915 .with_context(|| format!("parse the commit count {counted:?}"))
916}
917
918fn origin_refs_available(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
919 if origin_refs_listed(executor, checkout)? {
920 return Ok(true);
921 }
922 let fetch = managed_git_command(
925 &ManagedWorktreeTarget::Local,
926 checkout,
927 ["fetch", "origin"],
928 "fetch origin refs",
929 );
930 executor.execute(&fetch)?;
931 origin_refs_listed(executor, checkout)
932}
933
934fn origin_refs_listed(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
935 managed_git_stdout(
936 executor,
937 &ManagedWorktreeTarget::Local,
938 checkout,
939 [
940 "for-each-ref",
941 "--format=%(objectname)",
942 "refs/remotes/origin",
943 ],
944 "list origin refs",
945 )
946 .map(|refs| !refs.trim().is_empty())
947}
948
949#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
950struct DirtyFileCounts {
951 staged_files: u64,
952 unstaged_files: u64,
953 untracked_files: u64,
954}
955
956fn dirty_file_counts(executor: &impl CommandExecutor, checkout: &Path) -> Result<DirtyFileCounts> {
959 let command = managed_git_command(
960 &ManagedWorktreeTarget::Local,
961 checkout,
962 ["status", "--porcelain=v1", "-z"],
963 "read checkout status",
964 );
965 let output = executor.execute(&command)?;
966 ensure!(
967 output.status == 0,
968 "read checkout status failed with status {}: {}",
969 output.status,
970 String::from_utf8_lossy(&output.stderr).trim()
971 );
972 let mut counts = DirtyFileCounts::default();
973 let mut records = output
974 .stdout
975 .split(|byte| *byte == 0)
976 .filter(|record| !record.is_empty());
977 while let Some(record) = records.next() {
978 let [index, worktree, ..] = record else {
979 bail!("git status produced a record shorter than its status field");
980 };
981 if *index == b'?' && *worktree == b'?' {
982 counts.untracked_files += 1;
983 continue;
984 }
985 if !matches!(index, b' ' | b'?') {
986 counts.staged_files += 1;
987 }
988 if !matches!(worktree, b' ' | b'?') {
989 counts.unstaged_files += 1;
990 }
991 if *index == b'R' || *index == b'C' || *worktree == b'R' || *worktree == b'C' {
992 records.next();
993 }
994 }
995 Ok(counts)
996}
997
998fn untracked_bytes(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1002 let command = managed_git_command(
1003 &ManagedWorktreeTarget::Local,
1004 checkout,
1005 ["ls-files", "--others", "--exclude-standard", "-z"],
1006 "list untracked files",
1007 );
1008 let output = executor.execute(&command)?;
1009 ensure!(
1010 output.status == 0,
1011 "list untracked files failed with status {}: {}",
1012 output.status,
1013 String::from_utf8_lossy(&output.stderr).trim()
1014 );
1015 let mut total = 0;
1016 for record in output
1017 .stdout
1018 .split(|byte| *byte == 0)
1019 .filter(|record| !record.is_empty())
1020 {
1021 let relative = mj_core::path_input::from_git_bytes(record)?;
1022 let path = checkout.join(relative);
1023 match std::fs::symlink_metadata(&path) {
1026 Ok(metadata) => total += metadata.len(),
1027 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1028 Err(error) => {
1029 return Err(error).with_context(|| format!("measure {}", path.display()));
1030 }
1031 }
1032 }
1033 Ok(total)
1034}
1035
1036#[derive(Debug, Clone, PartialEq, Eq)]
1038pub(super) struct CheckoutPosition {
1039 pub(super) head_commit: String,
1040 branch: Option<String>,
1041}
1042
1043fn read_checkout_position(
1044 executor: &impl CommandExecutor,
1045 target: &ManagedWorktreeTarget,
1046 directory: &Path,
1047) -> Result<CheckoutPosition> {
1048 let head_commit = managed_git_stdout(
1049 executor,
1050 target,
1051 directory,
1052 ["rev-parse", "HEAD"],
1053 "resolve checkout head commit",
1054 )?;
1055 let branch_command = managed_git_command(
1056 target,
1057 directory,
1058 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1059 "resolve checkout branch",
1060 );
1061 let branch_output = executor.execute(&branch_command)?;
1062 let branch = match branch_output.status {
1063 0 => Some(
1064 String::from_utf8(branch_output.stdout)
1065 .context("checkout branch was not UTF-8")?
1066 .trim()
1067 .to_owned(),
1068 ),
1069 1 | 128 => None,
1071 status => bail!(
1072 "resolve checkout branch failed with status {status}: {}",
1073 String::from_utf8_lossy(&branch_output.stderr).trim()
1074 ),
1075 };
1076 Ok(CheckoutPosition {
1077 head_commit,
1078 branch,
1079 })
1080}
1081
1082pub(super) fn managed_worktree_base_commit(
1088 worktree: &ManagedWorktree,
1089 executor: &impl CommandExecutor,
1090) -> Result<String> {
1091 if let Some(base) = &worktree.base_commit {
1092 return Ok(base.clone());
1093 }
1094 let reference = format!("refs/heads/{}", worktree.branch);
1095 let reflog_command = managed_git_command(
1096 &worktree.target,
1097 &worktree.source_repository,
1098 ["reflog", "show", "--format=%H", &reference],
1099 "read the session branch reflog",
1100 );
1101 let reflog_output = executor.execute(&reflog_command)?;
1102 if reflog_output.status == 0 {
1103 let text = String::from_utf8(reflog_output.stdout)
1104 .context("the session branch reflog was not UTF-8")?;
1105 if let Some(creation) = text.lines().rfind(|line| !line.trim().is_empty()) {
1108 return Ok(creation.trim().to_owned());
1109 }
1110 }
1111 let head = read_checkout_position(executor, &worktree.target, &worktree.worktree_root)?;
1112 tracing::warn!(
1113 branch = %worktree.branch,
1114 "the reflog for this session branch is gone, so its checkpoint bundle will carry no commits"
1115 );
1116 Ok(head.head_commit)
1117}
1118
1119pub(super) fn raw_checkout_position(
1122 session: &SessionRecord,
1123 config: &Config,
1124 project_directory: &Path,
1125 executor: &impl CommandExecutor,
1126) -> Result<CheckoutPosition> {
1127 let target = match &session.managed_worktree {
1128 Some(worktree) => worktree.target.clone(),
1129 None => {
1130 let template = config
1131 .targets
1132 .get(&session.target_template_id)
1133 .context("the bare target this session last used is missing")?;
1134 managed_worktree_target(template)?
1135 }
1136 };
1137 read_checkout_position(executor, &target, project_directory)
1138}
1139
1140pub(super) fn raw_checkout_divergence_notice(
1146 directory: &Path,
1147 recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
1148 live: &CheckoutPosition,
1149) -> Option<String> {
1150 let recorded = recorded?;
1151 if recorded.head_commit.is_empty()
1152 || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
1153 {
1154 return None;
1155 }
1156 Some(format!(
1157 "The working tree at {} moved from {} to {} while this session was stopped.",
1158 directory.display(),
1159 checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
1160 checkout_position_text(&live.head_commit, live.branch.as_deref()),
1161 ))
1162}
1163
1164fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
1165 let short = head_commit.get(..12).unwrap_or(head_commit);
1166 match branch {
1167 Some(branch) => format!("{short} ({branch})"),
1168 None => format!("{short} (detached)"),
1169 }
1170}
1171
1172fn inspect_raw_project(
1173 executor: &impl CommandExecutor,
1174 target: &ManagedWorktreeTarget,
1175 selected: &Path,
1176) -> Result<RawProjectInspection> {
1177 let repository = PathBuf::from(managed_git_stdout(
1178 executor,
1179 target,
1180 selected,
1181 ["rev-parse", "--path-format=absolute", "--show-toplevel"],
1182 "resolve raw project repository root",
1183 )?);
1184 let prefix = managed_git_stdout(
1185 executor,
1186 target,
1187 selected,
1188 ["rev-parse", "--show-prefix"],
1189 "resolve raw project relative directory",
1190 )?;
1191 let git_dir = PathBuf::from(managed_git_stdout(
1192 executor,
1193 target,
1194 selected,
1195 ["rev-parse", "--absolute-git-dir"],
1196 "resolve raw project Git directory",
1197 )?);
1198 let common_git_dir = PathBuf::from(managed_git_stdout(
1199 executor,
1200 target,
1201 selected,
1202 ["rev-parse", "--path-format=absolute", "--git-common-dir"],
1203 "resolve raw project common Git directory",
1204 )?);
1205 let branch_command = managed_git_command(
1206 target,
1207 selected,
1208 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1209 "resolve raw project branch",
1210 );
1211 let branch_output = executor.execute(&branch_command)?;
1212 let branch = match branch_output.status {
1213 0 => Some(
1214 String::from_utf8(branch_output.stdout)
1215 .context("raw project branch was not UTF-8")?
1216 .trim()
1217 .to_owned(),
1218 ),
1219 1 | 128 => None,
1220 status => bail!(
1221 "resolve raw project branch failed with status {status}: {}",
1222 String::from_utf8_lossy(&branch_output.stderr).trim()
1223 ),
1224 };
1225 let upstream = match branch {
1226 Some(branch) => {
1227 let reference = format!("refs/heads/{branch}");
1228 let upstream = managed_git_stdout(
1229 executor,
1230 target,
1231 selected,
1232 ["for-each-ref", "--format=%(upstream:short)", &reference],
1233 "resolve raw project upstream",
1234 )?;
1235 (!upstream.is_empty()).then_some(upstream)
1236 }
1237 None => None,
1238 };
1239 Ok(RawProjectInspection {
1240 source_project_directory: repository.join(prefix),
1241 source_repository: repository,
1242 primary_checkout: git_dir == common_git_dir,
1243 upstream,
1244 })
1245}
1246
1247fn ensure_managed_worktree_excluded(
1248 executor: &impl CommandExecutor,
1249 target: &ManagedWorktreeTarget,
1250 repository: &Path,
1251) -> Result<()> {
1252 let check = managed_git_command(
1253 target,
1254 repository,
1255 [
1256 "check-ignore",
1257 "--quiet",
1258 "--no-index",
1259 "--",
1260 ".mj/worktrees/",
1261 ],
1262 "check managed worktree exclusion",
1263 );
1264 let output = executor.execute(&check)?;
1265 match output.status {
1266 0 => return Ok(()),
1267 1 => {}
1268 status => bail!(
1269 "check managed worktree exclusion failed with status {status}: {}",
1270 String::from_utf8_lossy(&output.stderr).trim()
1271 ),
1272 }
1273 let exclude_path = PathBuf::from(managed_git_stdout(
1274 executor,
1275 target,
1276 repository,
1277 [
1278 "rev-parse",
1279 "--path-format=absolute",
1280 "--git-path",
1281 "info/exclude",
1282 ],
1283 "resolve repository-local exclude file",
1284 )?);
1285 const ENTRY: &str = "/.mj/worktrees/";
1286 match target {
1287 ManagedWorktreeTarget::Local => {
1288 use std::io::Write;
1289 let existing = match std::fs::read_to_string(&exclude_path) {
1290 Ok(existing) => existing,
1291 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1292 Err(error) => return Err(error.into()),
1293 };
1294 if existing.lines().any(|line| line.trim() == ENTRY) {
1295 return Ok(());
1296 }
1297 if let Some(parent) = exclude_path.parent() {
1298 std::fs::create_dir_all(parent)?;
1299 }
1300 let mut file = std::fs::OpenOptions::new()
1301 .create(true)
1302 .append(true)
1303 .open(&exclude_path)
1304 .with_context(|| format!("open {}", exclude_path.display()))?;
1305 if !existing.is_empty() && !existing.ends_with('\n') {
1306 writeln!(file)?;
1307 }
1308 writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
1309 }
1310 ManagedWorktreeTarget::Ssh { .. } => {
1311 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";
1312 let command = managed_target_command(
1313 target,
1314 "sh",
1315 [
1316 "-c",
1317 SCRIPT,
1318 "hel-exclude",
1319 &exclude_path.to_string_lossy(),
1320 ENTRY,
1321 ],
1322 )
1323 .purpose("update remote repository-local exclude file");
1324 execute_checked(executor, command)?;
1325 }
1326 }
1327 Ok(())
1328}
1329
1330pub(crate) fn path_exists_on_managed_target(
1331 executor: &impl CommandExecutor,
1332 target: &ManagedWorktreeTarget,
1333 path: &Path,
1334) -> Result<bool> {
1335 match target {
1336 ManagedWorktreeTarget::Local => path
1337 .try_exists()
1338 .with_context(|| format!("check managed project path {}", path.display())),
1339 ManagedWorktreeTarget::Ssh { .. } => {
1340 let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
1341 .purpose("check managed worktree path");
1342 let output = executor.execute(&command)?;
1343 match output.status {
1344 0 => Ok(true),
1345 1 => Ok(false),
1346 status => bail!(
1347 "check managed worktree path failed with status {status}: {}",
1348 String::from_utf8_lossy(&output.stderr).trim()
1349 ),
1350 }
1351 }
1352 }
1353}
1354
1355pub(super) fn managed_worktree_checkout_exists(
1356 executor: &impl CommandExecutor,
1357 worktree: &ManagedWorktree,
1358) -> Result<bool> {
1359 path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1360}
1361
1362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1364pub(super) enum PrimaryCheckoutRequirement {
1365 Clean,
1368 Any,
1372}
1373
1374pub(super) fn create_managed_worktree(
1375 executor: &impl CommandExecutor,
1376 worktree: &ManagedWorktree,
1377 upstream: Option<&str>,
1378 requirement: PrimaryCheckoutRequirement,
1379) -> Result<()> {
1380 ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
1381 if requirement == PrimaryCheckoutRequirement::Clean {
1382 let status = managed_git_stdout(
1383 executor,
1384 &worktree.target,
1385 &worktree.source_repository,
1386 ["status", "--porcelain=v1", "--untracked-files=all"],
1387 "inspect primary checkout changes",
1388 )?;
1389 if !status.is_empty() {
1390 let paths = status.lines().take(20).collect::<Vec<_>>().join("\n ");
1391 bail!(
1392 "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n {paths}"
1393 );
1394 }
1395 }
1396 let parent = worktree
1397 .worktree_root
1398 .parent()
1399 .context("managed worktree root has no parent")?;
1400 execute_checked(
1401 executor,
1402 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1403 .purpose("create managed worktree directory"),
1404 )?;
1405 execute_checked(
1406 executor,
1407 managed_git_command(
1408 &worktree.target,
1409 &worktree.source_repository,
1410 [
1411 "worktree",
1412 "add",
1413 "-b",
1414 &worktree.branch,
1415 &worktree.worktree_root.to_string_lossy(),
1416 "HEAD",
1417 ],
1418 "create managed raw-session worktree",
1419 ),
1420 )?;
1421 if let Some(upstream) = upstream {
1422 execute_checked(
1423 executor,
1424 managed_git_command(
1425 &worktree.target,
1426 &worktree.worktree_root,
1427 ["branch", "--set-upstream-to", upstream, &worktree.branch],
1428 "set managed worktree branch upstream",
1429 ),
1430 )?;
1431 }
1432 Ok(())
1433}
1434
1435pub(super) fn restore_managed_worktree(
1439 executor: &impl CommandExecutor,
1440 worktree: &ManagedWorktree,
1441) -> Result<bool> {
1442 if managed_worktree_checkout_exists(executor, worktree)? {
1443 return Ok(false);
1444 }
1445 ensure!(
1446 path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
1447 "managed worktree source repository is unavailable: {}",
1448 worktree.source_repository.display()
1449 );
1450 let branch_ref = format!("refs/heads/{}", worktree.branch);
1451 let check = managed_git_command(
1452 &worktree.target,
1453 &worktree.source_repository,
1454 ["show-ref", "--verify", "--quiet", &branch_ref],
1455 "check retired managed worktree branch",
1456 );
1457 let output = executor.execute(&check)?;
1458 match output.status {
1459 0 => {}
1460 1 => bail!(
1461 "managed worktree branch is unavailable: {}",
1462 worktree.branch
1463 ),
1464 status => bail!(
1465 "check retired managed worktree branch failed with status {status}: {}",
1466 String::from_utf8_lossy(&output.stderr).trim()
1467 ),
1468 }
1469 execute_checked(
1472 executor,
1473 managed_git_command(
1474 &worktree.target,
1475 &worktree.source_repository,
1476 ["worktree", "prune"],
1477 "prune retired managed worktree metadata",
1478 ),
1479 )?;
1480 let parent = worktree
1481 .worktree_root
1482 .parent()
1483 .context("managed worktree root has no parent")?;
1484 execute_checked(
1485 executor,
1486 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1487 .purpose("recreate managed worktree directory"),
1488 )?;
1489 execute_checked(
1490 executor,
1491 managed_git_command(
1492 &worktree.target,
1493 &worktree.source_repository,
1494 [
1495 "worktree",
1496 "add",
1497 "--",
1498 &worktree.worktree_root.to_string_lossy(),
1499 &worktree.branch,
1500 ],
1501 "restore managed raw-session worktree",
1502 ),
1503 )?;
1504 Ok(true)
1505}
1506
1507fn ensure_managed_worktree_available(
1508 executor: &impl CommandExecutor,
1509 worktree: &ManagedWorktree,
1510) -> Result<()> {
1511 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1512 bail!(
1513 "managed worktree path already exists: {}",
1514 worktree.worktree_root.display()
1515 );
1516 }
1517 let branch_ref = format!("refs/heads/{}", worktree.branch);
1518 let check = managed_git_command(
1519 &worktree.target,
1520 &worktree.source_repository,
1521 ["show-ref", "--verify", "--quiet", &branch_ref],
1522 "check managed worktree branch availability",
1523 );
1524 let output = executor.execute(&check)?;
1525 match output.status {
1526 0 => bail!(
1527 "managed worktree branch already exists: {}",
1528 worktree.branch
1529 ),
1530 1 => Ok(()),
1531 status => bail!(
1532 "check managed worktree branch availability failed with status {status}: {}",
1533 String::from_utf8_lossy(&output.stderr).trim()
1534 ),
1535 }
1536}
1537
1538fn retained_managed_worktree_branch_available(
1543 executor: &impl CommandExecutor,
1544 worktree: &ManagedWorktree,
1545) -> Result<bool> {
1546 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1547 bail!(
1548 "managed worktree path already exists: {}",
1549 worktree.worktree_root.display()
1550 );
1551 }
1552 let branch_ref = format!("refs/heads/{}", worktree.branch);
1553 let check = managed_git_command(
1554 &worktree.target,
1555 &worktree.source_repository,
1556 ["show-ref", "--verify", "--quiet", &branch_ref],
1557 "check retained managed worktree branch",
1558 );
1559 let output = executor.execute(&check)?;
1560 match output.status {
1561 1 => Ok(false),
1562 0 => {
1563 let worktrees = managed_git_stdout(
1564 executor,
1565 &worktree.target,
1566 &worktree.source_repository,
1567 ["worktree", "list", "--porcelain", "-z"],
1568 "check retained managed worktree checkout",
1569 )?;
1570 let branch_field = format!("branch {branch_ref}");
1571 if worktrees.split('\0').any(|field| field == branch_field) {
1572 bail!(
1573 "managed worktree branch is still checked out: {}",
1574 worktree.branch
1575 );
1576 }
1577 Ok(true)
1578 }
1579 status => bail!(
1580 "check retained managed worktree branch failed with status {status}: {}",
1581 String::from_utf8_lossy(&output.stderr).trim()
1582 ),
1583 }
1584}
1585
1586pub(super) fn preserve_retained_managed_worktree_branch(
1590 executor: &impl CommandExecutor,
1591 worktree: &ManagedWorktree,
1592) -> Result<String> {
1593 let session_id = worktree
1594 .branch
1595 .strip_prefix("mj/")
1596 .context("managed worktree branch is not session-owned")?;
1597 let branch_ref = format!("refs/heads/{}", worktree.branch);
1598 let tip = managed_git_stdout(
1599 executor,
1600 &worktree.target,
1601 &worktree.source_repository,
1602 ["rev-parse", "--verify", &branch_ref],
1603 "read retained managed worktree branch tip",
1604 )?;
1605 let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
1606 let existing = managed_git_command(
1607 &worktree.target,
1608 &worktree.source_repository,
1609 ["show-ref", "--verify", "--quiet", &recovery_ref],
1610 "check retained managed worktree recovery ref",
1611 );
1612 let output = executor.execute(&existing)?;
1613 match output.status {
1614 0 => {
1615 let existing_tip = managed_git_stdout(
1616 executor,
1617 &worktree.target,
1618 &worktree.source_repository,
1619 ["rev-parse", "--verify", &recovery_ref],
1620 "verify retained managed worktree recovery ref",
1621 )?;
1622 ensure!(
1623 existing_tip == tip,
1624 "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
1625 );
1626 Ok(recovery_ref)
1627 }
1628 1 => {
1629 execute_checked(
1630 executor,
1631 managed_git_command(
1632 &worktree.target,
1633 &worktree.source_repository,
1634 ["update-ref", &recovery_ref, &tip],
1635 "preserve retained managed worktree branch",
1636 ),
1637 )?;
1638 Ok(recovery_ref)
1639 }
1640 status => bail!(
1641 "check retained managed worktree recovery ref failed with status {status}: {}",
1642 String::from_utf8_lossy(&output.stderr).trim()
1643 ),
1644 }
1645}
1646
1647pub(super) fn retire_managed_worktree(
1654 executor: &impl CommandExecutor,
1655 worktree: &ManagedWorktree,
1656) -> Result<()> {
1657 if !remove_managed_worktree_checkout(executor, worktree)? {
1658 return Ok(());
1659 }
1660 remove_empty_managed_worktree_directories(executor, worktree)
1661}
1662
1663fn remove_managed_worktree_checkout(
1666 executor: &impl CommandExecutor,
1667 worktree: &ManagedWorktree,
1668) -> Result<bool> {
1669 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1670 return Ok(false);
1671 }
1672 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1673 execute_checked(
1674 executor,
1675 managed_git_command(
1676 &worktree.target,
1677 &worktree.source_repository,
1678 [
1679 "worktree",
1680 "remove",
1681 "--force",
1682 &worktree.worktree_root.to_string_lossy(),
1683 ],
1684 "remove managed raw-session worktree",
1685 ),
1686 )?;
1687 }
1688 execute_checked(
1689 executor,
1690 managed_git_command(
1691 &worktree.target,
1692 &worktree.source_repository,
1693 ["worktree", "prune"],
1694 "prune managed worktree metadata",
1695 ),
1696 )?;
1697 Ok(true)
1698}
1699
1700pub(super) fn cleanup_managed_worktree(
1701 executor: &impl CommandExecutor,
1702 worktree: &ManagedWorktree,
1703) -> Result<()> {
1704 if !remove_managed_worktree_checkout(executor, worktree)? {
1705 return Ok(());
1706 }
1707 let branch_ref = format!("refs/heads/{}", worktree.branch);
1708 let check = managed_git_command(
1709 &worktree.target,
1710 &worktree.source_repository,
1711 ["show-ref", "--verify", "--quiet", &branch_ref],
1712 "check managed worktree branch",
1713 );
1714 let output = executor.execute(&check)?;
1715 match output.status {
1716 0 => {
1717 execute_checked(
1718 executor,
1719 managed_git_command(
1720 &worktree.target,
1721 &worktree.source_repository,
1722 ["branch", "-D", "--", &worktree.branch],
1723 "delete managed raw-session branch",
1724 ),
1725 )?;
1726 }
1727 1 => {}
1728 status => bail!(
1729 "check managed worktree branch failed with status {status}: {}",
1730 String::from_utf8_lossy(&output.stderr).trim()
1731 ),
1732 }
1733 remove_empty_managed_worktree_directories(executor, worktree)
1734}
1735
1736fn remove_empty_managed_worktree_directories(
1737 executor: &impl CommandExecutor,
1738 worktree: &ManagedWorktree,
1739) -> Result<()> {
1740 let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1741 let hel = worktree.source_repository.join(".mj");
1742 match &worktree.target {
1743 ManagedWorktreeTarget::Local => {
1744 for directory in [&worktrees, &hel] {
1745 match std::fs::remove_dir(directory) {
1746 Ok(()) => {}
1747 Err(error)
1748 if matches!(
1749 error.kind(),
1750 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1751 ) => {}
1752 Err(error) => return Err(error.into()),
1753 }
1754 }
1755 }
1756 ManagedWorktreeTarget::Ssh { .. } => {
1757 let command = managed_target_command(
1758 &worktree.target,
1759 "rmdir",
1760 ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1761 )
1762 .purpose("remove empty managed worktree directories");
1763 let _ = executor.execute(&command)?;
1764 }
1765 }
1766 Ok(())
1767}
1768
1769#[cfg(test)]
1770mod tests {
1771 use std::cell::RefCell;
1772 use std::collections::BTreeMap;
1773 use std::path::{Path, PathBuf};
1774 use std::process::Command;
1775
1776 use anyhow::Result;
1777
1778 use crate::controller::Controller;
1779 use crate::controller::resume::apply_failed_resume_rollback;
1780 use crate::controller::test_support::{
1781 FIXTURE_FETCH_URL, FixtureRemoteExecutor, checkout_with_network_remote,
1782 checkpoint_test_session, committed_repository, local_bundle, managed_raw_session,
1783 managed_worktree_session, raw_session_on, resume_compatibility_config, ssh_worktree_target,
1784 test_git,
1785 };
1786 use mj_checkpoint::archive::RepositoryMetadata;
1787 use mj_core::config::{
1788 Config, HarnessProfile, ProjectBundle, ProjectRepository, TargetTemplate,
1789 };
1790 use mj_core::state::{ManagedWorktree, ManagedWorktreeTarget, SessionState, State};
1791
1792 use crate::targets::{
1793 CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
1794 };
1795
1796 use super::*;
1797
1798 #[test]
1799 fn worktree_choice_survives_reload_and_controls_creation() {
1800 const CHILD: &str = "MJ_TEST_WORKTREE_CHOICE_CHILD";
1801 if std::env::var_os(CHILD).is_none() {
1802 let directory = tempfile::tempdir().unwrap();
1803 let mut command = Command::new(std::env::current_exe().unwrap());
1804 command.args(["--exact", "controller::worktree::tests::worktree_choice_survives_reload_and_controls_creation", "--nocapture"])
1805 .env(CHILD, "1").env("MJ_DATA_DIR", directory.path()).env("MJ_CONFIG_DIR", directory.path());
1806 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1807 assert!(
1808 output.status.success(),
1809 "{}\n{}",
1810 String::from_utf8_lossy(&output.stdout),
1811 String::from_utf8_lossy(&output.stderr)
1812 );
1813 return;
1814 }
1815 let _writer = crate::database::install_isolated_test_writer();
1816 let repository = committed_repository();
1817 let root = repository.path().canonicalize().unwrap();
1818 let linked_parent = tempfile::tempdir().unwrap();
1819 let linked = linked_parent.path().join("linked");
1820 test_git(
1821 &root,
1822 &["worktree", "add", "-b", "side", linked.to_str().unwrap()],
1823 );
1824 test_git(&linked, &["branch", "--set-upstream-to=master"]);
1825 std::fs::write(linked.join("nested/file.txt"), "linked commit\n").unwrap();
1826 test_git(&linked, &["commit", "-am", "side commit"]);
1827 let linked = linked.canonicalize().unwrap();
1828 let mut config = Config::default();
1829 config
1830 .targets
1831 .insert("localhost".into(), TargetTemplate::LocalBare);
1832 config.save().unwrap();
1833 let mut controller = Controller {
1834 config,
1835 state: State::default(),
1836 };
1837 assert_eq!(
1838 controller
1839 .managed_worktree_options("localhost", &root, &ProcessExecutor)
1840 .unwrap(),
1841 ManagedWorktreeOptions {
1842 available: true,
1843 default_create: true
1844 }
1845 );
1846 assert_eq!(
1847 controller
1848 .managed_worktree_options("localhost", &linked, &ProcessExecutor)
1849 .unwrap(),
1850 ManagedWorktreeOptions {
1851 available: true,
1852 default_create: false
1853 }
1854 );
1855
1856 std::fs::write(root.join("dirty.txt"), "keep me\n").unwrap();
1859 let selected = root.join("nested");
1860 let mut record = raw_session_on("localhost", selected.to_str().unwrap());
1861 record.create_managed_worktree = Some(false);
1862 crate::database::save_session(&record).unwrap();
1863 for _ in 0..2 {
1864 controller.reload().unwrap();
1865 assert!(
1866 !controller
1867 .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1868 .unwrap()
1869 );
1870 assert_eq!(
1871 controller.state.sessions[&record.id]
1872 .project_directory
1873 .as_ref(),
1874 Some(&selected)
1875 );
1876 assert!(
1877 controller.state.sessions[&record.id]
1878 .managed_worktree
1879 .is_none()
1880 );
1881 controller
1882 .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1883 .unwrap();
1884 }
1885 assert!(root.join("dirty.txt").exists());
1886 assert!(!root.join(".mj/worktrees").exists());
1887 assert_eq!(test_git(&root, &["branch", "--show-current"]), "master");
1888 std::fs::remove_file(root.join("dirty.txt")).unwrap();
1889
1890 record.project_directory = Some(linked.join("nested"));
1893 record.create_managed_worktree = None;
1894 crate::database::save_session(&record).unwrap();
1895 controller.reload().unwrap();
1896 assert!(
1897 !controller
1898 .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1899 .unwrap()
1900 );
1901 record.create_managed_worktree = Some(true);
1902 crate::database::save_session(&record).unwrap();
1903 controller.reload().unwrap();
1904 assert!(
1905 controller
1906 .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1907 .unwrap()
1908 );
1909 controller.reload().unwrap();
1910 let managed = controller.state.sessions[&record.id]
1911 .managed_worktree
1912 .clone()
1913 .unwrap();
1914 assert_eq!(
1915 test_git(&managed.worktree_root, &["rev-parse", "HEAD"]),
1916 test_git(&linked, &["rev-parse", "HEAD"])
1917 );
1918 assert_ne!(
1919 test_git(&managed.worktree_root, &["rev-parse", "HEAD"]),
1920 test_git(&root, &["rev-parse", "HEAD"])
1921 );
1922 assert_eq!(
1923 test_git(
1924 &managed.worktree_root,
1925 &["rev-parse", "--abbrev-ref", "@{upstream}"]
1926 ),
1927 "master"
1928 );
1929 assert_eq!(
1930 controller.state.sessions[&record.id].project_directory,
1931 Some(managed.worktree_root.join("nested"))
1932 );
1933 controller
1934 .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1935 .unwrap();
1936 assert!(linked.join("nested/file.txt").exists());
1937 assert!(!managed.worktree_root.exists());
1938
1939 record.project_directory = Some(root.clone());
1940 record.create_managed_worktree = None;
1941 crate::database::save_session(&record).unwrap();
1942 controller.reload().unwrap();
1943 assert!(
1944 controller
1945 .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1946 .unwrap()
1947 );
1948 controller
1949 .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1950 .unwrap();
1951 }
1952
1953 #[test]
1954 fn explicit_worktree_creation_rejects_plain_directories() {
1955 let directory = tempfile::tempdir().unwrap();
1956 let mut record = raw_session_on("localhost", directory.path().to_str().unwrap());
1957 record.create_managed_worktree = Some(true);
1958 let id = record.id.clone();
1959 let mut config = Config::default();
1960 config
1961 .targets
1962 .insert("localhost".into(), TargetTemplate::LocalBare);
1963 let mut controller = Controller {
1964 config,
1965 state: State {
1966 sessions: [(id.clone(), record)].into_iter().collect(),
1967 ..State::default()
1968 },
1969 };
1970 assert_eq!(
1971 controller
1972 .managed_worktree_options("localhost", directory.path(), &ProcessExecutor)
1973 .unwrap(),
1974 ManagedWorktreeOptions::default()
1975 );
1976 let error = controller
1977 .prepare_managed_raw_worktree(&id, &ProcessExecutor)
1978 .unwrap_err();
1979 assert!(error.to_string().contains("requires a Git project"));
1980 assert!(!directory.path().join(".git").exists());
1981 }
1982
1983 #[test]
1984 fn local_bare_validation_accepts_projects_and_plain_directories_but_rejects_missing_paths() {
1985 let project = committed_repository();
1986 let plain = tempfile::tempdir().unwrap();
1987 let mut config = Config::default();
1988 config
1989 .targets
1990 .insert("localhost".into(), TargetTemplate::LocalBare);
1991 let controller = Controller {
1992 config,
1993 state: State::default(),
1994 };
1995 controller
1996 .validate_project_directory("localhost", project.path(), &ProcessExecutor)
1997 .unwrap();
1998 controller
1999 .validate_project_directory("localhost", plain.path(), &ProcessExecutor)
2000 .unwrap();
2001 assert!(
2002 controller
2003 .validate_project_directory(
2004 "localhost",
2005 &plain.path().join("missing"),
2006 &ProcessExecutor
2007 )
2008 .is_err()
2009 );
2010 }
2011
2012 #[test]
2013 fn a_plain_local_directory_starts_without_creating_a_git_worktree() {
2014 let plain = tempfile::tempdir().unwrap();
2015 let session = raw_session_on("localhost", plain.path().to_str().unwrap());
2016 let session_id = session.id.clone();
2017 let mut config = Config::default();
2018 config
2019 .targets
2020 .insert("localhost".into(), TargetTemplate::LocalBare);
2021 let mut controller = Controller {
2022 config,
2023 state: State {
2024 sessions: [(session_id.clone(), session)].into_iter().collect(),
2025 ..State::default()
2026 },
2027 };
2028 assert!(
2029 !controller
2030 .prepare_managed_raw_worktree(&session_id, &ProcessExecutor)
2031 .unwrap()
2032 );
2033 let session = &controller.state.sessions[&session_id];
2034 assert_eq!(session.project_directory.as_deref(), Some(plain.path()));
2035 assert!(session.managed_worktree.is_none());
2036 assert!(!plain.path().join(".git").exists());
2037 }
2038
2039 #[test]
2040 fn raw_linked_worktree_origin_matches_the_configured_github_project() {
2041 struct OriginExecutor;
2042 impl CommandExecutor for OriginExecutor {
2043 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2044 assert_eq!(
2045 command.args,
2046 [
2047 "-C",
2048 "/mnt/optane/bifrost-fird",
2049 "config",
2050 "--get",
2051 "remote.origin.url",
2052 ]
2053 );
2054 Ok(CommandOutput {
2055 status: 0,
2056 stdout: b"git@github.com:BrokkAi/bifrost-dev.git\n".to_vec(),
2057 stderr: Vec::new(),
2058 })
2059 }
2060 }
2061
2062 let mut config = Config::default();
2063 config
2064 .targets
2065 .insert("localhost".into(), TargetTemplate::LocalBare);
2066 let session = raw_session_on("localhost", "/mnt/optane/bifrost-fird");
2067 let session_id = session.id.clone();
2068 let controller = Controller {
2069 config,
2070 state: State {
2071 sessions: [(session_id.clone(), session)].into_iter().collect(),
2072 ..State::default()
2073 },
2074 };
2075
2076 let source = controller
2077 .resolve_session_project_source(&session_id, &OriginExecutor)
2078 .unwrap();
2079
2080 assert_eq!(source.key, "github:brokkai/bifrost-dev");
2081 assert_eq!(source.short, "bifrost-dev");
2082 assert_eq!(source.full, "BrokkAi/bifrost-dev");
2083 }
2084 #[test]
2085 fn managed_worktree_origin_uses_source_repository_while_checkout_is_retired() {
2086 struct OriginExecutor;
2087 impl CommandExecutor for OriginExecutor {
2088 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2089 assert_eq!(
2090 command.args,
2091 [
2092 "-C",
2093 "/home/dev/project",
2094 "config",
2095 "--get",
2096 "remote.origin.url",
2097 ]
2098 );
2099 Ok(CommandOutput {
2100 status: 0,
2101 stdout: b"git@github.com:example/project.git\n".to_vec(),
2102 stderr: Vec::new(),
2103 })
2104 }
2105 }
2106
2107 let session = managed_raw_session(ManagedWorktreeTarget::Local);
2108 let session_id = session.id.clone();
2109 let controller = Controller {
2110 config: Config::default(),
2111 state: State {
2112 sessions: [(session_id.clone(), session)].into_iter().collect(),
2113 ..State::default()
2114 },
2115 };
2116
2117 let source = controller
2118 .resolve_session_project_source(&session_id, &OriginExecutor)
2119 .unwrap();
2120
2121 assert_eq!(source.key, "github:example/project");
2122 }
2123
2124 #[test]
2125 fn raw_no_origin_uses_the_canonical_main_repository_root() {
2126 struct NoOriginExecutor {
2127 commands: RefCell<Vec<CommandSpec>>,
2128 }
2129 impl CommandExecutor for NoOriginExecutor {
2130 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2131 self.commands.borrow_mut().push(command.clone());
2132 if command.args.iter().any(|argument| argument == "config") {
2133 return Ok(CommandOutput {
2134 status: 1,
2135 stdout: Vec::new(),
2136 stderr: Vec::new(),
2137 });
2138 }
2139 let stdout = if command
2140 .args
2141 .iter()
2142 .any(|argument| argument == "--show-toplevel")
2143 {
2144 "/worktrees/project-side\n"
2145 } else if command
2146 .args
2147 .iter()
2148 .any(|argument| argument == "--git-common-dir")
2149 {
2150 "/projects/project/.git\n"
2151 } else {
2152 panic!("unexpected command {:?}", command.args);
2153 };
2154 Ok(CommandOutput {
2155 status: 0,
2156 stdout: stdout.as_bytes().to_vec(),
2157 stderr: Vec::new(),
2158 })
2159 }
2160 }
2161
2162 let mut config = Config::default();
2163 config
2164 .targets
2165 .insert("localhost".into(), TargetTemplate::LocalBare);
2166 let session = raw_session_on("localhost", "/worktrees/project-side");
2167 let session_id = session.id.clone();
2168 let controller = Controller {
2169 config,
2170 state: State {
2171 sessions: [(session_id.clone(), session)].into_iter().collect(),
2172 ..State::default()
2173 },
2174 };
2175 let executor = NoOriginExecutor {
2176 commands: RefCell::new(Vec::new()),
2177 };
2178
2179 let source = controller
2180 .resolve_session_project_source(&session_id, &executor)
2181 .unwrap();
2182
2183 assert_eq!(source.key, "path:/projects/project");
2184 assert_eq!(source.short, "project");
2185 assert_eq!(source.full, "/projects/project");
2186 assert_eq!(executor.commands.borrow().len(), 3);
2187 }
2188
2189 #[test]
2190 fn raw_non_git_directory_keeps_its_local_path_source() {
2191 struct NonGitExecutor {
2192 commands: RefCell<Vec<CommandSpec>>,
2193 }
2194 impl CommandExecutor for NonGitExecutor {
2195 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2196 self.commands.borrow_mut().push(command.clone());
2197 let status = if command.args.iter().any(|argument| argument == "config") {
2198 1
2199 } else {
2200 assert!(
2201 command
2202 .args
2203 .iter()
2204 .any(|argument| argument == "--show-toplevel")
2205 );
2206 128
2207 };
2208 Ok(CommandOutput {
2209 status,
2210 stdout: Vec::new(),
2211 stderr: b"fatal: not a git repository\n".to_vec(),
2212 })
2213 }
2214 }
2215
2216 let mut config = Config::default();
2217 config
2218 .targets
2219 .insert("localhost".into(), TargetTemplate::LocalBare);
2220 let session = raw_session_on("localhost", "/scratch/project");
2221 let session_id = session.id.clone();
2222 let controller = Controller {
2223 config,
2224 state: State {
2225 sessions: [(session_id.clone(), session)].into_iter().collect(),
2226 ..State::default()
2227 },
2228 };
2229 let executor = NonGitExecutor {
2230 commands: RefCell::new(Vec::new()),
2231 };
2232
2233 let source = controller
2234 .resolve_session_project_source(&session_id, &executor)
2235 .unwrap();
2236
2237 assert_eq!(source.key, "path:/scratch/project");
2238 assert_eq!(source.full, "/scratch/project");
2239 assert_eq!(executor.commands.borrow().len(), 2);
2240 }
2241
2242 #[test]
2243 fn project_root_lookup_reports_git_failures_instead_of_treating_them_as_non_git() {
2244 struct FailedGit;
2245 impl CommandExecutor for FailedGit {
2246 fn execute(&self, _: &CommandSpec) -> Result<CommandOutput> {
2247 Ok(CommandOutput {
2248 status: 128,
2249 stdout: Vec::new(),
2250 stderr: b"fatal: detected dubious ownership in repository".to_vec(),
2251 })
2252 }
2253 }
2254 let error = resolve_git_root(
2255 &ManagedWorktreeTarget::Local,
2256 Path::new("/project"),
2257 &FailedGit,
2258 )
2259 .unwrap_err();
2260 assert!(error.to_string().contains("dubious ownership"));
2261 }
2262
2263 struct CheckoutPositionExecutor {
2265 head_commit: String,
2266 branch: Option<String>,
2267 }
2268 impl CommandExecutor for CheckoutPositionExecutor {
2269 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2270 let stdout = if command.args.iter().any(|argument| argument == "rev-parse") {
2271 self.head_commit.clone()
2272 } else if command
2273 .args
2274 .iter()
2275 .any(|argument| argument == "symbolic-ref")
2276 {
2277 match &self.branch {
2278 Some(branch) => branch.clone(),
2279 None => {
2280 return Ok(CommandOutput {
2281 status: 1,
2282 stdout: Vec::new(),
2283 stderr: Vec::new(),
2284 });
2285 }
2286 }
2287 } else {
2288 panic!("unexpected command {:?}", command.args);
2289 };
2290 Ok(CommandOutput {
2291 status: 0,
2292 stdout: format!("{stdout}\n").into_bytes(),
2293 stderr: Vec::new(),
2294 })
2295 }
2296 }
2297 fn recorded_repository(head_commit: &str, branch: Option<&str>) -> RepositoryMetadata {
2298 RepositoryMetadata {
2299 push_urls: Vec::new(),
2300 remote_workspace: false,
2301 id: "project".into(),
2302 relative_destination: PathBuf::from("project"),
2303 origin: "mj-local:project".into(),
2304 base_commit: String::new(),
2305 head_commit: head_commit.into(),
2306 branch: branch.map(str::to_owned),
2307 }
2308 }
2309 #[test]
2310 fn a_raw_checkout_that_moved_while_stopped_gets_a_conversation_line() {
2311 let config = resume_compatibility_config();
2312 let session = managed_raw_session(ManagedWorktreeTarget::Local);
2313 let directory = session.project_directory.clone().unwrap();
2314 let executor = CheckoutPositionExecutor {
2315 head_commit: "b".repeat(40),
2316 branch: Some("mj/0123456789abcdef0123456789abcdef".into()),
2317 };
2318
2319 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
2320 let notice = raw_checkout_divergence_notice(
2321 &directory,
2322 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
2323 &live,
2324 )
2325 .expect("a moved checkout is reported");
2326
2327 assert!(
2328 notice.contains(&directory.display().to_string()),
2329 "{notice}"
2330 );
2331 assert!(notice.contains("aaaaaaaaaaaa (main)"), "{notice}");
2332 assert!(
2333 notice.contains("bbbbbbbbbbbb (mj/0123456789abcdef0123456789abcdef)"),
2334 "{notice}"
2335 );
2336 assert!(
2337 notice.contains("while this session was stopped"),
2338 "{notice}"
2339 );
2340 }
2341 #[test]
2342 fn a_raw_checkout_that_stayed_put_gets_no_conversation_line() {
2343 let config = resume_compatibility_config();
2344 let session = managed_raw_session(ManagedWorktreeTarget::Local);
2345 let directory = session.project_directory.clone().unwrap();
2346 let executor = CheckoutPositionExecutor {
2347 head_commit: "a".repeat(40),
2348 branch: Some("main".into()),
2349 };
2350
2351 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
2352
2353 assert_eq!(
2354 raw_checkout_divergence_notice(
2355 &directory,
2356 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
2357 &live,
2358 ),
2359 None
2360 );
2361 }
2362 #[test]
2363 fn a_checkpoint_without_recorded_git_identity_reports_nothing() {
2364 let live = CheckoutPosition {
2365 head_commit: "b".repeat(40),
2366 branch: None,
2367 };
2368
2369 assert_eq!(
2370 raw_checkout_divergence_notice(Path::new("/home/dev/project"), None, &live),
2371 None
2372 );
2373 assert_eq!(
2374 raw_checkout_divergence_notice(
2375 Path::new("/home/dev/project"),
2376 Some(&recorded_repository("", None)),
2377 &live,
2378 ),
2379 None
2380 );
2381 }
2382 #[test]
2383 fn a_detached_checkout_is_named_as_detached() {
2384 let config = resume_compatibility_config();
2385 let session = managed_raw_session(ManagedWorktreeTarget::Local);
2386 let directory = session.project_directory.clone().unwrap();
2387 let executor = CheckoutPositionExecutor {
2388 head_commit: "c".repeat(40),
2389 branch: None,
2390 };
2391
2392 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
2393 let notice = raw_checkout_divergence_notice(
2394 &directory,
2395 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
2396 &live,
2397 )
2398 .expect("a moved checkout is reported");
2399
2400 assert!(notice.contains("cccccccccccc (detached)"), "{notice}");
2401 }
2402 #[test]
2403 fn bundle_sessions_resume_on_any_workspace_target() {
2404 let config = resume_compatibility_config();
2405 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2406
2407 assert_eq!(
2408 resume_compatibility(&session, &config, "podman"),
2409 Ok(ResumePlan::InPlace)
2410 );
2411 assert_eq!(
2412 resume_compatibility(&session, &config, "ssh-bare"),
2413 Ok(ResumePlan::InPlace)
2414 );
2415 }
2416 #[test]
2417 fn a_single_local_repository_can_become_a_checkout() {
2418 let mut config = resume_compatibility_config();
2419 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2420 session.bundle_id = "project".into();
2421 config.bundles.insert(
2422 "project".into(),
2423 local_bundle(Path::new("/home/dev/project")),
2424 );
2425
2426 assert_eq!(
2427 resume_compatibility(&session, &config, "local-bare"),
2428 Ok(ResumePlan::WorkspaceToRaw)
2429 );
2430 }
2431 #[test]
2432 fn a_github_project_cannot_become_a_checkout() {
2433 let mut config = resume_compatibility_config();
2434 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2435 session.bundle_id = "project".into();
2436 let mut bundle = local_bundle(Path::new("/home/dev/project"));
2437 bundle.repositories[0].local = None;
2438 bundle.repositories[0].github = Some("example/project".into());
2439 config.bundles.insert("project".into(), bundle);
2440
2441 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2442
2443 assert!(reason.contains("came from GitHub"), "{reason}");
2444 assert!(
2445 reason.contains("resume it on a container, SSH, or EC2 target"),
2446 "{reason}"
2447 );
2448 }
2449 #[test]
2450 fn a_multi_repository_project_cannot_become_a_checkout() {
2451 let mut config = resume_compatibility_config();
2452 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2453 session.bundle_id = "project".into();
2454 let mut bundle = local_bundle(Path::new("/home/dev/project"));
2455 bundle.repositories.push(ProjectRepository {
2456 id: "tools".into(),
2457 github: None,
2458 local: Some(PathBuf::from("/home/dev/tools")),
2459 destination: PathBuf::from("tools"),
2460 git_ref: None,
2461 });
2462 config.bundles.insert("project".into(), bundle);
2463
2464 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2465
2466 assert!(reason.contains("2 repositories"), "{reason}");
2467 assert!(reason.contains("one checkout"), "{reason}");
2468 }
2469 #[test]
2470 fn bundle_sessions_refuse_a_local_bare_target_with_a_reason() {
2471 let config = resume_compatibility_config();
2472 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2473
2474 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2475
2476 assert!(reason.contains("created from a project bundle"), "{reason}");
2477 assert!(
2478 reason.contains("resume it on a container, SSH, or EC2 target"),
2479 "{reason}"
2480 );
2481 }
2482 #[test]
2483 fn managed_raw_sessions_resume_on_their_own_worktree_host() {
2484 let config = resume_compatibility_config();
2485
2486 assert_eq!(
2487 resume_compatibility(
2488 &managed_raw_session(ManagedWorktreeTarget::Local),
2489 &config,
2490 "local-bare",
2491 ),
2492 Ok(ResumePlan::InPlace)
2493 );
2494 assert_eq!(
2495 resume_compatibility(
2496 &managed_raw_session(ssh_worktree_target()),
2497 &config,
2498 "ssh-bare",
2499 ),
2500 Ok(ResumePlan::InPlace)
2501 );
2502 }
2503 #[test]
2504 fn managed_raw_sessions_refuse_a_bare_target_on_another_host() {
2505 let config = resume_compatibility_config();
2506
2507 let reason = resume_compatibility(
2508 &managed_raw_session(ManagedWorktreeTarget::Local),
2509 &config,
2510 "ssh-bare",
2511 )
2512 .unwrap_err();
2513 assert!(reason.contains("this machine"), "{reason}");
2514
2515 let reason = resume_compatibility(
2516 &managed_raw_session(ssh_worktree_target()),
2517 &config,
2518 "local-bare",
2519 )
2520 .unwrap_err();
2521 assert!(reason.contains("dev@builder"), "{reason}");
2522 }
2523 #[test]
2524 fn a_whole_local_checkout_can_move_to_an_isolated_target() {
2525 let config = resume_compatibility_config();
2526 for session in [
2527 managed_raw_session(ManagedWorktreeTarget::Local),
2528 raw_session_on("local-bare", "/home/dev/project"),
2529 ] {
2530 assert_eq!(
2531 resume_compatibility(&session, &config, "podman"),
2532 Ok(ResumePlan::RawToWorkspace)
2533 );
2534 }
2535 }
2536 fn add_network_remote(checkout: &Path) {
2539 test_git(checkout, &["remote", "add", "origin", FIXTURE_FETCH_URL]);
2540 }
2541
2542 fn fixture_network_source() -> mj_core::remote_git::NetworkGitSource {
2543 mj_core::remote_git::NetworkGitSource {
2544 fetch_url: FIXTURE_FETCH_URL.to_owned(),
2545 push_urls: vec![FIXTURE_FETCH_URL.to_owned()],
2546 }
2547 }
2548
2549 #[test]
2550 fn a_checkout_without_a_network_remote_cannot_be_planned_for_a_target() {
2551 let repository = committed_repository();
2552 let config = resume_compatibility_config();
2553 let session = raw_session_on("local-bare", &repository.path().to_string_lossy());
2554
2555 let error = plan_raw_to_workspace(&session, &config, &ProcessExecutor).unwrap_err();
2556
2557 let detail = format!("{error:#}");
2558 assert!(detail.contains("has no network Git remote"), "{detail}");
2559 assert!(detail.contains("git remote add origin"), "{detail}");
2560 assert!(detail.contains("bare target"), "{detail}");
2561 }
2562
2563 #[test]
2564 fn planning_a_conversion_records_the_checkouts_network_remote() {
2565 let (checkout, _remote_parent, _remote) = checkout_with_network_remote();
2566 let config = resume_compatibility_config();
2567 let session = raw_session_on("local-bare", &checkout.path().to_string_lossy());
2568
2569 let conversion = plan_raw_to_workspace(&session, &config, &ProcessExecutor).unwrap();
2570
2571 assert_eq!(conversion.source.fetch_url, FIXTURE_FETCH_URL);
2572 assert_eq!(conversion.source.push_urls, [FIXTURE_FETCH_URL]);
2573 assert_eq!(conversion.checkout, checkout.path().canonicalize().unwrap());
2574 assert!(conversion.retire.is_none());
2575 }
2576
2577 #[test]
2578 fn a_raw_checkout_snapshot_restores_into_a_fresh_clone_of_its_remote() {
2579 let (checkout, _remote_parent, remote) = checkout_with_network_remote();
2580 let pushed = test_git(checkout.path(), &["rev-parse", "HEAD"]);
2581 std::fs::write(checkout.path().join("nested/file.txt"), "session commit\n").unwrap();
2584 test_git(checkout.path(), &["commit", "-am", "session commit"]);
2585 let head = test_git(checkout.path(), &["rev-parse", "HEAD"]);
2586 std::fs::write(checkout.path().join("staged.txt"), "staged\n").unwrap();
2587 test_git(checkout.path(), &["add", "staged.txt"]);
2588 std::fs::write(checkout.path().join("nested/file.txt"), "unstaged\n").unwrap();
2589 let untracked = "u".repeat(100 * 1024);
2592 std::fs::write(checkout.path().join("untracked.txt"), &untracked).unwrap();
2593 let source =
2594 mj_core::remote_git::resolve_local_repository(checkout.path(), &ProcessExecutor)
2595 .unwrap();
2596
2597 let snapshot = raw_checkout_snapshot(
2598 checkout.path(),
2599 &source,
2600 Path::new("project"),
2601 &mj_checkpoint::archive::SystemGit,
2602 )
2603 .unwrap();
2604
2605 assert!(snapshot.metadata.remote_workspace);
2606 assert_eq!(snapshot.metadata.origin, FIXTURE_FETCH_URL);
2607 assert_eq!(snapshot.metadata.push_urls, [FIXTURE_FETCH_URL]);
2608 assert_eq!(snapshot.metadata.base_commit, pushed);
2609 assert_eq!(snapshot.metadata.head_commit, head);
2610 assert_eq!(snapshot.metadata.branch.as_deref(), Some("master"));
2611
2612 let fresh_parent = tempfile::tempdir().unwrap();
2615 let fresh = fresh_parent.path().join("workspace");
2616 let output = Command::new("git")
2617 .arg("clone")
2618 .arg(&remote)
2619 .arg(&fresh)
2620 .output()
2621 .unwrap();
2622 assert!(
2623 output.status.success(),
2624 "{}",
2625 String::from_utf8_lossy(&output.stderr)
2626 );
2627 mj_checkpoint::archive::restore_git_snapshot(
2628 &mj_checkpoint::archive::SystemGit,
2629 &fresh,
2630 &snapshot,
2631 )
2632 .unwrap();
2633
2634 assert_eq!(test_git(&fresh, &["rev-parse", "HEAD"]), head);
2635 assert_eq!(test_git(&fresh, &["branch", "--show-current"]), "master");
2636 assert_eq!(
2637 test_git(&fresh, &["diff", "--cached", "--name-only"]),
2638 "staged.txt"
2639 );
2640 assert_eq!(
2641 test_git(&fresh, &["diff", "--name-only"]),
2642 "nested/file.txt"
2643 );
2644 assert_eq!(
2645 std::fs::read_to_string(fresh.join("nested/file.txt")).unwrap(),
2646 "unstaged\n"
2647 );
2648 assert_eq!(
2649 std::fs::read_to_string(fresh.join("untracked.txt")).unwrap(),
2650 untracked
2651 );
2652 assert_eq!(
2653 test_git(&fresh, &["config", "--local", "mj.remoteWorkspace"]),
2654 "true"
2655 );
2656 assert_eq!(
2657 test_git(&fresh, &["config", "--local", "mj.baseCommit"]),
2658 pushed
2659 );
2660 }
2661
2662 #[test]
2663 fn a_conversion_preview_counts_unpushed_commits_and_dirty_files() {
2664 let (checkout, _remote_parent, remote) = checkout_with_network_remote();
2665 std::fs::write(checkout.path().join("nested/file.txt"), "session commit\n").unwrap();
2666 test_git(checkout.path(), &["commit", "-am", "session commit"]);
2667 std::fs::write(checkout.path().join("staged.txt"), "staged\n").unwrap();
2668 test_git(checkout.path(), &["add", "staged.txt"]);
2669 std::fs::write(checkout.path().join("nested/file.txt"), "unstaged\n").unwrap();
2670 let untracked = "u".repeat(100 * 1024);
2671 std::fs::write(checkout.path().join("untracked.txt"), &untracked).unwrap();
2672 let config = resume_compatibility_config();
2673 let session = raw_session_on("local-bare", &checkout.path().to_string_lossy());
2674 let executor = FixtureRemoteExecutor { remote };
2675 let conversion = plan_raw_to_workspace(&session, &config, &executor).unwrap();
2676
2677 let preview = raw_conversion_preview(&session, &conversion, &executor).unwrap();
2678
2679 assert_eq!(preview.fetch_url, FIXTURE_FETCH_URL);
2680 assert_eq!(preview.default_branch, "master");
2681 assert_eq!(preview.branch.as_deref(), Some("master"));
2682 assert_eq!(preview.unpushed_commits, 1);
2683 assert_eq!(preview.staged_files, 1);
2684 assert_eq!(preview.unstaged_files, 1);
2685 assert_eq!(preview.untracked_files, 1);
2686 assert_eq!(preview.untracked_bytes, untracked.len() as u64);
2687 assert!(
2688 preview.host_checkout_retained,
2689 "the user's own checkout stays on this machine"
2690 );
2691 assert_eq!(
2692 preview.destination,
2693 PathBuf::from("/workspace").join(checkout.path().file_name().unwrap())
2694 );
2695 }
2696
2697 #[test]
2698 fn a_conversion_preview_reports_a_managed_worktree_as_not_retained() {
2699 let (checkout, _remote_parent, remote) = checkout_with_network_remote();
2700 let session_id = "0123456789abcdef0123456789abcdef";
2701 let session = managed_worktree_session(checkout.path(), session_id);
2702 let config = resume_compatibility_config();
2703 let executor = FixtureRemoteExecutor { remote };
2704 let conversion = plan_raw_to_workspace(&session, &config, &executor).unwrap();
2705
2706 let preview = raw_conversion_preview(&session, &conversion, &executor).unwrap();
2707
2708 assert!(
2709 !preview.host_checkout_retained,
2710 "a managed worktree is retired by the move"
2711 );
2712 assert_eq!(preview.branch, Some(format!("mj/{session_id}")));
2713 assert_eq!(preview.unpushed_commits, 0);
2714 assert_eq!(preview.staged_files, 0);
2715 assert_eq!(preview.unstaged_files, 0);
2716 assert_eq!(
2717 preview.destination,
2718 PathBuf::from("/workspace").join(session_id)
2719 );
2720 }
2721
2722 #[test]
2723 fn a_conversion_preview_refuses_a_dirty_submodule() {
2724 let (checkout, _remote_parent, _remote) = checkout_with_network_remote();
2725 let submodule = committed_repository();
2726 test_git(
2727 checkout.path(),
2728 &[
2729 "-c",
2730 "protocol.file.allow=always",
2731 "submodule",
2732 "add",
2733 &submodule.path().to_string_lossy(),
2734 "sub",
2735 ],
2736 );
2737 test_git(checkout.path(), &["commit", "-m", "add submodule"]);
2738 std::fs::write(checkout.path().join("sub/nested/file.txt"), "dirty\n").unwrap();
2739 let config = resume_compatibility_config();
2740 let session = raw_session_on("local-bare", &checkout.path().to_string_lossy());
2741 let conversion = plan_raw_to_workspace(&session, &config, &ProcessExecutor).unwrap();
2742
2743 let error = raw_conversion_preview(&session, &conversion, &ProcessExecutor).unwrap_err();
2744
2745 assert!(
2746 format!("{error:#}").contains("dirty submodule"),
2747 "{error:#}"
2748 );
2749 }
2750 #[test]
2751 fn a_raw_checkout_on_an_ssh_host_cannot_convert() {
2752 let config = resume_compatibility_config();
2753
2754 let reason = resume_compatibility(
2755 &managed_raw_session(ssh_worktree_target()),
2756 &config,
2757 "podman",
2758 )
2759 .unwrap_err();
2760 assert!(reason.contains("works directly in"), "{reason}");
2761 assert!(reason.contains("dev@builder"), "{reason}");
2762
2763 let reason = resume_compatibility(
2764 &raw_session_on("ssh-bare", "/srv/project"),
2765 &config,
2766 "podman",
2767 )
2768 .unwrap_err();
2769 assert!(reason.contains("on an SSH host"), "{reason}");
2770 }
2771 #[test]
2772 fn a_session_that_opens_a_subdirectory_of_its_worktree_cannot_convert() {
2773 let config = resume_compatibility_config();
2774 let mut session = managed_raw_session(ManagedWorktreeTarget::Local);
2775 let worktree = session.managed_worktree.as_mut().unwrap();
2776 worktree.source_project_directory = worktree.source_repository.join("crate");
2777 session.project_directory = Some(worktree.worktree_root.join("crate"));
2778
2779 let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
2780
2781 assert!(reason.contains("subdirectory of its checkout"), "{reason}");
2782 }
2783 #[test]
2784 fn unmanaged_raw_sessions_require_the_same_bare_target_kind() {
2785 let config = resume_compatibility_config();
2786 let local = raw_session_on("local-bare", "/home/dev/project");
2787 let remote = raw_session_on("ssh-bare", "/srv/project");
2788
2789 assert_eq!(
2790 resume_compatibility(&local, &config, "local-bare"),
2791 Ok(ResumePlan::InPlace)
2792 );
2793 assert_eq!(
2794 resume_compatibility(&remote, &config, "ssh-bare"),
2795 Ok(ResumePlan::InPlace)
2796 );
2797 for (session, target) in [(&local, "ssh-bare"), (&remote, "local-bare")] {
2798 let reason = resume_compatibility(session, &config, target).unwrap_err();
2799 assert!(reason.contains("directly on its host"), "{reason}");
2800 }
2801 }
2802 #[test]
2803 fn resume_compatibility_names_a_target_that_is_gone() {
2804 let config = resume_compatibility_config();
2805 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2806
2807 let reason = resume_compatibility(&session, &config, "retired").unwrap_err();
2808
2809 assert!(reason.contains("retired"), "{reason}");
2810 }
2811 #[test]
2812 fn managed_raw_worktree_inherits_upstream_and_cleans_up_owned_artifacts() {
2813 let repository = committed_repository();
2814 let remote_parent = tempfile::tempdir().unwrap();
2815 let remote = remote_parent.path().join("remote.git");
2816 let output = Command::new("git")
2817 .args(["init", "--bare"])
2818 .arg(&remote)
2819 .output()
2820 .unwrap();
2821 assert!(output.status.success());
2822 test_git(
2823 repository.path(),
2824 &["remote", "add", "origin", &remote.to_string_lossy()],
2825 );
2826 test_git(
2827 repository.path(),
2828 &["push", "--set-upstream", "origin", "master"],
2829 );
2830
2831 let target = ManagedWorktreeTarget::Local;
2832 let inspection =
2833 inspect_raw_project(&ProcessExecutor, &target, &repository.path().join("nested"))
2834 .unwrap();
2835 assert!(inspection.primary_checkout);
2836 assert_eq!(inspection.upstream.as_deref(), Some("origin/master"));
2837 assert_eq!(
2840 inspection.source_project_directory,
2841 repository.path().canonicalize().unwrap().join("nested")
2842 );
2843
2844 let session_id = "0123456789abcdef0123456789abcdef";
2845 let worktree = ManagedWorktree {
2846 source_project_directory: inspection.source_project_directory,
2847 source_repository: inspection.source_repository,
2848 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2849 branch: format!("mj/{session_id}"),
2850 target,
2851 base_commit: None,
2852 };
2853 create_managed_worktree(
2854 &ProcessExecutor,
2855 &worktree,
2856 inspection.upstream.as_deref(),
2857 PrimaryCheckoutRequirement::Clean,
2858 )
2859 .unwrap();
2860 assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2861 assert_eq!(
2862 test_git(
2863 &worktree.worktree_root,
2864 &[
2865 "rev-parse",
2866 "--abbrev-ref",
2867 "--symbolic-full-name",
2868 "@{upstream}"
2869 ]
2870 ),
2871 "origin/master"
2872 );
2873 assert_eq!(test_git(repository.path(), &["status", "--porcelain"]), "");
2874 std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2875
2876 cleanup_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2877 assert!(!worktree.worktree_root.exists());
2878 assert!(!repository.path().join(".mj").exists());
2879 let output = Command::new("git")
2880 .arg("-C")
2881 .arg(repository.path())
2882 .args([
2883 "show-ref",
2884 "--verify",
2885 &format!("refs/heads/{}", worktree.branch),
2886 ])
2887 .output()
2888 .unwrap();
2889 assert!(!output.status.success());
2890 }
2891 #[test]
2892 fn retired_worktree_can_be_recreated_from_its_retained_branch() {
2893 let repository = committed_repository();
2894 let session_id = "0123456789abcdef0123456789abcdef";
2895 let session = managed_worktree_session(repository.path(), session_id);
2896 let worktree = session.managed_worktree.unwrap();
2897 std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2899
2900 retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2901
2902 assert!(!worktree.worktree_root.exists());
2903 assert!(!repository.path().join(".mj").exists());
2904 let branch = Command::new("git")
2905 .arg("-C")
2906 .arg(repository.path())
2907 .args([
2908 "show-ref",
2909 "--verify",
2910 &format!("refs/heads/{}", worktree.branch),
2911 ])
2912 .output()
2913 .unwrap();
2914 assert!(
2915 branch.status.success(),
2916 "the session branch must survive: later checkpoints are deltas against it"
2917 );
2918
2919 assert!(restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2920 assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2921 assert!(!restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2922 }
2923 #[test]
2924 fn retiring_a_remote_worktree_prunes_registration_after_target_removed_checkout() {
2925 struct RemoteExecutor {
2926 path_checks: RefCell<usize>,
2927 commands: RefCell<Vec<CommandSpec>>,
2928 }
2929
2930 impl CommandExecutor for RemoteExecutor {
2931 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2932 self.commands.borrow_mut().push(command.clone());
2933 let status = if command.purpose == "check managed worktree path" {
2934 let mut checks = self.path_checks.borrow_mut();
2935 let status = i32::from(*checks != 0);
2936 *checks += 1;
2937 status
2938 } else {
2939 0
2940 };
2941 Ok(CommandOutput {
2942 status,
2943 stdout: Vec::new(),
2944 stderr: Vec::new(),
2945 })
2946 }
2947 }
2948
2949 let worktree = ManagedWorktree {
2950 source_project_directory: PathBuf::from("/srv/project"),
2951 source_repository: PathBuf::from("/srv/project"),
2952 worktree_root: PathBuf::from("/srv/project/.mj/worktrees/session"),
2953 branch: "mj/session".into(),
2954 target: ManagedWorktreeTarget::Ssh {
2955 destination: "builder".into(),
2956 ssh_args: Vec::new(),
2957 },
2958 base_commit: None,
2959 };
2960 let executor = RemoteExecutor {
2961 path_checks: RefCell::new(0),
2962 commands: RefCell::new(Vec::new()),
2963 };
2964
2965 retire_managed_worktree(&executor, &worktree).unwrap();
2966
2967 let purposes = executor
2968 .commands
2969 .borrow()
2970 .iter()
2971 .map(|command| command.purpose.clone())
2972 .collect::<Vec<_>>();
2973 assert_eq!(
2974 purposes,
2975 [
2976 "check managed worktree path",
2977 "check managed worktree path",
2978 "prune managed worktree metadata",
2979 "remove empty managed worktree directories",
2980 ]
2981 );
2982 }
2983 #[test]
2984 fn a_managed_conversion_carries_the_session_worktree_not_the_primary_checkout() {
2985 let repository = committed_repository();
2986 add_network_remote(repository.path());
2987 let session_id = "0123456789abcdef0123456789abcdef";
2988 let session = managed_worktree_session(repository.path(), session_id);
2989 let worktree = session.managed_worktree.clone().unwrap();
2990
2991 let conversion =
2992 plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
2993
2994 assert_eq!(conversion.checkout, worktree.worktree_root);
2995 assert_eq!(conversion.repository, repository.path());
2996 assert_eq!(conversion.retire, Some(worktree));
2997 let bundle = conversion.new_bundle.expect("a bundle is synthesized");
2998 assert_eq!(bundle.repositories.len(), 1);
2999 assert_eq!(bundle.primary_repo, bundle.repositories[0].id);
3000 assert_eq!(
3001 bundle.repositories[0].local.as_deref(),
3002 Some(repository.path())
3003 );
3004 assert_eq!(bundle.repositories[0].github, None);
3005 assert_eq!(
3008 bundle.repositories[0].destination,
3009 PathBuf::from(session_id)
3010 );
3011 }
3012 #[test]
3013 fn an_unmanaged_conversion_serves_the_main_repository_behind_a_linked_worktree() {
3014 let repository = committed_repository();
3015 add_network_remote(repository.path());
3016 let session_id = "0123456789abcdef0123456789abcdef";
3017 let linked = managed_worktree_session(repository.path(), session_id);
3018 let checkout = linked.managed_worktree.unwrap().worktree_root;
3019 let mut session = checkpoint_test_session(session_id);
3020 session.state = SessionState::Stopped;
3021 session.target_template_id = "local-bare".into();
3022 session.project_directory = Some(checkout.clone());
3023
3024 let conversion =
3025 plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
3026
3027 assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
3028 assert_eq!(
3029 conversion.repository,
3030 repository.path().canonicalize().unwrap()
3031 );
3032 assert_eq!(conversion.retire, None);
3033 }
3034 #[cfg(unix)]
3039 #[test]
3040 fn an_unmanaged_conversion_accepts_a_checkout_reached_through_a_symlink() {
3041 let repository = committed_repository();
3042 add_network_remote(repository.path());
3043 let session_id = "0123456789abcdef0123456789abcdef";
3044 let linked = managed_worktree_session(repository.path(), session_id);
3045 let checkout = linked.managed_worktree.unwrap().worktree_root;
3046 let alias = tempfile::tempdir().unwrap();
3047 let symlink = alias.path().join("checkout");
3048 std::os::unix::fs::symlink(&checkout, &symlink).unwrap();
3049 let mut session = checkpoint_test_session(session_id);
3050 session.state = SessionState::Stopped;
3051 session.target_template_id = "local-bare".into();
3052 session.project_directory = Some(symlink);
3053
3054 let conversion =
3055 plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
3056
3057 assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
3058 assert_eq!(
3059 conversion.repository,
3060 repository.path().canonicalize().unwrap()
3061 );
3062 assert_eq!(conversion.retire, None);
3063 }
3064 #[test]
3065 fn a_conversion_reuses_a_bundle_that_already_describes_the_checkout() {
3066 let repository = PathBuf::from("/home/dev/project");
3067 let destination = PathBuf::from("project");
3068 let existing = ProjectBundle {
3069 primary_repo: "project".into(),
3070 repositories: vec![ProjectRepository {
3071 id: "project".into(),
3072 github: None,
3073 local: Some(repository.clone()),
3074 destination: destination.clone(),
3075 git_ref: None,
3076 }],
3077 };
3078 let mut config = Config::default();
3079 config.bundles.insert("existing".into(), existing);
3080
3081 assert_eq!(
3082 converted_raw_bundle(&config, "remote-project-abcdef", &repository, &destination),
3083 ("existing".to_owned(), None)
3084 );
3085
3086 let (id, synthesized) = converted_raw_bundle(
3089 &config,
3090 "remote-project-abcdef",
3091 &repository,
3092 Path::new("elsewhere"),
3093 );
3094 assert_ne!(id, "existing");
3095 assert_eq!(
3096 synthesized.unwrap().repositories[0].destination,
3097 PathBuf::from("elsewhere")
3098 );
3099 }
3100 #[test]
3101 fn a_converted_record_is_a_valid_bundle_session() {
3102 let session_id = "0123456789abcdef0123456789abcdef";
3103 let mut config = resume_compatibility_config();
3104 let mut record = managed_raw_session(ManagedWorktreeTarget::Local);
3105 record.state = SessionState::Running;
3106 record.target_template_id = "podman".into();
3107 let conversion = RawToWorkspaceConversion {
3108 checkout: record.project_directory.clone().unwrap(),
3109 repository: PathBuf::from("/home/dev/project"),
3110 source: fixture_network_source(),
3111 bundle_id: "project".into(),
3112 new_bundle: Some(ProjectBundle {
3113 primary_repo: "project".into(),
3114 repositories: vec![ProjectRepository {
3115 id: "project".into(),
3116 github: None,
3117 local: Some(PathBuf::from("/home/dev/project")),
3118 destination: PathBuf::from(session_id),
3119 git_ref: None,
3120 }],
3121 }),
3122 retire: record.managed_worktree.clone(),
3123 };
3124
3125 config.bundles.insert(
3126 conversion.bundle_id.clone(),
3127 conversion.new_bundle.clone().unwrap(),
3128 );
3129 config.profiles.insert(
3130 record.last_profile.clone(),
3131 HarnessProfile {
3132 enabled: true,
3133 kind: record.harness_kind,
3134 home: PathBuf::from("/profiles/codex"),
3135 environment: BTreeMap::new(),
3136 context_window_bytes: None,
3137 },
3138 );
3139 apply_raw_to_workspace(&mut record, &conversion);
3140
3141 assert_eq!(record.project_directory, None);
3142 assert_eq!(record.managed_worktree, None);
3143 assert_eq!(record.bundle_id, "project");
3144 let state = State {
3145 sessions: BTreeMap::from([(session_id.into(), record)]),
3146 ..State::default()
3147 };
3148 state.validate_against_config(&config).unwrap();
3149 }
3150 #[test]
3151 fn a_session_leaving_its_target_claims_a_worktree_of_its_own_repository() {
3152 let repository = committed_repository();
3153 let session_id = "0123456789abcdef0123456789abcdef";
3154 let mut session = checkpoint_test_session(session_id);
3155 session.state = SessionState::Stopped;
3156 session.bundle_id = "project".into();
3157 let mut config = resume_compatibility_config();
3158 config
3159 .bundles
3160 .insert("project".into(), local_bundle(repository.path()));
3161 let controller = Controller {
3162 config,
3163 state: State {
3164 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
3165 ..State::default()
3166 },
3167 };
3168
3169 let conversion = controller
3170 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
3171 .unwrap();
3172
3173 assert_eq!(
3174 conversion.worktree,
3175 ManagedWorktree {
3176 source_project_directory: repository.path().to_path_buf(),
3177 source_repository: repository.path().to_path_buf(),
3178 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
3179 branch: format!("mj/{session_id}"),
3180 target: ManagedWorktreeTarget::Local,
3181 base_commit: Some(test_git(repository.path(), &["rev-parse", "HEAD"])),
3184 }
3185 );
3186
3187 std::fs::write(repository.path().join("dirty.txt"), "primary\n").unwrap();
3190 create_managed_worktree(
3191 &ProcessExecutor,
3192 &conversion.worktree,
3193 None,
3194 PrimaryCheckoutRequirement::Any,
3195 )
3196 .unwrap();
3197 assert!(conversion.worktree.worktree_root.is_dir());
3198
3199 let error = controller
3201 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
3202 .unwrap_err();
3203 assert!(format!("{error:#}").contains("already exists"), "{error:#}");
3204 }
3205 #[test]
3206 fn a_return_to_local_reuses_its_retained_branch_and_preserves_its_tip() {
3207 let repository = committed_repository();
3208 let session_id = "0123456789abcdef0123456789abcdef";
3209 let branch = format!("mj/{session_id}");
3210 let original_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
3211 test_git(repository.path(), &["branch", &branch]);
3212 let mut config = resume_compatibility_config();
3213 config
3214 .bundles
3215 .insert("project".into(), local_bundle(repository.path()));
3216 let session = checkpoint_test_session(session_id);
3217 let controller = Controller {
3218 config,
3219 state: State {
3220 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
3221 ..State::default()
3222 },
3223 };
3224
3225 let conversion = controller
3226 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
3227 .unwrap();
3228
3229 assert!(conversion.reuse_existing_branch);
3230 assert!(!conversion.worktree.worktree_root.exists());
3231 let recovery_ref =
3232 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
3233 .unwrap();
3234 assert_eq!(
3235 recovery_ref,
3236 format!("refs/mj/recovery/{session_id}/{original_tip}")
3237 );
3238 assert_eq!(
3239 test_git(
3240 repository.path(),
3241 &["show-ref", "--hash", recovery_ref.as_str()],
3242 ),
3243 original_tip
3244 );
3245 assert_eq!(
3246 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
3247 .unwrap(),
3248 recovery_ref
3249 );
3250
3251 test_git(repository.path(), &["checkout", &branch]);
3252 test_git(
3253 repository.path(),
3254 &["commit", "--allow-empty", "-m", "later retained tip"],
3255 );
3256 let later_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
3257 test_git(repository.path(), &["checkout", "master"]);
3258 let later_recovery_ref =
3259 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
3260 .unwrap();
3261 assert_eq!(
3262 later_recovery_ref,
3263 format!("refs/mj/recovery/{session_id}/{later_tip}")
3264 );
3265 assert_ne!(later_recovery_ref, recovery_ref);
3266 assert_eq!(
3267 test_git(
3268 repository.path(),
3269 &["show-ref", "--hash", recovery_ref.as_str()],
3270 ),
3271 original_tip
3272 );
3273 }
3274 #[test]
3275 fn a_return_to_local_rejects_a_retained_branch_checked_out_elsewhere() {
3276 let repository = committed_repository();
3277 let session_id = "0123456789abcdef0123456789abcdef";
3278 let branch = format!("mj/{session_id}");
3279 test_git(repository.path(), &["branch", &branch]);
3280 let elsewhere = repository.path().join("other-worktree");
3281 test_git(
3282 repository.path(),
3283 &["worktree", "add", &elsewhere.to_string_lossy(), &branch],
3284 );
3285 let mut config = resume_compatibility_config();
3286 config
3287 .bundles
3288 .insert("project".into(), local_bundle(repository.path()));
3289 let session = checkpoint_test_session(session_id);
3290 let controller = Controller {
3291 config,
3292 state: State {
3293 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
3294 ..State::default()
3295 },
3296 };
3297
3298 let error = controller
3299 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
3300 .unwrap_err();
3301
3302 assert!(error.to_string().contains("still checked out"), "{error:#}");
3303 assert!(
3304 !repository
3305 .path()
3306 .join(".mj/worktrees")
3307 .join(session_id)
3308 .exists()
3309 );
3310 assert_eq!(
3311 test_git(repository.path(), &["rev-parse", &branch]),
3312 test_git(repository.path(), &["rev-parse", "HEAD"])
3313 );
3314 }
3315 #[test]
3316 fn a_session_that_left_its_target_is_a_valid_raw_session() {
3317 let session_id = "0123456789abcdef0123456789abcdef";
3318 let repository = PathBuf::from("/home/dev/project");
3319 let mut config = resume_compatibility_config();
3320 config
3321 .bundles
3322 .insert("project".into(), local_bundle(&repository));
3323 config.profiles.insert(
3324 "codex".into(),
3325 HarnessProfile {
3326 enabled: true,
3327 kind: mj_core::config::HarnessKind::Codex,
3328 home: PathBuf::from("/profiles/codex"),
3329 environment: BTreeMap::new(),
3330 context_window_bytes: None,
3331 },
3332 );
3333 let mut record = checkpoint_test_session(session_id);
3334 record.bundle_id = "project".into();
3335 record.target_template_id = "local-bare".into();
3336 let conversion = WorkspaceToRawConversion {
3337 worktree: ManagedWorktree {
3338 source_project_directory: repository.clone(),
3339 source_repository: repository.clone(),
3340 worktree_root: repository.join(".mj/worktrees").join(session_id),
3341 branch: format!("mj/{session_id}"),
3342 target: ManagedWorktreeTarget::Local,
3343 base_commit: None,
3344 },
3345 reuse_existing_branch: false,
3346 };
3347
3348 apply_workspace_to_raw(&mut record, &conversion);
3349
3350 assert_eq!(
3351 record.project_directory.as_deref(),
3352 Some(conversion.worktree.worktree_root.as_path())
3353 );
3354 assert_eq!(record.bundle_id, "project", "the bundle still describes it");
3355 let state = State {
3356 sessions: BTreeMap::from([(session_id.into(), record)]),
3357 ..State::default()
3358 };
3359 state.validate_against_config(&config).unwrap();
3360 }
3361 #[test]
3362 fn a_failed_departure_returns_the_session_to_its_bundle() {
3363 let session_id = "0123456789abcdef0123456789abcdef";
3364 let repository = PathBuf::from("/home/dev/project");
3365 let previous = {
3366 let mut record = checkpoint_test_session(session_id);
3367 record.state = SessionState::Stopped;
3368 record.bundle_id = "project".into();
3369 record
3370 };
3371 let mut converted = previous.clone();
3372 converted.state = SessionState::Provisioning;
3373 apply_workspace_to_raw(
3374 &mut converted,
3375 &WorkspaceToRawConversion {
3376 worktree: ManagedWorktree {
3377 source_project_directory: repository.clone(),
3378 source_repository: repository.clone(),
3379 worktree_root: repository.join(".mj/worktrees").join(session_id),
3380 branch: format!("mj/{session_id}"),
3381 target: ManagedWorktreeTarget::Local,
3382 base_commit: None,
3383 },
3384 reuse_existing_branch: false,
3385 },
3386 );
3387
3388 apply_failed_resume_rollback(&mut converted, &previous, "podman is unavailable", None);
3389
3390 assert_eq!(converted.project_directory, None);
3391 assert_eq!(converted.managed_worktree, None);
3392 assert_eq!(converted.bundle_id, "project");
3393 }
3394 #[test]
3395 fn a_failed_conversion_returns_the_session_to_its_checkout() {
3396 let previous = managed_raw_session(ManagedWorktreeTarget::Local);
3397 let mut converted = previous.clone();
3398 converted.state = SessionState::Provisioning;
3399 converted.target_template_id = "podman".into();
3400 apply_raw_to_workspace(
3401 &mut converted,
3402 &RawToWorkspaceConversion {
3403 checkout: previous.project_directory.clone().unwrap(),
3404 repository: PathBuf::from("/home/dev/project"),
3405 source: fixture_network_source(),
3406 bundle_id: "project".into(),
3407 new_bundle: None,
3408 retire: previous.managed_worktree.clone(),
3409 },
3410 );
3411
3412 let mut cleaned = converted.clone();
3413 apply_failed_resume_rollback(&mut cleaned, &previous, "podman is unavailable", None);
3414 assert_eq!(cleaned.project_directory, previous.project_directory);
3415 assert_eq!(cleaned.managed_worktree, previous.managed_worktree);
3416 assert_eq!(cleaned.bundle_id, previous.bundle_id);
3417
3418 let mut stranded = converted;
3421 apply_failed_resume_rollback(
3422 &mut stranded,
3423 &previous,
3424 "podman is unavailable",
3425 Some("podman rm failed".into()),
3426 );
3427 assert_eq!(stranded.state, SessionState::Error);
3428 assert_eq!(stranded.project_directory, previous.project_directory);
3429 assert_eq!(stranded.managed_worktree, previous.managed_worktree);
3430 assert_eq!(stranded.bundle_id, previous.bundle_id);
3431 }
3432 #[test]
3433 fn cancelled_new_session_cleanup_removes_managed_worktree_and_branch() {
3434 let repository = committed_repository();
3435 let session_id = "0123456789abcdef0123456789abcdef";
3436 let worktree = ManagedWorktree {
3437 source_project_directory: repository.path().to_path_buf(),
3438 source_repository: repository.path().to_path_buf(),
3439 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
3440 branch: format!("mj/{session_id}"),
3441 target: ManagedWorktreeTarget::Local,
3442 base_commit: None,
3443 };
3444 create_managed_worktree(
3445 &ProcessExecutor,
3446 &worktree,
3447 None,
3448 PrimaryCheckoutRequirement::Clean,
3449 )
3450 .unwrap();
3451
3452 let mut session = checkpoint_test_session(session_id);
3453 session.project_directory = Some(worktree.worktree_root.clone());
3454 session.managed_worktree = Some(worktree.clone());
3455 let controller = Controller {
3456 config: Config::default(),
3457 state: State {
3458 sessions: BTreeMap::from([(session_id.into(), session)]),
3459 ..State::default()
3460 },
3461 };
3462 let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
3463 let executor = CancellableProcessExecutor::new(cancelled);
3464
3465 controller
3466 .cleanup_new_session_worktree_after_failure(session_id, &executor)
3467 .unwrap();
3468
3469 assert!(!worktree.worktree_root.exists());
3470 assert!(!repository.path().join(".mj").exists());
3471 let branch = Command::new("git")
3472 .arg("-C")
3473 .arg(repository.path())
3474 .args([
3475 "show-ref",
3476 "--verify",
3477 &format!("refs/heads/{}", worktree.branch),
3478 ])
3479 .output()
3480 .unwrap();
3481 assert!(!branch.status.success());
3482 }
3483 #[test]
3484 fn managed_raw_worktree_refuses_dirty_primary_and_skips_existing_worktree() {
3485 let repository = committed_repository();
3486 std::fs::write(repository.path().join("dirty.txt"), "dirty\n").unwrap();
3487 let target = ManagedWorktreeTarget::Local;
3488 let inspection = inspect_raw_project(&ProcessExecutor, &target, repository.path()).unwrap();
3489 let session_id = "fedcba9876543210fedcba9876543210";
3490 let managed = ManagedWorktree {
3491 source_project_directory: inspection.source_project_directory,
3492 source_repository: inspection.source_repository,
3493 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
3494 branch: format!("mj/{session_id}"),
3495 target: target.clone(),
3496 base_commit: None,
3497 };
3498 let error = create_managed_worktree(
3499 &ProcessExecutor,
3500 &managed,
3501 None,
3502 PrimaryCheckoutRequirement::Clean,
3503 )
3504 .unwrap_err();
3505 assert!(error.to_string().contains("uncommitted changes"));
3506 assert!(!managed.worktree_root.exists());
3507
3508 std::fs::remove_file(repository.path().join("dirty.txt")).unwrap();
3509 let existing = repository.path().join("existing-worktree");
3510 test_git(
3511 repository.path(),
3512 &[
3513 "worktree",
3514 "add",
3515 "--detach",
3516 &existing.to_string_lossy(),
3517 "HEAD",
3518 ],
3519 );
3520 let linked = inspect_raw_project(&ProcessExecutor, &target, &existing).unwrap();
3521 assert!(!linked.primary_checkout);
3522 }
3523 #[test]
3524 fn managed_worktree_preflight_preserves_colliding_branch_and_directory() {
3525 let repository = committed_repository();
3526 let target = ManagedWorktreeTarget::Local;
3527 let session_id = "abcdef0123456789abcdef0123456789";
3528 let branch = format!("mj/{session_id}");
3529 test_git(repository.path(), &["branch", &branch]);
3530 let worktree = ManagedWorktree {
3531 source_project_directory: repository.path().to_path_buf(),
3532 source_repository: repository.path().to_path_buf(),
3533 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
3534 branch: branch.clone(),
3535 target,
3536 base_commit: None,
3537 };
3538
3539 let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
3540 assert!(error.to_string().contains("branch already exists"));
3541 assert!(
3542 !test_git(
3543 repository.path(),
3544 &["show-ref", "--verify", &format!("refs/heads/{branch}")]
3545 )
3546 .is_empty()
3547 );
3548 std::fs::create_dir_all(&worktree.worktree_root).unwrap();
3549 let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
3550 assert!(error.to_string().contains("path already exists"));
3551 assert!(worktree.worktree_root.is_dir());
3552 }
3553 #[test]
3554 fn managed_worktree_ssh_commands_preserve_hostile_path_boundaries() {
3555 let target = ManagedWorktreeTarget::Ssh {
3556 destination: "builder".into(),
3557 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
3558 };
3559 let command = managed_git_command(
3560 &target,
3561 Path::new("/srv/project with ' quote"),
3562 ["worktree", "prune"],
3563 "prune test",
3564 );
3565 assert_eq!(command.program, "ssh");
3566 assert_eq!(&command.args[..3], ["-o", "BatchMode=yes", "builder"]);
3567 assert_eq!(
3568 command.args[3],
3569 "'git' '-C' '/srv/project with '\\'' quote' 'worktree' 'prune'"
3570 );
3571 }
3572}