dashboard: diff against applied/proposal/<id>, prefer fetched_sha

approval_diff now runs git diff refs/heads/main..refs/tags/
proposal/<id> against the applied repo instead of cobbling a
single-file diff from proposed. consequences: multi-file
proposals show every change, manager amendments in proposed
cannot lie about what'll be deployed, no-op proposals render
an explicit '(proposal matches currently-deployed tree)'.
displayed sha prefers fetched_sha (hive-c0re-vouched) and
falls back to commit_ref only for the brief pre-fetch window.
unified_diff helper + similar dep dropped — git diff is the
source of truth now. dead-code allows on the lifecycle git
helpers + approvals.set_fetched_sha come off since all are
wired up. readme picks up the tag flow + /applied RO mount.
This commit is contained in:
müde 2026-05-15 23:18:17 +02:00
parent fc61cb9310
commit e26143a412
6 changed files with 42 additions and 55 deletions

View file

@ -342,8 +342,12 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
for a in approvals {
out.push(match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => {
let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned();
let diff = approval_diff(&a.agent, &a.commit_ref).await;
// Prefer the canonical fetched sha from applied;
// commit_ref is only the manager's claim and may be
// amended out from under us.
let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref);
let sha = displayed[..displayed.len().min(12)].to_owned();
let diff = approval_diff(&a.agent, a.id).await;
ApprovalView {
id: a.id,
agent: a.agent,
@ -925,55 +929,40 @@ fn claude_has_session(dir: &Path) -> bool {
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
}
async fn approval_diff(agent: &str, commit_ref: &str) -> String {
let proposed = Coordinator::agent_proposed_dir(agent);
if !proposed.exists() {
return format!(
"(proposed dir {} does not exist — agent destroyed?)",
proposed.display()
);
/// Multi-file unified diff between the currently-deployed tree and
/// the proposal for this approval. Runs against the applied repo
/// since the canonical proposal commit lives there (manager-side
/// amendments don't move it). Empty output means proposal == main —
/// a no-op approval.
async fn approval_diff(agent: &str, approval_id: i64) -> String {
let applied = Coordinator::agent_applied_dir(agent);
if !applied.join(".git").exists() {
return format!("(no applied git repo at {})", applied.display());
}
if !proposed.join(".git").exists() {
return format!("(no git repo at {})", proposed.display());
}
let applied = Coordinator::agent_applied_dir(agent).join("agent.nix");
let applied_text = std::fs::read_to_string(&applied).unwrap_or_default();
match git_show(&proposed, commit_ref).await {
Ok(s) => unified_diff(&applied_text, &s),
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
match git_diff_main_to(&applied, &proposal_ref).await {
Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
Ok(s) => s,
Err(e) => format!("(error: {e:#})"),
}
}
async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result<String> {
async fn git_diff_main_to(applied_dir: &Path, target_ref: &str) -> Result<String> {
let out = lifecycle::git_command()
.current_dir(proposed_dir)
.args(["show", &format!("{commit_ref}:agent.nix")])
.current_dir(applied_dir)
.args(["diff", &format!("refs/heads/main..{target_ref}")])
.output()
.await
.with_context(|| {
format!(
"spawn `git show` in {} (HYPERHIVE_GIT={})",
proposed_dir.display(),
std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "<unset>".into()),
)
})?;
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"git show {commit_ref}:agent.nix failed: {}",
"git diff main..{target_ref} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn unified_diff(applied: &str, proposed: &str) -> String {
let diff = similar::TextDiff::from_lines(applied, proposed);
diff.unified_diff()
.context_radius(3)
.header("applied", "proposed")
.to_string()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")