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
22 changes: 22 additions & 0 deletions .github/scripts/comment-pr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const { owner, repo, number } = context.issue;
const commitSha = process.env.COMMIT_SHA;
const statsOutput = process.env.STATS_OUTPUT;
const eventName = process.env.EVENT_NAME;

let body;
let title = 'Live demo of git-ai:';

if (eventName === 'push') {
body = statsOutput;
} else {
body = statsOutput;
}

const comment = title + '\n\n' + body;

await github.rest.issues.createComment({
owner,
repo,
issue_number: number,
body: comment
});
60 changes: 60 additions & 0 deletions .github/workflows/pr-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: PR Comment on Merge

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
pull-requests: write
issues: write

jobs:
comment-on-merge:
name: Comment on Merged PR
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

# Grab the refs since actions shallow clones by default
- name: Fetch AI refs
run: |
git fetch origin refs/ai/*:refs/ai/*
git fetch origin refs/ai/*:refs/remotes/origin/ai/*

- name: Install git-ai
run: |
curl -sSL https://gitai.run/install.sh | bash
echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Get git-ai stats
id: git-ai-stats
run: |
export PATH="$HOME/.local/bin:$PATH"
COMMIT_SHA="${{ github.event.pull_request.head.sha }}"
git-ai stats $COMMIT_SHA > git_ai_stats.txt
echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT

- name: Comment on merged PR
uses: actions/github-script@v7
with:
script: |
const { owner, repo, number } = context.issue;
const commitSha = '${{ steps.git-ai-stats.outputs.commit_sha }}';

// Read stats from file to avoid YAML escaping issues
const fs = require('fs');
const statsOutput = fs.readFileSync('git_ai_stats.txt', 'utf8');

const title = '📝 **PR Commit Analysis**';
const body = '```\n' + statsOutput + '\n```';

await github.rest.issues.createComment({
owner,
repo,
issue_number: number,
body: title + '\n\n' + body
});
4 changes: 2 additions & 2 deletions src/commands/blame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ pub fn run(
Ok(line_authors)
}

fn get_git_blame_hunks(
pub fn get_git_blame_hunks(
repo: &Repository,
file_path: &str,
start_line: u32,
Expand Down Expand Up @@ -149,7 +149,7 @@ fn get_git_blame_hunks(
Ok(hunks)
}

fn overlay_ai_authorship(
pub fn overlay_ai_authorship(
repo: &Repository,
blame_hunks: &[BlameHunk],
file_path: &str,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ pub mod checkpoint;
pub mod init;
pub mod post_commit;
pub mod pre_commit;
pub mod stats;
pub use blame::run as blame;
pub use checkpoint::run as checkpoint;
pub use init::run as init;
pub use post_commit::run as post_commit;
pub use pre_commit::run as pre_commit;
pub use stats::run as stats;
169 changes: 169 additions & 0 deletions src/commands/stats.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
use crate::commands::blame::{get_git_blame_hunks, overlay_ai_authorship};
use crate::error::GitAiError;
use git2::{DiffOptions, Repository};
use std::collections::HashMap;

#[derive(Debug, Clone)]
pub struct FileStats {
pub additions: HashMap<String, u32>,
pub deletions: u32,
pub total_additions: u32,
}

pub fn run(repo: &Repository, sha: &str) -> Result<(), GitAiError> {
// Find the commit
let commit = repo.find_commit(repo.revparse_single(sha)?.id())?;

// Get the parent commit (for diff)
let parent = if let Ok(parent) = commit.parent(0) {
parent
} else {
return Err(GitAiError::Generic("Commit has no parent".to_string()));
};

// Get the diff between parent and commit
let tree = commit.tree()?;
let parent_tree = parent.tree()?;

let mut diff_opts = DiffOptions::new();
diff_opts.context_lines(0); // No context lines

let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), Some(&mut diff_opts))?;

// Process the diff and collect statistics
let mut file_stats = HashMap::new();
let mut total_additions_by_author = HashMap::new();
let mut total_deletions = 0;

// For each file, collect added and deleted lines
let mut file_added_lines: HashMap<String, Vec<u32>> = HashMap::new();
let mut file_deleted_counts: HashMap<String, u32> = HashMap::new();

diff.foreach(
&mut |_delta, _| true,
None,
None,
Some(&mut |delta, _hunk, line| {
let file_path = delta
.new_file()
.path()
.unwrap()
.to_string_lossy()
.to_string();
match line.origin() {
'+' => {
let line_num = line.new_lineno().unwrap_or(0) as u32;
file_added_lines
.entry(file_path)
.or_default()
.push(line_num);
}
'-' => {
*file_deleted_counts.entry(file_path).or_default() += 1;
total_deletions += 1;
}
_ => {}
}
true
}),
)?;

// For each file, use blame overlay logic to attribute added lines
for (file_path, added_lines) in file_added_lines.iter() {
// Get blame hunks for the file (for all lines, since we may not know the exact range)
let total_lines = {
// Try to get the file from the new tree
if let Ok(entry) = tree.get_path(std::path::Path::new(file_path)) {
if let Ok(blob) = repo.find_blob(entry.id()) {
let content = std::str::from_utf8(blob.content()).unwrap_or("");
content.lines().count() as u32
} else {
0
}
} else {
0
}
};
if total_lines == 0 {
continue;
}
let blame_hunks = match get_git_blame_hunks(repo, file_path, 1, total_lines) {
Ok(hunks) => hunks,
Err(_) => continue,
};
let line_authors = match overlay_ai_authorship(repo, &blame_hunks, file_path) {
Ok(authors) => authors,
Err(_) => continue,
};
let stats = file_stats.entry(file_path.clone()).or_insert(FileStats {
additions: HashMap::new(),
deletions: *file_deleted_counts.get(file_path).unwrap_or(&0),
total_additions: 0,
});
for &line_num in added_lines {
let author = line_authors
.get(&line_num)
.cloned()
.unwrap_or("unknown".to_string());
*stats.additions.entry(author.clone()).or_insert(0) += 1;
*total_additions_by_author.entry(author).or_insert(0) += 1;
stats.total_additions += 1;
}
}
// For files with only deletions
for (file_path, &del_count) in file_deleted_counts.iter() {
file_stats.entry(file_path.clone()).or_insert(FileStats {
additions: HashMap::new(),
deletions: del_count,
total_additions: 0,
});
}

// Print the statistics
print_stats(&file_stats, &total_additions_by_author, total_deletions);

Ok(())
}

fn print_stats(
file_stats: &HashMap<String, FileStats>,
total_additions_by_author: &HashMap<String, u32>,
total_deletions: u32,
) {
println!("{}", "=".repeat(50));

// Print per-file statistics
for (file_path, stats) in file_stats.iter() {
print_file_stats(file_path, stats);
}

// Print totals
println!("\nTotal Additions:");
for (author, count) in total_additions_by_author.iter() {
println!(" {} +{}", author, count);
}

println!("\nTotal Deletions: -{}", total_deletions);
}

fn print_file_stats(file_path: &str, stats: &FileStats) {
// Calculate total changes for the file
let total_additions = stats.total_additions;
let total_deletions = stats.deletions;

// Print file header with total changes
println!(
"\n{} (+{} -{})",
file_path, total_additions, total_deletions
);

// Print additions by author
for (author, count) in stats.additions.iter() {
println!(" {} (+{})", author, count);
}

// Print deletions (no author attribution)
if stats.deletions > 0 {
println!(" -{}", stats.deletions);
}
}
9 changes: 9 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ enum Commands {
/// file to blame (can include line range like "file.rs:10-20")
file: String,
},
/// show authorship statistics for a commit
Stats {
/// commit SHA to analyze (defaults to HEAD)
sha: Option<String>,
},
PreCommit,
PostCommit {
/// force execution even if working directory is not clean
Expand Down Expand Up @@ -102,6 +107,10 @@ fn main() {
// Convert the blame result to unit result to match other commands
commands::blame(&repo, &file_path, line_range).map(|_| ())
}
Commands::Stats { sha } => {
let sha = sha.as_deref().unwrap_or("HEAD");
commands::stats(&repo, sha)
}
Commands::PreCommit => commands::pre_commit(&repo, default_user_name),
Commands::PostCommit { force } => {
commands::post_commit(&repo, *force).unwrap();
Expand Down
Loading