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