cargo_release/ops/
git.rs

1use std::path::Path;
2use std::path::PathBuf;
3use std::process::Command;
4
5use bstr::ByteSlice;
6
7use crate::error::CargoResult;
8use crate::ops::cmd::call_on_path;
9
10pub fn fetch(dir: &Path, remote: &str, branch: &str) -> CargoResult<()> {
11    Command::new("git")
12        .arg("fetch")
13        .arg(remote)
14        .arg(branch)
15        .current_dir(dir)
16        .output()
17        .map(|_| ())
18        .map_err(|_| anyhow::format_err!("`git` not found"))
19}
20
21pub fn is_behind_remote(dir: &Path, remote: &str, branch: &str) -> CargoResult<bool> {
22    let repo = git2::Repository::discover(dir)?;
23
24    let branch_id = repo.revparse_single(branch)?.id();
25
26    let remote_branch = format!("{remote}/{branch}");
27    let behind = match repo.revparse_single(&remote_branch) {
28        Ok(o) => {
29            let remote_branch_id = o.id();
30
31            let base_id = repo.merge_base(remote_branch_id, branch_id)?;
32
33            log::trace!("{remote_branch}: {remote_branch_id}");
34            log::trace!("merge base: {base_id}");
35
36            base_id != remote_branch_id
37        }
38        Err(err) => {
39            let _ = crate::ops::shell::warn(format!("push target `{remote_branch}` doesn't exist"));
40            log::trace!("error {err}");
41            false
42        }
43    };
44
45    Ok(behind)
46}
47
48pub fn is_local_unchanged(dir: &Path, remote: &str, branch: &str) -> CargoResult<bool> {
49    let repo = git2::Repository::discover(dir)?;
50
51    let branch_id = repo.revparse_single(branch)?.id();
52
53    let remote_branch = format!("{remote}/{branch}");
54    let unchanged = match repo.revparse_single(&remote_branch) {
55        Ok(o) => {
56            let remote_branch_id = o.id();
57
58            let base_id = repo.merge_base(remote_branch_id, branch_id)?;
59
60            log::trace!("{remote_branch}: {remote_branch_id}");
61            log::trace!("merge base: {base_id}");
62
63            base_id == branch_id
64        }
65        Err(err) => {
66            let _ = crate::ops::shell::warn(format!("push target `{remote_branch}` doesn't exist"));
67            log::trace!("error {err}");
68            false
69        }
70    };
71
72    Ok(unchanged)
73}
74
75pub fn current_branch(dir: &Path) -> CargoResult<String> {
76    let repo = git2::Repository::discover(dir)?;
77
78    let resolved = repo.head()?.resolve()?;
79    let name = resolved.shorthand().unwrap_or("HEAD");
80    Ok(name.to_owned())
81}
82
83pub fn is_dirty(dir: &Path) -> CargoResult<Option<Vec<String>>> {
84    let repo = git2::Repository::discover(dir)?;
85
86    let mut entries = Vec::new();
87
88    let state = repo.state();
89    let dirty_state = state != git2::RepositoryState::Clean;
90    if dirty_state {
91        entries.push(format!("Dirty because of state {state:?}"));
92    }
93
94    let mut options = git2::StatusOptions::new();
95    options
96        .show(git2::StatusShow::IndexAndWorkdir)
97        .include_untracked(true);
98    let statuses = repo.statuses(Some(&mut options))?;
99    let dirty_tree = !statuses.is_empty();
100    if dirty_tree {
101        for status in statuses.iter() {
102            let path = bytes2path(status.path_bytes());
103            entries.push(format!("{} ({:?})", path.display(), status.status()));
104        }
105    }
106
107    if entries.is_empty() {
108        Ok(None)
109    } else {
110        Ok(Some(entries))
111    }
112}
113
114pub fn changed_files(dir: &Path, tag: &str) -> CargoResult<Option<Vec<PathBuf>>> {
115    let root = top_level(dir)?;
116
117    let output = Command::new("git")
118        .arg("diff")
119        .arg(format!("{tag}..HEAD"))
120        .arg("--name-only")
121        .arg("--exit-code")
122        .arg("--")
123        .arg(".")
124        .current_dir(dir)
125        .output()?;
126    match output.status.code() {
127        Some(0) => Ok(Some(Vec::new())),
128        Some(1) => {
129            let paths = output
130                .stdout
131                .lines()
132                .map(|l| root.join(l.to_path_lossy()))
133                .collect();
134            Ok(Some(paths))
135        }
136        _ => Ok(None), // For cases like non-existent tag
137    }
138}
139
140pub fn commit_all(dir: &Path, msg: &str, sign: bool, dry_run: bool) -> CargoResult<bool> {
141    let repo = git2::Repository::discover(dir)?;
142    let mut options = git2::StatusOptions::new();
143    options
144        .show(git2::StatusShow::IndexAndWorkdir)
145        .include_untracked(true);
146    let statuses = repo.statuses(Some(&mut options))?;
147    let dirty_tree = !statuses.is_empty();
148
149    if dirty_tree || dry_run {
150        call_on_path(
151            vec!["git", "commit", if sign { "-S" } else { "" }, "-am", msg],
152            dir,
153            dry_run,
154        )
155    } else {
156        log::debug!("No files changed, skipping commit");
157        Ok(true)
158    }
159}
160
161pub fn tag(dir: &Path, name: &str, msg: &str, sign: bool, dry_run: bool) -> CargoResult<bool> {
162    let mut cmd = vec!["git", "tag", name];
163    if !msg.is_empty() {
164        cmd.extend(["-a", "-m", msg]);
165        if sign {
166            cmd.push("-s");
167        }
168    }
169    call_on_path(cmd, dir, dry_run)
170}
171
172pub fn tag_exists(dir: &Path, name: &str) -> CargoResult<bool> {
173    let repo = git2::Repository::discover(dir)?;
174
175    let names = repo.tag_names(Some(name))?;
176    Ok(!names.is_empty())
177}
178
179pub fn find_last_tag(dir: &Path, glob: &globset::GlobMatcher) -> Option<String> {
180    let repo = git2::Repository::discover(dir).ok()?;
181    let mut tags: std::collections::HashMap<git2::Oid, String> = Default::default();
182    repo.tag_foreach(|id, name| {
183        let name = String::from_utf8_lossy(name);
184        let name = name.strip_prefix("refs/tags/").unwrap_or(&name);
185        if glob.is_match(name) {
186            let name = name.to_owned();
187            let tag = repo.find_tag(id);
188            let target = tag.and_then(|t| t.target());
189            let commit = target.and_then(|t| t.peel_to_commit());
190            if let Ok(commit) = commit {
191                tags.insert(commit.id(), name);
192            }
193        }
194        true
195    })
196    .ok()?;
197
198    let mut revwalk = repo.revwalk().ok()?;
199    revwalk.simplify_first_parent().ok()?;
200    // If just walking first parents, shouldn't really need to sort
201    revwalk.set_sorting(git2::Sort::NONE).ok()?;
202    revwalk.push_head().ok()?;
203    let name = revwalk.find_map(|id| {
204        let id = id.ok()?;
205        tags.remove(&id)
206    })?;
207    Some(name)
208}
209
210pub fn push<'s>(
211    dir: &Path,
212    remote: &str,
213    refs: impl IntoIterator<Item = &'s str>,
214    options: impl IntoIterator<Item = &'s str>,
215    dry_run: bool,
216) -> CargoResult<bool> {
217    // Use an atomic push to ensure that e.g. if main and a tag are pushed together, and the local
218    // main diverges from the remote main, that the push fails entirely.
219    let mut command = vec!["git", "push", "--atomic"];
220
221    for option in options {
222        command.push("--push-option");
223        command.push(option);
224    }
225
226    command.push(remote);
227
228    let mut is_empty = true;
229    for ref_ in refs {
230        command.push(ref_);
231        is_empty = false;
232    }
233    if is_empty {
234        return Ok(true);
235    }
236
237    call_on_path(command, dir, dry_run)
238}
239
240pub fn top_level(dir: &Path) -> CargoResult<PathBuf> {
241    let repo = git2::Repository::discover(dir)?;
242
243    repo.workdir()
244        .map(|p| p.to_owned())
245        .ok_or_else(|| anyhow::format_err!("bare repos are unsupported"))
246}
247
248pub fn git_version() -> CargoResult<()> {
249    Command::new("git")
250        .arg("--version")
251        .output()
252        .map(|_| ())
253        .map_err(|_| anyhow::format_err!("`git` not found"))
254}
255
256// From git2 crate
257#[cfg(unix)]
258pub fn bytes2path(b: &[u8]) -> &Path {
259    use std::os::unix::prelude::OsStrExt;
260    Path::new(std::ffi::OsStr::from_bytes(b))
261}
262
263// From git2 crate
264#[cfg(windows)]
265pub fn bytes2path(b: &[u8]) -> &std::path::Path {
266    use std::str;
267    std::path::Path::new(str::from_utf8(b).unwrap())
268}