mj_controller/controller/
new_session_preflight.rs1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail, ensure};
6use mj_core::config::is_bare_project_target;
7use mj_core::local_git::LocalRemoteRepair;
8use mj_core::remote_git::{default_branch, display_url, resolve_repository};
9use mj_core::state::ManagedWorktreeOptions;
10
11use super::Controller;
12use crate::targets::CommandExecutor;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct NewSessionRepository {
17 pub id: String,
18 pub fetch_url: String,
19 pub default_branch: String,
20 pub push_urls: Vec<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct NewSessionPreflight {
26 pub project_directory: Option<PathBuf>,
27 pub managed_worktree: ManagedWorktreeOptions,
28 pub remote_repairs: Vec<LocalRemoteRepair>,
29 pub remote_repositories: Vec<NewSessionRepository>,
30 pub local_changes_excluded: bool,
31}
32
33impl Controller {
34 pub fn preflight_new_session(
37 &self,
38 bundle_id: &str,
39 target_id: &str,
40 project_directory: Option<&Path>,
41 executor: &impl CommandExecutor,
42 ) -> Result<NewSessionPreflight> {
43 ensure!(
44 !executor.cancellation_requested(),
45 "project preflight cancelled"
46 );
47 let target_is_bare = self
48 .config
49 .targets
50 .get(target_id)
51 .with_context(|| format!("unknown target template {target_id:?}"))
52 .map(is_bare_project_target)?;
53 if target_is_bare {
54 let directory =
55 project_directory.context("project directory is required for a bare target")?;
56 let directory = self.resolve_project_directory(target_id, directory, executor)?;
57 ensure!(
58 !executor.cancellation_requested(),
59 "project preflight cancelled"
60 );
61 let managed_worktree =
62 self.managed_worktree_options(target_id, &directory, executor)?;
63 return Ok(NewSessionPreflight {
64 managed_worktree,
65 project_directory: Some(directory),
66 remote_repairs: Vec::new(),
67 remote_repositories: Vec::new(),
68 local_changes_excluded: false,
69 });
70 }
71 if project_directory.is_some() {
72 bail!("project directory is unsupported for this target");
73 }
74
75 let bundle = self
76 .config
77 .bundles
78 .get(bundle_id)
79 .context("unknown bundle")?;
80 let repairs = mj_core::local_git::repository_remote_repairs(bundle, executor)?;
81 if !repairs.is_empty() {
82 return Ok(NewSessionPreflight {
83 managed_worktree: Default::default(),
84 project_directory: None,
85 remote_repairs: repairs,
86 remote_repositories: Vec::new(),
87 local_changes_excluded: true,
88 });
89 }
90 let remote_repositories = bundle
91 .repositories
92 .iter()
93 .map(|repository| {
94 ensure!(
95 !executor.cancellation_requested(),
96 "repository preflight cancelled"
97 );
98 let source = resolve_repository(repository, executor)
99 .with_context(|| format!("repository {:?}", repository.id))?;
100 let default_branch = default_branch(&source, executor)
101 .with_context(|| format!("repository {:?}", repository.id))?;
102 Ok(NewSessionRepository {
103 id: repository.id.clone(),
104 fetch_url: display_url(&source.fetch_url),
105 default_branch,
106 push_urls: source
107 .push_urls
108 .iter()
109 .map(|url| display_url(url))
110 .collect(),
111 })
112 })
113 .collect::<Result<Vec<_>>>()?;
114 Ok(NewSessionPreflight {
115 managed_worktree: Default::default(),
116 project_directory: None,
117 remote_repairs: Vec::new(),
118 remote_repositories,
119 local_changes_excluded: true,
120 })
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use std::cell::Cell;
127
128 use super::*;
129 use crate::controller::config_only_controller;
130 use crate::controller::test_support::{
131 committed_repository, local_bundle, resume_compatibility_config, test_git,
132 };
133 use crate::targets::{CommandOutput, CommandSpec, ProcessExecutor};
134 use mj_core::config::{ProjectBundle, ProjectRepository};
135
136 #[derive(Default)]
137 struct RemoteExecutor {
138 requests: Cell<usize>,
139 cancelled: Cell<bool>,
140 cancel_after_request: bool,
141 fail: bool,
142 }
143
144 impl CommandExecutor for RemoteExecutor {
145 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
146 if command.args.iter().any(|arg| arg == "ls-remote") {
147 self.requests.set(self.requests.get() + 1);
148 self.cancelled.set(self.cancel_after_request);
149 if self.fail {
150 bail!("remote unavailable");
151 }
152 return Ok(CommandOutput {
153 status: 0,
154 stdout: b"ref: refs/heads/main\tHEAD\n0123456789abcdef0123456789abcdef01234567\tHEAD\n".to_vec(),
155 stderr: Vec::new(),
156 });
157 }
158 ProcessExecutor.execute(command)
159 }
160
161 fn cancellation_requested(&self) -> bool {
162 self.cancelled.get()
163 }
164 }
165
166 fn remote_controller() -> Controller {
167 let mut config = resume_compatibility_config();
168 config.bundles.insert(
169 "project".into(),
170 ProjectBundle {
171 primary_repo: "one".into(),
172 repositories: ["one", "two"]
173 .into_iter()
174 .map(|id| ProjectRepository {
175 id: id.into(),
176 github: Some(format!("https://user:secret@example.com/{id}.git")),
177 local: None,
178 destination: id.into(),
179 git_ref: None,
180 })
181 .collect(),
182 },
183 );
184 config_only_controller(config)
185 }
186
187 #[test]
188 fn preflight_resolves_all_remote_branches_and_sanitizes_preview_urls() {
189 let executor = RemoteExecutor::default();
190 let result = remote_controller()
191 .preflight_new_session("project", "podman", None, &executor)
192 .unwrap();
193 assert_eq!(executor.requests.get(), 2);
194 assert!(result.local_changes_excluded);
195 assert!(result.project_directory.is_none());
196 assert!(result.remote_repairs.is_empty());
197 for (repository, id) in result.remote_repositories.iter().zip(["one", "two"]) {
198 assert_eq!(repository.id, id);
199 assert_eq!(repository.default_branch, "main");
200 assert_eq!(
201 repository.fetch_url,
202 format!("https://example.com/{id}.git")
203 );
204 assert_eq!(repository.push_urls, vec![repository.fetch_url.clone()]);
205 }
206 }
207
208 #[test]
209 fn preflight_reports_repository_failure_and_stops_remaining_checks() {
210 let executor = RemoteExecutor {
211 fail: true,
212 ..Default::default()
213 };
214 let error = remote_controller()
215 .preflight_new_session("project", "podman", None, &executor)
216 .unwrap_err();
217 assert!(format!("{error:#}").contains("repository \"one\""));
218 assert!(format!("{error:#}").contains("remote unavailable"));
219 assert_eq!(executor.requests.get(), 1);
220 }
221
222 #[test]
223 fn preflight_cancellation_prevents_initial_and_remaining_remote_checks() {
224 for already_cancelled in [true, false] {
225 let executor = RemoteExecutor {
226 cancelled: Cell::new(already_cancelled),
227 cancel_after_request: true,
228 ..Default::default()
229 };
230 let error = remote_controller()
231 .preflight_new_session("project", "podman", None, &executor)
232 .unwrap_err();
233 assert!(format!("{error:#}").contains("cancelled"));
234 assert_eq!(executor.requests.get(), usize::from(!already_cancelled));
235 }
236 }
237
238 #[test]
239 fn preflight_proposes_tracking_repairs_without_writing_or_contacting_remotes() {
240 let repository = committed_repository();
241 test_git(
242 repository.path(),
243 &[
244 "remote",
245 "add",
246 "origin",
247 "https://user:secret@example.com/repo.git",
248 ],
249 );
250 test_git(
251 repository.path(),
252 &["config", "branch.master.remote", "missing"],
253 );
254 let mut controller = remote_controller();
255 controller
256 .config
257 .bundles
258 .insert("project".into(), local_bundle(repository.path()));
259 let executor = RemoteExecutor::default();
260 let result = controller
261 .preflight_new_session("project", "podman", None, &executor)
262 .unwrap();
263 assert_eq!(executor.requests.get(), 0);
264 assert!(result.remote_repositories.is_empty());
265 assert_eq!(result.remote_repairs.len(), 1);
266 assert_eq!(result.remote_repairs[0].replacement_remote, "origin");
267 assert_eq!(
268 result.remote_repairs[0].fetch_url,
269 "https://example.com/repo.git"
270 );
271 assert_eq!(
272 test_git(repository.path(), &["config", "branch.master.remote"]),
273 "missing"
274 );
275 }
276
277 #[test]
278 fn bare_preflight_resolves_directory_and_worktree_defaults_without_mutation() {
279 let repository = committed_repository();
280 let controller = config_only_controller(resume_compatibility_config());
281 let root = repository.path().canonicalize().unwrap();
282 let result = controller
283 .preflight_new_session("", "local-bare", Some(&root), &ProcessExecutor)
284 .unwrap();
285 assert_eq!(result.project_directory.as_ref(), Some(&root));
286 assert!(result.managed_worktree.available);
287 assert!(result.managed_worktree.default_create);
288 assert!(!result.local_changes_excluded);
289 assert!(!root.join(".mj/worktrees").exists());
290
291 let linked_parent = tempfile::tempdir().unwrap();
292 let linked = linked_parent.path().join("linked");
293 test_git(
294 &root,
295 &["worktree", "add", "-b", "side", linked.to_str().unwrap()],
296 );
297 let result = controller
298 .preflight_new_session("", "local-bare", Some(&linked), &ProcessExecutor)
299 .unwrap();
300 assert!(result.managed_worktree.available);
301 assert!(!result.managed_worktree.default_create);
302 }
303
304 #[test]
305 fn bare_preflight_accepts_non_git_directories_but_rejects_missing_or_unborn_projects() {
306 let directory = tempfile::tempdir().unwrap();
307 let controller = config_only_controller(resume_compatibility_config());
308 let result = controller
309 .preflight_new_session("", "local-bare", Some(directory.path()), &ProcessExecutor)
310 .unwrap();
311 assert!(!result.managed_worktree.available);
312 let missing = directory.path().join("missing");
313 assert!(
314 controller
315 .preflight_new_session("", "local-bare", Some(&missing), &ProcessExecutor)
316 .is_err()
317 );
318 test_git(directory.path(), &["init", "--initial-branch=master"]);
319 assert!(
320 controller
321 .preflight_new_session("", "local-bare", Some(directory.path()), &ProcessExecutor)
322 .is_err()
323 );
324 }
325
326 #[test]
327 fn preflight_rejects_a_project_selection_incompatible_with_its_target() {
328 let controller = remote_controller();
329 let executor = RemoteExecutor::default();
330 assert!(
331 controller
332 .preflight_new_session("project", "podman", Some(Path::new("/project")), &executor)
333 .is_err()
334 );
335 assert!(
336 controller
337 .preflight_new_session("project", "local-bare", None, &executor)
338 .is_err()
339 );
340 assert_eq!(executor.requests.get(), 0);
341 }
342}