1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail, ensure};
6
7#[cfg(test)]
8use mj_core::config::ProjectRepository;
9use mj_core::config::{Config, TargetTemplate};
10use mj_core::local_git::canonical_repository;
11use mj_core::repository::{ProjectBundleSnapshot, RepositoryIdentity};
12use mj_core::state::{
13 ManagedCheckoutKind, ManagedWorktree, ManagedWorktreeOptions, ManagedWorktreeTarget,
14 ProjectSourceIdentity, SessionRecord,
15};
16
17use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
18pub(crate) use mj_client::target::managed_worktree_target;
19pub use mj_client::target::{ResumePlan, resume_compatibility, resume_compatibility_with_checkout};
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 if session.project.is_some() {
140 return Ok(session.project_source(&self.config));
141 }
142 let checkout = self.state.checkout(session_id)?;
143 let Some(directory) = checkout.project_directory() else {
144 return Ok(session.project_source(&self.config));
145 };
146 let (target, origin_directory) = match checkout.effective() {
147 mj_core::state::Checkout::ManagedWorktree { worktree, .. } => (
151 worktree.target.clone(),
152 worktree.source_repository.as_path(),
153 ),
154 mj_core::state::Checkout::Attached { .. } => (
155 managed_worktree_target(
156 self.config
157 .targets
158 .get(&session.target_template_id)
159 .with_context(|| {
160 format!(
161 "session {session_id} target {:?} is no longer configured",
162 session.target_template_id
163 )
164 })?,
165 )?,
166 directory,
167 ),
168 mj_core::state::Checkout::ManagedWorkspace => {
169 return Ok(session.project_source(&self.config));
170 }
171 mj_core::state::Checkout::Borrowed { .. } => {
172 unreachable!("effective checkout resolves borrowing")
173 }
174 };
175 let output = executor.execute(&managed_git_command(
176 &target,
177 origin_directory,
178 ["config", "--get", "remote.origin.url"],
179 "resolve project Git origin",
180 ))?;
181 match output.status {
182 0 => {
183 let origin =
184 String::from_utf8(output.stdout).context("project Git origin was not UTF-8")?;
185 if let Some(identity) = ProjectSourceIdentity::git_remote(origin.trim()) {
186 return Ok(identity);
187 }
188 }
189 1 => {}
191 status => bail!(
192 "resolve project Git origin failed with status {status}: {}",
193 String::from_utf8_lossy(&output.stderr).trim()
194 ),
195 }
196 let root = resolve_git_root(&target, origin_directory, executor)?
197 .unwrap_or_else(|| origin_directory.to_path_buf());
198 let remote = match &target {
199 ManagedWorktreeTarget::Local => None,
200 ManagedWorktreeTarget::Ssh { destination, .. } => Some(destination.as_str()),
201 };
202 Ok(ProjectSourceIdentity::path(&root, remote))
203 }
204
205 pub(super) fn plan_workspace_to_raw(
208 &self,
209 session: &SessionRecord,
210 target_id: &str,
211 executor: &impl CommandExecutor,
212 ) -> Result<WorkspaceToRawConversion> {
213 let bundle = session
214 .project_bundle(&self.config)
215 .context("session bundle is missing")?;
216 let [repository] = bundle.repositories.as_slice() else {
217 bail!("a checkout holds exactly one repository");
218 };
219 let source = repository
220 .local
221 .as_deref()
222 .context("only a repository already on this machine can become a checkout")?;
223 self.validate_project_directory(target_id, source, executor)
224 .context("this session's repository is unavailable")?;
225 let mut worktree = ManagedWorktree {
226 kind: Default::default(),
227 source_project_directory: source.to_path_buf(),
228 source_repository: source.to_path_buf(),
229 worktree_root: source.join(".mj").join("worktrees").join(&session.id),
230 branch: format!("mj/{}", session.id),
231 target: managed_worktree_target(
232 self.config
233 .targets
234 .get(target_id)
235 .with_context(|| format!("unknown target template {target_id:?}"))?,
236 )?,
237 base_commit: None,
238 };
239 let reuse_existing_branch =
240 retained_managed_worktree_branch_available(executor, &worktree)?;
241 if !reuse_existing_branch {
242 let (branch, remote_branch) = managed_clone_starting_branch(
243 executor,
244 &worktree.target,
245 source,
246 session.launch_branch.as_deref(),
247 )?;
248 worktree.kind = ManagedCheckoutKind::Clone;
249 worktree.worktree_root = source.join(".mj").join("clones").join(&session.id);
250 worktree.branch = branch.clone();
251 worktree.base_commit = Some(managed_git_stdout(
252 executor,
253 &worktree.target,
254 source,
255 [
256 "rev-parse",
257 "--verify",
258 &format!(
259 "{}^{{commit}}",
260 if remote_branch {
261 format!("refs/remotes/origin/{branch}")
262 } else {
263 format!("refs/heads/{branch}")
264 }
265 ),
266 ],
267 "resolve converted checkout source commit",
268 )?);
269 }
270 if !reuse_existing_branch {
271 ensure_managed_worktree_available(executor, &worktree)?;
272 }
273 Ok(WorkspaceToRawConversion {
274 worktree,
275 reuse_existing_branch,
276 })
277 }
278
279 pub(super) fn prepare_managed_raw_worktree(
280 &mut self,
281 session_id: &str,
282 executor: &impl CommandExecutor,
283 ) -> Result<bool> {
284 let session = self
285 .state
286 .sessions
287 .get(session_id)
288 .with_context(|| format!("unknown session {session_id}"))?
289 .clone();
290 let checkout = self.state.checkout(session_id)?;
291 let selected = match checkout {
292 mj_core::state::Checkout::Attached { path } => Some(path.to_path_buf()),
293 mj_core::state::Checkout::ManagedWorktree { .. }
294 | mj_core::state::Checkout::ManagedWorkspace
295 | mj_core::state::Checkout::Borrowed { .. } => None,
296 };
297 drop(checkout);
298 let Some(selected) = selected else {
299 return Ok(false);
300 };
301 if session.create_managed_worktree == Some(false) {
302 return Ok(false);
303 }
304 let template = self
305 .config
306 .targets
307 .get(&session.target_template_id)
308 .context("raw session target template disappeared during provisioning")?;
309 if matches!(template, TargetTemplate::SshBare { .. }) {
310 self.validate_project_directory(&session.target_template_id, &selected, executor)?;
311 }
312 let target = managed_worktree_target(template)?;
313 if matches!(target, ManagedWorktreeTarget::Local)
314 && local_project_repository(&selected, executor)?.is_none()
315 {
316 ensure!(
320 session.create_managed_worktree != Some(true) && session.launch_base.is_none(),
321 "managed worktree creation requires a Git project"
322 );
323 return Ok(false);
324 }
325 let inspection = inspect_raw_project(executor, &target, &selected)?;
326 if !inspection.primary_checkout
327 && session.create_managed_worktree != Some(true)
328 && session.launch_base.is_none()
329 {
330 return Ok(false);
331 }
332 let relative_directory = inspection
333 .source_project_directory
334 .strip_prefix(&inspection.source_repository)
335 .context("raw project directory is outside its repository")?
336 .to_path_buf();
337 let worktree_root = inspection
338 .source_repository
339 .join(".mj")
340 .join("clones")
341 .join(session_id);
342 let (branch, remote_branch) = managed_clone_starting_branch(
346 executor,
347 &target,
348 &inspection.source_repository,
349 session.launch_branch.as_deref(),
350 )?;
351 let base_commit = match session.launch_base.as_deref() {
352 Some(revision) => managed_git_stdout(
353 executor,
354 &target,
355 &inspection.source_repository,
356 [
357 "rev-parse",
358 "--verify",
359 "--end-of-options",
360 &format!("{revision}^{{commit}}"),
361 ],
362 "resolve the launch base",
363 )?
364 .trim()
365 .to_owned(),
366 None => managed_git_stdout(
367 executor,
368 &target,
369 &inspection.source_repository,
370 [
371 "rev-parse",
372 "--verify",
373 &format!(
374 "{}^{{commit}}",
375 if remote_branch {
376 format!("refs/remotes/origin/{branch}")
377 } else {
378 format!("refs/heads/{branch}")
379 }
380 ),
381 ],
382 "resolve selected branch tip",
383 )?,
384 };
385 let managed = ManagedWorktree {
386 kind: ManagedCheckoutKind::Clone,
387 source_project_directory: inspection.source_project_directory,
388 source_repository: inspection.source_repository,
389 worktree_root: worktree_root.clone(),
390 branch,
391 target,
392 base_commit: Some(base_commit),
393 };
394 ensure_managed_worktree_available(executor, &managed)?;
395 let record = self.state.sessions.get_mut(session_id).unwrap();
396 record.project_directory = Some(worktree_root.join(relative_directory));
397 record.managed_worktree = Some(managed.clone());
398 record.updated_at = now();
399 self.persist_session_state(session_id)?;
400 create_managed_worktree(
401 executor,
402 &managed,
403 inspection.upstream.as_deref(),
404 PrimaryCheckoutRequirement::Clean,
405 )?;
406 Ok(true)
407 }
408
409 pub(super) fn cleanup_new_session_worktree_after_failure(
410 &self,
411 session_id: &str,
412 executor: &impl CommandExecutor,
413 ) -> Result<()> {
414 if !self.state.sessions.contains_key(session_id) {
415 return Ok(());
416 }
417 let mj_core::state::Checkout::ManagedWorktree { worktree, .. } =
418 self.state.checkout(session_id)?
419 else {
420 return Ok(());
421 };
422 cleanup_managed_worktree(executor, worktree, BranchDisposition::Delete)
425 }
426}
427
428pub(crate) struct RemoteGitExecutor<'a, E> {
430 pub(crate) executor: &'a E,
431 pub(crate) ssh: SshTarget,
432}
433
434impl<E: CommandExecutor> CommandExecutor for RemoteGitExecutor<'_, E> {
435 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
436 let mut arguments = vec!["env".to_owned()];
437 arguments.extend(
438 command
439 .env
440 .iter()
441 .map(|(key, value)| format!("{key}={value}")),
442 );
443 arguments.push(command.program.clone());
444 arguments.extend(command.args.clone());
445 self.executor
446 .execute(&crate::targets::ssh_command(&self.ssh, arguments).purpose(&command.purpose))
447 }
448
449 fn cancellation_requested(&self) -> bool {
450 self.executor.cancellation_requested()
451 }
452}
453
454#[derive(Debug, Clone, PartialEq, Eq)]
455struct RawProjectInspection {
456 source_project_directory: PathBuf,
457 source_repository: PathBuf,
458 primary_checkout: bool,
459 upstream: Option<String>,
460}
461
462fn managed_clone_starting_branch(
463 executor: &impl CommandExecutor,
464 target: &ManagedWorktreeTarget,
465 repository: &Path,
466 selected: Option<&str>,
467) -> Result<(String, bool)> {
468 if let Some(branch) = selected {
469 let format = executor.execute(&managed_git_command(
470 target,
471 repository,
472 ["check-ref-format", "--branch", branch],
473 "validate selected branch",
474 ))?;
475 ensure!(format.status == 0, "invalid selected Git branch {branch:?}");
476 for (reference, remote) in [
477 (format!("refs/heads/{branch}"), false),
478 (format!("refs/remotes/origin/{branch}"), true),
479 ] {
480 let present = executor.execute(&managed_git_command(
481 target,
482 repository,
483 ["show-ref", "--verify", "--quiet", &reference],
484 "find selected branch",
485 ))?;
486 match present.status {
487 0 => return Ok((branch.to_owned(), remote)),
488 1 => {}
489 status => bail!("find selected branch failed with status {status}"),
490 }
491 }
492 bail!("selected branch {branch:?} is unavailable in the source repository");
493 }
494 let remote_head = managed_git_command(
495 target,
496 repository,
497 [
498 "symbolic-ref",
499 "--quiet",
500 "--short",
501 "refs/remotes/origin/HEAD",
502 ],
503 "resolve origin default branch",
504 );
505 let output = executor.execute(&remote_head)?;
506 match output.status {
507 0 => {
508 let reference = String::from_utf8(output.stdout)?;
509 let branch = reference
510 .trim()
511 .strip_prefix("origin/")
512 .context("origin/HEAD does not name an origin branch")?;
513 ensure!(!branch.is_empty(), "origin/HEAD has no branch");
514 Ok((branch.to_owned(), true))
515 }
516 1 => {
517 let origin = executor.execute(&managed_git_command(
518 target,
519 repository,
520 ["config", "--get", "remote.origin.url"],
521 "inspect origin remote",
522 ))?;
523 if origin.status == 0 {
524 let remote = managed_git_stdout(
525 executor,
526 target,
527 repository,
528 ["ls-remote", "--symref", "origin", "HEAD"],
529 "resolve remote default branch",
530 )?;
531 let branch = remote
532 .lines()
533 .find_map(|line| {
534 line.strip_prefix("ref: refs/heads/")?
535 .strip_suffix("\tHEAD")
536 })
537 .context("origin did not advertise a default branch")?;
538 let cached = executor.execute(&managed_git_command(
539 target,
540 repository,
541 [
542 "show-ref",
543 "--verify",
544 "--quiet",
545 &format!("refs/remotes/origin/{branch}"),
546 ],
547 "find remote default branch in source",
548 ))?;
549 ensure!(
550 cached.status == 0,
551 "origin default branch {branch:?} is not in the source repository; fetch it before starting a session"
552 );
553 return Ok((branch.to_owned(), true));
554 }
555 ensure!(
556 origin.status == 1,
557 "inspect origin remote failed with status {}",
558 origin.status
559 );
560 managed_git_stdout(
561 executor,
562 target,
563 repository,
564 ["symbolic-ref", "--quiet", "--short", "HEAD"],
565 "resolve source checkout branch",
566 )
567 .map(|branch| (branch, false))
568 .context("source checkout is detached; select a starting branch explicitly")
569 }
570 status => bail!(
571 "resolve origin default branch failed with status {status}: {}",
572 String::from_utf8_lossy(&output.stderr).trim()
573 ),
574 }
575}
576
577fn managed_target_ssh(target: &ManagedWorktreeTarget) -> Option<SshTarget> {
578 match target {
579 ManagedWorktreeTarget::Local => None,
580 ManagedWorktreeTarget::Ssh {
581 destination,
582 ssh_args,
583 } => Some(SshTarget {
584 destination: destination.clone(),
585 ssh_args: ssh_args.clone(),
586 }),
587 }
588}
589
590pub(super) fn managed_target_command(
591 target: &ManagedWorktreeTarget,
592 program: &str,
593 args: impl IntoIterator<Item = impl AsRef<str>>,
594) -> CommandSpec {
595 let args = args
596 .into_iter()
597 .map(|arg| arg.as_ref().to_owned())
598 .collect::<Vec<_>>();
599 match managed_target_ssh(target) {
600 None => CommandSpec::new(program, args),
601 Some(ssh) => {
602 let mut remote = vec![program.to_owned()];
603 remote.extend(args);
604 crate::targets::ssh_command(&ssh, remote)
605 }
606 }
607}
608
609pub(super) fn managed_git_command(
610 target: &ManagedWorktreeTarget,
611 directory: &Path,
612 args: impl IntoIterator<Item = impl AsRef<str>>,
613 purpose: impl Into<String>,
614) -> CommandSpec {
615 let mut command_args = vec!["-C".to_owned(), directory.to_string_lossy().into_owned()];
616 command_args.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
617 managed_target_command(target, "git", command_args).purpose(purpose)
618}
619
620fn command_stdout(output: CommandOutput, purpose: &str) -> Result<String> {
621 if output.status != 0 {
622 bail!(
623 "{purpose} failed with status {}: {}",
624 output.status,
625 String::from_utf8_lossy(&output.stderr).trim()
626 );
627 }
628 let stdout = String::from_utf8(output.stdout)
629 .with_context(|| format!("{purpose} produced non-UTF-8 output"))?;
630 Ok(stdout.trim_end_matches(['\r', '\n']).to_owned())
631}
632
633fn managed_git_stdout(
634 executor: &impl CommandExecutor,
635 target: &ManagedWorktreeTarget,
636 directory: &Path,
637 args: impl IntoIterator<Item = impl AsRef<str>>,
638 purpose: &str,
639) -> Result<String> {
640 let command = managed_git_command(target, directory, args, purpose);
641 command_stdout(executor.execute(&command)?, purpose)
642}
643
644fn resolve_git_root(
647 target: &ManagedWorktreeTarget,
648 directory: &Path,
649 executor: &impl CommandExecutor,
650) -> Result<Option<PathBuf>> {
651 let args = [
654 "-C".to_owned(),
655 directory.to_string_lossy().into_owned(),
656 "rev-parse".into(),
657 "--show-toplevel".into(),
658 ];
659 let top_level = match target {
660 ManagedWorktreeTarget::Local => {
661 let mut command = CommandSpec::new("git", args);
662 command.env.insert("LC_ALL".into(), "C".into());
663 command
664 }
665 ManagedWorktreeTarget::Ssh { .. } => managed_target_command(
666 target,
667 "env",
668 ["LC_ALL=C".to_owned(), "git".into()]
669 .into_iter()
670 .chain(args),
671 ),
672 }
673 .purpose("resolve project Git root");
674 let output = executor.execute(&top_level)?;
675 if output.status != 0 {
676 if output.status == 128
677 && String::from_utf8_lossy(&output.stderr).starts_with("fatal: not a git repository")
678 {
679 return Ok(None);
680 }
681 bail!(
682 "resolve project Git root failed with status {}: {}",
683 output.status,
684 String::from_utf8_lossy(&output.stderr).trim()
685 );
686 }
687 let root = PathBuf::from(
688 String::from_utf8(output.stdout)
689 .context("project Git root was not UTF-8")?
690 .trim_end_matches(['\r', '\n']),
691 );
692 if !root.is_absolute() {
693 bail!(
694 "resolve project Git root returned a non-absolute path: {}",
695 root.display()
696 );
697 }
698
699 let prefix = managed_git_stdout(
700 executor,
701 target,
702 directory,
703 ["rev-parse", "--show-prefix"],
704 "resolve project relative directory",
705 )?;
706 let common = mj_core::local_git::resolve_git_path(
709 &root.join(prefix),
710 &managed_git_stdout(
711 executor,
712 target,
713 directory,
714 ["rev-parse", "--git-common-dir"],
715 "resolve project Git common directory",
716 )?,
717 )?;
718 if common.file_name() == Some(std::ffi::OsStr::new(".git"))
719 && let Some(main_root) = common.parent()
720 {
721 return Ok(Some(main_root.to_path_buf()));
722 }
723 Ok(Some(root))
724}
725
726pub fn local_project_repository(
729 directory: &Path,
730 executor: &impl CommandExecutor,
731) -> Result<Option<PathBuf>> {
732 resolve_git_root(&ManagedWorktreeTarget::Local, directory, executor)
733}
734
735pub(super) fn apply_raw_to_workspace(
740 record: &mut SessionRecord,
741 conversion: &RawToWorkspaceConversion,
742) -> Result<()> {
743 let project = record
744 .project
745 .get_or_insert_with(|| conversion.project.clone());
746 ensure!(
747 project.bundle == conversion.project.bundle
748 && project.identities == conversion.project.identities,
749 "the accepted project changed while preparing the checkout transition"
750 );
751 project
752 .network_sources
753 .insert(conversion.repository_id.clone(), conversion.source.clone());
754 record.project_directory = None;
755 record.managed_worktree = None;
756 Ok(())
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
762pub(super) enum ResumeConversion {
763 RawToWorkspace(Box<RawToWorkspaceConversion>),
764 WorkspaceToRaw(WorkspaceToRawConversion),
765}
766
767impl ResumeConversion {
768 pub(super) fn raw_to_workspace(&self) -> Option<&RawToWorkspaceConversion> {
769 match self {
770 Self::RawToWorkspace(conversion) => Some(conversion),
771 Self::WorkspaceToRaw(_) => None,
772 }
773 }
774
775 pub(super) fn workspace_to_raw(&self) -> Option<&WorkspaceToRawConversion> {
776 match self {
777 Self::WorkspaceToRaw(conversion) => Some(conversion),
778 Self::RawToWorkspace(_) => None,
779 }
780 }
781}
782
783#[derive(Debug, Clone, PartialEq, Eq)]
787pub(super) struct WorkspaceToRawConversion {
788 pub(super) worktree: ManagedWorktree,
789 pub(super) reuse_existing_branch: bool,
793}
794
795pub(super) fn apply_workspace_to_raw(
798 record: &mut SessionRecord,
799 conversion: &WorkspaceToRawConversion,
800) {
801 record.project_directory = Some(conversion.worktree.worktree_root.clone());
802 record.managed_worktree = Some(conversion.worktree.clone());
803}
804
805#[derive(Debug, Clone, PartialEq, Eq)]
808pub(super) struct RawToWorkspaceConversion {
809 pub(super) checkout: PathBuf,
813 pub(super) repository_id: String,
815 pub(super) destination: PathBuf,
817 pub(super) project: ProjectBundleSnapshot,
820 pub(super) source: mj_core::remote_git::NetworkGitSource,
824 pub(super) retire: Option<ManagedWorktree>,
826}
827
828pub(super) fn plan_raw_to_workspace_for_session(
831 session: &SessionRecord,
832 checkout: &mj_core::state::Checkout<'_>,
833 config: &Config,
834 executor: &impl CommandExecutor,
835) -> Result<RawToWorkspaceConversion> {
836 let project = accepted_project_snapshot(session, config, executor)?;
837 plan_raw_to_workspace_with_checkout(checkout, &project, executor)
838}
839
840fn accepted_project_snapshot(
841 session: &SessionRecord,
842 config: &Config,
843 executor: &impl CommandExecutor,
844) -> Result<ProjectBundleSnapshot> {
845 if let Some(project) = &session.project {
846 return Ok(project.clone());
847 }
848 let bundle = session
849 .project_bundle(config)
850 .context("raw session has no accepted project bundle")?;
851 crate::project_catalog::snapshot(bundle, executor, false)
852}
853
854#[cfg(test)]
857pub(super) fn plan_raw_to_workspace(
858 session: &SessionRecord,
859 executor: &impl CommandExecutor,
860) -> Result<RawToWorkspaceConversion> {
861 let checkout = session.checkout();
862 let project = if let Some(project) = &session.project {
863 project.clone()
864 } else {
865 let project_directory = checkout
866 .project_directory()
867 .context("a raw session has no project directory")?;
868 let repository = match &checkout {
869 mj_core::state::Checkout::ManagedWorktree { worktree, .. } => {
870 worktree.source_repository.clone()
871 }
872 mj_core::state::Checkout::Attached { .. }
873 | mj_core::state::Checkout::Borrowed { .. } => {
874 inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?
875 .source_repository
876 }
877 mj_core::state::Checkout::ManagedWorkspace => {
878 bail!("a bundle workspace cannot be converted as a raw checkout")
879 }
880 };
881 let repository = canonical_repository(&repository)?;
882 let destination = PathBuf::from(
883 project_directory
884 .file_name()
885 .context("a raw project directory cannot be the filesystem root")?,
886 );
887 crate::project_catalog::snapshot(
888 &mj_core::config::ProjectBundle {
889 primary_repo: "project".to_owned(),
890 repositories: vec![ProjectRepository {
891 id: "project".to_owned(),
892 github: None,
893 local: Some(repository),
894 destination,
895 git_ref: None,
896 }],
897 },
898 executor,
899 false,
900 )?
901 };
902 plan_raw_to_workspace_with_checkout(&checkout, &project, executor)
903}
904
905pub(super) fn plan_raw_to_workspace_with_checkout(
906 checkout: &mj_core::state::Checkout<'_>,
907 project: &ProjectBundleSnapshot,
908 executor: &impl CommandExecutor,
909) -> Result<RawToWorkspaceConversion> {
910 let project_directory = checkout
911 .project_directory()
912 .context("a raw session has no project directory")?;
913 let (checkout, source_repository, retire) = match checkout {
919 mj_core::state::Checkout::ManagedWorktree { worktree, .. } => {
920 ensure!(
921 worktree.worktree_root == project_directory,
922 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
923 project_directory.display()
924 );
925 (
926 worktree.worktree_root.clone(),
927 canonical_repository(&worktree.source_repository)?,
928 Some((**worktree).clone()),
929 )
930 }
931 mj_core::state::Checkout::Attached { .. } | mj_core::state::Checkout::Borrowed { .. } => {
932 let inspection =
933 inspect_raw_project(executor, &ManagedWorktreeTarget::Local, project_directory)?;
934 ensure!(
935 inspection.source_project_directory == inspection.source_repository,
936 "{} is a subdirectory of its checkout; only a whole checkout can move into a target",
937 project_directory.display()
938 );
939 let source_repository = canonical_repository(&inspection.source_repository)?;
940 (inspection.source_project_directory, source_repository, None)
941 }
942 mj_core::state::Checkout::ManagedWorkspace => {
943 bail!("a bundle workspace cannot be converted as a raw checkout")
944 }
945 };
946 ensure!(
947 project.bundle.repositories.len() == 1,
948 "a raw checkout can only transition with a single-repository bundle"
949 );
950 project.key()?;
951 let repository = project
952 .bundle
953 .repositories
954 .iter()
955 .find(|repository| repository.id == project.bundle.primary_repo)
956 .context("accepted project primary repository is missing")?;
957 let source = if repository.github.is_some() {
958 mj_core::remote_git::resolve_repository(repository, executor)?
959 } else {
960 mj_core::remote_git::resolve_local_repository(&checkout, executor).with_context(|| {
961 format!(
962 "bundle repository {:?} has no network source for {}: add one (for example `git remote add origin <url>`) or resume this session on a bare target",
963 repository.id,
964 checkout.display()
965 )
966 })?
967 };
968 let source_identity = RepositoryIdentity::from_remote(&source.fetch_url)
969 .context("resolved checkout source has no repository identity")?;
970 let accepted_identity = project
971 .identities
972 .get(&repository.id)
973 .context("accepted project repository identity is missing")?;
974 ensure!(
975 accepted_identity == &source_identity
976 || matches!(accepted_identity, RepositoryIdentity::Local(path) if path == &source_repository),
977 "resolved checkout source does not match repository {:?} in the accepted bundle",
978 repository.id
979 );
980 Ok(RawToWorkspaceConversion {
981 checkout,
982 repository_id: repository.id.clone(),
983 destination: repository.destination.clone(),
984 project: project.clone(),
985 source,
986 retire,
987 })
988}
989
990pub(super) fn raw_checkout_snapshot(
998 checkout: &Path,
999 repository_id: &str,
1000 source: &mj_core::remote_git::NetworkGitSource,
1001 destination: &Path,
1002 git: &dyn mj_checkpoint::archive::GitCommandRunner,
1003 managed_clone: bool,
1004) -> Result<mj_checkpoint::archive::RepositorySnapshot> {
1005 mj_checkpoint::checkpoint::repair_origin_refs(git, checkout, repository_id)?;
1008 reject_dirty_submodules_for_move(git, checkout)?;
1009 let boundary = origin_boundary_commit(git, checkout)?;
1010 let history = if managed_clone {
1011 mj_checkpoint::archive::GitHistoryMode::CloneFrom(
1012 boundary
1013 .clone()
1014 .context("managed clone has no origin boundary commit")?,
1015 )
1016 } else {
1017 mj_checkpoint::archive::GitHistoryMode::SessionDelta
1018 };
1019 let mut snapshot = mj_checkpoint::archive::collect_git_snapshot(
1020 git,
1021 checkout,
1022 &mj_checkpoint::archive::GitCollectionSpec {
1023 id: repository_id.to_owned(),
1024 relative_destination: destination.to_path_buf(),
1025 history,
1026 origin_override: None,
1027 },
1028 )
1029 .with_context(|| format!("snapshot the checkout at {}", checkout.display()))?;
1030 snapshot.metadata.origin =
1033 mj_checkpoint::archive::redact_origin_credentials(&source.fetch_url)?;
1034 snapshot.metadata.push_urls = source
1035 .push_urls
1036 .iter()
1037 .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
1038 .collect::<Result<Vec<_>>>()?;
1039 snapshot.metadata.remote_workspace = true;
1040 snapshot.metadata.base_commit =
1041 boundary.unwrap_or_else(|| snapshot.metadata.head_commit.clone());
1042 Ok(snapshot)
1043}
1044
1045fn origin_boundary_commit(
1049 git: &dyn mj_checkpoint::archive::GitCommandRunner,
1050 checkout: &Path,
1051) -> Result<Option<String>> {
1052 let listed = git_runner_stdout(
1053 git,
1054 checkout,
1055 [
1056 "rev-list",
1057 "--boundary",
1058 "HEAD",
1059 "--not",
1060 "--remotes=origin",
1061 ],
1062 "list commits outside origin",
1063 )?;
1064 Ok(listed
1067 .lines()
1068 .filter_map(|line| line.strip_prefix('-'))
1069 .map(|commit| commit.trim().to_owned())
1070 .find(|commit| !commit.is_empty()))
1071}
1072
1073fn git_runner_stdout(
1074 git: &dyn mj_checkpoint::archive::GitCommandRunner,
1075 repository: &Path,
1076 args: impl IntoIterator<Item = impl AsRef<str>>,
1077 purpose: &str,
1078) -> Result<String> {
1079 let output = git.run(
1080 repository,
1081 &mj_checkpoint::archive::GitCommand {
1082 arguments: args
1083 .into_iter()
1084 .map(|argument| std::ffi::OsString::from(argument.as_ref()))
1085 .collect(),
1086 stdin: Vec::new(),
1087 env: Vec::new(),
1088 },
1089 )?;
1090 command_stdout(
1091 CommandOutput {
1092 status: output.status,
1093 stdout: output.stdout,
1094 stderr: output.stderr,
1095 },
1096 purpose,
1097 )
1098}
1099
1100#[cfg(test)]
1103pub(super) fn raw_conversion_preview(
1104 session: &SessionRecord,
1105 conversion: &RawToWorkspaceConversion,
1106 executor: &(impl CommandExecutor + Sync),
1107) -> Result<mj_core::state::RawConversionPreview> {
1108 raw_conversion_preview_with_checkout(session, conversion, executor)
1109}
1110
1111pub(super) fn raw_conversion_preview_with_checkout(
1112 session: &SessionRecord,
1113 conversion: &RawToWorkspaceConversion,
1114 executor: &(impl CommandExecutor + Sync),
1115) -> Result<mj_core::state::RawConversionPreview> {
1116 let checkout = conversion.checkout.as_path();
1117 reject_dirty_submodules_for_move(&ExecutorGit(executor), checkout)?;
1120 let default_branch = mj_core::remote_git::default_branch(&conversion.source, executor)?;
1121 let position = read_checkout_position(executor, &ManagedWorktreeTarget::Local, checkout)?;
1122 let unpushed_commits = unpushed_commit_count(executor, checkout)?;
1123 let dirty = dirty_file_counts(executor, checkout)?;
1124 let container_workspace = match session.container_workspace.clone() {
1130 Some(workspace) => workspace,
1131 None => mj_core::targets::new_container_workspace(&session.id)?,
1132 };
1133 Ok(mj_core::state::RawConversionPreview {
1134 checkout: checkout.to_path_buf(),
1135 destination: container_workspace.join(&conversion.destination),
1136 branch: position.branch,
1137 fetch_url: conversion.source.fetch_url.clone(),
1138 push_urls: conversion.source.push_urls.clone(),
1139 default_branch,
1140 unpushed_commits,
1141 staged_files: dirty.staged_files,
1142 unstaged_files: dirty.unstaged_files,
1143 untracked_files: dirty.untracked_files,
1144 untracked_bytes: untracked_bytes(executor, checkout)?,
1145 host_checkout_retained: conversion.retire.is_none(),
1146 })
1147}
1148
1149fn reject_dirty_submodules_for_move(
1154 git: &dyn mj_checkpoint::archive::GitCommandRunner,
1155 checkout: &Path,
1156) -> Result<()> {
1157 let inspection = mj_checkpoint::checkpoint::inspect_submodules(git, checkout)
1158 .with_context(|| format!("checkout {}", checkout.display()))?;
1159 for gitlink in &inspection.unregistered {
1160 tracing::warn!(
1161 checkout = %checkout.display(),
1162 gitlink = %gitlink.display(),
1163 "gitlink has no .gitmodules entry; the move carries the commit it points to, not its files"
1164 );
1165 }
1166 if let Some(dirty) = inspection.dirty_summary() {
1167 bail!(
1168 "{}: {dirty}, which cannot move into a target; commit or stash them first",
1169 checkout.display()
1170 );
1171 }
1172 Ok(())
1173}
1174
1175struct ExecutorGit<'a, E>(&'a E);
1179
1180impl<E: CommandExecutor + Sync> mj_checkpoint::archive::GitCommandRunner for ExecutorGit<'_, E> {
1181 fn run(
1182 &self,
1183 repository: &Path,
1184 command: &mj_checkpoint::archive::GitCommand,
1185 ) -> Result<mj_checkpoint::archive::GitOutput> {
1186 ensure!(
1187 command.stdin.is_empty() && command.env.is_empty(),
1188 "an executor Git command takes no standard input or extra environment"
1189 );
1190 let output = self.0.execute(&managed_git_command(
1191 &ManagedWorktreeTarget::Local,
1192 repository,
1193 command
1194 .arguments
1195 .iter()
1196 .map(|argument| argument.to_string_lossy().into_owned()),
1197 "inspect submodules",
1198 ))?;
1199 Ok(mj_checkpoint::archive::GitOutput {
1200 status: output.status,
1201 stdout: output.stdout,
1202 stderr: output.stderr,
1203 })
1204 }
1205}
1206
1207fn unpushed_commit_count(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1211 if !origin_refs_available(executor, checkout)? {
1212 return Ok(0);
1213 }
1214 let counted = managed_git_stdout(
1215 executor,
1216 &ManagedWorktreeTarget::Local,
1217 checkout,
1218 ["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
1219 "count commits outside origin",
1220 )?;
1221 counted
1222 .trim()
1223 .parse()
1224 .with_context(|| format!("parse the commit count {counted:?}"))
1225}
1226
1227fn origin_refs_available(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
1228 if origin_refs_listed(executor, checkout)? {
1229 return Ok(true);
1230 }
1231 let fetch = managed_git_command(
1234 &ManagedWorktreeTarget::Local,
1235 checkout,
1236 ["fetch", "origin"],
1237 "fetch origin refs",
1238 );
1239 executor.execute(&fetch)?;
1240 origin_refs_listed(executor, checkout)
1241}
1242
1243fn origin_refs_listed(executor: &impl CommandExecutor, checkout: &Path) -> Result<bool> {
1244 managed_git_stdout(
1245 executor,
1246 &ManagedWorktreeTarget::Local,
1247 checkout,
1248 [
1249 "for-each-ref",
1250 "--format=%(objectname)",
1251 "refs/remotes/origin",
1252 ],
1253 "list origin refs",
1254 )
1255 .map(|refs| !refs.trim().is_empty())
1256}
1257
1258#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1259struct DirtyFileCounts {
1260 staged_files: u64,
1261 unstaged_files: u64,
1262 untracked_files: u64,
1263}
1264
1265fn dirty_file_counts(executor: &impl CommandExecutor, checkout: &Path) -> Result<DirtyFileCounts> {
1268 let command = managed_git_command(
1269 &ManagedWorktreeTarget::Local,
1270 checkout,
1271 ["status", "--porcelain=v1", "-z"],
1272 "read checkout status",
1273 );
1274 let output = executor.execute(&command)?;
1275 ensure!(
1276 output.status == 0,
1277 "read checkout status failed with status {}: {}",
1278 output.status,
1279 String::from_utf8_lossy(&output.stderr).trim()
1280 );
1281 let mut counts = DirtyFileCounts::default();
1282 let mut records = output
1283 .stdout
1284 .split(|byte| *byte == 0)
1285 .filter(|record| !record.is_empty());
1286 while let Some(record) = records.next() {
1287 let [index, worktree, ..] = record else {
1288 bail!("git status produced a record shorter than its status field");
1289 };
1290 if *index == b'?' && *worktree == b'?' {
1291 counts.untracked_files += 1;
1292 continue;
1293 }
1294 if !matches!(index, b' ' | b'?') {
1295 counts.staged_files += 1;
1296 }
1297 if !matches!(worktree, b' ' | b'?') {
1298 counts.unstaged_files += 1;
1299 }
1300 if *index == b'R' || *index == b'C' || *worktree == b'R' || *worktree == b'C' {
1301 records.next();
1302 }
1303 }
1304 Ok(counts)
1305}
1306
1307fn untracked_bytes(executor: &impl CommandExecutor, checkout: &Path) -> Result<u64> {
1311 let command = managed_git_command(
1312 &ManagedWorktreeTarget::Local,
1313 checkout,
1314 ["ls-files", "--others", "--exclude-standard", "-z"],
1315 "list untracked files",
1316 );
1317 let output = executor.execute(&command)?;
1318 ensure!(
1319 output.status == 0,
1320 "list untracked files failed with status {}: {}",
1321 output.status,
1322 String::from_utf8_lossy(&output.stderr).trim()
1323 );
1324 let mut total = 0;
1325 for record in output
1326 .stdout
1327 .split(|byte| *byte == 0)
1328 .filter(|record| !record.is_empty())
1329 {
1330 let relative = mj_core::path_input::from_git_bytes(record)?;
1331 let path = checkout.join(relative);
1332 match std::fs::symlink_metadata(&path) {
1335 Ok(metadata) => total += metadata.len(),
1336 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1337 Err(error) => {
1338 return Err(error).with_context(|| format!("measure {}", path.display()));
1339 }
1340 }
1341 }
1342 Ok(total)
1343}
1344
1345#[derive(Debug, Clone, PartialEq, Eq)]
1347pub(super) struct CheckoutPosition {
1348 pub(super) head_commit: String,
1349 branch: Option<String>,
1350}
1351
1352fn read_checkout_position(
1353 executor: &impl CommandExecutor,
1354 target: &ManagedWorktreeTarget,
1355 directory: &Path,
1356) -> Result<CheckoutPosition> {
1357 let head_commit = managed_git_stdout(
1358 executor,
1359 target,
1360 directory,
1361 ["rev-parse", "HEAD"],
1362 "resolve checkout head commit",
1363 )?;
1364 let branch_command = managed_git_command(
1365 target,
1366 directory,
1367 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1368 "resolve checkout branch",
1369 );
1370 let branch_output = executor.execute(&branch_command)?;
1371 let branch = match branch_output.status {
1372 0 => Some(
1373 String::from_utf8(branch_output.stdout)
1374 .context("checkout branch was not UTF-8")?
1375 .trim()
1376 .to_owned(),
1377 ),
1378 1 | 128 => None,
1380 status => bail!(
1381 "resolve checkout branch failed with status {status}: {}",
1382 String::from_utf8_lossy(&branch_output.stderr).trim()
1383 ),
1384 };
1385 Ok(CheckoutPosition {
1386 head_commit,
1387 branch,
1388 })
1389}
1390
1391pub(super) fn managed_worktree_base_commit(
1397 worktree: &ManagedWorktree,
1398 executor: &impl CommandExecutor,
1399) -> Result<String> {
1400 if let Some(base) = &worktree.base_commit {
1401 return Ok(base.clone());
1402 }
1403 let reference = format!("refs/heads/{}", worktree.branch);
1404 let reflog_command = managed_git_command(
1405 &worktree.target,
1406 &worktree.source_repository,
1407 ["reflog", "show", "--format=%H", &reference],
1408 "read the session branch reflog",
1409 );
1410 let reflog_output = executor.execute(&reflog_command)?;
1411 if reflog_output.status == 0 {
1412 let text = String::from_utf8(reflog_output.stdout)
1413 .context("the session branch reflog was not UTF-8")?;
1414 if let Some(creation) = text.lines().rfind(|line| !line.trim().is_empty()) {
1417 return Ok(creation.trim().to_owned());
1418 }
1419 }
1420 let head = read_checkout_position(executor, &worktree.target, &worktree.worktree_root)?;
1421 tracing::warn!(
1422 branch = %worktree.branch,
1423 "the reflog for this session branch is gone, so its checkpoint bundle will carry no commits"
1424 );
1425 Ok(head.head_commit)
1426}
1427
1428#[cfg(test)]
1431pub(super) fn raw_checkout_position(
1432 session: &SessionRecord,
1433 config: &Config,
1434 project_directory: &Path,
1435 executor: &impl CommandExecutor,
1436) -> Result<CheckoutPosition> {
1437 let checkout = match session.checkout() {
1438 checkout @ mj_core::state::Checkout::ManagedWorktree { .. } => checkout,
1439 _ => mj_core::state::Checkout::Attached {
1440 path: project_directory,
1441 },
1442 };
1443 raw_checkout_position_with_checkout(session, &checkout, config, project_directory, executor)
1444}
1445
1446pub(super) fn raw_checkout_position_with_checkout(
1447 session: &SessionRecord,
1448 checkout: &mj_core::state::Checkout<'_>,
1449 config: &Config,
1450 project_directory: &Path,
1451 executor: &impl CommandExecutor,
1452) -> Result<CheckoutPosition> {
1453 let target = match checkout.effective() {
1454 mj_core::state::Checkout::ManagedWorktree { worktree, .. } => worktree.target.clone(),
1455 mj_core::state::Checkout::Attached { .. } => {
1456 let runtime = session.target_runtime_settings(config)?;
1457 match (&*runtime.kind, &runtime.connection) {
1458 ("local-bare", mj_core::state::TargetConnection::Local) => {
1459 ManagedWorktreeTarget::Local
1460 }
1461 ("ssh-bare", mj_core::state::TargetConnection::Ssh { ssh }) => {
1462 let ssh = targets::SshTarget::from(ssh);
1463 ManagedWorktreeTarget::Ssh {
1464 destination: ssh.destination,
1465 ssh_args: ssh.ssh_args,
1466 }
1467 }
1468 _ => bail!("the session's recorded target is not a bare checkout"),
1469 }
1470 }
1471 mj_core::state::Checkout::ManagedWorkspace | mj_core::state::Checkout::Borrowed { .. } => {
1472 bail!("the session's recorded target is not a bare checkout")
1473 }
1474 };
1475 read_checkout_position(executor, &target, project_directory)
1476}
1477
1478pub(super) fn raw_checkout_divergence_notice(
1484 directory: &Path,
1485 recorded: Option<&mj_checkpoint::archive::RepositoryMetadata>,
1486 live: &CheckoutPosition,
1487) -> Option<String> {
1488 let recorded = recorded?;
1489 if recorded.head_commit.is_empty()
1490 || (recorded.head_commit == live.head_commit && recorded.branch == live.branch)
1491 {
1492 return None;
1493 }
1494 Some(format!(
1495 "The working tree at {} moved from {} to {} while this session was stopped.",
1496 directory.display(),
1497 checkout_position_text(&recorded.head_commit, recorded.branch.as_deref()),
1498 checkout_position_text(&live.head_commit, live.branch.as_deref()),
1499 ))
1500}
1501
1502fn checkout_position_text(head_commit: &str, branch: Option<&str>) -> String {
1503 let short = head_commit.get(..12).unwrap_or(head_commit);
1504 match branch {
1505 Some(branch) => format!("{short} ({branch})"),
1506 None => format!("{short} (detached)"),
1507 }
1508}
1509
1510fn inspect_raw_project(
1511 executor: &impl CommandExecutor,
1512 target: &ManagedWorktreeTarget,
1513 selected: &Path,
1514) -> Result<RawProjectInspection> {
1515 let repository = PathBuf::from(managed_git_stdout(
1516 executor,
1517 target,
1518 selected,
1519 ["rev-parse", "--show-toplevel"],
1520 "resolve raw project repository root",
1521 )?);
1522 let prefix = managed_git_stdout(
1523 executor,
1524 target,
1525 selected,
1526 ["rev-parse", "--show-prefix"],
1527 "resolve raw project relative directory",
1528 )?;
1529 let git_dir = PathBuf::from(managed_git_stdout(
1530 executor,
1531 target,
1532 selected,
1533 ["rev-parse", "--absolute-git-dir"],
1534 "resolve raw project Git directory",
1535 )?);
1536 let common_git_dir = mj_core::local_git::resolve_git_path(
1539 &repository.join(&prefix),
1540 &managed_git_stdout(
1541 executor,
1542 target,
1543 selected,
1544 ["rev-parse", "--git-common-dir"],
1545 "resolve raw project common Git directory",
1546 )?,
1547 )?;
1548 let branch_command = managed_git_command(
1549 target,
1550 selected,
1551 ["symbolic-ref", "--quiet", "--short", "HEAD"],
1552 "resolve raw project branch",
1553 );
1554 let branch_output = executor.execute(&branch_command)?;
1555 let branch = match branch_output.status {
1556 0 => Some(
1557 String::from_utf8(branch_output.stdout)
1558 .context("raw project branch was not UTF-8")?
1559 .trim()
1560 .to_owned(),
1561 ),
1562 1 | 128 => None,
1563 status => bail!(
1564 "resolve raw project branch failed with status {status}: {}",
1565 String::from_utf8_lossy(&branch_output.stderr).trim()
1566 ),
1567 };
1568 let upstream = match branch {
1569 Some(branch) => {
1570 let reference = format!("refs/heads/{branch}");
1571 let upstream = managed_git_stdout(
1572 executor,
1573 target,
1574 selected,
1575 ["for-each-ref", "--format=%(upstream:short)", &reference],
1576 "resolve raw project upstream",
1577 )?;
1578 (!upstream.is_empty()).then_some(upstream)
1579 }
1580 None => None,
1581 };
1582 Ok(RawProjectInspection {
1583 source_project_directory: repository.join(prefix),
1584 source_repository: repository,
1585 primary_checkout: git_dir == common_git_dir,
1586 upstream,
1587 })
1588}
1589
1590fn ensure_managed_worktree_excluded(
1591 executor: &impl CommandExecutor,
1592 target: &ManagedWorktreeTarget,
1593 repository: &Path,
1594 kind: ManagedCheckoutKind,
1595) -> Result<()> {
1596 let (path, entry) = match kind {
1597 ManagedCheckoutKind::Worktree => (".mj/worktrees/", "/.mj/worktrees/"),
1598 ManagedCheckoutKind::Clone => (".mj/clones/", "/.mj/clones/"),
1599 };
1600 let check = managed_git_command(
1601 target,
1602 repository,
1603 ["check-ignore", "--quiet", "--no-index", "--", path],
1604 "check managed worktree exclusion",
1605 );
1606 let output = executor.execute(&check)?;
1607 match output.status {
1608 0 => return Ok(()),
1609 1 => {}
1610 status => bail!(
1611 "check managed worktree exclusion failed with status {status}: {}",
1612 String::from_utf8_lossy(&output.stderr).trim()
1613 ),
1614 }
1615 let exclude_path = mj_core::local_git::resolve_git_path(
1616 repository,
1617 &managed_git_stdout(
1618 executor,
1619 target,
1620 repository,
1621 ["rev-parse", "--git-path", "info/exclude"],
1622 "resolve repository-local exclude file",
1623 )?,
1624 )?;
1625 match target {
1626 ManagedWorktreeTarget::Local => {
1627 use std::io::Write;
1628 let existing = match std::fs::read_to_string(&exclude_path) {
1629 Ok(existing) => existing,
1630 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1631 Err(error) => return Err(error.into()),
1632 };
1633 if existing.lines().any(|line| line.trim() == entry) {
1634 return Ok(());
1635 }
1636 if let Some(parent) = exclude_path.parent() {
1637 std::fs::create_dir_all(parent)?;
1638 }
1639 let mut file = std::fs::OpenOptions::new()
1640 .create(true)
1641 .append(true)
1642 .open(&exclude_path)
1643 .with_context(|| format!("open {}", exclude_path.display()))?;
1644 if !existing.is_empty() && !existing.ends_with('\n') {
1645 writeln!(file)?;
1646 }
1647 writeln!(file, "# Mjolnir managed checkouts\n{entry}")?;
1648 }
1649 ManagedWorktreeTarget::Ssh { .. } => {
1650 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";
1651 let command = managed_target_command(
1652 target,
1653 "sh",
1654 [
1655 "-c",
1656 SCRIPT,
1657 "hel-exclude",
1658 &exclude_path.to_string_lossy(),
1659 entry,
1660 ],
1661 )
1662 .purpose("update remote repository-local exclude file");
1663 execute_checked(executor, command)?;
1664 }
1665 }
1666 Ok(())
1667}
1668
1669pub(crate) fn path_exists_on_managed_target(
1670 executor: &impl CommandExecutor,
1671 target: &ManagedWorktreeTarget,
1672 path: &Path,
1673) -> Result<bool> {
1674 match target {
1675 ManagedWorktreeTarget::Local => path
1676 .try_exists()
1677 .with_context(|| format!("check managed project path {}", path.display())),
1678 ManagedWorktreeTarget::Ssh { .. } => {
1679 let command = managed_target_command(target, "test", ["-e", &path.to_string_lossy()])
1680 .purpose("check managed worktree path");
1681 let output = executor.execute(&command)?;
1682 match output.status {
1683 0 => Ok(true),
1684 1 => Ok(false),
1685 status => bail!(
1686 "check managed worktree path failed with status {status}: {}",
1687 String::from_utf8_lossy(&output.stderr).trim()
1688 ),
1689 }
1690 }
1691 }
1692}
1693
1694pub(super) fn managed_worktree_checkout_exists(
1695 executor: &impl CommandExecutor,
1696 worktree: &ManagedWorktree,
1697) -> Result<bool> {
1698 path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)
1699}
1700
1701pub(super) fn managed_worktree_checkout_is_dirty(
1707 executor: &impl CommandExecutor,
1708 worktree: &ManagedWorktree,
1709) -> Result<bool> {
1710 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
1711 return Ok(false);
1712 }
1713 let status = managed_git_stdout(
1714 executor,
1715 &worktree.target,
1716 &worktree.worktree_root,
1717 ["status", "--porcelain=v1", "--untracked-files=all"],
1718 "inspect managed worktree changes",
1719 )?;
1720 Ok(!status.is_empty())
1721}
1722
1723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1725pub(super) enum PrimaryCheckoutRequirement {
1726 Clean,
1729 Any,
1733}
1734
1735pub(super) fn create_managed_worktree(
1736 executor: &impl CommandExecutor,
1737 worktree: &ManagedWorktree,
1738 upstream: Option<&str>,
1739 requirement: PrimaryCheckoutRequirement,
1740) -> Result<()> {
1741 ensure_managed_worktree_excluded(
1742 executor,
1743 &worktree.target,
1744 &worktree.source_repository,
1745 worktree.kind,
1746 )?;
1747 if worktree.kind == ManagedCheckoutKind::Clone {
1748 return create_managed_clone(executor, worktree);
1749 }
1750 if requirement == PrimaryCheckoutRequirement::Clean {
1751 let status = managed_git_stdout(
1752 executor,
1753 &worktree.target,
1754 &worktree.source_repository,
1755 ["status", "--porcelain=v1", "--untracked-files=all"],
1756 "inspect primary checkout changes",
1757 )?;
1758 if !status.is_empty() {
1759 let paths = status.lines().take(20).collect::<Vec<_>>().join("\n ");
1760 bail!(
1761 "primary checkout has uncommitted changes; commit or stash them before creating a raw session worktree:\n {paths}"
1762 );
1763 }
1764 }
1765 let parent = worktree
1766 .worktree_root
1767 .parent()
1768 .context("managed worktree root has no parent")?;
1769 execute_checked(
1770 executor,
1771 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
1772 .purpose("create managed worktree directory"),
1773 )?;
1774 execute_checked(
1775 executor,
1776 managed_git_command(
1777 &worktree.target,
1778 &worktree.source_repository,
1779 [
1780 "worktree",
1781 "add",
1782 "-b",
1783 &worktree.branch,
1784 &worktree.worktree_root.to_string_lossy(),
1785 worktree.base_commit.as_deref().unwrap_or("HEAD"),
1786 ],
1787 "create managed raw-session worktree",
1788 ),
1789 )?;
1790 if let Some(upstream) = upstream {
1791 execute_checked(
1792 executor,
1793 managed_git_command(
1794 &worktree.target,
1795 &worktree.worktree_root,
1796 ["branch", "--set-upstream-to", upstream, &worktree.branch],
1797 "set managed worktree branch upstream",
1798 ),
1799 )?;
1800 }
1801 Ok(())
1802}
1803
1804fn create_managed_clone(executor: &impl CommandExecutor, checkout: &ManagedWorktree) -> Result<()> {
1805 let parent = checkout
1806 .worktree_root
1807 .parent()
1808 .context("managed clone has no parent")?;
1809 let staging = checkout.worktree_root.with_extension("provisioning");
1810 ensure!(
1811 !path_exists_on_managed_target(executor, &checkout.target, &staging)?
1812 && !path_exists_on_managed_target(executor, &checkout.target, &checkout.worktree_root)?,
1813 "managed clone path is already occupied: {}",
1814 checkout.worktree_root.display()
1815 );
1816 execute_checked(
1817 executor,
1818 managed_target_command(&checkout.target, "mkdir", ["-p", &parent.to_string_lossy()])
1819 .purpose("create managed clone parent"),
1820 )?;
1821 let create = (|| -> Result<()> {
1822 execute_checked(
1823 executor,
1824 managed_target_command(
1825 &checkout.target,
1826 "git",
1827 [
1828 "clone",
1829 "--local",
1830 "--dissociate",
1831 "--no-checkout",
1832 "--",
1833 &checkout.source_repository.to_string_lossy(),
1834 &staging.to_string_lossy(),
1835 ],
1836 )
1837 .purpose("seed independent managed clone"),
1838 )?;
1839 let origin = executor.execute(&managed_git_command(
1840 &checkout.target,
1841 &checkout.source_repository,
1842 ["config", "--get", "remote.origin.url"],
1843 "read source origin URL",
1844 ))?;
1845 execute_checked(
1846 executor,
1847 managed_git_command(
1848 &checkout.target,
1849 &staging,
1850 ["remote", "remove", "origin"],
1851 "discard local seed as clone remote",
1852 ),
1853 )?;
1854 match origin.status {
1855 0 => {
1856 let url = String::from_utf8(origin.stdout)?;
1857 execute_checked(
1858 executor,
1859 managed_git_command(
1860 &checkout.target,
1861 &staging,
1862 ["remote", "add", "origin", url.trim()],
1863 "set clone fetch and push remote",
1864 ),
1865 )?;
1866 copy_clone_push_configuration(executor, checkout, &staging)?;
1867 copy_source_origin_refs(executor, checkout, &staging)?;
1868 }
1869 1 => {}
1870 status => bail!(
1871 "read source origin URL failed with status {status}: {}",
1872 String::from_utf8_lossy(&origin.stderr).trim()
1873 ),
1874 }
1875 copy_clone_local_git_preferences(executor, checkout, &staging)?;
1876 execute_checked(
1877 executor,
1878 managed_git_command(
1879 &checkout.target,
1880 &staging,
1881 [
1882 "switch",
1883 "--no-track",
1884 "-C",
1885 &checkout.branch,
1886 checkout
1887 .base_commit
1888 .as_deref()
1889 .context("managed clone has no launch commit")?,
1890 ],
1891 "select managed clone starting branch",
1892 ),
1893 )?;
1894 if origin.status == 0 {
1895 execute_checked(
1896 executor,
1897 managed_git_command(
1898 &checkout.target,
1899 &staging,
1900 [
1901 "config",
1902 "--local",
1903 &format!("branch.{}.remote", checkout.branch),
1904 "origin",
1905 ],
1906 "set clone branch push remote",
1907 ),
1908 )?;
1909 execute_checked(
1910 executor,
1911 managed_git_command(
1912 &checkout.target,
1913 &staging,
1914 [
1915 "config",
1916 "--local",
1917 &format!("branch.{}.merge", checkout.branch),
1918 &format!("refs/heads/{}", checkout.branch),
1919 ],
1920 "set clone branch tracking name",
1921 ),
1922 )?;
1923 }
1924 execute_checked(
1925 executor,
1926 managed_target_command(
1927 &checkout.target,
1928 "mv",
1929 [
1930 "--",
1931 &staging.to_string_lossy(),
1932 &checkout.worktree_root.to_string_lossy(),
1933 ],
1934 )
1935 .purpose("publish managed clone checkout"),
1936 )?;
1937 Ok(())
1938 })();
1939 if create.is_err() && path_exists_on_managed_target(executor, &checkout.target, &staging)? {
1940 execute_checked(
1941 executor,
1942 managed_target_command(
1943 &checkout.target,
1944 "rm",
1945 ["-rf", "--", &staging.to_string_lossy()],
1946 )
1947 .purpose("remove failed managed clone staging directory"),
1948 )?;
1949 }
1950 create
1951}
1952
1953fn copy_clone_push_configuration(
1954 executor: &impl CommandExecutor,
1955 checkout: &ManagedWorktree,
1956 staging: &Path,
1957) -> Result<()> {
1958 let output = executor.execute(&managed_git_command(
1959 &checkout.target,
1960 &checkout.source_repository,
1961 ["config", "--local", "--get-all", "remote.origin.pushurl"],
1962 "read source push destinations",
1963 ))?;
1964 match output.status {
1965 0 => {
1966 for url in String::from_utf8(output.stdout)?
1967 .lines()
1968 .filter(|line| !line.is_empty())
1969 {
1970 execute_checked(
1971 executor,
1972 managed_git_command(
1973 &checkout.target,
1974 staging,
1975 ["remote", "set-url", "--push", "--add", "origin", url],
1976 "preserve clone push destination",
1977 ),
1978 )?;
1979 }
1980 }
1981 1 => {}
1982 status => bail!("read source push destinations failed with status {status}"),
1983 }
1984 Ok(())
1985}
1986
1987fn copy_source_origin_refs(
1988 executor: &impl CommandExecutor,
1989 checkout: &ManagedWorktree,
1990 staging: &Path,
1991) -> Result<()> {
1992 let refs = managed_git_stdout(
1993 executor,
1994 &checkout.target,
1995 &checkout.source_repository,
1996 [
1997 "for-each-ref",
1998 "--format=%(refname) %(objectname)",
1999 "refs/remotes/origin",
2000 ],
2001 "read cached origin branches",
2002 )?;
2003 for line in refs.lines() {
2004 let (name, oid) = line
2005 .split_once(' ')
2006 .context("malformed source remote ref")?;
2007 if name == "refs/remotes/origin/HEAD" {
2008 continue;
2009 }
2010 execute_checked(
2011 executor,
2012 managed_git_command(
2013 &checkout.target,
2014 staging,
2015 ["update-ref", name, oid],
2016 "preserve cached origin branch",
2017 ),
2018 )?;
2019 }
2020 Ok(())
2021}
2022
2023fn copy_clone_local_git_preferences(
2024 executor: &impl CommandExecutor,
2025 checkout: &ManagedWorktree,
2026 staging: &Path,
2027) -> Result<()> {
2028 let config = executor.execute(&managed_git_command(
2029 &checkout.target,
2030 &checkout.source_repository,
2031 ["config", "--local", "--null", "--list"],
2032 "read source Git preferences",
2033 ))?;
2034 ensure!(
2035 config.status == 0,
2036 "read source Git preferences failed with status {}",
2037 config.status
2038 );
2039 for entry in config
2040 .stdout
2041 .split(|byte| *byte == 0)
2042 .filter(|entry| !entry.is_empty())
2043 {
2044 let Some(split) = entry.iter().position(|byte| *byte == b'\n') else {
2045 bail!("source Git configuration contains a malformed entry");
2046 };
2047 let key = std::str::from_utf8(&entry[..split])?;
2048 if !clone_local_preference(key) {
2049 continue;
2050 }
2051 let value = std::str::from_utf8(&entry[split + 1..])?;
2052 execute_checked(
2053 executor,
2054 managed_git_command(
2055 &checkout.target,
2056 staging,
2057 ["config", "--local", "--add", key, value],
2058 "preserve Git identity and local preferences",
2059 ),
2060 )?;
2061 }
2062 let source_exclude = mj_core::local_git::resolve_git_path(
2063 &checkout.source_repository,
2064 &managed_git_stdout(
2065 executor,
2066 &checkout.target,
2067 &checkout.source_repository,
2068 ["rev-parse", "--git-path", "info/exclude"],
2069 "locate source Git exclusions",
2070 )?,
2071 )?;
2072 if path_exists_on_managed_target(executor, &checkout.target, &source_exclude)? {
2073 let clone_exclude = mj_core::local_git::resolve_git_path(
2074 staging,
2075 &managed_git_stdout(
2076 executor,
2077 &checkout.target,
2078 staging,
2079 ["rev-parse", "--git-path", "info/exclude"],
2080 "locate clone Git exclusions",
2081 )?,
2082 )?;
2083 execute_checked(
2084 executor,
2085 managed_target_command(
2086 &checkout.target,
2087 "cp",
2088 [
2089 "--",
2090 &source_exclude.to_string_lossy(),
2091 &clone_exclude.to_string_lossy(),
2092 ],
2093 )
2094 .purpose("preserve source Git exclusions"),
2095 )?;
2096 }
2097 Ok(())
2098}
2099
2100fn clone_local_preference(key: &str) -> bool {
2101 key.starts_with("user.")
2102 || key.starts_with("commit.")
2103 || key.starts_with("gpg.")
2104 || key.starts_with("credential.")
2105 || key.starts_with("url.")
2106 || key.starts_with("push.")
2107 || matches!(
2108 key,
2109 "core.hookspath" | "core.excludesfile" | "core.attributesfile" | "core.sshcommand"
2110 )
2111}
2112
2113pub(super) fn restore_managed_worktree(
2117 executor: &impl CommandExecutor,
2118 worktree: &ManagedWorktree,
2119) -> Result<bool> {
2120 if managed_worktree_checkout_exists(executor, worktree)? {
2121 return Ok(false);
2122 }
2123 if worktree.kind == ManagedCheckoutKind::Clone {
2124 create_managed_worktree(executor, worktree, None, PrimaryCheckoutRequirement::Any)?;
2125 return Ok(true);
2126 }
2127 ensure!(
2128 path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)?,
2129 "managed worktree source repository is unavailable: {}",
2130 worktree.source_repository.display()
2131 );
2132 let branch_ref = format!("refs/heads/{}", worktree.branch);
2133 let check = managed_git_command(
2134 &worktree.target,
2135 &worktree.source_repository,
2136 ["show-ref", "--verify", "--quiet", &branch_ref],
2137 "check retired managed worktree branch",
2138 );
2139 let output = executor.execute(&check)?;
2140 match output.status {
2141 0 => {}
2142 1 => bail!(
2143 "managed worktree branch is unavailable: {}",
2144 worktree.branch
2145 ),
2146 status => bail!(
2147 "check retired managed worktree branch failed with status {status}: {}",
2148 String::from_utf8_lossy(&output.stderr).trim()
2149 ),
2150 }
2151 execute_checked(
2154 executor,
2155 managed_git_command(
2156 &worktree.target,
2157 &worktree.source_repository,
2158 ["worktree", "prune"],
2159 "prune retired managed worktree metadata",
2160 ),
2161 )?;
2162 let parent = worktree
2163 .worktree_root
2164 .parent()
2165 .context("managed worktree root has no parent")?;
2166 execute_checked(
2167 executor,
2168 managed_target_command(&worktree.target, "mkdir", ["-p", &parent.to_string_lossy()])
2169 .purpose("recreate managed worktree directory"),
2170 )?;
2171 execute_checked(
2172 executor,
2173 managed_git_command(
2174 &worktree.target,
2175 &worktree.source_repository,
2176 [
2177 "worktree",
2178 "add",
2179 "--",
2180 &worktree.worktree_root.to_string_lossy(),
2181 &worktree.branch,
2182 ],
2183 "restore managed raw-session worktree",
2184 ),
2185 )?;
2186 Ok(true)
2187}
2188
2189fn ensure_managed_worktree_available(
2190 executor: &impl CommandExecutor,
2191 worktree: &ManagedWorktree,
2192) -> Result<()> {
2193 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2194 bail!(
2195 "managed worktree path already exists: {}",
2196 worktree.worktree_root.display()
2197 );
2198 }
2199 if worktree.kind == ManagedCheckoutKind::Clone {
2200 return Ok(());
2201 }
2202 let branch_ref = format!("refs/heads/{}", worktree.branch);
2203 let check = managed_git_command(
2204 &worktree.target,
2205 &worktree.source_repository,
2206 ["show-ref", "--verify", "--quiet", &branch_ref],
2207 "check managed worktree branch availability",
2208 );
2209 let output = executor.execute(&check)?;
2210 match output.status {
2211 0 => bail!(
2212 "managed worktree branch already exists: {}",
2213 worktree.branch
2214 ),
2215 1 => Ok(()),
2216 status => bail!(
2217 "check managed worktree branch availability failed with status {status}: {}",
2218 String::from_utf8_lossy(&output.stderr).trim()
2219 ),
2220 }
2221}
2222
2223fn retained_managed_worktree_branch_available(
2228 executor: &impl CommandExecutor,
2229 worktree: &ManagedWorktree,
2230) -> Result<bool> {
2231 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2232 bail!(
2233 "managed worktree path already exists: {}",
2234 worktree.worktree_root.display()
2235 );
2236 }
2237 let branch_ref = format!("refs/heads/{}", worktree.branch);
2238 let check = managed_git_command(
2239 &worktree.target,
2240 &worktree.source_repository,
2241 ["show-ref", "--verify", "--quiet", &branch_ref],
2242 "check retained managed worktree branch",
2243 );
2244 let output = executor.execute(&check)?;
2245 match output.status {
2246 1 => Ok(false),
2247 0 => {
2248 let worktrees = managed_git_stdout(
2249 executor,
2250 &worktree.target,
2251 &worktree.source_repository,
2252 ["worktree", "list", "--porcelain", "-z"],
2253 "check retained managed worktree checkout",
2254 )?;
2255 let branch_field = format!("branch {branch_ref}");
2256 if worktrees.split('\0').any(|field| field == branch_field) {
2257 bail!(
2258 "managed worktree branch is still checked out: {}",
2259 worktree.branch
2260 );
2261 }
2262 Ok(true)
2263 }
2264 status => bail!(
2265 "check retained managed worktree branch failed with status {status}: {}",
2266 String::from_utf8_lossy(&output.stderr).trim()
2267 ),
2268 }
2269}
2270
2271pub(super) fn preserve_retained_managed_worktree_branch(
2275 executor: &impl CommandExecutor,
2276 worktree: &ManagedWorktree,
2277) -> Result<String> {
2278 let session_id = worktree
2279 .branch
2280 .strip_prefix("mj/")
2281 .context("managed worktree branch is not session-owned")?;
2282 let branch_ref = format!("refs/heads/{}", worktree.branch);
2283 let tip = managed_git_stdout(
2284 executor,
2285 &worktree.target,
2286 &worktree.source_repository,
2287 ["rev-parse", "--verify", &branch_ref],
2288 "read retained managed worktree branch tip",
2289 )?;
2290 let recovery_ref = format!("refs/mj/recovery/{session_id}/{tip}");
2291 let existing = managed_git_command(
2292 &worktree.target,
2293 &worktree.source_repository,
2294 ["show-ref", "--verify", "--quiet", &recovery_ref],
2295 "check retained managed worktree recovery ref",
2296 );
2297 let output = executor.execute(&existing)?;
2298 match output.status {
2299 0 => {
2300 let existing_tip = managed_git_stdout(
2301 executor,
2302 &worktree.target,
2303 &worktree.source_repository,
2304 ["rev-parse", "--verify", &recovery_ref],
2305 "verify retained managed worktree recovery ref",
2306 )?;
2307 ensure!(
2308 existing_tip == tip,
2309 "retained managed worktree recovery ref {recovery_ref} points to {existing_tip}, expected {tip}"
2310 );
2311 Ok(recovery_ref)
2312 }
2313 1 => {
2314 execute_checked(
2315 executor,
2316 managed_git_command(
2317 &worktree.target,
2318 &worktree.source_repository,
2319 ["update-ref", &recovery_ref, &tip],
2320 "preserve retained managed worktree branch",
2321 ),
2322 )?;
2323 Ok(recovery_ref)
2324 }
2325 status => bail!(
2326 "check retained managed worktree recovery ref failed with status {status}: {}",
2327 String::from_utf8_lossy(&output.stderr).trim()
2328 ),
2329 }
2330}
2331
2332pub(super) fn retire_managed_worktree(
2339 executor: &impl CommandExecutor,
2340 worktree: &ManagedWorktree,
2341) -> Result<()> {
2342 if worktree.kind == ManagedCheckoutKind::Clone {
2343 let base = worktree
2344 .base_commit
2345 .as_deref()
2346 .context("managed clone has no source commit")?;
2347 execute_checked(
2348 executor,
2349 managed_git_command(
2350 &worktree.target,
2351 &worktree.source_repository,
2352 ["cat-file", "-e", &format!("{base}^{{commit}}")],
2353 "verify clone recovery prerequisite in source repository",
2354 ),
2355 )?;
2356 }
2357 cleanup_managed_worktree(executor, worktree, BranchDisposition::Keep)
2358}
2359
2360fn remove_managed_worktree_checkout(
2367 executor: &impl CommandExecutor,
2368 worktree: &ManagedWorktree,
2369) -> Result<bool> {
2370 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
2371 return Ok(false);
2372 }
2373 let release = || {
2374 super::mbx::release::BuildStateRelease::managed_checkout(
2375 managed_target_ssh(&worktree.target),
2376 &worktree.worktree_root,
2377 )
2378 .run(executor);
2379 };
2380 if worktree.kind == ManagedCheckoutKind::Clone {
2381 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2382 execute_checked(
2383 executor,
2384 managed_target_command(
2385 &worktree.target,
2386 "rm",
2387 ["-rf", "--", &worktree.worktree_root.to_string_lossy()],
2388 )
2389 .purpose("remove managed clone after its worker stopped"),
2390 )?;
2391 release();
2392 }
2393 return Ok(true);
2394 }
2395 if path_exists_on_managed_target(executor, &worktree.target, &worktree.worktree_root)? {
2396 execute_checked(
2397 executor,
2398 managed_git_command(
2399 &worktree.target,
2400 &worktree.source_repository,
2401 [
2402 "worktree",
2403 "remove",
2404 "--force",
2405 &worktree.worktree_root.to_string_lossy(),
2406 ],
2407 "remove managed raw-session worktree",
2408 ),
2409 )?;
2410 release();
2411 }
2412 execute_checked(
2413 executor,
2414 managed_git_command(
2415 &worktree.target,
2416 &worktree.source_repository,
2417 ["worktree", "prune"],
2418 "prune managed worktree metadata",
2419 ),
2420 )?;
2421 Ok(true)
2422}
2423
2424fn managed_branch_is_merged(
2432 executor: &impl CommandExecutor,
2433 worktree: &ManagedWorktree,
2434) -> Result<Option<bool>> {
2435 if !path_exists_on_managed_target(executor, &worktree.target, &worktree.source_repository)? {
2436 return Ok(None);
2437 }
2438 let branch_ref = format!("refs/heads/{}", worktree.branch);
2439 let refs = managed_git_stdout(
2440 executor,
2441 &worktree.target,
2442 &worktree.source_repository,
2443 [
2444 "for-each-ref",
2445 "--contains",
2446 &branch_ref,
2447 "--format=%(refname)",
2448 "refs/heads",
2449 "refs/remotes",
2450 ],
2451 "list the branches containing a managed worktree branch",
2452 )?;
2453 Ok(Some(refs.lines().any(containing_ref_is_not_a_session)))
2454}
2455
2456fn containing_ref_is_not_a_session(reference: &str) -> bool {
2461 let reference = reference.trim();
2462 let remote_head = reference.starts_with("refs/remotes/") && reference.ends_with("/HEAD");
2463 !reference.is_empty() && !reference.starts_with("refs/heads/mj/") && !remote_head
2464}
2465
2466pub(super) fn cleanup_managed_worktree(
2470 executor: &impl CommandExecutor,
2471 worktree: &ManagedWorktree,
2472 branch: BranchDisposition,
2473) -> Result<()> {
2474 if !remove_managed_worktree_checkout(executor, worktree)? {
2475 return Ok(());
2476 }
2477 if worktree.kind == ManagedCheckoutKind::Clone {
2478 return remove_empty_managed_worktree_directories(executor, worktree);
2479 }
2480 if branch == BranchDisposition::Keep {
2481 return remove_empty_managed_worktree_directories(executor, worktree);
2482 }
2483 let branch_ref = format!("refs/heads/{}", worktree.branch);
2484 let check = managed_git_command(
2485 &worktree.target,
2486 &worktree.source_repository,
2487 ["show-ref", "--verify", "--quiet", &branch_ref],
2488 "check managed worktree branch",
2489 );
2490 let output = executor.execute(&check)?;
2491 let present = match output.status {
2492 0 => true,
2493 1 => false,
2494 status => bail!(
2495 "check managed worktree branch failed with status {status}: {}",
2496 String::from_utf8_lossy(&output.stderr).trim()
2497 ),
2498 };
2499 let delete = match branch {
2500 BranchDisposition::Delete => present,
2501 BranchDisposition::DeleteIfMerged if present => {
2502 let merged = managed_branch_is_merged(executor, worktree)?;
2503 let delete = merged == Some(true);
2504 tracing::info!(
2505 branch = %worktree.branch,
2506 delete,
2507 reason = match merged {
2508 Some(true) => "another branch already contains its commits",
2509 Some(false) => "it holds commits no other branch contains",
2510 None => "its repository is gone",
2511 },
2512 "archiving decided what to do with a session branch"
2513 );
2514 delete
2515 }
2516 BranchDisposition::DeleteIfMerged | BranchDisposition::Keep => false,
2517 };
2518 if delete {
2519 execute_checked(
2520 executor,
2521 managed_git_command(
2522 &worktree.target,
2523 &worktree.source_repository,
2524 ["branch", "-D", "--", &worktree.branch],
2525 "delete managed raw-session branch",
2526 ),
2527 )?;
2528 }
2529 remove_empty_managed_worktree_directories(executor, worktree)
2530}
2531
2532fn remove_empty_managed_worktree_directories(
2533 executor: &impl CommandExecutor,
2534 worktree: &ManagedWorktree,
2535) -> Result<()> {
2536 let worktrees = worktree
2537 .source_repository
2538 .join(".mj")
2539 .join(match worktree.kind {
2540 ManagedCheckoutKind::Worktree => "worktrees",
2541 ManagedCheckoutKind::Clone => "clones",
2542 });
2543 let hel = worktree.source_repository.join(".mj");
2544 match &worktree.target {
2545 ManagedWorktreeTarget::Local => {
2546 for directory in [&worktrees, &hel] {
2547 match std::fs::remove_dir(directory) {
2548 Ok(()) => {}
2549 Err(error)
2550 if matches!(
2551 error.kind(),
2552 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
2553 ) => {}
2554 Err(error) => return Err(error.into()),
2555 }
2556 }
2557 }
2558 ManagedWorktreeTarget::Ssh { .. } => {
2559 let command = managed_target_command(
2560 &worktree.target,
2561 "rmdir",
2562 ["--", &worktrees.to_string_lossy(), &hel.to_string_lossy()],
2563 )
2564 .purpose("remove empty managed worktree directories");
2565 let _ = executor.execute(&command)?;
2566 }
2567 }
2568 Ok(())
2569}
2570
2571#[cfg(test)]
2572mod tests;