use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use semver::Version;
use crate::Result;
pub fn release(root: &Path, version: &Version, written: &BTreeSet<PathBuf>) -> Result<()> {
let tag = format!("v{version}");
reject_unwritten(root, written, &tag)?;
if !run(root, &["add", "."])? {
return Err(String::from(
"`git add .` failed; nothing was committed or tagged",
));
}
if !run(root, &["diff", "--cached", "--quiet"])? && !run(root, &["commit", "-m", &tag])? {
return Err(format!(
"`git commit -m {tag}` failed; nothing was tagged or pushed"
));
}
if !run(root, &["tag", "-a", &tag, "-m", &tag])? {
return Err(format!("`git tag -a {tag}` failed; nothing was pushed"));
}
for args in [&["push"][..], &["push", "--tags"][..]] {
if !run(root, args)? {
let step = format!("git {}", args.join(" "));
return Err(format!(
"`{step}` failed; {tag} is a tag on this machine only"
));
}
}
Ok(())
}
pub(crate) fn reject_unwritten(root: &Path, written: &BTreeSet<PathBuf>, tag: &str) -> Result<()> {
let done = capture(
root,
&["status", "--porcelain=v1", "-z", "--untracked-files=all"],
)?;
if !done.status.success() {
return Err(String::from(
"`git status` failed; the release did not start",
));
}
let mut offenders: Vec<String> = Vec::new();
for (_code, paths) in porcelain(&done.stdout) {
for path in paths {
if !written.contains(&root.join(&path)) && !offenders.contains(&path) {
offenders.push(path);
}
}
}
if !offenders.is_empty() {
return Err(format!(
"`git status` shows files this run did not write: {}\n commit or stash them before releasing {tag}",
offenders.join(", ")
));
}
Ok(())
}
fn porcelain(raw: &[u8]) -> Vec<(String, Vec<String>)> {
let mut entries = Vec::new();
let mut records = raw
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty());
while let Some(record) = records.next() {
let text = String::from_utf8_lossy(record);
if text.len() < 4 {
continue;
}
let (code, rest) = text.split_at(2);
let mut paths = vec![rest[1..].to_string()];
if matches!(code.as_bytes()[0], b'R' | b'C') {
if let Some(source) = records.next() {
paths.push(String::from_utf8_lossy(source).into_owned());
}
}
entries.push((code.to_string(), paths));
}
entries
}
fn run(root: &Path, args: &[&str]) -> Result<bool> {
Command::new("git")
.args(args)
.current_dir(root)
.status()
.map(|done| done.success())
.map_err(|err| format!("cannot run `git {}`: {err}", args[0]))
}
fn capture(root: &Path, args: &[&str]) -> Result<std::process::Output> {
Command::new("git")
.args(args)
.current_dir(root)
.output()
.map_err(|err| format!("cannot run `git {}`: {err}", args[0]))
}
#[cfg(test)]
mod tests {
use super::*;
const CONFIG: [&str; 8] = [
"-c",
"user.name=semver-sync",
"-c",
"user.email=semver-sync@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgSign=false",
];
fn git(dir: &Path, args: &[&str]) -> String {
let done = Command::new("git")
.args(CONFIG)
.args(args)
.current_dir(dir)
.output()
.expect("run git");
assert!(
done.status.success(),
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&done.stderr)
);
String::from_utf8_lossy(&done.stdout).trim_end().to_string()
}
fn a_repo_with_somewhere_to_push(dir: &Path) -> (PathBuf, PathBuf) {
let repo = dir.join("repo");
let remote = dir.join("remote.git");
std::fs::create_dir_all(&repo).expect("create the repo dir");
let remote_name = remote.to_string_lossy().to_string();
git(dir, &["init", "--bare", "-q", &remote_name]);
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("Cargo.toml"), "[package]\n").expect("commit a file");
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "one commit"]);
git(&repo, &["remote", "add", "origin", &remote_name]);
git(&repo, &["push", "-q", "--set-upstream", "origin", "main"]);
(repo, remote)
}
fn a_run_wrote_the_manifest(repo: &Path) -> BTreeSet<PathBuf> {
std::fs::write(repo.join("Cargo.toml"), "[package]\nversion = \"1.2.3\"\n")
.expect("rewrite the manifest");
[repo.join("Cargo.toml")].into_iter().collect()
}
fn version() -> Version {
Version::parse("1.2.3").expect("a version")
}
#[test]
fn releasing_tags_the_version_and_sends_the_tag() {
let dirs = tempfile::tempdir().expect("temp dir");
let (repo, remote) = a_repo_with_somewhere_to_push(dirs.path());
release(&repo, &version(), &BTreeSet::new()).expect("release");
assert_eq!(git(&remote, &["tag", "--list", "v1.2.3"]), "v1.2.3");
assert_eq!(git(&repo, &["cat-file", "-t", "v1.2.3"]), "tag");
assert_eq!(
git(
&repo,
&["for-each-ref", "refs/tags/v1.2.3", "--format=%(contents)"]
),
"v1.2.3"
);
assert_eq!(git(&repo, &["log", "-1", "--format=%s"]), "one commit");
}
#[test]
fn releasing_commits_what_the_run_wrote_and_tags_that_commit() {
let dirs = tempfile::tempdir().expect("temp dir");
let (repo, remote) = a_repo_with_somewhere_to_push(dirs.path());
let written = a_run_wrote_the_manifest(&repo);
release(&repo, &version(), &written).expect("release");
assert_eq!(git(&repo, &["log", "-1", "--format=%s"]), "v1.2.3");
assert_eq!(
git(&repo, &["show", "v1.2.3:Cargo.toml"]),
"[package]\nversion = \"1.2.3\""
);
assert_eq!(
git(&remote, &["log", "-n", "1", "--format=%s", "main"]),
"v1.2.3"
);
}
#[test]
fn release_stops_at_files_the_run_did_not_write() {
let dirs = tempfile::tempdir().expect("temp dir");
let (repo, remote) = a_repo_with_somewhere_to_push(dirs.path());
let written = a_run_wrote_the_manifest(&repo);
std::fs::write(repo.join("scratch.rs"), "fn main() {}\n").expect("leave work in progress");
std::fs::write(repo.join("notes.md"), "mine\n").expect("leave work staged");
git(&repo, &["add", "notes.md"]);
let err =
release(&repo, &version(), &written).expect_err("the tree is not the run's alone");
assert!(err.contains("scratch.rs"), "{err}");
assert!(err.contains("notes.md"), "{err}");
assert!(!err.contains("Cargo.toml"), "{err}");
assert_eq!(git(&repo, &["log", "-1", "--format=%s"]), "one commit");
assert_eq!(git(&repo, &["tag", "--list"]), "");
assert_eq!(git(&remote, &["tag", "--list"]), "");
}
#[test]
fn a_repo_with_nowhere_to_push_leaves_the_tag_local() {
let dirs = tempfile::tempdir().expect("temp dir");
let (repo, _) = a_repo_with_somewhere_to_push(dirs.path());
git(&repo, &["remote", "remove", "origin"]);
let err = release(&repo, &version(), &BTreeSet::new())
.expect_err("a push with no remote cannot work");
assert!(err.contains("git push"), "{err}");
assert_eq!(git(&repo, &["tag", "--list", "v1.2.3"]), "v1.2.3");
}
#[test]
fn porcelain_pairs_a_rename_with_the_record_behind_it() {
let entries = porcelain(b"R new.rs\0old.rs\0?? loose.txt\0M staged.rs\0");
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].1, vec!["new.rs", "old.rs"]);
assert_eq!(entries[1].1, vec!["loose.txt"]);
assert_eq!(entries[2].1, vec!["staged.rs"]);
}
}