use crate::{audit, manifest::Manifest, paths, proc};
use anyhow::Result;
fn looks_like_git_push(cmd: &[String]) -> bool {
git_subcommand(cmd).is_some_and(|i| cmd[i] == "push")
}
fn git_subcommand(cmd: &[String]) -> Option<usize> {
const TAKES_VALUE: &[&str] = &[
"-C",
"-c",
"--git-dir",
"--work-tree",
"--namespace",
"--config-env",
"--super-prefix",
];
if cmd.first().map(String::as_str) != Some("git") {
return None;
}
let mut i = 1;
while i < cmd.len() {
let a = cmd[i].as_str();
if TAKES_VALUE.contains(&a) {
i += 2;
} else if a.starts_with('-') {
i += 1;
} else {
return Some(i);
}
}
None
}
pub fn run(name: &str, cmd: &[String]) -> Result<()> {
if cmd.is_empty() {
anyhow::bail!("usage: moor run <project> -- <command...>");
}
let m = Manifest::load(&paths::manifest_path(name)?)?;
crate::secrets::resolve_into_env(name, &m.secrets);
let container = m.sandbox_container();
let pushed = looks_like_git_push(cmd).then(|| {
let (dir, git_ref) = push_target(cmd);
let commit = resolve_commit(&container, &dir, &git_ref);
push_data(&git_ref, commit.as_deref())
});
let mut args: Vec<&str> = vec!["exec", &container];
args.extend(cmd.iter().map(|s| s.as_str()));
let status = proc::run_inherit("docker", &args)?;
match pushed {
Some(data) => audit::log_exec_with(name, &m, "push", cmd, status.code(), data)?,
None => audit::log_exec(name, &m, "exec", cmd, status.code())?,
}
proc::require_success(&format!("`{}` in {container}", cmd.join(" ")), status)?;
Ok(())
}
fn push_target(cmd: &[String]) -> (String, String) {
let sub = git_subcommand(cmd).unwrap_or(cmd.len());
let mut dir = "/workspace".to_string();
let mut globals = cmd[1..sub].iter();
while let Some(a) = globals.next() {
if a == "-C" {
if let Some(d) = globals.next() {
dir = d.clone();
}
}
}
let args = cmd.iter().skip(sub + 1);
let positional: Vec<&String> = args.filter(|a| !a.starts_with('-')).collect();
let git_ref = positional
.get(1)
.map(|spec| {
spec.trim_start_matches('+')
.split(':')
.next()
.unwrap_or("")
.to_string()
})
.filter(|r| !r.is_empty())
.unwrap_or_else(|| "HEAD".to_string());
(dir, git_ref)
}
fn resolve_commit(container: &str, dir: &str, git_ref: &str) -> Option<String> {
let rev = format!("{git_ref}^{{commit}}");
let (status, out) = proc::run_capture(
"docker",
&[
"exec",
container,
"git",
"-C",
dir,
"rev-parse",
"--verify",
"--quiet",
&rev,
],
)
.ok()?;
let sha = out.trim();
(status.success() && sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()))
.then(|| sha.to_string())
}
fn push_data(git_ref: &str, commit: Option<&str>) -> serde_json::Value {
serde_json::json!({ "ref": git_ref, "commit": commit })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_plain_git_push() {
assert!(looks_like_git_push(&["git".into(), "push".into()]));
assert!(looks_like_git_push(&[
"git".into(),
"push".into(),
"origin".into(),
"main".into()
]));
}
#[test]
fn does_not_flag_other_git_commands() {
assert!(!looks_like_git_push(&[
"git".into(),
"clone".into(),
"x".into()
]));
assert!(!looks_like_git_push(&["git".into(), "fetch".into()]));
assert!(!looks_like_git_push(&["git".into(), "status".into()]));
}
#[test]
fn a_push_after_global_options_is_a_push() {
let argv = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
assert!(looks_like_git_push(&argv(
"git -C /workspace push origin main"
)));
assert!(looks_like_git_push(&argv(
"git -c credential.helper=!f push -u origin feature"
)));
assert_eq!(
push_target(&argv("git -c x=y -C /repo push origin feature")),
("/repo".to_string(), "feature".to_string())
);
}
#[test]
fn a_push_word_after_another_subcommand_is_not_a_push() {
let argv = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
for cmd in [
"git stash push -m wip -- src/a.rs",
"git commit -m push",
"git log --grep push",
"git -C push status",
] {
assert!(!looks_like_git_push(&argv(cmd)), "{cmd}");
}
}
#[test]
fn does_not_flag_non_git_commands() {
assert!(!looks_like_git_push(&["keel".into(), "run".into()]));
assert!(!looks_like_git_push(&[
"npm".into(),
"run".into(),
"push".into()
]));
}
#[test]
fn empty_command_is_not_a_push() {
assert!(!looks_like_git_push(&[]));
}
fn argv(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect()
}
#[test]
fn push_entry_names_ref_and_commit() {
assert_eq!(
push_target(&argv("git push")),
("/workspace".into(), "HEAD".into())
);
assert_eq!(
push_target(&argv("git push origin")),
("/workspace".into(), "HEAD".into())
);
assert_eq!(
push_target(&argv("git push -u origin feature")),
("/workspace".into(), "feature".into())
);
assert_eq!(
push_target(&argv(
"git -C /workspace/app push --force origin +main:release"
)),
("/workspace/app".into(), "main".into())
);
let sha = "0123456789abcdef0123456789abcdef01234567";
let data = push_data("main", Some(sha));
assert_eq!(data["ref"], "main");
assert_eq!(data["commit"], sha);
assert!(push_data("main", None)["commit"].is_null());
}
}