Skip to content

Commit 6f9c87b

Browse files
hooks: Implement rebase-only post-rewrite JSON evidence capture
Add post-rewrite hook runtime that captures Git post-rewrite rebase STDIN as a structured JSON artifact under context/tmp/post-rewrite/. Non-rebase methods (amend, other) remain deterministic no-ops. Include the new post-rewrite hook asset in the canonical SCE-managed required-hook set (setup --hooks, doctor install/repair), and update context documentation to reflect the active rebase capture posture without remap/AgentTraceDb/rebuild behavior. Touches: cli/src/services/hooks/mod.rs (parser, payload, persistence), cli/assets/hooks/post-rewrite (hook template), cli/src/services/setup/mod.rs (hook asset inventory), cli/src/services/doctor/mod.rs (required hook count), cli/src/services/default_paths.rs (hook path constant), and 13 context/ files. Co-authored-by: SCE <sce@crocoder.dev>
1 parent c4c3b8e commit 6f9c87b

19 files changed

Lines changed: 329 additions & 31 deletions

cli/assets/hooks/post-rewrite

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
exec sce hooks post-rewrite "$@"

cli/src/services/default_paths.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ pub(crate) mod hook_dir {
337337
pub const PRE_COMMIT: &str = "pre-commit";
338338
pub const COMMIT_MSG: &str = "commit-msg";
339339
pub const POST_COMMIT: &str = "post-commit";
340+
pub const POST_REWRITE: &str = "post-rewrite";
340341
}
341342

342343
#[allow(dead_code)]

