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
884fn path_exists_on_managed_target(
885 executor: &impl CommandExecutor,
886 target: &ManagedWorktreeTarget,
887 path: &Path,
888) -> Result<bool> {
889 match target {
890 ManagedWorktreeTarget::Local => Ok(path.exists()),
891 ManagedWorktreeTarget::Ssh { .. } => {
892 let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
893 .purpose("check managed worktree path");
894 let output = executor.execute(&command)?;
895 match output.status {
896 0 => Ok(true),
897 1 => Ok(false),
898 status => bail!(
899 "check managed worktree path failed with status {status}: {}",
900 String::from_utf8_lossy(&output.stderr).trim()
901 ),
902 }
903 }
904 }
905}
906
907pub(super) fn managed_worktree_checkout_exists(
908 executor: &impl CommandExecutor,
909 worktree: &ManagedWorktree,
910) -> Result<bool> {
911 path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
912}
913
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
916pub(super) enum PrimaryCheckoutRequirement {
917 Clean,
920 Any,
924}
925
926pub(super) fn create_managed_worktree(
927 executor: &impl CommandExecutor,
928 worktree: &ManagedWorktree,
929 upstream: Option<&str>,
930 requirement: PrimaryCheckoutRequirement,
931) -> Result<()> {
932 ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
933 if requirement == PrimaryCheckoutRequirement::Clean {
934 let status = managed_git_stdout(
935 executor,
936 &worktree.target,
937 &worktree.source_repository,
938 ["status", "--porcelain=v1", "--untracked-files=all"],
939 "inspect primary checkout changes",
940 )?;
941 if !status.is_empty() {
942 let paths = status.lines().take(20).collect::<Vec<_>>().join("\n ");
943 bail!(
944 "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n {paths}"
945 );
946 }
947 }
948 let parent = worktree
949 .worktree_root
950 .parent()
951 .context("managed worktree root has no parent")?;
952 execute_checked(
953 executor,
954 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
955 .purpose("create managed worktree directory"),
956 )?;
957 execute_checked(
958 executor,
959 managed_git_command(
960 &worktree.target,
961 &worktree.source_repository,
962 [
963 "worktree",
964 "add",
965 "-b",
966 &worktree.branch,
967 &worktree.worktree_root.to_string_lossy(),
968 "HEAD",
969 ],
970 "create managed raw-session worktree",
971 ),
972 )?;
973 if let Some(upstream) = upstream {
974 execute_checked(
975 executor,
976 managed_git_command(
977 &worktree.target,
978 &worktree.worktree_root,
979 ["branch", "--set-upstream-to", upstream, &worktree.branch],
980 "set managed worktree branch upstream",
981 ),
982 )?;
983 }
984 Ok(())
985}
986
987pub(super) fn restore_managed_worktree(
991 executor: &impl CommandExecutor,
992 worktree: &ManagedWorktree,
993) -> Result<bool> {
994 if managed_worktree_checkout_exists(executor, worktree)? {
995 return Ok(false);
996 }
997 ensure!(
998 path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
999 "managed worktree source repository is unavailable: {}",
1000 worktree.source_repository.display()
1001 );
1002 let branch_ref = format!("refs/heads/{}", worktree.branch);
1003 let check = managed_git_command(
1004 &worktree.target,
1005 &worktree.source_repository,
1006 ["show-ref", "--verify", "--quiet", &branch_ref],
1007 "check retired managed worktree branch",
1008 );
1009 let output = executor.execute(&check)?;
1010 match output.status {
1011 0 => {}
1012 1 => bail!(
1013 "managed worktree branch is unavailable: {}",
1014 worktree.branch
1015 ),
1016 status => bail!(
1017 "check retired managed worktree branch failed with status {status}: {}",
1018 String::from_utf8_lossy(&output.stderr).trim()
1019 ),
1020 }
1021 execute_checked(
1024 executor,
1025 managed_git_command(
1026 &worktree.target,
1027 &worktree.source_repository,
1028 ["worktree", "prune"],
1029 "prune retired managed worktree metadata",
1030 ),
1031 )?;
1032 let parent = worktree
1033 .worktree_root
1034 .parent()
1035 .context("managed worktree root has no parent")?;
1036 execute_checked(
1037 executor,
1038 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1039 .purpose("recreate managed worktree directory"),
1040 )?;
1041 execute_checked(
1042 executor,
1043 managed_git_command(
1044 &worktree.target,
1045 &worktree.source_repository,
1046 [
1047 "worktree",
1048 "add",
1049 "--",
1050 &worktree.worktree_root.to_string_lossy(),
1051 &worktree.branch,
1052 ],
1053 "restore managed raw-session worktree",
1054 ),
1055 )?;
1056 Ok(true)
1057}
1058
1059fn ensure_managed_worktree_available(
1060 executor: &impl CommandExecutor,
1061 worktree: &ManagedWorktree,
1062) -> Result<()> {
1063 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1064 bail!(
1065 "managed worktree path already exists: {}",
1066 worktree.worktree_root.display()
1067 );
1068 }
1069 let branch_ref = format!("refs/heads/{}", worktree.branch);
1070 let check = managed_git_command(
1071 &worktree.target,
1072 &worktree.source_repository,
1073 ["show-ref", "--verify", "--quiet", &branch_ref],
1074 "check managed worktree branch availability",
1075 );
1076 let output = executor.execute(&check)?;
1077 match output.status {
1078 0 => bail!(
1079 "managed worktree branch already exists: {}",
1080 worktree.branch
1081 ),
1082 1 => Ok(()),
1083 status => bail!(
1084 "check managed worktree branch availability failed with status {status}: {}",
1085 String::from_utf8_lossy(&output.stderr).trim()
1086 ),
1087 }
1088}
1089
1090fn retained_managed_worktree_branch_available(
1095 executor: &impl CommandExecutor,
1096 worktree: &ManagedWorktree,
1097) -> Result<bool> {
1098 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1099 bail!(
1100 "managed worktree path already exists: {}",
1101 worktree.worktree_root.display()
1102 );
1103 }
1104 let branch_ref = format!("refs/heads/{}", worktree.branch);
1105 let check = managed_git_command(
1106 &worktree.target,
1107 &worktree.source_repository,
1108 ["show-ref", "--verify", "--quiet", &branch_ref],
1109 "check retained managed worktree branch",
1110 );
1111 let output = executor.execute(&check)?;
1112 match output.status {
1113 1 => Ok(false),
1114 0 => {
1115 let worktrees = managed_git_stdout(
1116 executor,
1117 &worktree.target,
1118 &worktree.source_repository,
1119 ["worktree", "list", "--porcelain", "-z"],
1120 "check retained managed worktree checkout",
1121 )?;
1122 let branch_field = format!("branch {branch_ref}");
1123 if worktrees.split('\0').any(|field| field == branch_field) {
1124 bail!(
1125 "managed worktree branch is still checked out: {}",
1126 worktree.branch
1127 );
1128 }
1129 Ok(true)
1130 }
1131 status => bail!(
1132 "check retained managed worktree branch failed with status {status}: {}",
1133 String::from_utf8_lossy(&output.stderr).trim()
1134 ),
1135 }
1136}
1137
1138pub(super) fn preserve_retained_managed_worktree_branch(
1142 executor: &impl CommandExecutor,
1143 worktree: &ManagedWorktree,
1144) -> Result<String> {
1145 let session_id = worktree
1146 .branch
1147 .strip_prefix("mj/")
1148 .context("managed worktree branch is not session-owned")?;
1149 let branch_ref = format!("refs/heads/{}", worktree.branch);
1150 let tip = managed_git_stdout(
1151 executor,
1152 &worktree.target,
1153 &worktree.source_repository,
1154 ["rev-parse", "--verify", &branch_ref],
1155 "read retained managed worktree branch tip",
1156 )?;
1157 let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
1158 let existing = managed_git_command(
1159 &worktree.target,
1160 &worktree.source_repository,
1161 ["show-ref", "--verify", "--quiet", &recovery_ref],
1162 "check retained managed worktree recovery ref",
1163 );
1164 let output = executor.execute(&existing)?;
1165 match output.status {
1166 0 => {
1167 let existing_tip = managed_git_stdout(
1168 executor,
1169 &worktree.target,
1170 &worktree.source_repository,
1171 ["rev-parse", "--verify", &recovery_ref],
1172 "verify retained managed worktree recovery ref",
1173 )?;
1174 ensure!(
1175 existing_tip == tip,
1176 "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
1177 );
1178 Ok(recovery_ref)
1179 }
1180 1 => {
1181 execute_checked(
1182 executor,
1183 managed_git_command(
1184 &worktree.target,
1185 &worktree.source_repository,
1186 ["update-ref", &recovery_ref, &tip],
1187 "preserve retained managed worktree branch",
1188 ),
1189 )?;
1190 Ok(recovery_ref)
1191 }
1192 status => bail!(
1193 "check retained managed worktree recovery ref failed with status {status}: {}",
1194 String::from_utf8_lossy(&output.stderr).trim()
1195 ),
1196 }
1197}
1198
1199pub(super) fn retire_managed_worktree(
1206 executor: &impl CommandExecutor,
1207 worktree: &ManagedWorktree,
1208) -> Result<()> {
1209 if !remove_managed_worktree_checkout(executor, worktree)? {
1210 return Ok(());
1211 }
1212 remove_empty_managed_worktree_directories(executor, worktree)
1213}
1214
1215fn remove_managed_worktree_checkout(
1218 executor: &impl CommandExecutor,
1219 worktree: &ManagedWorktree,
1220) -> Result<bool> {
1221 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1222 return Ok(false);
1223 }
1224 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1225 execute_checked(
1226 executor,
1227 managed_git_command(
1228 &worktree.target,
1229 &worktree.source_repository,
1230 [
1231 "worktree",
1232 "remove",
1233 "--force",
1234 &worktree.worktree_root.to_string_lossy(),
1235 ],
1236 "remove managed raw-session worktree",
1237 ),
1238 )?;
1239 }
1240 execute_checked(
1241 executor,
1242 managed_git_command(
1243 &worktree.target,
1244 &worktree.source_repository,
1245 ["worktree", "prune"],
1246 "prune managed worktree metadata",
1247 ),
1248 )?;
1249 Ok(true)
1250}
1251
1252pub(super) fn cleanup_managed_worktree(
1253 executor: &impl CommandExecutor,
1254 worktree: &ManagedWorktree,
1255) -> Result<()> {
1256 if !remove_managed_worktree_checkout(executor, worktree)? {
1257 return Ok(());
1258 }
1259 let branch_ref = format!("refs/heads/{}", worktree.branch);
1260 let check = managed_git_command(
1261 &worktree.target,
1262 &worktree.source_repository,
1263 ["show-ref", "--verify", "--quiet", &branch_ref],
1264 "check managed worktree branch",
1265 );
1266 let output = executor.execute(&check)?;
1267 match output.status {
1268 0 => {
1269 execute_checked(
1270 executor,
1271 managed_git_command(
1272 &worktree.target,
1273 &worktree.source_repository,
1274 ["branch", "-D", "--", &worktree.branch],
1275 "delete managed raw-session branch",
1276 ),
1277 )?;
1278 }
1279 1 => {}
1280 status => bail!(
1281 "check managed worktree branch failed with status {status}: {}",
1282 String::from_utf8_lossy(&output.stderr).trim()
1283 ),
1284 }
1285 remove_empty_managed_worktree_directories(executor, worktree)
1286}
1287
1288fn remove_empty_managed_worktree_directories(
1289 executor: &impl CommandExecutor,
1290 worktree: &ManagedWorktree,
1291) -> Result<()> {
1292 let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1293 let hel = worktree.source_repository.join(".mj");
1294 match &worktree.target {
1295 ManagedWorktreeTarget::Local => {
1296 for directory in [&worktrees, &hel] {
1297 match std::fs::remove_dir(directory) {
1298 Ok(()) => {}
1299 Err(error)
1300 if matches!(
1301 error.kind(),
1302 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1303 ) => {}
1304 Err(error) => return Err(error.into()),
1305 }
1306 }
1307 }
1308 ManagedWorktreeTarget::Ssh { .. } => {
1309 let command = managed_target_command(
1310 &worktree.target,
1311 "rmdir",
1312 ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1313 )
1314 .purpose("remove empty managed worktree directories");
1315 let _ = executor.execute(&command)?;
1316 }
1317 }
1318 Ok(())
1319}
1320
1321const 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";
1324
1325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1327pub enum ResumePlan {
1328 InPlace,
1330 RawToWorkspace,
1332 WorkspaceToRaw,
1334}
1335
1336pub fn resume_compatibility(
1343 session: &SessionRecord,
1344 config: &HelConfig,
1345 target_id: &str,
1346) -> Result<ResumePlan, String> {
1347 let Some(target) = config.targets.get(target_id) else {
1348 return Err(format!("target {target_id} is no longer configured"));
1349 };
1350 let Some(project_directory) = &session.project_directory else {
1351 if matches!(target, TargetTemplate::LocalBare) {
1352 return workspace_to_raw_compatibility(session, config);
1353 }
1354 return Ok(ResumePlan::InPlace);
1355 };
1356 let directory = project_directory.display();
1357 let Some(worktree) = &session.managed_worktree else {
1358 let Some(previous) = config.targets.get(&session.target_template_id) else {
1359 return Err(
1360 "the bare target this session last used is no longer configured".to_owned(),
1361 );
1362 };
1363 if is_bare_project_target(target) {
1364 if matches!(previous, TargetTemplate::LocalBare)
1365 == matches!(target, TargetTemplate::LocalBare)
1366 {
1367 return Ok(ResumePlan::InPlace);
1368 }
1369 return Err(format!(
1370 "this session opens {directory} directly on its host; resume it on the same kind of bare target"
1371 ));
1372 }
1373 if matches!(previous, TargetTemplate::LocalBare) {
1376 return Ok(ResumePlan::RawToWorkspace);
1377 }
1378 return Err(format!(
1379 "this session opens {directory} on an SSH host; resume it on a bare target there"
1380 ));
1381 };
1382 match managed_worktree_target(target) {
1383 Ok(resume_target) if resume_target == worktree.target => Ok(ResumePlan::InPlace),
1384 Ok(_) => Err(format!(
1385 "this session's working tree lives on {}; resume it there",
1386 managed_worktree_location(&worktree.target)
1387 )),
1388 Err(_) if worktree.target != ManagedWorktreeTarget::Local => Err(format!(
1389 "this session works directly in {directory} on {}; resume it on a bare target there",
1390 managed_worktree_location(&worktree.target)
1391 )),
1392 Err(_) if Some(&worktree.worktree_root) == session.project_directory.as_ref() => {
1396 Ok(ResumePlan::RawToWorkspace)
1397 }
1398 Err(_) => Err(format!(
1399 "this session opens {directory}, a subdirectory of its checkout; resume it on a bare target"
1400 )),
1401 }
1402}
1403
1404fn workspace_to_raw_compatibility(
1407 session: &SessionRecord,
1408 config: &HelConfig,
1409) -> Result<ResumePlan, String> {
1410 let Some(bundle) = config.bundles.get(&session.bundle_id) else {
1411 return Err(BUNDLE_ON_LOCAL_BARE.to_owned());
1412 };
1413 let [repository] = bundle.repositories.as_slice() else {
1414 return Err(format!(
1415 "this session's project has {} repositories; a local bare target holds one checkout — resume it on a container, SSH, or EC2 target",
1416 bundle.repositories.len()
1417 ));
1418 };
1419 if repository.local.is_none() {
1420 return Err(
1421 "this session's project came from GitHub; resume it on a container, SSH, or EC2 target"
1422 .to_owned(),
1423 );
1424 }
1425 Ok(ResumePlan::WorkspaceToRaw)
1426}
1427
1428fn managed_worktree_location(target: &ManagedWorktreeTarget) -> String {
1430 match target {
1431 ManagedWorktreeTarget::Local => "this machine".to_owned(),
1432 ManagedWorktreeTarget::Ssh { destination, .. } => destination.clone(),
1433 }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438 use std::cell::RefCell;
1439 use std::collections::BTreeMap;
1440 use std::path::{Path, PathBuf};
1441 use std::process::Command;
1442
1443 use anyhow::Result;
1444
1445 use crate::hel_controller::Controller;
1446 use crate::hel_controller::resume::apply_failed_resume_rollback;
1447 use crate::hel_controller::test_support::{
1448 checkpoint_test_session, committed_repository, local_bundle, managed_raw_session,
1449 managed_worktree_session, raw_session_on, resume_compatibility_config, ssh_worktree_target,
1450 test_git,
1451 };
1452 use hel::hel_archive::RepositoryMetadata;
1453 use hel::hel_config::{
1454 HarnessProfile, HelConfig, ProjectBundle, ProjectRepository, TargetTemplate,
1455 };
1456 use hel::hel_state::{HelState, ManagedWorktree, ManagedWorktreeTarget, SessionState};
1457 use hel::hel_targets::{
1458 CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
1459 };
1460
1461 use super::*;
1462
1463 #[test]
1464 fn local_bare_validation_accepts_projects_and_plain_directories_but_rejects_missing_paths() {
1465 let project = committed_repository();
1466 let plain = tempfile::tempdir().unwrap();
1467 let mut config = HelConfig::default();
1468 config
1469 .targets
1470 .insert("localhost".into(), TargetTemplate::LocalBare);
1471 let controller = Controller {
1472 config,
1473 state: HelState::default(),
1474 };
1475 controller
1476 .validate_project_directory("localhost", project.path(), &ProcessExecutor)
1477 .unwrap();
1478 controller
1479 .validate_project_directory("localhost", plain.path(), &ProcessExecutor)
1480 .unwrap();
1481 assert!(
1482 controller
1483 .validate_project_directory(
1484 "localhost",
1485 &plain.path().join("missing"),
1486 &ProcessExecutor
1487 )
1488 .is_err()
1489 );
1490 }
1491
1492 #[test]
1493 fn a_plain_local_directory_starts_without_creating_a_git_worktree() {
1494 let plain = tempfile::tempdir().unwrap();
1495 let session = raw_session_on("localhost", plain.path().to_str().unwrap());
1496 let session_id = session.id.clone();
1497 let mut config = HelConfig::default();
1498 config
1499 .targets
1500 .insert("localhost".into(), TargetTemplate::LocalBare);
1501 let mut controller = Controller {
1502 config,
1503 state: HelState {
1504 sessions: [(session_id.clone(), session)].into_iter().collect(),
1505 ..HelState::default()
1506 },
1507 };
1508 assert!(
1509 !controller
1510 .prepare_managed_raw_worktree(&session_id, &ProcessExecutor)
1511 .unwrap()
1512 );
1513 let session = &controller.state.sessions[&session_id];
1514 assert_eq!(session.project_directory.as_deref(), Some(plain.path()));
1515 assert!(session.managed_worktree.is_none());
1516 assert!(!plain.path().join(".git").exists());
1517 }
1518
1519 #[test]
1520 fn raw_linked_worktree_origin_matches_the_configured_github_project() {
1521 struct OriginExecutor;
1522 impl CommandExecutor for OriginExecutor {
1523 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1524 assert_eq!(
1525 command.args,
1526 [
1527 "-C",
1528 "/mnt/optane/bifrost-fird",
1529 "config",
1530 "--get",
1531 "remote.origin.url",
1532 ]
1533 );
1534 Ok(CommandOutput {
1535 status: 0,
1536 stdout: b"git@github.com:BrokkAi/bifrost-dev.git\n".to_vec(),
1537 stderr: Vec::new(),
1538 })
1539 }
1540 }
1541
1542 let mut config = HelConfig::default();
1543 config
1544 .targets
1545 .insert("localhost".into(), TargetTemplate::LocalBare);
1546 let session = raw_session_on("localhost", "/mnt/optane/bifrost-fird");
1547 let session_id = session.id.clone();
1548 let controller = Controller {
1549 config,
1550 state: HelState {
1551 sessions: [(session_id.clone(), session)].into_iter().collect(),
1552 ..HelState::default()
1553 },
1554 };
1555
1556 let source = controller
1557 .resolve_session_project_source(&session_id, &OriginExecutor)
1558 .unwrap();
1559
1560 assert_eq!(source.key, "github:brokkai/bifrost-dev");
1561 assert_eq!(source.short, "bifrost-dev");
1562 assert_eq!(source.full, "BrokkAi/bifrost-dev");
1563 }
1564 #[test]
1565 fn managed_worktree_origin_uses_source_repository_while_checkout_is_retired() {
1566 struct OriginExecutor;
1567 impl CommandExecutor for OriginExecutor {
1568 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1569 assert_eq!(
1570 command.args,
1571 [
1572 "-C",
1573 "/home/dev/project",
1574 "config",
1575 "--get",
1576 "remote.origin.url",
1577 ]
1578 );
1579 Ok(CommandOutput {
1580 status: 0,
1581 stdout: b"git@github.com:example/project.git\n".to_vec(),
1582 stderr: Vec::new(),
1583 })
1584 }
1585 }
1586
1587 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1588 let session_id = session.id.clone();
1589 let controller = Controller {
1590 config: HelConfig::default(),
1591 state: HelState {
1592 sessions: [(session_id.clone(), session)].into_iter().collect(),
1593 ..HelState::default()
1594 },
1595 };
1596
1597 let source = controller
1598 .resolve_session_project_source(&session_id, &OriginExecutor)
1599 .unwrap();
1600
1601 assert_eq!(source.key, "github:example/project");
1602 }
1603
1604 #[test]
1605 fn raw_no_origin_uses_the_canonical_main_repository_root() {
1606 struct NoOriginExecutor {
1607 commands: RefCell<Vec<CommandSpec>>,
1608 }
1609 impl CommandExecutor for NoOriginExecutor {
1610 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1611 self.commands.borrow_mut().push(command.clone());
1612 if command.args.iter().any(|argument| argument == "config") {
1613 return Ok(CommandOutput {
1614 status: 1,
1615 stdout: Vec::new(),
1616 stderr: Vec::new(),
1617 });
1618 }
1619 let stdout = if command
1620 .args
1621 .iter()
1622 .any(|argument| argument == "--show-toplevel")
1623 {
1624 "/worktrees/project-side\n"
1625 } else if command
1626 .args
1627 .iter()
1628 .any(|argument| argument == "--git-common-dir")
1629 {
1630 "/projects/project/.git\n"
1631 } else {
1632 panic!("unexpected command {:?}", command.args);
1633 };
1634 Ok(CommandOutput {
1635 status: 0,
1636 stdout: stdout.as_bytes().to_vec(),
1637 stderr: Vec::new(),
1638 })
1639 }
1640 }
1641
1642 let mut config = HelConfig::default();
1643 config
1644 .targets
1645 .insert("localhost".into(), TargetTemplate::LocalBare);
1646 let session = raw_session_on("localhost", "/worktrees/project-side");
1647 let session_id = session.id.clone();
1648 let controller = Controller {
1649 config,
1650 state: HelState {
1651 sessions: [(session_id.clone(), session)].into_iter().collect(),
1652 ..HelState::default()
1653 },
1654 };
1655 let executor = NoOriginExecutor {
1656 commands: RefCell::new(Vec::new()),
1657 };
1658
1659 let source = controller
1660 .resolve_session_project_source(&session_id, &executor)
1661 .unwrap();
1662
1663 assert_eq!(source.key, "path:/projects/project");
1664 assert_eq!(source.short, "project");
1665 assert_eq!(source.full, "/projects/project");
1666 assert_eq!(executor.commands.borrow().len(), 3);
1667 }
1668
1669 #[test]
1670 fn raw_non_git_directory_keeps_its_local_path_source() {
1671 struct NonGitExecutor {
1672 commands: RefCell<Vec<CommandSpec>>,
1673 }
1674 impl CommandExecutor for NonGitExecutor {
1675 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1676 self.commands.borrow_mut().push(command.clone());
1677 let status = if command.args.iter().any(|argument| argument == "config") {
1678 1
1679 } else {
1680 assert!(
1681 command
1682 .args
1683 .iter()
1684 .any(|argument| argument == "--show-toplevel")
1685 );
1686 128
1687 };
1688 Ok(CommandOutput {
1689 status,
1690 stdout: Vec::new(),
1691 stderr: b"fatal: not a git repository\n".to_vec(),
1692 })
1693 }
1694 }
1695
1696 let mut config = HelConfig::default();
1697 config
1698 .targets
1699 .insert("localhost".into(), TargetTemplate::LocalBare);
1700 let session = raw_session_on("localhost", "/scratch/project");
1701 let session_id = session.id.clone();
1702 let controller = Controller {
1703 config,
1704 state: HelState {
1705 sessions: [(session_id.clone(), session)].into_iter().collect(),
1706 ..HelState::default()
1707 },
1708 };
1709 let executor = NonGitExecutor {
1710 commands: RefCell::new(Vec::new()),
1711 };
1712
1713 let source = controller
1714 .resolve_session_project_source(&session_id, &executor)
1715 .unwrap();
1716
1717 assert_eq!(source.key, "path:/scratch/project");
1718 assert_eq!(source.full, "/scratch/project");
1719 assert_eq!(executor.commands.borrow().len(), 2);
1720 }
1721
1722 #[test]
1723 fn project_root_lookup_reports_git_failures_instead_of_treating_them_as_non_git() {
1724 struct FailedGit;
1725 impl CommandExecutor for FailedGit {
1726 fn execute(&self, _: &CommandSpec) -> Result<CommandOutput> {
1727 Ok(CommandOutput {
1728 status: 128,
1729 stdout: Vec::new(),
1730 stderr: b"fatal: detected dubious ownership in repository".to_vec(),
1731 })
1732 }
1733 }
1734 let error = resolve_git_root(
1735 &ManagedWorktreeTarget::Local,
1736 Path::new("/project"),
1737 &FailedGit,
1738 )
1739 .unwrap_err();
1740 assert!(error.to_string().contains("dubious ownership"));
1741 }
1742
1743 struct CheckoutPositionExecutor {
1745 head_commit: String,
1746 branch: Option<String>,
1747 }
1748 impl CommandExecutor for CheckoutPositionExecutor {
1749 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1750 let stdout = if command.args.iter().any(|argument| argument == "rev-parse") {
1751 self.head_commit.clone()
1752 } else if command
1753 .args
1754 .iter()
1755 .any(|argument| argument == "symbolic-ref")
1756 {
1757 match &self.branch {
1758 Some(branch) => branch.clone(),
1759 None => {
1760 return Ok(CommandOutput {
1761 status: 1,
1762 stdout: Vec::new(),
1763 stderr: Vec::new(),
1764 });
1765 }
1766 }
1767 } else {
1768 panic!("unexpected command {:?}", command.args);
1769 };
1770 Ok(CommandOutput {
1771 status: 0,
1772 stdout: format!("{stdout}\n").into_bytes(),
1773 stderr: Vec::new(),
1774 })
1775 }
1776 }
1777 fn recorded_repository(head_commit: &str, branch: Option<&str>) -> RepositoryMetadata {
1778 RepositoryMetadata {
1779 id: "project".into(),
1780 relative_destination: PathBuf::from("project"),
1781 origin: "mj-local:project".into(),
1782 base_commit: String::new(),
1783 head_commit: head_commit.into(),
1784 branch: branch.map(str::to_owned),
1785 }
1786 }
1787 #[test]
1788 fn a_raw_checkout_that_moved_while_stopped_gets_a_conversation_line() {
1789 let config = resume_compatibility_config();
1790 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1791 let directory = session.project_directory.clone().unwrap();
1792 let executor = CheckoutPositionExecutor {
1793 head_commit: "b".repeat(40),
1794 branch: Some("mj/0123456789abcdef0123456789abcdef".into()),
1795 };
1796
1797 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1798 let notice = raw_checkout_divergence_notice(
1799 &directory,
1800 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1801 &live,
1802 )
1803 .expect("a moved checkout is reported");
1804
1805 assert!(
1806 notice.contains(&directory.display().to_string()),
1807 "{notice}"
1808 );
1809 assert!(notice.contains("aaaaaaaaaaaa (main)"), "{notice}");
1810 assert!(
1811 notice.contains("bbbbbbbbbbbb (mj/0123456789abcdef0123456789abcdef)"),
1812 "{notice}"
1813 );
1814 assert!(
1815 notice.contains("while this session was stopped"),
1816 "{notice}"
1817 );
1818 }
1819 #[test]
1820 fn a_raw_checkout_that_stayed_put_gets_no_conversation_line() {
1821 let config = resume_compatibility_config();
1822 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1823 let directory = session.project_directory.clone().unwrap();
1824 let executor = CheckoutPositionExecutor {
1825 head_commit: "a".repeat(40),
1826 branch: Some("main".into()),
1827 };
1828
1829 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1830
1831 assert_eq!(
1832 raw_checkout_divergence_notice(
1833 &directory,
1834 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1835 &live,
1836 ),
1837 None
1838 );
1839 }
1840 #[test]
1841 fn a_checkpoint_without_recorded_git_identity_reports_nothing() {
1842 let live = CheckoutPosition {
1843 head_commit: "b".repeat(40),
1844 branch: None,
1845 };
1846
1847 assert_eq!(
1848 raw_checkout_divergence_notice(Path::new("/home/dev/project"), None, &live),
1849 None
1850 );
1851 assert_eq!(
1852 raw_checkout_divergence_notice(
1853 Path::new("/home/dev/project"),
1854 Some(&recorded_repository("", None)),
1855 &live,
1856 ),
1857 None
1858 );
1859 }
1860 #[test]
1861 fn a_detached_checkout_is_named_as_detached() {
1862 let config = resume_compatibility_config();
1863 let session = managed_raw_session(ManagedWorktreeTarget::Local);
1864 let directory = session.project_directory.clone().unwrap();
1865 let executor = CheckoutPositionExecutor {
1866 head_commit: "c".repeat(40),
1867 branch: None,
1868 };
1869
1870 let live = raw_checkout_position(&session, &config, &directory, &executor).unwrap();
1871 let notice = raw_checkout_divergence_notice(
1872 &directory,
1873 Some(&recorded_repository(&"a".repeat(40), Some("main"))),
1874 &live,
1875 )
1876 .expect("a moved checkout is reported");
1877
1878 assert!(notice.contains("cccccccccccc (detached)"), "{notice}");
1879 }
1880 #[test]
1881 fn bundle_sessions_resume_on_any_workspace_target() {
1882 let config = resume_compatibility_config();
1883 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1884
1885 assert_eq!(
1886 resume_compatibility(&session, &config, "podman"),
1887 Ok(ResumePlan::InPlace)
1888 );
1889 assert_eq!(
1890 resume_compatibility(&session, &config, "ssh-bare"),
1891 Ok(ResumePlan::InPlace)
1892 );
1893 }
1894 #[test]
1895 fn a_single_local_repository_can_become_a_checkout() {
1896 let mut config = resume_compatibility_config();
1897 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1898 session.bundle_id = "project".into();
1899 config.bundles.insert(
1900 "project".into(),
1901 local_bundle(Path::new("/home/dev/project")),
1902 );
1903
1904 assert_eq!(
1905 resume_compatibility(&session, &config, "local-bare"),
1906 Ok(ResumePlan::WorkspaceToRaw)
1907 );
1908 }
1909 #[test]
1910 fn a_github_project_cannot_become_a_checkout() {
1911 let mut config = resume_compatibility_config();
1912 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1913 session.bundle_id = "project".into();
1914 let mut bundle = local_bundle(Path::new("/home/dev/project"));
1915 bundle.repositories[0].local = None;
1916 bundle.repositories[0].github = Some("example/project".into());
1917 config.bundles.insert("project".into(), bundle);
1918
1919 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1920
1921 assert!(reason.contains("came from GitHub"), "{reason}");
1922 assert!(
1923 reason.contains("resume it on a container, SSH, or EC2 target"),
1924 "{reason}"
1925 );
1926 }
1927 #[test]
1928 fn a_multi_repository_project_cannot_become_a_checkout() {
1929 let mut config = resume_compatibility_config();
1930 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1931 session.bundle_id = "project".into();
1932 let mut bundle = local_bundle(Path::new("/home/dev/project"));
1933 bundle.repositories.push(ProjectRepository {
1934 id: "tools".into(),
1935 github: None,
1936 local: Some(PathBuf::from("/home/dev/tools")),
1937 destination: PathBuf::from("tools"),
1938 git_ref: None,
1939 });
1940 config.bundles.insert("project".into(), bundle);
1941
1942 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1943
1944 assert!(reason.contains("2 repositories"), "{reason}");
1945 assert!(reason.contains("one checkout"), "{reason}");
1946 }
1947 #[test]
1948 fn bundle_sessions_refuse_a_local_bare_target_with_a_reason() {
1949 let config = resume_compatibility_config();
1950 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
1951
1952 let reason = resume_compatibility(&session, &config, "local-bare").unwrap_err();
1953
1954 assert!(reason.contains("created from a project bundle"), "{reason}");
1955 assert!(
1956 reason.contains("resume it on a container, SSH, or EC2 target"),
1957 "{reason}"
1958 );
1959 }
1960 #[test]
1961 fn managed_raw_sessions_resume_on_their_own_worktree_host() {
1962 let config = resume_compatibility_config();
1963
1964 assert_eq!(
1965 resume_compatibility(
1966 &managed_raw_session(ManagedWorktreeTarget::Local),
1967 &config,
1968 "local-bare",
1969 ),
1970 Ok(ResumePlan::InPlace)
1971 );
1972 assert_eq!(
1973 resume_compatibility(
1974 &managed_raw_session(ssh_worktree_target()),
1975 &config,
1976 "ssh-bare",
1977 ),
1978 Ok(ResumePlan::InPlace)
1979 );
1980 }
1981 #[test]
1982 fn managed_raw_sessions_refuse_a_bare_target_on_another_host() {
1983 let config = resume_compatibility_config();
1984
1985 let reason = resume_compatibility(
1986 &managed_raw_session(ManagedWorktreeTarget::Local),
1987 &config,
1988 "ssh-bare",
1989 )
1990 .unwrap_err();
1991 assert!(reason.contains("this machine"), "{reason}");
1992
1993 let reason = resume_compatibility(
1994 &managed_raw_session(ssh_worktree_target()),
1995 &config,
1996 "local-bare",
1997 )
1998 .unwrap_err();
1999 assert!(reason.contains("dev@builder"), "{reason}");
2000 }
2001 #[test]
2002 fn a_local_raw_checkout_converts_when_it_resumes_on_a_container() {
2003 let config = resume_compatibility_config();
2004
2005 assert_eq!(
2006 resume_compatibility(
2007 &managed_raw_session(ManagedWorktreeTarget::Local),
2008 &config,
2009 "podman",
2010 ),
2011 Ok(ResumePlan::RawToWorkspace)
2012 );
2013 assert_eq!(
2014 resume_compatibility(
2015 &raw_session_on("local-bare", "/home/dev/project"),
2016 &config,
2017 "podman",
2018 ),
2019 Ok(ResumePlan::RawToWorkspace)
2020 );
2021 }
2022 #[test]
2023 fn a_raw_checkout_on_an_ssh_host_cannot_convert() {
2024 let config = resume_compatibility_config();
2025
2026 let reason = resume_compatibility(
2027 &managed_raw_session(ssh_worktree_target()),
2028 &config,
2029 "podman",
2030 )
2031 .unwrap_err();
2032 assert!(reason.contains("works directly in"), "{reason}");
2033 assert!(reason.contains("dev@builder"), "{reason}");
2034
2035 let reason = resume_compatibility(
2036 &raw_session_on("ssh-bare", "/srv/project"),
2037 &config,
2038 "podman",
2039 )
2040 .unwrap_err();
2041 assert!(reason.contains("on an SSH host"), "{reason}");
2042 }
2043 #[test]
2044 fn a_session_that_opens_a_subdirectory_of_its_worktree_cannot_convert() {
2045 let config = resume_compatibility_config();
2046 let mut session = managed_raw_session(ManagedWorktreeTarget::Local);
2047 let worktree = session.managed_worktree.as_mut().unwrap();
2048 worktree.source_project_directory = worktree.source_repository.join("crate");
2049 session.project_directory = Some(worktree.worktree_root.join("crate"));
2050
2051 let reason = resume_compatibility(&session, &config, "podman").unwrap_err();
2052
2053 assert!(reason.contains("subdirectory of its checkout"), "{reason}");
2054 }
2055 #[test]
2056 fn unmanaged_raw_sessions_require_the_same_bare_target_kind() {
2057 let config = resume_compatibility_config();
2058 let local = raw_session_on("local-bare", "/home/dev/project");
2059 let remote = raw_session_on("ssh-bare", "/srv/project");
2060
2061 assert_eq!(
2062 resume_compatibility(&local, &config, "local-bare"),
2063 Ok(ResumePlan::InPlace)
2064 );
2065 assert_eq!(
2066 resume_compatibility(&remote, &config, "ssh-bare"),
2067 Ok(ResumePlan::InPlace)
2068 );
2069 for (session, target) in [(&local, "ssh-bare"), (&remote, "local-bare")] {
2070 let reason = resume_compatibility(session, &config, target).unwrap_err();
2071 assert!(reason.contains("directly on its host"), "{reason}");
2072 }
2073 }
2074 #[test]
2075 fn resume_compatibility_names_a_target_that_is_gone() {
2076 let config = resume_compatibility_config();
2077 let session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
2078
2079 let reason = resume_compatibility(&session, &config, "retired").unwrap_err();
2080
2081 assert!(reason.contains("retired"), "{reason}");
2082 }
2083 #[test]
2084 fn managed_raw_worktree_inherits_upstream_and_cleans_up_owned_artifacts() {
2085 let repository = committed_repository();
2086 let remote_parent = tempfile::tempdir().unwrap();
2087 let remote = remote_parent.path().join("remote.git");
2088 let output = Command::new("git")
2089 .args(["init", "--bare"])
2090 .arg(&remote)
2091 .output()
2092 .unwrap();
2093 assert!(output.status.success());
2094 test_git(
2095 repository.path(),
2096 &["remote", "add", "origin", &remote.to_string_lossy()],
2097 );
2098 test_git(
2099 repository.path(),
2100 &["push", "--set-upstream", "origin", "master"],
2101 );
2102
2103 let target = ManagedWorktreeTarget::Local;
2104 let inspection =
2105 inspect_raw_project(&ProcessExecutor, &target, &repository.path().join("nested"))
2106 .unwrap();
2107 assert!(inspection.primary_checkout);
2108 assert_eq!(inspection.upstream.as_deref(), Some("origin/master"));
2109 assert_eq!(
2112 inspection.source_project_directory,
2113 repository.path().canonicalize().unwrap().join("nested")
2114 );
2115
2116 let session_id = "0123456789abcdef0123456789abcdef";
2117 let worktree = ManagedWorktree {
2118 source_project_directory: inspection.source_project_directory,
2119 source_repository: inspection.source_repository,
2120 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2121 branch: format!("mj/{session_id}"),
2122 target,
2123 };
2124 create_managed_worktree(
2125 &ProcessExecutor,
2126 &worktree,
2127 inspection.upstream.as_deref(),
2128 PrimaryCheckoutRequirement::Clean,
2129 )
2130 .unwrap();
2131 assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2132 assert_eq!(
2133 test_git(
2134 &worktree.worktree_root,
2135 &[
2136 "rev-parse",
2137 "--abbrev-ref",
2138 "--symbolic-full-name",
2139 "@{upstream}"
2140 ]
2141 ),
2142 "origin/master"
2143 );
2144 assert_eq!(test_git(repository.path(), &["status", "--porcelain"]), "");
2145 std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2146
2147 cleanup_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2148 assert!(!worktree.worktree_root.exists());
2149 assert!(!repository.path().join(".mj").exists());
2150 let output = Command::new("git")
2151 .arg("-C")
2152 .arg(repository.path())
2153 .args([
2154 "show-ref",
2155 "--verify",
2156 &format!("refs/heads/{}", worktree.branch),
2157 ])
2158 .output()
2159 .unwrap();
2160 assert!(!output.status.success());
2161 }
2162 #[test]
2163 fn retired_worktree_can_be_recreated_from_its_retained_branch() {
2164 let repository = committed_repository();
2165 let session_id = "0123456789abcdef0123456789abcdef";
2166 let session = managed_worktree_session(repository.path(), session_id);
2167 let worktree = session.managed_worktree.unwrap();
2168 std::fs::write(worktree.worktree_root.join("dirty.txt"), "session\n").unwrap();
2170
2171 retire_managed_worktree(&ProcessExecutor, &worktree).unwrap();
2172
2173 assert!(!worktree.worktree_root.exists());
2174 assert!(!repository.path().join(".mj").exists());
2175 let branch = Command::new("git")
2176 .arg("-C")
2177 .arg(repository.path())
2178 .args([
2179 "show-ref",
2180 "--verify",
2181 &format!("refs/heads/{}", worktree.branch),
2182 ])
2183 .output()
2184 .unwrap();
2185 assert!(
2186 branch.status.success(),
2187 "the session branch must survive: later checkpoints are deltas against it"
2188 );
2189
2190 assert!(restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2191 assert!(worktree.worktree_root.join("nested/file.txt").is_file());
2192 assert!(!restore_managed_worktree(&ProcessExecutor, &worktree).unwrap());
2193 }
2194 #[test]
2195 fn retiring_a_remote_worktree_prunes_registration_after_target_removed_checkout() {
2196 struct RemoteExecutor {
2197 path_checks: RefCell<usize>,
2198 commands: RefCell<Vec<CommandSpec>>,
2199 }
2200
2201 impl CommandExecutor for RemoteExecutor {
2202 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2203 self.commands.borrow_mut().push(command.clone());
2204 let status = if command.purpose == "check managed worktree path" {
2205 let mut checks = self.path_checks.borrow_mut();
2206 let status = i32::from(*checks != 0);
2207 *checks += 1;
2208 status
2209 } else {
2210 0
2211 };
2212 Ok(CommandOutput {
2213 status,
2214 stdout: Vec::new(),
2215 stderr: Vec::new(),
2216 })
2217 }
2218 }
2219
2220 let worktree = ManagedWorktree {
2221 source_project_directory: PathBuf::from("/srv/project"),
2222 source_repository: PathBuf::from("/srv/project"),
2223 worktree_root: PathBuf::from("/srv/project/.mj/worktrees/session"),
2224 branch: "mj/session".into(),
2225 target: ManagedWorktreeTarget::Ssh {
2226 destination: "builder".into(),
2227 ssh_args: Vec::new(),
2228 },
2229 };
2230 let executor = RemoteExecutor {
2231 path_checks: RefCell::new(0),
2232 commands: RefCell::new(Vec::new()),
2233 };
2234
2235 retire_managed_worktree(&executor, &worktree).unwrap();
2236
2237 let purposes = executor
2238 .commands
2239 .borrow()
2240 .iter()
2241 .map(|command| command.purpose.clone())
2242 .collect::<Vec<_>>();
2243 assert_eq!(
2244 purposes,
2245 [
2246 "check managed worktree path",
2247 "check managed worktree path",
2248 "prune managed worktree metadata",
2249 "remove empty managed worktree directories",
2250 ]
2251 );
2252 }
2253 #[test]
2254 fn a_managed_conversion_carries_the_session_worktree_not_the_primary_checkout() {
2255 let repository = committed_repository();
2256 let session_id = "0123456789abcdef0123456789abcdef";
2257 let session = managed_worktree_session(repository.path(), session_id);
2258 let worktree = session.managed_worktree.clone().unwrap();
2259
2260 let conversion =
2261 plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
2262
2263 assert_eq!(conversion.checkout, worktree.worktree_root);
2264 assert_eq!(conversion.repository, repository.path());
2265 assert_eq!(conversion.retire, Some(worktree));
2266 let bundle = conversion.new_bundle.expect("a bundle is synthesized");
2267 assert_eq!(bundle.repositories.len(), 1);
2268 assert_eq!(bundle.primary_repo, bundle.repositories[0].id);
2269 assert_eq!(
2270 bundle.repositories[0].local.as_deref(),
2271 Some(repository.path())
2272 );
2273 assert_eq!(bundle.repositories[0].github, None);
2274 assert_eq!(
2277 bundle.repositories[0].destination,
2278 PathBuf::from(session_id)
2279 );
2280 }
2281 #[test]
2282 fn an_unmanaged_conversion_serves_the_main_repository_behind_a_linked_worktree() {
2283 let repository = committed_repository();
2284 let session_id = "0123456789abcdef0123456789abcdef";
2285 let linked = managed_worktree_session(repository.path(), session_id);
2286 let checkout = linked.managed_worktree.unwrap().worktree_root;
2287 let mut session = checkpoint_test_session(session_id);
2288 session.state = SessionState::Stopped;
2289 session.target_template_id = "local-bare".into();
2290 session.project_directory = Some(checkout.clone());
2291
2292 let conversion =
2293 plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
2294
2295 assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
2296 assert_eq!(
2297 conversion.repository,
2298 repository.path().canonicalize().unwrap()
2299 );
2300 assert_eq!(conversion.retire, None);
2301 }
2302 #[cfg(unix)]
2307 #[test]
2308 fn an_unmanaged_conversion_accepts_a_checkout_reached_through_a_symlink() {
2309 let repository = committed_repository();
2310 let session_id = "0123456789abcdef0123456789abcdef";
2311 let linked = managed_worktree_session(repository.path(), session_id);
2312 let checkout = linked.managed_worktree.unwrap().worktree_root;
2313 let alias = tempfile::tempdir().unwrap();
2314 let symlink = alias.path().join("checkout");
2315 std::os::unix::fs::symlink(&checkout, &symlink).unwrap();
2316 let mut session = checkpoint_test_session(session_id);
2317 session.state = SessionState::Stopped;
2318 session.target_template_id = "local-bare".into();
2319 session.project_directory = Some(symlink);
2320
2321 let conversion =
2322 plan_raw_to_workspace(&session, &HelConfig::default(), &ProcessExecutor).unwrap();
2323
2324 assert_eq!(conversion.checkout, checkout.canonicalize().unwrap());
2325 assert_eq!(
2326 conversion.repository,
2327 repository.path().canonicalize().unwrap()
2328 );
2329 assert_eq!(conversion.retire, None);
2330 }
2331 #[test]
2332 fn a_conversion_reuses_a_bundle_that_already_describes_the_checkout() {
2333 let repository = PathBuf::from("/home/dev/project");
2334 let destination = PathBuf::from("project");
2335 let existing = ProjectBundle {
2336 primary_repo: "project".into(),
2337 repositories: vec![ProjectRepository {
2338 id: "project".into(),
2339 github: None,
2340 local: Some(repository.clone()),
2341 destination: destination.clone(),
2342 git_ref: None,
2343 }],
2344 };
2345 let mut config = HelConfig::default();
2346 config.bundles.insert("existing".into(), existing);
2347
2348 assert_eq!(
2349 converted_raw_bundle(&config, "remote-project-abcdef", &repository, &destination),
2350 ("existing".to_owned(), None)
2351 );
2352
2353 let (id, synthesized) = converted_raw_bundle(
2356 &config,
2357 "remote-project-abcdef",
2358 &repository,
2359 Path::new("elsewhere"),
2360 );
2361 assert_ne!(id, "existing");
2362 assert_eq!(
2363 synthesized.unwrap().repositories[0].destination,
2364 PathBuf::from("elsewhere")
2365 );
2366 }
2367 #[test]
2368 fn a_converted_record_is_a_valid_bundle_session() {
2369 let session_id = "0123456789abcdef0123456789abcdef";
2370 let mut config = resume_compatibility_config();
2371 let mut record = managed_raw_session(ManagedWorktreeTarget::Local);
2372 record.state = SessionState::Running;
2373 record.target_template_id = "podman".into();
2374 let conversion = RawToWorkspaceConversion {
2375 checkout: record.project_directory.clone().unwrap(),
2376 repository: PathBuf::from("/home/dev/project"),
2377 bundle_id: "project".into(),
2378 new_bundle: Some(ProjectBundle {
2379 primary_repo: "project".into(),
2380 repositories: vec![ProjectRepository {
2381 id: "project".into(),
2382 github: None,
2383 local: Some(PathBuf::from("/home/dev/project")),
2384 destination: PathBuf::from(session_id),
2385 git_ref: None,
2386 }],
2387 }),
2388 retire: record.managed_worktree.clone(),
2389 };
2390
2391 config.bundles.insert(
2392 conversion.bundle_id.clone(),
2393 conversion.new_bundle.clone().unwrap(),
2394 );
2395 config.profiles.insert(
2396 record.last_profile.clone(),
2397 HarnessProfile {
2398 kind: record.harness_kind,
2399 home: PathBuf::from("/profiles/codex"),
2400 environment: BTreeMap::new(),
2401 context_window_bytes: None,
2402 },
2403 );
2404 apply_raw_to_workspace(&mut record, &conversion);
2405
2406 assert_eq!(record.project_directory, None);
2407 assert_eq!(record.managed_worktree, None);
2408 assert_eq!(record.bundle_id, "project");
2409 let state = HelState {
2410 sessions: BTreeMap::from([(session_id.into(), record)]),
2411 ..HelState::default()
2412 };
2413 state.validate_against_config(&config).unwrap();
2414 }
2415 #[test]
2416 fn a_session_leaving_its_target_claims_a_worktree_of_its_own_repository() {
2417 let repository = committed_repository();
2418 let session_id = "0123456789abcdef0123456789abcdef";
2419 let mut session = checkpoint_test_session(session_id);
2420 session.state = SessionState::Stopped;
2421 session.bundle_id = "project".into();
2422 let mut config = resume_compatibility_config();
2423 config
2424 .bundles
2425 .insert("project".into(), local_bundle(repository.path()));
2426 let controller = Controller {
2427 config,
2428 state: HelState {
2429 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2430 ..HelState::default()
2431 },
2432 };
2433
2434 let conversion = controller
2435 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2436 .unwrap();
2437
2438 assert_eq!(
2439 conversion.worktree,
2440 ManagedWorktree {
2441 source_project_directory: repository.path().to_path_buf(),
2442 source_repository: repository.path().to_path_buf(),
2443 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2444 branch: format!("mj/{session_id}"),
2445 target: ManagedWorktreeTarget::Local,
2446 }
2447 );
2448
2449 std::fs::write(repository.path().join("dirty.txt"), "primary\n").unwrap();
2452 create_managed_worktree(
2453 &ProcessExecutor,
2454 &conversion.worktree,
2455 None,
2456 PrimaryCheckoutRequirement::Any,
2457 )
2458 .unwrap();
2459 assert!(conversion.worktree.worktree_root.is_dir());
2460
2461 let error = controller
2463 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2464 .unwrap_err();
2465 assert!(format!("{error:#}").contains("already exists"), "{error:#}");
2466 }
2467 #[test]
2468 fn a_return_to_local_reuses_its_retained_branch_and_preserves_its_tip() {
2469 let repository = committed_repository();
2470 let session_id = "0123456789abcdef0123456789abcdef";
2471 let branch = format!("mj/{session_id}");
2472 let original_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
2473 test_git(repository.path(), &["branch", &branch]);
2474 let mut config = resume_compatibility_config();
2475 config
2476 .bundles
2477 .insert("project".into(), local_bundle(repository.path()));
2478 let session = checkpoint_test_session(session_id);
2479 let controller = Controller {
2480 config,
2481 state: HelState {
2482 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2483 ..HelState::default()
2484 },
2485 };
2486
2487 let conversion = controller
2488 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2489 .unwrap();
2490
2491 assert!(conversion.reuse_existing_branch);
2492 assert!(!conversion.worktree.worktree_root.exists());
2493 let recovery_ref =
2494 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2495 .unwrap();
2496 assert_eq!(
2497 recovery_ref,
2498 format!("refs/mj/recovery/{session_id}/{original_tip}")
2499 );
2500 assert_eq!(
2501 test_git(
2502 repository.path(),
2503 &["show-ref", "--hash", recovery_ref.as_str()],
2504 ),
2505 original_tip
2506 );
2507 assert_eq!(
2508 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2509 .unwrap(),
2510 recovery_ref
2511 );
2512
2513 test_git(repository.path(), &["checkout", &branch]);
2514 test_git(
2515 repository.path(),
2516 &["commit", "--allow-empty", "-m", "later retained tip"],
2517 );
2518 let later_tip = test_git(repository.path(), &["rev-parse", "HEAD"]);
2519 test_git(repository.path(), &["checkout", "master"]);
2520 let later_recovery_ref =
2521 preserve_retained_managed_worktree_branch(&ProcessExecutor, &conversion.worktree)
2522 .unwrap();
2523 assert_eq!(
2524 later_recovery_ref,
2525 format!("refs/mj/recovery/{session_id}/{later_tip}")
2526 );
2527 assert_ne!(later_recovery_ref, recovery_ref);
2528 assert_eq!(
2529 test_git(
2530 repository.path(),
2531 &["show-ref", "--hash", recovery_ref.as_str()],
2532 ),
2533 original_tip
2534 );
2535 }
2536 #[test]
2537 fn a_return_to_local_rejects_a_retained_branch_checked_out_elsewhere() {
2538 let repository = committed_repository();
2539 let session_id = "0123456789abcdef0123456789abcdef";
2540 let branch = format!("mj/{session_id}");
2541 test_git(repository.path(), &["branch", &branch]);
2542 let elsewhere = repository.path().join("other-worktree");
2543 test_git(
2544 repository.path(),
2545 &["worktree", "add", &elsewhere.to_string_lossy(), &branch],
2546 );
2547 let mut config = resume_compatibility_config();
2548 config
2549 .bundles
2550 .insert("project".into(), local_bundle(repository.path()));
2551 let session = checkpoint_test_session(session_id);
2552 let controller = Controller {
2553 config,
2554 state: HelState {
2555 sessions: BTreeMap::from([(session_id.into(), session.clone())]),
2556 ..HelState::default()
2557 },
2558 };
2559
2560 let error = controller
2561 .plan_workspace_to_raw(&session, "local-bare", &ProcessExecutor)
2562 .unwrap_err();
2563
2564 assert!(error.to_string().contains("still checked out"), "{error:#}");
2565 assert!(
2566 !repository
2567 .path()
2568 .join(".mj/worktrees")
2569 .join(session_id)
2570 .exists()
2571 );
2572 assert_eq!(
2573 test_git(repository.path(), &["rev-parse", &branch]),
2574 test_git(repository.path(), &["rev-parse", "HEAD"])
2575 );
2576 }
2577 #[test]
2578 fn a_session_that_left_its_target_is_a_valid_raw_session() {
2579 let session_id = "0123456789abcdef0123456789abcdef";
2580 let repository = PathBuf::from("/home/dev/project");
2581 let mut config = resume_compatibility_config();
2582 config
2583 .bundles
2584 .insert("project".into(), local_bundle(&repository));
2585 config.profiles.insert(
2586 "codex".into(),
2587 HarnessProfile {
2588 kind: hel::hel_config::HarnessKind::Codex,
2589 home: PathBuf::from("/profiles/codex"),
2590 environment: BTreeMap::new(),
2591 context_window_bytes: None,
2592 },
2593 );
2594 let mut record = checkpoint_test_session(session_id);
2595 record.bundle_id = "project".into();
2596 record.target_template_id = "local-bare".into();
2597 let conversion = WorkspaceToRawConversion {
2598 worktree: ManagedWorktree {
2599 source_project_directory: repository.clone(),
2600 source_repository: repository.clone(),
2601 worktree_root: repository.join(".mj/worktrees").join(session_id),
2602 branch: format!("mj/{session_id}"),
2603 target: ManagedWorktreeTarget::Local,
2604 },
2605 reuse_existing_branch: false,
2606 };
2607
2608 apply_workspace_to_raw(&mut record, &conversion);
2609
2610 assert_eq!(
2611 record.project_directory.as_deref(),
2612 Some(conversion.worktree.worktree_root.as_path())
2613 );
2614 assert_eq!(record.bundle_id, "project", "the bundle still describes it");
2615 let state = HelState {
2616 sessions: BTreeMap::from([(session_id.into(), record)]),
2617 ..HelState::default()
2618 };
2619 state.validate_against_config(&config).unwrap();
2620 }
2621 #[test]
2622 fn a_failed_departure_returns_the_session_to_its_bundle() {
2623 let session_id = "0123456789abcdef0123456789abcdef";
2624 let repository = PathBuf::from("/home/dev/project");
2625 let previous = {
2626 let mut record = checkpoint_test_session(session_id);
2627 record.state = SessionState::Stopped;
2628 record.bundle_id = "project".into();
2629 record
2630 };
2631 let mut converted = previous.clone();
2632 converted.state = SessionState::Provisioning;
2633 apply_workspace_to_raw(
2634 &mut converted,
2635 &WorkspaceToRawConversion {
2636 worktree: ManagedWorktree {
2637 source_project_directory: repository.clone(),
2638 source_repository: repository.clone(),
2639 worktree_root: repository.join(".mj/worktrees").join(session_id),
2640 branch: format!("mj/{session_id}"),
2641 target: ManagedWorktreeTarget::Local,
2642 },
2643 reuse_existing_branch: false,
2644 },
2645 );
2646
2647 apply_failed_resume_rollback(&mut converted, &previous, "podman is unavailable", None);
2648
2649 assert_eq!(converted.project_directory, None);
2650 assert_eq!(converted.managed_worktree, None);
2651 assert_eq!(converted.bundle_id, "project");
2652 }
2653 #[test]
2654 fn a_failed_conversion_returns_the_session_to_its_checkout() {
2655 let previous = managed_raw_session(ManagedWorktreeTarget::Local);
2656 let mut converted = previous.clone();
2657 converted.state = SessionState::Provisioning;
2658 converted.target_template_id = "podman".into();
2659 apply_raw_to_workspace(
2660 &mut converted,
2661 &RawToWorkspaceConversion {
2662 checkout: previous.project_directory.clone().unwrap(),
2663 repository: PathBuf::from("/home/dev/project"),
2664 bundle_id: "project".into(),
2665 new_bundle: None,
2666 retire: previous.managed_worktree.clone(),
2667 },
2668 );
2669
2670 let mut cleaned = converted.clone();
2671 apply_failed_resume_rollback(&mut cleaned, &previous, "podman is unavailable", None);
2672 assert_eq!(cleaned.project_directory, previous.project_directory);
2673 assert_eq!(cleaned.managed_worktree, previous.managed_worktree);
2674 assert_eq!(cleaned.bundle_id, previous.bundle_id);
2675
2676 let mut stranded = converted;
2679 apply_failed_resume_rollback(
2680 &mut stranded,
2681 &previous,
2682 "podman is unavailable",
2683 Some("podman rm failed".into()),
2684 );
2685 assert_eq!(stranded.state, SessionState::Error);
2686 assert_eq!(stranded.project_directory, previous.project_directory);
2687 assert_eq!(stranded.managed_worktree, previous.managed_worktree);
2688 assert_eq!(stranded.bundle_id, previous.bundle_id);
2689 }
2690 #[test]
2691 fn cancelled_new_session_cleanup_removes_managed_worktree_and_branch() {
2692 let repository = committed_repository();
2693 let session_id = "0123456789abcdef0123456789abcdef";
2694 let worktree = ManagedWorktree {
2695 source_project_directory: repository.path().to_path_buf(),
2696 source_repository: repository.path().to_path_buf(),
2697 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2698 branch: format!("mj/{session_id}"),
2699 target: ManagedWorktreeTarget::Local,
2700 };
2701 create_managed_worktree(
2702 &ProcessExecutor,
2703 &worktree,
2704 None,
2705 PrimaryCheckoutRequirement::Clean,
2706 )
2707 .unwrap();
2708
2709 let mut session = checkpoint_test_session(session_id);
2710 session.project_directory = Some(worktree.worktree_root.clone());
2711 session.managed_worktree = Some(worktree.clone());
2712 let controller = Controller {
2713 config: HelConfig::default(),
2714 state: HelState {
2715 sessions: BTreeMap::from([(session_id.into(), session)]),
2716 ..HelState::default()
2717 },
2718 };
2719 let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2720 let executor = CancellableProcessExecutor::new(cancelled);
2721
2722 controller
2723 .cleanup_new_session_worktree_after_failure(session_id, &executor)
2724 .unwrap();
2725
2726 assert!(!worktree.worktree_root.exists());
2727 assert!(!repository.path().join(".mj").exists());
2728 let branch = Command::new("git")
2729 .arg("-C")
2730 .arg(repository.path())
2731 .args([
2732 "show-ref",
2733 "--verify",
2734 &format!("refs/heads/{}", worktree.branch),
2735 ])
2736 .output()
2737 .unwrap();
2738 assert!(!branch.status.success());
2739 }
2740 #[test]
2741 fn managed_raw_worktree_refuses_dirty_primary_and_skips_existing_worktree() {
2742 let repository = committed_repository();
2743 std::fs::write(repository.path().join("dirty.txt"), "dirty\n").unwrap();
2744 let target = ManagedWorktreeTarget::Local;
2745 let inspection = inspect_raw_project(&ProcessExecutor, &target, repository.path()).unwrap();
2746 let session_id = "fedcba9876543210fedcba9876543210";
2747 let managed = ManagedWorktree {
2748 source_project_directory: inspection.source_project_directory,
2749 source_repository: inspection.source_repository,
2750 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2751 branch: format!("mj/{session_id}"),
2752 target: target.clone(),
2753 };
2754 let error = create_managed_worktree(
2755 &ProcessExecutor,
2756 &managed,
2757 None,
2758 PrimaryCheckoutRequirement::Clean,
2759 )
2760 .unwrap_err();
2761 assert!(error.to_string().contains("uncommitted changes"));
2762 assert!(!managed.worktree_root.exists());
2763
2764 std::fs::remove_file(repository.path().join("dirty.txt")).unwrap();
2765 let existing = repository.path().join("existing-worktree");
2766 test_git(
2767 repository.path(),
2768 &[
2769 "worktree",
2770 "add",
2771 "--detach",
2772 &existing.to_string_lossy(),
2773 "HEAD",
2774 ],
2775 );
2776 let linked = inspect_raw_project(&ProcessExecutor, &target, &existing).unwrap();
2777 assert!(!linked.primary_checkout);
2778 }
2779 #[test]
2780 fn managed_worktree_preflight_preserves_colliding_branch_and_directory() {
2781 let repository = committed_repository();
2782 let target = ManagedWorktreeTarget::Local;
2783 let session_id = "abcdef0123456789abcdef0123456789";
2784 let branch = format!("mj/{session_id}");
2785 test_git(repository.path(), &["branch", &branch]);
2786 let worktree = ManagedWorktree {
2787 source_project_directory: repository.path().to_path_buf(),
2788 source_repository: repository.path().to_path_buf(),
2789 worktree_root: repository.path().join(".mj/worktrees").join(session_id),
2790 branch: branch.clone(),
2791 target,
2792 };
2793
2794 let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2795 assert!(error.to_string().contains("branch already exists"));
2796 assert!(
2797 !test_git(
2798 repository.path(),
2799 &["show-ref", "--verify", &format!("refs/heads/{branch}")]
2800 )
2801 .is_empty()
2802 );
2803 std::fs::create_dir_all(&worktree.worktree_root).unwrap();
2804 let error = ensure_managed_worktree_available(&ProcessExecutor, &worktree).unwrap_err();
2805 assert!(error.to_string().contains("path already exists"));
2806 assert!(worktree.worktree_root.is_dir());
2807 }
2808 #[test]
2809 fn managed_worktree_ssh_commands_preserve_hostile_path_boundaries() {
2810 let target = ManagedWorktreeTarget::Ssh {
2811 destination: "builder".into(),
2812 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
2813 };
2814 let command = managed_git_command(
2815 &target,
2816 Path::new("/srv/project with ' quote"),
2817 ["worktree", "prune"],
2818 "prune test",
2819 );
2820 assert_eq!(command.program, "ssh");
2821 assert_eq!(&command.args[..3], ["-o", "BatchMode=yes", "builder"]);
2822 assert_eq!(
2823 command.args[3],
2824 "'git' '-C' '/srv/project with '\\'' quote' 'worktree' 'prune'"
2825 );
2826 }
2827}