1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use anyhow::{Context, Result, bail, ensure};
7
8use hel::hel_config::{HelConfig, ProjectBundle, TargetTemplate, is_bare_project_target};
9use hel::hel_local_git::canonical_repository;
10use hel::hel_state::{
11 ManagedWorktree, ManagedWorktreeTarget, ProjectSourceIdentity, SessionRecord,
12};
13use hel::hel_targets::{
14 self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
15};
16
17use super::{Controller, backend_ssh, execute_checked, now, ssh_command_spec};
18
19impl Controller {
20 pub fn validate_project_directory(
22 &self,
23 target_id: &str,
24 directory: &Path,
25 executor: &impl CommandExecutor,
26 ) -> Result<()> {
27 let target = self
28 .config
29 .targets
30 .get(target_id)
31 .with_context(|| format!("unknown target template {target_id:?}"))?;
32 match target {
33 TargetTemplate::LocalBare => {
34 ensure!(
35 directory.is_dir(),
36 "project directory does not exist or is not a directory"
37 );
38 let output = executor.execute(
39 &CommandSpec::new(
40 "git",
41 [
42 "-C",
43 &directory.to_string_lossy(),
44 "rev-parse",
45 "--verify",
46 "HEAD",
47 ],
48 )
49 .purpose("verify local bare Git project"),
50 )?;
51 ensure!(
52 output.status == 0
53 && !String::from_utf8_lossy(&output.stdout).trim().is_empty(),
54 "project directory has no valid Git HEAD: {}",
55 String::from_utf8_lossy(&output.stderr).trim()
56 );
57 Ok(())
58 }
59 TargetTemplate::SshBare { ssh, .. } => {
60 hel_targets::validate_bare_project_directory(&backend_ssh(ssh), directory, executor)
61 }
62 _ => bail!("project directory validation requires a bare target"),
63 }
64 }
65
66 pub fn resolve_session_project_source(
70 &self,
71 session_id: &str,
72 executor: &impl CommandExecutor,
73 ) -> Result<ProjectSourceIdentity> {
74 let session = self
75 .state
76 .sessions
77 .get(session_id)
78 .with_context(|| format!("unknown session {session_id}"))?;
79 let Some(directory) = session.project_directory.as_deref() else {
80 return Ok(session.project_source(&self.config));
81 };
82 let (target, origin_directory) = match &session.managed_worktree {
83 Some(worktree) => (
87 worktree.target.clone(),
88 worktree.source_repository.as_path(),
89 ),
90 None => (
91 managed_worktree_target(
92 self.config
93 .targets
94 .get(&session.target_template_id)
95 .with_context(|| {
96 format!(
97 "session {session_id} target {:?} is no longer configured",
98 session.target_template_id
99 )
100 })?,
101 )?,
102 directory,
103 ),
104 };
105 let output = executor.execute(&managed_git_command(
106 &target,
107 origin_directory,
108 ["config", "--get", "remote.origin.url"],
109 "resolve project Git origin",
110 ))?;
111 match output.status {
112 0 => {
113 let origin =
114 String::from_utf8(output.stdout).context("project Git origin was not UTF-8")?;
115 Ok(ProjectSourceIdentity::git_remote(origin.trim())
116 .unwrap_or_else(|| session.project_source(&self.config)))
117 }
118 1 => Ok(session.project_source(&self.config)),
120 status => bail!(
121 "resolve project Git origin failed with status {status}: {}",
122 String::from_utf8_lossy(&output.stderr).trim()
123 ),
124 }
125 }
126
127 pub(super) fn plan_workspace_to_raw(
130 &self,
131 session: &SessionRecord,
132 target_id: &str,
133 executor: &impl CommandExecutor,
134 ) -> Result<WorkspaceToRawConversion> {
135 let bundle = self
136 .config
137 .bundles
138 .get(&session.bundle_id)
139 .context("session bundle is missing")?;
140 let [repository] = bundle.repositories.as_slice() else {
141 bail!("a checkout holds exactly one repository");
142 };
143 let source = repository
144 .local
145 .as_deref()
146 .context("only a repository already on this machine can become a checkout")?;
147 self.validate_project_directory(target_id, source, executor)
148 .context("this session's repository is unavailable")?;
149 let worktree = ManagedWorktree {
150 source_project_directory: source.to_path_buf(),
151 source_repository: source.to_path_buf(),
152 worktree_root: source.join(".mj").join("worktrees").join(&session.id),
153 branch: format!("mj/{}", session.id),
154 target: managed_worktree_target(
155 self.config
156 .targets
157 .get(target_id)
158 .with_context(|| format!("unknown target template {target_id:?}"))?,
159 )?,
160 };
161 ensure_managed_worktree_available(executor, &worktree)?;
162 Ok(WorkspaceToRawConversion { worktree })
163 }
164
165 pub(super) fn prepare_managed_raw_worktree(
166 &mut self,
167 session_id: &str,
168 executor: &impl CommandExecutor,
169 ) -> Result<bool> {
170 let session = self
171 .state
172 .sessions
173 .get(session_id)
174 .with_context(|| format!("unknown session {session_id}"))?
175 .clone();
176 let Some(selected) = session.project_directory.as_deref() else {
177 return Ok(false);
178 };
179 if session.managed_worktree.is_some() {
180 return Ok(false);
181 }
182 let template = self
183 .config
184 .targets
185 .get(&session.target_template_id)
186 .context("raw session target template disappeared during provisioning")?;
187 let target = managed_worktree_target(template)?;
188 let inspection = inspect_raw_project(executor, &target, selected)?;
189 if !inspection.primary_checkout {
190 return Ok(false);
191 }
192 let relative_directory = inspection
193 .source_project_directory
194 .strip_prefix(&inspection.source_repository)
195 .context("raw project directory is outside its repository")?
196 .to_path_buf();
197 let worktree_root = inspection
198 .source_repository
199 .join(".mj")
200 .join("worktrees")
201 .join(session_id);
202 let managed = ManagedWorktree {
203 source_project_directory: inspection.source_project_directory,
204 source_repository: inspection.source_repository,
205 worktree_root: worktree_root.clone(),
206 branch: format!("mj/{session_id}"),
207 target,
208 };
209 ensure_managed_worktree_available(executor, &managed)?;
210 let record = self.state.sessions.get_mut(session_id).unwrap();
211 record.project_directory = Some(worktree_root.join(relative_directory));
212 record.managed_worktree = Some(managed.clone());
213 record.updated_at = now();
214 self.persist_session_state(session_id)?;
215 create_managed_worktree(
216 executor,
217 &managed,
218 inspection.upstream.as_deref(),
219 PrimaryCheckoutRequirement::Clean,
220 )?;
221 Ok(true)
222 }
223
224 fn cleanup_new_session_worktree(
225 &self,
226 session_id: &str,
227 executor: &impl CommandExecutor,
228 ) -> Result<()> {
229 let Some(worktree) = self
230 .state
231 .sessions
232 .get(session_id)
233 .and_then(|session| session.managed_worktree.as_ref())
234 else {
235 return Ok(());
236 };
237 cleanup_managed_worktree(executor, worktree)
238 }
239
240 pub(super) fn cleanup_new_session_worktree_after_failure(
241 &self,
242 session_id: &str,
243 executor: &impl CommandExecutor,
244 ) -> Result<()> {
245 if executor.cancellation_requested() {
246 let cleanup_executor =
247 CancellableProcessExecutor::with_timeout(Duration::from_secs(15));
248 self.cleanup_new_session_worktree(session_id, &cleanup_executor)
249 } else {
250 self.cleanup_new_session_worktree(session_id, executor)
251 }
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256struct RawProjectInspection {
257 source_project_directory: PathBuf,
258 source_repository: PathBuf,
259 primary_checkout: bool,
260 upstream: Option<String>,
261}
262
263pub(super) fn managed_worktree_target(template: &TargetTemplate) -> Result<ManagedWorktreeTarget> {
264 match template {
265 TargetTemplate::LocalBare => Ok(ManagedWorktreeTarget::Local),
266 TargetTemplate::SshBare { ssh, .. } => {
267 let ssh = backend_ssh(ssh);
268 Ok(ManagedWorktreeTarget::Ssh {
269 destination: ssh.destination,
270 ssh_args: ssh.ssh_args,
271 })
272 }
273 _ => bail!("managed raw worktrees require a bare target"),
274 }
275}
276
277fn managed_target_ssh(target: &ManagedWorktreeTarget) -> Option<SshTarget> {
278 match target {
279 ManagedWorktreeTarget::Local => None,
280 ManagedWorktreeTarget::Ssh {
281 destination,
282 ssh_args,
283 } => Some(SshTarget {
284 destination: destination.clone(),
285 ssh_args: ssh_args.clone(),
286 }),
287 }
288}
289
290fn managed_target_command(
291 target: &ManagedWorktreeTarget,
292 program: &str,
293 args: impl IntoIterator<Item = impl AsRef<str>>,
294) -> CommandSpec {
295 let args = args
296 .into_iter()
297 .map(|arg| arg.as_ref().to_owned())
298 .collect::<Vec<_>>();
299 match managed_target_ssh(target) {
300 None => CommandSpec::new(program, args),
301 Some(ssh) => {
302 let mut remote = vec![program.to_owned()];
303 remote.extend(args);
304 ssh_command_spec(&ssh, remote)
305 }
306 }
307}
308
309fn managed_git_command(
310 target: &ManagedWorktreeTarget,
311 directory: &Path,
312 args: impl IntoIterator<Item = impl AsRef<str>>,
313 purpose: impl Into<String>,
314) -> CommandSpec {
315 let mut command_args = vec!["-C".to_owned(), directory.to_string_lossy().into_owned()];
316 command_args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
317 managed_target_command(target, "git", command_args).purpose(purpose)
318}
319
320fn command_stdout(output: CommandOutput, purpose: &str) -> Result<String> {
321 if output.status != 0 {
322 bail!(
323 "{purpose} failed with status {}: {}",
324 output.status,
325 String::from_utf8_lossy(&output.stderr).trim()
326 );
327 }
328 let stdout = String::from_utf8(output.stdout)
329 .with_context(|| format!("{purpose} produced non-UTF-8 output"))?;
330 Ok(stdout.trim_end_matches(['\r', '\n']).to_owned())
331}
332
333fn managed_git_stdout(
334 executor: &impl CommandExecutor,
335 target: &ManagedWorktreeTarget,
336 directory: &Path,
337 args: impl IntoIterator<Item = impl AsRef<str>>,
338 purpose: &str,
339) -> Result<String> {
340 let command = managed_git_command(target, directory, args, purpose);
341 command_stdout(executor.execute(&command)?, purpose)
342}
343
344pub(super) fn apply_raw_to_workspace(
349 record: &mut SessionRecord,
350 conversion: &RawToWorkspaceConversion,
351) {
352 record.project_directory = None;
353 record.managed_worktree = None;
354 record.bundle_id.clone_from(&conversion.bundle_id);
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
360pub(super) enum ResumeConversion {
361 RawToWorkspace(RawToWorkspaceConversion),
362 WorkspaceToRaw(WorkspaceToRawConversion),
363}
364
365impl ResumeConversion {
366 pub(super) fn raw_to_workspace(&self) -> Option<&RawToWorkspaceConversion> {
367 match self {
368 Self::RawToWorkspace(conversion) => Some(conversion),
369 Self::WorkspaceToRaw(_) => None,
370 }
371 }
372
373 pub(super) fn workspace_to_raw(&self) -> Option<&WorkspaceToRawConversion> {
374 match self {
375 Self::WorkspaceToRaw(conversion) => Some(conversion),
376 Self::RawToWorkspace(_) => None,
377 }
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq)]
385pub(super) struct WorkspaceToRawConversion {
386 pub(super) worktree: ManagedWorktree,
387}
388
389pub(super) fn apply_workspace_to_raw(
392 record: &mut SessionRecord,
393 conversion: &WorkspaceToRawConversion,
394) {
395 record.project_directory = Some(conversion.worktree.worktree_root.clone());
396 record.managed_worktree = Some(conversion.worktree.clone());
397}
398
399#[derive(Debug, Clone, PartialEq, Eq)]
402pub(super) struct RawToWorkspaceConversion {
403 pub(super) checkout: PathBuf,
407 pub(super) repository: PathBuf,
409 pub(super) bundle_id: String,
410 pub(super) new_bundle: Option<ProjectBundle>,
412 pub(super) retire: Option<ManagedWorktree>,
414}
415
416pub(super) fn plan_raw_to_workspace(
419 session: &SessionRecord,
420 config: &HelConfig,
421 executor: &impl CommandExecutor,
422) -> Result<RawToWorkspaceConversion> {
423 let project_directory = session
424 .project_directory
425 .as_deref()
426 .context("a raw session has no project directory")?;
427 let (checkout, repository, retire) = match &session.managed_worktree {
433 Some(worktree) => {
434 ensure!(
435 worktree.worktree_root == project_directory,
436 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
437 project_directory.display()
438 );
439 (
440 worktree.worktree_root.clone(),
441 worktree.source_repository.clone(),
442 Some(worktree.clone()),
443 )
444 }
445 None => {
446 let inspection =
447 inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
448 ensure!(
449 inspection.source_project_directory == inspection.source_repository,
450 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
451 project_directory.display()
452 );
453 let repository = canonical_repository(&inspection.source_repository)?;
454 (inspection.source_repository, repository, None)
455 }
456 };
457 let destination = PathBuf::from(
461 project_directory
462 .file_name()
463 .context("a raw project directory cannot be the filesystem root")?,
464 );
465 let (bundle_id, new_bundle) =
466 converted_raw_bundle(config, &session.bundle_id, &repository, &destination);
467 Ok(RawToWorkspaceConversion {
468 checkout,
469 repository,
470 bundle_id,
471 new_bundle,
472 retire,
473 })
474}
475
476fn converted_raw_bundle(
480 config: &HelConfig,
481 session_bundle_id: &str,
482 repository: &Path,
483 destination: &Path,
484) -> (String, Option<ProjectBundle>) {
485 let describes_checkout = |bundle: &ProjectBundle| {
486 bundle.repositories.len() == 1
487 && bundle.repositories[0].github.is_none()
488 && bundle.repositories[0].local.as_deref() == Some(repository)
489 && bundle.repositories[0].destination == destination
490 };
491 if config
492 .bundles
493 .get(session_bundle_id)
494 .is_some_and(describes_checkout)
495 {
496 return (session_bundle_id.to_owned(), None);
497 }
498 if let Some((id, _)) = config
499 .bundles
500 .iter()
501 .find(|(_, bundle)| describes_checkout(bundle))
502 {
503 return (id.clone(), None);
504 }
505 let name = repository
506 .file_name()
507 .map(|name| name.to_string_lossy().into_owned())
508 .unwrap_or_default();
509 let id = crate::hel_import::unique_bundle_id(config, &crate::hel_import::setup_style_id(&name));
510 let bundle = ProjectBundle {
511 primary_repo: id.clone(),
512 repositories: vec![hel::hel_config::ProjectRepository {
513 id: id.clone(),
514 github: None,
515 local: Some(repository.to_path_buf()),
516 destination: destination.to_path_buf(),
517 git_ref: None,
518 }],
519 };
520 (id, Some(bundle))
521}
522
523#[derive(Debug, Clone, PartialEq, Eq)]
525pub(super) struct CheckoutPosition {
526 head_commit: String,
527 branch: Option<String>,
528}
529
530fn read_checkout_position(
531 executor: &impl CommandExecutor,
532 target: &ManagedWorktreeTarget,
533 directory: &Path,
534) -> Result<CheckoutPosition> {
535 let head_commit = managed_git_stdout(
536 executor,
537 target,
538 directory,
539 ["rev-parse", "HEAD"],
540 "resolve checkout head commit",
541 )?;
542 let branch_command = managed_git_command(
543 target,
544 directory,
545 ["symbolic-ref", "--quiet", "--short", "HEAD"],
546 "resolve checkout branch",
547 );
548 let branch_output = executor.execute(&branch_command)?;
549 let branch = match branch_output.status {
550 0 => Some(
551 String::from_utf8(branch_output.stdout)
552 .context("checkout branch was not UTF-8")?
553 .trim()
554 .to_owned(),
555 ),
556 1 | 128 => None,
558 status => bail!(
559 "resolve checkout branch failed with status {status}: {}",
560 String::from_utf8_lossy(&branch_output.stderr).trim()
561 ),
562 };
563 Ok(CheckoutPosition {
564 head_commit,
565 branch,
566 })
567}
568
569pub(super) fn raw_checkout_position(
572 session: &SessionRecord,
573 config: &HelConfig,
574 project_directory: &Path,
575 executor: &impl CommandExecutor,
576) -> Result<CheckoutPosition> {
577 let target = match &session.managed_worktree {
578 Some(worktree) => worktree.target.clone(),
579 None => {
580 let template = config
581 .targets
582 .get(&session.target_template_id)
583 .context("the bare target this session last used is missing")?;
584 managed_worktree_target(template)?
585 }
586 };
587 read_checkout_position(executor, &target, project_directory)
588}
589
590pub(super) fn raw_checkout_divergence_notice(
596 directory: &Path,
597 recorded: Option<&hel::hel_archive::RepositoryMetadata>,
598 live: &CheckoutPosition,
599) -> Option<String> {
600 let recorded = recorded?;
601 if recorded.head_commit.is_empty()
602 || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
603 {
604 return None;
605 }
606 Some(format!(
607 "The working tree at {} moved from {} to {} while this session was stopped.",
608 directory.display(),
609 checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
610 checkout_position_text(&live.head_commit, live.branch.as_deref()),
611 ))
612}
613
614fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
615 let short = head_commit.get(..12).unwrap_or(head_commit);
616 match branch {
617 Some(branch) => format!("{short} ({branch})"),
618 None => format!("{short} (detached)"),
619 }
620}
621
622fn inspect_raw_project(
623 executor: &impl CommandExecutor,
624 target: &ManagedWorktreeTarget,
625 selected: &Path,
626) -> Result<RawProjectInspection> {
627 let repository = PathBuf::from(managed_git_stdout(
628 executor,
629 target,
630 selected,
631 ["rev-parse", "--path-format=absolute", "--show-toplevel"],
632 "resolve raw project repository root",
633 )?);
634 let prefix = managed_git_stdout(
635 executor,
636 target,
637 selected,
638 ["rev-parse", "--show-prefix"],
639 "resolve raw project relative directory",
640 )?;
641 let git_dir = PathBuf::from(managed_git_stdout(
642 executor,
643 target,
644 selected,
645 ["rev-parse", "--absolute-git-dir"],
646 "resolve raw project Git directory",
647 )?);
648 let common_git_dir = PathBuf::from(managed_git_stdout(
649 executor,
650 target,
651 selected,
652 ["rev-parse", "--path-format=absolute", "--git-common-dir"],
653 "resolve raw project common Git directory",
654 )?);
655 let branch_command = managed_git_command(
656 target,
657 selected,
658 ["symbolic-ref", "--quiet", "--short", "HEAD"],
659 "resolve raw project branch",
660 );
661 let branch_output = executor.execute(&branch_command)?;
662 let branch = match branch_output.status {
663 0 => Some(
664 String::from_utf8(branch_output.stdout)
665 .context("raw project branch was not UTF-8")?
666 .trim()
667 .to_owned(),
668 ),
669 1 | 128 => None,
670 status => bail!(
671 "resolve raw project branch failed with status {status}: {}",
672 String::from_utf8_lossy(&branch_output.stderr).trim()
673 ),
674 };
675 let upstream = match branch {
676 Some(branch) => {
677 let reference = format!("refs/heads/{branch}");
678 let upstream = managed_git_stdout(
679 executor,
680 target,
681 selected,
682 ["for-each-ref", "--format=%(upstream:short)", &reference],
683 "resolve raw project upstream",
684 )?;
685 (!upstream.is_empty()).then_some(upstream)
686 }
687 None => None,
688 };
689 Ok(RawProjectInspection {
690 source_project_directory: repository.join(prefix),
691 source_repository: repository,
692 primary_checkout: git_dir == common_git_dir,
693 upstream,
694 })
695}
696
697fn ensure_managed_worktree_excluded(
698 executor: &impl CommandExecutor,
699 target: &ManagedWorktreeTarget,
700 repository: &Path,
701) -> Result<()> {
702 let check = managed_git_command(
703 target,
704 repository,
705 [
706 "check-ignore",
707 "--quiet",
708 "--no-index",
709 "--",
710 ".mj/worktrees/",
711 ],
712 "check managed worktree exclusion",
713 );
714 let output = executor.execute(&check)?;
715 match output.status {
716 0 => return Ok(()),
717 1 => {}
718 status => bail!(
719 "check managed worktree exclusion failed with status {status}: {}",
720 String::from_utf8_lossy(&output.stderr).trim()
721 ),
722 }
723 let exclude_path = PathBuf::from(managed_git_stdout(
724 executor,
725 target,
726 repository,
727 [
728 "rev-parse",
729 "--path-format=absolute",
730 "--git-path",
731 "info/exclude",
732 ],
733 "resolve repository-local exclude file",
734 )?);
735 const ENTRY: &str = "/.mj/worktrees/";
736 match target {
737 ManagedWorktreeTarget::Local => {
738 use std::io::Write;
739 let existing = match std::fs::read_to_string(&exclude_path) {
740 Ok(existing) => existing,
741 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
742 Err(error) => return Err(error.into()),
743 };
744 if existing.lines().any(|line| line.trim() == ENTRY) {
745 return Ok(());
746 }
747 if let Some(parent) = exclude_path.parent() {
748 std::fs::create_dir_all(parent)?;
749 }
750 let mut file = std::fs::OpenOptions::new()
751 .create(true)
752 .append(true)
753 .open(&exclude_path)
754 .with_context(|| format!("open {}", exclude_path.display()))?;
755 if !existing.is_empty() && !existing.ends_with('\n') {
756 writeln!(file)?;
757 }
758 writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
759 }
760 ManagedWorktreeTarget::Ssh { .. } => {
761 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";
762 let command = managed_target_command(
763 target,
764 "sh",
765 [
766 "-c",
767 SCRIPT,
768 "hel-exclude",
769 &exclude_path.to_string_lossy(),
770 ENTRY,
771 ],
772 )
773 .purpose("update remote repository-local exclude file");
774 execute_checked(executor, command)?;
775 }
776 }
777 Ok(())
778}
779
780fn path_exists_on_managed_target(
781 executor: &impl CommandExecutor,
782 target: &ManagedWorktreeTarget,
783 path: &Path,
784) -> Result<bool> {
785 match target {
786 ManagedWorktreeTarget::Local => Ok(path.exists()),
787 ManagedWorktreeTarget::Ssh { .. } => {
788 let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
789 .purpose("check managed worktree path");
790 let output = executor.execute(&command)?;
791 match output.status {
792 0 => Ok(true),
793 1 => Ok(false),
794 status => bail!(
795 "check managed worktree path failed with status {status}: {}",
796 String::from_utf8_lossy(&output.stderr).trim()
797 ),
798 }
799 }
800 }
801}
802
803pub(super) fn managed_worktree_checkout_exists(
804 executor: &impl CommandExecutor,
805 worktree: &ManagedWorktree,
806) -> Result<bool> {
807 path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
808}
809
810#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812pub(super) enum PrimaryCheckoutRequirement {
813 Clean,
816 Any,
820}
821
822pub(super) fn create_managed_worktree(
823 executor: &impl CommandExecutor,
824 worktree: &ManagedWorktree,
825 upstream: Option<&str>,
826 requirement: PrimaryCheckoutRequirement,
827) -> Result<()> {
828 ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
829 if requirement == PrimaryCheckoutRequirement::Clean {
830 let status = managed_git_stdout(
831 executor,
832 &worktree.target,
833 &worktree.source_repository,
834 ["status", "--porcelain=v1", "--untracked-files=all"],
835 "inspect primary checkout changes",
836 )?;
837 if !status.is_empty() {
838 let paths = status.lines().take(20).collect::<Vec<_>>().join("\n ");
839 bail!(
840 "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n {paths}"
841 );
842 }
843 }
844 let parent = worktree
845 .worktree_root
846 .parent()
847 .context("managed worktree root has no parent")?;
848 execute_checked(
849 executor,
850 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
851 .purpose("create managed worktree directory"),
852 )?;
853 execute_checked(
854 executor,
855 managed_git_command(
856 &worktree.target,
857 &worktree.source_repository,
858 [
859 "worktree",
860 "add",
861 "-b",
862 &worktree.branch,
863 &worktree.worktree_root.to_string_lossy(),
864 "HEAD",
865 ],
866 "create managed raw-session worktree",
867 ),
868 )?;
869 if let Some(upstream) = upstream {
870 execute_checked(
871 executor,
872 managed_git_command(
873 &worktree.target,
874 &worktree.worktree_root,
875 ["branch", "--set-upstream-to", upstream, &worktree.branch],
876 "set managed worktree branch upstream",
877 ),
878 )?;
879 }
880 Ok(())
881}
882
883pub(super) fn restore_managed_worktree(
887 executor: &impl CommandExecutor,
888 worktree: &ManagedWorktree,
889) -> Result<bool> {
890 if managed_worktree_checkout_exists(executor, worktree)? {
891 return Ok(false);
892 }
893 ensure!(
894 path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
895 "managed worktree source repository is unavailable: {}",
896 worktree.source_repository.display()
897 );
898 let branch_ref = format!("refs/heads/{}", worktree.branch);
899 let check = managed_git_command(
900 &worktree.target,
901 &worktree.source_repository,
902 ["show-ref", "--verify", "--quiet", &branch_ref],
903 "check retired managed worktree branch",
904 );
905 let output = executor.execute(&check)?;
906 match output.status {
907 0 => {}
908 1 => bail!(
909 "managed worktree branch is unavailable: {}",
910 worktree.branch
911 ),
912 status => bail!(
913 "check retired managed worktree branch failed with status {status}: {}",
914 String::from_utf8_lossy(&output.stderr).trim()
915 ),
916 }
917 execute_checked(
920 executor,
921 managed_git_command(
922 &worktree.target,
923 &worktree.source_repository,
924 ["worktree", "prune"],
925 "prune retired managed worktree metadata",
926 ),
927 )?;
928 let parent = worktree
929 .worktree_root
930 .parent()
931 .context("managed worktree root has no parent")?;
932 execute_checked(
933 executor,
934 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
935 .purpose("recreate managed worktree directory"),
936 )?;
937 execute_checked(
938 executor,
939 managed_git_command(
940 &worktree.target,
941 &worktree.source_repository,
942 [
943 "worktree",
944 "add",
945 "--",
946 &worktree.worktree_root.to_string_lossy(),
947 &worktree.branch,
948 ],
949 "restore managed raw-session worktree",
950 ),
951 )?;
952 Ok(true)
953}
954
955fn ensure_managed_worktree_available(
956 executor: &impl CommandExecutor,
957 worktree: &ManagedWorktree,
958) -> Result<()> {
959 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
960 bail!(
961 "managed worktree path already exists: {}",
962 worktree.worktree_root.display()
963 );
964 }
965 let branch_ref = format!("refs/heads/{}", worktree.branch);
966 let check = managed_git_command(
967 &worktree.target,
968 &worktree.source_repository,
969 ["show-ref", "--verify", "--quiet", &branch_ref],
970 "check managed worktree branch availability",
971 );
972 let output = executor.execute(&check)?;
973 match output.status {
974 0 => bail!(
975 "managed worktree branch already exists: {}",
976 worktree.branch
977 ),
978 1 => Ok(()),
979 status => bail!(
980 "check managed worktree branch availability failed with status {status}: {}",
981 String::from_utf8_lossy(&output.stderr).trim()
982 ),
983 }
984}
985
986pub(super) fn retire_managed_worktree(
993 executor: &impl CommandExecutor,
994 worktree: &ManagedWorktree,
995) -> Result<()> {
996 if !remove_managed_worktree_checkout(executor, worktree)? {
997 return Ok(());
998 }
999 remove_empty_managed_worktree_directories(executor, worktree)
1000}
1001
1002fn remove_managed_worktree_checkout(
1005 executor: &impl CommandExecutor,
1006 worktree: &ManagedWorktree,
1007) -> Result<bool> {
1008 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1009 return Ok(false);
1010 }
1011 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1012 execute_checked(
1013 executor,
1014 managed_git_command(
1015 &worktree.target,
1016 &worktree.source_repository,
1017 [
1018 "worktree",
1019 "remove",
1020 "--force",
1021 &worktree.worktree_root.to_string_lossy(),
1022 ],
1023 "remove managed raw-session worktree",
1024 ),
1025 )?;
1026 }
1027 execute_checked(
1028 executor,
1029 managed_git_command(
1030 &worktree.target,
1031 &worktree.source_repository,
1032 ["worktree", "prune"],
1033 "prune managed worktree metadata",
1034 ),
1035 )?;
1036 Ok(true)
1037}
1038
1039pub(super) fn cleanup_managed_worktree(
1040 executor: &impl CommandExecutor,
1041 worktree: &ManagedWorktree,
1042) -> Result<()> {
1043 if !remove_managed_worktree_checkout(executor, worktree)? {
1044 return Ok(());
1045 }
1046 let branch_ref = format!("refs/heads/{}", worktree.branch);
1047 let check = managed_git_command(
1048 &worktree.target,
1049 &worktree.source_repository,
1050 ["show-ref", "--verify", "--quiet", &branch_ref],
1051 "check managed worktree branch",
1052 );
1053 let output = executor.execute(&check)?;
1054 match output.status {
1055 0 => {
1056 execute_checked(
1057 executor,
1058 managed_git_command(
1059 &worktree.target,
1060 &worktree.source_repository,
1061 ["branch", "-D", "--", &worktree.branch],
1062 "delete managed raw-session branch",
1063 ),
1064 )?;
1065 }
1066 1 => {}
1067 status => bail!(
1068 "check managed worktree branch failed with status {status}: {}",
1069 String::from_utf8_lossy(&output.stderr).trim()
1070 ),
1071 }
1072 remove_empty_managed_worktree_directories(executor, worktree)
1073}
1074
1075fn remove_empty_managed_worktree_directories(
1076 executor: &impl CommandExecutor,
1077 worktree: &ManagedWorktree,
1078) -> Result<()> {
1079 let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1080 let hel = worktree.source_repository.join(".mj");
1081 match &worktree.target {
1082 ManagedWorktreeTarget::Local => {
1083 for directory in [&worktrees, &hel] {
1084 match std::fs::remove_dir(directory) {
1085 Ok(()) => {}
1086 Err(error)
1087 if matches!(
1088 error.kind(),
1089 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1090 ) => {}
1091 Err(error) => return Err(error.into()),
1092 }
1093 }
1094 }
1095 ManagedWorktreeTarget::Ssh { .. } => {
1096 let command = managed_target_command(
1097 &worktree.target,
1098 "rmdir",
1099 ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1100 )
1101 .purpose("remove empty managed worktree directories");
1102 let _ = executor.execute(&command)?;
1103 }
1104 }
1105 Ok(())
1106}
1107
1108const BUNDLE_ON_LOCAL_BARE: &str = "this session was created from a project bundle; a local bare target only hosts raw project sessions — resume it on a container, SSH, or EC2 target";
1111
1112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1114pub enum ResumePlan {
1115 InPlace,
1117 RawToWorkspace,
1119 WorkspaceToRaw,
1121}
1122
1123pub fn resume_compatibility(
1130 session: &SessionRecord,
1131 config: &HelConfig,
1132 target_id: &str,
1133) -> Result<ResumePlan, String> {
1134 let Some(target) = config.targets.get(target_id) else {
1135 return Err(format!("target {target_id} is no longer configured"));
1136 };
1137 let Some(project_directory) = &session.project_directory else {
1138 if matches!(target, TargetTemplate::LocalBare) {
1139 return workspace_to_raw_compatibility(session, config);
1140 }
1141 return Ok(ResumePlan::InPlace);
1142 };
1143 let directory = project_directory.display();
1144 let Some(worktree) = &session.managed_worktree else {
1145 let Some(previous) = config.targets.get(&session.target_template_id) else {
1146 return Err(
1147 "the bare target this session last used is no longer configured".to_owned(),
1148 );
1149 };
1150 if is_bare_project_target(target) {
1151 if matches!(previous, TargetTemplate::LocalBare)
1152 == matches!(target, TargetTemplate::LocalBare)
1153 {
1154 return Ok(ResumePlan::InPlace);
1155 }
1156 return Err(format!(
1157 "this session opens {directory} directly on its host; resume it on the same kind of bare target"
1158 ));
1159 }
1160 if matches!(previous, TargetTemplate::LocalBare) {
1163 return Ok(ResumePlan::RawToWorkspace);
1164 }
1165 return Err(format!(
1166 "this session opens {directory} on an SSH host; resume it on a bare target there"
1167 ));
1168 };
1169 match managed_worktree_target(target) {
1170 Ok(resume_target) if resume_target == worktree.target => Ok(ResumePlan::InPlace),
1171 Ok(_) => Err(format!(
1172 "this session's working tree lives on {}; resume it there",
1173 managed_worktree_location(&worktree.target)
1174 )),
1175 Err(_) if worktree.target != ManagedWorktreeTarget::Local => Err(format!(
1176 "this session works directly in {directory} on {}; resume it on a bare target there",
1177 managed_worktree_location(&worktree.target)
1178 )),
1179 Err(_) if Some(&worktree.worktree_root) == session.project_directory.as_ref() => {
1183 Ok(ResumePlan::RawToWorkspace)
1184 }
1185 Err(_) => Err(format!(
1186 "this session opens {directory}, a subdirectory of its checkout; resume it on a bare target"
1187 )),
1188 }
1189}
1190
1191fn workspace_to_raw_compatibility(
1194 session: &SessionRecord,
1195 config: &HelConfig,
1196) -> Result<ResumePlan, String> {
1197 let Some(bundle) = config.bundles.get(&session.bundle_id) else {
1198 return Err(BUNDLE_ON_LOCAL_BARE.to_owned());
1199 };
1200 let [repository] = bundle.repositories.as_slice() else {
1201 return Err(format!(
1202 "this session's project has {} repositories; a local bare target holds one checkout — resume it on a container, SSH, or EC2 target",
1203 bundle.repositories.len()
1204 ));
1205 };
1206 if repository.local.is_none() {
1207 return Err(
1208 "this session's project came from GitHub; resume it on a container, SSH, or EC2 target"
1209 .to_owned(),
1210 );
1211 }
1212 Ok(ResumePlan::WorkspaceToRaw)
1213}
1214
1215fn managed_worktree_location(target: &ManagedWorktreeTarget) -> String {
1217 match target {
1218 ManagedWorktreeTarget::Local => "this machine".to_owned(),
1219 ManagedWorktreeTarget::Ssh { destination, .. } => destination.clone(),
1220 }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use std::cell::RefCell;
1226 use std::collections::BTreeMap;
1227 use std::path::{Path, PathBuf};
1228 use std::process::Command;
1229
1230 use anyhow::Result;
1231
1232 use crate::hel_controller::Controller;
1233 use crate::hel_controller::resume::apply_failed_resume_rollback;
1234 use crate::hel_controller::test_support::{
1235 checkpoint_test_session, committed_repository, local_bundle, managed_raw_session,
1236 managed_worktree_session, raw_session_on, resume_compatibility_config, ssh_worktree_target,
1237 test_git,
1238 };
1239 use hel::hel_archive::RepositoryMetadata;
1240 use hel::hel_config::{
1241 HarnessProfile, HelConfig, ProjectBundle, ProjectRepository, TargetTemplate,
1242 };
1243 use hel::hel_state::{HelState, ManagedWorktree, ManagedWorktreeTarget, SessionState};
1244 use hel::hel_targets::{
1245 CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
1246 };
1247
1248 use super::*;
1249
1250 #[test]
1251 fn local_bare_project_validation_runs_git_in_the_selected_directory() {
1252 struct GitExecutor {
1253 commands: RefCell<Vec<CommandSpec>>,
1254 }
1255 impl CommandExecutor for GitExecutor {
1256 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1257 self.commands.borrow_mut().push(command.clone());
1258 Ok(CommandOutput {
1259 status: 0,
1260 stdout: b"true\n".to_vec(),
1261 stderr: Vec::new(),
1262 })
1263 }
1264 }
1265
1266 let project = tempfile::tempdir().unwrap();
1267 let mut config = HelConfig::default();
1268 config
1269 .targets
1270 .insert("localhost".into(), TargetTemplate::LocalBare);
1271 let controller = Controller {
1272 config,
1273 state: HelState::default(),
1274 };
1275 let executor = GitExecutor {
1276 commands: RefCell::new(Vec::new()),
1277 };
1278
1279 controller
1280 .validate_project_directory("localhost", project.path(), &executor)
1281 .unwrap();
1282 let commands = executor.commands.borrow();
1283 assert_eq!(commands.len(), 1);
1284 assert_eq!(commands[0].program, "git");
1285 assert_eq!(commands[0].args[0], "-C");
1286 assert_eq!(commands[0].args[1], project.path().to_string_lossy());
1287 assert_eq!(commands[0].args[2..], ["rev-parse", "--verify", "HEAD"]);
1288 }
1289
1290 #[test]
1291 fn raw_linked_worktree_origin_matches_the_configured_github_project() {
1292 struct OriginExecutor;
1293 impl CommandExecutor for OriginExecutor {
1294 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1295 assert_eq!(
1296 command.args,
1297 [
1298 "-C",
1299 "/mnt/optane/bifrost-fird",
1300 "config",
1301 "--get",
1302 "remote.origin.url",
1303 ]
1304 );
1305 Ok(CommandOutput {
1306 status: 0,
1307 stdout: b"git@github.com:BrokkAi/bifrost-dev.git\n".to_vec(),
1308 stderr: Vec::new(),
1309 })
1310 }
1311 }
1312
1313 let mut config = HelConfig::default();
1314 config
1315 .targets
1316 .insert("localhost".into(), TargetTemplate::LocalBare);
1317 let session = raw_session_on("localhost", "/mnt/optane/bifrost-fird");
1318 let session_id = session.id.clone();
1319 let controller = Controller {
1320 config,
1321 state: HelState {
1322 sessions: [(session_id.clone(), session)].into_iter().collect(),
1323 ..HelState::default()
1324 },
1325 };
1326
1327 let source = controller
1328 .resolve_session_project_source(&session_id, &OriginExecutor)
1329 .unwrap();
1330
1331 assert_eq!(source.key, "github:brokkai/bifrost-dev");
1332 assert_eq!(source.short, "bifrost-dev");
1333 assert_eq!(source.full, "BrokkAi/bifrost-dev");
1334 }
1335 #[test]
1336 fn managed_worktree_origin_uses_source_repository_while_checkout_is_retired() {
1337 struct OriginExecutor;
1338 impl CommandExecutor for OriginExecutor {
1339 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1340 assert_eq!(
1341 command.args,
1342 [
1343 "-C",
1344 "/home/dev/project",
1345 "config",
1346 "--get",
1347 "remote.origin.url",
1348 ]
1349 );
1350 Ok(CommandOutput {
1351 status: 0,
1352 stdout: b"git@github.com:example/project.git\n".to_vec(),
1353 stderr: Vec::new(),
1354 })
1355 }
1356 }
1357
1358 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1359 let session_id = session.id.clone();
1360 let controller = Controller {
1361 config: HelConfig::default(),
1362 state: HelState {
1363 sessions: [(session_id.clone(), session)].into_iter().collect(),
1364 ..HelState::default()
1365 },
1366 };
1367
1368 let source = controller
1369 .resolve_session_project_source(&session_id, &OriginExecutor)
1370 .unwrap();
1371
1372 assert_eq!(source.key, "github:example/project");
1373 }
1374
1375 struct CheckoutPositionExecutor {
1377 head_commit: String,
1378 branch: Option<String>,
1379 }
1380 impl CommandExecutor for CheckoutPositionExecutor {
1381 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1382 let stdout = if command.args.iter().any(|argument| argument == "rev-parse") {
1383 self.head_commit.clone()
1384 } else if command
1385 .args
1386 .iter()
1387 .any(|argument| argument == "symbolic-ref")
1388 {
1389 match &self.branch {
1390 Some(branch) => branch.clone(),
1391 None => {
1392 return Ok(CommandOutput {
1393 status: 1,
1394 stdout: Vec::new(),
1395 stderr: Vec::new(),
1396 });
1397 }
1398 }
1399 } else {
1400 panic!("unexpected command {:?}", command.args);
1401 };
1402 Ok(CommandOutput {
1403 status: 0,
1404 stdout: format!("{stdout}\n").into_bytes(),
1405 stderr: Vec::new(),
1406 })
1407 }
1408 }
1409 fn recorded_repository(head_commit: &str, branch: Option<&str>) -> RepositoryMetadata {
1410 RepositoryMetadata {
1411 id: "project".into(),
1412 relative_destination: PathBuf::from("project"),
1413 origin: "mj-local:project".into(),
1414 base_commit: String::new(),
1415 head_commit: head_commit.into(),
1416 branch: branch.map(str::to_owned),
1417 }
1418 }
1419 #[test]
1420 fn a_raw_checkout_that_moved_while_stopped_gets_a_conversation_line() {
1421 let config = resume_compatibility_config();
1422 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1423 let directory = session.project_directory.clone().unwrap();
1424 let executor = CheckoutPositionExecutor {
1425 head_commit: "b".repeat(40),
1426 branch: Some("mj/0123456789abcdef0123456789abcdef".into()),
1427 };
1428
1429 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1430 let notice = raw_checkout_divergence_notice(
1431 &directory,
1432 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1433 &live,
1434 )
1435 .expect("a moved checkout is reported");
1436
1437 assert!(
1438 notice.contains(&directory.display().to_string()),
1439 "{notice}"
1440 );
1441 assert!(notice.contains("aaaaaaaaaaaa (main)"), "{notice}");
1442 assert!(
1443 notice.contains("bbbbbbbbbbbb (mj/0123456789abcdef0123456789abcdef)"),
1444 "{notice}"
1445 );
1446 assert!(
1447 notice.contains("while this session was stopped"),
1448 "{notice}"
1449 );
1450 }
1451 #[test]
1452 fn a_raw_checkout_that_stayed_put_gets_no_conversation_line() {
1453 let config = resume_compatibility_config();
1454 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1455 let directory = session.project_directory.clone().unwrap();
1456 let executor = CheckoutPositionExecutor {
1457 head_commit: "a".repeat(40),
1458 branch: Some("main".into()),
1459 };
1460
1461 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1462
1463 assert_eq!(
1464 raw_checkout_divergence_notice(
1465 &directory,
1466 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1467 &live,
1468 ),
1469 None
1470 );
1471 }
1472 #[test]
1473 fn a_checkpoint_without_recorded_git_identity_reports_nothing() {
1474 let live = CheckoutPosition {
1475 head_commit: "b".repeat(40),
1476 branch: None,
1477 };
1478
1479 assert_eq!(
1480 raw_checkout_divergence_notice(Path::new("/home/dev/project"), None, &live),
1481 None
1482 );
1483 assert_eq!(
1484 raw_checkout_divergence_notice(
1485 Path::new("/home/dev/project"),
1486 Some(&recorded_repository("", None)),
1487 &live,
1488 ),
1489 None
1490 );
1491 }
1492 #[test]
1493 fn a_detached_checkout_is_named_as_detached() {
1494 let config = resume_compatibility_config();
1495 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1496 let directory = session.project_directory.clone().unwrap();
1497 let executor = CheckoutPositionExecutor {
1498 head_commit: "c".repeat(40),
1499 branch: None,
1500 };
1501
1502 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1503 let notice = raw_checkout_divergence_notice(
1504 &directory,
1505 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1506 &live,
1507 )
1508 .expect("a moved checkout is reported");
1509
1510 assert!(notice.contains("cccccccccccc (detached)"), "{notice}");
1511 }
1512 #[test]
1513 fn bundle_sessions_resume_on_any_workspace_target() {
1514 let config = resume_compatibility_config();
1515 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1516
1517 assert_eq!(
1518 resume_compatibility(&session, &config, "podman"),
1519 Ok(ResumePlan::InPlace)
1520 );
1521 assert_eq!(
1522 resume_compatibility(&session, &config, "ssh-bare"),
1523 Ok(ResumePlan::InPlace)
1524 );
1525 }
1526 #[test]
1527 fn a_single_local_repository_can_become_a_checkout() {
1528 let mut config = resume_compatibility_config();
1529 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1530 session.bundle_id = "project".into();
1531 config.bundles.insert(
1532 "project".into(),
1533 local_bundle(Path::new("/home/dev/project")),
1534 );
1535
1536 assert_eq!(
1537 resume_compatibility(&session, &config, "local-bare"),
1538 Ok(ResumePlan::WorkspaceToRaw)
1539 );
1540 }
1541 #[test]
1542 fn a_github_project_cannot_become_a_checkout() {
1543 let mut config = resume_compatibility_config();
1544 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1545 session.bundle_id = "project".into();
1546 let mut bundle = local_bundle(Path::new("/home/dev/project"));
1547 bundle.repositories[0].local = None;
1548 bundle.repositories[0].github = Some("example/project".into());
1549 config.bundles.insert("project".into(), bundle);
1550
1551 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1552
1553 assert!(reason.contains("came from GitHub"), "{reason}");
1554 assert!(
1555 reason.contains("resume it on a container, SSH, or EC2 target"),
1556 "{reason}"
1557 );
1558 }
1559 #[test]
1560 fn a_multi_repository_project_cannot_become_a_checkout() {
1561 let mut config = resume_compatibility_config();
1562 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1563 session.bundle_id = "project".into();
1564 let mut bundle = local_bundle(Path::new("/home/dev/project"));
1565 bundle.repositories.push(ProjectRepository {
1566 id: "tools".into(),
1567 github: None,
1568 local: Some(PathBuf::from("/home/dev/tools")),
1569 destination: PathBuf::from("tools"),
1570 git_ref: None,
1571 });
1572 config.bundles.insert("project".into(), bundle);
1573
1574 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1575
1576 assert!(reason.contains("2 repositories"), "{reason}");
1577 assert!(reason.contains("one checkout"), "{reason}");
1578 }
1579 #[test]
1580 fn bundle_sessions_refuse_a_local_bare_target_with_a_reason() {
1581 let config = resume_compatibility_config();
1582 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1583
1584 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1585
1586 assert!(reason.contains("created from a project bundle"), "{reason}");
1587 assert!(
1588 reason.contains("resume it on a container, SSH, or EC2 target"),
1589 "{reason}"
1590 );
1591 }
1592 #[test]
1593 fn managed_raw_sessions_resume_on_their_own_worktree_host() {
1594 let config = resume_compatibility_config();
1595
1596 assert_eq!(
1597 resume_compatibility(
1598 &managed_raw_session(ManagedWorktreeTarget::Local),
1599 &config,
1600 "local-bare",
1601 ),
1602 Ok(ResumePlan::InPlace)
1603 );
1604 assert_eq!(
1605 resume_compatibility(
1606 &managed_raw_session(ssh_worktree_target()),
1607 &config,
1608 "ssh-bare",
1609 ),
1610 Ok(ResumePlan::InPlace)
1611 );
1612 }
1613 #[test]
1614 fn managed_raw_sessions_refuse_a_bare_target_on_another_host() {
1615 let config = resume_compatibility_config();
1616
1617 let reason = resume_compatibility(
1618 &managed_raw_session(ManagedWorktreeTarget::Local),
1619 &config,
1620 "ssh-bare",
1621 )
1622 .unwrap_err();
1623 assert!(reason.contains("this machine"), "{reason}");
1624
1625 let reason = resume_compatibility(
1626 &managed_raw_session(ssh_worktree_target()),
1627 &config,
1628 "local-bare",
1629 )
1630 .unwrap_err();
1631 assert!(reason.contains("dev@builder"), "{reason}");
1632 }
1633 #[test]
1634 fn a_local_raw_checkout_converts_when_it_resumes_on_a_container() {
1635 let config = resume_compatibility_config();
1636
1637 assert_eq!(
1638 resume_compatibility(
1639 &managed_raw_session(ManagedWorktreeTarget::Local),
1640 &config,
1641 "podman",
1642 ),
1643 Ok(ResumePlan::RawToWorkspace)
1644 );
1645 assert_eq!(
1646 resume_compatibility(
1647 &raw_session_on("local-bare", "/home/dev/project"),
1648 &config,
1649 "podman",
1650 ),
1651 Ok(ResumePlan::RawToWorkspace)
1652 );
1653 }
1654 #[test]
1655 fn a_raw_checkout_on_an_ssh_host_cannot_convert() {
1656 let config = resume_compatibility_config();
1657
1658 let reason = resume_compatibility(
1659 &managed_raw_session(ssh_worktree_target()),
1660 &config,
1661 "podman",
1662 )
1663 .unwrap_err();
1664 assert!(reason.contains("works directly in"), "{reason}");
1665 assert!(reason.contains("dev@builder"), "{reason}");
1666
1667 let reason = resume_compatibility(
1668 &raw_session_on("ssh-bare", "/srv/project"),
1669 &config,
1670 "podman",
1671 )
1672 .unwrap_err();
1673 assert!(reason.contains("on an SSH host"), "{reason}");
1674 }
1675 #[test]
1676 fn a_session_that_opens_a_subdirectory_of_its_worktree_cannot_convert() {
1677 let config = resume_compatibility_config();
1678 let mut session = managed_raw_session(ManagedWorktreeTarget::Local);
1679 let worktree = session.managed_worktree.as_mut().unwrap();
1680 worktree.source_project_directory = worktree.source_repository.join("crate");
1681 session.project_directory = Some(worktree.worktree_root.join("crate"));
1682
1683 let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
1684
1685 assert!(reason.contains("subdirectory of its checkout"), "{reason}");
1686 }
1687 #[test]
1688 fn unmanaged_raw_sessions_require_the_same_bare_target_kind() {
1689 let config = resume_compatibility_config();
1690 let local = raw_session_on("local-bare", "/home/dev/project");
1691 let remote = raw_session_on("ssh-bare", "/srv/project");
1692
1693 assert_eq!(
1694 resume_compatibility(&local, &config, "local-bare"),
1695 Ok(ResumePlan::InPlace)
1696 );
1697 assert_eq!(
1698 resume_compatibility(&remote, &config, "ssh-bare"),
1699 Ok(ResumePlan::InPlace)
1700 );
1701 for (session, target) in [(&local, "ssh-bare"), (&remote, "local-bare")] {
1702 let reason = resume_compatibility(session, &config, target).unwrap_err();
1703 assert!(reason.contains("directly on its host"), "{reason}");
1704 }
1705 }
1706 #[test]
1707 fn resume_compatibility_names_a_target_that_is_gone() {
1708 let config = resume_compatibility_config();
1709 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1710
1711 let reason = resume_compatibility(&session, &config, "retired").unwrap_err();
1712
1713 assert!(reason.contains("retired"), "{reason}");
1714 }
1715 #[test]
1716 fn managed_raw_worktree_inherits_upstream_and_cleans_up_owned_artifacts() {
1717 let repository = committed_repository();
1718 let remote_parent = tempfile::tempdir().unwrap();
1719 let remote = remote_parent.path().join("remote.git");
1720 let output = Command::new("git")
1721 .args(["init", "--bare"])
1722 .arg(&remote)
1723 .output()
1724 .unwrap();
1725 assert!(output.status.success());
1726 test_git(
1727 repository.path(),
1728 &["remote", "add", "origin", &remote.to_string_lossy()],
1729 );
1730 test_git(
1731 repository.path(),
1732 &["push", "--set-upstream", "origin", "master"],
1733 );
1734
1735 let target = ManagedWorktreeTarget::Local;
1736 let inspection =
1737 inspect_raw_project(&ProcessExecutor, &target, &repository.path().join("nested"))
1738 .unwrap();
1739 assert!(inspection.primary_checkout);
1740 assert_eq!(inspection.upstream.as_deref(), Some("origin/master"));
1741 assert_eq!(
1744 inspection.source_project_directory,
1745 repository.path().canonicalize().unwrap().join("nested")
1746 );
1747
1748 let session_id = "0123456789abcdef0123456789abcdef";
1749 let worktree = ManagedWorktree {
1750 source_project_directory: inspection.source_project_directory,
1751 source_repository: inspection.source_repository,
1752 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
1753 branch: format!("mj/{session_id}"),
1754 target,
1755 };
1756 create_managed_worktree(
1757 &ProcessExecutor,
1758 &worktree,
1759 inspection.upstream.as_deref(),
1760 PrimaryCheckoutRequirement::Clean,
1761 )
1762 .unwrap();
1763 assert!(worktree.worktree_root.join("nested/file.txt").is_file());
1764 assert_eq!(
1765 test_git(
1766 &worktree.worktree_root,
1767 &[
1768 "rev-parse",
1769 "--abbrev-ref",
1770 "--symbolic-full-name",
1771 "@{upstream}"
1772 ]
1773 ),
1774 "origin/master"
1775 );
1776 assert_eq!(test_git(repository.path(), &["status", "--porcelain"]), "");
1777 std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
1778
1779 cleanup_managed_worktree(&ProcessExecutor, &worktree).unwrap();
1780 assert!(!worktree.worktree_root.exists());
1781 assert!(!repository.path().join(".mj").exists());
1782 let output = Command::new("git")
1783 .arg("-C")
1784 .arg(repository.path())
1785 .args([
1786 "show-ref",
1787 "--verify",
1788 &format!("refs/heads/{}", worktree.branch),
1789 ])
1790 .output()
1791 .unwrap();
1792 assert!(!output.status.success());
1793 }
1794 #[test]
1795 fn retired_worktree_can_be_recreated_from_its_retained_branch() {
1796 let repository = committed_repository();
1797 let session_id = "0123456789abcdef0123456789abcdef";
1798 let session = managed_worktree_session(repository.path(), session_id);
1799 let worktree = session.managed_worktree.unwrap();
1800 std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
1802
1803 retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
1804
1805 assert!(!worktree.worktree_root.exists());
1806 assert!(!repository.path().join(".mj").exists());
1807 let branch = Command::new("git")
1808 .arg("-C")
1809 .arg(repository.path())
1810 .args([
1811 "show-ref",
1812 "--verify",
1813 &format!("refs/heads/{}", worktree.branch),
1814 ])
1815 .output()
1816 .unwrap();
1817 assert!(
1818 branch.status.success(),
1819 "the session branch must survive: later checkpoints are deltas against it"
1820 );
1821
1822 assert!(restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
1823 assert!(worktree.worktree_root.join("nested/file.txt").is_file());
1824 assert!(!restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
1825 }
1826 #[test]
1827 fn retiring_a_remote_worktree_prunes_registration_after_target_removed_checkout() {
1828 struct RemoteExecutor {
1829 path_checks: RefCell<usize>,
1830 commands: RefCell<Vec<CommandSpec>>,
1831 }
1832
1833 impl CommandExecutor for RemoteExecutor {
1834 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1835 self.commands.borrow_mut().push(command.clone());
1836 let status = if command.purpose == "check managed worktree path" {
1837 let mut checks = self.path_checks.borrow_mut();
1838 let status = i32::from(*checks != 0);
1839 *checks += 1;
1840 status
1841 } else {
1842 0
1843 };
1844 Ok(CommandOutput {
1845 status,
1846 stdout: Vec::new(),
1847 stderr: Vec::new(),
1848 })
1849 }
1850 }
1851
1852 let worktree = ManagedWorktree {
1853 source_project_directory: PathBuf::from("/srv/project"),
1854 source_repository: PathBuf::from("/srv/project"),
1855 worktree_root: PathBuf::from("/srv/project/.mj/worktrees/session"),
1856 branch: "mj/session".into(),
1857 target: ManagedWorktreeTarget::Ssh {
1858 destination: "builder".into(),
1859 ssh_args: Vec::new(),
1860 },
1861 };
1862 let executor = RemoteExecutor {
1863 path_checks: RefCell::new(0),
1864 commands: RefCell::new(Vec::new()),
1865 };
1866
1867 retire_managed_worktree(&executor, &worktree).unwrap();
1868
1869 let purposes = executor
1870 .commands
1871 .borrow()
1872 .iter()
1873 .map(|command| command.purpose.clone())
1874 .collect::<Vec<_>>();
1875 assert_eq!(
1876 purposes,
1877 [
1878 "check managed worktree path",
1879 "check managed worktree path",
1880 "prune managed worktree metadata",
1881 "remove empty managed worktree directories",
1882 ]
1883 );
1884 }
1885 #[test]
1886 fn a_managed_conversion_carries_the_session_worktree_not_the_primary_checkout() {
1887 let repository = committed_repository();
1888 let session_id = "0123456789abcdef0123456789abcdef";
1889 let session = managed_worktree_session(repository.path(), session_id);
1890 let worktree = session.managed_worktree.clone().unwrap();
1891
1892 let conversion =
1893 plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
1894
1895 assert_eq!(conversion.checkout, worktree.worktree_root);
1896 assert_eq!(conversion.repository, repository.path());
1897 assert_eq!(conversion.retire, Some(worktree));
1898 let bundle = conversion.new_bundle.expect("a bundle is synthesized");
1899 assert_eq!(bundle.repositories.len(), 1);
1900 assert_eq!(bundle.primary_repo, bundle.repositories[0].id);
1901 assert_eq!(
1902 bundle.repositories[0].local.as_deref(),
1903 Some(repository.path())
1904 );
1905 assert_eq!(bundle.repositories[0].github, None);
1906 assert_eq!(
1909 bundle.repositories[0].destination,
1910 PathBuf::from(session_id)
1911 );
1912 }
1913 #[test]
1914 fn an_unmanaged_conversion_serves_the_main_repository_behind_a_linked_worktree() {
1915 let repository = committed_repository();
1916 let session_id = "0123456789abcdef0123456789abcdef";
1917 let linked = managed_worktree_session(repository.path(), session_id);
1918 let checkout = linked.managed_worktree.unwrap().worktree_root;
1919 let mut session = checkpoint_test_session(session_id);
1920 session.state = SessionState::Stopped;
1921 session.target_template_id = "local-bare".into();
1922 session.project_directory = Some(checkout.clone());
1923
1924 let conversion =
1925 plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
1926
1927 assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
1928 assert_eq!(
1929 conversion.repository,
1930 repository.path().canonicalize().unwrap()
1931 );
1932 assert_eq!(conversion.retire, None);
1933 }
1934 #[cfg(unix)]
1939 #[test]
1940 fn an_unmanaged_conversion_accepts_a_checkout_reached_through_a_symlink() {
1941 let repository = committed_repository();
1942 let session_id = "0123456789abcdef0123456789abcdef";
1943 let linked = managed_worktree_session(repository.path(), session_id);
1944 let checkout = linked.managed_worktree.unwrap().worktree_root;
1945 let alias = tempfile::tempdir().unwrap();
1946 let symlink = alias.path().join("checkout");
1947 std::os::unix::fs::symlink(&checkout, &symlink).unwrap();
1948 let mut session = checkpoint_test_session(session_id);
1949 session.state = SessionState::Stopped;
1950 session.target_template_id = "local-bare".into();
1951 session.project_directory = Some(symlink);
1952
1953 let conversion =
1954 plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
1955
1956 assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
1957 assert_eq!(
1958 conversion.repository,
1959 repository.path().canonicalize().unwrap()
1960 );
1961 assert_eq!(conversion.retire, None);
1962 }
1963 #[test]
1964 fn a_conversion_reuses_a_bundle_that_already_describes_the_checkout() {
1965 let repository = PathBuf::from("/home/dev/project");
1966 let destination = PathBuf::from("project");
1967 let existing = ProjectBundle {
1968 primary_repo: "project".into(),
1969 repositories: vec![ProjectRepository {
1970 id: "project".into(),
1971 github: None,
1972 local: Some(repository.clone()),
1973 destination: destination.clone(),
1974 git_ref: None,
1975 }],
1976 };
1977 let mut config = HelConfig::default();
1978 config.bundles.insert("existing".into(), existing);
1979
1980 assert_eq!(
1981 converted_raw_bundle(&config, "remote-project-abcdef", &repository, &destination),
1982 ("existing".to_owned(), None)
1983 );
1984
1985 let (id, synthesized) = converted_raw_bundle(
1988 &config,
1989 "remote-project-abcdef",
1990 &repository,
1991 Path::new("elsewhere"),
1992 );
1993 assert_ne!(id, "existing");
1994 assert_eq!(
1995 synthesized.unwrap().repositories[0].destination,
1996 PathBuf::from("elsewhere")
1997 );
1998 }
1999 #[test]
2000 fn a_converted_record_is_a_valid_bundle_session() {
2001 let session_id = "0123456789abcdef0123456789abcdef";
2002 let mut config = resume_compatibility_config();
2003 let mut record = managed_raw_session(ManagedWorktreeTarget::Local);
2004 record.state = SessionState::Running;
2005 record.target_template_id = "podman".into();
2006 let conversion = RawToWorkspaceConversion {
2007 checkout: record.project_directory.clone().unwrap(),
2008 repository: PathBuf::from("/home/dev/project"),
2009 bundle_id: "project".into(),
2010 new_bundle: Some(ProjectBundle {
2011 primary_repo: "project".into(),
2012 repositories: vec![ProjectRepository {
2013 id: "project".into(),
2014 github: None,
2015 local: Some(PathBuf::from("/home/dev/project")),
2016 destination: PathBuf::from(session_id),
2017 git_ref: None,
2018 }],
2019 }),
2020 retire: record.managed_worktree.clone(),
2021 };
2022
2023 config.bundles.insert(
2024 conversion.bundle_id.clone(),
2025 conversion.new_bundle.clone().unwrap(),
2026 );
2027 config.profiles.insert(
2028 record.last_profile.clone(),
2029 HarnessProfile {
2030 kind: record.harness_kind,
2031 home: PathBuf::from("/profiles/codex"),
2032 executable: None,
2033 environment: BTreeMap::new(),
2034 context_window_bytes: None,
2035 },
2036 );
2037 apply_raw_to_workspace(&mut record, &conversion);
2038
2039 assert_eq!(record.project_directory, None);
2040 assert_eq!(record.managed_worktree, None);
2041 assert_eq!(record.bundle_id, "project");
2042 let state = HelState {
2043 sessions: BTreeMap::from([(session_id.into(), record)]),
2044 ..HelState::default()
2045 };
2046 state.validate_against_config(&config).unwrap();
2047 }
2048 #[test]
2049 fn a_session_leaving_its_target_claims_a_worktree_of_its_own_repository() {
2050 let repository = committed_repository();
2051 let session_id = "0123456789abcdef0123456789abcdef";
2052 let mut session = checkpoint_test_session(session_id);
2053 session.state = SessionState::Stopped;
2054 session.bundle_id = "project".into();
2055 let mut config = resume_compatibility_config();
2056 config
2057 .bundles
2058 .insert("project".into(), local_bundle(repository.path()));
2059 let controller = Controller {
2060 config,
2061 state: HelState {
2062 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2063 ..HelState::default()
2064 },
2065 };
2066
2067 let conversion = controller
2068 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2069 .unwrap();
2070
2071 assert_eq!(
2072 conversion.worktree,
2073 ManagedWorktree {
2074 source_project_directory: repository.path().to_path_buf(),
2075 source_repository: repository.path().to_path_buf(),
2076 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2077 branch: format!("mj/{session_id}"),
2078 target: ManagedWorktreeTarget::Local,
2079 }
2080 );
2081
2082 std::fs::write(repository.path().join("dirty.txt"), "primary\n").unwrap();
2085 create_managed_worktree(
2086 &ProcessExecutor,
2087 &conversion.worktree,
2088 None,
2089 PrimaryCheckoutRequirement::Any,
2090 )
2091 .unwrap();
2092 assert!(conversion.worktree.worktree_root.is_dir());
2093
2094 let error = controller
2096 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2097 .unwrap_err();
2098 assert!(format!("{error:#}").contains("already exists"), "{error:#}");
2099 }
2100 #[test]
2101 fn a_session_that_left_its_target_is_a_valid_raw_session() {
2102 let session_id = "0123456789abcdef0123456789abcdef";
2103 let repository = PathBuf::from("/home/dev/project");
2104 let mut config = resume_compatibility_config();
2105 config
2106 .bundles
2107 .insert("project".into(), local_bundle(&repository));
2108 config.profiles.insert(
2109 "codex".into(),
2110 HarnessProfile {
2111 kind: hel::hel_config::HarnessKind::Codex,
2112 home: PathBuf::from("/profiles/codex"),
2113 executable: None,
2114 environment: BTreeMap::new(),
2115 context_window_bytes: None,
2116 },
2117 );
2118 let mut record = checkpoint_test_session(session_id);
2119 record.bundle_id = "project".into();
2120 record.target_template_id = "local-bare".into();
2121 let conversion = WorkspaceToRawConversion {
2122 worktree: ManagedWorktree {
2123 source_project_directory: repository.clone(),
2124 source_repository: repository.clone(),
2125 worktree_root: repository.join(".mj/worktrees").join(session_id),
2126 branch: format!("mj/{session_id}"),
2127 target: ManagedWorktreeTarget::Local,
2128 },
2129 };
2130
2131 apply_workspace_to_raw(&mut record, &conversion);
2132
2133 assert_eq!(
2134 record.project_directory.as_deref(),
2135 Some(conversion.worktree.worktree_root.as_path())
2136 );
2137 assert_eq!(record.bundle_id, "project", "the bundle still describes it");
2138 let state = HelState {
2139 sessions: BTreeMap::from([(session_id.into(), record)]),
2140 ..HelState::default()
2141 };
2142 state.validate_against_config(&config).unwrap();
2143 }
2144 #[test]
2145 fn a_failed_departure_returns_the_session_to_its_bundle() {
2146 let session_id = "0123456789abcdef0123456789abcdef";
2147 let repository = PathBuf::from("/home/dev/project");
2148 let previous = {
2149 let mut record = checkpoint_test_session(session_id);
2150 record.state = SessionState::Stopped;
2151 record.bundle_id = "project".into();
2152 record
2153 };
2154 let mut converted = previous.clone();
2155 converted.state = SessionState::Provisioning;
2156 apply_workspace_to_raw(
2157 &mut converted,
2158 &WorkspaceToRawConversion {
2159 worktree: ManagedWorktree {
2160 source_project_directory: repository.clone(),
2161 source_repository: repository.clone(),
2162 worktree_root: repository.join(".mj/worktrees").join(session_id),
2163 branch: format!("mj/{session_id}"),
2164 target: ManagedWorktreeTarget::Local,
2165 },
2166 },
2167 );
2168
2169 apply_failed_resume_rollback(&mut converted, &previous, "podman is unavailable", None);
2170
2171 assert_eq!(converted.project_directory, None);
2172 assert_eq!(converted.managed_worktree, None);
2173 assert_eq!(converted.bundle_id, "project");
2174 }
2175 #[test]
2176 fn a_failed_conversion_returns_the_session_to_its_checkout() {
2177 let previous = managed_raw_session(ManagedWorktreeTarget::Local);
2178 let mut converted = previous.clone();
2179 converted.state = SessionState::Provisioning;
2180 converted.target_template_id = "podman".into();
2181 apply_raw_to_workspace(
2182 &mut converted,
2183 &RawToWorkspaceConversion {
2184 checkout: previous.project_directory.clone().unwrap(),
2185 repository: PathBuf::from("/home/dev/project"),
2186 bundle_id: "project".into(),
2187 new_bundle: None,
2188 retire: previous.managed_worktree.clone(),
2189 },
2190 );
2191
2192 let mut cleaned = converted.clone();
2193 apply_failed_resume_rollback(&mut cleaned, &previous, "podman is unavailable", None);
2194 assert_eq!(cleaned.project_directory, previous.project_directory);
2195 assert_eq!(cleaned.managed_worktree, previous.managed_worktree);
2196 assert_eq!(cleaned.bundle_id, previous.bundle_id);
2197
2198 let mut stranded = converted;
2201 apply_failed_resume_rollback(
2202 &mut stranded,
2203 &previous,
2204 "podman is unavailable",
2205 Some("podman rm failed".into()),
2206 );
2207 assert_eq!(stranded.state, SessionState::Error);
2208 assert_eq!(stranded.project_directory, previous.project_directory);
2209 assert_eq!(stranded.managed_worktree, previous.managed_worktree);
2210 assert_eq!(stranded.bundle_id, previous.bundle_id);
2211 }
2212 #[test]
2213 fn cancelled_new_session_cleanup_removes_managed_worktree_and_branch() {
2214 let repository = committed_repository();
2215 let session_id = "0123456789abcdef0123456789abcdef";
2216 let worktree = ManagedWorktree {
2217 source_project_directory: repository.path().to_path_buf(),
2218 source_repository: repository.path().to_path_buf(),
2219 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2220 branch: format!("mj/{session_id}"),
2221 target: ManagedWorktreeTarget::Local,
2222 };
2223 create_managed_worktree(
2224 &ProcessExecutor,
2225 &worktree,
2226 None,
2227 PrimaryCheckoutRequirement::Clean,
2228 )
2229 .unwrap();
2230
2231 let mut session = checkpoint_test_session(session_id);
2232 session.project_directory = Some(worktree.worktree_root.clone());
2233 session.managed_worktree = Some(worktree.clone());
2234 let controller = Controller {
2235 config: HelConfig::default(),
2236 state: HelState {
2237 sessions: BTreeMap::from([(session_id.into(), session)]),
2238 ..HelState::default()
2239 },
2240 };
2241 let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2242 let executor = CancellableProcessExecutor::new(cancelled);
2243
2244 controller
2245 .cleanup_new_session_worktree_after_failure(session_id, &executor)
2246 .unwrap();
2247
2248 assert!(!worktree.worktree_root.exists());
2249 assert!(!repository.path().join(".mj").exists());
2250 let branch = Command::new("git")
2251 .arg("-C")
2252 .arg(repository.path())
2253 .args([
2254 "show-ref",
2255 "--verify",
2256 &format!("refs/heads/{}", worktree.branch),
2257 ])
2258 .output()
2259 .unwrap();
2260 assert!(!branch.status.success());
2261 }
2262 #[test]
2263 fn managed_raw_worktree_refuses_dirty_primary_and_skips_existing_worktree() {
2264 let repository = committed_repository();
2265 std::fs::write(repository.path().join("dirty.txt"), "dirty\n").unwrap();
2266 let target = ManagedWorktreeTarget::Local;
2267 let inspection = inspect_raw_project(&ProcessExecutor, &target, repository.path()).unwrap();
2268 let session_id = "fedcba9876543210fedcba9876543210";
2269 let managed = ManagedWorktree {
2270 source_project_directory: inspection.source_project_directory,
2271 source_repository: inspection.source_repository,
2272 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2273 branch: format!("mj/{session_id}"),
2274 target: target.clone(),
2275 };
2276 let error = create_managed_worktree(
2277 &ProcessExecutor,
2278 &managed,
2279 None,
2280 PrimaryCheckoutRequirement::Clean,
2281 )
2282 .unwrap_err();
2283 assert!(error.to_string().contains("uncommitted changes"));
2284 assert!(!managed.worktree_root.exists());
2285
2286 std::fs::remove_file(repository.path().join("dirty.txt")).unwrap();
2287 let existing = repository.path().join("existing-worktree");
2288 test_git(
2289 repository.path(),
2290 &[
2291 "worktree",
2292 "add",
2293 "--detach",
2294 &existing.to_string_lossy(),
2295 "HEAD",
2296 ],
2297 );
2298 let linked = inspect_raw_project(&ProcessExecutor, &target, &existing).unwrap();
2299 assert!(!linked.primary_checkout);
2300 }
2301 #[test]
2302 fn managed_worktree_preflight_preserves_colliding_branch_and_directory() {
2303 let repository = committed_repository();
2304 let target = ManagedWorktreeTarget::Local;
2305 let session_id = "abcdef0123456789abcdef0123456789";
2306 let branch = format!("mj/{session_id}");
2307 test_git(repository.path(), &["branch", &branch]);
2308 let worktree = ManagedWorktree {
2309 source_project_directory: repository.path().to_path_buf(),
2310 source_repository: repository.path().to_path_buf(),
2311 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2312 branch: branch.clone(),
2313 target,
2314 };
2315
2316 let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2317 assert!(error.to_string().contains("branch already exists"));
2318 assert!(
2319 !test_git(
2320 repository.path(),
2321 &["show-ref", "--verify", &format!("refs/heads/{branch}")]
2322 )
2323 .is_empty()
2324 );
2325 std::fs::create_dir_all(&worktree.worktree_root).unwrap();
2326 let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2327 assert!(error.to_string().contains("path already exists"));
2328 assert!(worktree.worktree_root.is_dir());
2329 }
2330 #[test]
2331 fn managed_worktree_ssh_commands_preserve_hostile_path_boundaries() {
2332 let target = ManagedWorktreeTarget::Ssh {
2333 destination: "builder".into(),
2334 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2335 };
2336 let command = managed_git_command(
2337 &target,
2338 Path::new("/srv/project with ' quote"),
2339 ["worktree", "prune"],
2340 "prune test",
2341 );
2342 assert_eq!(command.program, "ssh");
2343 assert_eq!(&command.args[..3], ["-o", "BatchMode=yes", "builder"]);
2344 assert_eq!(
2345 command.args[3],
2346 "'git' '-C' '/srv/project with '\\'' quote' 'worktree' 'prune'"
2347 );
2348 }
2349}