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