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