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