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        // Keep writable script descriptors out of this multithreaded process:
237        // a concurrent fork can retain one and make exec fail with ETXTBSY.
238        let status = std::process::Command::new("sh")
239            .args([
240                "-c",
241                "printf '%s\\n' '#!/bin/sh' \"$2\" > \"$1\"",
242                "fixture",
243            ])
244            .arg(&path)
245            .arg(body)
246            .status()
247            .unwrap();
248        assert!(status.success());
249        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
250        path.to_string_lossy().into_owned()
251    }
252
253    #[test]
254    fn github_names_refuse_traversal_options_and_invalid_characters() {
255        for name in [
256            "bare",
257            "a/b/c",
258            "a/..",
259            "a/.",
260            "a/",
261            "/b",
262            "a--b/c",
263            "a-/b",
264            "-a/b",
265            "a/b;touch",
266            "a/b\\c",
267            "a/é",
268        ] {
269            assert!(validate_github_name(name).is_err(), "{name}");
270        }
271        assert!(validate_github_name(&format!("{}/b", "a".repeat(40))).is_err());
272        assert!(validate_github_name(&format!("a/{}", "b".repeat(101))).is_err());
273        for name in ["owner/repo", "a-b/.repo_name-1", "Owner/Repo.git"] {
274            validate_github_name(name).unwrap();
275        }
276    }
277
278    #[tokio::test]
279    async fn acquisition_failures_are_explicit_and_preserve_existing_paths() {
280        let tmp = tempfile::tempdir().unwrap();
281        let mut cfg = crate::config::Repos {
282            roots: vec![tmp.path().join("root")],
283            ..Default::default()
284        };
285        let gh = script(tmp.path(), "gh", "echo denied; echo forbidden >&2; exit 7");
286        let error = clone_github_with_gh("sample/project", &cfg, &gh)
287            .await
288            .unwrap_err()
289            .to_string();
290        assert!(
291            error.contains(&gh)
292                && error.contains("denied")
293                && error.contains("forbidden")
294                && error.contains('7'),
295            "{error}"
296        );
297        let gh = script(
298            tmp.path(),
299            "gh",
300            "echo '{\"nameWithOwner\":\"other/project\"}'",
301        );
302        assert!(
303            clone_github_with_gh("sample/project", &cfg, &gh)
304                .await
305                .unwrap_err()
306                .to_string()
307                .contains("different repository")
308        );
309        let gh = script(
310            tmp.path(),
311            "gh",
312            "echo '{\"nameWithOwner\":\"sample/project\"}'",
313        );
314        let clone = script(tmp.path(), "clone", "echo clone-failed >&2; exit 9");
315        cfg.clone_command = Some(vec![clone.clone()]);
316        let error = clone_github_with_gh("sample/project", &cfg, &gh)
317            .await
318            .unwrap_err()
319            .to_string();
320        assert!(error.contains(&clone) && error.contains("clone-failed"));
321        assert!(!acquisition_incomplete(
322            &cfg.roots[0].join("github.com/sample/project")
323        ));
324        let clone = script(tmp.path(), "clone", "exit 0");
325        cfg.clone_command = Some(vec![clone]);
326        assert!(
327            clone_github_with_gh("sample/project", &cfg, &gh)
328                .await
329                .unwrap_err()
330                .to_string()
331                .contains("did not create")
332        );
333        let dest = cfg.roots[0].join("github.com/sample/project");
334        std::fs::create_dir_all(&dest).unwrap();
335        std::fs::write(dest.join("keep"), "untouched").unwrap();
336        assert!(
337            clone_github_with_gh("sample/project", &cfg, &gh)
338                .await
339                .unwrap_err()
340                .to_string()
341                .contains("already exists")
342        );
343        assert_eq!(
344            std::fs::read_to_string(dest.join("keep")).unwrap(),
345            "untouched"
346        );
347        cfg.roots.clear();
348        assert!(
349            clone_github_with_gh("sample/project", &cfg, &gh)
350                .await
351                .unwrap_err()
352                .to_string()
353                .contains("nonempty roots")
354        );
355    }
356
357    #[tokio::test]
358    async fn acquisition_retries_after_failure_without_a_destination() {
359        let tmp = tempfile::tempdir().unwrap();
360        let gh = script(
361            tmp.path(),
362            "gh",
363            "echo '{\"nameWithOwner\":\"sample/project\"}'",
364        );
365        let mut cfg = crate::config::Repos {
366            roots: vec![tmp.path().join("root")],
367            clone_command: Some(vec![
368                tmp.path().join("missing-command").display().to_string(),
369            ]),
370            ..Default::default()
371        };
372        let dest = cfg.roots[0].join("github.com/sample/project");
373        assert!(
374            clone_github_with_gh("sample/project", &cfg, &gh)
375                .await
376                .is_err()
377        );
378        assert!(!dest.exists());
379        assert!(!acquisition_incomplete(&dest));
380        let clone = script(
381            tmp.path(),
382            "clone",
383            r#"if [ ! -f "$0.attempted" ]; then
384    touch "$0.attempted"
385    echo transient-failure >&2
386    exit 9
387fi
388git init -q "$1""#,
389        );
390        cfg.clone_command = Some(vec![clone, "{dest}".into()]);
391        let error = clone_github_with_gh("sample/project", &cfg, &gh)
392            .await
393            .unwrap_err();
394        assert!(error.to_string().contains("transient-failure"));
395        assert!(!dest.exists());
396        assert!(!acquisition_incomplete(&dest));
397        let acquired = clone_github_with_gh("sample/project", &cfg, &gh)
398            .await
399            .unwrap();
400        assert_eq!(acquired, dest.canonicalize().unwrap());
401        assert!(!acquisition_incomplete(&dest));
402
403        // A failed command that did create a checkout must remain quarantined.
404        cfg.roots = vec![tmp.path().join("partial")];
405        let partial = cfg.roots[0].join("github.com/sample/project");
406        let clone = script(tmp.path(), "partial-clone", "git init -q \"$1\"; exit 9");
407        cfg.clone_command = Some(vec![clone, "{dest}".into()]);
408        assert!(
409            clone_github_with_gh("sample/project", &cfg, &gh)
410                .await
411                .is_err()
412        );
413        assert!(partial.join(".git").exists());
414        assert!(acquisition_incomplete(&partial));
415        assert!(crate::repos::scan(&cfg.roots).is_empty());
416        assert!(
417            clone_github_with_gh("sample/project", &cfg, &gh)
418                .await
419                .unwrap_err()
420                .to_string()
421                .contains("already exists")
422        );
423    }
424
425    #[tokio::test]
426    async fn acquisition_default_command_uses_the_exact_destination() {
427        let tmp = tempfile::tempdir().unwrap();
428        let gh = script(
429            tmp.path(),
430            "gh",
431            r#"export GH_HOST=enterprise.invalid
432if [ "$2" = view ]; then
433    [ "$3" = https://github.com/sample/project ] || exit 8
434    echo '{"nameWithOwner":"Sample/Project"}'
435else
436    [ "$1" = repo ] && [ "$2" = clone ] && [ "$3" = https://github.com/sample/project.git ] || exit 8
437    git init -q "$4"
438fi"#,
439        );
440        let cfg = crate::config::Repos {
441            roots: vec![tmp.path().join("root with spaces")],
442            ..Default::default()
443        };
444        let path = clone_github_with_gh("sample/project", &cfg, &gh)
445            .await
446            .unwrap();
447        assert_eq!(
448            path,
449            cfg.roots[0]
450                .join("github.com/sample/project")
451                .canonicalize()
452                .unwrap()
453        );
454    }
455
456    #[tokio::test]
457    async fn configured_clone_preserves_spaces_in_substituted_arguments() {
458        let tmp = tempfile::tempdir().unwrap();
459        let gh = script(
460            tmp.path(),
461            "gh",
462            "echo '{\"nameWithOwner\":\"sample/project\"}'",
463        );
464        let clone = script(
465            tmp.path(),
466            "clone with spaces",
467            r#"[ "$#" = 4 ] && [ "$1" = 'sample x' ] && [ "$2" = project ] && [ "$3" = sample/project ] || exit 8
468git init -q "$4""#,
469        );
470        let cfg = crate::config::Repos {
471            roots: vec![tmp.path().join("root with spaces")],
472            clone_command: Some(vec![
473                clone,
474                "{owner} x".into(),
475                "{name}".into(),
476                "{repo}".into(),
477                "{dest}".into(),
478            ]),
479            ..Default::default()
480        };
481        let path = clone_github_with_gh("sample/project", &cfg, &gh)
482            .await
483            .unwrap();
484        assert_eq!(
485            path,
486            cfg.roots[0]
487                .join("github.com/sample/project")
488                .canonicalize()
489                .unwrap()
490        );
491    }
492
493    #[tokio::test]
494    async fn clone_lock_is_exclusive_and_releases_on_drop() {
495        let tmp = tempfile::tempdir().unwrap();
496        let cfg = crate::config::Repos {
497            roots: vec![tmp.path().to_owned()],
498            ..Default::default()
499        };
500        let first = clone_lock(&cfg).await.unwrap();
501        assert!(
502            tokio::time::timeout(Duration::from_millis(100), clone_lock(&cfg))
503                .await
504                .is_err()
505        );
506        drop(first);
507        tokio::time::timeout(Duration::from_secs(1), clone_lock(&cfg))
508            .await
509            .unwrap()
510            .unwrap();
511    }
512}