use std::{path::Path, process::Command};
use anyhow::{Result, anyhow};
use objects::object::Attribution;
use serde::Serialize;
#[derive(Clone, Debug, Serialize)]
pub(crate) struct GitCommitPreview {
pub message: String,
pub files: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct GitCommitInfo {
pub sha: String,
pub message: String,
}
#[derive(Debug)]
pub(super) struct GitCommitBlocked {
pub blockers: Vec<String>,
}
impl std::fmt::Display for GitCommitBlocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "git commit blocked: {}", self.blockers.join("; "))
}
}
impl std::error::Error for GitCommitBlocked {}
pub(super) fn validate_git_state(
repo_root: &Path,
expected_paths: &[String],
) -> std::result::Result<(), GitCommitBlocked> {
let mut blockers = Vec::new();
if !repo_root.join(".git").exists() {
blockers.push(format!(
"no git repository at {} (--git-commit requires a git overlay)",
repo_root.display()
));
return Err(GitCommitBlocked { blockers });
}
let head_check = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["symbolic-ref", "--quiet", "HEAD"])
.output();
match head_check {
Ok(out) if !out.status.success() => {
blockers.push("git HEAD is detached (--git-commit requires an attached branch)".into());
}
Err(err) => {
blockers.push(format!("failed to inspect git HEAD: {err}"));
return Err(GitCommitBlocked { blockers });
}
_ => {}
}
let status = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["status", "--porcelain", "-z", "--untracked-files=normal"])
.output();
let status = match status {
Ok(out) if out.status.success() => out,
Ok(out) => {
blockers.push(format!(
"git status failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
return Err(GitCommitBlocked { blockers });
}
Err(err) => {
blockers.push(format!("failed to run git status: {err}"));
return Err(GitCommitBlocked { blockers });
}
};
let expected: std::collections::HashSet<&str> =
expected_paths.iter().map(|p| p.as_str()).collect();
let unrelated = parse_porcelain_z_unrelated(&status.stdout, &expected);
if !unrelated.is_empty() {
let preview: Vec<String> = unrelated.iter().take(5).cloned().collect();
let suffix = if unrelated.len() > preview.len() {
format!(" (+{} more)", unrelated.len() - preview.len())
} else {
String::new()
};
blockers.push(format!(
"{} unrelated uncommitted git change(s) outside the merge: {}{}",
unrelated.len(),
preview.join(", "),
suffix
));
}
if blockers.is_empty() {
Ok(())
} else {
Err(GitCommitBlocked { blockers })
}
}
fn parse_porcelain_z_unrelated(
raw: &[u8],
expected: &std::collections::HashSet<&str>,
) -> Vec<String> {
let mut unrelated: Vec<String> = Vec::new();
let records: Vec<&[u8]> = raw.split(|b| *b == 0).filter(|r| !r.is_empty()).collect();
let mut i = 0;
while i < records.len() {
let rec = records[i];
if rec.len() < 4 {
i += 1;
continue;
}
let xy = &rec[..2];
let is_rename_or_copy = xy.iter().any(|c| matches!(*c, b'R' | b'C'));
let path_bytes = &rec[3..];
let path = String::from_utf8_lossy(path_bytes).into_owned();
if !expected.contains(path.as_str()) {
unrelated.push(path);
}
if is_rename_or_copy {
i += 2;
} else {
i += 1;
}
}
unrelated
}
pub(super) fn build_commit_message(
base_message: &str,
merge_state_id: &str,
attribution: &Attribution,
) -> String {
let subject = base_message.lines().next().unwrap_or(base_message).trim();
let mut out = String::new();
out.push_str(subject);
out.push_str("\n\n");
out.push_str(&format!("Heddle merge state: {merge_state_id}\n"));
out.push('\n');
out.push_str(&format!("Merge-State: {merge_state_id}\n"));
out.push_str(&format!(
"Co-Authored-By: {} <{}>\n",
attribution.principal.name, attribution.principal.email
));
out
}
pub(super) fn write_git_commit(
repo_root: &Path,
paths: &[String],
message: &str,
) -> Result<GitCommitInfo> {
if paths.is_empty() {
return Err(anyhow!(
"merge produced no changed paths — refusing to write an empty git commit"
));
}
let mut add_cmd = Command::new("git");
add_cmd.arg("-C").arg(repo_root).args(["add", "--"]);
for path in paths {
add_cmd.arg(path);
}
let add = add_cmd
.output()
.map_err(|err| anyhow!("git add failed: {err}"))?;
if !add.status.success() {
return Err(anyhow!(
"git add failed: {}",
String::from_utf8_lossy(&add.stderr).trim()
));
}
let commit = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["commit", "-m", message, "--allow-empty-message"])
.output()
.map_err(|err| anyhow!("git commit failed: {err}"))?;
if !commit.status.success() {
return Err(anyhow!(
"git commit failed: {}",
String::from_utf8_lossy(&commit.stderr).trim()
));
}
let rev = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["rev-parse", "--short", "HEAD"])
.output()
.map_err(|err| anyhow!("git rev-parse failed: {err}"))?;
if !rev.status.success() {
return Err(anyhow!(
"git rev-parse HEAD failed: {}",
String::from_utf8_lossy(&rev.stderr).trim()
));
}
let sha = String::from_utf8_lossy(&rev.stdout).trim().to_string();
Ok(GitCommitInfo {
sha,
message: message.to_string(),
})
}
#[cfg(test)]
mod tests {
use objects::object::Principal;
use super::*;
#[test]
fn build_commit_message_has_merge_state_trailer_and_coauthor() {
let attribution = Attribution::human(Principal::new("Ada Lovelace", "ada@example.com"));
let msg = build_commit_message("Merge thread 'feature'", "abcd1234", &attribution);
assert!(msg.starts_with("Merge thread 'feature'\n\n"));
assert!(msg.contains("Heddle merge state: abcd1234\n"));
assert!(msg.contains("\nMerge-State: abcd1234\n"));
assert!(msg.contains("Co-Authored-By: Ada Lovelace <ada@example.com>\n"));
}
#[test]
fn parse_porcelain_z_handles_paths_with_spaces() {
let mut raw: Vec<u8> = Vec::new();
raw.extend_from_slice(b" M path with spaces.txt");
raw.push(0);
raw.extend_from_slice(b" M unrelated.txt");
raw.push(0);
let expected: std::collections::HashSet<&str> =
["path with spaces.txt"].into_iter().collect();
let unrelated = parse_porcelain_z_unrelated(&raw, &expected);
assert_eq!(
unrelated,
vec!["unrelated.txt".to_string()],
"the path with spaces must match against `expected` cleanly; only `unrelated.txt` should surface"
);
}
#[test]
fn parse_porcelain_z_skips_rename_origin_trailer() {
let mut raw: Vec<u8> = Vec::new();
raw.extend_from_slice(b"R dst path.txt");
raw.push(0);
raw.extend_from_slice(b"src.txt");
raw.push(0);
raw.extend_from_slice(b" M expected.txt");
raw.push(0);
let expected: std::collections::HashSet<&str> =
["dst path.txt", "expected.txt"].into_iter().collect();
let unrelated = parse_porcelain_z_unrelated(&raw, &expected);
assert!(
unrelated.is_empty(),
"rename's new-path side is expected and origin is the trailer — nothing should surface. got: {unrelated:?}"
);
}
#[test]
fn parse_porcelain_z_reports_unrelated_paths_with_spaces() {
let mut raw: Vec<u8> = Vec::new();
raw.extend_from_slice(b" M weird path.txt");
raw.push(0);
let expected: std::collections::HashSet<&str> = ["other.txt"].into_iter().collect();
let unrelated = parse_porcelain_z_unrelated(&raw, &expected);
assert_eq!(unrelated, vec!["weird path.txt".to_string()]);
}
#[test]
fn validate_git_state_accepts_path_with_spaces_as_expected() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
let _ = Command::new("git")
.arg("-C")
.arg(root)
.args(["init", "--initial-branch=main"])
.output()
.expect("git init");
let _ = Command::new("git")
.arg("-C")
.arg(root)
.args(["config", "user.email", "test@example.com"])
.output();
let _ = Command::new("git")
.arg("-C")
.arg(root)
.args(["config", "user.name", "Test"])
.output();
std::fs::write(root.join("seed.txt"), b"seed").unwrap();
let _ = Command::new("git")
.arg("-C")
.arg(root)
.args(["add", "seed.txt"])
.output();
let _ = Command::new("git")
.arg("-C")
.arg(root)
.args(["commit", "-m", "seed"])
.output();
let weird = "path with spaces.txt";
std::fs::write(root.join(weird), b"hello").unwrap();
let expected_paths = vec![weird.to_string()];
let result = validate_git_state(root, &expected_paths);
assert!(
result.is_ok(),
"validate_git_state must accept a path with spaces when it's in expected_paths, got: {:?}",
result.err()
);
}
#[test]
fn build_commit_message_uses_only_first_subject_line() {
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let msg = build_commit_message(
"Merge thread 'x'\n\nlonger body\nthat we drop",
"deadbeef",
&attribution,
);
assert!(msg.starts_with("Merge thread 'x'\n\n"));
assert!(!msg.contains("longer body"));
}
}