cli/src/services/doctor/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ use types::{
3030

3131
pub const NAME: &str = "doctor";
3232

33-
pub(super) const REQUIRED_HOOKS: [&str; 3] = ["pre-commit", "commit-msg", "post-commit"];
33+
pub(super) const REQUIRED_HOOKS: [&str; 4] =
34+
["pre-commit", "commit-msg", "post-commit", "post-rewrite"];
3435

3536
pub type DoctorFormat = OutputFormat;
3637

cli/src/services/hooks/mod.rs

Lines changed: 253 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ struct DiffTracePayload {
6161
tool_version: Option<String>,
6262
}
6363

64+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
65+
struct RewrittenCommitPair {
66+
old_oid: String,
67+
new_oid: String,
68+
}
69+
70+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
71+
struct PostRewriteRebasePayload {
72+
method: String,
73+
capture_timestamp: String,
74+
repository_root: String,
75+
git_environment: BTreeMap<String, String>,
76+
raw_stdin: String,
77+
parsed_pairs: Vec<RewrittenCommitPair>,
78+
parse_diagnostics: Vec<String>,
79+
head_oid: Option<String>,
80+
head_patch: Option<String>,
81+
}
82+
6483
/// Required `sce hooks diff-trace` STDIN payload shape:
6584
/// `{ sessionID, diff, time, model_id, tool_name, tool_version }`.
6685
///
@@ -746,8 +765,112 @@ fn run_post_rewrite_subcommand_with_trace(
746765
_: &HookSubcommand,
747766
rewrite_method: &str,
748767
) -> Result<String> {
749-
let stdin_payload = read_hook_stdin();
750-
stdin_payload.and_then(|_| run_post_rewrite_subcommand(repository_root, rewrite_method))
768+
let method = rewrite_method.trim();
769+
770+
if method == "rebase" {
771+
let runtime = resolve_runtime_state(repository_root)?;
772+
if !runtime.sce_disabled {
773+
let stdin_payload = read_hook_stdin()?;
774+
return run_post_rewrite_rebase_subcommand(repository_root, method, &stdin_payload);
775+
}
776+
}
777+
778+
run_post_rewrite_subcommand(repository_root, method)
779+
}
780+
781+
fn parse_post_rewrite_stdin(input: &str) -> (Vec<RewrittenCommitPair>, Vec<String>) {
782+
let mut pairs = Vec::new();
783+
let mut diagnostics = Vec::new();
784+
785+
for (line_index, line) in input.lines().enumerate() {
786+
let trimmed = line.trim();
787+
if trimmed.is_empty() {
788+
continue;
789+
}
790+
791+
let parts: Vec<&str> = trimmed.split_whitespace().collect();
792+
if parts.len() >= 2 {
793+
pairs.push(RewrittenCommitPair {
794+
old_oid: parts[0].to_string(),
795+
new_oid: parts[1].to_string(),
796+
});
797+
if parts.len() > 2 {
798+
diagnostics.push(format!(
799+
"Line {}: unexpected content after commit pair: '{}'",
800+
line_index + 1,
801+
parts[2..].join(" ")
802+
));
803+
}
804+
} else {
805+
diagnostics.push(format!(
806+
"Line {}: expected '<old-oid> <new-oid>', got: '{}'",
807+
line_index + 1,
808+
trimmed
809+
));
810+
}
811+
}
812+
813+
(pairs, diagnostics)
814+
}
815+
816+
fn run_post_rewrite_rebase_subcommand(
817+
repository_root: &Path,
818+
method: &str,
819+
stdin_payload: &str,
820+
) -> Result<String> {
821+
let (parsed_pairs, parse_diagnostics) = parse_post_rewrite_stdin(stdin_payload);
822+
let capture_timestamp = Utc::now().to_rfc3339();
823+
let git_environment = collect_git_environment();
824+
825+
let head_oid = run_git_command_capture_stdout(
826+
repository_root,
827+
&["rev-parse", "HEAD"],
828+
"Failed to capture HEAD revision from git for post-rewrite rebase evidence.",
829+
)
830+
.ok();
831+
832+
let head_patch = run_git_command_capture_stdout(
833+
repository_root,
834+
&["show", "--format=", "--patch", "--no-ext-diff", "HEAD"],
835+
"Failed to capture HEAD patch from git for post-rewrite rebase evidence.",
836+
)
837+
.ok();
838+
839+
let payload = PostRewriteRebasePayload {
840+
method: method.to_string(),
841+
capture_timestamp,
842+
repository_root: repository_root.to_string_lossy().to_string(),
843+
git_environment,
844+
raw_stdin: stdin_payload.to_string(),
845+
parsed_pairs,
846+
parse_diagnostics,
847+
head_oid,
848+
head_patch,
849+
};
850+
851+
let serialized = format!(
852+
"{}\n",
853+
serde_json::to_string_pretty(&payload)
854+
.context("Failed to serialize post-rewrite rebase payload for persistence.")?
855+
);
856+
857+
let artifact_directory = repository_root
858+
.join("context")
859+
.join("tmp")
860+
.join("post-rewrite");
861+
862+
persist_serialized_trace_payload(
863+
&artifact_directory,
864+
"post-rewrite-rebase",
865+
&serialized,
866+
"post-rewrite rebase evidence",
867+
)?;
868+
869+
Ok(format!(
870+
"post-rewrite hook captured rebase evidence: {} pairs, {} diagnostic(s), artifact in context/tmp/post-rewrite/.",
871+
payload.parsed_pairs.len(),
872+
payload.parse_diagnostics.len()
873+
))
751874
}
752875

753876
fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str {
@@ -1172,4 +1295,132 @@ mod tests {
11721295
assert_eq!(output.tool_name, Some(String::from("opencode")));
11731296
assert_eq!(output.tool_version, Some(String::from("1.2.3")));
11741297
}
1298+
1299+
// --- post-rewrite rebase capture tests ---
1300+
1301+
#[test]
1302+
fn parse_post_rewrite_stdin_valid_lines() {
1303+
let input = "abc123 def456\n\n789abc def012\n";
1304+
let (pairs, diagnostics) = parse_post_rewrite_stdin(input);
1305+
1306+
assert_eq!(pairs.len(), 2);
1307+
assert_eq!(
1308+
pairs[0],
1309+
RewrittenCommitPair {
1310+
old_oid: "abc123".to_string(),
1311+
new_oid: "def456".to_string(),
1312+
}
1313+
);
1314+
assert_eq!(
1315+
pairs[1],
1316+
RewrittenCommitPair {
1317+
old_oid: "789abc".to_string(),
1318+
new_oid: "def012".to_string(),
1319+
}
1320+
);
1321+
assert!(
1322+
diagnostics.is_empty(),
1323+
"expected no diagnostics for valid input"
1324+
);
1325+
}
1326+
1327+
#[test]
1328+
fn parse_post_rewrite_stdin_malformed_lines() {
1329+
let input = "abc123 def456\nnot-a-pair\n789abc def012\n";
1330+
let (pairs, diagnostics) = parse_post_rewrite_stdin(input);
1331+
1332+
assert_eq!(
1333+
pairs.len(),
1334+
2,
1335+
"valid lines should be parsed despite malformed line"
1336+
);
1337+
assert_eq!(
1338+
diagnostics.len(),
1339+
1,
1340+
"malformed line should produce one diagnostic"
1341+
);
1342+
assert!(
1343+
diagnostics[0].contains("not-a-pair"),
1344+
"diagnostic should reference the malformed content"
1345+
);
1346+
assert!(
1347+
diagnostics[0].contains("Line 2"),
1348+
"diagnostic should reference the correct line number"
1349+
);
1350+
}
1351+
1352+
#[test]
1353+
fn parse_post_rewrite_stdin_empty_input() {
1354+
let input = "";
1355+
let (pairs, diagnostics) = parse_post_rewrite_stdin(input);
1356+
1357+
assert!(pairs.is_empty(), "empty input should produce no pairs");
1358+
assert!(
1359+
diagnostics.is_empty(),
1360+
"empty input should produce no diagnostics"
1361+
);
1362+
}
1363+
1364+
#[test]
1365+
fn parse_post_rewrite_stdin_extra_content() {
1366+
let input = "abc123 def456 extra trailing content\n";
1367+
let (pairs, diagnostics) = parse_post_rewrite_stdin(input);
1368+
1369+
assert_eq!(
1370+
pairs.len(),
1371+
1,
1372+
"pair should be parsed from line with extra content"
1373+
);
1374+
assert_eq!(
1375+
diagnostics.len(),
1376+
1,
1377+
"extra content should produce one diagnostic"
1378+
);
1379+
assert!(
1380+
diagnostics[0].contains("trailing content"),
1381+
"diagnostic should reference the extra content"
1382+
);
1383+
}
1384+
1385+
#[test]
1386+
fn parse_post_rewrite_stdin_blank_lines_skipped() {
1387+
let input = "abc123 def456\n\n\n789abc def012\n";
1388+
let (pairs, diagnostics) = parse_post_rewrite_stdin(input);
1389+
1390+
assert_eq!(pairs.len(), 2, "blank lines should be skipped");
1391+
assert!(
1392+
diagnostics.is_empty(),
1393+
"blank lines should not produce diagnostics"
1394+
);
1395+
}
1396+
1397+
#[test]
1398+
fn post_rewrite_amend_method_returns_no_op() {
1399+
let result = run_post_rewrite_subcommand(Path::new("/fake/repo"), "amend");
1400+
assert!(result.is_ok(), "post-rewrite amend should succeed");
1401+
let output = result.expect("already checked is_ok");
1402+
assert!(
1403+
output.contains("no-op runtime state"),
1404+
"amend method should report no-op: got '{output}'"
1405+
);
1406+
assert!(
1407+
output.contains("rewrite_method='amend'"),
1408+
"amend method should be included in output: got '{output}'"
1409+
);
1410+
}
1411+
1412+
#[test]
1413+
fn post_rewrite_other_method_returns_no_op() {
1414+
let result = run_post_rewrite_subcommand(Path::new("/fake/repo"), "other");
1415+
assert!(result.is_ok(), "post-rewrite other should succeed");
1416+
let output = result.expect("already checked is_ok");
1417+
assert!(
1418+
output.contains("no-op runtime state"),
1419+
"other method should report no-op: got '{output}'"
1420+
);
1421+
assert!(
1422+
output.contains("rewrite_method='other'"),
1423+
"other method should be included in output: got '{output}'"
1424+
);
1425+
}
11751426
}

cli/src/services/setup/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub enum RequiredHookAsset {
3636
PreCommit,
3737
CommitMsg,
3838
PostCommit,
39+
PostRewrite,
3940
}
4041

4142
include!(concat!(env!("OUT_DIR"), "/setup_embedded_assets.rs"));
@@ -50,6 +51,7 @@ pub fn get_required_hook_asset(hook: RequiredHookAsset) -> Option<&'static Embed
5051
RequiredHookAsset::PreCommit => default_paths::hook_dir::PRE_COMMIT,
5152
RequiredHookAsset::CommitMsg => default_paths::hook_dir::COMMIT_MSG,
5253
RequiredHookAsset::PostCommit => default_paths::hook_dir::POST_COMMIT,
54+
RequiredHookAsset::PostRewrite => default_paths::hook_dir::POST_REWRITE,
5355
};
5456

