1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use anyhow::{Context, Result, bail, ensure};
7
8use mj_core::config::{Config, ProjectBundle, TargetTemplate};
9use mj_core::local_git::canonical_repository;
10use mj_core::state::{
11 ManagedWorktree, ManagedWorktreeOptions, ManagedWorktreeTarget, ProjectSourceIdentity,
12 SessionRecord,
13};
14
15use crate::targets::{
16 self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
17};
18pub(super) use mj_client::target::managed_worktree_target;
19pub use mj_client::target::{ResumePlan, resume_compatibility};
20
21use super::{Controller, execute_checked, now};
22
23impl Controller {
24 pub fn managed_worktree_options(
26 &self,
27 target_id: &str,
28 directory: &Path,
29 executor: &impl CommandExecutor,
30 ) -> Result<ManagedWorktreeOptions> {
31 let template = self
32 .config
33 .targets
34 .get(target_id)
35 .with_context(|| format!("unknown target template {target_id:?}"))?;
36 if !mj_core::config::is_bare_project_target(template) {
37 return Ok(ManagedWorktreeOptions::default());
38 }
39 let target = managed_worktree_target(template)?;
40 if matches!(target, ManagedWorktreeTarget::Local)
41 && local_project_repository(directory, executor)?.is_none()
42 {
43 return Ok(ManagedWorktreeOptions::default());
44 }
45 let inspection = inspect_raw_project(executor, &target, directory)?;
46 Ok(ManagedWorktreeOptions {
47 available: true,
48 default_create: inspection.primary_checkout,
49 })
50 }
51
52 pub fn resolve_project_directory(
54 &self,
55 target_id: &str,
56 directory: &Path,
57 executor: &impl CommandExecutor,
58 ) -> Result<PathBuf> {
59 mj_core::path_input::validate_absolute_input(directory)?;
60 let directory = self.resolve_input_path(target_id, directory, executor)?;
61 self.validate_project_directory(target_id, &directory, executor)?;
62 Ok(directory)
63 }
64
65 pub fn validate_project_directory(
67 &self,
68 target_id: &str,
69 directory: &Path,
70 executor: &impl CommandExecutor,
71 ) -> Result<()> {
72 let target = self
73 .config
74 .targets
75 .get(target_id)
76 .with_context(|| format!("unknown target template {target_id:?}"))?;
77 match target {
78 TargetTemplate::LocalBare => {
79 ensure!(
80 directory.is_dir(),
81 "project directory does not exist or is not a directory"
82 );
83 if local_project_repository(directory, executor)?.is_none() {
84 return Ok(());
85 }
86 let output = executor.execute(
87 &CommandSpec::new(
88 "git",
89 [
90 "-C",
91 &directory.to_string_lossy(),
92 "rev-parse",
93 "--verify",
94 "HEAD",
95 ],
96 )
97 .purpose("verify local bare Git project"),
98 )?;
99 ensure!(
100 output.status == 0
101 && !String::from_utf8_lossy(&output.stdout).trim().is_empty(),
102 "project directory has no valid Git HEAD: {}",
103 String::from_utf8_lossy(&output.stderr).trim()
104 );
105 Ok(())
106 }
107 TargetTemplate::SshBare { ssh, .. } => {
108 targets::validate_bare_project_directory(
109 &SshTarget::from(ssh),
110 directory,
111 executor,
112 )?;
113 mj_core::remote_git::resolve_local_repository(
114 directory,
115 &RemoteGitExecutor {
116 executor,
117 ssh: SshTarget::from(ssh),
118 },
119 )?;
120 Ok(())
121 }
122 _ => bail!("project directory validation requires a bare target"),
123 }
124 }
125
126 pub fn resolve_session_project_source(
130 &self,
131 session_id: &str,
132 executor: &impl CommandExecutor,
133 ) -> Result<ProjectSourceIdentity> {
134 let session = self
135 .state
136 .sessions
137 .get(session_id)
138 .with_context(|| format!("unknown session {session_id}"))?;
139 let Some(directory) = session.project_directory.as_deref() else {
140 return Ok(session.project_source(&self.config));
141 };
142 let (target, origin_directory) = match &session.managed_worktree {
143 Some(worktree) => (
147 worktree.target.clone(),
148 worktree.source_repository.as_path(),
149 ),
150 None => (
151 managed_worktree_target(
152 self.config
153 .targets
154 .get(&session.target_template_id)
155 .with_context(|| {
156 format!(
157 "session {session_id} target {:?} is no longer configured",
158 session.target_template_id
159 )
160 })?,
161 )?,
162 directory,
163 ),
164 };
165 let output = executor.execute(&managed_git_command(
166 &target,
167 origin_directory,
168 ["config", "--get", "remote.origin.url"],
169 "resolve project Git origin",
170 ))?;
171 match output.status {
172 0 => {
173 let origin =
174 String::from_utf8(output.stdout).context("project Git origin was not UTF-8")?;
175 if let Some(identity) = ProjectSourceIdentity::git_remote(origin.trim()) {
176 return Ok(identity);
177 }
178 }
179 1 => {}
181 status => bail!(
182 "resolve project Git origin failed with status {status}: {}",
183 String::from_utf8_lossy(&output.stderr).trim()
184 ),
185 }
186 let root = resolve_git_root(&target, origin_directory, executor)?
187 .unwrap_or_else(|| origin_directory.to_path_buf());
188 let remote = match &target {
189 ManagedWorktreeTarget::Local => None,
190 ManagedWorktreeTarget::Ssh { destination, .. } => Some(destination.as_str()),
191 };
192 Ok(ProjectSourceIdentity::path(&root, remote))
193 }
194
195 pub(super) fn plan_workspace_to_raw(
198 &self,
199 session: &SessionRecord,
200 target_id: &str,
201 executor: &impl CommandExecutor,
202 ) -> Result<WorkspaceToRawConversion> {
203 let bundle = self
204 .config
205 .bundles
206 .get(&session.bundle_id)
207 .context("session bundle is missing")?;
208 let [repository] = bundle.repositories.as_slice() else {
209 bail!("a checkout holds exactly one repository");
210 };
211 let source = repository
212 .local
213 .as_deref()
214 .context("only a repository already on this machine can become a checkout")?;
215 self.validate_project_directory(target_id, source, executor)
216 .context("this session's repository is unavailable")?;
217 let mut worktree = ManagedWorktree {
218 source_project_directory: source.to_path_buf(),
219 source_repository: source.to_path_buf(),
220 worktree_root: source.join(".mj").join("worktrees").join(&session.id),
221 branch: format!("mj/{}", session.id),
222 target: managed_worktree_target(
223 self.config
224 .targets
225 .get(target_id)
226 .with_context(|| format!("unknown target template {target_id:?}"))?,
227 )?,
228 base_commit: None,
229 };
230 let reuse_existing_branch =
231 retained_managed_worktree_branch_available(executor, &worktree)?;
232 if !reuse_existing_branch {
236 worktree.base_commit =
237 Some(read_checkout_position(executor, &worktree.target, source)?.head_commit);
238 }
239 if !reuse_existing_branch {
240 ensure_managed_worktree_available(executor, &worktree)?;
241 }
242 Ok(WorkspaceToRawConversion {
243 worktree,
244 reuse_existing_branch,
245 })
246 }
247
248 pub(super) fn prepare_managed_raw_worktree(
249 &mut self,
250 session_id: &str,
251 executor: &impl CommandExecutor,
252 ) -> Result<bool> {
253 let session = self
254 .state
255 .sessions
256 .get(session_id)
257 .with_context(|| format!("unknown session {session_id}"))?
258 .clone();
259 let Some(selected) = session.project_directory.as_deref() else {
260 return Ok(false);
261 };
262 if session.managed_worktree.is_some() {
263 return Ok(false);
264 }
265 if session.create_managed_worktree == Some(false) {
266 return Ok(false);
267 }
268 let template = self
269 .config
270 .targets
271 .get(&session.target_template_id)
272 .context("raw session target template disappeared during provisioning")?;
273 if matches!(template, TargetTemplate::SshBare { .. }) {
274 self.validate_project_directory(&session.target_template_id, selected, executor)?;
275 }
276 let target = managed_worktree_target(template)?;
277 if matches!(target, ManagedWorktreeTarget::Local)
278 && local_project_repository(selected, executor)?.is_none()
279 {
280 ensure!(
281 session.create_managed_worktree != Some(true),
282 "managed worktree creation requires a Git project"
283 );
284 return Ok(false);
285 }
286 let inspection = inspect_raw_project(executor, &target, selected)?;
287 if !inspection.primary_checkout && session.create_managed_worktree != Some(true) {
288 return Ok(false);
289 }
290 let relative_directory = inspection
291 .source_project_directory
292 .strip_prefix(&inspection.source_repository)
293 .context("raw project directory is outside its repository")?
294 .to_path_buf();
295 let worktree_root = inspection
296 .source_repository
297 .join(".mj")
298 .join("worktrees")
299 .join(session_id);
300 let base_commit =
303 read_checkout_position(executor, &target, &inspection.source_repository)?.head_commit;
304 let managed = ManagedWorktree {
305 source_project_directory: inspection.source_project_directory,
306 source_repository: inspection.source_repository,
307 worktree_root: worktree_root.clone(),
308 branch: format!("mj/{session_id}"),
309 target,
310 base_commit: Some(base_commit),
311 };
312 ensure_managed_worktree_available(executor, &managed)?;
313 let record = self.state.sessions.get_mut(session_id).unwrap();
314 record.project_directory = Some(worktree_root.join(relative_directory));
315 record.managed_worktree = Some(managed.clone());
316 record.updated_at = now();
317 self.persist_session_state(session_id)?;
318 create_managed_worktree(
319 executor,
320 &managed,
321 inspection.upstream.as_deref(),
322 PrimaryCheckoutRequirement::Clean,
323 )?;
324 Ok(true)
325 }
326
327 fn cleanup_new_session_worktree(
328 &self,
329 session_id: &str,
330 executor: &impl CommandExecutor,
331 ) -> Result<()> {
332 let Some(worktree) = self
333 .state
334 .sessions
335 .get(session_id)
336 .and_then(|session| session.managed_worktree.as_ref())
337 else {
338 return Ok(());
339 };
340 cleanup_managed_worktree(executor, worktree)
341 }
342
343 pub(super) fn cleanup_new_session_worktree_after_failure(
344 &self,
345 session_id: &str,
346 executor: &impl CommandExecutor,
347 ) -> Result<()> {
348 if executor.cancellation_requested() {
349 let cleanup_executor =
350 CancellableProcessExecutor::with_timeout(Duration::from_secs(15));
351 self.cleanup_new_session_worktree(session_id, &cleanup_executor)
352 } else {
353 self.cleanup_new_session_worktree(session_id, executor)
354 }
355 }
356}
357
358struct RemoteGitExecutor<'a, E> {
360 executor: &'a E,
361 ssh: SshTarget,
362}
363
364impl<E: CommandExecutor> CommandExecutor for RemoteGitExecutor<'_, E> {
365 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
366 let mut arguments = vec!["env".to_owned()];
367 arguments.extend(
368 command
369 .env
370 .iter()
371 .map(|(key, value)| format!("{key}={value}")),
372 );
373 arguments.push(command.program.clone());
374 arguments.extend(command.args.clone());
375 self.executor
376 .execute(&crate::targets::ssh_command(&self.ssh, arguments).purpose(&command.purpose))
377 }
378
379 fn cancellation_requested(&self) -> bool {
380 self.executor.cancellation_requested()
381 }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq)]
385struct RawProjectInspection {
386 source_project_directory: PathBuf,
387 source_repository: PathBuf,
388 primary_checkout: bool,
389 upstream: Option<String>,
390}
391
392fn managed_target_ssh(target: &ManagedWorktreeTarget) -> Option<SshTarget> {
393 match target {
394 ManagedWorktreeTarget::Local => None,
395 ManagedWorktreeTarget::Ssh {
396 destination,
397 ssh_args,
398 } => Some(SshTarget {
399 destination: destination.clone(),
400 ssh_args: ssh_args.clone(),
401 }),
402 }
403}
404
405fn managed_target_command(
406 target: &ManagedWorktreeTarget,
407 program: &str,
408 args: impl IntoIterator<Item = impl AsRef<str>>,
409) -> CommandSpec {
410 let args = args
411 .into_iter()
412 .map(|arg| arg.as_ref().to_owned())
413 .collect::<Vec<_>>();
414 match managed_target_ssh(target) {
415 None => CommandSpec::new(program, args),
416 Some(ssh) => {
417 let mut remote = vec![program.to_owned()];
418 remote.extend(args);
419 crate::targets::ssh_command(&ssh, remote)
420 }
421 }
422}
423
424fn managed_git_command(
425 target: &ManagedWorktreeTarget,
426 directory: &Path,
427 args: impl IntoIterator<Item = impl AsRef<str>>,
428 purpose: impl Into<String>,
429) -> CommandSpec {
430 let mut command_args = vec!["-C".to_owned(), directory.to_string_lossy().into_owned()];
431 command_args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
432 managed_target_command(target, "git", command_args).purpose(purpose)
433}
434
435fn command_stdout(output: CommandOutput, purpose: &str) -> Result<String> {
436 if output.status != 0 {
437 bail!(
438 "{purpose} failed with status {}: {}",
439 output.status,
440 String::from_utf8_lossy(&output.stderr).trim()
441 );
442 }
443 let stdout = String::from_utf8(output.stdout)
444 .with_context(|| format!("{purpose} produced non-UTF-8 output"))?;
445 Ok(stdout.trim_end_matches(['\r', '\n']).to_owned())
446}
447
448fn managed_git_stdout(
449 executor: &impl CommandExecutor,
450 target: &ManagedWorktreeTarget,
451 directory: &Path,
452 args: impl IntoIterator<Item = impl AsRef<str>>,
453 purpose: &str,
454) -> Result<String> {
455 let command = managed_git_command(target, directory, args, purpose);
456 command_stdout(executor.execute(&command)?, purpose)
457}
458
459fn resolve_git_root(
462 target: &ManagedWorktreeTarget,
463 directory: &Path,
464 executor: &impl CommandExecutor,
465) -> Result<Option<PathBuf>> {
466 let args = [
469 "-C".to_owned(),
470 directory.to_string_lossy().into_owned(),
471 "rev-parse".into(),
472 "--path-format=absolute".into(),
473 "--show-toplevel".into(),
474 ];
475 let top_level = match target {
476 ManagedWorktreeTarget::Local => {
477 let mut command = CommandSpec::new("git", args);
478 command.env.insert("LC_ALL".into(), "C".into());
479 command
480 }
481 ManagedWorktreeTarget::Ssh { .. } => managed_target_command(
482 target,
483 "env",
484 ["LC_ALL=C".to_owned(), "git".into()]
485 .into_iter()
486 .chain(args),
487 ),
488 }
489 .purpose("resolve project Git root");
490 let output = executor.execute(&top_level)?;
491 if output.status != 0 {
492 if output.status == 128
493 && String::from_utf8_lossy(&output.stderr).starts_with("fatal: not a git repository")
494 {
495 return Ok(None);
496 }
497 bail!(
498 "resolve project Git root failed with status {}: {}",
499 output.status,
500 String::from_utf8_lossy(&output.stderr).trim()
501 );
502 }
503 let root = PathBuf::from(
504 String::from_utf8(output.stdout)
505 .context("project Git root was not UTF-8")?
506 .trim_end_matches(['\r', '\n']),
507 );
508 if root.as_os_str().is_empty() {
509 bail!("resolve project Git root returned an empty path");
510 }
511
512 let common = PathBuf::from(managed_git_stdout(
513 executor,
514 target,
515 directory,
516 ["rev-parse", "--path-format=absolute", "--git-common-dir"],
517 "resolve project Git common directory",
518 )?);
519 if common.file_name() == Some(std::ffi::OsStr::new(".git"))
520 && let Some(main_root) = common.parent()
521 {
522 return Ok(Some(main_root.to_path_buf()));
523 }
524 Ok(Some(root))
525}
526
527pub fn local_project_repository(
530 directory: &Path,
531 executor: &impl CommandExecutor,
532) -> Result<Option<PathBuf>> {
533 resolve_git_root(&ManagedWorktreeTarget::Local, directory, executor)
534}
535
536pub(super) fn apply_raw_to_workspace(
541 record: &mut SessionRecord,
542 conversion: &RawToWorkspaceConversion,
543) {
544 record.project_directory = None;
545 record.managed_worktree = None;
546 record.bundle_id.clone_from(&conversion.bundle_id);
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
552pub(super) enum ResumeConversion {
553 RawToWorkspace(RawToWorkspaceConversion),
554 WorkspaceToRaw(WorkspaceToRawConversion),
555}
556
557impl ResumeConversion {
558 pub(super) fn raw_to_workspace(&self) -> Option<&RawToWorkspaceConversion> {
559 match self {
560 Self::RawToWorkspace(conversion) => Some(conversion),
561 Self::WorkspaceToRaw(_) => None,
562 }
563 }
564
565 pub(super) fn workspace_to_raw(&self) -> Option<&WorkspaceToRawConversion> {
566 match self {
567 Self::WorkspaceToRaw(conversion) => Some(conversion),
568 Self::RawToWorkspace(_) => None,
569 }
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
577pub(super) struct WorkspaceToRawConversion {
578 pub(super) worktree: ManagedWorktree,
579 pub(super) reuse_existing_branch: bool,
583}
584
585pub(super) fn apply_workspace_to_raw(
588 record: &mut SessionRecord,
589 conversion: &WorkspaceToRawConversion,
590) {
591 record.project_directory = Some(conversion.worktree.worktree_root.clone());
592 record.managed_worktree = Some(conversion.worktree.clone());
593}
594
595#[derive(Debug, Clone, PartialEq, Eq)]
598pub(super) struct RawToWorkspaceConversion {
599 pub(super) checkout: PathBuf,
603 pub(super) repository: PathBuf,
605 pub(super) source: mj_core::remote_git::NetworkGitSource,
609 pub(super) bundle_id: String,
610 pub(super) new_bundle: Option<ProjectBundle>,
612 pub(super) retire: Option<ManagedWorktree>,
614}
615
616pub(super) fn plan_raw_to_workspace(
619 session: &SessionRecord,
620 config: &Config,
621 executor: &impl CommandExecutor,
622) -> Result<RawToWorkspaceConversion> {
623 let project_directory = session
624 .project_directory
625 .as_deref()
626 .context("a raw session has no project directory")?;
627 let (checkout, repository, retire) = match &session.managed_worktree {
633 Some(worktree) => {
634 ensure!(
635 worktree.worktree_root == project_directory,
636 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
637 project_directory.display()
638 );
639 (
640 worktree.worktree_root.clone(),
641 worktree.source_repository.clone(),
642 Some(worktree.clone()),
643 )
644 }
645 None => {
646 let inspection =
647 inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
648 ensure!(
649 inspection.source_project_directory == inspection.source_repository,
650 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
651 project_directory.display()
652 );
653 let repository = canonical_repository(&inspection.source_repository)?;
654 (inspection.source_repository, repository, None)
655 }
656 };
657 let destination = PathBuf::from(
661 project_directory
662 .file_name()
663 .context("a raw project directory cannot be the filesystem root")?,
664 );
665 let (bundle_id, new_bundle) =
666 converted_raw_bundle(config, &session.bundle_id, &repository, &destination);
667 let source = mj_core::remote_git::resolve_local_repository(&checkout, executor).with_context(
671 || {
672 format!(
673 "{} has no network Git remote; add one (for example `git remote add origin <url>`) or resume this session on a bare target",
674 checkout.display()
675 )
676 },
677 )?;
678 Ok(RawToWorkspaceConversion {
679 checkout,
680 repository,
681 source,
682 bundle_id,
683 new_bundle,
684 retire,
685 })
686}
687
688fn converted_raw_bundle(
692 config: &Config,
693 session_bundle_id: &str,
694 repository: &Path,
695 destination: &Path,
696) -> (String, Option<ProjectBundle>) {
697 let describes_checkout = |bundle: &ProjectBundle| {
698 bundle.repositories.len() == 1
699 && bundle.repositories[0].github.is_none()
700 && bundle.repositories[0].local.as_deref() == Some(repository)
701 && bundle.repositories[0].destination == destination
702 };
703 if config
704 .bundles
705 .get(session_bundle_id)
706 .is_some_and(describes_checkout)
707 {
708 return (session_bundle_id.to_owned(), None);
709 }
710 if let Some((id, _)) = config
711 .bundles
712 .iter()
713 .find(|(_, bundle)| describes_checkout(bundle))
714 {
715 return (id.clone(), None);
716 }
717 let name = repository
718 .file_name()
719 .map(|name| name.to_string_lossy().into_owned())
720 .unwrap_or_default();
721 let id = crate::import::unique_bundle_id(config, &crate::import::setup_style_id(&name));
722 let bundle = ProjectBundle {
723 primary_repo: id.clone(),
724 repositories: vec![mj_core::config::ProjectRepository {
725 id: id.clone(),
726 github: None,
727 local: Some(repository.to_path_buf()),
728 destination: destination.to_path_buf(),
729 git_ref: None,
730 }],
731 };
732 (id, Some(bundle))
733}
734
735const RAW_CONVERSION_REPOSITORY_ID: &str = "project";
738
739pub(super) fn raw_checkout_snapshot(
747 checkout: &Path,
748 source: &mj_core::remote_git::NetworkGitSource,
749 destination: &Path,
750 git: &dyn mj_checkpoint::archive::GitCommandRunner,
751) -> Result<mj_checkpoint::archive::RepositorySnapshot> {
752 mj_checkpoint::checkpoint::repair_origin_refs(git, checkout, RAW_CONVERSION_REPOSITORY_ID)?;
755 mj_checkpoint::checkpoint::reject_dirty_submodules(git, checkout)
756 .with_context(|| format!("checkout {}", checkout.display()))?;
757 let mut snapshot = mj_checkpoint::archive::collect_git_snapshot(
758 git,
759 checkout,
760 &mj_checkpoint::archive::GitCollectionSpec {
761 id: RAW_CONVERSION_REPOSITORY_ID.to_owned(),
762 relative_destination: destination.to_path_buf(),
763 history: mj_checkpoint::archive::GitHistoryMode::SessionDelta,
764 origin_override: None,
765 },
766 )
767 .with_context(|| format!("snapshot the checkout at {}", checkout.display()))?;
768 snapshot.metadata.origin =
771 mj_checkpoint::archive::redact_origin_credentials(&source.fetch_url)?;
772 snapshot.metadata.push_urls = source
773 .push_urls
774 .iter()
775 .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
776 .collect::<Result<Vec<_>>>()?;
777 snapshot.metadata.remote_workspace = true;
778 snapshot.metadata.base_commit = origin_boundary_commit(git, checkout)?
779 .unwrap_or_else(|| snapshot.metadata.head_commit.clone());
780 Ok(snapshot)
781}
782
783fn origin_boundary_commit(
787 git: &dyn mj_checkpoint::archive::GitCommandRunner,
788 checkout: &Path,
789) -> Result<Option<String>> {
790 let listed = git_runner_stdout(
791 git,
792 checkout,
793 [
794 "rev-list",
795 "--boundary",
796 "HEAD",
797 "--not",
798 "--remotes=origin",
799 ],
800 "list commits outside origin",
801 )?;
802 Ok(listed
805 .lines()
806 .filter_map(|line| line.strip_prefix('-'))
807 .map(|commit| commit.trim().to_owned())
808 .find(|commit| !commit.is_empty()))
809}
810
811fn git_runner_stdout(
812 git: &dyn mj_checkpoint::archive::GitCommandRunner,
813 repository: &Path,
814 args: impl IntoIterator<Item = impl AsRef<str>>,
815 purpose: &str,
816) -> Result<String> {
817 let output = git.run(
818 repository,
819 &mj_checkpoint::archive::GitCommand {
820 arguments: args
821 .into_iter()
822 .map(|argument| std::ffi::OsString::from(argument.as_ref()))
823 .collect(),
824 stdin: Vec::new(),
825 env: Vec::new(),
826 },
827 )?;
828 command_stdout(
829 CommandOutput {
830 status: output.status,
831 stdout: output.stdout,
832 stderr: output.stderr,
833 },
834 purpose,
835 )
836}
837
838pub(super) fn raw_conversion_preview(
841 session: &SessionRecord,
842 conversion: &RawToWorkspaceConversion,
843 executor: &impl CommandExecutor,
844) -> Result<mj_core::state::RawConversionPreview> {
845 let checkout = conversion.checkout.as_path();
846 reject_dirty_submodules_in_checkout(executor, checkout)?;
849 let default_branch = mj_core::remote_git::default_branch(&conversion.source, executor)?;
850 let position = read_checkout_position(executor, &ManagedWorktreeTarget::Local, checkout)?;
851 let unpushed_commits = unpushed_commit_count(executor, checkout)?;
852 let dirty = dirty_file_counts(executor, checkout)?;
853 let directory = session
856 .project_directory
857 .as_deref()
858 .context("a raw session has no project directory")?
859 .file_name()
860 .context("a raw project directory cannot be the filesystem root")?;
861 let container_workspace = match session.container_workspace.clone() {
867 Some(workspace) => workspace,
868 None => mj_core::targets::new_container_workspace(&session.id)?,
869 };
870 Ok(mj_core::state::RawConversionPreview {
871 checkout: checkout.to_path_buf(),
872 destination: container_workspace.join(directory),
873 branch: position.branch,
874 fetch_url: conversion.source.fetch_url.clone(),
875 push_urls: conversion.source.push_urls.clone(),
876 default_branch,
877 unpushed_commits,
878 staged_files: dirty.staged_files,
879 unstaged_files: dirty.unstaged_files,
880 untracked_files: dirty.untracked_files,
881 untracked_bytes: untracked_bytes(executor, checkout)?,
882 host_checkout_retained: conversion.retire.is_none(),
883 })
884}
885
886fn reject_dirty_submodules_in_checkout(
887 executor: &impl CommandExecutor,
888 checkout: &Path,
889) -> Result<()> {
890 let listed = managed_git_stdout(
891 executor,
892 &ManagedWorktreeTarget::Local,
893 checkout,
894 [
895 "submodule",
896 "foreach",
897 "--recursive",
898 "--quiet",
899 "git status --porcelain",
900 ],
901 "inspect submodules",
902 )?;
903 ensure!(
904 listed.trim().is_empty(),
905 "{} has a dirty submodule, which cannot move into a target; commit or discard the submodule's changes first",
906 checkout.display()
907 );
908 Ok(())
909}
910
911fn unpushed_commit_count(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
915 if !origin_refs_available(executor, checkout)? {
916 return Ok(0);
917 }
918 let counted = managed_git_stdout(
919 executor,
920 &ManagedWorktreeTarget::Local,
921 checkout,
922 ["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
923 "count commits outside origin",
924 )?;
925 counted
926 .trim()
927 .parse()
928 .with_context(|| format!("parse the commit count {counted:?}"))
929}
930
931fn origin_refs_available(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
932 if origin_refs_listed(executor, checkout)? {
933 return Ok(true);
934 }
935 let fetch = managed_git_command(
938 &ManagedWorktreeTarget::Local,
939 checkout,
940 ["fetch", "origin"],
941 "fetch origin refs",
942 );
943 executor.execute(&fetch)?;
944 origin_refs_listed(executor, checkout)
945}
946
947fn origin_refs_listed(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
948 managed_git_stdout(
949 executor,
950 &ManagedWorktreeTarget::Local,
951 checkout,
952 [
953 "for-each-ref",
954 "--format=%(objectname)",
955 "refs/remotes/origin",
956 ],
957 "list origin refs",
958 )
959 .map(|refs| !refs.trim().is_empty())
960}
961
962#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
963struct DirtyFileCounts {
964 staged_files: u64,
965 unstaged_files: u64,
966 untracked_files: u64,
967}
968
969fn dirty_file_counts(executor: &impl CommandExecutor, checkout: &Path) -> Result<DirtyFileCounts> {
972 let command = managed_git_command(
973 &ManagedWorktreeTarget::Local,
974 checkout,
975 ["status", "--porcelain=v1", "-z"],
976 "read checkout status",
977 );
978 let output = executor.execute(&command)?;
979 ensure!(
980 output.status == 0,
981 "read checkout status failed with status {}: {}",
982 output.status,
983 String::from_utf8_lossy(&output.stderr).trim()
984 );
985 let mut counts = DirtyFileCounts::default();
986 let mut records = output
987 .stdout
988 .split(|byte| *byte == 0)
989 .filter(|record| !record.is_empty());
990 while let Some(record) = records.next() {
991 let [index, worktree, ..] = record else {
992 bail!("git status produced a record shorter than its status field");
993 };
994 if *index == b'?' && *worktree == b'?' {
995 counts.untracked_files += 1;
996 continue;
997 }
998 if !matches!(index, b' ' | b'?') {
999 counts.staged_files += 1;
1000 }
1001 if !matches!(worktree, b' ' | b'?') {
1002 counts.unstaged_files += 1;
1003 }
1004 if *index == b'R' || *index == b'C' || *worktree == b'R' || *worktree == b'C' {
1005 records.next();
1006 }
1007 }
1008 Ok(counts)
1009}
1010
1011fn untracked_bytes(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1015 let command = managed_git_command(
1016 &ManagedWorktreeTarget::Local,
1017 checkout,
1018 ["ls-files", "--others", "--exclude-standard", "-z"],
1019 "list untracked files",
1020 );
1021 let output = executor.execute(&command)?;
1022 ensure!(
1023 output.status == 0,
1024 "list untracked files failed with status {}: {}",
1025 output.status,
1026 String::from_utf8_lossy(&output.stderr).trim()
1027 );
1028 let mut total = 0;
1029 for record in output
1030 .stdout
1031 .split(|byte| *byte == 0)
1032 .filter(|record| !record.is_empty())
1033 {
1034 let relative = mj_core::path_input::from_git_bytes(record)?;
1035 let path = checkout.join(relative);
1036 match std::fs::symlink_metadata(&path) {
1039 Ok(metadata) => total += metadata.len(),
1040 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1041 Err(error) => {
1042 return Err(error).with_context(|| format!("measure {}", path.display()));
1043 }
1044 }
1045 }
1046 Ok(total)
1047}
1048
1049#[derive(Debug, Clone, PartialEq, Eq)]
1051pub(super) struct CheckoutPosition {
1052 pub(super) head_commit: String,
1053 branch: Option<String>,
1054}
1055
1056fn read_checkout_position(
1057 executor: &impl CommandExecutor,
1058 target: &ManagedWorktreeTarget,
1059 directory: &Path,
1060) -> Result<CheckoutPosition> {
1061 let head_commit = managed_git_stdout(
1062 executor,
1063 target,
1064 directory,
1065 ["rev-parse", "HEAD"],
1066 "resolve checkout head commit",
1067 )?;
1068 let branch_command = managed_git_command(
1069 target,
1070 directory,
1071 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1072 "resolve checkout branch",
1073 );
1074 let branch_output = executor.execute(&branch_command)?;
1075 let branch = match branch_output.status {
1076 0 => Some(
1077 String::from_utf8(branch_output.stdout)
1078 .context("checkout branch was not UTF-8")?
1079 .trim()
1080 .to_owned(),
1081 ),
1082 1 | 128 => None,
1084 status => bail!(
1085 "resolve checkout branch failed with status {status}: {}",
1086 String::from_utf8_lossy(&branch_output.stderr).trim()
1087 ),
1088 };
1089 Ok(CheckoutPosition {
1090 head_commit,
1091 branch,
1092 })
1093}
1094
1095pub(super) fn managed_worktree_base_commit(
1101 worktree: &ManagedWorktree,
1102 executor: &impl CommandExecutor,
1103) -> Result<String> {
1104 if let Some(base) = &worktree.base_commit {
1105 return Ok(base.clone());
1106 }
1107 let reference = format!("refs/heads/{}", worktree.branch);
1108 let reflog_command = managed_git_command(
1109 &worktree.target,
1110 &worktree.source_repository,
1111 ["reflog", "show", "--format=%H", &reference],
1112 "read the session branch reflog",
1113 );
1114 let reflog_output = executor.execute(&reflog_command)?;
1115 if reflog_output.status == 0 {
1116 let text = String::from_utf8(reflog_output.stdout)
1117 .context("the session branch reflog was not UTF-8")?;
1118 if let Some(creation) = text.lines().rfind(|line| !line.trim().is_empty()) {
1121 return Ok(creation.trim().to_owned());
1122 }
1123 }
1124 let head = read_checkout_position(executor, &worktree.target, &worktree.worktree_root)?;
1125 tracing::warn!(
1126 branch = %worktree.branch,
1127 "the reflog for this session branch is gone, so its checkpoint bundle will carry no commits"
1128 );
1129 Ok(head.head_commit)
1130}
1131
1132pub(super) fn raw_checkout_position(
1135 session: &SessionRecord,
1136 config: &Config,
1137 project_directory: &Path,
1138 executor: &impl CommandExecutor,
1139) -> Result<CheckoutPosition> {
1140 let target = match &session.managed_worktree {
1141 Some(worktree) => worktree.target.clone(),
1142 None => {
1143 let template = config
1144 .targets
1145 .get(&session.target_template_id)
1146 .context("the bare target this session last used is missing")?;
1147 managed_worktree_target(template)?
1148 }
1149 };
1150 read_checkout_position(executor, &target, project_directory)
1151}
1152
1153pub(super) fn raw_checkout_divergence_notice(
1159 directory: &Path,
1160 recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
1161 live: &CheckoutPosition,
1162) -> Option<String> {
1163 let recorded = recorded?;
1164 if recorded.head_commit.is_empty()
1165 || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
1166 {
1167 return None;
1168 }
1169 Some(format!(
1170 "The working tree at {} moved from {} to {} while this session was stopped.",
1171 directory.display(),
1172 checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
1173 checkout_position_text(&live.head_commit, live.branch.as_deref()),
1174 ))
1175}
1176
1177fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
1178 let short = head_commit.get(..12).unwrap_or(head_commit);
1179 match branch {
1180 Some(branch) => format!("{short} ({branch})"),
1181 None => format!("{short} (detached)"),
1182 }
1183}
1184
1185fn inspect_raw_project(
1186 executor: &impl CommandExecutor,
1187 target: &ManagedWorktreeTarget,
1188 selected: &Path,
1189) -> Result<RawProjectInspection> {
1190 let repository = PathBuf::from(managed_git_stdout(
1191 executor,
1192 target,
1193 selected,
1194 ["rev-parse", "--path-format=absolute", "--show-toplevel"],
1195 "resolve raw project repository root",
1196 )?);
1197 let prefix = managed_git_stdout(
1198 executor,
1199 target,
1200 selected,
1201 ["rev-parse", "--show-prefix"],
1202 "resolve raw project relative directory",
1203 )?;
1204 let git_dir = PathBuf::from(managed_git_stdout(
1205 executor,
1206 target,
1207 selected,
1208 ["rev-parse", "--absolute-git-dir"],
1209 "resolve raw project Git directory",
1210 )?);
1211 let common_git_dir = PathBuf::from(managed_git_stdout(
1212 executor,
1213 target,
1214 selected,
1215 ["rev-parse", "--path-format=absolute", "--git-common-dir"],
1216 "resolve raw project common Git directory",
1217 )?);
1218 let branch_command = managed_git_command(
1219 target,
1220 selected,
1221 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1222 "resolve raw project branch",
1223 );
1224 let branch_output = executor.execute(&branch_command)?;
1225 let branch = match branch_output.status {
1226 0 => Some(
1227 String::from_utf8(branch_output.stdout)
1228 .context("raw project branch was not UTF-8")?
1229 .trim()
1230 .to_owned(),
1231 ),
1232 1 | 128 => None,
1233 status => bail!(
1234 "resolve raw project branch failed with status {status}: {}",
1235 String::from_utf8_lossy(&branch_output.stderr).trim()
1236 ),
1237 };
1238 let upstream = match branch {
1239 Some(branch) => {
1240 let reference = format!("refs/heads/{branch}");
1241 let upstream = managed_git_stdout(
1242 executor,
1243 target,
1244 selected,
1245 ["for-each-ref", "--format=%(upstream:short)", &reference],
1246 "resolve raw project upstream",
1247 )?;
1248 (!upstream.is_empty()).then_some(upstream)
1249 }
1250 None => None,
1251 };
1252 Ok(RawProjectInspection {
1253 source_project_directory: repository.join(prefix),
1254 source_repository: repository,
1255 primary_checkout: git_dir == common_git_dir,
1256 upstream,
1257 })
1258}
1259
1260fn ensure_managed_worktree_excluded(
1261 executor: &impl CommandExecutor,
1262 target: &ManagedWorktreeTarget,
1263 repository: &Path,
1264) -> Result<()> {
1265 let check = managed_git_command(
1266 target,
1267 repository,
1268 [
1269 "check-ignore",
1270 "--quiet",
1271 "--no-index",
1272 "--",
1273 ".mj/worktrees/",
1274 ],
1275 "check managed worktree exclusion",
1276 );
1277 let output = executor.execute(&check)?;
1278 match output.status {
1279 0 => return Ok(()),
1280 1 => {}
1281 status => bail!(
1282 "check managed worktree exclusion failed with status {status}: {}",
1283 String::from_utf8_lossy(&output.stderr).trim()
1284 ),
1285 }
1286 let exclude_path = PathBuf::from(managed_git_stdout(
1287 executor,
1288 target,
1289 repository,
1290 [
1291 "rev-parse",
1292 "--path-format=absolute",
1293 "--git-path",
1294 "info/exclude",
1295 ],
1296 "resolve repository-local exclude file",
1297 )?);
1298 const ENTRY: &str = "/.mj/worktrees/";
1299 match target {
1300 ManagedWorktreeTarget::Local => {
1301 use std::io::Write;
1302 let existing = match std::fs::read_to_string(&exclude_path) {
1303 Ok(existing) => existing,
1304 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1305 Err(error) => return Err(error.into()),
1306 };
1307 if existing.lines().any(|line| line.trim() == ENTRY) {
1308 return Ok(());
1309 }
1310 if let Some(parent) = exclude_path.parent() {
1311 std::fs::create_dir_all(parent)?;
1312 }
1313 let mut file = std::fs::OpenOptions::new()
1314 .create(true)
1315 .append(true)
1316 .open(&exclude_path)
1317 .with_context(|| format!("open {}", exclude_path.display()))?;
1318 if !existing.is_empty() && !existing.ends_with('\n') {
1319 writeln!(file)?;
1320 }
1321 writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
1322 }
1323 ManagedWorktreeTarget::Ssh { .. } => {
1324 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";
1325 let command = managed_target_command(
1326 target,
1327 "sh",
1328 [
1329 "-c",
1330 SCRIPT,
1331 "hel-exclude",
1332 &exclude_path.to_string_lossy(),
1333 ENTRY,
1334 ],
1335 )
1336 .purpose("update remote repository-local exclude file");
1337 execute_checked(executor, command)?;
1338 }
1339 }
1340 Ok(())
1341}
1342
1343pub(crate) fn path_exists_on_managed_target(
1344 executor: &impl CommandExecutor,
1345 target: &ManagedWorktreeTarget,
1346 path: &Path,
1347) -> Result<bool> {
1348 match target {
1349 ManagedWorktreeTarget::Local => path
1350 .try_exists()
1351 .with_context(|| format!("check managed project path {}", path.display())),
1352 ManagedWorktreeTarget::Ssh { .. } => {
1353 let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
1354 .purpose("check managed worktree path");
1355 let output = executor.execute(&command)?;
1356 match output.status {
1357 0 => Ok(true),
1358 1 => Ok(false),
1359 status => bail!(
1360 "check managed worktree path failed with status {status}: {}",
1361 String::from_utf8_lossy(&output.stderr).trim()
1362 ),
1363 }
1364 }
1365 }
1366}
1367
1368pub(super) fn managed_worktree_checkout_exists(
1369 executor: &impl CommandExecutor,
1370 worktree: &ManagedWorktree,
1371) -> Result<bool> {
1372 path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1373}
1374
1375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1377pub(super) enum PrimaryCheckoutRequirement {
1378 Clean,
1381 Any,
1385}
1386
1387pub(super) fn create_managed_worktree(
1388 executor: &impl CommandExecutor,
1389 worktree: &ManagedWorktree,
1390 upstream: Option<&str>,
1391 requirement: PrimaryCheckoutRequirement,
1392) -> Result<()> {
1393 ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
1394 if requirement == PrimaryCheckoutRequirement::Clean {
1395 let status = managed_git_stdout(
1396 executor,
1397 &worktree.target,
1398 &worktree.source_repository,
1399 ["status", "--porcelain=v1", "--untracked-files=all"],
1400 "inspect primary checkout changes",
1401 )?;
1402 if !status.is_empty() {
1403 let paths = status.lines().take(20).collect::<Vec<_>>().join("\n ");
1404 bail!(
1405 "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n {paths}"
1406 );
1407 }
1408 }
1409 let parent = worktree
1410 .worktree_root
1411 .parent()
1412 .context("managed worktree root has no parent")?;
1413 execute_checked(
1414 executor,
1415 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1416 .purpose("create managed worktree directory"),
1417 )?;
1418 execute_checked(
1419 executor,
1420 managed_git_command(
1421 &worktree.target,
1422 &worktree.source_repository,
1423 [
1424 "worktree",
1425 "add",
1426 "-b",
1427 &worktree.branch,
1428 &worktree.worktree_root.to_string_lossy(),
1429 "HEAD",
1430 ],
1431 "create managed raw-session worktree",
1432 ),
1433 )?;
1434 if let Some(upstream) = upstream {
1435 execute_checked(
1436 executor,
1437 managed_git_command(
1438 &worktree.target,
1439 &worktree.worktree_root,
1440 ["branch", "--set-upstream-to", upstream, &worktree.branch],
1441 "set managed worktree branch upstream",
1442 ),
1443 )?;
1444 }
1445 Ok(())
1446}
1447
1448pub(super) fn restore_managed_worktree(
1452 executor: &impl CommandExecutor,
1453 worktree: &ManagedWorktree,
1454) -> Result<bool> {
1455 if managed_worktree_checkout_exists(executor, worktree)? {
1456 return Ok(false);
1457 }
1458 ensure!(
1459 path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
1460 "managed worktree source repository is unavailable: {}",
1461 worktree.source_repository.display()
1462 );
1463 let branch_ref = format!("refs/heads/{}", worktree.branch);
1464 let check = managed_git_command(
1465 &worktree.target,
1466 &worktree.source_repository,
1467 ["show-ref", "--verify", "--quiet", &branch_ref],
1468 "check retired managed worktree branch",
1469 );
1470 let output = executor.execute(&check)?;
1471 match output.status {
1472 0 => {}
1473 1 => bail!(
1474 "managed worktree branch is unavailable: {}",
1475 worktree.branch
1476 ),
1477 status => bail!(
1478 "check retired managed worktree branch failed with status {status}: {}",
1479 String::from_utf8_lossy(&output.stderr).trim()
1480 ),
1481 }
1482 execute_checked(
1485 executor,
1486 managed_git_command(
1487 &worktree.target,
1488 &worktree.source_repository,
1489 ["worktree", "prune"],
1490 "prune retired managed worktree metadata",
1491 ),
1492 )?;
1493 let parent = worktree
1494 .worktree_root
1495 .parent()
1496 .context("managed worktree root has no parent")?;
1497 execute_checked(
1498 executor,
1499 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1500 .purpose("recreate managed worktree directory"),
1501 )?;
1502 execute_checked(
1503 executor,
1504 managed_git_command(
1505 &worktree.target,
1506 &worktree.source_repository,
1507 [
1508 "worktree",
1509 "add",
1510 "--",
1511 &worktree.worktree_root.to_string_lossy(),
1512 &worktree.branch,
1513 ],
1514 "restore managed raw-session worktree",
1515 ),
1516 )?;
1517 Ok(true)
1518}
1519
1520fn ensure_managed_worktree_available(
1521 executor: &impl CommandExecutor,
1522 worktree: &ManagedWorktree,
1523) -> Result<()> {
1524 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1525 bail!(
1526 "managed worktree path already exists: {}",
1527 worktree.worktree_root.display()
1528 );
1529 }
1530 let branch_ref = format!("refs/heads/{}", worktree.branch);
1531 let check = managed_git_command(
1532 &worktree.target,
1533 &worktree.source_repository,
1534 ["show-ref", "--verify", "--quiet", &branch_ref],
1535 "check managed worktree branch availability",
1536 );
1537 let output = executor.execute(&check)?;
1538 match output.status {
1539 0 => bail!(
1540 "managed worktree branch already exists: {}",
1541 worktree.branch
1542 ),
1543 1 => Ok(()),
1544 status => bail!(
1545 "check managed worktree branch availability failed with status {status}: {}",
1546 String::from_utf8_lossy(&output.stderr).trim()
1547 ),
1548 }
1549}
1550
1551fn retained_managed_worktree_branch_available(
1556 executor: &impl CommandExecutor,
1557 worktree: &ManagedWorktree,
1558) -> Result<bool> {
1559 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1560 bail!(
1561 "managed worktree path already exists: {}",
1562 worktree.worktree_root.display()
1563 );
1564 }
1565 let branch_ref = format!("refs/heads/{}", worktree.branch);
1566 let check = managed_git_command(
1567 &worktree.target,
1568 &worktree.source_repository,
1569 ["show-ref", "--verify", "--quiet", &branch_ref],
1570 "check retained managed worktree branch",
1571 );
1572 let output = executor.execute(&check)?;
1573 match output.status {
1574 1 => Ok(false),
1575 0 => {
1576 let worktrees = managed_git_stdout(
1577 executor,
1578 &worktree.target,
1579 &worktree.source_repository,
1580 ["worktree", "list", "--porcelain", "-z"],
1581 "check retained managed worktree checkout",
1582 )?;
1583 let branch_field = format!("branch {branch_ref}");
1584 if worktrees.split('\0').any(|field| field == branch_field) {
1585 bail!(
1586 "managed worktree branch is still checked out: {}",
1587 worktree.branch
1588 );
1589 }
1590 Ok(true)
1591 }
1592 status => bail!(
1593 "check retained managed worktree branch failed with status {status}: {}",
1594 String::from_utf8_lossy(&output.stderr).trim()
1595 ),
1596 }
1597}
1598
1599pub(super) fn preserve_retained_managed_worktree_branch(
1603 executor: &impl CommandExecutor,
1604 worktree: &ManagedWorktree,
1605) -> Result<String> {
1606 let session_id = worktree
1607 .branch
1608 .strip_prefix("mj/")
1609 .context("managed worktree branch is not session-owned")?;
1610 let branch_ref = format!("refs/heads/{}", worktree.branch);
1611 let tip = managed_git_stdout(
1612 executor,
1613 &worktree.target,
1614 &worktree.source_repository,
1615 ["rev-parse", "--verify", &branch_ref],
1616 "read retained managed worktree branch tip",
1617 )?;
1618 let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
1619 let existing = managed_git_command(
1620 &worktree.target,
1621 &worktree.source_repository,
1622 ["show-ref", "--verify", "--quiet", &recovery_ref],
1623 "check retained managed worktree recovery ref",
1624 );
1625 let output = executor.execute(&existing)?;
1626 match output.status {
1627 0 => {
1628 let existing_tip = managed_git_stdout(
1629 executor,
1630 &worktree.target,
1631 &worktree.source_repository,
1632 ["rev-parse", "--verify", &recovery_ref],
1633 "verify retained managed worktree recovery ref",
1634 )?;
1635 ensure!(
1636 existing_tip == tip,
1637 "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
1638 );
1639 Ok(recovery_ref)
1640 }
1641 1 => {
1642 execute_checked(
1643 executor,
1644 managed_git_command(
1645 &worktree.target,
1646 &worktree.source_repository,
1647 ["update-ref", &recovery_ref, &tip],
1648 "preserve retained managed worktree branch",
1649 ),
1650 )?;
1651 Ok(recovery_ref)
1652 }
1653 status => bail!(
1654 "check retained managed worktree recovery ref failed with status {status}: {}",
1655 String::from_utf8_lossy(&output.stderr).trim()
1656 ),
1657 }
1658}
1659
1660pub(super) fn retire_managed_worktree(
1667 executor: &impl CommandExecutor,
1668 worktree: &ManagedWorktree,
1669) -> Result<()> {
1670 if !remove_managed_worktree_checkout(executor, worktree)? {
1671 return Ok(());
1672 }
1673 remove_empty_managed_worktree_directories(executor, worktree)
1674}
1675
1676fn remove_managed_worktree_checkout(
1679 executor: &impl CommandExecutor,
1680 worktree: &ManagedWorktree,
1681) -> Result<bool> {
1682 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1683 return Ok(false);
1684 }
1685 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1686 execute_checked(
1687 executor,
1688 managed_git_command(
1689 &worktree.target,
1690 &worktree.source_repository,
1691 [
1692 "worktree",
1693 "remove",
1694 "--force",
1695 &worktree.worktree_root.to_string_lossy(),
1696 ],
1697 "remove managed raw-session worktree",
1698 ),
1699 )?;
1700 }
1701 execute_checked(
1702 executor,
1703 managed_git_command(
1704 &worktree.target,
1705 &worktree.source_repository,
1706 ["worktree", "prune"],
1707 "prune managed worktree metadata",
1708 ),
1709 )?;
1710 Ok(true)
1711}
1712
1713pub(super) fn cleanup_managed_worktree(
1714 executor: &impl CommandExecutor,
1715 worktree: &ManagedWorktree,
1716) -> Result<()> {
1717 if !remove_managed_worktree_checkout(executor, worktree)? {
1718 return Ok(());
1719 }
1720 let branch_ref = format!("refs/heads/{}", worktree.branch);
1721 let check = managed_git_command(
1722 &worktree.target,
1723 &worktree.source_repository,
1724 ["show-ref", "--verify", "--quiet", &branch_ref],
1725 "check managed worktree branch",
1726 );
1727 let output = executor.execute(&check)?;
1728 match output.status {
1729 0 => {
1730 execute_checked(
1731 executor,
1732 managed_git_command(
1733 &worktree.target,
1734 &worktree.source_repository,
1735 ["branch", "-D", "--", &worktree.branch],
1736 "delete managed raw-session branch",
1737 ),
1738 )?;
1739 }
1740 1 => {}
1741 status => bail!(
1742 "check managed worktree branch failed with status {status}: {}",
1743 String::from_utf8_lossy(&output.stderr).trim()
1744 ),
1745 }
1746 remove_empty_managed_worktree_directories(executor, worktree)
1747}
1748
1749fn remove_empty_managed_worktree_directories(
1750 executor: &impl CommandExecutor,
1751 worktree: &ManagedWorktree,
1752) -> Result<()> {
1753 let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1754 let hel = worktree.source_repository.join(".mj");
1755 match &worktree.target {
1756 ManagedWorktreeTarget::Local => {
1757 for directory in [&worktrees, &hel] {
1758 match std::fs::remove_dir(directory) {
1759 Ok(()) => {}
1760 Err(error)
1761 if matches!(
1762 error.kind(),
1763 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1764 ) => {}
1765 Err(error) => return Err(error.into()),
1766 }
1767 }
1768 }
1769 ManagedWorktreeTarget::Ssh { .. } => {
1770 let command = managed_target_command(
1771 &worktree.target,
1772 "rmdir",
1773 ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1774 )
1775 .purpose("remove empty managed worktree directories");
1776 let _ = executor.execute(&command)?;
1777 }
1778 }
1779 Ok(())
1780}
1781
1782#[cfg(test)]
1783mod tests;