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