5557
HOOK_EMBEDDED_ASSETS
@@ -1098,3 +1100,31 @@ where
10981100
pub fn setup_cancelled_text() -> String {
10991101
value("Setup cancelled. No files were changed.")
11001102
}
1103+
1104+
#[cfg(test)]
1105+
mod tests {
1106+
use super::*;
1107+
1108+
#[test]
1109+
fn required_hook_assets_contains_four_entries() {
1110+
let assets: Vec<_> = iter_required_hook_assets().collect();
1111+
let filenames: Vec<&str> = assets.iter().map(|a| a.relative_path).collect();
1112+
assert_eq!(assets.len(), 4, "expected exactly 4 required hooks");
1113+
assert!(
1114+
filenames.contains(&"post-rewrite"),
1115+
"post-rewrite must be in the required hook set: {filenames:?}"
1116+
);
1117+
assert!(
1118+
filenames.contains(&"pre-commit"),
1119+
"pre-commit must be in the required hook set: {filenames:?}"
1120+
);
1121+
assert!(
1122+
filenames.contains(&"commit-msg"),
1123+
"commit-msg must be in the required hook set: {filenames:?}"
1124+
);
1125+
assert!(
1126+
filenames.contains(&"post-commit"),
1127+
"post-commit must be in the required hook set: {filenames:?}"
1128+
);
1129+
}
1130+
}

context/architecture.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)