use super::common::{
fail, fixing_enabled, hl, ok, repo_root, restage, run as run_tool, staged_files, warn, which,
Restaged,
};
use crate::check::Outcome;
use crate::git;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
pub const GO_PATHS: &[&str] = &[".go", "go.mod", "go.sum"];
pub const EXTS: &[&str] = &[".go"];
fn is_go_path(f: &str) -> bool {
let name = f.rsplit('/').next().unwrap_or(f);
GO_PATHS.iter().any(|pattern| {
if pattern.starts_with('.') {
name.ends_with(pattern)
} else {
name == *pattern
}
})
}
fn module_root_for(root: &str, file: &str) -> Option<PathBuf> {
let mut dir = Path::new(root).join(file);
dir.pop();
loop {
if dir.join("go.mod").is_file() {
return Some(dir);
}
if dir == Path::new(root) || !dir.starts_with(root) {
return None;
}
if !dir.pop() {
return None;
}
}
}
fn module_roots<'a>(root: &str, files: impl Iterator<Item = &'a str>) -> Vec<PathBuf> {
let mut seen = BTreeSet::new();
for f in files.filter(|f| is_go_path(f)) {
if let Some(d) = module_root_for(root, f) {
seen.insert(d);
}
}
seen.into_iter().collect()
}
fn run_in_roots(roots: &[PathBuf], argv: &[String], args: &[&str]) -> bool {
let extra: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
let mut all_ok = true;
for dir in roots {
let d = dir.to_string_lossy().into_owned();
if !run_tool(&d, argv, &extra) {
all_ok = false;
}
}
all_ok
}
fn unformatted(root: &str, gofmt: &str, files: &[String]) -> Option<Vec<String>> {
let mut cmd = std::process::Command::new(gofmt);
cmd.arg("-l")
.args(files)
.current_dir(root)
.stdin(std::process::Stdio::null());
super::common::strip_git_env(&mut cmd);
let (ran, out) = super::common::capture_within(&mut cmd)?;
match ran {
super::common::Ran::TimedOut(budget) => {
super::common::say_timed_out(gofmt, budget);
None
}
super::common::Ran::Status(s) if !s.success() => {
for line in out.lines() {
crate::say!("{line}");
}
Some(files.to_vec())
}
super::common::Ran::Status(_) => Some(
out.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.collect(),
),
}
}
pub fn fmt(_args: &[std::ffi::OsString]) -> Outcome {
let files = staged_files(EXTS);
if files.is_empty() {
return Outcome::Passed;
}
let root = repo_root();
if module_roots(&root, files.iter().map(String::as_str)).is_empty() {
return Outcome::Passed;
}
let Some(gofmt) = which("gofmt") else {
warn("Go staged but gofmt is not installed — install the Go toolchain.");
return Outcome::Unavailable;
};
let Some(dirty) = unformatted(&root, &gofmt, &files) else {
return Outcome::Unavailable;
};
if dirty.is_empty() {
ok("Go formatting is clean");
return Outcome::Passed;
}
if fixing_enabled() {
let argv = vec![gofmt.clone()];
let mut args: Vec<&str> = vec!["-w"];
args.extend(dirty.iter().map(String::as_str));
if run_in_roots(&[PathBuf::from(&root)], &argv, &args) {
match restage(&files) {
Restaged::Staged => {
ok("Go reformatted and re-staged");
return Outcome::Fixed;
}
Restaged::Failed(stuck) => {
fail(&format!(
"gofmt rewrote these files but {} failed — the index still holds the \
UNFORMATTED content: {}",
hl("git add"),
stuck.join(", ")
));
return Outcome::Failed;
}
Restaged::Nothing => {}
}
}
}
fail(&format!(
"Unformatted Go: {}. Run {}.",
dirty.join(", "),
hl("gofmt -w .")
));
Outcome::Failed
}
pub fn vet(_args: &[std::ffi::OsString]) -> Outcome {
let files: Vec<String> = staged_files(&[])
.into_iter()
.filter(|f| is_go_path(f))
.collect();
if files.is_empty() {
return Outcome::Passed;
}
let root = repo_root();
let roots = module_roots(&root, files.iter().map(String::as_str));
if roots.is_empty() {
return Outcome::Passed;
}
let Some(go) = which("go") else {
warn("Go staged but the go toolchain is not installed.");
return Outcome::Unavailable;
};
if run_in_roots(&roots, &[go], &["vet", "./..."]) {
ok("go vet passed");
Outcome::Passed
} else {
fail(&format!("{} found problems.", hl("go vet")));
Outcome::Failed
}
}
pub fn test(refs: &[crate::pushrefs::PushRef]) -> Outcome {
let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
warn("go-test: git would not answer — the gate did NOT run");
return Outcome::Unavailable;
};
let zero = git::stdout(&["hash-object", "--stdin"])
.map(|h| "0".repeat(h.len()))
.unwrap_or_else(|| "0".repeat(40));
let mut ran_any = false;
for r in refs {
let changed = crate::pushrefs::changed_files_for(r, &zero);
let roots = module_roots(&root, changed.iter().map(String::as_str));
if roots.is_empty() {
continue;
}
let Some(go) = which("go") else {
warn("Go changed but the go toolchain is not installed — the gate did NOT run");
return Outcome::Unavailable;
};
let (where_, _guard) = crate::pushed_tree::where_to_run(&r.local_oid, &root);
let roots: Vec<PathBuf> = roots
.iter()
.map(|rt| {
rt.strip_prefix(&root)
.map(|rel| where_.join(rel))
.unwrap_or_else(|_| rt.clone())
})
.collect();
if !run_in_roots(&roots, std::slice::from_ref(&go), &["test", "./..."]) {
fail("Go tests failed. Push aborted.");
return Outcome::Failed;
}
ran_any = true;
}
if ran_any {
ok("Go tests passed");
}
Outcome::Passed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_go_paths() {
assert!(is_go_path("cmd/api/main.go"));
assert!(is_go_path("go.mod"));
assert!(is_go_path("services/auth/go.sum"));
assert!(!is_go_path("README.md"));
assert!(!is_go_path("main.gone"));
assert!(!is_go_path("docs/go.mod.md"));
assert!(!is_go_path("vendor/Notgo.mod"));
}
#[test]
fn finds_the_nearest_module_not_the_repo_root() {
let tmp = std::env::temp_dir().join("amont-go-roots");
let _ = std::fs::remove_dir_all(&tmp);
let nested = tmp.join("services/api");
std::fs::create_dir_all(nested.join("cmd")).unwrap();
std::fs::write(nested.join("go.mod"), "module api\n").unwrap();
let root = tmp.to_string_lossy().into_owned();
let got = module_roots(&root, ["services/api/cmd/main.go"].into_iter());
assert_eq!(got, vec![nested.clone()], "should find the nested module");
std::fs::create_dir_all(tmp.join("scripts")).unwrap();
let none = module_roots(&root, ["scripts/loose.go"].into_iter());
assert!(none.is_empty(), "no module above it: {none:?}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn several_files_in_one_module_yield_one_root() {
let tmp = std::env::temp_dir().join("amont-go-dedupe");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("pkg")).unwrap();
std::fs::write(tmp.join("go.mod"), "module x\n").unwrap();
let root = tmp.to_string_lossy().into_owned();
let got = module_roots(&root, ["pkg/a.go", "pkg/b.go", "go.mod"].into_iter());
assert_eq!(got.len(), 1, "one go invocation, not three: {got:?}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn non_go_files_select_nothing() {
let got = module_roots("/tmp", ["README.md", "a.rs"].into_iter());
assert!(got.is_empty());
}
}