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) {
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")
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(¬e);
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)")
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)]