use std::path::Path;
use async_trait::async_trait;
use solid_pod_rs::provenance::{GitMark, GitMarker, ProvenanceError};
use tokio::process::Command;
const PINNED_BRANCH: &str = "main";
#[derive(Debug, Clone)]
pub struct ShellGitMarker {
committer_name: String,
}
impl ShellGitMarker {
#[must_use]
pub fn new() -> Self {
Self {
committer_name: "solid-pod-rs".to_string(),
}
}
#[must_use]
pub fn with_committer_name(name: impl Into<String>) -> Self {
Self {
committer_name: name.into(),
}
}
}
impl Default for ShellGitMarker {
fn default() -> Self {
Self::new()
}
}
async fn git(repo: &Path, args: &[&str]) -> Result<String, ProvenanceError> {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ProvenanceError::Git("git binary not found in PATH".into())
} else {
ProvenanceError::Git(format!("spawn git {args:?}: {e}"))
}
})?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = match (stderr.trim().is_empty(), stdout.trim().is_empty()) {
(false, false) => format!("{}; {}", stderr.trim(), stdout.trim()),
(false, true) => stderr.trim().to_string(),
(true, false) => stdout.trim().to_string(),
(true, true) => String::new(),
};
Err(ProvenanceError::Git(format!(
"git {args:?} exited {:?}: {detail}",
output.status.code(),
)))
}
}
fn repo_slug(repo: &Path) -> String {
repo.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| repo.to_string_lossy().into_owned())
}
async fn head_sha(repo: &Path) -> Result<Option<String>, ProvenanceError> {
match git(repo, &["rev-parse", "HEAD"]).await {
Ok(sha) if !sha.is_empty() => Ok(Some(sha)),
Ok(_) => Ok(None),
Err(ProvenanceError::Git(msg))
if msg.contains("unknown revision")
|| msg.contains("ambiguous argument")
|| msg.contains("bad revision")
|| msg.contains("does not have any commits") =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
#[async_trait(?Send)]
impl GitMarker for ShellGitMarker {
async fn mark_write(
&self,
repo: &Path,
path: &str,
agent_did: &str,
message: &str,
) -> Result<GitMark, ProvenanceError> {
if path.starts_with('/') || path.contains("..") {
return Err(ProvenanceError::InvalidPath(path.to_string()));
}
let parent = head_sha(repo).await?;
git(repo, &["add", "--", path]).await?;
let name_cfg = format!("user.name={}", self.committer_name);
let email_cfg = format!("user.email={agent_did}");
let commit_res = git(
repo,
&[
"-c",
&name_cfg,
"-c",
&email_cfg,
"commit",
"-m",
message,
],
)
.await;
match commit_res {
Ok(_) => {
let commit_sha = head_sha(repo)
.await?
.ok_or_else(|| ProvenanceError::Git("HEAD unresolved after commit".into()))?;
Ok(GitMark {
commit_sha,
repo: repo_slug(repo),
branch: PINNED_BRANCH.to_string(),
parent,
})
}
Err(ProvenanceError::Git(msg))
if msg.contains("nothing to commit")
|| msg.contains("no changes added")
|| msg.contains("working tree clean")
|| msg.contains("nothing added to commit") =>
{
match &parent {
Some(head) => {
let head_parent = git(repo, &["rev-parse", &format!("{head}^")])
.await
.ok()
.filter(|s| !s.is_empty());
Ok(GitMark {
commit_sha: head.clone(),
repo: repo_slug(repo),
branch: PINNED_BRANCH.to_string(),
parent: head_parent,
})
}
None => Err(ProvenanceError::Git(
"nothing to commit and no prior HEAD".into(),
)),
}
}
Err(e) => Err(e),
}
}
async fn head(&self, repo: &Path) -> Result<Option<String>, ProvenanceError> {
head_sha(repo).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Stdio;
use tempfile::TempDir;
fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
async fn init_repo() -> TempDir {
let td = TempDir::new().unwrap();
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(td.path())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
};
run(&["init", "-b", "main"]);
td
}
async fn write_file(repo: &Path, rel: &str, contents: &str) {
let abs = repo.join(rel);
if let Some(parent) = abs.parent() {
tokio::fs::create_dir_all(parent).await.unwrap();
}
tokio::fs::write(abs, contents).await.unwrap();
}
#[tokio::test]
async fn head_is_none_on_unborn_branch() {
if !git_available() {
return;
}
let td = init_repo().await;
let marker = ShellGitMarker::new();
assert_eq!(marker.head(td.path()).await.unwrap(), None);
}
#[tokio::test]
async fn mark_write_creates_commit_and_captures_sha() {
if !git_available() {
return;
}
let td = init_repo().await;
let marker = ShellGitMarker::new();
write_file(td.path(), "notes/hello.ttl", "<a> <b> <c> .").await;
let mark = marker
.mark_write(td.path(), "notes/hello.ttl", "did:nostr:abcd", "PUT /notes/hello.ttl")
.await
.unwrap();
assert_eq!(mark.commit_sha.len(), 40);
assert!(mark.commit_sha.bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(mark.branch, "main");
assert_eq!(mark.repo, td.path().file_name().unwrap().to_string_lossy());
assert_eq!(mark.parent, None);
let head = marker.head(td.path()).await.unwrap().unwrap();
assert_eq!(head, mark.commit_sha);
let email = git(td.path(), &["log", "-1", "--format=%ae"]).await.unwrap();
assert_eq!(email, "did:nostr:abcd");
let name = git(td.path(), &["log", "-1", "--format=%an"]).await.unwrap();
assert_eq!(name, "solid-pod-rs");
}
#[tokio::test]
async fn parent_chain_links_two_writes() {
if !git_available() {
return;
}
let td = init_repo().await;
let marker = ShellGitMarker::new();
write_file(td.path(), "a.ttl", "first").await;
let m1 = marker
.mark_write(td.path(), "a.ttl", "did:nostr:a", "write a")
.await
.unwrap();
write_file(td.path(), "b.ttl", "second").await;
let m2 = marker
.mark_write(td.path(), "b.ttl", "did:nostr:b", "write b")
.await
.unwrap();
assert_ne!(m1.commit_sha, m2.commit_sha);
assert_eq!(m1.parent, None);
assert_eq!(m2.parent.as_deref(), Some(m1.commit_sha.as_str()));
}
#[tokio::test]
async fn nothing_to_commit_returns_head_without_error() {
if !git_available() {
return;
}
let td = init_repo().await;
let marker = ShellGitMarker::new();
write_file(td.path(), "a.ttl", "content").await;
let m1 = marker
.mark_write(td.path(), "a.ttl", "did:nostr:a", "write a")
.await
.unwrap();
let m2 = marker
.mark_write(td.path(), "a.ttl", "did:nostr:a", "re-write a")
.await
.unwrap();
assert_eq!(m2.commit_sha, m1.commit_sha, "HEAD must not advance");
assert_eq!(
marker.head(td.path()).await.unwrap().as_deref(),
Some(m1.commit_sha.as_str())
);
}
#[tokio::test]
async fn rejects_path_traversal() {
if !git_available() {
return;
}
let td = init_repo().await;
let marker = ShellGitMarker::new();
assert!(matches!(
marker
.mark_write(td.path(), "../escape.ttl", "did:nostr:a", "x")
.await,
Err(ProvenanceError::InvalidPath(_))
));
assert!(matches!(
marker
.mark_write(td.path(), "/abs.ttl", "did:nostr:a", "x")
.await,
Err(ProvenanceError::InvalidPath(_))
));
}
}