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