Skip to main content

mj_controller/controller/
new_session_preflight.rs

1//! Shared project checks for new-session review. Call only in supervised background work.
2
3use 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/// Repository endpoints sanitized for presentation in either control surface.
15#[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/// A repair proposal is returned before any remote branch checks are attempted.
24#[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    /// Inspect the selected project without changing it. The caller owns the
35    /// executor's cancellation and deadline, and any repair confirmation.
36    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}