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_cancellation_prevents_initial_and_remaining_remote_checks() {
210 for already_cancelled in [true, false] {
211 let executor = RemoteExecutor {
212 cancelled: Cell::new(already_cancelled),
213 cancel_after_request: true,
214 ..Default::default()
215 };
216 let error = remote_controller()
217 .preflight_new_session("project", "podman", None, &executor)
218 .unwrap_err();
219 assert!(format!("{error:#}").contains("cancelled"));
220 assert_eq!(executor.requests.get(), usize::from(!already_cancelled));
221 }
222 }
223
224 #[test]
225 fn preflight_proposes_tracking_repairs_without_writing_or_contacting_remotes() {
226 let repository = committed_repository();
227 test_git(
228 repository.path(),
229 &[
230 "remote",
231 "add",
232 "origin",
233 "https://user:secret@example.com/repo.git",
234 ],
235 );
236 test_git(
237 repository.path(),
238 &["config", "branch.master.remote", "missing"],
239 );
240 let mut controller = remote_controller();
241 controller
242 .config
243 .bundles
244 .insert("project".into(), local_bundle(repository.path()));
245 let executor = RemoteExecutor::default();
246 let result = controller
247 .preflight_new_session("project", "podman", None, &executor)
248 .unwrap();
249 assert_eq!(executor.requests.get(), 0);
250 assert!(result.remote_repositories.is_empty());
251 assert_eq!(result.remote_repairs.len(), 1);
252 assert_eq!(result.remote_repairs[0].replacement_remote, "origin");
253 assert_eq!(
254 result.remote_repairs[0].fetch_url,
255 "https://example.com/repo.git"
256 );
257 assert_eq!(
258 test_git(repository.path(), &["config", "branch.master.remote"]),
259 "missing"
260 );
261 }
262
263 #[test]
264 fn bare_preflight_resolves_directory_and_worktree_defaults_without_mutation() {
265 let repository = committed_repository();
266 let controller = config_only_controller(resume_compatibility_config());
267 let root = repository.path().canonicalize().unwrap();
268 let result = controller
269 .preflight_new_session("", "local-bare", Some(&root), &ProcessExecutor)
270 .unwrap();
271 assert_eq!(result.project_directory.as_ref(), Some(&root));
272 assert!(result.managed_worktree.available);
273 assert!(result.managed_worktree.default_create);
274 assert!(!result.local_changes_excluded);
275 assert!(!root.join(".mj/worktrees").exists());
276
277 let linked_parent = tempfile::tempdir().unwrap();
278 let linked = linked_parent.path().join("linked");
279 test_git(
280 &root,
281 &["worktree", "add", "-b", "side", linked.to_str().unwrap()],
282 );
283 let result = controller
284 .preflight_new_session("", "local-bare", Some(&linked), &ProcessExecutor)
285 .unwrap();
286 assert!(result.managed_worktree.available);
287 assert!(!result.managed_worktree.default_create);
288 }
289
290 #[test]
291 fn bare_preflight_accepts_non_git_directories_but_rejects_missing_or_unborn_projects() {
292 let directory = tempfile::tempdir().unwrap();
293 let controller = config_only_controller(resume_compatibility_config());
294 let result = controller
295 .preflight_new_session("", "local-bare", Some(directory.path()), &ProcessExecutor)
296 .unwrap();
297 assert!(!result.managed_worktree.available);
298 let missing = directory.path().join("missing");
299 assert!(
300 controller
301 .preflight_new_session("", "local-bare", Some(&missing), &ProcessExecutor)
302 .is_err()
303 );
304 test_git(directory.path(), &["init", "--initial-branch=master"]);
305 assert!(
306 controller
307 .preflight_new_session("", "local-bare", Some(directory.path()), &ProcessExecutor)
308 .is_err()
309 );
310 }
311
312 #[test]
313 fn preflight_reports_repository_failure_and_stops_remaining_checks() {
314 let executor = RemoteExecutor {
315 fail: true,
316 ..Default::default()
317 };
318 let error = remote_controller()
319 .preflight_new_session("project", "podman", None, &executor)
320 .unwrap_err();
321 assert!(format!("{error:#}").contains("repository \"one\""));
322 assert!(format!("{error:#}").contains("remote unavailable"));
323 assert_eq!(executor.requests.get(), 1);
324 }
325}