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) bundle_id: String,
602 pub(super) new_bundle: Option<ProjectBundle>,
604 pub(super) retire: Option<ManagedWorktree>,
606}
607
608pub(super) fn plan_raw_to_workspace(
611 session: &SessionRecord,
612 config: &Config,
613 executor: &impl CommandExecutor,
614) -> Result<RawToWorkspaceConversion> {
615 let project_directory = session
616 .project_directory
617 .as_deref()
618 .context("a raw session has no project directory")?;
619 let (checkout, repository, retire) = match &session.managed_worktree {
625 Some(worktree) => {
626 ensure!(
627 worktree.worktree_root == project_directory,
628 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
629 project_directory.display()
630 );
631 (
632 worktree.worktree_root.clone(),
633 worktree.source_repository.clone(),
634 Some(worktree.clone()),
635 )
636 }
637 None => {
638 let inspection =
639 inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
640 ensure!(
641 inspection.source_project_directory == inspection.source_repository,
642 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
643 project_directory.display()
644 );
645 let repository = canonical_repository(&inspection.source_repository)?;
646 (inspection.source_repository, repository, None)
647 }
648 };
649 let destination = PathBuf::from(
653 project_directory
654 .file_name()
655 .context("a raw project directory cannot be the filesystem root")?,
656 );
657 let (bundle_id, new_bundle) =
658 converted_raw_bundle(config, &session.bundle_id, &repository, &destination);
659 Ok(RawToWorkspaceConversion {
660 checkout,
661 repository,
662 bundle_id,
663 new_bundle,
664 retire,
665 })
666}
667
668fn converted_raw_bundle(
672 config: &Config,
673 session_bundle_id: &str,
674 repository: &Path,
675 destination: &Path,
676) -> (String, Option<ProjectBundle>) {
677 let describes_checkout = |bundle: &ProjectBundle| {
678 bundle.repositories.len() == 1
679 && bundle.repositories[0].github.is_none()
680 && bundle.repositories[0].local.as_deref() == Some(repository)
681 && bundle.repositories[0].destination == destination
682 };
683 if config
684 .bundles
685 .get(session_bundle_id)
686 .is_some_and(describes_checkout)
687 {
688 return (session_bundle_id.to_owned(), None);
689 }
690 if let Some((id, _)) = config
691 .bundles
692 .iter()
693 .find(|(_, bundle)| describes_checkout(bundle))
694 {
695 return (id.clone(), None);
696 }
697 let name = repository
698 .file_name()
699 .map(|name| name.to_string_lossy().into_owned())
700 .unwrap_or_default();
701 let id = crate::import::unique_bundle_id(config, &crate::import::setup_style_id(&name));
702 let bundle = ProjectBundle {
703 primary_repo: id.clone(),
704 repositories: vec![mj_core::config::ProjectRepository {
705 id: id.clone(),
706 github: None,
707 local: Some(repository.to_path_buf()),
708 destination: destination.to_path_buf(),
709 git_ref: None,
710 }],
711 };
712 (id, Some(bundle))
713}
714
715#[derive(Debug, Clone, PartialEq, Eq)]
717pub(super) struct CheckoutPosition {
718 pub(super) head_commit: String,
719 branch: Option<String>,
720}
721
722fn read_checkout_position(
723 executor: &impl CommandExecutor,
724 target: &ManagedWorktreeTarget,
725 directory: &Path,
726) -> Result<CheckoutPosition> {
727 let head_commit = managed_git_stdout(
728 executor,
729 target,
730 directory,
731 ["rev-parse", "HEAD"],
732 "resolve checkout head commit",
733 )?;
734 let branch_command = managed_git_command(
735 target,
736 directory,
737 ["symbolic-ref", "--quiet", "--short", "HEAD"],
738 "resolve checkout branch",
739 );
740 let branch_output = executor.execute(&branch_command)?;
741 let branch = match branch_output.status {
742 0 => Some(
743 String::from_utf8(branch_output.stdout)
744 .context("checkout branch was not UTF-8")?
745 .trim()
746 .to_owned(),
747 ),
748 1 | 128 => None,
750 status => bail!(
751 "resolve checkout branch failed with status {status}: {}",
752 String::from_utf8_lossy(&branch_output.stderr).trim()
753 ),
754 };
755 Ok(CheckoutPosition {
756 head_commit,
757 branch,
758 })
759}
760
761pub(super) fn raw_checkout_position(
764 session: &SessionRecord,
765 config: &Config,
766 project_directory: &Path,
767 executor: &impl CommandExecutor,
768) -> Result<CheckoutPosition> {
769 let target = match &session.managed_worktree {
770 Some(worktree) => worktree.target.clone(),
771 None => {
772 let template = config
773 .targets
774 .get(&session.target_template_id)
775 .context("the bare target this session last used is missing")?;
776 managed_worktree_target(template)?
777 }
778 };
779 read_checkout_position(executor, &target, project_directory)
780}
781
782pub(super) fn raw_checkout_divergence_notice(
788 directory: &Path,
789 recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
790 live: &CheckoutPosition,
791) -> Option<String> {
792 let recorded = recorded?;
793 if recorded.head_commit.is_empty()
794 || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
795 {
796 return None;
797 }
798 Some(format!(
799 "The working tree at {} moved from {} to {} while this session was stopped.",
800 directory.display(),
801 checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
802 checkout_position_text(&live.head_commit, live.branch.as_deref()),
803 ))
804}
805
806fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
807 let short = head_commit.get(..12).unwrap_or(head_commit);
808 match branch {
809 Some(branch) => format!("{short} ({branch})"),
810 None => format!("{short} (detached)"),
811 }
812}
813
814fn inspect_raw_project(
815 executor: &impl CommandExecutor,
816 target: &ManagedWorktreeTarget,
817 selected: &Path,
818) -> Result<RawProjectInspection> {
819 let repository = PathBuf::from(managed_git_stdout(
820 executor,
821 target,
822 selected,
823 ["rev-parse", "--path-format=absolute", "--show-toplevel"],
824 "resolve raw project repository root",
825 )?);
826 let prefix = managed_git_stdout(
827 executor,
828 target,
829 selected,
830 ["rev-parse", "--show-prefix"],
831 "resolve raw project relative directory",
832 )?;
833 let git_dir = PathBuf::from(managed_git_stdout(
834 executor,
835 target,
836 selected,
837 ["rev-parse", "--absolute-git-dir"],
838 "resolve raw project Git directory",
839 )?);
840 let common_git_dir = PathBuf::from(managed_git_stdout(
841 executor,
842 target,
843 selected,
844 ["rev-parse", "--path-format=absolute", "--git-common-dir"],
845 "resolve raw project common Git directory",
846 )?);
847 let branch_command = managed_git_command(
848 target,
849 selected,
850 ["symbolic-ref", "--quiet", "--short", "HEAD"],
851 "resolve raw project branch",
852 );
853 let branch_output = executor.execute(&branch_command)?;
854 let branch = match branch_output.status {
855 0 => Some(
856 String::from_utf8(branch_output.stdout)
857 .context("raw project branch was not UTF-8")?
858 .trim()
859 .to_owned(),
860 ),
861 1 | 128 => None,
862 status => bail!(
863 "resolve raw project branch failed with status {status}: {}",
864 String::from_utf8_lossy(&branch_output.stderr).trim()
865 ),
866 };
867 let upstream = match branch {
868 Some(branch) => {
869 let reference = format!("refs/heads/{branch}");
870 let upstream = managed_git_stdout(
871 executor,
872 target,
873 selected,
874 ["for-each-ref", "--format=%(upstream:short)", &reference],
875 "resolve raw project upstream",
876 )?;
877 (!upstream.is_empty()).then_some(upstream)
878 }
879 None => None,
880 };
881 Ok(RawProjectInspection {
882 source_project_directory: repository.join(prefix),
883 source_repository: repository,
884 primary_checkout: git_dir == common_git_dir,
885 upstream,
886 })
887}
888
889fn ensure_managed_worktree_excluded(
890 executor: &impl CommandExecutor,
891 target: &ManagedWorktreeTarget,
892 repository: &Path,
893) -> Result<()> {
894 let check = managed_git_command(
895 target,
896 repository,
897 [
898 "check-ignore",
899 "--quiet",
900 "--no-index",
901 "--",
902 ".mj/worktrees/",
903 ],
904 "check managed worktree exclusion",
905 );
906 let output = executor.execute(&check)?;
907 match output.status {
908 0 => return Ok(()),
909 1 => {}
910 status => bail!(
911 "check managed worktree exclusion failed with status {status}: {}",
912 String::from_utf8_lossy(&output.stderr).trim()
913 ),
914 }
915 let exclude_path = PathBuf::from(managed_git_stdout(
916 executor,
917 target,
918 repository,
919 [
920 "rev-parse",
921 "--path-format=absolute",
922 "--git-path",
923 "info/exclude",
924 ],
925 "resolve repository-local exclude file",
926 )?);
927 const ENTRY: &str = "/.mj/worktrees/";
928 match target {
929 ManagedWorktreeTarget::Local => {
930 use std::io::Write;
931 let existing = match std::fs::read_to_string(&exclude_path) {
932 Ok(existing) => existing,
933 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
934 Err(error) => return Err(error.into()),
935 };
936 if existing.lines().any(|line| line.trim() == ENTRY) {
937 return Ok(());
938 }
939 if let Some(parent) = exclude_path.parent() {
940 std::fs::create_dir_all(parent)?;
941 }
942 let mut file = std::fs::OpenOptions::new()
943 .create(true)
944 .append(true)
945 .open(&exclude_path)
946 .with_context(|| format!("open {}", exclude_path.display()))?;
947 if !existing.is_empty() && !existing.ends_with('\n') {
948 writeln!(file)?;
949 }
950 writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
951 }
952 ManagedWorktreeTarget::Ssh { .. } => {
953 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";
954 let command = managed_target_command(
955 target,
956 "sh",
957 [
958 "-c",
959 SCRIPT,
960 "hel-exclude",
961 &exclude_path.to_string_lossy(),
962 ENTRY,
963 ],
964 )
965 .purpose("update remote repository-local exclude file");
966 execute_checked(executor, command)?;
967 }
968 }
969 Ok(())
970}
971
972pub(crate) fn path_exists_on_managed_target(
973 executor: &impl CommandExecutor,
974 target: &ManagedWorktreeTarget,
975 path: &Path,
976) -> Result<bool> {
977 match target {
978 ManagedWorktreeTarget::Local => path
979 .try_exists()
980 .with_context(|| format!("check managed project path {}", path.display())),
981 ManagedWorktreeTarget::Ssh { .. } => {
982 let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
983 .purpose("check managed worktree path");
984 let output = executor.execute(&command)?;
985 match output.status {
986 0 => Ok(true),
987 1 => Ok(false),
988 status => bail!(
989 "check managed worktree path failed with status {status}: {}",
990 String::from_utf8_lossy(&output.stderr).trim()
991 ),
992 }
993 }
994 }
995}
996
997pub(super) fn managed_worktree_checkout_exists(
998 executor: &impl CommandExecutor,
999 worktree: &ManagedWorktree,
1000) -> Result<bool> {
1001 path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1006pub(super) enum PrimaryCheckoutRequirement {
1007 Clean,
1010 Any,
1014}
1015
1016pub(super) fn create_managed_worktree(
1017 executor: &impl CommandExecutor,
1018 worktree: &ManagedWorktree,
1019 upstream: Option<&str>,
1020 requirement: PrimaryCheckoutRequirement,
1021) -> Result<()> {
1022 ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
1023 if requirement == PrimaryCheckoutRequirement::Clean {
1024 let status = managed_git_stdout(
1025 executor,
1026 &worktree.target,
1027 &worktree.source_repository,
1028 ["status", "--porcelain=v1", "--untracked-files=all"],
1029 "inspect primary checkout changes",
1030 )?;
1031 if !status.is_empty() {
1032 let paths = status.lines().take(20).collect::<Vec<_>>().join("\n ");
1033 bail!(
1034 "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n {paths}"
1035 );
1036 }
1037 }
1038 let parent = worktree
1039 .worktree_root
1040 .parent()
1041 .context("managed worktree root has no parent")?;
1042 execute_checked(
1043 executor,
1044 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1045 .purpose("create managed worktree directory"),
1046 )?;
1047 execute_checked(
1048 executor,
1049 managed_git_command(
1050 &worktree.target,
1051 &worktree.source_repository,
1052 [
1053 "worktree",
1054 "add",
1055 "-b",
1056 &worktree.branch,
1057 &worktree.worktree_root.to_string_lossy(),
1058 "HEAD",
1059 ],
1060 "create managed raw-session worktree",
1061 ),
1062 )?;
1063 if let Some(upstream) = upstream {
1064 execute_checked(
1065 executor,
1066 managed_git_command(
1067 &worktree.target,
1068 &worktree.worktree_root,
1069 ["branch", "--set-upstream-to", upstream, &worktree.branch],
1070 "set managed worktree branch upstream",
1071 ),
1072 )?;
1073 }
1074 Ok(())
1075}
1076
1077pub(super) fn restore_managed_worktree(
1081 executor: &impl CommandExecutor,
1082 worktree: &ManagedWorktree,
1083) -> Result<bool> {
1084 if managed_worktree_checkout_exists(executor, worktree)? {
1085 return Ok(false);
1086 }
1087 ensure!(
1088 path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
1089 "managed worktree source repository is unavailable: {}",
1090 worktree.source_repository.display()
1091 );
1092 let branch_ref = format!("refs/heads/{}", worktree.branch);
1093 let check = managed_git_command(
1094 &worktree.target,
1095 &worktree.source_repository,
1096 ["show-ref", "--verify", "--quiet", &branch_ref],
1097 "check retired managed worktree branch",
1098 );
1099 let output = executor.execute(&check)?;
1100 match output.status {
1101 0 => {}
1102 1 => bail!(
1103 "managed worktree branch is unavailable: {}",
1104 worktree.branch
1105 ),
1106 status => bail!(
1107 "check retired managed worktree branch failed with status {status}: {}",
1108 String::from_utf8_lossy(&output.stderr).trim()
1109 ),
1110 }
1111 execute_checked(
1114 executor,
1115 managed_git_command(
1116 &worktree.target,
1117 &worktree.source_repository,
1118 ["worktree", "prune"],
1119 "prune retired managed worktree metadata",
1120 ),
1121 )?;
1122 let parent = worktree
1123 .worktree_root
1124 .parent()
1125 .context("managed worktree root has no parent")?;
1126 execute_checked(
1127 executor,
1128 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1129 .purpose("recreate managed worktree directory"),
1130 )?;
1131 execute_checked(
1132 executor,
1133 managed_git_command(
1134 &worktree.target,
1135 &worktree.source_repository,
1136 [
1137 "worktree",
1138 "add",
1139 "--",
1140 &worktree.worktree_root.to_string_lossy(),
1141 &worktree.branch,
1142 ],
1143 "restore managed raw-session worktree",
1144 ),
1145 )?;
1146 Ok(true)
1147}
1148
1149fn ensure_managed_worktree_available(
1150 executor: &impl CommandExecutor,
1151 worktree: &ManagedWorktree,
1152) -> Result<()> {
1153 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1154 bail!(
1155 "managed worktree path already exists: {}",
1156 worktree.worktree_root.display()
1157 );
1158 }
1159 let branch_ref = format!("refs/heads/{}", worktree.branch);
1160 let check = managed_git_command(
1161 &worktree.target,
1162 &worktree.source_repository,
1163 ["show-ref", "--verify", "--quiet", &branch_ref],
1164 "check managed worktree branch availability",
1165 );
1166 let output = executor.execute(&check)?;
1167 match output.status {
1168 0 => bail!(
1169 "managed worktree branch already exists: {}",
1170 worktree.branch
1171 ),
1172 1 => Ok(()),
1173 status => bail!(
1174 "check managed worktree branch availability failed with status {status}: {}",
1175 String::from_utf8_lossy(&output.stderr).trim()
1176 ),
1177 }
1178}
1179
1180fn retained_managed_worktree_branch_available(
1185 executor: &impl CommandExecutor,
1186 worktree: &ManagedWorktree,
1187) -> Result<bool> {
1188 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1189 bail!(
1190 "managed worktree path already exists: {}",
1191 worktree.worktree_root.display()
1192 );
1193 }
1194 let branch_ref = format!("refs/heads/{}", worktree.branch);
1195 let check = managed_git_command(
1196 &worktree.target,
1197 &worktree.source_repository,
1198 ["show-ref", "--verify", "--quiet", &branch_ref],
1199 "check retained managed worktree branch",
1200 );
1201 let output = executor.execute(&check)?;
1202 match output.status {
1203 1 => Ok(false),
1204 0 => {
1205 let worktrees = managed_git_stdout(
1206 executor,
1207 &worktree.target,
1208 &worktree.source_repository,
1209 ["worktree", "list", "--porcelain", "-z"],
1210 "check retained managed worktree checkout",
1211 )?;
1212 let branch_field = format!("branch {branch_ref}");
1213 if worktrees.split('\0').any(|field| field == branch_field) {
1214 bail!(
1215 "managed worktree branch is still checked out: {}",
1216 worktree.branch
1217 );
1218 }
1219 Ok(true)
1220 }
1221 status => bail!(
1222 "check retained managed worktree branch failed with status {status}: {}",
1223 String::from_utf8_lossy(&output.stderr).trim()
1224 ),
1225 }
1226}
1227
1228pub(super) fn preserve_retained_managed_worktree_branch(
1232 executor: &impl CommandExecutor,
1233 worktree: &ManagedWorktree,
1234) -> Result<String> {
1235 let session_id = worktree
1236 .branch
1237 .strip_prefix("mj/")
1238 .context("managed worktree branch is not session-owned")?;
1239 let branch_ref = format!("refs/heads/{}", worktree.branch);
1240 let tip = managed_git_stdout(
1241 executor,
1242 &worktree.target,
1243 &worktree.source_repository,
1244 ["rev-parse", "--verify", &branch_ref],
1245 "read retained managed worktree branch tip",
1246 )?;
1247 let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
1248 let existing = managed_git_command(
1249 &worktree.target,
1250 &worktree.source_repository,
1251 ["show-ref", "--verify", "--quiet", &recovery_ref],
1252 "check retained managed worktree recovery ref",
1253 );
1254 let output = executor.execute(&existing)?;
1255 match output.status {
1256 0 => {
1257 let existing_tip = managed_git_stdout(
1258 executor,
1259 &worktree.target,
1260 &worktree.source_repository,
1261 ["rev-parse", "--verify", &recovery_ref],
1262 "verify retained managed worktree recovery ref",
1263 )?;
1264 ensure!(
1265 existing_tip == tip,
1266 "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
1267 );
1268 Ok(recovery_ref)
1269 }
1270 1 => {
1271 execute_checked(
1272 executor,
1273 managed_git_command(
1274 &worktree.target,
1275 &worktree.source_repository,
1276 ["update-ref", &recovery_ref, &tip],
1277 "preserve retained managed worktree branch",
1278 ),
1279 )?;
1280 Ok(recovery_ref)
1281 }
1282 status => bail!(
1283 "check retained managed worktree recovery ref failed with status {status}: {}",
1284 String::from_utf8_lossy(&output.stderr).trim()
1285 ),
1286 }
1287}
1288
1289pub(super) fn retire_managed_worktree(
1296 executor: &impl CommandExecutor,
1297 worktree: &ManagedWorktree,
1298) -> Result<()> {
1299 if !remove_managed_worktree_checkout(executor, worktree)? {
1300 return Ok(());
1301 }
1302 remove_empty_managed_worktree_directories(executor, worktree)
1303}
1304
1305fn remove_managed_worktree_checkout(
1308 executor: &impl CommandExecutor,
1309 worktree: &ManagedWorktree,
1310) -> Result<bool> {
1311 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1312 return Ok(false);
1313 }
1314 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1315 execute_checked(
1316 executor,
1317 managed_git_command(
1318 &worktree.target,
1319 &worktree.source_repository,
1320 [
1321 "worktree",
1322 "remove",
1323 "--force",
1324 &worktree.worktree_root.to_string_lossy(),
1325 ],
1326 "remove managed raw-session worktree",
1327 ),
1328 )?;
1329 }
1330 execute_checked(
1331 executor,
1332 managed_git_command(
1333 &worktree.target,
1334 &worktree.source_repository,
1335 ["worktree", "prune"],
1336 "prune managed worktree metadata",
1337 ),
1338 )?;
1339 Ok(true)
1340}
1341
1342pub(super) fn cleanup_managed_worktree(
1343 executor: &impl CommandExecutor,
1344 worktree: &ManagedWorktree,
1345) -> Result<()> {
1346 if !remove_managed_worktree_checkout(executor, worktree)? {
1347 return Ok(());
1348 }
1349 let branch_ref = format!("refs/heads/{}", worktree.branch);
1350 let check = managed_git_command(
1351 &worktree.target,
1352 &worktree.source_repository,
1353 ["show-ref", "--verify", "--quiet", &branch_ref],
1354 "check managed worktree branch",
1355 );
1356 let output = executor.execute(&check)?;
1357 match output.status {
1358 0 => {
1359 execute_checked(
1360 executor,
1361 managed_git_command(
1362 &worktree.target,
1363 &worktree.source_repository,
1364 ["branch", "-D", "--", &worktree.branch],
1365 "delete managed raw-session branch",
1366 ),
1367 )?;
1368 }
1369 1 => {}
1370 status => bail!(
1371 "check managed worktree branch failed with status {status}: {}",
1372 String::from_utf8_lossy(&output.stderr).trim()
1373 ),
1374 }
1375 remove_empty_managed_worktree_directories(executor, worktree)
1376}
1377
1378fn remove_empty_managed_worktree_directories(
1379 executor: &impl CommandExecutor,
1380 worktree: &ManagedWorktree,
1381) -> Result<()> {
1382 let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1383 let hel = worktree.source_repository.join(".mj");
1384 match &worktree.target {
1385 ManagedWorktreeTarget::Local => {
1386 for directory in [&worktrees, &hel] {
1387 match std::fs::remove_dir(directory) {
1388 Ok(()) => {}
1389 Err(error)
1390 if matches!(
1391 error.kind(),
1392 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1393 ) => {}
1394 Err(error) => return Err(error.into()),
1395 }
1396 }
1397 }
1398 ManagedWorktreeTarget::Ssh { .. } => {
1399 let command = managed_target_command(
1400 &worktree.target,
1401 "rmdir",
1402 ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1403 )
1404 .purpose("remove empty managed worktree directories");
1405 let _ = executor.execute(&command)?;
1406 }
1407 }
1408 Ok(())
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413 use std::cell::RefCell;
1414 use std::collections::BTreeMap;
1415 use std::path::{Path, PathBuf};
1416 use std::process::Command;
1417
1418 use anyhow::Result;
1419
1420 use crate::controller::Controller;
1421 use crate::controller::resume::apply_failed_resume_rollback;
1422 use crate::controller::test_support::{
1423 checkpoint_test_session, committed_repository, local_bundle, managed_raw_session,
1424 managed_worktree_session, raw_session_on, resume_compatibility_config, ssh_worktree_target,
1425 test_git,
1426 };
1427 use mj_checkpoint::archive::RepositoryMetadata;
1428 use mj_core::config::{
1429 Config, HarnessProfile, ProjectBundle, ProjectRepository, TargetTemplate,
1430 };
1431 use mj_core::state::{ManagedWorktree, ManagedWorktreeTarget, SessionState, State};
1432
1433 use crate::targets::{
1434 CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
1435 };
1436
1437 use super::*;
1438
1439 #[test]
1440 fn worktree_choice_survives_reload_and_controls_creation() {
1441 const CHILD: &str = "MJ_TEST_WORKTREE_CHOICE_CHILD";
1442 if std::env::var_os(CHILD).is_none() {
1443 let directory = tempfile::tempdir().unwrap();
1444 let mut command = Command::new(std::env::current_exe().unwrap());
1445 command.args(["--exact", "controller::worktree::tests::worktree_choice_survives_reload_and_controls_creation", "--nocapture"])
1446 .env(CHILD, "1").env("MJ_DATA_DIR", directory.path()).env("MJ_CONFIG_DIR", directory.path());
1447 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1448 assert!(
1449 output.status.success(),
1450 "{}\n{}",
1451 String::from_utf8_lossy(&output.stdout),
1452 String::from_utf8_lossy(&output.stderr)
1453 );
1454 return;
1455 }
1456 let _writer = crate::database::install_isolated_test_writer();
1457 let repository = committed_repository();
1458 let root = repository.path().canonicalize().unwrap();
1459 let linked_parent = tempfile::tempdir().unwrap();
1460 let linked = linked_parent.path().join("linked");
1461 test_git(
1462 &root,
1463 &["worktree", "add", "-b", "side", linked.to_str().unwrap()],
1464 );
1465 test_git(&linked, &["branch", "--set-upstream-to=master"]);
1466 std::fs::write(linked.join("nested/file.txt"), "linked commit\n").unwrap();
1467 test_git(&linked, &["commit", "-am", "side commit"]);
1468 let linked = linked.canonicalize().unwrap();
1469 let mut config = Config::default();
1470 config
1471 .targets
1472 .insert("localhost".into(), TargetTemplate::LocalBare);
1473 config.save().unwrap();
1474 let mut controller = Controller {
1475 config,
1476 state: State::default(),
1477 };
1478 assert_eq!(
1479 controller
1480 .managed_worktree_options("localhost", &root, &ProcessExecutor)
1481 .unwrap(),
1482 ManagedWorktreeOptions {
1483 available: true,
1484 default_create: true
1485 }
1486 );
1487 assert_eq!(
1488 controller
1489 .managed_worktree_options("localhost", &linked, &ProcessExecutor)
1490 .unwrap(),
1491 ManagedWorktreeOptions {
1492 available: true,
1493 default_create: false
1494 }
1495 );
1496
1497 std::fs::write(root.join("dirty.txt"), "keep me\n").unwrap();
1500 let selected = root.join("nested");
1501 let mut record = raw_session_on("localhost", selected.to_str().unwrap());
1502 record.create_managed_worktree = Some(false);
1503 crate::database::save_session(&record).unwrap();
1504 for _ in 0..2 {
1505 controller.reload().unwrap();
1506 assert!(
1507 !controller
1508 .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1509 .unwrap()
1510 );
1511 assert_eq!(
1512 controller.state.sessions[&record.id]
1513 .project_directory
1514 .as_ref(),
1515 Some(&selected)
1516 );
1517 assert!(
1518 controller.state.sessions[&record.id]
1519 .managed_worktree
1520 .is_none()
1521 );
1522 controller
1523 .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1524 .unwrap();
1525 }
1526 assert!(root.join("dirty.txt").exists());
1527 assert!(!root.join(".mj/worktrees").exists());
1528 assert_eq!(test_git(&root, &["branch", "--show-current"]), "master");
1529 std::fs::remove_file(root.join("dirty.txt")).unwrap();
1530
1531 record.project_directory = Some(linked.join("nested"));
1534 record.create_managed_worktree = None;
1535 crate::database::save_session(&record).unwrap();
1536 controller.reload().unwrap();
1537 assert!(
1538 !controller
1539 .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1540 .unwrap()
1541 );
1542 record.create_managed_worktree = Some(true);
1543 crate::database::save_session(&record).unwrap();
1544 controller.reload().unwrap();
1545 assert!(
1546 controller
1547 .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1548 .unwrap()
1549 );
1550 controller.reload().unwrap();
1551 let managed = controller.state.sessions[&record.id]
1552 .managed_worktree
1553 .clone()
1554 .unwrap();
1555 assert_eq!(
1556 test_git(&managed.worktree_root, &["rev-parse", "HEAD"]),
1557 test_git(&linked, &["rev-parse", "HEAD"])
1558 );
1559 assert_ne!(
1560 test_git(&managed.worktree_root, &["rev-parse", "HEAD"]),
1561 test_git(&root, &["rev-parse", "HEAD"])
1562 );
1563 assert_eq!(
1564 test_git(
1565 &managed.worktree_root,
1566 &["rev-parse", "--abbrev-ref", "@{upstream}"]
1567 ),
1568 "master"
1569 );
1570 assert_eq!(
1571 controller.state.sessions[&record.id].project_directory,
1572 Some(managed.worktree_root.join("nested"))
1573 );
1574 controller
1575 .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1576 .unwrap();
1577 assert!(linked.join("nested/file.txt").exists());
1578 assert!(!managed.worktree_root.exists());
1579
1580 record.project_directory = Some(root.clone());
1581 record.create_managed_worktree = None;
1582 crate::database::save_session(&record).unwrap();
1583 controller.reload().unwrap();
1584 assert!(
1585 controller
1586 .prepare_managed_raw_worktree(&record.id, &ProcessExecutor)
1587 .unwrap()
1588 );
1589 controller
1590 .cleanup_new_session_worktree(&record.id, &ProcessExecutor)
1591 .unwrap();
1592 }
1593
1594 #[test]
1595 fn explicit_worktree_creation_rejects_plain_directories() {
1596 let directory = tempfile::tempdir().unwrap();
1597 let mut record = raw_session_on("localhost", directory.path().to_str().unwrap());
1598 record.create_managed_worktree = Some(true);
1599 let id = record.id.clone();
1600 let mut config = Config::default();
1601 config
1602 .targets
1603 .insert("localhost".into(), TargetTemplate::LocalBare);
1604 let mut controller = Controller {
1605 config,
1606 state: State {
1607 sessions: [(id.clone(), record)].into_iter().collect(),
1608 ..State::default()
1609 },
1610 };
1611 assert_eq!(
1612 controller
1613 .managed_worktree_options("localhost", directory.path(), &ProcessExecutor)
1614 .unwrap(),
1615 ManagedWorktreeOptions::default()
1616 );
1617 let error = controller
1618 .prepare_managed_raw_worktree(&id, &ProcessExecutor)
1619 .unwrap_err();
1620 assert!(error.to_string().contains("requires a Git project"));
1621 assert!(!directory.path().join(".git").exists());
1622 }
1623
1624 #[test]
1625 fn local_bare_validation_accepts_projects_and_plain_directories_but_rejects_missing_paths() {
1626 let project = committed_repository();
1627 let plain = tempfile::tempdir().unwrap();
1628 let mut config = Config::default();
1629 config
1630 .targets
1631 .insert("localhost".into(), TargetTemplate::LocalBare);
1632 let controller = Controller {
1633 config,
1634 state: State::default(),
1635 };
1636 controller
1637 .validate_project_directory("localhost", project.path(), &ProcessExecutor)
1638 .unwrap();
1639 controller
1640 .validate_project_directory("localhost", plain.path(), &ProcessExecutor)
1641 .unwrap();
1642 assert!(
1643 controller
1644 .validate_project_directory(
1645 "localhost",
1646 &plain.path().join("missing"),
1647 &ProcessExecutor
1648 )
1649 .is_err()
1650 );
1651 }
1652
1653 #[test]
1654 fn a_plain_local_directory_starts_without_creating_a_git_worktree() {
1655 let plain = tempfile::tempdir().unwrap();
1656 let session = raw_session_on("localhost", plain.path().to_str().unwrap());
1657 let session_id = session.id.clone();
1658 let mut config = Config::default();
1659 config
1660 .targets
1661 .insert("localhost".into(), TargetTemplate::LocalBare);
1662 let mut controller = Controller {
1663 config,
1664 state: State {
1665 sessions: [(session_id.clone(), session)].into_iter().collect(),
1666 ..State::default()
1667 },
1668 };
1669 assert!(
1670 !controller
1671 .prepare_managed_raw_worktree(&session_id, &ProcessExecutor)
1672 .unwrap()
1673 );
1674 let session = &controller.state.sessions[&session_id];
1675 assert_eq!(session.project_directory.as_deref(), Some(plain.path()));
1676 assert!(session.managed_worktree.is_none());
1677 assert!(!plain.path().join(".git").exists());
1678 }
1679
1680 #[test]
1681 fn raw_linked_worktree_origin_matches_the_configured_github_project() {
1682 struct OriginExecutor;
1683 impl CommandExecutor for OriginExecutor {
1684 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1685 assert_eq!(
1686 command.args,
1687 [
1688 "-C",
1689 "/mnt/optane/bifrost-fird",
1690 "config",
1691 "--get",
1692 "remote.origin.url",
1693 ]
1694 );
1695 Ok(CommandOutput {
1696 status: 0,
1697 stdout: b"git@github.com:BrokkAi/bifrost-dev.git\n".to_vec(),
1698 stderr: Vec::new(),
1699 })
1700 }
1701 }
1702
1703 let mut config = Config::default();
1704 config
1705 .targets
1706 .insert("localhost".into(), TargetTemplate::LocalBare);
1707 let session = raw_session_on("localhost", "/mnt/optane/bifrost-fird");
1708 let session_id = session.id.clone();
1709 let controller = Controller {
1710 config,
1711 state: State {
1712 sessions: [(session_id.clone(), session)].into_iter().collect(),
1713 ..State::default()
1714 },
1715 };
1716
1717 let source = controller
1718 .resolve_session_project_source(&session_id, &OriginExecutor)
1719 .unwrap();
1720
1721 assert_eq!(source.key, "github:brokkai/bifrost-dev");
1722 assert_eq!(source.short, "bifrost-dev");
1723 assert_eq!(source.full, "BrokkAi/bifrost-dev");
1724 }
1725 #[test]
1726 fn managed_worktree_origin_uses_source_repository_while_checkout_is_retired() {
1727 struct OriginExecutor;
1728 impl CommandExecutor for OriginExecutor {
1729 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1730 assert_eq!(
1731 command.args,
1732 [
1733 "-C",
1734 "/home/dev/project",
1735 "config",
1736 "--get",
1737 "remote.origin.url",
1738 ]
1739 );
1740 Ok(CommandOutput {
1741 status: 0,
1742 stdout: b"git@github.com:example/project.git\n".to_vec(),
1743 stderr: Vec::new(),
1744 })
1745 }
1746 }
1747
1748 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1749 let session_id = session.id.clone();
1750 let controller = Controller {
1751 config: Config::default(),
1752 state: State {
1753 sessions: [(session_id.clone(), session)].into_iter().collect(),
1754 ..State::default()
1755 },
1756 };
1757
1758 let source = controller
1759 .resolve_session_project_source(&session_id, &OriginExecutor)
1760 .unwrap();
1761
1762 assert_eq!(source.key, "github:example/project");
1763 }
1764
1765 #[test]
1766 fn raw_no_origin_uses_the_canonical_main_repository_root() {
1767 struct NoOriginExecutor {
1768 commands: RefCell<Vec<CommandSpec>>,
1769 }
1770 impl CommandExecutor for NoOriginExecutor {
1771 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1772 self.commands.borrow_mut().push(command.clone());
1773 if command.args.iter().any(|argument| argument == "config") {
1774 return Ok(CommandOutput {
1775 status: 1,
1776 stdout: Vec::new(),
1777 stderr: Vec::new(),
1778 });
1779 }
1780 let stdout = if command
1781 .args
1782 .iter()
1783 .any(|argument| argument == "--show-toplevel")
1784 {
1785 "/worktrees/project-side\n"
1786 } else if command
1787 .args
1788 .iter()
1789 .any(|argument| argument == "--git-common-dir")
1790 {
1791 "/projects/project/.git\n"
1792 } else {
1793 panic!("unexpected command {:?}", command.args);
1794 };
1795 Ok(CommandOutput {
1796 status: 0,
1797 stdout: stdout.as_bytes().to_vec(),
1798 stderr: Vec::new(),
1799 })
1800 }
1801 }
1802
1803 let mut config = Config::default();
1804 config
1805 .targets
1806 .insert("localhost".into(), TargetTemplate::LocalBare);
1807 let session = raw_session_on("localhost", "/worktrees/project-side");
1808 let session_id = session.id.clone();
1809 let controller = Controller {
1810 config,
1811 state: State {
1812 sessions: [(session_id.clone(), session)].into_iter().collect(),
1813 ..State::default()
1814 },
1815 };
1816 let executor = NoOriginExecutor {
1817 commands: RefCell::new(Vec::new()),
1818 };
1819
1820 let source = controller
1821 .resolve_session_project_source(&session_id, &executor)
1822 .unwrap();
1823
1824 assert_eq!(source.key, "path:/projects/project");
1825 assert_eq!(source.short, "project");
1826 assert_eq!(source.full, "/projects/project");
1827 assert_eq!(executor.commands.borrow().len(), 3);
1828 }
1829
1830 #[test]
1831 fn raw_non_git_directory_keeps_its_local_path_source() {
1832 struct NonGitExecutor {
1833 commands: RefCell<Vec<CommandSpec>>,
1834 }
1835 impl CommandExecutor for NonGitExecutor {
1836 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1837 self.commands.borrow_mut().push(command.clone());
1838 let status = if command.args.iter().any(|argument| argument == "config") {
1839 1
1840 } else {
1841 assert!(
1842 command
1843 .args
1844 .iter()
1845 .any(|argument| argument == "--show-toplevel")
1846 );
1847 128
1848 };
1849 Ok(CommandOutput {
1850 status,
1851 stdout: Vec::new(),
1852 stderr: b"fatal: not a git repository\n".to_vec(),
1853 })
1854 }
1855 }
1856
1857 let mut config = Config::default();
1858 config
1859 .targets
1860 .insert("localhost".into(), TargetTemplate::LocalBare);
1861 let session = raw_session_on("localhost", "/scratch/project");
1862 let session_id = session.id.clone();
1863 let controller = Controller {
1864 config,
1865 state: State {
1866 sessions: [(session_id.clone(), session)].into_iter().collect(),
1867 ..State::default()
1868 },
1869 };
1870 let executor = NonGitExecutor {
1871 commands: RefCell::new(Vec::new()),
1872 };
1873
1874 let source = controller
1875 .resolve_session_project_source(&session_id, &executor)
1876 .unwrap();
1877
1878 assert_eq!(source.key, "path:/scratch/project");
1879 assert_eq!(source.full, "/scratch/project");
1880 assert_eq!(executor.commands.borrow().len(), 2);
1881 }
1882
1883 #[test]
1884 fn project_root_lookup_reports_git_failures_instead_of_treating_them_as_non_git() {
1885 struct FailedGit;
1886 impl CommandExecutor for FailedGit {
1887 fn execute(&self, _: &CommandSpec) -> Result<CommandOutput> {
1888 Ok(CommandOutput {
1889 status: 128,
1890 stdout: Vec::new(),
1891 stderr: b"fatal: detected dubious ownership in repository".to_vec(),
1892 })
1893 }
1894 }
1895 let error = resolve_git_root(
1896 &ManagedWorktreeTarget::Local,
1897 Path::new("/project"),
1898 &FailedGit,
1899 )
1900 .unwrap_err();
1901 assert!(error.to_string().contains("dubious ownership"));
1902 }
1903
1904 struct CheckoutPositionExecutor {
1906 head_commit: String,
1907 branch: Option<String>,
1908 }
1909 impl CommandExecutor for CheckoutPositionExecutor {
1910 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1911 let stdout = if command.args.iter().any(|argument| argument == "rev-parse") {
1912 self.head_commit.clone()
1913 } else if command
1914 .args
1915 .iter()
1916 .any(|argument| argument == "symbolic-ref")
1917 {
1918 match &self.branch {
1919 Some(branch) => branch.clone(),
1920 None => {
1921 return Ok(CommandOutput {
1922 status: 1,
1923 stdout: Vec::new(),
1924 stderr: Vec::new(),
1925 });
1926 }
1927 }
1928 } else {
1929 panic!("unexpected command {:?}", command.args);
1930 };
1931 Ok(CommandOutput {
1932 status: 0,
1933 stdout: format!("{stdout}\n").into_bytes(),
1934 stderr: Vec::new(),
1935 })
1936 }
1937 }
1938 fn recorded_repository(head_commit: &str, branch: Option<&str>) -> RepositoryMetadata {
1939 RepositoryMetadata {
1940 push_urls: Vec::new(),
1941 remote_workspace: false,
1942 id: "project".into(),
1943 relative_destination: PathBuf::from("project"),
1944 origin: "mj-local:project".into(),
1945 base_commit: String::new(),
1946 head_commit: head_commit.into(),
1947 branch: branch.map(str::to_owned),
1948 }
1949 }
1950 #[test]
1951 fn a_raw_checkout_that_moved_while_stopped_gets_a_conversation_line() {
1952 let config = resume_compatibility_config();
1953 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1954 let directory = session.project_directory.clone().unwrap();
1955 let executor = CheckoutPositionExecutor {
1956 head_commit: "b".repeat(40),
1957 branch: Some("mj/0123456789abcdef0123456789abcdef".into()),
1958 };
1959
1960 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1961 let notice = raw_checkout_divergence_notice(
1962 &directory,
1963 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1964 &live,
1965 )
1966 .expect("a moved checkout is reported");
1967
1968 assert!(
1969 notice.contains(&directory.display().to_string()),
1970 "{notice}"
1971 );
1972 assert!(notice.contains("aaaaaaaaaaaa (main)"), "{notice}");
1973 assert!(
1974 notice.contains("bbbbbbbbbbbb (mj/0123456789abcdef0123456789abcdef)"),
1975 "{notice}"
1976 );
1977 assert!(
1978 notice.contains("while this session was stopped"),
1979 "{notice}"
1980 );
1981 }
1982 #[test]
1983 fn a_raw_checkout_that_stayed_put_gets_no_conversation_line() {
1984 let config = resume_compatibility_config();
1985 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1986 let directory = session.project_directory.clone().unwrap();
1987 let executor = CheckoutPositionExecutor {
1988 head_commit: "a".repeat(40),
1989 branch: Some("main".into()),
1990 };
1991
1992 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1993
1994 assert_eq!(
1995 raw_checkout_divergence_notice(
1996 &directory,
1997 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1998 &live,
1999 ),
2000 None
2001 );
2002 }
2003 #[test]
2004 fn a_checkpoint_without_recorded_git_identity_reports_nothing() {
2005 let live = CheckoutPosition {
2006 head_commit: "b".repeat(40),
2007 branch: None,
2008 };
2009
2010 assert_eq!(
2011 raw_checkout_divergence_notice(Path::new("/home/dev/project"), None, &live),
2012 None
2013 );
2014 assert_eq!(
2015 raw_checkout_divergence_notice(
2016 Path::new("/home/dev/project"),
2017 Some(&recorded_repository("", None)),
2018 &live,
2019 ),
2020 None
2021 );
2022 }
2023 #[test]
2024 fn a_detached_checkout_is_named_as_detached() {
2025 let config = resume_compatibility_config();
2026 let session = managed_raw_session(ManagedWorktreeTarget::Local);
2027 let directory = session.project_directory.clone().unwrap();
2028 let executor = CheckoutPositionExecutor {
2029 head_commit: "c".repeat(40),
2030 branch: None,
2031 };
2032
2033 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
2034 let notice = raw_checkout_divergence_notice(
2035 &directory,
2036 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
2037 &live,
2038 )
2039 .expect("a moved checkout is reported");
2040
2041 assert!(notice.contains("cccccccccccc (detached)"), "{notice}");
2042 }
2043 #[test]
2044 fn bundle_sessions_resume_on_any_workspace_target() {
2045 let config = resume_compatibility_config();
2046 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2047
2048 assert_eq!(
2049 resume_compatibility(&session, &config, "podman"),
2050 Ok(ResumePlan::InPlace)
2051 );
2052 assert_eq!(
2053 resume_compatibility(&session, &config, "ssh-bare"),
2054 Ok(ResumePlan::InPlace)
2055 );
2056 }
2057 #[test]
2058 fn a_single_local_repository_can_become_a_checkout() {
2059 let mut config = resume_compatibility_config();
2060 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2061 session.bundle_id = "project".into();
2062 config.bundles.insert(
2063 "project".into(),
2064 local_bundle(Path::new("/home/dev/project")),
2065 );
2066
2067 assert_eq!(
2068 resume_compatibility(&session, &config, "local-bare"),
2069 Ok(ResumePlan::WorkspaceToRaw)
2070 );
2071 }
2072 #[test]
2073 fn a_github_project_cannot_become_a_checkout() {
2074 let mut config = resume_compatibility_config();
2075 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2076 session.bundle_id = "project".into();
2077 let mut bundle = local_bundle(Path::new("/home/dev/project"));
2078 bundle.repositories[0].local = None;
2079 bundle.repositories[0].github = Some("example/project".into());
2080 config.bundles.insert("project".into(), bundle);
2081
2082 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2083
2084 assert!(reason.contains("came from GitHub"), "{reason}");
2085 assert!(
2086 reason.contains("resume it on a container, SSH, or EC2 target"),
2087 "{reason}"
2088 );
2089 }
2090 #[test]
2091 fn a_multi_repository_project_cannot_become_a_checkout() {
2092 let mut config = resume_compatibility_config();
2093 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2094 session.bundle_id = "project".into();
2095 let mut bundle = local_bundle(Path::new("/home/dev/project"));
2096 bundle.repositories.push(ProjectRepository {
2097 id: "tools".into(),
2098 github: None,
2099 local: Some(PathBuf::from("/home/dev/tools")),
2100 destination: PathBuf::from("tools"),
2101 git_ref: None,
2102 });
2103 config.bundles.insert("project".into(), bundle);
2104
2105 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2106
2107 assert!(reason.contains("2 repositories"), "{reason}");
2108 assert!(reason.contains("one checkout"), "{reason}");
2109 }
2110 #[test]
2111 fn bundle_sessions_refuse_a_local_bare_target_with_a_reason() {
2112 let config = resume_compatibility_config();
2113 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2114
2115 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
2116
2117 assert!(reason.contains("created from a project bundle"), "{reason}");
2118 assert!(
2119 reason.contains("resume it on a container, SSH, or EC2 target"),
2120 "{reason}"
2121 );
2122 }
2123 #[test]
2124 fn managed_raw_sessions_resume_on_their_own_worktree_host() {
2125 let config = resume_compatibility_config();
2126
2127 assert_eq!(
2128 resume_compatibility(
2129 &managed_raw_session(ManagedWorktreeTarget::Local),
2130 &config,
2131 "local-bare",
2132 ),
2133 Ok(ResumePlan::InPlace)
2134 );
2135 assert_eq!(
2136 resume_compatibility(
2137 &managed_raw_session(ssh_worktree_target()),
2138 &config,
2139 "ssh-bare",
2140 ),
2141 Ok(ResumePlan::InPlace)
2142 );
2143 }
2144 #[test]
2145 fn managed_raw_sessions_refuse_a_bare_target_on_another_host() {
2146 let config = resume_compatibility_config();
2147
2148 let reason = resume_compatibility(
2149 &managed_raw_session(ManagedWorktreeTarget::Local),
2150 &config,
2151 "ssh-bare",
2152 )
2153 .unwrap_err();
2154 assert!(reason.contains("this machine"), "{reason}");
2155
2156 let reason = resume_compatibility(
2157 &managed_raw_session(ssh_worktree_target()),
2158 &config,
2159 "local-bare",
2160 )
2161 .unwrap_err();
2162 assert!(reason.contains("dev@builder"), "{reason}");
2163 }
2164 #[test]
2165 fn raw_checkpoints_cannot_move_to_an_isolated_target() {
2166 let config = resume_compatibility_config();
2167 for session in [
2168 managed_raw_session(ManagedWorktreeTarget::Local),
2169 raw_session_on("local-bare", "/home/dev/project"),
2170 ] {
2171 let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
2172 assert!(reason.contains("network repository provenance"), "{reason}");
2173 assert!(reason.contains("bare target"), "{reason}");
2174 }
2175 }
2176 #[test]
2177 fn a_raw_checkout_on_an_ssh_host_cannot_convert() {
2178 let config = resume_compatibility_config();
2179
2180 let reason = resume_compatibility(
2181 &managed_raw_session(ssh_worktree_target()),
2182 &config,
2183 "podman",
2184 )
2185 .unwrap_err();
2186 assert!(reason.contains("works directly in"), "{reason}");
2187 assert!(reason.contains("dev@builder"), "{reason}");
2188
2189 let reason = resume_compatibility(
2190 &raw_session_on("ssh-bare", "/srv/project"),
2191 &config,
2192 "podman",
2193 )
2194 .unwrap_err();
2195 assert!(reason.contains("on an SSH host"), "{reason}");
2196 }
2197 #[test]
2198 fn a_session_that_opens_a_subdirectory_of_its_worktree_cannot_convert() {
2199 let config = resume_compatibility_config();
2200 let mut session = managed_raw_session(ManagedWorktreeTarget::Local);
2201 let worktree = session.managed_worktree.as_mut().unwrap();
2202 worktree.source_project_directory = worktree.source_repository.join("crate");
2203 session.project_directory = Some(worktree.worktree_root.join("crate"));
2204
2205 let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
2206
2207 assert!(reason.contains("subdirectory of its checkout"), "{reason}");
2208 }
2209 #[test]
2210 fn unmanaged_raw_sessions_require_the_same_bare_target_kind() {
2211 let config = resume_compatibility_config();
2212 let local = raw_session_on("local-bare", "/home/dev/project");
2213 let remote = raw_session_on("ssh-bare", "/srv/project");
2214
2215 assert_eq!(
2216 resume_compatibility(&local, &config, "local-bare"),
2217 Ok(ResumePlan::InPlace)
2218 );
2219 assert_eq!(
2220 resume_compatibility(&remote, &config, "ssh-bare"),
2221 Ok(ResumePlan::InPlace)
2222 );
2223 for (session, target) in [(&local, "ssh-bare"), (&remote, "local-bare")] {
2224 let reason = resume_compatibility(session, &config, target).unwrap_err();
2225 assert!(reason.contains("directly on its host"), "{reason}");
2226 }
2227 }
2228 #[test]
2229 fn resume_compatibility_names_a_target_that_is_gone() {
2230 let config = resume_compatibility_config();
2231 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2232
2233 let reason = resume_compatibility(&session, &config, "retired").unwrap_err();
2234
2235 assert!(reason.contains("retired"), "{reason}");
2236 }
2237 #[test]
2238 fn managed_raw_worktree_inherits_upstream_and_cleans_up_owned_artifacts() {
2239 let repository = committed_repository();
2240 let remote_parent = tempfile::tempdir().unwrap();
2241 let remote = remote_parent.path().join("remote.git");
2242 let output = Command::new("git")
2243 .args(["init", "--bare"])
2244 .arg(&remote)
2245 .output()
2246 .unwrap();
2247 assert!(output.status.success());
2248 test_git(
2249 repository.path(),
2250 &["remote", "add", "origin", &remote.to_string_lossy()],
2251 );
2252 test_git(
2253 repository.path(),
2254 &["push", "--set-upstream", "origin", "master"],
2255 );
2256
2257 let target = ManagedWorktreeTarget::Local;
2258 let inspection =
2259 inspect_raw_project(&ProcessExecutor, &target, &repository.path().join("nested"))
2260 .unwrap();
2261 assert!(inspection.primary_checkout);
2262 assert_eq!(inspection.upstream.as_deref(), Some("origin/master"));
2263 assert_eq!(
2266 inspection.source_project_directory,
2267 repository.path().canonicalize().unwrap().join("nested")
2268 );
2269
2270 let session_id = "0123456789abcdef0123456789abcdef";
2271 let worktree = ManagedWorktree {
2272 source_project_directory: inspection.source_project_directory,
2273 source_repository: inspection.source_repository,
2274 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2275 branch: format!("mj/{session_id}"),
2276 target,
2277 base_commit: None,
2278 };
2279 create_managed_worktree(
2280 &ProcessExecutor,
2281 &worktree,
2282 inspection.upstream.as_deref(),
2283 PrimaryCheckoutRequirement::Clean,
2284 )
2285 .unwrap();
2286 assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2287 assert_eq!(
2288 test_git(
2289 &worktree.worktree_root,
2290 &[
2291 "rev-parse",
2292 "--abbrev-ref",
2293 "--symbolic-full-name",
2294 "@{upstream}"
2295 ]
2296 ),
2297 "origin/master"
2298 );
2299 assert_eq!(test_git(repository.path(), &["status", "--porcelain"]), "");
2300 std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2301
2302 cleanup_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2303 assert!(!worktree.worktree_root.exists());
2304 assert!(!repository.path().join(".mj").exists());
2305 let output = Command::new("git")
2306 .arg("-C")
2307 .arg(repository.path())
2308 .args([
2309 "show-ref",
2310 "--verify",
2311 &format!("refs/heads/{}", worktree.branch),
2312 ])
2313 .output()
2314 .unwrap();
2315 assert!(!output.status.success());
2316 }
2317 #[test]
2318 fn retired_worktree_can_be_recreated_from_its_retained_branch() {
2319 let repository = committed_repository();
2320 let session_id = "0123456789abcdef0123456789abcdef";
2321 let session = managed_worktree_session(repository.path(), session_id);
2322 let worktree = session.managed_worktree.unwrap();
2323 std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2325
2326 retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2327
2328 assert!(!worktree.worktree_root.exists());
2329 assert!(!repository.path().join(".mj").exists());
2330 let branch = Command::new("git")
2331 .arg("-C")
2332 .arg(repository.path())
2333 .args([
2334 "show-ref",
2335 "--verify",
2336 &format!("refs/heads/{}", worktree.branch),
2337 ])
2338 .output()
2339 .unwrap();
2340 assert!(
2341 branch.status.success(),
2342 "the session branch must survive: later checkpoints are deltas against it"
2343 );
2344
2345 assert!(restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2346 assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2347 assert!(!restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2348 }
2349 #[test]
2350 fn retiring_a_remote_worktree_prunes_registration_after_target_removed_checkout() {
2351 struct RemoteExecutor {
2352 path_checks: RefCell<usize>,
2353 commands: RefCell<Vec<CommandSpec>>,
2354 }
2355
2356 impl CommandExecutor for RemoteExecutor {
2357 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2358 self.commands.borrow_mut().push(command.clone());
2359 let status = if command.purpose == "check managed worktree path" {
2360 let mut checks = self.path_checks.borrow_mut();
2361 let status = i32::from(*checks != 0);
2362 *checks += 1;
2363 status
2364 } else {
2365 0
2366 };
2367 Ok(CommandOutput {
2368 status,
2369 stdout: Vec::new(),
2370 stderr: Vec::new(),
2371 })
2372 }
2373 }
2374
2375 let worktree = ManagedWorktree {
2376 source_project_directory: PathBuf::from("/srv/project"),
2377 source_repository: PathBuf::from("/srv/project"),
2378 worktree_root: PathBuf::from("/srv/project/.mj/worktrees/session"),
2379 branch: "mj/session".into(),
2380 target: ManagedWorktreeTarget::Ssh {
2381 destination: "builder".into(),
2382 ssh_args: Vec::new(),
2383 },
2384 base_commit: None,
2385 };
2386 let executor = RemoteExecutor {
2387 path_checks: RefCell::new(0),
2388 commands: RefCell::new(Vec::new()),
2389 };
2390
2391 retire_managed_worktree(&executor, &worktree).unwrap();
2392
2393 let purposes = executor
2394 .commands
2395 .borrow()
2396 .iter()
2397 .map(|command| command.purpose.clone())
2398 .collect::<Vec<_>>();
2399 assert_eq!(
2400 purposes,
2401 [
2402 "check managed worktree path",
2403 "check managed worktree path",
2404 "prune managed worktree metadata",
2405 "remove empty managed worktree directories",
2406 ]
2407 );
2408 }
2409 #[test]
2410 fn a_managed_conversion_carries_the_session_worktree_not_the_primary_checkout() {
2411 let repository = committed_repository();
2412 let session_id = "0123456789abcdef0123456789abcdef";
2413 let session = managed_worktree_session(repository.path(), session_id);
2414 let worktree = session.managed_worktree.clone().unwrap();
2415
2416 let conversion =
2417 plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
2418
2419 assert_eq!(conversion.checkout, worktree.worktree_root);
2420 assert_eq!(conversion.repository, repository.path());
2421 assert_eq!(conversion.retire, Some(worktree));
2422 let bundle = conversion.new_bundle.expect("a bundle is synthesized");
2423 assert_eq!(bundle.repositories.len(), 1);
2424 assert_eq!(bundle.primary_repo, bundle.repositories[0].id);
2425 assert_eq!(
2426 bundle.repositories[0].local.as_deref(),
2427 Some(repository.path())
2428 );
2429 assert_eq!(bundle.repositories[0].github, None);
2430 assert_eq!(
2433 bundle.repositories[0].destination,
2434 PathBuf::from(session_id)
2435 );
2436 }
2437 #[test]
2438 fn an_unmanaged_conversion_serves_the_main_repository_behind_a_linked_worktree() {
2439 let repository = committed_repository();
2440 let session_id = "0123456789abcdef0123456789abcdef";
2441 let linked = managed_worktree_session(repository.path(), session_id);
2442 let checkout = linked.managed_worktree.unwrap().worktree_root;
2443 let mut session = checkpoint_test_session(session_id);
2444 session.state = SessionState::Stopped;
2445 session.target_template_id = "local-bare".into();
2446 session.project_directory = Some(checkout.clone());
2447
2448 let conversion =
2449 plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
2450
2451 assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
2452 assert_eq!(
2453 conversion.repository,
2454 repository.path().canonicalize().unwrap()
2455 );
2456 assert_eq!(conversion.retire, None);
2457 }
2458 #[cfg(unix)]
2463 #[test]
2464 fn an_unmanaged_conversion_accepts_a_checkout_reached_through_a_symlink() {
2465 let repository = committed_repository();
2466 let session_id = "0123456789abcdef0123456789abcdef";
2467 let linked = managed_worktree_session(repository.path(), session_id);
2468 let checkout = linked.managed_worktree.unwrap().worktree_root;
2469 let alias = tempfile::tempdir().unwrap();
2470 let symlink = alias.path().join("checkout");
2471 std::os::unix::fs::symlink(&checkout, &symlink).unwrap();
2472 let mut session = checkpoint_test_session(session_id);
2473 session.state = SessionState::Stopped;
2474 session.target_template_id = "local-bare".into();
2475 session.project_directory = Some(symlink);
2476
2477 let conversion =
2478 plan_raw_to_workspace(&session, &Config::default(), &ProcessExecutor).unwrap();
2479
2480 assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
2481 assert_eq!(
2482 conversion.repository,
2483 repository.path().canonicalize().unwrap()
2484 );
2485 assert_eq!(conversion.retire, None);
2486 }
2487 #[test]
2488 fn a_conversion_reuses_a_bundle_that_already_describes_the_checkout() {
2489 let repository = PathBuf::from("/home/dev/project");
2490 let destination = PathBuf::from("project");
2491 let existing = ProjectBundle {
2492 primary_repo: "project".into(),
2493 repositories: vec![ProjectRepository {
2494 id: "project".into(),
2495 github: None,
2496 local: Some(repository.clone()),
2497 destination: destination.clone(),
2498 git_ref: None,
2499 }],
2500 };
2501 let mut config = Config::default();
2502 config.bundles.insert("existing".into(), existing);
2503
2504 assert_eq!(
2505 converted_raw_bundle(&config, "remote-project-abcdef", &repository, &destination),
2506 ("existing".to_owned(), None)
2507 );
2508
2509 let (id, synthesized) = converted_raw_bundle(
2512 &config,
2513 "remote-project-abcdef",
2514 &repository,
2515 Path::new("elsewhere"),
2516 );
2517 assert_ne!(id, "existing");
2518 assert_eq!(
2519 synthesized.unwrap().repositories[0].destination,
2520 PathBuf::from("elsewhere")
2521 );
2522 }
2523 #[test]
2524 fn a_converted_record_is_a_valid_bundle_session() {
2525 let session_id = "0123456789abcdef0123456789abcdef";
2526 let mut config = resume_compatibility_config();
2527 let mut record = managed_raw_session(ManagedWorktreeTarget::Local);
2528 record.state = SessionState::Running;
2529 record.target_template_id = "podman".into();
2530 let conversion = RawToWorkspaceConversion {
2531 checkout: record.project_directory.clone().unwrap(),
2532 repository: PathBuf::from("/home/dev/project"),
2533 bundle_id: "project".into(),
2534 new_bundle: Some(ProjectBundle {
2535 primary_repo: "project".into(),
2536 repositories: vec![ProjectRepository {
2537 id: "project".into(),
2538 github: None,
2539 local: Some(PathBuf::from("/home/dev/project")),
2540 destination: PathBuf::from(session_id),
2541 git_ref: None,
2542 }],
2543 }),
2544 retire: record.managed_worktree.clone(),
2545 };
2546
2547 config.bundles.insert(
2548 conversion.bundle_id.clone(),
2549 conversion.new_bundle.clone().unwrap(),
2550 );
2551 config.profiles.insert(
2552 record.last_profile.clone(),
2553 HarnessProfile {
2554 enabled: true,
2555 kind: record.harness_kind,
2556 home: PathBuf::from("/profiles/codex"),
2557 environment: BTreeMap::new(),
2558 context_window_bytes: None,
2559 },
2560 );
2561 apply_raw_to_workspace(&mut record, &conversion);
2562
2563 assert_eq!(record.project_directory, None);
2564 assert_eq!(record.managed_worktree, None);
2565 assert_eq!(record.bundle_id, "project");
2566 let state = State {
2567 sessions: BTreeMap::from([(session_id.into(), record)]),
2568 ..State::default()
2569 };
2570 state.validate_against_config(&config).unwrap();
2571 }
2572 #[test]
2573 fn a_session_leaving_its_target_claims_a_worktree_of_its_own_repository() {
2574 let repository = committed_repository();
2575 let session_id = "0123456789abcdef0123456789abcdef";
2576 let mut session = checkpoint_test_session(session_id);
2577 session.state = SessionState::Stopped;
2578 session.bundle_id = "project".into();
2579 let mut config = resume_compatibility_config();
2580 config
2581 .bundles
2582 .insert("project".into(), local_bundle(repository.path()));
2583 let controller = Controller {
2584 config,
2585 state: State {
2586 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2587 ..State::default()
2588 },
2589 };
2590
2591 let conversion = controller
2592 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2593 .unwrap();
2594
2595 assert_eq!(
2596 conversion.worktree,
2597 ManagedWorktree {
2598 source_project_directory: repository.path().to_path_buf(),
2599 source_repository: repository.path().to_path_buf(),
2600 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2601 branch: format!("mj/{session_id}"),
2602 target: ManagedWorktreeTarget::Local,
2603 base_commit: Some(test_git(repository.path(), &["rev-parse", "HEAD"])),
2606 }
2607 );
2608
2609 std::fs::write(repository.path().join("dirty.txt"), "primary\n").unwrap();
2612 create_managed_worktree(
2613 &ProcessExecutor,
2614 &conversion.worktree,
2615 None,
2616 PrimaryCheckoutRequirement::Any,
2617 )
2618 .unwrap();
2619 assert!(conversion.worktree.worktree_root.is_dir());
2620
2621 let error = controller
2623 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2624 .unwrap_err();
2625 assert!(format!("{error:#}").contains("already exists"), "{error:#}");
2626 }
2627 #[test]
2628 fn a_return_to_local_reuses_its_retained_branch_and_preserves_its_tip() {
2629 let repository = committed_repository();
2630 let session_id = "0123456789abcdef0123456789abcdef";
2631 let branch = format!("mj/{session_id}");
2632 let original_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
2633 test_git(repository.path(), &["branch", &branch]);
2634 let mut config = resume_compatibility_config();
2635 config
2636 .bundles
2637 .insert("project".into(), local_bundle(repository.path()));
2638 let session = checkpoint_test_session(session_id);
2639 let controller = Controller {
2640 config,
2641 state: State {
2642 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2643 ..State::default()
2644 },
2645 };
2646
2647 let conversion = controller
2648 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2649 .unwrap();
2650
2651 assert!(conversion.reuse_existing_branch);
2652 assert!(!conversion.worktree.worktree_root.exists());
2653 let recovery_ref =
2654 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2655 .unwrap();
2656 assert_eq!(
2657 recovery_ref,
2658 format!("refs/mj/recovery/{session_id}/{original_tip}")
2659 );
2660 assert_eq!(
2661 test_git(
2662 repository.path(),
2663 &["show-ref", "--hash", recovery_ref.as_str()],
2664 ),
2665 original_tip
2666 );
2667 assert_eq!(
2668 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2669 .unwrap(),
2670 recovery_ref
2671 );
2672
2673 test_git(repository.path(), &["checkout", &branch]);
2674 test_git(
2675 repository.path(),
2676 &["commit", "--allow-empty", "-m", "later retained tip"],
2677 );
2678 let later_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
2679 test_git(repository.path(), &["checkout", "master"]);
2680 let later_recovery_ref =
2681 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2682 .unwrap();
2683 assert_eq!(
2684 later_recovery_ref,
2685 format!("refs/mj/recovery/{session_id}/{later_tip}")
2686 );
2687 assert_ne!(later_recovery_ref, recovery_ref);
2688 assert_eq!(
2689 test_git(
2690 repository.path(),
2691 &["show-ref", "--hash", recovery_ref.as_str()],
2692 ),
2693 original_tip
2694 );
2695 }
2696 #[test]
2697 fn a_return_to_local_rejects_a_retained_branch_checked_out_elsewhere() {
2698 let repository = committed_repository();
2699 let session_id = "0123456789abcdef0123456789abcdef";
2700 let branch = format!("mj/{session_id}");
2701 test_git(repository.path(), &["branch", &branch]);
2702 let elsewhere = repository.path().join("other-worktree");
2703 test_git(
2704 repository.path(),
2705 &["worktree", "add", &elsewhere.to_string_lossy(), &branch],
2706 );
2707 let mut config = resume_compatibility_config();
2708 config
2709 .bundles
2710 .insert("project".into(), local_bundle(repository.path()));
2711 let session = checkpoint_test_session(session_id);
2712 let controller = Controller {
2713 config,
2714 state: State {
2715 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2716 ..State::default()
2717 },
2718 };
2719
2720 let error = controller
2721 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2722 .unwrap_err();
2723
2724 assert!(error.to_string().contains("still checked out"), "{error:#}");
2725 assert!(
2726 !repository
2727 .path()
2728 .join(".mj/worktrees")
2729 .join(session_id)
2730 .exists()
2731 );
2732 assert_eq!(
2733 test_git(repository.path(), &["rev-parse", &branch]),
2734 test_git(repository.path(), &["rev-parse", "HEAD"])
2735 );
2736 }
2737 #[test]
2738 fn a_session_that_left_its_target_is_a_valid_raw_session() {
2739 let session_id = "0123456789abcdef0123456789abcdef";
2740 let repository = PathBuf::from("/home/dev/project");
2741 let mut config = resume_compatibility_config();
2742 config
2743 .bundles
2744 .insert("project".into(), local_bundle(&repository));
2745 config.profiles.insert(
2746 "codex".into(),
2747 HarnessProfile {
2748 enabled: true,
2749 kind: mj_core::config::HarnessKind::Codex,
2750 home: PathBuf::from("/profiles/codex"),
2751 environment: BTreeMap::new(),
2752 context_window_bytes: None,
2753 },
2754 );
2755 let mut record = checkpoint_test_session(session_id);
2756 record.bundle_id = "project".into();
2757 record.target_template_id = "local-bare".into();
2758 let conversion = WorkspaceToRawConversion {
2759 worktree: ManagedWorktree {
2760 source_project_directory: repository.clone(),
2761 source_repository: repository.clone(),
2762 worktree_root: repository.join(".mj/worktrees").join(session_id),
2763 branch: format!("mj/{session_id}"),
2764 target: ManagedWorktreeTarget::Local,
2765 base_commit: None,
2766 },
2767 reuse_existing_branch: false,
2768 };
2769
2770 apply_workspace_to_raw(&mut record, &conversion);
2771
2772 assert_eq!(
2773 record.project_directory.as_deref(),
2774 Some(conversion.worktree.worktree_root.as_path())
2775 );
2776 assert_eq!(record.bundle_id, "project", "the bundle still describes it");
2777 let state = State {
2778 sessions: BTreeMap::from([(session_id.into(), record)]),
2779 ..State::default()
2780 };
2781 state.validate_against_config(&config).unwrap();
2782 }
2783 #[test]
2784 fn a_failed_departure_returns_the_session_to_its_bundle() {
2785 let session_id = "0123456789abcdef0123456789abcdef";
2786 let repository = PathBuf::from("/home/dev/project");
2787 let previous = {
2788 let mut record = checkpoint_test_session(session_id);
2789 record.state = SessionState::Stopped;
2790 record.bundle_id = "project".into();
2791 record
2792 };
2793 let mut converted = previous.clone();
2794 converted.state = SessionState::Provisioning;
2795 apply_workspace_to_raw(
2796 &mut converted,
2797 &WorkspaceToRawConversion {
2798 worktree: ManagedWorktree {
2799 source_project_directory: repository.clone(),
2800 source_repository: repository.clone(),
2801 worktree_root: repository.join(".mj/worktrees").join(session_id),
2802 branch: format!("mj/{session_id}"),
2803 target: ManagedWorktreeTarget::Local,
2804 base_commit: None,
2805 },
2806 reuse_existing_branch: false,
2807 },
2808 );
2809
2810 apply_failed_resume_rollback(&mut converted, &previous, "podman is unavailable", None);
2811
2812 assert_eq!(converted.project_directory, None);
2813 assert_eq!(converted.managed_worktree, None);
2814 assert_eq!(converted.bundle_id, "project");
2815 }
2816 #[test]
2817 fn a_failed_conversion_returns_the_session_to_its_checkout() {
2818 let previous = managed_raw_session(ManagedWorktreeTarget::Local);
2819 let mut converted = previous.clone();
2820 converted.state = SessionState::Provisioning;
2821 converted.target_template_id = "podman".into();
2822 apply_raw_to_workspace(
2823 &mut converted,
2824 &RawToWorkspaceConversion {
2825 checkout: previous.project_directory.clone().unwrap(),
2826 repository: PathBuf::from("/home/dev/project"),
2827 bundle_id: "project".into(),
2828 new_bundle: None,
2829 retire: previous.managed_worktree.clone(),
2830 },
2831 );
2832
2833 let mut cleaned = converted.clone();
2834 apply_failed_resume_rollback(&mut cleaned, &previous, "podman is unavailable", None);
2835 assert_eq!(cleaned.project_directory, previous.project_directory);
2836 assert_eq!(cleaned.managed_worktree, previous.managed_worktree);
2837 assert_eq!(cleaned.bundle_id, previous.bundle_id);
2838
2839 let mut stranded = converted;
2842 apply_failed_resume_rollback(
2843 &mut stranded,
2844 &previous,
2845 "podman is unavailable",
2846 Some("podman rm failed".into()),
2847 );
2848 assert_eq!(stranded.state, SessionState::Error);
2849 assert_eq!(stranded.project_directory, previous.project_directory);
2850 assert_eq!(stranded.managed_worktree, previous.managed_worktree);
2851 assert_eq!(stranded.bundle_id, previous.bundle_id);
2852 }
2853 #[test]
2854 fn cancelled_new_session_cleanup_removes_managed_worktree_and_branch() {
2855 let repository = committed_repository();
2856 let session_id = "0123456789abcdef0123456789abcdef";
2857 let worktree = ManagedWorktree {
2858 source_project_directory: repository.path().to_path_buf(),
2859 source_repository: repository.path().to_path_buf(),
2860 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2861 branch: format!("mj/{session_id}"),
2862 target: ManagedWorktreeTarget::Local,
2863 base_commit: None,
2864 };
2865 create_managed_worktree(
2866 &ProcessExecutor,
2867 &worktree,
2868 None,
2869 PrimaryCheckoutRequirement::Clean,
2870 )
2871 .unwrap();
2872
2873 let mut session = checkpoint_test_session(session_id);
2874 session.project_directory = Some(worktree.worktree_root.clone());
2875 session.managed_worktree = Some(worktree.clone());
2876 let controller = Controller {
2877 config: Config::default(),
2878 state: State {
2879 sessions: BTreeMap::from([(session_id.into(), session)]),
2880 ..State::default()
2881 },
2882 };
2883 let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2884 let executor = CancellableProcessExecutor::new(cancelled);
2885
2886 controller
2887 .cleanup_new_session_worktree_after_failure(session_id, &executor)
2888 .unwrap();
2889
2890 assert!(!worktree.worktree_root.exists());
2891 assert!(!repository.path().join(".mj").exists());
2892 let branch = Command::new("git")
2893 .arg("-C")
2894 .arg(repository.path())
2895 .args([
2896 "show-ref",
2897 "--verify",
2898 &format!("refs/heads/{}", worktree.branch),
2899 ])
2900 .output()
2901 .unwrap();
2902 assert!(!branch.status.success());
2903 }
2904 #[test]
2905 fn managed_raw_worktree_refuses_dirty_primary_and_skips_existing_worktree() {
2906 let repository = committed_repository();
2907 std::fs::write(repository.path().join("dirty.txt"), "dirty\n").unwrap();
2908 let target = ManagedWorktreeTarget::Local;
2909 let inspection = inspect_raw_project(&ProcessExecutor, &target, repository.path()).unwrap();
2910 let session_id = "fedcba9876543210fedcba9876543210";
2911 let managed = ManagedWorktree {
2912 source_project_directory: inspection.source_project_directory,
2913 source_repository: inspection.source_repository,
2914 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2915 branch: format!("mj/{session_id}"),
2916 target: target.clone(),
2917 base_commit: None,
2918 };
2919 let error = create_managed_worktree(
2920 &ProcessExecutor,
2921 &managed,
2922 None,
2923 PrimaryCheckoutRequirement::Clean,
2924 )
2925 .unwrap_err();
2926 assert!(error.to_string().contains("uncommitted changes"));
2927 assert!(!managed.worktree_root.exists());
2928
2929 std::fs::remove_file(repository.path().join("dirty.txt")).unwrap();
2930 let existing = repository.path().join("existing-worktree");
2931 test_git(
2932 repository.path(),
2933 &[
2934 "worktree",
2935 "add",
2936 "--detach",
2937 &existing.to_string_lossy(),
2938 "HEAD",
2939 ],
2940 );
2941 let linked = inspect_raw_project(&ProcessExecutor, &target, &existing).unwrap();
2942 assert!(!linked.primary_checkout);
2943 }
2944 #[test]
2945 fn managed_worktree_preflight_preserves_colliding_branch_and_directory() {
2946 let repository = committed_repository();
2947 let target = ManagedWorktreeTarget::Local;
2948 let session_id = "abcdef0123456789abcdef0123456789";
2949 let branch = format!("mj/{session_id}");
2950 test_git(repository.path(), &["branch", &branch]);
2951 let worktree = ManagedWorktree {
2952 source_project_directory: repository.path().to_path_buf(),
2953 source_repository: repository.path().to_path_buf(),
2954 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2955 branch: branch.clone(),
2956 target,
2957 base_commit: None,
2958 };
2959
2960 let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2961 assert!(error.to_string().contains("branch already exists"));
2962 assert!(
2963 !test_git(
2964 repository.path(),
2965 &["show-ref", "--verify", &format!("refs/heads/{branch}")]
2966 )
2967 .is_empty()
2968 );
2969 std::fs::create_dir_all(&worktree.worktree_root).unwrap();
2970 let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2971 assert!(error.to_string().contains("path already exists"));
2972 assert!(worktree.worktree_root.is_dir());
2973 }
2974 #[test]
2975 fn managed_worktree_ssh_commands_preserve_hostile_path_boundaries() {
2976 let target = ManagedWorktreeTarget::Ssh {
2977 destination: "builder".into(),
2978 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2979 };
2980 let command = managed_git_command(
2981 &target,
2982 Path::new("/srv/project with ' quote"),
2983 ["worktree", "prune"],
2984 "prune test",
2985 );
2986 assert_eq!(command.program, "ssh");
2987 assert_eq!(&command.args[..3], ["-o", "BatchMode=yes", "builder"]);
2988 assert_eq!(
2989 command.args[3],
2990 "'git' '-C' '/srv/project with '\\'' quote' 'worktree' 'prune'"
2991 );
2992 }
2993}