@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
@@ -83,7 +83,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
83 - **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
84 `list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`,
85 `multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the
86 - spec list (`all_tools`) and the execute `match` (`execute_tool`).
86 + spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces
87 + commit attribution: when a command creates a new commit that lacks the
88 + `Co-Authored-By: siGit Code` trailer (`COMMIT_CO_AUTHOR_TRAILER`), it amends the trailer in —
89 + unless the commit already exists on a remote, which is never rewritten.
90 - **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
91 folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
92 instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,
src/main.rs
+17 -7
@@ -84,8 +84,7 @@ use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
84 #[cfg(unix)]
85 use std::os::unix::io::{AsRawFd, FromRawFd};
86
87 -const SYSTEM_PROMPT: &str = concat!(
88 - "\
87 +const SYSTEM_PROMPT: &str = "\
88 Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
89 Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are.
90
@@ -141,9 +140,10 @@ Git operations — always use run_command:
140 - if a clone or init fails, check the error, fix the cause (wrong path, missing \
141 directory, permissions), and retry
142 - when you create a commit, always end the commit message with a blank line and \
144 - then this trailer on its own line: Co-Authored-By: siGit Code ",
145 - env!("CARGO_PKG_VERSION"),
146 - " <sigit@sigit.si>
143 + then this trailer on its own line: Co-Authored-By: siGit Code <sigit@sigit.si> \
144 + — GitHub reads that exact format and credits siGit as co-author. If a commit \
145 + lands without it, siGit Code amends the trailer in automatically and the tool \
146 + output says so; do not amend again yourself.
147
148 Never introduce yourself unless asked. Jump straight into the answer. \
149 Keep answers short. Write idiomatic code. \
@@ -207,8 +207,7 @@ force smbCloud-specific advice into the answer. When it is about smbCloud, be \
207 specific and practical.
208
209 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
210 -root cause, not the symptom. Correct beats clever."
211 -);
210 +root cause, not the symptom. Correct beats clever.";
211
212 /// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1).
213 /// the full [`SYSTEM_PROMPT`] wastes context and confuses them.
@@ -2814,6 +2813,17 @@ async fn main() -> anyhow::Result<()> {
2813 mod tests {
2814 use super::*;
2815
2816 + #[test]
2817 + fn system_prompt_advertises_the_commit_co_author_trailer() {
2818 + // The prompt instructs the model with the exact trailer that
2819 + // `tools::ensure_commit_co_author` enforces; if the two drift apart the
2820 + // safety net would re-amend commits the model already attributed.
2821 + assert!(
2822 + SYSTEM_PROMPT.contains(tools::COMMIT_CO_AUTHOR_TRAILER),
2823 + "SYSTEM_PROMPT must quote tools::COMMIT_CO_AUTHOR_TRAILER verbatim"
2824 + );
2825 + }
2826 +
2827 #[test]
2828 fn ascii_safe_replaces_multibyte_chars() {
2829 // The exact label that crashed Zed: the cloud tier name plus the old
src/tools.rs
+214
@@ -1379,6 +1379,77 @@ fn exec_delete_file(arguments: &str) -> String {
1379 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1380 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1381
1382 +/// Trailer identifying siGit Code as the co-author of commits it creates.
1383 +/// GitHub detects `Co-authored-by:` trailers on the last lines of a commit
1384 +/// message (separated from the body by a blank line) and lists the agent
1385 +/// alongside the human author; `sigit@sigit.si` belongs to the
1386 +/// <https://github.com/sigitc> account ("siGit Code"), so the co-author is
1387 +/// rendered with that account's avatar and profile link. The system prompt
1388 +/// asks the model to add this itself; [`ensure_commit_co_author`] is the
1389 +/// safety net when it forgets.
1390 +pub const COMMIT_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: siGit Code <sigit@sigit.si>";
1391 +
1392 +/// Run `git <args>` in `cwd`, returning trimmed stdout on success.
1393 +fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
1394 + let output = Command::new("git")
1395 + .args(args)
1396 + .current_dir(cwd)
1397 + .output()
1398 + .ok()?;
1399 + output
1400 + .status
1401 + .success()
1402 + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1403 +}
1404 +
1405 +fn git_head(cwd: &Path) -> Option<String> {
1406 + git_stdout(cwd, &["rev-parse", "HEAD"])
1407 +}
1408 +
1409 +/// Deterministic co-author attribution: if the commit at HEAD lacks the
1410 +/// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which
1411 +/// places it after a blank line — the format GitHub detects). Never rewrites
1412 +/// a commit that is already on a remote. Returns a note describing the amend
1413 +/// so the model and user can see it happened.
1414 +fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
1415 + let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?;
1416 + if message
1417 + .to_lowercase()
1418 + .contains("co-authored-by: sigit code")
1419 + {
1420 + return None;
1421 + }
1422 + // Amending changes the commit id; a commit that any remote ref already
1423 + // contains must be left alone or the branch diverges from its upstream.
1424 + match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) {
1425 + Some(remotes) if remotes.is_empty() => {}
1426 + _ => return None,
1427 + }
1428 + let amend = Command::new("git")
1429 + .args(["commit", "--amend", "--no-edit", "--trailer"])
1430 + .arg(COMMIT_CO_AUTHOR_TRAILER)
1431 + .current_dir(cwd)
1432 + .output()
1433 + .ok()?;
1434 + if amend.status.success() {
1435 + log::info!(
1436 + "appended co-author trailer to the new commit in {}",
1437 + cwd.display()
1438 + );
1439 + Some(format!(
1440 + "[siGit Code] The new commit was amended to append the co-author trailer \
1441 + \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)."
1442 + ))
1443 + } else {
1444 + log::warn!(
1445 + "could not append co-author trailer in {}: {}",
1446 + cwd.display(),
1447 + String::from_utf8_lossy(&amend.stderr).trim()
1448 + );
1449 + None
1450 + }
1451 +}
1452 +
1453 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT.
1454 fn exec_run_command(arguments: &str) -> String {
1455 let args: Value = match serde_json::from_str(arguments) {
@@ -1405,6 +1476,17 @@ fn exec_run_command(arguments: &str) -> String {
1476
1477 log::info!("run_command: `{command_str}` in `{cwd_str}`");
1478
1479 + // Co-author attribution: note where HEAD is before a command that looks
1480 + // like it may commit, so a new commit can be detected afterwards. The
1481 + // string check is only a cheap trigger — a false positive costs one
1482 + // `git rev-parse` and nothing else.
1483 + let may_commit = command_str.contains("git") && command_str.contains("commit");
1484 + let head_before = if may_commit {
1485 + git_head(&cwd_path)
1486 + } else {
1487 + None
1488 + };
1489 +
1490 #[cfg(unix)]
1491 let mut child = match Command::new("sh")
1492 .arg("-c")
@@ -1459,6 +1541,21 @@ fn exec_run_command(arguments: &str) -> String {
1541 combined.push_str(&String::from_utf8_lossy(&output.stdout));
1542 combined.push_str(&String::from_utf8_lossy(&output.stderr));
1543
1544 + // A new commit appeared under this command: make sure it carries the
1545 + // siGit co-author trailer (see `ensure_commit_co_author`).
1546 + if may_commit {
1547 + let head_after = git_head(&cwd_path);
1548 + if head_after.is_some()
1549 + && head_after != head_before
1550 + && let Some(note) = ensure_commit_co_author(&cwd_path)
1551 + {
1552 + if !combined.is_empty() && !combined.ends_with('\n') {
1553 + combined.push('\n');
1554 + }
1555 + combined.push_str(&note);
1556 + }
1557 + }
1558 +
1559 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
1560 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
1561 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
@@ -2138,6 +2235,123 @@ mod tests {
2235 assert!(result.contains("Exit code 0"), "got: {result}");
2236 }
2237
2238 + /// Fresh git repo with one commit, test identity, and signing off (the
2239 + /// developer's global gpgsign must not leak into sandbox commits).
2240 + fn init_test_repo(name: &str) -> std::path::PathBuf {
2241 + let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id()));
2242 + let _ = fs::remove_dir_all(&dir);
2243 + fs::create_dir_all(&dir).unwrap();
2244 + test_git(&dir, &["init", "-q", "-b", "main"]);
2245 + test_git(&dir, &["config", "user.name", "Test User"]);
2246 + test_git(&dir, &["config", "user.email", "test@example.com"]);
2247 + test_git(&dir, &["config", "commit.gpgsign", "false"]);
2248 + fs::write(dir.join("file.txt"), "one\n").unwrap();
2249 + test_git(&dir, &["add", "file.txt"]);
2250 + test_git(&dir, &["commit", "-q", "-m", "Initial"]);
2251 + dir
2252 + }
2253 +
2254 + fn test_git(dir: &Path, args: &[&str]) {
2255 + let out = Command::new("git")
2256 + .args(args)
2257 + .current_dir(dir)
2258 + .output()
2259 + .unwrap();
2260 + assert!(
2261 + out.status.success(),
2262 + "git {args:?} failed: {}",
2263 + String::from_utf8_lossy(&out.stderr)
2264 + );
2265 + }
2266 +
2267 + #[test]
2268 + fn run_command_appends_co_author_trailer_to_new_commits() {
2269 + let dir = init_test_repo("coauthor_append");
2270 + fs::write(dir.join("file.txt"), "two\n").unwrap();
2271 + let args = serde_json::json!({
2272 + "command": "git add file.txt && git commit -m \"Update file\"",
2273 + "cwd": dir.display().to_string(),
2274 + })
2275 + .to_string();
2276 +
2277 + let result = exec_run_command(&args);
2278 + assert!(result.contains("co-author trailer"), "got: {result}");
2279 +
2280 + let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2281 + assert!(
2282 + message.ends_with(COMMIT_CO_AUTHOR_TRAILER),
2283 + "trailer must be the last line: {message:?}"
2284 + );
2285 + assert!(
2286 + message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")),
2287 + "trailer needs a blank line before it for GitHub to detect it: {message:?}"
2288 + );
2289 + let _ = fs::remove_dir_all(&dir);
2290 + }
2291 +
2292 + #[test]
2293 + fn run_command_keeps_existing_co_author_trailer() {
2294 + let dir = init_test_repo("coauthor_present");
2295 + fs::write(dir.join("file.txt"), "two\n").unwrap();
2296 + let command = format!(
2297 + "git add file.txt && git commit -m \"Update file\" -m \"{COMMIT_CO_AUTHOR_TRAILER}\""
2298 + );
2299 + let args = serde_json::json!({
2300 + "command": command,
2301 + "cwd": dir.display().to_string(),
2302 + })
2303 + .to_string();
2304 +
2305 + let result = exec_run_command(&args);
2306 + assert!(
2307 + !result.contains("[siGit Code]"),
2308 + "no amend expected: {result}"
2309 + );
2310 +
2311 + let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2312 + assert_eq!(
2313 + message.matches("Co-Authored-By: siGit Code").count(),
2314 + 1,
2315 + "trailer must not be duplicated: {message:?}"
2316 + );
2317 + let _ = fs::remove_dir_all(&dir);
2318 + }
2319 +
2320 + #[test]
2321 + fn run_command_never_amends_pushed_commits() {
2322 + let dir = init_test_repo("coauthor_pushed");
2323 + let remote = std::env::temp_dir().join(format!(
2324 + "sigit_test_coauthor_remote_{}.git",
2325 + std::process::id()
2326 + ));
2327 + let _ = fs::remove_dir_all(&remote);
2328 + fs::create_dir_all(&remote).unwrap();
2329 + test_git(&remote, &["init", "-q", "--bare"]);
2330 + test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]);
2331 +
2332 + fs::write(dir.join("file.txt"), "two\n").unwrap();
2333 + let args = serde_json::json!({
2334 + "command": "git add file.txt && git commit -m \"Update file\" && git push -q origin main",
2335 + "cwd": dir.display().to_string(),
2336 + })
2337 + .to_string();
2338 +
2339 + let result = exec_run_command(&args);
2340 + assert!(
2341 + !result.contains("[siGit Code]"),
2342 + "no amend expected: {result}"
2343 + );
2344 +
2345 + // Already on the remote when the gate ran, so it must be untouched.
2346 + let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2347 + assert!(
2348 + !message.contains("Co-Authored-By"),
2349 + "pushed commit must not be rewritten: {message:?}"
2350 + );
2351 + let _ = fs::remove_dir_all(&dir);
2352 + let _ = fs::remove_dir_all(&remote);
2353 + }
2354 +
2355 #[test]
2356 fn test_run_command_failure() {
2357 #[cfg(unix)]