diff --git a/scripts/capture-cursor.py b/scripts/capture-cursor.py index e5824dc..855b622 100644 --- a/scripts/capture-cursor.py +++ b/scripts/capture-cursor.py @@ -38,6 +38,80 @@ def first(payload: dict, *keys, default=None): return default +def coerce_line(value): + try: + n = int(value) + return n if n > 0 else None + except Exception: + return None + + +def _add_line_range(out: set, start, end=None): + start_n = coerce_line(start) + end_n = coerce_line(end if end is not None else start) + if start_n is None or end_n is None: + return + for ln in range(min(start_n, end_n), max(start_n, end_n) + 1): + out.add(ln) + + +def _extract_line_from_position(value): + if isinstance(value, dict): + return first(value, "line", "lineNumber", "line_number") + return value + + +def _add_lines_from_range_object(out: set, value): + if not isinstance(value, dict): + return + + start = first(value, "startLine", "start_line", "line_start", "line", "lineNumber", "line_number") + end = first(value, "endLine", "end_line", "line_end", default=start) + if start is not None: + _add_line_range(out, start, end) + return + + start_pos = _extract_line_from_position(value.get("start")) + end_pos = _extract_line_from_position(value.get("end")) + if start_pos is not None: + _add_line_range(out, start_pos, end_pos if end_pos is not None else start_pos) + + +def extract_lines(value): + """Extract 1-indexed line numbers from common Cursor hook payload shapes.""" + out = set() + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + _add_lines_from_range_object(out, item) + else: + line = coerce_line(item) + if line is not None: + out.add(line) + elif isinstance(value, dict): + _add_lines_from_range_object(out, value) + else: + line = coerce_line(value) + if line is not None: + out.add(line) + return sorted(out) + + +def extract_lines_from_changes(changes): + out = set() + if not isinstance(changes, list): + return [] + for change in changes: + if not isinstance(change, dict): + continue + before = len(out) + _add_lines_from_range_object(out, change) + if len(out) == before: + for key in ("range", "newRange", "new_range", "selection", "targetSelection"): + _add_lines_from_range_object(out, change.get(key)) + return sorted(out) + + def find_repo_root(cwd: str) -> str: try: result = subprocess.run( @@ -260,20 +334,12 @@ def main(): # Line numbers from payload if event_name == "afterFileEdit": - old_lines = first(payload, "old_lines", "oldLines", default=[]) - new_lines = first(payload, "new_lines", "newLines", default=[]) - if not isinstance(old_lines, list): - old_lines = [] - if not isinstance(new_lines, list): - new_lines = [] - if not new_lines and isinstance(payload.get("changes"), list): - for ch in payload["changes"]: - if not isinstance(ch, dict): - continue - start = ch.get("startLine") or ch.get("line_start") - end = ch.get("endLine") or ch.get("line_end") or start - if isinstance(start, int) and isinstance(end, int): - new_lines.extend(list(range(min(start, end), max(start, end) + 1))) + old_lines = extract_lines(first(payload, "old_lines", "oldLines", default=[])) + new_lines = extract_lines(first(payload, "new_lines", "newLines", "changed_lines", "changedLines", "lines", default=[])) + if not new_lines: + new_lines = extract_lines_from_changes(payload.get("changes")) + if not new_lines: + new_lines = extract_lines_from_changes(payload.get("edits")) entry["lines"] = new_lines if new_lines else old_lines debug_log( diff --git a/scripts/tests/test_capture_cursor.py b/scripts/tests/test_capture_cursor.py index da90b15..8e02b79 100644 --- a/scripts/tests/test_capture_cursor.py +++ b/scripts/tests/test_capture_cursor.py @@ -28,6 +28,28 @@ def test_normalize_windows_style_path(self): normalized = self.mod.normalize_path(r"\home\prakh\repo\src\main.rs", "/tmp") self.assertEqual(normalized, "/home/prakh/repo/src/main.rs") + def test_extract_lines_from_cursor_change_ranges(self): + payload = [ + {"startLine": 3, "endLine": 5}, + {"range": {"start": {"line": 8}, "end": {"line": 9}}}, + {"line_number": "12"}, + ] + self.assertEqual(self.mod.extract_lines_from_changes(payload), [3, 4, 5, 8, 9, 12]) + + def test_extract_lines_prefers_top_level_range_over_nested_selection(self): + payload = [ + { + "startLine": 10, + "endLine": 10, + "selection": {"start": {"line": 5}, "end": {"line": 20}}, + } + ] + self.assertEqual(self.mod.extract_lines_from_changes(payload), [10]) + + def test_extract_lines_accepts_mixed_line_lists(self): + payload = [2, "4", {"start_line": 7, "end_line": 8}, 0, "nope"] + self.assertEqual(self.mod.extract_lines(payload), [2, 4, 7, 8]) + def test_session_log_uses_repo_hint(self): with tempfile.TemporaryDirectory() as tmp: repo = Path(tmp) / "repo" diff --git a/src/commands/diff.rs b/src/commands/diff.rs index cce5d42..ad41a9e 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -13,7 +13,8 @@ pub fn run(store: &Store, args: &DiffArgs) -> Result<()> { let commit = args.commit.as_deref().unwrap_or("HEAD"); let entries = store.load_entries()?; - // Run git diff to get changed lines + // Run a zero-context diff so changed-line parsing is not polluted by + // surrounding context lines. let diff_output = run_git_diff(&store.repo_root, commit)?; let changed = parse_diff_hunks(&diff_output); @@ -69,7 +70,7 @@ pub fn run(store: &Store, args: &DiffArgs) -> Result<()> { total_changed, total_ai, if total_changed > 0 { - (total_ai as f64 / total_changed as f64 * 100.0) + total_ai as f64 / total_changed as f64 * 100.0 } else { 0.0 } @@ -79,52 +80,116 @@ pub fn run(store: &Store, args: &DiffArgs) -> Result<()> { } fn run_git_diff(repo_root: &std::path::Path, commit: &str) -> Result { + let spec = diff_spec(commit); let output = std::process::Command::new("git") - .args(["diff", commit]) + .args(["diff", "--unified=0", "--no-color", "--no-ext-diff", &spec]) .current_dir(repo_root) .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git diff failed: {}", stderr.trim()); + } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } +fn diff_spec(commit: &str) -> String { + if commit.contains("..") { + commit.to_string() + } else { + format!("{commit}^!") + } +} + fn parse_diff_hunks(diff: &str) -> std::collections::HashMap> { let mut result: std::collections::HashMap> = std::collections::HashMap::new(); let mut current_file = String::new(); - let mut base_line: u32 = 0; + let mut new_line: u32 = 0; for line in diff.lines() { if line.starts_with("diff --git") { - // Extract file from "diff --git a/path b/path" - if let Some(path) = line.split_whitespace().last() { - // Remove "b/" prefix + current_file.clear(); + continue; + } + + if let Some(path) = line.strip_prefix("+++ ") { + if path == "/dev/null" { + current_file.clear(); + } else { current_file = path.trim_start_matches("b/").to_string(); } - } else if line.starts_with("@@") { + continue; + } + + if line.starts_with("@@") { // Parse hunk header: @@ -start,count +start,count @@ if let Some(end) = line.find(" @@") { let header = &line[4..end]; if let Some(pos) = header.find("+") { let after_plus = &header[pos + 1..]; if let Some(comma) = after_plus.find(',') { - base_line = after_plus[..comma].parse().unwrap_or(1); + new_line = after_plus[..comma].parse().unwrap_or(1); } else { - base_line = after_plus.parse().unwrap_or(1); + new_line = after_plus.parse().unwrap_or(1); } } } - } else if (line.starts_with('+') || line.starts_with(' ')) - && !line.starts_with("+++") - && !line.starts_with("index") - { - // Added or context line + continue; + } + + if line.starts_with('+') && !line.starts_with("+++") { if !current_file.is_empty() { result .entry(current_file.clone()) .or_default() - .push(base_line); + .push(new_line); } - base_line += 1; + new_line += 1; + } else if line.starts_with(' ') { + new_line += 1; } } + for lines in result.values_mut() { + lines.sort_unstable(); + lines.dedup(); + } + result } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_commit_diff_uses_commit_changes_not_worktree_delta() { + assert_eq!(diff_spec("HEAD"), "HEAD^!"); + assert_eq!(diff_spec("abc123"), "abc123^!"); + } + + #[test] + fn explicit_range_is_preserved() { + assert_eq!(diff_spec("main..HEAD"), "main..HEAD"); + assert_eq!(diff_spec("main...HEAD"), "main...HEAD"); + } + + #[test] + fn parses_only_new_side_changed_lines() { + let diff = r#"diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -10,3 +10,3 @@ + context +-old ++new + context +@@ -20,0 +21,2 @@ ++added one ++added two +"#; + + let changed = parse_diff_hunks(diff); + assert_eq!(changed.get("src/lib.rs"), Some(&vec![11, 21, 22])); + } +} diff --git a/src/commands/install_ci.rs b/src/commands/install_ci.rs index 517102b..71c705d 100644 --- a/src/commands/install_ci.rs +++ b/src/commands/install_ci.rs @@ -54,6 +54,7 @@ on: permissions: contents: read checks: write + pull-requests: write jobs: policy: @@ -67,6 +68,10 @@ jobs: run: | git fetch origin '+refs/agentdiff/*:refs/agentdiff/*' || true + - name: Check out PR head branch + run: | + git checkout -B "${{ github.head_ref }}" "${{ github.event.pull_request.head.sha }}" + - name: Install agentdiff run: | curl -fsSL https://raw.githubusercontent.com/codeprakhar25/agentdiff/main/install.sh | bash @@ -75,6 +80,14 @@ jobs: - name: Check policy run: | agentdiff policy check --format github-annotations + + - name: Post attribution comment + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: | + PR="${{ github.event.pull_request.number }}" + agentdiff report --format markdown --post-pr-comment "$PR" || true "#; pub fn run(repo_root: &Path, args: &InstallCiArgs) -> Result<()> { @@ -86,18 +99,35 @@ pub fn run(repo_root: &Path, args: &InstallCiArgs) -> Result<()> { let consolidate_path = workflows_dir.join("agentdiff-consolidate.yml"); let policy_path = workflows_dir.join("agentdiff-policy.yml"); - write_workflow(&consolidate_path, CONSOLIDATE_WORKFLOW, args.force, "agentdiff-consolidate.yml")?; - write_workflow(&policy_path, POLICY_WORKFLOW, args.force, "agentdiff-policy.yml")?; + write_workflow( + &consolidate_path, + CONSOLIDATE_WORKFLOW, + args.force, + "agentdiff-consolidate.yml", + )?; + write_workflow( + &policy_path, + POLICY_WORKFLOW, + args.force, + "agentdiff-policy.yml", + )?; println!(); println!(" {}", "install-ci complete".bold().green()); println!(); println!(" Next steps:"); println!(" 1. Commit the workflow files:"); - println!(" git add .github/workflows/agentdiff-consolidate.yml .github/workflows/agentdiff-policy.yml"); + println!( + " git add .github/workflows/agentdiff-consolidate.yml .github/workflows/agentdiff-policy.yml" + ); println!(" git commit -m 'ci: add agentdiff consolidation and policy workflows'"); - println!(" 2. Ensure each developer runs: {}", "agentdiff init".cyan()); - println!(" 3. On merge, traces auto-consolidate and an attribution comment is posted to the PR."); + println!( + " 2. Ensure each developer runs: {}", + "agentdiff init".cyan() + ); + println!( + " 3. On merge, traces auto-consolidate and an attribution comment is posted to the PR." + ); Ok(()) } diff --git a/src/commands/push.rs b/src/commands/push.rs index c7226eb..dca62cd 100644 --- a/src/commands/push.rs +++ b/src/commands/push.rs @@ -2,7 +2,7 @@ use anyhow::Result; use crate::cli::PushArgs; use crate::store::{self, Store}; -use crate::util::ok; +use crate::util::{ok, warn}; pub fn run(store: &Store, args: &PushArgs) -> Result<()> { let branch = match &args.branch { @@ -29,12 +29,19 @@ pub fn run(store: &Store, args: &PushArgs) -> Result<()> { // Read existing remote traces via GitHub API (avoids needing a prior git fetch) let ref_name = store::branch_ref_name(&branch); - let remote_traces_raw = store::fetch_ref_content_via_api( - &store.repo_root, - &ref_name, - "traces.jsonl", - ) - .unwrap_or(None); + let mut remote_read_failed = false; + let remote_traces_raw = + match store::fetch_ref_content_via_api(&store.repo_root, &ref_name, "traces.jsonl") { + Ok(raw) => raw, + Err(e) => { + let msg = e.to_string(); + remote_read_failed = true; + if !msg.contains("not a GitHub URL") && !args.quiet { + eprintln!("agentdiff: warn — could not read remote traces from GitHub: {e}"); + } + None + } + }; let mut remote_traces = if let Some(raw) = remote_traces_raw { store::parse_traces_from_jsonl(&raw) } else { @@ -56,32 +63,61 @@ pub fn run(store: &Store, args: &PushArgs) -> Result<()> { // Always write the local ref first — consolidate reads from this ref and // it must be present even when there is no GitHub remote. - let _ = store::write_to_ref( + if let Err(e) = store::write_to_ref( &store.repo_root, &ref_name, "traces.jsonl", &jsonl, &format!("agentdiff: traces for {branch}"), - ); + ) { + if !args.quiet { + eprintln!("agentdiff: warn — could not write local trace ref: {e}"); + } + } + + if new_count == 0 { + prune_local_traces(&local_path, args.quiet)?; + if !args.quiet { + println!( + " {} traces for branch '{}' already up to date ({} total on ref)", + ok(), + branch, + remote_traces.len() + ); + } + return Ok(()); + } // Best-effort push to GitHub via the Git Database API. // Non-fatal: local repos (no GitHub remote) or unauthenticated machines // will fail here but the local ref is already updated above. - if let Err(e) = store::push_content_to_ref( - &store.repo_root, - &ref_name, - "traces.jsonl", - &jsonl, - &format!("agentdiff: traces for {branch}"), - ) { - // Suppress "not a GitHub URL" noise for local repos; warn on real errors. - let msg = e.to_string(); - if !msg.contains("not a GitHub URL") && !args.quiet { - eprintln!("agentdiff: warn — could not push traces to GitHub: {e}"); + let remote_pushed = if remote_read_failed { + false + } else { + match store::push_content_to_ref( + &store.repo_root, + &ref_name, + "traces.jsonl", + &jsonl, + &format!("agentdiff: traces for {branch}"), + ) { + Ok(_) => true, + Err(e) => { + // Suppress "not a GitHub URL" noise for local repos; warn on real errors. + let msg = e.to_string(); + if !msg.contains("not a GitHub URL") && !args.quiet { + eprintln!("agentdiff: warn — could not push traces to GitHub: {e}"); + } + false + } } + }; + + if remote_pushed { + prune_local_traces(&local_path, args.quiet)?; } - if !args.quiet { + if !args.quiet && remote_pushed { println!( " {} pushed {} trace(s) for branch '{}' ({} total on ref)", ok(), @@ -89,7 +125,26 @@ pub fn run(store: &Store, args: &PushArgs) -> Result<()> { branch, remote_traces.len() ); + } else if !args.quiet { + println!( + " {} wrote {} trace(s) for branch '{}' to local ref; remote sync pending", + warn(), + new_count, + branch + ); } Ok(()) } + +fn prune_local_traces(path: &std::path::Path, quiet: bool) -> Result<()> { + if !path.exists() { + return Ok(()); + } + + std::fs::remove_file(path)?; + if !quiet { + println!(" {} cleared local trace buffer", ok()); + } + Ok(()) +} diff --git a/src/commands/report.rs b/src/commands/report.rs index d8c8841..9912361 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -24,8 +24,8 @@ pub fn run(store: &Store, args: &ReportArgs) -> Result<()> { let mut entries = store.load_entries()?; if let Some(ref since) = args.since { - if let Ok(since_ts) = chrono::DateTime::parse_from_rfc3339(since) - .map(|dt| dt.with_timezone(&chrono::Utc)) + if let Ok(since_ts) = + chrono::DateTime::parse_from_rfc3339(since).map(|dt| dt.with_timezone(&chrono::Utc)) { entries.retain(|e| e.timestamp >= since_ts); } @@ -67,7 +67,7 @@ fn post_pr_comment(repo_root: &Path, body: &str, pr_number: Option) -> Resu cmd.arg(n.to_string()); } - cmd.args(["--body", body]) + cmd.args(["--body", body, "--edit-last", "--create-if-none"]) .current_dir(repo_root) .stdin(Stdio::null()) .stdout(Stdio::inherit()) @@ -241,8 +241,7 @@ fn write_or_stdout(path: Option<&Path>, content: &str) -> Result<()> { fs::create_dir_all(parent) .with_context(|| format!("creating {}", parent.display()))?; } - fs::write(p, content.as_bytes()) - .with_context(|| format!("writing {}", p.display()))?; + fs::write(p, content.as_bytes()).with_context(|| format!("writing {}", p.display()))?; } None => { print!("{content}");