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