use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail, ensure};
use serde::Serialize;
use super::format::{Session, State, Task};
use crate::process::{self, Control, Interruption};
#[derive(Serialize)]
pub(super) struct Prepared {
pub repository: PathBuf,
origin: Option<PathBuf>,
}
pub(super) fn prepare(directory: &Path, task: &Task, destination: &Path) -> Result<Prepared> {
prepare_repository(directory, task, destination, None)
}
pub(super) fn prepare_trial(directory: &Path, task: &Task, destination: &Path) -> Result<Prepared> {
let inputs = task.trial.as_ref().context("missing Trial inputs")?;
git(directory, &["check-ref-format", "--branch", &inputs.base])?;
git(directory, &["check-ref-format", "--branch", &inputs.branch])?;
ensure!(
inputs.base != inputs.branch,
"Trial Base branch and Issue branch must differ"
);
prepare_repository(directory, task, destination, Some(inputs))
}
fn prepare_repository(
directory: &Path,
task: &Task,
destination: &Path,
trial: Option<&crate::trial::Inputs>,
) -> Result<Prepared> {
if task.state != State::Approved {
bail!("proposed Eval tasks cannot be prepared or run");
}
if destination.try_exists()? {
bail!("Eval destination already exists: {}", destination.display());
}
let destination = std::env::current_dir()?.join(destination);
let parent = destination
.parent()
.context("Eval destination needs a parent")?
.canonicalize()?;
let destination = parent.join(
destination
.file_name()
.context("Eval destination needs a name")?,
);
let source = directory
.join(&task.repository)
.canonicalize()
.context("Eval repository must be a local directory")?;
let source_path = source
.to_str()
.context("Eval repository path must be UTF-8")?;
for id in [&task.base, &task.change] {
git(&source, &["cat-file", "-e", &format!("{id}^{{commit}}")])?;
}
git(
&source,
&["merge-base", "--is-ancestor", &task.base, &task.change],
)?;
let object_format = git(&source, &["rev-parse", "--show-object-format"])?;
let temporary = tempfile::Builder::new()
.prefix(".thirdshift-eval-")
.tempdir_in(&parent)?;
let repo = temporary.path();
git(
repo,
&[
"init",
"-q",
"--template=",
&format!("--object-format={object_format}"),
],
)?;
git(
repo,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--no-recurse-submodules",
source_path,
&task.base,
],
)?;
git(repo, &["checkout", "-q", "--detach", &task.base])?;
let patch = bytes(
&source,
&[
"diff",
"--no-ext-diff",
"--no-textconv",
"--binary",
"--full-index",
"--no-color",
"--unified=3",
"--no-relative",
"--src-prefix=a/",
"--dst-prefix=b/",
"--submodule=short",
"--ignore-submodules=none",
&task.base,
&task.change,
"--",
],
None,
)?;
if !patch.is_empty() {
bytes(repo, &["apply", "--index", "--binary", "-"], Some(&patch))?;
}
copy_inputs(directory, task, repo)?;
prune(repo)?;
verify(repo, &task.base)?;
let origin = if task.session == Session::SpecReview {
let seed_branch = trial.map_or("base", |inputs| inputs.base.as_str());
let relative = ".git/eval-origin.git";
let bare = repo.join(relative);
git(
repo,
&[
"init",
"-q",
"--bare",
"--template=",
&format!("--object-format={object_format}"),
relative,
],
)?;
git(
&bare,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
source_path,
&format!("{}:refs/heads/{seed_branch}", task.base),
],
)?;
git(
&bare,
&["symbolic-ref", "HEAD", &format!("refs/heads/{seed_branch}")],
)?;
prune(&bare)?;
verify(&bare, &task.base)?;
let final_origin = destination.join(relative);
git(
repo,
&[
"remote",
"add",
"origin",
final_origin
.to_str()
.context("Eval origin path must be UTF-8")?,
],
)?;
Some((bare, final_origin))
} else {
None
};
if let Some(inputs) = trial {
git(repo, &["branch", &inputs.base, &task.base])?;
git(repo, &["checkout", "-b", &inputs.branch])?;
git(
repo,
&[
"-c",
"core.hooksPath=/dev/null",
"-c",
"commit.gpgsign=false",
"-c",
"user.name=Eval",
"-c",
"user.email=eval@example.invalid",
"commit",
"--allow-empty",
"-m",
"Eval change",
],
)?;
if let Some((bare, _)) = &origin {
git(
repo,
&[
"push",
bare.to_str().context("Eval origin path must be UTF-8")?,
&format!("refs/heads/{0}:refs/heads/{0}", inputs.base),
&format!("refs/heads/{0}:refs/heads/{0}", inputs.branch),
],
)?;
}
fs::create_dir_all(repo.join(".git/info"))?;
fs::write(
repo.join(".git/info/exclude"),
format!("/{}\n", crate::prompt::ISSUE_FILE),
)?;
}
fs::rename(repo, &destination).context("could not publish prepared Eval repository")?;
Ok(Prepared {
repository: destination,
origin: origin.map(|(_, final_origin)| final_origin),
})
}
fn copy_inputs(directory: &Path, task: &Task, repo: &Path) -> Result<()> {
let root = directory.canonicalize()?;
for (destination, source) in &task.inputs {
let source = root.join(source).canonicalize()?;
if !source.starts_with(&root) || !source.is_file() {
bail!("Eval input must be a file inside its task folder");
}
let mut target = repo.to_path_buf();
let parts: Vec<_> = destination.split('/').collect();
for (index, part) in parts.iter().enumerate() {
target.push(part);
match fs::symlink_metadata(&target) {
Ok(metadata) if metadata.file_type().is_symlink() => bail!(
"Eval input destination crosses a symlink: {}",
target.display()
),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if index + 1 < parts.len() {
fs::create_dir(&target)?;
}
}
Err(error) => return Err(error.into()),
}
}
fs::copy(source, target)?;
}
Ok(())
}
fn prune(repo: &Path) -> Result<()> {
git(repo, &["reflog", "expire", "--expire=now", "--all"])?;
git(repo, &["repack", "-ad"])?;
git(repo, &["prune", "--expire=now"])?;
Ok(())
}
pub(super) fn verify(repo: &Path, base: &str) -> Result<()> {
let expected = git(repo, &["rev-list", base])?;
let actual = git(repo, &["rev-list", "--all", "HEAD"])?;
let expected: BTreeSet<_> = expected.lines().collect();
let actual: BTreeSet<_> = actual.lines().collect();
if actual != expected || git(repo, &["rev-parse", "HEAD"])? != base {
bail!("Eval preparation refused: history beyond base {base} survives");
}
Ok(())
}
pub(super) fn git(repo: &Path, args: &[&str]) -> Result<String> {
String::from_utf8(bytes(repo, args, None)?)
.map(|s| s.trim().into())
.context("git output is not UTF-8")
}
pub(super) fn bytes(repo: &Path, args: &[&str], input: Option<&[u8]>) -> Result<Vec<u8>> {
let output = process::output(
Command::new("git")
.current_dir(repo)
.args(["-c", "gc.auto=0", "-c", "protocol.file.allow=always"])
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_OBJECT_DIRECTORY")
.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES"),
input,
Control {
name: "Eval git",
interruption: Interruption::Ordinary,
stop: &|child| process::stop(child, &[libc::SIGTERM, libc::SIGKILL]),
},
)?;
if !output.status.success() {
bail!(
"Eval git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(output.stdout)
}
#[cfg(test)]
mod tests;