specfold_core/workspace/vcs/
submodules.rs1use super::repo::{git, split_nul};
5use std::path::Path;
6
7#[derive(Debug, Clone, Copy)]
9pub enum Side<'a> {
10 Staged,
12 Since(&'a str),
14}
15
16const ZERO: &str = "0000000000000000000000000000000000000000";
17
18pub fn submodule_changes(root: &Path, side: Side) -> Vec<String> {
21 expand(root, side).files
22}
23
24#[derive(Debug, Default)]
26pub struct Expansion {
27 pub files: Vec<String>,
29 pub expanded: Vec<String>,
31 pub missing: Vec<String>,
33}
34
35pub fn expand(root: &Path, side: Side) -> Expansion {
37 let mut args = vec!["diff", "--raw", "-z", "--no-abbrev", "--diff-filter=AM"];
38 match side {
39 Side::Staged => args.push("--cached"),
40 Side::Since(base) => args.push(base),
41 }
42 let Some(out) = git(root, &args) else { return Expansion::default() };
43 let fields = split_nul(out);
44 let mut e = Expansion::default();
45 for pair in fields.chunks(2) {
46 let [meta, path] = pair else { continue };
47 let parts: Vec<&str> = meta.trim_start_matches(':').split(' ').collect();
48 let [old_mode, new_mode, old, new, _status] = parts[..] else { continue };
49 if new_mode != "160000" {
50 continue;
51 }
52 let sub = root.join(path);
53 let inner = if sub.join(".git").exists() { inner_changes(&sub, old_mode, old, new) } else { None };
54 match inner {
55 Some(files) => {
56 e.files.extend(files.into_iter().map(|f| format!("{path}/{f}")));
57 e.expanded.push(path.clone());
58 }
59 None => e.missing.push(path.clone()),
60 }
61 }
62 e.files.sort();
63 e.files.dedup();
64 e
65}
66
67fn inner_changes(sub: &Path, old_mode: &str, old: &str, new: &str) -> Option<Vec<String>> {
70 let head;
71 let new = if new == ZERO {
72 let out = git(sub, &["rev-parse", "HEAD"])?;
73 head = String::from_utf8_lossy(&out).trim().to_string();
74 head.as_str()
75 } else {
76 new
77 };
78 let listed = if old_mode != "160000" || old == ZERO {
79 git(sub, &["ls-tree", "-r", "-z", "--name-only", new])
80 } else {
81 git(sub, &["diff", "--name-only", "-z", "--diff-filter=ACMR", old, new])
82 };
83 listed.map(split_nul)
84}
85
86pub fn gitlinks(root: &Path) -> Vec<String> {
88 let Some(out) = git(root, &["ls-files", "-s", "-z"]) else { return vec![] };
89 split_nul(out)
90 .into_iter()
91 .filter(|e| e.starts_with("160000 "))
92 .filter_map(|e| e.split_once('\t').map(|(_, p)| p.to_string()))
93 .collect()
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use std::process::Command;
100
101 fn run(dir: &Path, args: &[&str]) {
102 let ok = Command::new("git")
103 .arg("-C")
104 .arg(dir)
105 .args(["-c", "user.email=t@t", "-c", "user.name=t", "-c", "protocol.file.allow=always"])
106 .args(args)
107 .output()
108 .unwrap()
109 .status
110 .success();
111 assert!(ok, "git {args:?}");
112 }
113
114 #[test]
115 fn a_moved_gitlink_lists_the_files_changed_inside_it() {
116 let tmp = tempfile::tempdir().unwrap();
117 let lib = tmp.path().join("lib");
118 std::fs::create_dir_all(&lib).unwrap();
119 run(&lib, &["init", "-q"]);
120 std::fs::write(lib.join("a.md"), "a").unwrap();
121 run(&lib, &["add", "a.md"]);
122 run(&lib, &["commit", "-qm", "a"]);
123 let outer = tmp.path().join("outer");
124 std::fs::create_dir_all(&outer).unwrap();
125 run(&outer, &["init", "-q"]);
126 run(&outer, &["submodule", "add", "-q", lib.to_str().unwrap(), "libs/lib"]);
127 run(&outer, &["commit", "-qm", "pin"]);
128 let sub = outer.join("libs/lib");
129 std::fs::create_dir_all(sub.join(".spec/adr")).unwrap();
130 std::fs::write(sub.join(".spec/adr/new.md"), "n").unwrap();
131 run(&sub, &["add", ".spec/adr/new.md"]);
132 run(&sub, &["commit", "-qm", "new"]);
133 run(&outer, &["add", "libs/lib"]);
134 assert_eq!(submodule_changes(&outer, Side::Staged), vec!["libs/lib/.spec/adr/new.md".to_string()]);
135 assert_eq!(submodule_changes(&outer, Side::Since("HEAD")), vec!["libs/lib/.spec/adr/new.md".to_string()]);
136 assert_eq!(expand(&outer, Side::Staged).expanded, vec!["libs/lib".to_string()]);
137 std::fs::remove_file(sub.join(".git")).unwrap();
138 let e = expand(&outer, Side::Staged);
139 assert_eq!((e.files.len(), e.missing), (0, vec!["libs/lib".to_string()]));
140 }
141
142 #[test]
143 fn an_unmoved_gitlink_adds_nothing() {
144 let tmp = tempfile::tempdir().unwrap();
145 run(tmp.path(), &["init", "-q"]);
146 assert!(submodule_changes(tmp.path(), Side::Staged).is_empty());
147 }
148}