Skip to main content

git_topology/
cluster.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use std::process::Command;
5
6const TOPOLOGY_BRANCH: &str = "topology/v1";
7const CLUSTERS_FILE: &str = ".clusters.json";
8const INDEXED_SHA_FILE: &str = ".indexed-sha";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Cluster {
12    pub id: String,
13    pub name: String,
14    pub description: String,
15    pub files: Vec<String>,
16}
17
18impl Cluster {
19    pub fn contains_file(&self, file: &str) -> bool {
20        self.files.iter().any(|f| f == file)
21    }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ClusterMap {
26    pub version: u8,
27    pub clusters: Vec<Cluster>,
28}
29
30impl ClusterMap {
31    pub fn empty() -> Self {
32        Self {
33            version: 1,
34            clusters: vec![],
35        }
36    }
37
38    pub fn clusters_for_files(&self, files: &[String]) -> Vec<&Cluster> {
39        self.clusters
40            .iter()
41            .filter(|c| files.iter().any(|f| c.contains_file(f)))
42            .collect()
43    }
44}
45
46pub fn is_stale(repo_path: &Path) -> bool {
47    if !branch_exists(repo_path) {
48        return true;
49    }
50
51    let indexed_sha = read_indexed_sha(repo_path);
52    let indexed_sha = match indexed_sha {
53        Some(s) => s,
54        None => return true,
55    };
56
57    let head = Command::new("git")
58        .current_dir(repo_path)
59        .args(["rev-parse", "HEAD"])
60        .output()
61        .ok()
62        .and_then(|o| String::from_utf8(o.stdout).ok())
63        .map(|s| s.trim().to_string());
64
65    let head = match head {
66        Some(s) => s,
67        None => return true,
68    };
69
70    if indexed_sha == head {
71        return false;
72    }
73
74    // Check if any source file changed between indexed sha and HEAD
75    let diff = Command::new("git")
76        .current_dir(repo_path)
77        .args(["diff", "--name-only", &indexed_sha, &head])
78        .output()
79        .ok()
80        .and_then(|o| String::from_utf8(o.stdout).ok())
81        .unwrap_or_default();
82
83    diff.lines()
84        .any(|f| crate::chunking::languages::detect_language(f).is_some())
85}
86
87fn read_indexed_sha(repo_path: &Path) -> Option<String> {
88    let out = Command::new("git")
89        .current_dir(repo_path)
90        .args(["show", &format!("{}:{}", TOPOLOGY_BRANCH, INDEXED_SHA_FILE)])
91        .output()
92        .ok()?;
93
94    if out.status.success() {
95        String::from_utf8(out.stdout)
96            .ok()
97            .map(|s| s.trim().to_string())
98            .filter(|s| !s.is_empty())
99    } else {
100        None
101    }
102}
103
104pub fn branch_exists(repo_path: &Path) -> bool {
105    Command::new("git")
106        .current_dir(repo_path)
107        .args(["rev-parse", "--verify", TOPOLOGY_BRANCH])
108        .output()
109        .map(|o| o.status.success())
110        .unwrap_or(false)
111}
112
113pub fn read_cluster_map(repo_path: &Path) -> Result<Option<ClusterMap>> {
114    if !branch_exists(repo_path) {
115        return Ok(None);
116    }
117
118    let out = Command::new("git")
119        .current_dir(repo_path)
120        .args(["show", &format!("{}:{}", TOPOLOGY_BRANCH, CLUSTERS_FILE)])
121        .output()
122        .context("Failed to run git show for cluster map")?;
123
124    if !out.status.success() {
125        return Ok(None);
126    }
127
128    let map = serde_json::from_slice(&out.stdout).context("Failed to parse cluster map")?;
129    Ok(Some(map))
130}
131
132pub fn write_cluster_map(repo_path: &Path, map: &ClusterMap) -> Result<()> {
133    ensure_topology_branch(repo_path)?;
134
135    let worktree_path = repo_path.join(".git").join("topology-worktree");
136    setup_worktree(repo_path, &worktree_path, TOPOLOGY_BRANCH)?;
137
138    let json = serde_json::to_string_pretty(map).context("Failed to serialize cluster map")?;
139    std::fs::write(worktree_path.join(CLUSTERS_FILE), json)
140        .context("Failed to write cluster map")?;
141
142    let head_sha = Command::new("git")
143        .current_dir(repo_path)
144        .args(["rev-parse", "HEAD"])
145        .output()
146        .ok()
147        .and_then(|o| String::from_utf8(o.stdout).ok())
148        .map(|s| s.trim().to_string())
149        .unwrap_or_default();
150    std::fs::write(worktree_path.join(INDEXED_SHA_FILE), &head_sha)
151        .context("Failed to write indexed sha")?;
152
153    commit_worktree(repo_path, &worktree_path, "topology: update cluster map")?;
154    remove_worktree(repo_path, &worktree_path)?;
155
156    Ok(())
157}
158
159fn ensure_topology_branch(repo_path: &Path) -> Result<()> {
160    if branch_exists(repo_path) {
161        return Ok(());
162    }
163
164    let empty_tree = Command::new("git")
165        .current_dir(repo_path)
166        .args(["hash-object", "-t", "tree", "--stdin"])
167        .stdin(std::process::Stdio::null())
168        .output()
169        .context("Failed to create empty tree")?;
170
171    if !empty_tree.status.success() {
172        anyhow::bail!(
173            "Failed to create empty tree: {}",
174            String::from_utf8_lossy(&empty_tree.stderr)
175        );
176    }
177
178    let tree_sha = String::from_utf8_lossy(&empty_tree.stdout)
179        .trim()
180        .to_string();
181
182    let commit = Command::new("git")
183        .current_dir(repo_path)
184        .args([
185            "commit-tree",
186            &tree_sha,
187            "-m",
188            "init: create topology branch",
189        ])
190        .output()
191        .context("Failed to create initial commit")?;
192
193    if !commit.status.success() {
194        anyhow::bail!(
195            "Failed to create initial commit: {}",
196            String::from_utf8_lossy(&commit.stderr)
197        );
198    }
199
200    let commit_sha = String::from_utf8_lossy(&commit.stdout).trim().to_string();
201
202    let out = Command::new("git")
203        .current_dir(repo_path)
204        .args(["branch", TOPOLOGY_BRANCH, &commit_sha])
205        .output()
206        .context("Failed to create topology branch")?;
207
208    if !out.status.success() {
209        anyhow::bail!(
210            "Failed to create branch: {}",
211            String::from_utf8_lossy(&out.stderr)
212        );
213    }
214
215    Ok(())
216}
217
218fn setup_worktree(repo_path: &Path, worktree_path: &Path, branch: &str) -> Result<()> {
219    if worktree_path.exists() {
220        Command::new("git")
221            .current_dir(repo_path)
222            .args([
223                "worktree",
224                "remove",
225                "--force",
226                worktree_path.to_str().unwrap(),
227            ])
228            .output()
229            .ok();
230        std::fs::remove_dir_all(worktree_path).ok();
231        Command::new("git")
232            .current_dir(repo_path)
233            .args(["worktree", "prune"])
234            .output()
235            .ok();
236    }
237
238    let out = Command::new("git")
239        .current_dir(repo_path)
240        .args([
241            "worktree",
242            "add",
243            "--no-checkout",
244            worktree_path.to_str().unwrap(),
245            branch,
246        ])
247        .output()
248        .context("Failed to add topology worktree")?;
249
250    if !out.status.success() {
251        anyhow::bail!(
252            "Failed to set up topology worktree: {}",
253            String::from_utf8_lossy(&out.stderr)
254        );
255    }
256
257    Command::new("git")
258        .current_dir(worktree_path)
259        .args(["checkout", branch, "--", "."])
260        .output()
261        .ok();
262
263    Ok(())
264}
265
266fn commit_worktree(repo_path: &Path, worktree_path: &Path, message: &str) -> Result<()> {
267    Command::new("git")
268        .current_dir(worktree_path)
269        .args(["add", "-A"])
270        .output()
271        .context("Failed to stage topology files")?;
272
273    let status = Command::new("git")
274        .current_dir(worktree_path)
275        .args(["diff", "--cached", "--quiet"])
276        .status()
277        .context("Failed to check worktree status")?;
278
279    if status.success() {
280        return Ok(());
281    }
282
283    let head_sha = Command::new("git")
284        .current_dir(repo_path)
285        .args(["rev-parse", "--short", "HEAD"])
286        .output()
287        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
288        .unwrap_or_else(|_| "unknown".to_string());
289
290    let full_message = format!("{} ({})", message, head_sha);
291
292    let out = Command::new("git")
293        .current_dir(worktree_path)
294        .args(["commit", "-m", &full_message])
295        .output()
296        .context("Failed to commit topology branch")?;
297
298    if !out.status.success() {
299        anyhow::bail!(
300            "Failed to commit topology branch: {}",
301            String::from_utf8_lossy(&out.stderr)
302        );
303    }
304
305    Ok(())
306}
307
308fn remove_worktree(repo_path: &Path, worktree_path: &Path) -> Result<()> {
309    Command::new("git")
310        .current_dir(repo_path)
311        .args([
312            "worktree",
313            "remove",
314            "--force",
315            worktree_path.to_str().unwrap(),
316        ])
317        .output()
318        .context("Failed to remove topology worktree")?;
319
320    Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn make_cluster(id: &str, name: &str, files: &[&str]) -> Cluster {
328        Cluster {
329            id: id.to_string(),
330            name: name.to_string(),
331            description: String::new(),
332            files: files.iter().map(|f| f.to_string()).collect(),
333        }
334    }
335
336    #[test]
337    fn cluster_contains_file() {
338        let c = make_cluster("abc", "auth", &["src/auth/mod.rs", "src/auth/jwt.rs"]);
339        assert!(c.contains_file("src/auth/mod.rs"));
340        assert!(!c.contains_file("src/main.rs"));
341    }
342
343    #[test]
344    fn cluster_map_empty() {
345        let map = ClusterMap::empty();
346        assert_eq!(map.version, 1);
347        assert!(map.clusters.is_empty());
348    }
349
350    #[test]
351    fn clusters_for_files_matches() {
352        let map = ClusterMap {
353            version: 1,
354            clusters: vec![
355                make_cluster("a1", "auth", &["src/auth/mod.rs", "src/auth/jwt.rs"]),
356                make_cluster("b2", "db", &["src/db.rs", "src/models.rs"]),
357            ],
358        };
359
360        let files = vec!["src/auth/jwt.rs".to_string()];
361        let matched = map.clusters_for_files(&files);
362        assert_eq!(matched.len(), 1);
363        assert_eq!(matched[0].name, "auth");
364    }
365
366    #[test]
367    fn clusters_for_files_no_match() {
368        let map = ClusterMap {
369            version: 1,
370            clusters: vec![make_cluster("a1", "auth", &["src/auth/mod.rs"])],
371        };
372
373        let files = vec!["src/main.rs".to_string()];
374        let matched = map.clusters_for_files(&files);
375        assert!(matched.is_empty());
376    }
377
378    #[test]
379    fn clusters_for_files_multi_cluster_match() {
380        let map = ClusterMap {
381            version: 1,
382            clusters: vec![
383                make_cluster("a1", "auth", &["src/auth/mod.rs"]),
384                make_cluster("b2", "db", &["src/db.rs"]),
385            ],
386        };
387
388        let files = vec!["src/auth/mod.rs".to_string(), "src/db.rs".to_string()];
389        let matched = map.clusters_for_files(&files);
390        assert_eq!(matched.len(), 2);
391    }
392
393    #[test]
394    fn cluster_map_serialization_roundtrip() {
395        let map = ClusterMap {
396            version: 1,
397            clusters: vec![make_cluster(
398                "abc123",
399                "auth/middleware",
400                &["src/auth/mod.rs"],
401            )],
402        };
403
404        let json = serde_json::to_string(&map).unwrap();
405        let parsed: ClusterMap = serde_json::from_str(&json).unwrap();
406        assert_eq!(parsed.clusters.len(), 1);
407        assert_eq!(parsed.clusters[0].id, "abc123");
408        assert_eq!(parsed.clusters[0].name, "auth/middleware");
409    }
410}