@setoelkahfi / sigit / commits / 72c91b1

Enforce siGit Code co-author attribution on new commits

The system prompt already asked models to end commit messages with the Co-Authored-By trailer, but that only works when the model listens. Small local models often don't, and the trailer baked in the crate version, so the co-author identity changed every release. Attribution is now deterministic. After a run_command creates a new commit, siGit Code checks HEAD for the trailer and amends it in when missing, via git commit --amend --no-edit --trailer, which puts the trailer after a blank line at the end of the message: the exact format GitHub parses. run_command never rewrites a commit that already exists on a remote, and a commit that already carries the trailer is left untouched. The trailer lives in one constant, COMMIT_CO_AUTHOR_TRAILER, using sigit@sigit.si, the email of the sigitc GitHub account, so GitHub renders the co-author with that account's avatar. The system prompt quotes the constant verbatim and a unit test fails if the two drift apart.

paydii committed Jul 4, 2026 at 21:41 UTC 72c91b1d13f2a8f4fbd535d80ed459576a37d77d
3 files changed +235 -8
CLAUDE.md
+4 -1
index 7e71441..0a2b041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b - **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`, `list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`, `multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the - spec list (`all_tools`) and the execute `match` (`execute_tool`). + spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces + commit attribution: when a command creates a new commit that lacks the + `Co-Authored-By: siGit Code` trailer (`COMMIT_CO_AUTHOR_TRAILER`), it amends the trailer in — + unless the commit already exists on a remote, which is never rewritten. - **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,
src/main.rs
+17 -7
index 77c2f35..1248811 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,8 +84,7 @@ use tracing_subscriber::{EnvFilter, fmt as tracing_fmt}; #[cfg(unix)] use std::os::unix::io::{AsRawFd, FromRawFd}; -const SYSTEM_PROMPT: &str = concat!( - "\ +const SYSTEM_PROMPT: &str = "\ Your name is siGit — lowercase 's', uppercase 'G', no spaces. \ Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are. @@ -141,9 +140,10 @@ Git operations — always use run_command: - if a clone or init fails, check the error, fix the cause (wrong path, missing \ directory, permissions), and retry - when you create a commit, always end the commit message with a blank line and \ - then this trailer on its own line: Co-Authored-By: siGit Code ", - env!("CARGO_PKG_VERSION"), - " <sigit@sigit.si> + then this trailer on its own line: Co-Authored-By: siGit Code <sigit@sigit.si> \ + — GitHub reads that exact format and credits siGit as co-author. If a commit \ + lands without it, siGit Code amends the trailer in automatically and the tool \ + output says so; do not amend again yourself. Never introduce yourself unless asked. Jump straight into the answer. \ Keep answers short. Write idiomatic code. \ @@ -207,8 +207,7 @@ force smbCloud-specific advice into the answer. When it is about smbCloud, be \ specific and practical. Be direct and brief. Write clean, idiomatic code. When debugging, go for the \ -root cause, not the symptom. Correct beats clever." -); +root cause, not the symptom. Correct beats clever."; /// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1). /// the full [`SYSTEM_PROMPT`] wastes context and confuses them. @@ -2814,6 +2813,17 @@ async fn main() -> anyhow::Result<()> { mod tests { use super::*; + #[test] + fn system_prompt_advertises_the_commit_co_author_trailer() { + // The prompt instructs the model with the exact trailer that + // `tools::ensure_commit_co_author` enforces; if the two drift apart the + // safety net would re-amend commits the model already attributed. + assert!( + SYSTEM_PROMPT.contains(tools::COMMIT_CO_AUTHOR_TRAILER), + "SYSTEM_PROMPT must quote tools::COMMIT_CO_AUTHOR_TRAILER verbatim" + ); + } + #[test] fn ascii_safe_replaces_multibyte_chars() { // The exact label that crashed Zed: the cloud tier name plus the old
src/tools.rs
+214
index 721f965..cc81cd3 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1379,6 +1379,77 @@ fn exec_delete_file(arguments: &str) -> String { const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); const COMMAND_OUTPUT_LIMIT: usize = 50_000; +/// Trailer identifying siGit Code as the co-author of commits it creates. +/// GitHub detects `Co-authored-by:` trailers on the last lines of a commit +/// message (separated from the body by a blank line) and lists the agent +/// alongside the human author; `sigit@sigit.si` belongs to the +/// <https://github.com/sigitc> account ("siGit Code"), so the co-author is +/// rendered with that account's avatar and profile link. The system prompt +/// asks the model to add this itself; [`ensure_commit_co_author`] is the +/// safety net when it forgets. +pub const COMMIT_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: siGit Code <sigit@sigit.si>"; + +/// Run `git <args>` in `cwd`, returning trimmed stdout on success. +fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn git_head(cwd: &Path) -> Option<String> { + git_stdout(cwd, &["rev-parse", "HEAD"]) +} + +/// Deterministic co-author attribution: if the commit at HEAD lacks the +/// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which +/// places it after a blank line — the format GitHub detects). Never rewrites +/// a commit that is already on a remote. Returns a note describing the amend +/// so the model and user can see it happened. +fn ensure_commit_co_author(cwd: &Path) -> Option<String> { + let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?; + if message + .to_lowercase() + .contains("co-authored-by: sigit code") + { + return None; + } + // Amending changes the commit id; a commit that any remote ref already + // contains must be left alone or the branch diverges from its upstream. + match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) { + Some(remotes) if remotes.is_empty() => {} + _ => return None, + } + let amend = Command::new("git") + .args(["commit", "--amend", "--no-edit", "--trailer"]) + .arg(COMMIT_CO_AUTHOR_TRAILER) + .current_dir(cwd) + .output() + .ok()?; + if amend.status.success() { + log::info!( + "appended co-author trailer to the new commit in {}", + cwd.display() + ); + Some(format!( + "[siGit Code] The new commit was amended to append the co-author trailer \ + \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)." + )) + } else { + log::warn!( + "could not append co-author trailer in {}: {}", + cwd.display(), + String::from_utf8_lossy(&amend.stderr).trim() + ); + None + } +} + /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT. fn exec_run_command(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { @@ -1405,6 +1476,17 @@ fn exec_run_command(arguments: &str) -> String { log::info!("run_command: `{command_str}` in `{cwd_str}`"); + // Co-author attribution: note where HEAD is before a command that looks + // like it may commit, so a new commit can be detected afterwards. The + // string check is only a cheap trigger — a false positive costs one + // `git rev-parse` and nothing else. + let may_commit = command_str.contains("git") && command_str.contains("commit"); + let head_before = if may_commit { + git_head(&cwd_path) + } else { + None + }; + #[cfg(unix)] let mut child = match Command::new("sh") .arg("-c") @@ -1459,6 +1541,21 @@ fn exec_run_command(arguments: &str) -> String { combined.push_str(&String::from_utf8_lossy(&output.stdout)); combined.push_str(&String::from_utf8_lossy(&output.stderr)); + // A new commit appeared under this command: make sure it carries the + // siGit co-author trailer (see `ensure_commit_co_author`). + if may_commit { + let head_after = git_head(&cwd_path); + if head_after.is_some() + && head_after != head_before + && let Some(note) = ensure_commit_co_author(&cwd_path) + { + if !combined.is_empty() && !combined.ends_with('\n') { + combined.push('\n'); + } + combined.push_str(&note); + } + } + let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT { let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT]; format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)") @@ -2138,6 +2235,123 @@ mod tests { assert!(result.contains("Exit code 0"), "got: {result}"); } + /// Fresh git repo with one commit, test identity, and signing off (the + /// developer's global gpgsign must not leak into sandbox commits). + fn init_test_repo(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + test_git(&dir, &["init", "-q", "-b", "main"]); + test_git(&dir, &["config", "user.name", "Test User"]); + test_git(&dir, &["config", "user.email", "test@example.com"]); + test_git(&dir, &["config", "commit.gpgsign", "false"]); + fs::write(dir.join("file.txt"), "one\n").unwrap(); + test_git(&dir, &["add", "file.txt"]); + test_git(&dir, &["commit", "-q", "-m", "Initial"]); + dir + } + + fn test_git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + #[test] + fn run_command_appends_co_author_trailer_to_new_commits() { + let dir = init_test_repo("coauthor_append"); + fs::write(dir.join("file.txt"), "two\n").unwrap(); + let args = serde_json::json!({ + "command": "git add file.txt && git commit -m \"Update file\"", + "cwd": dir.display().to_string(), + }) + .to_string(); + + let result = exec_run_command(&args); + assert!(result.contains("co-author trailer"), "got: {result}"); + + let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap(); + assert!( + message.ends_with(COMMIT_CO_AUTHOR_TRAILER), + "trailer must be the last line: {message:?}" + ); + assert!( + message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")), + "trailer needs a blank line before it for GitHub to detect it: {message:?}" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn run_command_keeps_existing_co_author_trailer() { + let dir = init_test_repo("coauthor_present"); + fs::write(dir.join("file.txt"), "two\n").unwrap(); + let command = format!( + "git add file.txt && git commit -m \"Update file\" -m \"{COMMIT_CO_AUTHOR_TRAILER}\"" + ); + let args = serde_json::json!({ + "command": command, + "cwd": dir.display().to_string(), + }) + .to_string(); + + let result = exec_run_command(&args); + assert!( + !result.contains("[siGit Code]"), + "no amend expected: {result}" + ); + + let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap(); + assert_eq!( + message.matches("Co-Authored-By: siGit Code").count(), + 1, + "trailer must not be duplicated: {message:?}" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn run_command_never_amends_pushed_commits() { + let dir = init_test_repo("coauthor_pushed"); + let remote = std::env::temp_dir().join(format!( + "sigit_test_coauthor_remote_{}.git", + std::process::id() + )); + let _ = fs::remove_dir_all(&remote); + fs::create_dir_all(&remote).unwrap(); + test_git(&remote, &["init", "-q", "--bare"]); + test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]); + + fs::write(dir.join("file.txt"), "two\n").unwrap(); + let args = serde_json::json!({ + "command": "git add file.txt && git commit -m \"Update file\" && git push -q origin main", + "cwd": dir.display().to_string(), + }) + .to_string(); + + let result = exec_run_command(&args); + assert!( + !result.contains("[siGit Code]"), + "no amend expected: {result}" + ); + + // Already on the remote when the gate ran, so it must be untouched. + let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap(); + assert!( + !message.contains("Co-Authored-By"), + "pushed commit must not be rewritten: {message:?}" + ); + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&remote); + } + #[test] fn test_run_command_failure() { #[cfg(unix)]