use super::{PROPOSAL_REF_PREFIX, proposal_ref};
use crate::template::GitRunner;
use crate::workspace::{self, CONFIG_REF_PREFIX, config_ref, repo_git};
use std::io;
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Layout(#[from] workspace::LayoutError),
#[error("no proposal {id:?} in this workspace — staged: {}", crate::name::pool(.staged))]
Unknown { id: String, staged: Vec<String> },
#[error(
"proposal {id} is stale — it is parented on {parent}, and the lineage now stands at \
{tip}; nothing was moved. A stale proposal cannot be merged forward (its reviewer \
read a config that no longer governs): reject it, and the next checkpoint re-derives \
from the current tip (docs/DESIGN_LEARNING_LOOP.md §3)"
)]
Stale {
id: String,
parent: String,
tip: String,
},
#[error(
"proposal {id} is parented on the head of {} lineages — {}; accepting it would have to \
choose one. Advance or delete all but one, then accept",
.lineages.len(),
crate::name::pool(.lineages)
)]
Ambiguous { id: String, lineages: Vec<String> },
#[error("git {op}: {source}")]
Git {
op: &'static str,
#[source]
source: io::Error,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct Row {
pub id: String,
pub lineages: Vec<String>,
pub parent: String,
pub fresh: bool,
pub diffstat: String,
pub subject: String,
}
pub fn list(ws: &Path, git: &dyn GitRunner) -> Result<Vec<Row>, Error> {
workspace::require(ws)?;
ids(ws, git)?
.into_iter()
.map(|id| row(ws, &id, git))
.collect()
}
pub fn ids(ws: &Path, git: &dyn GitRunner) -> Result<Vec<String>, Error> {
workspace::ref_names(ws, PROPOSAL_REF_PREFIX, git).map_err(|source| Error::Git {
op: "for-each-ref proposal/",
source,
})
}
fn row(ws: &Path, id: &str, git: &dyn GitRunner) -> Result<Row, Error> {
let target = proposal_ref(id);
let parent = rev(ws, &format!("{target}^"), git)?;
let lineages = heads_at(ws, &parent, git)?;
Ok(Row {
id: id.to_owned(),
fresh: !lineages.is_empty(),
lineages,
diffstat: capture(
ws,
&["diff", "--shortstat", &parent, &target],
"diff --shortstat",
git,
)?,
subject: super::subject::of(ws, &parent, &target, git)?,
parent: short(&parent),
})
}
pub fn show(ws: &Path, id: &str, git: &dyn GitRunner) -> Result<String, Error> {
require_staged(ws, id, git)?;
capture(ws, &["show", &proposal_ref(id)], "show", git)
}
pub fn accept(ws: &Path, id: &str, git: &dyn GitRunner) -> Result<String, Error> {
require_staged(ws, id, git)?;
let target = proposal_ref(id);
let parent = rev(ws, &format!("{target}^"), git)?;
let commit = rev(ws, &target, git)?;
let lineages = heads_at(ws, &parent, git)?;
let [name] = lineages.as_slice() else {
return Err(stale_or_ambiguous(ws, id, &parent, lineages, git));
};
let head = format!("refs/heads/{}", config_ref(name));
run(
ws,
&["update-ref", &head, &commit, &parent],
"update-ref accept",
git,
)?;
run(
ws,
&["update-ref", "-d", &format!("refs/heads/{target}")],
"update-ref -d",
git,
)?;
Ok(format!(
"accepted {id}: {} now stands at {}",
config_ref(name),
short(&commit)
))
}
pub fn reject(ws: &Path, id: &str, git: &dyn GitRunner) -> Result<String, Error> {
require_staged(ws, id, git)?;
let target = proposal_ref(id);
run(
ws,
&["update-ref", "-d", &format!("refs/heads/{target}")],
"update-ref -d",
git,
)?;
Ok(format!("rejected {id}: {target} deleted"))
}
fn stale_or_ambiguous(
ws: &Path,
id: &str,
parent: &str,
lineages: Vec<String>,
git: &dyn GitRunner,
) -> Error {
if !lineages.is_empty() {
return Error::Ambiguous {
id: id.to_owned(),
lineages,
};
}
let tip = workspace::current_config::current_config(ws, &proposal_ref(id), git)
.map(|r| short(r.commit()))
.unwrap_or_else(|_| "an unreadable lineage".into());
Error::Stale {
id: id.to_owned(),
parent: short(parent),
tip,
}
}
fn heads_at(ws: &Path, commit: &str, git: &dyn GitRunner) -> Result<Vec<String>, Error> {
let repo = repo_git(ws);
let listing = git
.run_capture(
&repo,
&[
"for-each-ref",
"--format=%(objectname) %(refname:short)",
&format!("refs/heads/{CONFIG_REF_PREFIX}"),
],
)
.map_err(|source| Error::Git {
op: "for-each-ref config/",
source,
})?;
Ok(listing
.lines()
.filter_map(|line| line.trim().split_once(' '))
.filter(|(sha, _)| *sha == commit)
.filter_map(|(_, name)| name.strip_prefix(CONFIG_REF_PREFIX))
.map(str::to_owned)
.collect())
}
fn require_staged(ws: &Path, id: &str, git: &dyn GitRunner) -> Result<(), Error> {
workspace::require(ws)?;
let staged = ids(ws, git)?;
if staged.iter().any(|s| s == id) {
return Ok(());
}
Err(Error::Unknown {
id: id.to_owned(),
staged,
})
}
fn rev(ws: &Path, spec: &str, git: &dyn GitRunner) -> Result<String, Error> {
capture(ws, &["rev-parse", spec], "rev-parse", git)
}
pub(super) fn capture(
ws: &Path,
args: &[&str],
op: &'static str,
git: &dyn GitRunner,
) -> Result<String, Error> {
git.run_capture(&repo_git(ws), args)
.map(|out| out.trim().to_owned())
.map_err(|source| Error::Git { op, source })
}
fn run(ws: &Path, args: &[&str], op: &'static str, git: &dyn GitRunner) -> Result<(), Error> {
git.run(&repo_git(ws), args)
.map_err(|source| Error::Git { op, source })
}
fn short(sha: &str) -> String {
sha.chars().take(12).collect()
}
#[cfg(test)]
mod tests;