vdev/utils/
git.rs

1//! Git utilities
2
3use std::{collections::HashSet, 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 merge_branch(branch_name: &str) -> Result<()> {
38    let _output = run_and_check_output(&["merge", "--ff", branch_name])?;
39    Ok(())
40}
41
42pub fn tag_version(version: &str) -> Result<()> {
43    let _output = run_and_check_output(&["tag", "--annotate", version, "--message", version])?;
44    Ok(())
45}
46
47pub fn push_branch(branch_name: &str) -> Result<()> {
48    let _output = run_and_check_output(&["push", "origin", branch_name])?;
49    Ok(())
50}
51
52pub fn changed_files() -> Result<Vec<String>> {
53    let mut files = HashSet::new();
54
55    // Committed e.g.:
56    // A   relative/path/to/file.added
57    // M   relative/path/to/file.modified
58    let output = run_and_check_output(&["diff", "--name-status", "origin/master..."])?;
59    for line in output.lines() {
60        if !is_warning_line(line)
61            && let Some((_, path)) = line.split_once('\t')
62        {
63            files.insert(path.to_string());
64        }
65    }
66
67    // Tracked
68    let output = run_and_check_output(&["diff", "--name-only", "HEAD"])?;
69    for line in output.lines() {
70        if !is_warning_line(line) {
71            files.insert(line.to_string());
72        }
73    }
74
75    // Untracked
76    let output = run_and_check_output(&["ls-files", "--others", "--exclude-standard"])?;
77    for line in output.lines() {
78        files.insert(line.to_string());
79    }
80
81    let mut sorted = Vec::from_iter(files);
82    sorted.sort();
83
84    Ok(sorted)
85}
86
87pub fn list_files() -> Result<Vec<String>> {
88    Ok(run_and_check_output(&["ls-files"])?
89        .lines()
90        .map(str::to_owned)
91        .collect())
92}
93
94pub fn get_git_sha() -> Result<String> {
95    run_and_check_output(&["rev-parse", "--short", "HEAD"])
96        .map(|output| output.trim_end().to_string())
97}
98
99/// Get a list of files that have been modified, as a vector of strings
100pub fn get_modified_files() -> Result<Vec<String>> {
101    let args = vec![
102        "ls-files",
103        "--full-name",
104        "--modified",
105        "--others",
106        "--exclude-standard",
107    ];
108    Ok(run_and_check_output(&args)?
109        .lines()
110        .map(str::to_owned)
111        .collect())
112}
113
114pub fn set_config_value(key: &str, value: &str) -> Result<String> {
115    Command::new("git")
116        .args(["config", key, value])
117        .stdout(std::process::Stdio::null())
118        .check_output()
119}
120
121/// Checks if the current directory's repo is clean
122pub fn check_git_repository_clean() -> Result<bool> {
123    Ok(Command::new("git")
124        .args(["diff-index", "--quiet", "HEAD"])
125        .stdout(std::process::Stdio::null())
126        .status()
127        .map(|status| status.success())?)
128}
129
130pub fn add_files_in_current_dir() -> Result<String> {
131    Command::new("git").args(["add", "."]).check_output()
132}
133
134/// Commits changes from the current repo
135pub fn commit(commit_message: &str) -> Result<String> {
136    Command::new("git")
137        .args(["commit", "--all", "--message", commit_message])
138        .check_output()
139}
140
141/// Pushes changes from the current repo
142pub fn push() -> Result<String> {
143    Command::new("git").args(["push"]).check_output()
144}
145
146pub fn push_and_set_upstream(branch_name: &str) -> Result<String> {
147    Command::new("git")
148        .args(["push", "-u", "origin", branch_name])
149        .check_output()
150}
151
152pub fn clone(repo_url: &str) -> Result<String> {
153    // We cannot use capture_output since this will need to run in the CWD
154    Command::new("git").args(["clone", repo_url]).check_output()
155}
156
157/// Walks up from the current working directory until it finds a `.git`
158/// and opens that repo.  Panics (Err) if none is found.
159fn find_repo() -> Result<Repository, git2::Error> {
160    Repository::discover(".")
161}
162
163pub fn branch_exists(branch: &str) -> Result<bool> {
164    let repo = find_repo()?;
165
166    // Do the lookup inside its own scope so the temporary Branch<'_> is dropped
167    // before we try to drop `repo` at function exit.
168    let exists = {
169        match repo.find_branch(branch, BranchType::Local) {
170            Ok(_) => true,
171            Err(e) if e.code() == ErrorCode::NotFound => false,
172            Err(e) => bail!(e),
173        }
174    };
175
176    Ok(exists)
177}
178
179pub fn checkout_branch(branch_name: &str) -> Result<()> {
180    let _output = run_and_check_output(&["checkout", branch_name])?;
181    Ok(())
182}
183
184pub fn checkout_main_branch() -> Result<()> {
185    let _output = run_and_check_output(&["switch", "master"])?;
186    Ok(())
187}
188
189pub fn create_branch(branch_name: &str) -> Result<()> {
190    let repo = find_repo()?;
191
192    let head_ref = repo.head()?;
193    let target_oid = head_ref
194        .target()
195        .ok_or_else(|| anyhow!("HEAD is not pointing at a valid commit"))?;
196    let target_commit = repo.find_commit(target_oid)?;
197
198    let branch = repo.branch(branch_name, &target_commit, false)?;
199    let reference = branch.into_reference();
200    let full_ref_name = reference
201        .name()
202        .ok_or_else(|| git2::Error::from_str("branch reference has no name"))?;
203    repo.set_head(full_ref_name)?;
204    repo.checkout_head(None)?;
205
206    Ok(())
207}
208
209pub fn run_and_check_output(args: &[&str]) -> Result<String> {
210    Command::new("git").in_repo().args(args).check_output()
211}
212
213fn is_warning_line(line: &str) -> bool {
214    line.starts_with("warning: ") || line.contains("original line endings")
215}
216
217/// Returns a list of tracked files. If `pattern` is specified, it filters using that pattern.
218pub fn git_ls_files(pattern: Option<&str>) -> Result<Vec<String>> {
219    let args = match pattern {
220        Some(p) => vec!["ls-files", p],
221        None => vec!["ls-files"],
222    };
223
224    let output = run_and_check_output(&args)?;
225    Ok(output.lines().map(str::to_owned).collect())
226}