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::{BranchDisposition, 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!(
284 session.create_managed_worktree != Some(true) && session.launch_base.is_none(),
285 "managed worktree creation requires a Git project"
286 );
287 return Ok(false);
288 }
289 let inspection = inspect_raw_project(executor, &target, selected)?;
290 if !inspection.primary_checkout
291 && session.create_managed_worktree != Some(true)
292 && session.launch_base.is_none()
293 {
294 return Ok(false);
295 }
296 let relative_directory = inspection
297 .source_project_directory
298 .strip_prefix(&inspection.source_repository)
299 .context("raw project directory is outside its repository")?
300 .to_path_buf();
301 let worktree_root = inspection
302 .source_repository
303 .join(".mj")
304 .join("worktrees")
305 .join(session_id);
306 let base_commit = match session.launch_base.as_deref() {
310 Some(revision) => managed_git_stdout(
311 executor,
312 &target,
313 &inspection.source_repository,
314 [
315 "rev-parse",
316 "--verify",
317 "--end-of-options",
318 &format!("{revision}^{{commit}}"),
319 ],
320 "resolve the launch base",
321 )?
322 .trim()
323 .to_owned(),
324 None => {
325 read_checkout_position(executor, &target, &inspection.source_repository)?
326 .head_commit
327 }
328 };
329 let managed = ManagedWorktree {
330 source_project_directory: inspection.source_project_directory,
331 source_repository: inspection.source_repository,
332 worktree_root: worktree_root.clone(),
333 branch: format!("mj/{session_id}"),
334 target,
335 base_commit: Some(base_commit),
336 };
337 ensure_managed_worktree_available(executor, &managed)?;
338 let record = self.state.sessions.get_mut(session_id).unwrap();
339 record.project_directory = Some(worktree_root.join(relative_directory));
340 record.managed_worktree = Some(managed.clone());
341 record.updated_at = now();
342 self.persist_session_state(session_id)?;
343 create_managed_worktree(
344 executor,
345 &managed,
346 inspection.upstream.as_deref(),
347 PrimaryCheckoutRequirement::Clean,
348 )?;
349 Ok(true)
350 }
351
352 fn cleanup_new_session_worktree(
353 &self,
354 session_id: &str,
355 executor: &impl CommandExecutor,
356 ) -> Result<()> {
357 let Some(worktree) = self
358 .state
359 .sessions
360 .get(session_id)
361 .and_then(|session| session.managed_worktree.as_ref())
362 else {
363 return Ok(());
364 };
365 cleanup_managed_worktree(executor, worktree, BranchDisposition::Delete)
368 }
369
370 pub(super) fn cleanup_new_session_worktree_after_failure(
371 &self,
372 session_id: &str,
373 executor: &impl CommandExecutor,
374 ) -> Result<()> {
375 if executor.cancellation_requested() {
376 let cleanup_executor =
377 CancellableProcessExecutor::with_timeout(Duration::from_secs(15));
378 self.cleanup_new_session_worktree(session_id, &cleanup_executor)
379 } else {
380 self.cleanup_new_session_worktree(session_id, executor)
381 }
382 }
383}
384
385struct RemoteGitExecutor<'a, E> {
387 executor: &'a E,
388 ssh: SshTarget,
389}
390
391impl<E: CommandExecutor> CommandExecutor for RemoteGitExecutor<'_, E> {
392 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
393 let mut arguments = vec!["env".to_owned()];
394 arguments.extend(
395 command
396 .env
397 .iter()
398 .map(|(key, value)| format!("{key}={value}")),
399 );
400 arguments.push(command.program.clone());
401 arguments.extend(command.args.clone());
402 self.executor
403 .execute(&crate::targets::ssh_command(&self.ssh, arguments).purpose(&command.purpose))
404 }
405
406 fn cancellation_requested(&self) -> bool {
407 self.executor.cancellation_requested()
408 }
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
412struct RawProjectInspection {
413 source_project_directory: PathBuf,
414 source_repository: PathBuf,
415 primary_checkout: bool,
416 upstream: Option<String>,
417}
418
419fn managed_target_ssh(target: &ManagedWorktreeTarget) -> Option<SshTarget> {
420 match target {
421 ManagedWorktreeTarget::Local => None,
422 ManagedWorktreeTarget::Ssh {
423 destination,
424 ssh_args,
425 } => Some(SshTarget {
426 destination: destination.clone(),
427 ssh_args: ssh_args.clone(),
428 }),
429 }
430}
431
432fn managed_target_command(
433 target: &ManagedWorktreeTarget,
434 program: &str,
435 args: impl IntoIterator<Item = impl AsRef<str>>,
436) -> CommandSpec {
437 let args = args
438 .into_iter()
439 .map(|arg| arg.as_ref().to_owned())
440 .collect::<Vec<_>>();
441 match managed_target_ssh(target) {
442 None => CommandSpec::new(program, args),
443 Some(ssh) => {
444 let mut remote = vec![program.to_owned()];
445 remote.extend(args);
446 crate::targets::ssh_command(&ssh, remote)
447 }
448 }
449}
450
451fn managed_git_command(
452 target: &ManagedWorktreeTarget,
453 directory: &Path,
454 args: impl IntoIterator<Item = impl AsRef<str>>,
455 purpose: impl Into<String>,
456) -> CommandSpec {
457 let mut command_args = vec!["-C".to_owned(), directory.to_string_lossy().into_owned()];
458 command_args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
459 managed_target_command(target, "git", command_args).purpose(purpose)
460}
461
462fn command_stdout(output: CommandOutput, purpose: &str) -> Result<String> {
463 if output.status != 0 {
464 bail!(
465 "{purpose} failed with status {}: {}",
466 output.status,
467 String::from_utf8_lossy(&output.stderr).trim()
468 );
469 }
470 let stdout = String::from_utf8(output.stdout)
471 .with_context(|| format!("{purpose} produced non-UTF-8 output"))?;
472 Ok(stdout.trim_end_matches(['\r', '\n']).to_owned())
473}
474
475fn managed_git_stdout(
476 executor: &impl CommandExecutor,
477 target: &ManagedWorktreeTarget,
478 directory: &Path,
479 args: impl IntoIterator<Item = impl AsRef<str>>,
480 purpose: &str,
481) -> Result<String> {
482 let command = managed_git_command(target, directory, args, purpose);
483 command_stdout(executor.execute(&command)?, purpose)
484}
485
486fn resolve_git_root(
489 target: &ManagedWorktreeTarget,
490 directory: &Path,
491 executor: &impl CommandExecutor,
492) -> Result<Option<PathBuf>> {
493 let args = [
496 "-C".to_owned(),
497 directory.to_string_lossy().into_owned(),
498 "rev-parse".into(),
499 "--path-format=absolute".into(),
500 "--show-toplevel".into(),
501 ];
502 let top_level = match target {
503 ManagedWorktreeTarget::Local => {
504 let mut command = CommandSpec::new("git", args);
505 command.env.insert("LC_ALL".into(), "C".into());
506 command
507 }
508 ManagedWorktreeTarget::Ssh { .. } => managed_target_command(
509 target,
510 "env",
511 ["LC_ALL=C".to_owned(), "git".into()]
512 .into_iter()
513 .chain(args),
514 ),
515 }
516 .purpose("resolve project Git root");
517 let output = executor.execute(&top_level)?;
518 if output.status != 0 {
519 if output.status == 128
520 && String::from_utf8_lossy(&output.stderr).starts_with("fatal: not a git repository")
521 {
522 return Ok(None);
523 }
524 bail!(
525 "resolve project Git root failed with status {}: {}",
526 output.status,
527 String::from_utf8_lossy(&output.stderr).trim()
528 );
529 }
530 let root = PathBuf::from(
531 String::from_utf8(output.stdout)
532 .context("project Git root was not UTF-8")?
533 .trim_end_matches(['\r', '\n']),
534 );
535 if root.as_os_str().is_empty() {
536 bail!("resolve project Git root returned an empty path");
537 }
538
539 let common = PathBuf::from(managed_git_stdout(
540 executor,
541 target,
542 directory,
543 ["rev-parse", "--path-format=absolute", "--git-common-dir"],
544 "resolve project Git common directory",
545 )?);
546 if common.file_name() == Some(std::ffi::OsStr::new(".git"))
547 && let Some(main_root) = common.parent()
548 {
549 return Ok(Some(main_root.to_path_buf()));
550 }
551 Ok(Some(root))
552}
553
554pub fn local_project_repository(
557 directory: &Path,
558 executor: &impl CommandExecutor,
559) -> Result<Option<PathBuf>> {
560 resolve_git_root(&ManagedWorktreeTarget::Local, directory, executor)
561}
562
563pub(super) fn apply_raw_to_workspace(
568 record: &mut SessionRecord,
569 conversion: &RawToWorkspaceConversion,
570) {
571 record.project_directory = None;
572 record.managed_worktree = None;
573 record.bundle_id.clone_from(&conversion.bundle_id);
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
579pub(super) enum ResumeConversion {
580 RawToWorkspace(RawToWorkspaceConversion),
581 WorkspaceToRaw(WorkspaceToRawConversion),
582}
583
584impl ResumeConversion {
585 pub(super) fn raw_to_workspace(&self) -> Option<&RawToWorkspaceConversion> {
586 match self {
587 Self::RawToWorkspace(conversion) => Some(conversion),
588 Self::WorkspaceToRaw(_) => None,
589 }
590 }
591
592 pub(super) fn workspace_to_raw(&self) -> Option<&WorkspaceToRawConversion> {
593 match self {
594 Self::WorkspaceToRaw(conversion) => Some(conversion),
595 Self::RawToWorkspace(_) => None,
596 }
597 }
598}
599
600#[derive(Debug, Clone, PartialEq, Eq)]
604pub(super) struct WorkspaceToRawConversion {
605 pub(super) worktree: ManagedWorktree,
606 pub(super) reuse_existing_branch: bool,
610}
611
612pub(super) fn apply_workspace_to_raw(
615 record: &mut SessionRecord,
616 conversion: &WorkspaceToRawConversion,
617) {
618 record.project_directory = Some(conversion.worktree.worktree_root.clone());
619 record.managed_worktree = Some(conversion.worktree.clone());
620}
621
622#[derive(Debug, Clone, PartialEq, Eq)]
625pub(super) struct RawToWorkspaceConversion {
626 pub(super) checkout: PathBuf,
630 pub(super) repository: PathBuf,
632 pub(super) source: mj_core::remote_git::NetworkGitSource,
636 pub(super) bundle_id: String,
637 pub(super) new_bundle: Option<ProjectBundle>,
639 pub(super) retire: Option<ManagedWorktree>,
641}
642
643pub(super) fn plan_raw_to_workspace(
646 session: &SessionRecord,
647 config: &Config,
648 executor: &impl CommandExecutor,
649) -> Result<RawToWorkspaceConversion> {
650 let project_directory = session
651 .project_directory
652 .as_deref()
653 .context("a raw session has no project directory")?;
654 let (checkout, repository, retire) = match &session.managed_worktree {
660 Some(worktree) => {
661 ensure!(
662 worktree.worktree_root == project_directory,
663 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
664 project_directory.display()
665 );
666 (
667 worktree.worktree_root.clone(),
668 worktree.source_repository.clone(),
669 Some(worktree.clone()),
670 )
671 }
672 None => {
673 let inspection =
674 inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
675 ensure!(
676 inspection.source_project_directory == inspection.source_repository,
677 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
678 project_directory.display()
679 );
680 let repository = canonical_repository(&inspection.source_repository)?;
681 (inspection.source_repository, repository, None)
682 }
683 };
684 let destination = PathBuf::from(
688 project_directory
689 .file_name()
690 .context("a raw project directory cannot be the filesystem root")?,
691 );
692 let (bundle_id, new_bundle) =
693 converted_raw_bundle(config, &session.bundle_id, &repository, &destination);
694 let source = mj_core::remote_git::resolve_local_repository(&checkout, executor).with_context(
698 || {
699 format!(
700 "{} has no network Git remote; add one (for example `git remote add origin <url>`) or resume this session on a bare target",
701 checkout.display()
702 )
703 },
704 )?;
705 Ok(RawToWorkspaceConversion {
706 checkout,
707 repository,
708 source,
709 bundle_id,
710 new_bundle,
711 retire,
712 })
713}
714
715fn converted_raw_bundle(
719 config: &Config,
720 session_bundle_id: &str,
721 repository: &Path,
722 destination: &Path,
723) -> (String, Option<ProjectBundle>) {
724 let describes_checkout = |bundle: &ProjectBundle| {
725 bundle.repositories.len() == 1
726 && bundle.repositories[0].github.is_none()
727 && bundle.repositories[0].local.as_deref() == Some(repository)
728 && bundle.repositories[0].destination == destination
729 };
730 if config
731 .bundles
732 .get(session_bundle_id)
733 .is_some_and(describes_checkout)
734 {
735 return (session_bundle_id.to_owned(), None);
736 }
737 if let Some((id, _)) = config
738 .bundles
739 .iter()
740 .find(|(_, bundle)| describes_checkout(bundle))
741 {
742 return (id.clone(), None);
743 }
744 let name = repository
745 .file_name()
746 .map(|name| name.to_string_lossy().into_owned())
747 .unwrap_or_default();
748 let id = crate::import::unique_bundle_id(config, &crate::import::setup_style_id(&name));
749 let bundle = ProjectBundle {
750 primary_repo: id.clone(),
751 repositories: vec![mj_core::config::ProjectRepository {
752 id: id.clone(),
753 github: None,
754 local: Some(repository.to_path_buf()),
755 destination: destination.to_path_buf(),
756 git_ref: None,
757 }],
758 };
759 (id, Some(bundle))
760}
761
762const RAW_CONVERSION_REPOSITORY_ID: &str = "project";
765
766pub(super) fn raw_checkout_snapshot(
774 checkout: &Path,
775 source: &mj_core::remote_git::NetworkGitSource,
776 destination: &Path,
777 git: &dyn mj_checkpoint::archive::GitCommandRunner,
778) -> Result<mj_checkpoint::archive::RepositorySnapshot> {
779 mj_checkpoint::checkpoint::repair_origin_refs(git, checkout, RAW_CONVERSION_REPOSITORY_ID)?;
782 mj_checkpoint::checkpoint::reject_dirty_submodules(git, checkout)
783 .with_context(|| format!("checkout {}", checkout.display()))?;
784 let mut snapshot = mj_checkpoint::archive::collect_git_snapshot(
785 git,
786 checkout,
787 &mj_checkpoint::archive::GitCollectionSpec {
788 id: RAW_CONVERSION_REPOSITORY_ID.to_owned(),
789 relative_destination: destination.to_path_buf(),
790 history: mj_checkpoint::archive::GitHistoryMode::SessionDelta,
791 origin_override: None,
792 },
793 )
794 .with_context(|| format!("snapshot the checkout at {}", checkout.display()))?;
795 snapshot.metadata.origin =
798 mj_checkpoint::archive::redact_origin_credentials(&source.fetch_url)?;
799 snapshot.metadata.push_urls = source
800 .push_urls
801 .iter()
802 .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
803 .collect::<Result<Vec<_>>>()?;
804 snapshot.metadata.remote_workspace = true;
805 snapshot.metadata.base_commit = origin_boundary_commit(git, checkout)?
806 .unwrap_or_else(|| snapshot.metadata.head_commit.clone());
807 Ok(snapshot)
808}
809
810fn origin_boundary_commit(
814 git: &dyn mj_checkpoint::archive::GitCommandRunner,
815 checkout: &Path,
816) -> Result<Option<String>> {
817 let listed = git_runner_stdout(
818 git,
819 checkout,
820 [
821 "rev-list",
822 "--boundary",
823 "HEAD",
824 "--not",
825 "--remotes=origin",
826 ],
827 "list commits outside origin",
828 )?;
829 Ok(listed
832 .lines()
833 .filter_map(|line| line.strip_prefix('-'))
834 .map(|commit| commit.trim().to_owned())
835 .find(|commit| !commit.is_empty()))
836}
837
838fn git_runner_stdout(
839 git: &dyn mj_checkpoint::archive::GitCommandRunner,
840 repository: &Path,
841 args: impl IntoIterator<Item = impl AsRef<str>>,
842 purpose: &str,
843) -> Result<String> {
844 let output = git.run(
845 repository,
846 &mj_checkpoint::archive::GitCommand {
847 arguments: args
848 .into_iter()
849 .map(|argument| std::ffi::OsString::from(argument.as_ref()))
850 .collect(),
851 stdin: Vec::new(),
852 env: Vec::new(),
853 },
854 )?;
855 command_stdout(
856 CommandOutput {
857 status: output.status,
858 stdout: output.stdout,
859 stderr: output.stderr,
860 },
861 purpose,
862 )
863}
864
865pub(super) fn raw_conversion_preview(
868 session: &SessionRecord,
869 conversion: &RawToWorkspaceConversion,
870 executor: &impl CommandExecutor,
871) -> Result<mj_core::state::RawConversionPreview> {
872 let checkout = conversion.checkout.as_path();
873 reject_dirty_submodules_in_checkout(executor, checkout)?;
876 let default_branch = mj_core::remote_git::default_branch(&conversion.source, executor)?;
877 let position = read_checkout_position(executor, &ManagedWorktreeTarget::Local, checkout)?;
878 let unpushed_commits = unpushed_commit_count(executor, checkout)?;
879 let dirty = dirty_file_counts(executor, checkout)?;
880 let directory = session
883 .project_directory
884 .as_deref()
885 .context("a raw session has no project directory")?
886 .file_name()
887 .context("a raw project directory cannot be the filesystem root")?;
888 let container_workspace = match session.container_workspace.clone() {
894 Some(workspace) => workspace,
895 None => mj_core::targets::new_container_workspace(&session.id)?,
896 };
897 Ok(mj_core::state::RawConversionPreview {
898 checkout: checkout.to_path_buf(),
899 destination: container_workspace.join(directory),
900 branch: position.branch,
901 fetch_url: conversion.source.fetch_url.clone(),
902 push_urls: conversion.source.push_urls.clone(),
903 default_branch,
904 unpushed_commits,
905 staged_files: dirty.staged_files,
906 unstaged_files: dirty.unstaged_files,
907 untracked_files: dirty.untracked_files,
908 untracked_bytes: untracked_bytes(executor, checkout)?,
909 host_checkout_retained: conversion.retire.is_none(),
910 })
911}
912
913fn reject_dirty_submodules_in_checkout(
914 executor: &impl CommandExecutor,
915 checkout: &Path,
916) -> Result<()> {
917 let listed = managed_git_stdout(
918 executor,
919 &ManagedWorktreeTarget::Local,
920 checkout,
921 [
922 "submodule",
923 "foreach",
924 "--recursive",
925 "--quiet",
926 "git status --porcelain",
927 ],
928 "inspect submodules",
929 )?;
930 ensure!(
931 listed.trim().is_empty(),
932 "{} has a dirty submodule, which cannot move into a target; commit or discard the submodule's changes first",
933 checkout.display()
934 );
935 Ok(())
936}
937
938fn unpushed_commit_count(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
942 if !origin_refs_available(executor, checkout)? {
943 return Ok(0);
944 }
945 let counted = managed_git_stdout(
946 executor,
947 &ManagedWorktreeTarget::Local,
948 checkout,
949 ["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
950 "count commits outside origin",
951 )?;
952 counted
953 .trim()
954 .parse()
955 .with_context(|| format!("parse the commit count {counted:?}"))
956}
957
958fn origin_refs_available(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
959 if origin_refs_listed(executor, checkout)? {
960 return Ok(true);
961 }
962 let fetch = managed_git_command(
965 &ManagedWorktreeTarget::Local,
966 checkout,
967 ["fetch", "origin"],
968 "fetch origin refs",
969 );
970 executor.execute(&fetch)?;
971 origin_refs_listed(executor, checkout)
972}
973
974fn origin_refs_listed(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
975 managed_git_stdout(
976 executor,
977 &ManagedWorktreeTarget::Local,
978 checkout,
979 [
980 "for-each-ref",
981 "--format=%(objectname)",
982 "refs/remotes/origin",
983 ],
984 "list origin refs",
985 )
986 .map(|refs| !refs.trim().is_empty())
987}
988
989#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
990struct DirtyFileCounts {
991 staged_files: u64,
992 unstaged_files: u64,
993 untracked_files: u64,
994}
995
996fn dirty_file_counts(executor: &impl CommandExecutor, checkout: &Path) -> Result<DirtyFileCounts> {
999 let command = managed_git_command(
1000 &ManagedWorktreeTarget::Local,
1001 checkout,
1002 ["status", "--porcelain=v1", "-z"],
1003 "read checkout status",
1004 );
1005 let output = executor.execute(&command)?;
1006 ensure!(
1007 output.status == 0,
1008 "read checkout status failed with status {}: {}",
1009 output.status,
1010 String::from_utf8_lossy(&output.stderr).trim()
1011 );
1012 let mut counts = DirtyFileCounts::default();
1013 let mut records = output
1014 .stdout
1015 .split(|byte| *byte == 0)
1016 .filter(|record| !record.is_empty());
1017 while let Some(record) = records.next() {
1018 let [index, worktree, ..] = record else {
1019 bail!("git status produced a record shorter than its status field");
1020 };
1021 if *index == b'?' && *worktree == b'?' {
1022 counts.untracked_files += 1;
1023 continue;
1024 }
1025 if !matches!(index, b' ' | b'?') {
1026 counts.staged_files += 1;
1027 }
1028 if !matches!(worktree, b' ' | b'?') {
1029 counts.unstaged_files += 1;
1030 }
1031 if *index == b'R' || *index == b'C' || *worktree == b'R' || *worktree == b'C' {
1032 records.next();
1033 }
1034 }
1035 Ok(counts)
1036}
1037
1038fn untracked_bytes(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1042 let command = managed_git_command(
1043 &ManagedWorktreeTarget::Local,
1044 checkout,
1045 ["ls-files", "--others", "--exclude-standard", "-z"],
1046 "list untracked files",
1047 );
1048 let output = executor.execute(&command)?;
1049 ensure!(
1050 output.status == 0,
1051 "list untracked files failed with status {}: {}",
1052 output.status,
1053 String::from_utf8_lossy(&output.stderr).trim()
1054 );
1055 let mut total = 0;
1056 for record in output
1057 .stdout
1058 .split(|byte| *byte == 0)
1059 .filter(|record| !record.is_empty())
1060 {
1061 let relative = mj_core::path_input::from_git_bytes(record)?;
1062 let path = checkout.join(relative);
1063 match std::fs::symlink_metadata(&path) {
1066 Ok(metadata) => total += metadata.len(),
1067 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1068 Err(error) => {
1069 return Err(error).with_context(|| format!("measure {}", path.display()));
1070 }
1071 }
1072 }
1073 Ok(total)
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Eq)]
1078pub(super) struct CheckoutPosition {
1079 pub(super) head_commit: String,
1080 branch: Option<String>,
1081}
1082
1083fn read_checkout_position(
1084 executor: &impl CommandExecutor,
1085 target: &ManagedWorktreeTarget,
1086 directory: &Path,
1087) -> Result<CheckoutPosition> {
1088 let head_commit = managed_git_stdout(
1089 executor,
1090 target,
1091 directory,
1092 ["rev-parse", "HEAD"],
1093 "resolve checkout head commit",
1094 )?;
1095 let branch_command = managed_git_command(
1096 target,
1097 directory,
1098 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1099 "resolve checkout branch",
1100 );
1101 let branch_output = executor.execute(&branch_command)?;
1102 let branch = match branch_output.status {
1103 0 => Some(
1104 String::from_utf8(branch_output.stdout)
1105 .context("checkout branch was not UTF-8")?
1106 .trim()
1107 .to_owned(),
1108 ),
1109 1 | 128 => None,
1111 status => bail!(
1112 "resolve checkout branch failed with status {status}: {}",
1113 String::from_utf8_lossy(&branch_output.stderr).trim()
1114 ),
1115 };
1116 Ok(CheckoutPosition {
1117 head_commit,
1118 branch,
1119 })
1120}
1121
1122pub(super) fn managed_worktree_base_commit(
1128 worktree: &ManagedWorktree,
1129 executor: &impl CommandExecutor,
1130) -> Result<String> {
1131 if let Some(base) = &worktree.base_commit {
1132 return Ok(base.clone());
1133 }
1134 let reference = format!("refs/heads/{}", worktree.branch);
1135 let reflog_command = managed_git_command(
1136 &worktree.target,
1137 &worktree.source_repository,
1138 ["reflog", "show", "--format=%H", &reference],
1139 "read the session branch reflog",
1140 );
1141 let reflog_output = executor.execute(&reflog_command)?;
1142 if reflog_output.status == 0 {
1143 let text = String::from_utf8(reflog_output.stdout)
1144 .context("the session branch reflog was not UTF-8")?;
1145 if let Some(creation) = text.lines().rfind(|line| !line.trim().is_empty()) {
1148 return Ok(creation.trim().to_owned());
1149 }
1150 }
1151 let head = read_checkout_position(executor, &worktree.target, &worktree.worktree_root)?;
1152 tracing::warn!(
1153 branch = %worktree.branch,
1154 "the reflog for this session branch is gone, so its checkpoint bundle will carry no commits"
1155 );
1156 Ok(head.head_commit)
1157}
1158
1159pub(super) fn raw_checkout_position(
1162 session: &SessionRecord,
1163 config: &Config,
1164 project_directory: &Path,
1165 executor: &impl CommandExecutor,
1166) -> Result<CheckoutPosition> {
1167 let target = match &session.managed_worktree {
1168 Some(worktree) => worktree.target.clone(),
1169 None => {
1170 let template = config
1171 .targets
1172 .get(&session.target_template_id)
1173 .context("the bare target this session last used is missing")?;
1174 managed_worktree_target(template)?
1175 }
1176 };
1177 read_checkout_position(executor, &target, project_directory)
1178}
1179
1180pub(super) fn raw_checkout_divergence_notice(
1186 directory: &Path,
1187 recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
1188 live: &CheckoutPosition,
1189) -> Option<String> {
1190 let recorded = recorded?;
1191 if recorded.head_commit.is_empty()
1192 || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
1193 {
1194 return None;
1195 }
1196 Some(format!(
1197 "The working tree at {} moved from {} to {} while this session was stopped.",
1198 directory.display(),
1199 checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
1200 checkout_position_text(&live.head_commit, live.branch.as_deref()),
1201 ))
1202}
1203
1204fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
1205 let short = head_commit.get(..12).unwrap_or(head_commit);
1206 match branch {
1207 Some(branch) => format!("{short} ({branch})"),
1208 None => format!("{short} (detached)"),
1209 }
1210}
1211
1212fn inspect_raw_project(
1213 executor: &impl CommandExecutor,
1214 target: &ManagedWorktreeTarget,
1215 selected: &Path,
1216) -> Result<RawProjectInspection> {
1217 let repository = PathBuf::from(managed_git_stdout(
1218 executor,
1219 target,
1220 selected,
1221 ["rev-parse", "--path-format=absolute", "--show-toplevel"],
1222 "resolve raw project repository root",
1223 )?);
1224 let prefix = managed_git_stdout(
1225 executor,
1226 target,
1227 selected,
1228 ["rev-parse", "--show-prefix"],
1229 "resolve raw project relative directory",
1230 )?;
1231 let git_dir = PathBuf::from(managed_git_stdout(
1232 executor,
1233 target,
1234 selected,
1235 ["rev-parse", "--absolute-git-dir"],
1236 "resolve raw project Git directory",
1237 )?);
1238 let common_git_dir = PathBuf::from(managed_git_stdout(
1239 executor,
1240 target,
1241 selected,
1242 ["rev-parse", "--path-format=absolute", "--git-common-dir"],
1243 "resolve raw project common Git directory",
1244 )?);
1245 let branch_command = managed_git_command(
1246 target,
1247 selected,
1248 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1249 "resolve raw project branch",
1250 );
1251 let branch_output = executor.execute(&branch_command)?;
1252 let branch = match branch_output.status {
1253 0 => Some(
1254 String::from_utf8(branch_output.stdout)
1255 .context("raw project branch was not UTF-8")?
1256 .trim()
1257 .to_owned(),
1258 ),
1259 1 | 128 => None,
1260 status => bail!(
1261 "resolve raw project branch failed with status {status}: {}",
1262 String::from_utf8_lossy(&branch_output.stderr).trim()
1263 ),
1264 };
1265 let upstream = match branch {
1266 Some(branch) => {
1267 let reference = format!("refs/heads/{branch}");
1268 let upstream = managed_git_stdout(
1269 executor,
1270 target,
1271 selected,
1272 ["for-each-ref", "--format=%(upstream:short)", &reference],
1273 "resolve raw project upstream",
1274 )?;
1275 (!upstream.is_empty()).then_some(upstream)
1276 }
1277 None => None,
1278 };
1279 Ok(RawProjectInspection {
1280 source_project_directory: repository.join(prefix),
1281 source_repository: repository,
1282 primary_checkout: git_dir == common_git_dir,
1283 upstream,
1284 })
1285}
1286
1287fn ensure_managed_worktree_excluded(
1288 executor: &impl CommandExecutor,
1289 target: &ManagedWorktreeTarget,
1290 repository: &Path,
1291) -> Result<()> {
1292 let check = managed_git_command(
1293 target,
1294 repository,
1295 [
1296 "check-ignore",
1297 "--quiet",
1298 "--no-index",
1299 "--",
1300 ".mj/worktrees/",
1301 ],
1302 "check managed worktree exclusion",
1303 );
1304 let output = executor.execute(&check)?;
1305 match output.status {
1306 0 => return Ok(()),
1307 1 => {}
1308 status => bail!(
1309 "check managed worktree exclusion failed with status {status}: {}",
1310 String::from_utf8_lossy(&output.stderr).trim()
1311 ),
1312 }
1313 let exclude_path = PathBuf::from(managed_git_stdout(
1314 executor,
1315 target,
1316 repository,
1317 [
1318 "rev-parse",
1319 "--path-format=absolute",
1320 "--git-path",
1321 "info/exclude",
1322 ],
1323 "resolve repository-local exclude file",
1324 )?);
1325 const ENTRY: &str = "/.mj/worktrees/";
1326 match target {
1327 ManagedWorktreeTarget::Local => {
1328 use std::io::Write;
1329 let existing = match std::fs::read_to_string(&exclude_path) {
1330 Ok(existing) => existing,
1331 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1332 Err(error) => return Err(error.into()),
1333 };
1334 if existing.lines().any(|line| line.trim() == ENTRY) {
1335 return Ok(());
1336 }
1337 if let Some(parent) = exclude_path.parent() {
1338 std::fs::create_dir_all(parent)?;
1339 }
1340 let mut file = std::fs::OpenOptions::new()
1341 .create(true)
1342 .append(true)
1343 .open(&exclude_path)
1344 .with_context(|| format!("open {}", exclude_path.display()))?;
1345 if !existing.is_empty() && !existing.ends_with('\n') {
1346 writeln!(file)?;
1347 }
1348 writeln!(file, "# Hel managed worktrees\n{ENTRY}")?;
1349 }
1350 ManagedWorktreeTarget::Ssh { .. } => {
1351 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";
1352 let command = managed_target_command(
1353 target,
1354 "sh",
1355 [
1356 "-c",
1357 SCRIPT,
1358 "hel-exclude",
1359 &exclude_path.to_string_lossy(),
1360 ENTRY,
1361 ],
1362 )
1363 .purpose("update remote repository-local exclude file");
1364 execute_checked(executor, command)?;
1365 }
1366 }
1367 Ok(())
1368}
1369
1370pub(crate) fn path_exists_on_managed_target(
1371 executor: &impl CommandExecutor,
1372 target: &ManagedWorktreeTarget,
1373 path: &Path,
1374) -> Result<bool> {
1375 match target {
1376 ManagedWorktreeTarget::Local => path
1377 .try_exists()
1378 .with_context(|| format!("check managed project path {}", path.display())),
1379 ManagedWorktreeTarget::Ssh { .. } => {
1380 let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
1381 .purpose("check managed worktree path");
1382 let output = executor.execute(&command)?;
1383 match output.status {
1384 0 => Ok(true),
1385 1 => Ok(false),
1386 status => bail!(
1387 "check managed worktree path failed with status {status}: {}",
1388 String::from_utf8_lossy(&output.stderr).trim()
1389 ),
1390 }
1391 }
1392 }
1393}
1394
1395pub(super) fn managed_worktree_checkout_exists(
1396 executor: &impl CommandExecutor,
1397 worktree: &ManagedWorktree,
1398) -> Result<bool> {
1399 path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1400}
1401
1402pub(super) fn managed_worktree_checkout_is_dirty(
1408 executor: &impl CommandExecutor,
1409 worktree: &ManagedWorktree,
1410) -> Result<bool> {
1411 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1412 return Ok(false);
1413 }
1414 let status = managed_git_stdout(
1415 executor,
1416 &worktree.target,
1417 &worktree.worktree_root,
1418 ["status", "--porcelain=v1", "--untracked-files=all"],
1419 "inspect managed worktree changes",
1420 )?;
1421 Ok(!status.is_empty())
1422}
1423
1424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1426pub(super) enum PrimaryCheckoutRequirement {
1427 Clean,
1430 Any,
1434}
1435
1436pub(super) fn create_managed_worktree(
1437 executor: &impl CommandExecutor,
1438 worktree: &ManagedWorktree,
1439 upstream: Option<&str>,
1440 requirement: PrimaryCheckoutRequirement,
1441) -> Result<()> {
1442 ensure_managed_worktree_excluded(executor, &worktree.target, &worktree.source_repository)?;
1443 if requirement == PrimaryCheckoutRequirement::Clean {
1444 let status = managed_git_stdout(
1445 executor,
1446 &worktree.target,
1447 &worktree.source_repository,
1448 ["status", "--porcelain=v1", "--untracked-files=all"],
1449 "inspect primary checkout changes",
1450 )?;
1451 if !status.is_empty() {
1452 let paths = status.lines().take(20).collect::<Vec<_>>().join("\n ");
1453 bail!(
1454 "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n {paths}"
1455 );
1456 }
1457 }
1458 let parent = worktree
1459 .worktree_root
1460 .parent()
1461 .context("managed worktree root has no parent")?;
1462 execute_checked(
1463 executor,
1464 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1465 .purpose("create managed worktree directory"),
1466 )?;
1467 execute_checked(
1468 executor,
1469 managed_git_command(
1470 &worktree.target,
1471 &worktree.source_repository,
1472 [
1473 "worktree",
1474 "add",
1475 "-b",
1476 &worktree.branch,
1477 &worktree.worktree_root.to_string_lossy(),
1478 worktree.base_commit.as_deref().unwrap_or("HEAD"),
1479 ],
1480 "create managed raw-session worktree",
1481 ),
1482 )?;
1483 if let Some(upstream) = upstream {
1484 execute_checked(
1485 executor,
1486 managed_git_command(
1487 &worktree.target,
1488 &worktree.worktree_root,
1489 ["branch", "--set-upstream-to", upstream, &worktree.branch],
1490 "set managed worktree branch upstream",
1491 ),
1492 )?;
1493 }
1494 Ok(())
1495}
1496
1497pub(super) fn restore_managed_worktree(
1501 executor: &impl CommandExecutor,
1502 worktree: &ManagedWorktree,
1503) -> Result<bool> {
1504 if managed_worktree_checkout_exists(executor, worktree)? {
1505 return Ok(false);
1506 }
1507 ensure!(
1508 path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
1509 "managed worktree source repository is unavailable: {}",
1510 worktree.source_repository.display()
1511 );
1512 let branch_ref = format!("refs/heads/{}", worktree.branch);
1513 let check = managed_git_command(
1514 &worktree.target,
1515 &worktree.source_repository,
1516 ["show-ref", "--verify", "--quiet", &branch_ref],
1517 "check retired managed worktree branch",
1518 );
1519 let output = executor.execute(&check)?;
1520 match output.status {
1521 0 => {}
1522 1 => bail!(
1523 "managed worktree branch is unavailable: {}",
1524 worktree.branch
1525 ),
1526 status => bail!(
1527 "check retired managed worktree branch failed with status {status}: {}",
1528 String::from_utf8_lossy(&output.stderr).trim()
1529 ),
1530 }
1531 execute_checked(
1534 executor,
1535 managed_git_command(
1536 &worktree.target,
1537 &worktree.source_repository,
1538 ["worktree", "prune"],
1539 "prune retired managed worktree metadata",
1540 ),
1541 )?;
1542 let parent = worktree
1543 .worktree_root
1544 .parent()
1545 .context("managed worktree root has no parent")?;
1546 execute_checked(
1547 executor,
1548 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1549 .purpose("recreate managed worktree directory"),
1550 )?;
1551 execute_checked(
1552 executor,
1553 managed_git_command(
1554 &worktree.target,
1555 &worktree.source_repository,
1556 [
1557 "worktree",
1558 "add",
1559 "--",
1560 &worktree.worktree_root.to_string_lossy(),
1561 &worktree.branch,
1562 ],
1563 "restore managed raw-session worktree",
1564 ),
1565 )?;
1566 Ok(true)
1567}
1568
1569fn ensure_managed_worktree_available(
1570 executor: &impl CommandExecutor,
1571 worktree: &ManagedWorktree,
1572) -> Result<()> {
1573 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1574 bail!(
1575 "managed worktree path already exists: {}",
1576 worktree.worktree_root.display()
1577 );
1578 }
1579 let branch_ref = format!("refs/heads/{}", worktree.branch);
1580 let check = managed_git_command(
1581 &worktree.target,
1582 &worktree.source_repository,
1583 ["show-ref", "--verify", "--quiet", &branch_ref],
1584 "check managed worktree branch availability",
1585 );
1586 let output = executor.execute(&check)?;
1587 match output.status {
1588 0 => bail!(
1589 "managed worktree branch already exists: {}",
1590 worktree.branch
1591 ),
1592 1 => Ok(()),
1593 status => bail!(
1594 "check managed worktree branch availability failed with status {status}: {}",
1595 String::from_utf8_lossy(&output.stderr).trim()
1596 ),
1597 }
1598}
1599
1600fn retained_managed_worktree_branch_available(
1605 executor: &impl CommandExecutor,
1606 worktree: &ManagedWorktree,
1607) -> Result<bool> {
1608 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1609 bail!(
1610 "managed worktree path already exists: {}",
1611 worktree.worktree_root.display()
1612 );
1613 }
1614 let branch_ref = format!("refs/heads/{}", worktree.branch);
1615 let check = managed_git_command(
1616 &worktree.target,
1617 &worktree.source_repository,
1618 ["show-ref", "--verify", "--quiet", &branch_ref],
1619 "check retained managed worktree branch",
1620 );
1621 let output = executor.execute(&check)?;
1622 match output.status {
1623 1 => Ok(false),
1624 0 => {
1625 let worktrees = managed_git_stdout(
1626 executor,
1627 &worktree.target,
1628 &worktree.source_repository,
1629 ["worktree", "list", "--porcelain", "-z"],
1630 "check retained managed worktree checkout",
1631 )?;
1632 let branch_field = format!("branch {branch_ref}");
1633 if worktrees.split('\0').any(|field| field == branch_field) {
1634 bail!(
1635 "managed worktree branch is still checked out: {}",
1636 worktree.branch
1637 );
1638 }
1639 Ok(true)
1640 }
1641 status => bail!(
1642 "check retained managed worktree branch failed with status {status}: {}",
1643 String::from_utf8_lossy(&output.stderr).trim()
1644 ),
1645 }
1646}
1647
1648pub(super) fn preserve_retained_managed_worktree_branch(
1652 executor: &impl CommandExecutor,
1653 worktree: &ManagedWorktree,
1654) -> Result<String> {
1655 let session_id = worktree
1656 .branch
1657 .strip_prefix("mj/")
1658 .context("managed worktree branch is not session-owned")?;
1659 let branch_ref = format!("refs/heads/{}", worktree.branch);
1660 let tip = managed_git_stdout(
1661 executor,
1662 &worktree.target,
1663 &worktree.source_repository,
1664 ["rev-parse", "--verify", &branch_ref],
1665 "read retained managed worktree branch tip",
1666 )?;
1667 let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
1668 let existing = managed_git_command(
1669 &worktree.target,
1670 &worktree.source_repository,
1671 ["show-ref", "--verify", "--quiet", &recovery_ref],
1672 "check retained managed worktree recovery ref",
1673 );
1674 let output = executor.execute(&existing)?;
1675 match output.status {
1676 0 => {
1677 let existing_tip = managed_git_stdout(
1678 executor,
1679 &worktree.target,
1680 &worktree.source_repository,
1681 ["rev-parse", "--verify", &recovery_ref],
1682 "verify retained managed worktree recovery ref",
1683 )?;
1684 ensure!(
1685 existing_tip == tip,
1686 "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
1687 );
1688 Ok(recovery_ref)
1689 }
1690 1 => {
1691 execute_checked(
1692 executor,
1693 managed_git_command(
1694 &worktree.target,
1695 &worktree.source_repository,
1696 ["update-ref", &recovery_ref, &tip],
1697 "preserve retained managed worktree branch",
1698 ),
1699 )?;
1700 Ok(recovery_ref)
1701 }
1702 status => bail!(
1703 "check retained managed worktree recovery ref failed with status {status}: {}",
1704 String::from_utf8_lossy(&output.stderr).trim()
1705 ),
1706 }
1707}
1708
1709pub(super) fn retire_managed_worktree(
1716 executor: &impl CommandExecutor,
1717 worktree: &ManagedWorktree,
1718) -> Result<()> {
1719 cleanup_managed_worktree(executor, worktree, BranchDisposition::Keep)
1720}
1721
1722fn remove_managed_worktree_checkout(
1725 executor: &impl CommandExecutor,
1726 worktree: &ManagedWorktree,
1727) -> Result<bool> {
1728 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1729 return Ok(false);
1730 }
1731 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1732 execute_checked(
1733 executor,
1734 managed_git_command(
1735 &worktree.target,
1736 &worktree.source_repository,
1737 [
1738 "worktree",
1739 "remove",
1740 "--force",
1741 &worktree.worktree_root.to_string_lossy(),
1742 ],
1743 "remove managed raw-session worktree",
1744 ),
1745 )?;
1746 }
1747 execute_checked(
1748 executor,
1749 managed_git_command(
1750 &worktree.target,
1751 &worktree.source_repository,
1752 ["worktree", "prune"],
1753 "prune managed worktree metadata",
1754 ),
1755 )?;
1756 Ok(true)
1757}
1758
1759fn managed_branch_is_merged(
1767 executor: &impl CommandExecutor,
1768 worktree: &ManagedWorktree,
1769) -> Result<Option<bool>> {
1770 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
1771 return Ok(None);
1772 }
1773 let branch_ref = format!("refs/heads/{}", worktree.branch);
1774 let refs = managed_git_stdout(
1775 executor,
1776 &worktree.target,
1777 &worktree.source_repository,
1778 [
1779 "for-each-ref",
1780 "--contains",
1781 &branch_ref,
1782 "--format=%(refname)",
1783 "refs/heads",
1784 "refs/remotes",
1785 ],
1786 "list the branches containing a managed worktree branch",
1787 )?;
1788 Ok(Some(refs.lines().any(containing_ref_is_not_a_session)))
1789}
1790
1791fn containing_ref_is_not_a_session(reference: &str) -> bool {
1796 let reference = reference.trim();
1797 let remote_head = reference.starts_with("refs/remotes/") && reference.ends_with("/HEAD");
1798 !reference.is_empty() && !reference.starts_with("refs/heads/mj/") && !remote_head
1799}
1800
1801pub(super) fn cleanup_managed_worktree(
1805 executor: &impl CommandExecutor,
1806 worktree: &ManagedWorktree,
1807 branch: BranchDisposition,
1808) -> Result<()> {
1809 if !remove_managed_worktree_checkout(executor, worktree)? {
1810 return Ok(());
1811 }
1812 if branch == BranchDisposition::Keep {
1813 return remove_empty_managed_worktree_directories(executor, worktree);
1814 }
1815 let branch_ref = format!("refs/heads/{}", worktree.branch);
1816 let check = managed_git_command(
1817 &worktree.target,
1818 &worktree.source_repository,
1819 ["show-ref", "--verify", "--quiet", &branch_ref],
1820 "check managed worktree branch",
1821 );
1822 let output = executor.execute(&check)?;
1823 let present = match output.status {
1824 0 => true,
1825 1 => false,
1826 status => bail!(
1827 "check managed worktree branch failed with status {status}: {}",
1828 String::from_utf8_lossy(&output.stderr).trim()
1829 ),
1830 };
1831 let delete = match branch {
1832 BranchDisposition::Delete => present,
1833 BranchDisposition::DeleteIfMerged if present => {
1834 let merged = managed_branch_is_merged(executor, worktree)?;
1835 let delete = merged == Some(true);
1836 tracing::info!(
1837 branch = %worktree.branch,
1838 delete,
1839 reason = match merged {
1840 Some(true) => "another branch already contains its commits",
1841 Some(false) => "it holds commits no other branch contains",
1842 None => "its repository is gone",
1843 },
1844 "archiving decided what to do with a session branch"
1845 );
1846 delete
1847 }
1848 BranchDisposition::DeleteIfMerged | BranchDisposition::Keep => false,
1849 };
1850 if delete {
1851 execute_checked(
1852 executor,
1853 managed_git_command(
1854 &worktree.target,
1855 &worktree.source_repository,
1856 ["branch", "-D", "--", &worktree.branch],
1857 "delete managed raw-session branch",
1858 ),
1859 )?;
1860 }
1861 remove_empty_managed_worktree_directories(executor, worktree)
1862}
1863
1864fn remove_empty_managed_worktree_directories(
1865 executor: &impl CommandExecutor,
1866 worktree: &ManagedWorktree,
1867) -> Result<()> {
1868 let worktrees = worktree.source_repository.join(".mj").join("worktrees");
1869 let hel = worktree.source_repository.join(".mj");
1870 match &worktree.target {
1871 ManagedWorktreeTarget::Local => {
1872 for directory in [&worktrees, &hel] {
1873 match std::fs::remove_dir(directory) {
1874 Ok(()) => {}
1875 Err(error)
1876 if matches!(
1877 error.kind(),
1878 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
1879 ) => {}
1880 Err(error) => return Err(error.into()),
1881 }
1882 }
1883 }
1884 ManagedWorktreeTarget::Ssh { .. } => {
1885 let command = managed_target_command(
1886 &worktree.target,
1887 "rmdir",
1888 ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
1889 )
1890 .purpose("remove empty managed worktree directories");
1891 let _ = executor.execute(&command)?;
1892 }
1893 }
1894 Ok(())
1895}
1896
1897#[cfg(test)]
1898mod tests;