Skip to main content

magi/
repo_clone.rs

1//! Opt-in acquisition after the CLI repository resolver establishes a local miss.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6/// Validate an explicit GitHub owner/repository before invoking external tools.
7pub fn validate_github_name(repo: &str) -> anyhow::Result<(&str, &str)> {
8    let (owner, name) = repo.split_once('/').unwrap_or(("", ""));
9    anyhow::ensure!(
10        !owner.is_empty()
11            && owner.len() <= 39
12            && owner
13                .bytes()
14                .all(|c| c.is_ascii_alphanumeric() || c == b'-')
15            && !owner.starts_with('-')
16            && !owner.ends_with('-')
17            && !owner.contains("--")
18            && !name.is_empty()
19            && name.len() <= 100
20            && name
21                .bytes()
22                .all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c))
23            && name != "."
24            && name != "..",
25        "invalid GitHub owner/repo name: {repo:?}"
26    );
27    Ok((owner, name))
28}
29
30/// Process lock released even if a concurrently forked child retains the descriptor.
31pub struct CloneLock(std::fs::File);
32
33impl Drop for CloneLock {
34    fn drop(&mut self) {
35        #[cfg(unix)]
36        {
37            use std::os::fd::AsRawFd;
38            // SAFETY: this guard owns a live file descriptor.
39            unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) };
40        }
41        let _ = &self.0;
42    }
43}
44
45/// Serialize acquisitions across CLI processes; callers re-scan under this lock.
46pub async fn clone_lock(cfg: &crate::config::Repos) -> anyhow::Result<CloneLock> {
47    use anyhow::Context;
48    let root = cfg
49        .roots
50        .first()
51        .context("[repos] auto_clone requires a nonempty roots array")?;
52    std::fs::create_dir_all(root)?;
53    let file = std::fs::OpenOptions::new()
54        .create(true)
55        .truncate(false)
56        .read(true)
57        .write(true)
58        .open(root.join(".magi-clone.lock"))?;
59    loop {
60        match crate::pr_head::try_lock_file(&file) {
61            Ok(()) => return Ok(CloneLock(file)),
62            Err(e)
63                if e.kind() == std::io::ErrorKind::WouldBlock
64                    || cfg!(windows) && e.raw_os_error() == Some(33) =>
65            {
66                tokio::time::sleep(Duration::from_millis(50)).await;
67            }
68            Err(e) => return Err(e.into()),
69        }
70    }
71}
72
73/// A sibling file survives failed/cancelled acquisitions without pre-creating
74/// the destination (some configured clone tools require it to be absent).
75fn incomplete_marker(checkout: &Path) -> PathBuf {
76    let mut name = std::ffi::OsString::from(".magi-clone@");
77    name.push(checkout.file_name().unwrap_or_default());
78    checkout.with_file_name(name)
79}
80
81/// Discovery must not expose a checkout until its acquisition has succeeded.
82/// Treat unreadable markers as incomplete rather than publishing uncertain state.
83pub fn acquisition_incomplete(checkout: &Path) -> bool {
84    !matches!(std::fs::symlink_metadata(incomplete_marker(checkout)),
85        Err(e) if e.kind() == std::io::ErrorKind::NotFound)
86}
87
88/// Acquire a confirmed GitHub repository at the configured layout destination.
89/// Called only after a local miss under the resolver's acquisition lock.
90/// `gh` is the executable path, allowing tests to avoid changing PATH.
91pub async fn clone_github_with_gh(
92    repo: &str,
93    cfg: &crate::config::Repos,
94    gh: &str,
95) -> anyhow::Result<PathBuf> {
96    use anyhow::{Context, ensure};
97    let (owner, name) = validate_github_name(repo)?;
98    let root = cfg
99        .roots
100        .first()
101        .context("[repos] auto_clone requires a nonempty roots array")?;
102    let dest = root.join("github.com").join(owner).join(name);
103    match std::fs::symlink_metadata(&dest) {
104        Ok(_) => anyhow::bail!("clone destination already exists: {}", dest.display()),
105        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
106        Err(e) => {
107            return Err(e).with_context(|| format!("inspect clone destination {}", dest.display()));
108        }
109    }
110    let view = vec![
111        gh.to_owned(),
112        "repo".into(),
113        "view".into(),
114        format!("https://github.com/{repo}"),
115        "--json".into(),
116        "nameWithOwner".into(),
117    ];
118    let output = acquisition_command(&view).await?;
119    let value: serde_json::Value = serde_json::from_slice(&output).with_context(|| {
120        format!(
121            "command {view:?} returned invalid JSON: {}",
122            String::from_utf8_lossy(&output)
123        )
124    })?;
125    let confirmed = value["nameWithOwner"].as_str().with_context(|| {
126        format!(
127            "command {view:?} omitted nameWithOwner: {}",
128            String::from_utf8_lossy(&output)
129        )
130    })?;
131    validate_github_name(confirmed).with_context(|| {
132        format!(
133            "command {view:?} returned invalid name: {}",
134            String::from_utf8_lossy(&output)
135        )
136    })?;
137    ensure!(
138        confirmed.eq_ignore_ascii_case(repo),
139        "command {view:?} resolved to {confirmed:?}, refusing a different repository"
140    );
141    let default = vec![
142        gh.to_owned(),
143        "repo".into(),
144        "clone".into(),
145        "https://github.com/{repo}.git".into(),
146        "{dest}".into(),
147    ];
148    let template = cfg.clone_command.as_ref().unwrap_or(&default);
149    ensure!(
150        !template.is_empty() && !template[0].is_empty(),
151        "[repos] clone_command must contain an executable"
152    );
153    // Validated names cannot contain placeholders. Substitute dest last so its
154    // path is never interpreted as another template.
155    let argv: Vec<String> = template
156        .iter()
157        .map(|arg| {
158            arg.replace("{repo}", repo)
159                .replace("{owner}", owner)
160                .replace("{name}", name)
161                .replace("{dest}", &dest.to_string_lossy())
162        })
163        .collect();
164    std::fs::create_dir_all(dest.parent().context("clone destination has no parent")?)?;
165    let marker = incomplete_marker(&dest);
166    std::fs::OpenOptions::new().write(true).create_new(true).open(&marker)
167        .with_context(|| format!("cannot mark acquisition at {}; inspect any previous failed acquisition and its marker {}", dest.display(), marker.display()))?;
168    // Cancellation/process exit must keep the marker: the child may still run.
169    // A completed failure can safely clear it only when no destination exists.
170    let acquired: anyhow::Result<PathBuf> = async {
171        acquisition_command(&argv).await?;
172        let canonical = dest
173            .canonicalize()
174            .with_context(|| format!("command {argv:?} did not create {}", dest.display()))?;
175        let top = crate::git::toplevel(&canonical).await.with_context(|| {
176            format!(
177                "command {argv:?} did not create a checkout at {}",
178                dest.display()
179            )
180        })?;
181        ensure!(
182            top.canonicalize()? == canonical && canonical.join(".git").exists(),
183            "command {argv:?} did not create a checkout at {}",
184            dest.display()
185        );
186        Ok(canonical)
187    }
188    .await;
189    let canonical = acquired.map_err(|error| {
190        if matches!(std::fs::symlink_metadata(&dest),
191            Err(e) if e.kind() == std::io::ErrorKind::NotFound)
192        {
193            if let Err(cleanup) = std::fs::remove_file(&marker) {
194                return error.context(format!(
195                    "remove acquisition marker {} after failure: {cleanup}",
196                    marker.display()
197                ));
198            }
199        }
200        error
201    })?;
202    std::fs::remove_file(&marker).with_context(|| {
203        format!(
204            "publish completed acquisition by removing {}",
205            marker.display()
206        )
207    })?;
208    Ok(canonical)
209}
210
211async fn acquisition_command(argv: &[String]) -> anyhow::Result<Vec<u8>> {
212    use anyhow::{Context, ensure};
213    let output = tokio::process::Command::new(&argv[0])
214        .args(&argv[1..])
215        .output()
216        .await
217        .with_context(|| format!("could not execute command {argv:?}"))?;
218    ensure!(
219        output.status.success(),
220        "command {argv:?} failed ({}):\nstdout: {}\nstderr: {}",
221        output.status,
222        String::from_utf8_lossy(&output.stdout),
223        String::from_utf8_lossy(&output.stderr)
224    );
225    Ok(output.stdout)
226}
227
228#[cfg(all(test, unix))]
229mod acquisition_tests {
230    use super::*;
231    use std::os::unix::fs::PermissionsExt;
232    use std::path::Path;
233
234    fn script(dir: &Path, name: &str, body: &str) -> String {
235        let path = dir.join(name);
236        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
237        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
238        path.to_string_lossy().into_owned()
239    }
240
241    #[test]
242    fn github_names_refuse_traversal_options_and_invalid_characters() {
243        for name in [
244            "bare",
245            "a/b/c",
246            "a/..",
247            "a/.",
248            "a/",
249            "/b",
250            "a--b/c",
251            "a-/b",
252            "-a/b",
253            "a/b;touch",
254            "a/b\\c",
255            "a/é",
256        ] {
257            assert!(validate_github_name(name).is_err(), "{name}");
258        }
259        assert!(validate_github_name(&format!("{}/b", "a".repeat(40))).is_err());
260        assert!(validate_github_name(&format!("a/{}", "b".repeat(101))).is_err());
261        for name in ["owner/repo", "a-b/.repo_name-1", "Owner/Repo.git"] {
262            validate_github_name(name).unwrap();
263        }
264    }
265
266    #[tokio::test]
267    async fn acquisition_failures_are_explicit_and_preserve_existing_paths() {
268        let tmp = tempfile::tempdir().unwrap();
269        let mut cfg = crate::config::Repos {
270            roots: vec![tmp.path().join("root")],
271            ..Default::default()
272        };
273        let gh = script(tmp.path(), "gh", "echo denied; echo forbidden >&2; exit 7");
274        let error = clone_github_with_gh("sample/project", &cfg, &gh)
275            .await
276            .unwrap_err()
277            .to_string();
278        assert!(
279            error.contains(&gh)
280                && error.contains("denied")
281                && error.contains("forbidden")
282                && error.contains('7')
283        );
284        let gh = script(
285            tmp.path(),
286            "gh",
287            "echo '{\"nameWithOwner\":\"other/project\"}'",
288        );
289        assert!(
290            clone_github_with_gh("sample/project", &cfg, &gh)
291                .await
292                .unwrap_err()
293                .to_string()
294                .contains("different repository")
295        );
296        let gh = script(
297            tmp.path(),
298            "gh",
299            "echo '{\"nameWithOwner\":\"sample/project\"}'",
300        );
301        let clone = script(tmp.path(), "clone", "echo clone-failed >&2; exit 9");
302        cfg.clone_command = Some(vec![clone.clone()]);
303        let error = clone_github_with_gh("sample/project", &cfg, &gh)
304            .await
305            .unwrap_err()
306            .to_string();
307        assert!(error.contains(&clone) && error.contains("clone-failed"));
308        assert!(!acquisition_incomplete(
309            &cfg.roots[0].join("github.com/sample/project")
310        ));
311        let clone = script(tmp.path(), "clone", "exit 0");
312        cfg.clone_command = Some(vec![clone]);
313        assert!(
314            clone_github_with_gh("sample/project", &cfg, &gh)
315                .await
316                .unwrap_err()
317                .to_string()
318                .contains("did not create")
319        );
320        let dest = cfg.roots[0].join("github.com/sample/project");
321        std::fs::create_dir_all(&dest).unwrap();
322        std::fs::write(dest.join("keep"), "untouched").unwrap();
323        assert!(
324            clone_github_with_gh("sample/project", &cfg, &gh)
325                .await
326                .unwrap_err()
327                .to_string()
328                .contains("already exists")
329        );
330        assert_eq!(
331            std::fs::read_to_string(dest.join("keep")).unwrap(),
332            "untouched"
333        );
334        cfg.roots.clear();
335        assert!(
336            clone_github_with_gh("sample/project", &cfg, &gh)
337                .await
338                .unwrap_err()
339                .to_string()
340                .contains("nonempty roots")
341        );
342    }
343
344    #[tokio::test]
345    async fn acquisition_retries_after_failure_without_a_destination() {
346        let tmp = tempfile::tempdir().unwrap();
347        let gh = script(
348            tmp.path(),
349            "gh",
350            "echo '{\"nameWithOwner\":\"sample/project\"}'",
351        );
352        let mut cfg = crate::config::Repos {
353            roots: vec![tmp.path().join("root")],
354            clone_command: Some(vec![
355                tmp.path().join("missing-command").display().to_string(),
356            ]),
357            ..Default::default()
358        };
359        let dest = cfg.roots[0].join("github.com/sample/project");
360        assert!(
361            clone_github_with_gh("sample/project", &cfg, &gh)
362                .await
363                .is_err()
364        );
365        assert!(!dest.exists());
366        assert!(!acquisition_incomplete(&dest));
367        let clone = script(
368            tmp.path(),
369            "clone",
370            r#"if [ ! -f "$0.attempted" ]; then
371    touch "$0.attempted"
372    echo transient-failure >&2
373    exit 9
374fi
375git init -q "$1""#,
376        );
377        cfg.clone_command = Some(vec![clone, "{dest}".into()]);
378        let error = clone_github_with_gh("sample/project", &cfg, &gh)
379            .await
380            .unwrap_err();
381        assert!(error.to_string().contains("transient-failure"));
382        assert!(!dest.exists());
383        assert!(!acquisition_incomplete(&dest));
384        let acquired = clone_github_with_gh("sample/project", &cfg, &gh)
385            .await
386            .unwrap();
387        assert_eq!(acquired, dest.canonicalize().unwrap());
388        assert!(!acquisition_incomplete(&dest));
389
390        // A failed command that did create a checkout must remain quarantined.
391        cfg.roots = vec![tmp.path().join("partial")];
392        let partial = cfg.roots[0].join("github.com/sample/project");
393        let clone = script(tmp.path(), "partial-clone", "git init -q \"$1\"; exit 9");
394        cfg.clone_command = Some(vec![clone, "{dest}".into()]);
395        assert!(
396            clone_github_with_gh("sample/project", &cfg, &gh)
397                .await
398                .is_err()
399        );
400        assert!(partial.join(".git").exists());
401        assert!(acquisition_incomplete(&partial));
402        assert!(crate::repos::scan(&cfg.roots).is_empty());
403        assert!(
404            clone_github_with_gh("sample/project", &cfg, &gh)
405                .await
406                .unwrap_err()
407                .to_string()
408                .contains("already exists")
409        );
410    }
411
412    #[tokio::test]
413    async fn acquisition_default_command_uses_the_exact_destination() {
414        let tmp = tempfile::tempdir().unwrap();
415        let gh = script(
416            tmp.path(),
417            "gh",
418            r#"export GH_HOST=enterprise.invalid
419if [ "$2" = view ]; then
420    [ "$3" = https://github.com/sample/project ] || exit 8
421    echo '{"nameWithOwner":"Sample/Project"}'
422else
423    [ "$1" = repo ] && [ "$2" = clone ] && [ "$3" = https://github.com/sample/project.git ] || exit 8
424    git init -q "$4"
425fi"#,
426        );
427        let cfg = crate::config::Repos {
428            roots: vec![tmp.path().join("root with spaces")],
429            ..Default::default()
430        };
431        let path = clone_github_with_gh("sample/project", &cfg, &gh)
432            .await
433            .unwrap();
434        assert_eq!(
435            path,
436            cfg.roots[0]
437                .join("github.com/sample/project")
438                .canonicalize()
439                .unwrap()
440        );
441    }
442
443    #[tokio::test]
444    async fn configured_clone_preserves_spaces_in_substituted_arguments() {
445        let tmp = tempfile::tempdir().unwrap();
446        let gh = script(
447            tmp.path(),
448            "gh",
449            "echo '{\"nameWithOwner\":\"sample/project\"}'",
450        );
451        let clone = script(
452            tmp.path(),
453            "clone with spaces",
454            r#"[ "$#" = 4 ] && [ "$1" = 'sample x' ] && [ "$2" = project ] && [ "$3" = sample/project ] || exit 8
455git init -q "$4""#,
456        );
457        let cfg = crate::config::Repos {
458            roots: vec![tmp.path().join("root with spaces")],
459            clone_command: Some(vec![
460                clone,
461                "{owner} x".into(),
462                "{name}".into(),
463                "{repo}".into(),
464                "{dest}".into(),
465            ]),
466            ..Default::default()
467        };
468        let path = clone_github_with_gh("sample/project", &cfg, &gh)
469            .await
470            .unwrap();
471        assert_eq!(
472            path,
473            cfg.roots[0]
474                .join("github.com/sample/project")
475                .canonicalize()
476                .unwrap()
477        );
478    }
479
480    #[tokio::test]
481    async fn clone_lock_is_exclusive_and_releases_on_drop() {
482        let tmp = tempfile::tempdir().unwrap();
483        let cfg = crate::config::Repos {
484            roots: vec![tmp.path().to_owned()],
485            ..Default::default()
486        };
487        let first = clone_lock(&cfg).await.unwrap();
488        assert!(
489            tokio::time::timeout(Duration::from_millis(100), clone_lock(&cfg))
490                .await
491                .is_err()
492        );
493        drop(first);
494        tokio::time::timeout(Duration::from_secs(1), clone_lock(&cfg))
495            .await
496            .unwrap()
497            .unwrap();
498    }
499}