Skip to main content

review_queue/
workdir.rs

1//! Discovering canonical repos by scanning `Config::workdir` instead of requiring them to be
2//! cloned by rq or listed by hand.
3//!
4//! `scan()` walks the tree recursively, stopping at the first repo found in each branch (a repo's
5//! own subdirectories - submodules, vendored trees, nested worktrees - are never canonical repos
6//! themselves). A directory is a repo if it has a `.jj/repo` directory (jj, colocated or not -
7//! checked first so a colocated git+jj repo counts as jj) or a `.git` directory (git). A
8//! `.jj/repo` *file* (a secondary `jj workspace add` workspace) or a `.git` *file* (a git
9//! worktree) means "not canonical, don't descend either" - the real repo lives elsewhere and
10//! will be found from its own position in the tree.
11//!
12//! Every remote is read and normalized (`crate::repo::normalize_url`), so any URL form
13//! (`https://`, `git@host:`, `ssh://`) matches. A repo with no remotes at all can't be matched
14//! against anything a review reports, so it's dropped rather than cached. `name` - the id used to
15//! match a review's repo against this one - is `origin`'s normalized URL, or the first remote if
16//! there's no `origin`, so it stays stable regardless of which alias a review happens to report.
17//!
18//! The scan result is cached (`WorkdirCache`) so it isn't re-run on every `rq fetch`; see
19//! `RepoStore::resolve` for the rescan-on-miss policy.
20
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24use anyhow::{Context, Result};
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27
28use crate::config::VcsKind;
29use crate::repo::normalize_url;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct DiscoveredRepo {
33    pub path: PathBuf,
34    pub vcs: VcsKind,
35    /// This repo's matching id - `origin`'s normalized URL, or the first remote's.
36    pub name: String,
37    /// Every remote's normalized URL (including `name`'s), for matching a review's repo against.
38    pub remotes: Vec<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct WorkdirCache {
43    pub workdir: PathBuf,
44    pub scanned_at: DateTime<Utc>,
45    pub repos: Vec<DiscoveredRepo>,
46}
47
48impl WorkdirCache {
49    pub fn load(path: &Path) -> Result<Option<Self>> {
50        if !path.exists() {
51            return Ok(None);
52        }
53        let text =
54            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
55        if text.trim().is_empty() {
56            return Ok(None);
57        }
58        Ok(Some(
59            serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?,
60        ))
61    }
62
63    /// Atomic write: temp file in the same directory, then rename over the target.
64    pub fn save(&self, path: &Path) -> Result<()> {
65        let dir = path
66            .parent()
67            .context("workdir cache path has no parent directory")?;
68        std::fs::create_dir_all(dir)?;
69        let tmp = dir.join(format!(
70            ".{}.tmp",
71            path.file_name().unwrap().to_string_lossy()
72        ));
73        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)
74            .with_context(|| format!("writing {}", tmp.display()))?;
75        std::fs::rename(&tmp, path)
76            .with_context(|| format!("renaming {} to {}", tmp.display(), path.display()))?;
77        Ok(())
78    }
79
80    /// The first cached repo (still present on disk) with a remote matching one of
81    /// `normalized_urls`.
82    pub fn lookup(&self, normalized_urls: &[String]) -> Option<&DiscoveredRepo> {
83        self.repos
84            .iter()
85            .find(|r| r.path.exists() && r.remotes.iter().any(|u| normalized_urls.contains(u)))
86    }
87}
88
89/// Recursively scan `root` for canonical repos, skipping `skip` (rq's own data dir, so
90/// tool-managed clones never get picked up as if they were user checkouts) and any
91/// dot-directory. Errors reading a directory (permissions, races) are treated as "nothing here",
92/// not fatal - a workdir scan shouldn't abort over one unreadable subdirectory.
93pub fn scan(root: &Path, skip: &Path) -> Vec<DiscoveredRepo> {
94    let mut out = Vec::new();
95    scan_dir(root, skip, &mut out);
96    out
97}
98
99fn scan_dir(dir: &Path, skip: &Path, out: &mut Vec<DiscoveredRepo>) {
100    if dir == skip {
101        return;
102    }
103
104    let jj_repo = dir.join(".jj").join("repo");
105    if jj_repo.is_dir() {
106        if let Some(repo) = discovered_jj_repo(dir) {
107            out.push(repo);
108        }
109        return;
110    }
111    if jj_repo.is_file() {
112        return; // secondary `jj workspace add` workspace - the real repo is elsewhere
113    }
114
115    let git_dir = dir.join(".git");
116    if git_dir.is_dir() {
117        if let Some(repo) = discovered_git_repo(dir) {
118            out.push(repo);
119        }
120        return;
121    }
122    if git_dir.is_file() {
123        return; // git worktree - the real repo is elsewhere
124    }
125
126    let Ok(entries) = std::fs::read_dir(dir) else {
127        return;
128    };
129    for entry in entries.flatten() {
130        let Ok(file_type) = entry.file_type() else {
131            continue;
132        };
133        if file_type.is_symlink() || !file_type.is_dir() {
134            continue;
135        }
136        let path = entry.path();
137        if path
138            .file_name()
139            .and_then(|n| n.to_str())
140            .is_some_and(|n| n.starts_with('.'))
141        {
142            continue;
143        }
144        scan_dir(&path, skip, out);
145    }
146}
147
148fn discovered_git_repo(dir: &Path) -> Option<DiscoveredRepo> {
149    let output = Command::new("git")
150        .current_dir(dir)
151        .args(["config", "--get-regexp", r"^remote\..*\.url$"])
152        .output()
153        .ok()?;
154    // A repo with no remotes at all makes `--get-regexp` exit non-zero (nothing matched).
155    if !output.status.success() {
156        return None;
157    }
158    let text = String::from_utf8_lossy(&output.stdout);
159    let mut remotes = Vec::new();
160    for line in text.lines() {
161        let Some((key, url)) = line.split_once(' ') else {
162            continue;
163        };
164        let Some(name) = key
165            .strip_prefix("remote.")
166            .and_then(|s| s.strip_suffix(".url"))
167        else {
168            continue;
169        };
170        remotes.push((name.to_string(), url.to_string()));
171    }
172    build_discovered(dir.to_path_buf(), VcsKind::Git, remotes)
173}
174
175fn discovered_jj_repo(dir: &Path) -> Option<DiscoveredRepo> {
176    let output = Command::new("jj")
177        .current_dir(dir)
178        .args(["git", "remote", "list"])
179        .output()
180        .ok()?;
181    if !output.status.success() {
182        return None;
183    }
184    let text = String::from_utf8_lossy(&output.stdout);
185    let mut remotes = Vec::new();
186    for line in text.lines() {
187        let mut parts = line.split_whitespace();
188        let (Some(name), Some(url)) = (parts.next(), parts.next()) else {
189            continue;
190        };
191        remotes.push((name.to_string(), url.to_string()));
192    }
193    build_discovered(dir.to_path_buf(), VcsKind::Jj, remotes)
194}
195
196fn build_discovered(
197    path: PathBuf,
198    vcs: VcsKind,
199    remotes: Vec<(String, String)>,
200) -> Option<DiscoveredRepo> {
201    if remotes.is_empty() {
202        return None;
203    }
204    let normalized: Vec<String> = remotes.iter().map(|(_, u)| normalize_url(u)).collect();
205    let name = remotes
206        .iter()
207        .position(|(name, _)| name == "origin")
208        .unwrap_or(0);
209    Some(DiscoveredRepo {
210        path,
211        vcs,
212        name: normalized[name].clone(),
213        remotes: normalized,
214    })
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::process::Command;
221    use tempfile::TempDir;
222
223    fn git(dir: &Path, args: &[&str]) {
224        let out = Command::new("git")
225            .current_dir(dir)
226            .args(args)
227            .output()
228            .unwrap();
229        assert!(
230            out.status.success(),
231            "git {args:?} failed: {}",
232            String::from_utf8_lossy(&out.stderr)
233        );
234    }
235
236    fn git_repo_with_remote(dir: &Path, remote_url: &str) {
237        std::fs::create_dir_all(dir).unwrap();
238        git(dir, &["init", "-q"]);
239        git(dir, &["remote", "add", "origin", remote_url]);
240    }
241
242    fn jj_available() -> bool {
243        Command::new("jj")
244            .arg("--version")
245            .output()
246            .map(|o| o.status.success())
247            .unwrap_or(false)
248    }
249
250    macro_rules! require_jj {
251        () => {
252            if !jj_available() {
253                eprintln!("skipping: `jj` not found on PATH");
254                return;
255            }
256        };
257    }
258
259    #[test]
260    fn finds_a_nested_repo_and_normalizes_its_remote() {
261        let tmp = TempDir::new().unwrap();
262        let repo = tmp.path().join("a/b/repo");
263        git_repo_with_remote(&repo, "git@github.com:o/r.git");
264
265        let found = scan(tmp.path(), Path::new("/nonexistent"));
266        assert_eq!(found.len(), 1);
267        assert_eq!(found[0].path, repo);
268        assert_eq!(found[0].vcs, VcsKind::Git);
269        assert_eq!(found[0].name, "github.com/o/r");
270        assert_eq!(found[0].remotes, vec!["github.com/o/r".to_string()]);
271    }
272
273    #[test]
274    fn does_not_descend_into_a_found_repo() {
275        let tmp = TempDir::new().unwrap();
276        let outer = tmp.path().join("outer");
277        git_repo_with_remote(&outer, "https://example.com/o/outer");
278        // A vendored/nested repo inside it must not be reported separately.
279        git_repo_with_remote(&outer.join("vendor/nested"), "https://example.com/o/nested");
280
281        let found = scan(tmp.path(), Path::new("/nonexistent"));
282        assert_eq!(found.len(), 1);
283        assert_eq!(found[0].path, outer);
284    }
285
286    #[test]
287    fn skips_dot_directories_and_the_data_dir() {
288        let tmp = TempDir::new().unwrap();
289        std::fs::create_dir_all(tmp.path().join(".cache/repo")).unwrap();
290        git_repo_with_remote(
291            &tmp.path().join(".cache/repo"),
292            "https://example.com/o/hidden",
293        );
294        let skip_repo = tmp.path().join("data/repos/foo/source");
295        git_repo_with_remote(&skip_repo, "https://example.com/o/skip");
296
297        let found = scan(tmp.path(), &tmp.path().join("data/repos"));
298        assert!(found.is_empty());
299    }
300
301    #[test]
302    fn drops_a_repo_with_no_remotes() {
303        let tmp = TempDir::new().unwrap();
304        let repo = tmp.path().join("repo");
305        std::fs::create_dir_all(&repo).unwrap();
306        git(&repo, &["init", "-q"]);
307
308        assert!(scan(tmp.path(), Path::new("/nonexistent")).is_empty());
309    }
310
311    #[test]
312    fn skips_a_git_worktree() {
313        let tmp = TempDir::new().unwrap();
314        let repo = tmp.path().join("repo");
315        git_repo_with_remote(&repo, "https://example.com/o/r");
316        git(&repo, &["config", "user.name", "test"]);
317        git(&repo, &["config", "user.email", "test@example.com"]);
318        git(&repo, &["commit", "-q", "--allow-empty", "-m", "base"]);
319        let worktree = tmp.path().join("worktree");
320        git(
321            &repo,
322            &["worktree", "add", "--detach", worktree.to_str().unwrap()],
323        );
324
325        let found = scan(tmp.path(), Path::new("/nonexistent"));
326        assert_eq!(found.len(), 1);
327        assert_eq!(found[0].path, repo);
328    }
329
330    #[test]
331    fn prefers_origin_as_the_name_over_other_remotes() {
332        let tmp = TempDir::new().unwrap();
333        let repo = tmp.path().join("repo");
334        git_repo_with_remote(&repo, "https://example.com/fork/r");
335        git(
336            &repo,
337            &["remote", "add", "upstream", "https://example.com/o/r"],
338        );
339
340        let found = scan(tmp.path(), Path::new("/nonexistent"));
341        assert_eq!(found.len(), 1);
342        assert_eq!(found[0].name, "example.com/fork/r");
343        assert_eq!(
344            found[0]
345                .remotes
346                .iter()
347                .collect::<std::collections::HashSet<_>>(),
348            std::collections::HashSet::from([
349                &"example.com/fork/r".to_string(),
350                &"example.com/o/r".to_string(),
351            ])
352        );
353    }
354
355    #[test]
356    fn lookup_ignores_a_cached_entry_whose_path_no_longer_exists() {
357        let cache = WorkdirCache {
358            workdir: PathBuf::from("/dev"),
359            scanned_at: Utc::now(),
360            repos: vec![DiscoveredRepo {
361                path: PathBuf::from("/nonexistent/gone"),
362                vcs: VcsKind::Git,
363                name: "example.com/o/r".into(),
364                remotes: vec!["example.com/o/r".into()],
365            }],
366        };
367        assert!(cache.lookup(&["example.com/o/r".to_string()]).is_none());
368    }
369
370    #[test]
371    fn colocated_jj_repo_is_detected_as_jj_not_git() {
372        require_jj!();
373        let tmp = TempDir::new().unwrap();
374        let upstream = tmp.path().join("upstream");
375        std::fs::create_dir(&upstream).unwrap();
376        git(&upstream, &["init", "-q", "-b", "main"]);
377        git(&upstream, &["config", "user.name", "test"]);
378        git(&upstream, &["config", "user.email", "test@example.com"]);
379        git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
380
381        let canon = tmp.path().join("canon");
382        let out = Command::new("jj")
383            .current_dir(tmp.path())
384            .args([
385                "git",
386                "clone",
387                upstream.to_str().unwrap(),
388                canon.to_str().unwrap(),
389            ])
390            .output()
391            .unwrap();
392        assert!(
393            out.status.success(),
394            "jj git clone failed: {}",
395            String::from_utf8_lossy(&out.stderr)
396        );
397
398        let found = scan(tmp.path(), Path::new("/nonexistent"));
399        assert_eq!(found.len(), 1);
400        assert_eq!(found[0].path, canon);
401        assert_eq!(found[0].vcs, VcsKind::Jj, "colocated repo must count as jj");
402        assert_eq!(found[0].name, normalize_url(upstream.to_str().unwrap()));
403    }
404
405    #[test]
406    fn skips_a_secondary_jj_workspace() {
407        require_jj!();
408        let tmp = TempDir::new().unwrap();
409        let upstream = tmp.path().join("upstream");
410        std::fs::create_dir(&upstream).unwrap();
411        git(&upstream, &["init", "-q", "-b", "main"]);
412        git(&upstream, &["config", "user.name", "test"]);
413        git(&upstream, &["config", "user.email", "test@example.com"]);
414        git(&upstream, &["commit", "-q", "--allow-empty", "-m", "base"]);
415
416        let canon = tmp.path().join("canon");
417        let clone = Command::new("jj")
418            .current_dir(tmp.path())
419            .args([
420                "git",
421                "clone",
422                upstream.to_str().unwrap(),
423                canon.to_str().unwrap(),
424            ])
425            .output()
426            .unwrap();
427        assert!(clone.status.success());
428
429        let secondary = tmp.path().join("secondary");
430        let add = Command::new("jj")
431            .current_dir(&canon)
432            .args([
433                "workspace",
434                "add",
435                "--name",
436                "secondary",
437                secondary.to_str().unwrap(),
438            ])
439            .output()
440            .unwrap();
441        assert!(
442            add.status.success(),
443            "jj workspace add failed: {}",
444            String::from_utf8_lossy(&add.stderr)
445        );
446
447        let found = scan(tmp.path(), Path::new("/nonexistent"));
448        assert_eq!(found.len(), 1, "only the canonical repo, not the workspace");
449        assert_eq!(found[0].path, canon);
450    }
451}