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