Skip to main content

migrate_guard/
git.rs

1//! Shells to the `git` binary. No git library.
2
3use crate::error::{GuardError, Result};
4use crate::{parse_filename, MigrationFile};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8/// The `git` program + repo working directory.
9pub struct GitCmd {
10    pub program: String,
11    pub repo: PathBuf,
12}
13
14impl GitCmd {
15    pub fn new(repo: impl Into<PathBuf>) -> Self {
16        Self {
17            program: "git".to_string(),
18            repo: repo.into(),
19        }
20    }
21
22    fn run(&self, args: &[&str]) -> Result<String> {
23        let out = Command::new(&self.program)
24            .args(args)
25            .current_dir(&self.repo)
26            .output()?;
27        if !out.status.success() {
28            return Err(GuardError::Git {
29                cmd: format!("{} {}", self.program, args.join(" ")),
30                status: out.status.code(),
31                stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
32            });
33        }
34        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
35    }
36}
37
38/// Local branch short names.
39pub fn local_branches(git: &GitCmd) -> Result<Vec<String>> {
40    let out = git.run(&["for-each-ref", "--format=%(refname:short)", "refs/heads/"])?;
41    Ok(out
42        .lines()
43        .map(|l| l.trim().to_string())
44        .filter(|l| !l.is_empty())
45        .collect())
46}
47
48/// MigrationFiles of `dir` as they exist on `branch`.
49pub fn branch_migration_files(
50    git: &GitCmd,
51    branch: &str,
52    dir: &Path,
53) -> Result<Vec<MigrationFile>> {
54    // Verify the ref resolves to a commit first: a bad base_ref must be a loud
55    // error, not silently treated as "no migrations" (which would over-renumber
56    // or miss collisions). A VALID ref whose tree simply lacks `dir` returns
57    // exit 0 + empty stdout from ls-tree, which is the real tolerated case.
58    if git
59        .run(&[
60            "rev-parse",
61            "--verify",
62            "--quiet",
63            &format!("{branch}^{{commit}}"),
64        ])
65        .is_err()
66    {
67        return Err(GuardError::Git {
68            cmd: format!("git rev-parse --verify {branch}"),
69            status: None,
70            stderr: format!("ref '{branch}' does not resolve to a commit"),
71        });
72    }
73    let dir_str = dir.to_string_lossy();
74    let out = git.run(&["ls-tree", "-r", "--name-only", branch, "--", &dir_str])?;
75    Ok(files_from_paths(&out, dir))
76}
77
78/// MigrationFiles present in the working tree's `dir` but absent from `base_ref`.
79pub fn topic_new_files(git: &GitCmd, base_ref: &str, dir: &Path) -> Result<Vec<MigrationFile>> {
80    let base = branch_migration_files(git, base_ref, dir)?;
81    let base_names: std::collections::HashSet<_> =
82        base.iter().map(|f| f.filename.clone()).collect();
83    let working = crate::scan_dir("", &git.repo.join(dir))?; // role irrelevant here
84    Ok(working
85        .files
86        .into_iter()
87        .filter(|f| !base_names.contains(&f.filename))
88        .collect())
89}
90
91fn files_from_paths(ls_tree_out: &str, dir: &Path) -> Vec<MigrationFile> {
92    let mut files = Vec::new();
93    for line in ls_tree_out.lines() {
94        let p = Path::new(line.trim());
95        let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
96            continue;
97        };
98        if let Some((version, width, desc)) = parse_filename(name) {
99            files.push(MigrationFile {
100                version,
101                width,
102                description: desc.to_string(),
103                filename: name.to_string(),
104                path: dir.join(name),
105            });
106        }
107    }
108    files.sort_by_key(|f| f.version);
109    files
110}