Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 80 additions & 14 deletions scripts/capture-cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +100 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Potential over-attribution when change has both top-level range and nested range/selection

When a change dict carries both a top-level startLine/endLine and a nested range or selection key (a plausible Cursor payload, e.g. edit at line 10 inside a selected block spanning lines 5–20), _add_lines_from_range_object(out, change) adds only lines 10–10, but the subsequent for key loop then also calls _add_lines_from_range_object(out, change.get("range")), adding lines 5–20. The union over-attributes the surrounding selection as AI-authored. If a specific top-level range is already resolved, there is no reason to also expand from the nested range keys.

Suggested change
def extract_lines_from_changes(changes):
out = set()
if not isinstance(changes, list):
return []
for change in changes:
if not isinstance(change, dict):
continue
_add_lines_from_range_object(out, change)
for key in ("range", "newRange", "new_range", "selection", "targetSelection"):
_add_lines_from_range_object(out, change.get(key))
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:
# No top-level line info; try nested range keys.
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(
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions scripts/tests/test_capture_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
99 changes: 82 additions & 17 deletions src/commands/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
}
Expand All @@ -79,52 +80,116 @@ pub fn run(store: &Store, args: &DiffArgs) -> Result<()> {
}

fn run_git_diff(repo_root: &std::path::Path, commit: &str) -> Result<String> {
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())
}
Comment on lines 82 to 93
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Git exit code not checked — silent empty diff on failure

Command::output()? captures stdout but does not inspect output.status. If git diff HEAD^! exits non-zero (e.g. the initial commit has no parent, a typo in the ref, or a shallow clone), stdout is empty and parse_diff_hunks returns an empty map. The diff command then silently prints "0 lines changed, 0 AI-attributed" with no indication of the underlying error. The switch to {commit}^! makes this edge case more likely to surface (initial commits, shallow clones).

Suggested change
fn run_git_diff(repo_root: &std::path::Path, commit: &str) -> Result<String> {
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()?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn run_git_diff(repo_root: &std::path::Path, commit: &str) -> Result<String> {
let spec = diff_spec(commit);
let output = std::process::Command::new("git")
.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<String, Vec<u32>> {
let mut result: std::collections::HashMap<String, Vec<u32>> = 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]));
}
}
40 changes: 35 additions & 5 deletions src/commands/install_ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ on:
permissions:
contents: read
checks: write
pull-requests: write

jobs:
policy:
Expand All @@ -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
Expand All @@ -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<()> {
Expand All @@ -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(())
}
Expand Down
Loading
Loading