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