Skip to main content

specfold_core/workspace/vcs/
submodules.rs

1//! What a change touches inside submodules: a gitlink that moves stands for the files that
2//! changed between its two commits, which only the submodule's own checkout can list.
3
4use super::repo::{git, split_nul};
5use std::path::Path;
6
7/// Which change of the outer repository to read gitlinks from.
8#[derive(Debug, Clone, Copy)]
9pub enum Side<'a> {
10    /// The index against HEAD.
11    Staged,
12    /// The working tree against a base revision.
13    Since(&'a str),
14}
15
16const ZERO: &str = "0000000000000000000000000000000000000000";
17
18/// Repository-relative paths changed inside submodules whose gitlink this change adds or moves.
19/// A submodule that is not checked out, or that lacks either commit, adds nothing.
20pub fn submodule_changes(root: &Path, side: Side) -> Vec<String> {
21    let mut args = vec!["diff", "--raw", "-z", "--no-abbrev", "--diff-filter=AM"];
22    match side {
23        Side::Staged => args.push("--cached"),
24        Side::Since(base) => args.push(base),
25    }
26    let Some(out) = git(root, &args) else { return vec![] };
27    let fields = split_nul(out);
28    let mut files = vec![];
29    for pair in fields.chunks(2) {
30        let [meta, path] = pair else { continue };
31        let parts: Vec<&str> = meta.trim_start_matches(':').split(' ').collect();
32        let [old_mode, new_mode, old, new, _status] = parts[..] else { continue };
33        if new_mode != "160000" {
34            continue;
35        }
36        let sub = root.join(path);
37        if !sub.join(".git").exists() {
38            continue;
39        }
40        files.extend(inner_changes(&sub, old_mode, old, new).into_iter().map(|f| format!("{path}/{f}")));
41    }
42    files.sort();
43    files.dedup();
44    files
45}
46
47/// Files changed inside one submodule between the recorded commit and the new one; a working-tree
48/// side has no recorded commit, so the checkout's HEAD stands for it.
49fn inner_changes(sub: &Path, old_mode: &str, old: &str, new: &str) -> Vec<String> {
50    let head;
51    let new = if new == ZERO {
52        let Some(out) = git(sub, &["rev-parse", "HEAD"]) else { return vec![] };
53        head = String::from_utf8_lossy(&out).trim().to_string();
54        head.as_str()
55    } else {
56        new
57    };
58    let listed = if old_mode != "160000" || old == ZERO {
59        git(sub, &["ls-tree", "-r", "-z", "--name-only", new])
60    } else {
61        git(sub, &["diff", "--name-only", "-z", "--diff-filter=ACMR", old, new])
62    };
63    listed.map(split_nul).unwrap_or_default()
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use std::process::Command;
70
71    fn run(dir: &Path, args: &[&str]) {
72        let ok = Command::new("git")
73            .arg("-C")
74            .arg(dir)
75            .args(["-c", "user.email=t@t", "-c", "user.name=t", "-c", "protocol.file.allow=always"])
76            .args(args)
77            .output()
78            .unwrap()
79            .status
80            .success();
81        assert!(ok, "git {args:?}");
82    }
83
84    #[test]
85    fn a_moved_gitlink_lists_the_files_changed_inside_it() {
86        let tmp = tempfile::tempdir().unwrap();
87        let lib = tmp.path().join("lib");
88        std::fs::create_dir_all(&lib).unwrap();
89        run(&lib, &["init", "-q"]);
90        std::fs::write(lib.join("a.md"), "a").unwrap();
91        run(&lib, &["add", "a.md"]);
92        run(&lib, &["commit", "-qm", "a"]);
93        let outer = tmp.path().join("outer");
94        std::fs::create_dir_all(&outer).unwrap();
95        run(&outer, &["init", "-q"]);
96        run(&outer, &["submodule", "add", "-q", lib.to_str().unwrap(), "libs/lib"]);
97        run(&outer, &["commit", "-qm", "pin"]);
98        let sub = outer.join("libs/lib");
99        std::fs::create_dir_all(sub.join(".spec/adr")).unwrap();
100        std::fs::write(sub.join(".spec/adr/new.md"), "n").unwrap();
101        run(&sub, &["add", ".spec/adr/new.md"]);
102        run(&sub, &["commit", "-qm", "new"]);
103        run(&outer, &["add", "libs/lib"]);
104        assert_eq!(submodule_changes(&outer, Side::Staged), vec!["libs/lib/.spec/adr/new.md".to_string()]);
105        assert_eq!(submodule_changes(&outer, Side::Since("HEAD")), vec!["libs/lib/.spec/adr/new.md".to_string()]);
106    }
107
108    #[test]
109    fn an_unmoved_gitlink_adds_nothing() {
110        let tmp = tempfile::tempdir().unwrap();
111        run(tmp.path(), &["init", "-q"]);
112        assert!(submodule_changes(tmp.path(), Side::Staged).is_empty());
113    }
114}