vdev/utils/
git.rs

1//! Git utilities
2
3use std::{collections::HashSet, fs, path::Path, process::Command};
4
5use anyhow::{Context, Result, anyhow, bail};
6use git2::{BranchType, ErrorCode, Repository};
7
8use crate::app::CommandExt as _;
9
10/// Get the git HEAD tag if it exists
11pub fn git_head() -> Result<std::process::Output> {
12    Command::new("git")
13        .args(["describe", "--exact-match", "--tags", "HEAD"])
14        .output()
15        .context("Could not execute `git`")
16}
17
18/// Get the release channel from environment or default to "custom"
19pub fn get_channel() -> String {
20    std::env::var("CHANNEL").unwrap_or_else(|_| "custom".to_string())
21}
22
23pub fn current_branch() -> Result<String> {
24    let output = run_and_check_output(&["rev-parse", "--abbrev-ref", "HEAD"])?;
25    Ok(output.trim_end().to_string())
26}
27
28pub fn checkout_or_create_branch(branch_name: &str) -> Result<()> {
29    if branch_exists(branch_name)? {
30        checkout_branch(branch_name)?;
31    } else {
32        create_branch(branch_name)?;
33    }
34    Ok(())
35}
36
37pub fn changed_files() -> Result<Vec<String>> {
38    let mut files = HashSet::new();
39
40    // Committed e.g.:
41    // A   relative/path/to/file.added
42    // M   relative/path/to/file.modified
43    let output = run_and_check_output(&["diff", "--name-status", "origin/master..."])?;
44    for line in output.lines() {
45        if !is_warning_line(line)
46            && let Some((_, path)) = line.split_once('\t')
47        {
48            files.insert(path.to_string());
49        }
50    }
51
52    // Tracked
53    let output = run_and_check_output(&["diff", "--name-only", "HEAD"])?;
54    for line in output.lines() {
55        if !is_warning_line(line) {
56            files.insert(line.to_string());
57        }
58    }
59
60    // Untracked
61    let output = run_and_check_output(&["ls-files", "--others", "--exclude-standard"])?;
62    for line in output.lines() {
63        files.insert(line.to_string());
64    }
65
66    let mut sorted = Vec::from_iter(files);
67    sorted.sort();
68
69    Ok(sorted)
70}
71
72pub fn list_files() -> Result<Vec<String>> {
73    Ok(run_and_check_output(&["ls-files"])?
74        .lines()
75        .map(str::to_owned)
76        .collect())
77}
78
79pub fn get_git_sha() -> Result<String> {
80    run_and_check_output(&["rev-parse", "--short", "HEAD"])
81        .map(|output| output.trim_end().to_string())
82}
83
84/// Get a list of files that have been modified, as a vector of strings
85pub fn get_modified_files() -> Result<Vec<String>> {
86    let args = vec![
87        "ls-files",
88        "--full-name",
89        "--modified",
90        "--others",
91        "--exclude-standard",
92    ];
93    Ok(run_and_check_output(&args)?
94        .lines()
95        .map(str::to_owned)
96        .collect())
97}
98
99pub fn set_config_value(key: &str, value: &str) -> Result<String> {
100    Command::new("git")
101        .args(["config", key, value])
102        .stdout(std::process::Stdio::null())
103        .check_output()
104}
105
106/// Checks if the current directory's repo is clean
107pub fn check_git_repository_clean() -> Result<bool> {
108    Ok(Command::new("git")
109        .args(["diff-index", "--quiet", "HEAD"])
110        .stdout(std::process::Stdio::null())
111        .status()
112        .map(|status| status.success())?)
113}
114
115pub fn add_files_in_current_dir() -> Result<String> {
116    Command::new("git").args(["add", "."]).check_output()
117}
118
119/// Commits changes from the current repo
120pub fn commit(commit_message: &str) -> Result<String> {
121    Command::new("git")
122        .args(["commit", "--all", "--message", commit_message])
123        .check_output()
124}
125
126/// Removes a file from the index (and working tree) using `git rm`.
127pub fn rm(path: &str) -> Result<String> {
128    Command::new("git").args(["rm", path]).check_output()
129}
130
131/// Pushes changes from the current repo
132pub fn push() -> Result<String> {
133    Command::new("git").args(["push"]).check_output()
134}
135
136pub fn push_and_set_upstream(branch_name: &str) -> Result<String> {
137    Command::new("git")
138        .args(["push", "-u", "origin", branch_name])
139        .check_output()
140}
141
142pub fn clone(repo_url: &str) -> Result<String> {
143    // We cannot use capture_output since this will need to run in the CWD
144    Command::new("git").args(["clone", repo_url]).check_output()
145}
146
147/// Walks up from the current working directory until it finds a `.git`
148/// and opens that repo.  Panics (Err) if none is found.
149fn find_repo() -> Result<Repository, git2::Error> {
150    Repository::discover(".")
151}
152
153pub fn branch_exists(branch: &str) -> Result<bool> {
154    let repo = find_repo()?;
155
156    // Do the lookup inside its own scope so the temporary Branch<'_> is dropped
157    // before we try to drop `repo` at function exit.
158    let exists = {
159        match repo.find_branch(branch, BranchType::Local) {
160            Ok(_) => true,
161            Err(e) if e.code() == ErrorCode::NotFound => false,
162            Err(e) => bail!(e),
163        }
164    };
165
166    Ok(exists)
167}
168
169pub fn checkout_branch(branch_name: &str) -> Result<()> {
170    let _output = run_and_check_output(&["checkout", branch_name])?;
171    Ok(())
172}
173
174pub fn checkout_main_branch() -> Result<()> {
175    let _output = run_and_check_output(&["switch", "master"])?;
176    Ok(())
177}
178
179pub fn create_branch(branch_name: &str) -> Result<()> {
180    let repo = find_repo()?;
181
182    let head_ref = repo.head()?;
183    let target_oid = head_ref
184        .target()
185        .ok_or_else(|| anyhow!("HEAD is not pointing at a valid commit"))?;
186    let target_commit = repo.find_commit(target_oid)?;
187
188    let branch = repo.branch(branch_name, &target_commit, false)?;
189    let reference = branch.into_reference();
190    let full_ref_name = reference
191        .name()
192        .ok_or_else(|| git2::Error::from_str("branch reference has no name"))?;
193    repo.set_head(full_ref_name)?;
194    repo.checkout_head(None)?;
195
196    Ok(())
197}
198
199pub fn run_and_check_output(args: &[&str]) -> Result<String> {
200    Command::new("git").in_repo().args(args).check_output()
201}
202
203/// Sparse-checkout only `docs/generated` from a repo at the given commit.
204pub fn sparse_checkout_docs(sha: &str, repo_url: &str, clone_dir: &Path) -> Result<()> {
205    fs::create_dir_all(clone_dir)?;
206
207    let git = |args: &[&str]| -> Result<String> {
208        Command::new("git")
209            .current_dir(clone_dir)
210            .args(args)
211            .check_output()
212    };
213
214    git(&["init"])?;
215    git(&["remote", "add", "origin", repo_url])?;
216    git(&["config", "core.sparseCheckout", "true"])?;
217
218    let sparse_file = clone_dir.join(".git").join("info").join("sparse-checkout");
219    fs::create_dir_all(sparse_file.parent().unwrap())?;
220    fs::write(&sparse_file, "docs/generated\n")
221        .context("Failed to write sparse-checkout config")?;
222
223    git(&["fetch", "--depth", "1", "origin", sha])?;
224    git(&["checkout", "FETCH_HEAD"])?;
225
226    Ok(())
227}
228
229fn is_warning_line(line: &str) -> bool {
230    line.starts_with("warning: ") || line.contains("original line endings")
231}
232
233/// Returns a list of tracked files. If `pattern` is specified, it filters using that pattern.
234pub fn git_ls_files(pattern: Option<&str>) -> Result<Vec<String>> {
235    let args = match pattern {
236        Some(p) => vec!["ls-files", p],
237        None => vec!["ls-files"],
238    };
239
240    let output = run_and_check_output(&args)?;
241    Ok(output.lines().map(str::to_owned).collect())
242}