Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • 🔮 Predictions
  • 📰 Breaking News
  • 🎨 AI Art
  • 📖 Short Stories
  • View All →
  • Products →

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

© 2021-2026 Crashbytes® by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Build an AI PR Reviewer in C# — Part 1: Git Diff Parser From Scratch
TutorialFebruary 28, 202626 min read• By Michael Eakins

Build an AI PR Reviewer in C# — Part 1: Git Diff Parser From Scratch

Learn how to build a C# console application that captures and parses git diffs using pure .NET 8. Part 1 of a 3-part YouTube tutorial series covering Process execution, unified diff parsing, and CLI tool development with zero external dependencies.

Build an AI PR Reviewer in C# — Part 1: Git Diff Parser From Scratch

Quick Takeaways

What you'll learn in this article

26 min read
Intermediate
  • 1

    OutputType: Exe — This is a console application, not a library

  • 2

    TargetFramework: net8.0 — We're targeting .NET 8, the current LTS release

  • 3

    RootNamespace: AiPrReviewer — The default namespace for our code

  • 4

    ImplicitUsings: enable — Automatically imports System, System.Collections.Generic, System.Linq, System.IO, and other common namespaces so we don't need using statements for basic types

  • 5

    Nullable: enable — Turns on nullable reference types, which forces us to think about null safety at compile time

Keep reading for detailed implementation, code examples, and real-world results

Every production codebase needs code review. Every team does it differently. Some use GitHub's built-in review tools. Others rely on Slack pings and crossed fingers. But what if you could build your own automated reviewer that understands git diffs, analyzes changes, and eventually uses AI to provide intelligent feedback?

That's exactly what we're building in this 3-part YouTube tutorial series. By the end, you'll have a fully functional AI-powered pull request reviewer written in C# that integrates with AWS Bedrock, posts comments on GitHub PRs, and runs in CI/CD pipelines.

This is Part 1 — the foundation. We're starting from absolute zero: a blank directory, no NuGet packages, and a single Program.cs file. You'll learn how to execute shell commands from C#, parse the unified diff format that git produces, and build a CLI tool that gives you a clean, structured breakdown of any PR's changes.

Part 1 Focus

Git Diff Parsing

Pure .NET 8 — zero dependencies

↑ 0%NuGet packages required

If you've been writing JavaScript, Python, or Go and want to explore C# — this is the perfect entry point. We're not building an ASP.NET web app or a Blazor SPA. We're building a command-line tool that does one thing well: analyze code changes. And we're doing it the C# way, using System.Diagnostics.Process, LINQ, pattern matching, and the .NET Base Class Library.

What We're Building: The Complete Series

Before diving into code, here's the full picture. This series takes you from beginner to production-ready across three git branches:

Part 1 — main branch

Git Diff Parser (This Tutorial)

Console app that captures git diffs, parses unified diff format, and displays structured summaries. Pure .NET 8 with zero dependencies.

Part 2 — intermediate branch

AWS Bedrock Integration

Send parsed diffs to Claude via AWS Bedrock Converse API. Iterative prompt engineering from basic feedback to structured JSON output.

Part 3 — advanced branch

CI/CD Pipeline Integration

Run as GitHub Actions and GitLab CI pipeline. Post inline review comments directly on PRs and merge requests.

Each part builds directly on the previous one. The branch strategy means you can clone the repo, check out any branch, and see the complete working code for that stage. No guessing what changed between tutorials.

The companion code for the entire series lives in the ByteSizedExamples repository on GitHub. Clone it, follow along, or skip ahead — the code is there for reference.

Why C# for a Developer Tool?

You might be wondering why we're building a git tool in C# instead of Python or Node.js. Fair question. Here's why:

C# vs Scripting Languages for CLI Tools

C# / .NET 8

Startup time~100ms (AOT possible)
Type safetyFull compile-time checking
Process APIsSystem.Diagnostics.Process
String parsingSpan, ranges, LINQ
DistributionSingle file publish

Python / Node.js

Startup time~300-500ms (runtime load)
Type safetyOptional (mypy/TS)
Process APIssubprocess / child_process
String parsingSplit, regex, slicing
DistributionRequires runtime

C# gives us compile-time safety, excellent Process APIs, and a modern string handling story with ranges and pattern matching. Plus, .NET 8 supports ahead-of-time compilation — meaning you can eventually compile this tool to a native binary that starts instantly with no runtime dependency.

But more importantly: if your team already works in C# and .NET, building your developer tools in the same ecosystem means everyone on the team can contribute, debug, and extend them. No polyglot tax.

This is actually the first C# project on CrashBytes, and I picked it deliberately. C# is the language that enterprise teams use every day, but it's underrepresented in the "build cool CLI tools" space. Let's change that.

Prerequisites

You'll need three things installed before we start:

Bar chart data
toolrequired
.NET 8 SDK100
Git100
VS Code + C# Dev Kit85
Terminal/Shell100

Install .NET 8 SDK — If you're on macOS with Homebrew:

brew install dotnet@8

On Windows, download the installer from dotnet.microsoft.com. On Linux, follow the Microsoft docs for your distro.

Verify your installation:

dotnet --version
# Should output 8.0.xxx

Install VS Code with C# Dev Kit — This gives you IntelliSense, debugging, solution explorer, and the full C# development experience. Install the C# Dev Kit extension from the VS Code marketplace.

Git — You already have this. If somehow you don't: brew install git on macOS, or download from git-scm.com.

Project Setup: From Zero to Console App

Let's create the project. Open your terminal and run:

mkdir ai-pr-reviewer-csharp
cd ai-pr-reviewer-csharp

Now create the project file. In .NET, the .csproj file defines your project configuration — target framework, output type, dependencies. For Part 1, it's minimal:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>AiPrReviewer</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Save this as AiPrReviewer.csproj. Let's break down what each property does:

Pie chart data
NameValue
OutputType: Exe20
TargetFramework: net8.025
RootNamespace15
ImplicitUsings20
Nullable20
  • OutputType: Exe — This is a console application, not a library
  • TargetFramework: net8.0 — We're targeting .NET 8, the current LTS release
  • RootNamespace: AiPrReviewer — The default namespace for our code
  • ImplicitUsings: enable — Automatically imports System, System.Collections.Generic, System.Linq, System.IO, and other common namespaces so we don't need using statements for basic types
  • Nullable: enable — Turns on nullable reference types, which forces us to think about null safety at compile time

Notice what's not here: no <ItemGroup> with <PackageReference>. Zero NuGet packages. Everything we build in Part 1 uses the .NET Base Class Library only.

Verify the project builds:

dotnet build

You should see Build succeeded with zero warnings and zero errors. If you get an error about targeting .NET 8, make sure you installed the SDK (not just the runtime).

Advertisement

Step 1: CLI Argument Parsing

Our tool needs to accept two arguments: --repo (path to the git repository) and --branch (the base branch to diff against). Let's build a simple argument parser.

Create Program.cs:

using System.Diagnostics;

namespace AiPrReviewer;

class Program
{
    static int Main(string[] args)
    {
        var repoPath = GetArgValue(args, "--repo")
            ?? Directory.GetCurrentDirectory();
        var branch = GetArgValue(args, "--branch") ?? "main";

        if (args.Contains("--help") || args.Contains("-h"))
        {
            PrintUsage();
            return 0;
        }

        Console.WriteLine("=================================================");
        Console.WriteLine("  AI PR Reviewer — CrashBytes");
        Console.WriteLine("  Part 1: Git Diff Analysis");
        Console.WriteLine("=================================================");
        Console.WriteLine();

        return 0;
    }

    static string? GetArgValue(string[] args, string flag)
    {
        for (int i = 0; i < args.Length - 1; i++)
        {
            if (args[i] == flag)
                return args[i + 1];
        }
        return null;
    }

    static void PrintUsage()
    {
        Console.WriteLine("AI PR Reviewer — CrashBytes");
        Console.WriteLine();
        Console.WriteLine("Usage: dotnet run -- [options]");
        Console.WriteLine();
        Console.WriteLine("Options:");
        Console.WriteLine("  --repo <path>     Path to the git repository "
            + "(default: current directory)");
        Console.WriteLine("  --branch <name>   Base branch to diff against "
            + "(default: main)");
        Console.WriteLine("  --help, -h        Show this help message");
    }
}

A few things to notice here.

Return type is int, not void. Our Main method returns an integer exit code. This is standard practice for CLI tools — 0 means success, any non-zero value means failure. When we integrate this into CI/CD later, the exit code determines whether the pipeline step passes or fails.

GetArgValue uses simple iteration. We could use a library like System.CommandLine for argument parsing, but that adds a NuGet dependency. For a tool with two flags, manual parsing is perfectly fine and keeps us dependency-free.

Exit Code Convention

0 = Success

Non-zero = failure. CI/CD pipelines use this to pass/fail steps.

↑ 100%% of CLI tools follow this

The ?? null-coalescing operator. If GetArgValue returns null (the flag wasn't provided), we fall back to sensible defaults: the current working directory for --repo and main for --branch.

Test it:

dotnet run -- --help

Note the -- between dotnet run and your flags. This tells the dotnet CLI that everything after -- should be passed to your application, not consumed by dotnet run itself. This is a common source of confusion for beginners.

Step 2: Validating the Git Repository

Before we try to run any git commands, we need to verify that the specified path is actually a git repository. The simplest check: does a .git directory exist?

Add this validation after parsing args:

// Validate repo path
if (!Directory.Exists(Path.Combine(repoPath, ".git")))
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine($"Error: '{repoPath}' is not a git repository.");
    Console.ResetColor();
    return 1;
}

Console.WriteLine($"Repository: {repoPath}");
Console.WriteLine($"Base branch: {branch}");
Console.WriteLine();

We're using Console.ForegroundColor to print errors in red. This is a small UX detail that matters for CLI tools — users scan terminal output quickly, and color-coded errors stand out immediately.

Arg parsing100.0%
Repo validation100.0%
Git command execution0.0%
Diff parsing0.0%
Output formatting0.0%

Always call Console.ResetColor() after changing the foreground color. If you don't, every subsequent Console.WriteLine will keep using the changed color. It's a common gotcha that makes your output look broken.

Step 3: Running Git Commands from C#

This is the core technique you'll use throughout this series. We need to execute git diff as a child process, capture its stdout, and handle errors from stderr. C# provides System.Diagnostics.Process for this.

Add the RunGitCommand method:

static (bool Success, string Output, string Error) RunGitCommand(
    string workingDir, string arguments)
{
    var psi = new ProcessStartInfo
    {
        FileName = "git",
        Arguments = arguments,
        WorkingDirectory = workingDir,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
        CreateNoWindow = true
    };

    try
    {
        using var process = Process.Start(psi);
        if (process == null)
            return (false, "", "Failed to start git process.");

        var output = process.StandardOutput.ReadToEnd();
        var error = process.StandardError.ReadToEnd();
        process.WaitForExit();

        return (process.ExitCode == 0, output, error);
    }
    catch (Exception ex)
    {
        return (false, "", $"Failed to run git: {ex.Message}");
    }
}

Let's unpack every decision here, because this pattern will serve you well beyond this project.

ProcessStartInfo Configuration

ProcessStartInfo Properties Explained

What We Set

FileNamegit
RedirectStandardOutputtrue
RedirectStandardErrortrue
UseShellExecutefalse
CreateNoWindowtrue

Why It Matters

FileNameExecutable to run
RedirectStdOutCapture output in code
RedirectStdErrCapture errors in code
UseShellExecuteRequired false for redirect
CreateNoWindowNo popup console window

UseShellExecute = false is critical. When this is true (the default), .NET uses the operating system's shell to start the process. That means you can't redirect stdin/stdout/stderr. Setting it to false tells .NET to create the process directly, which gives us stream access.

WorkingDirectory ensures git runs in the correct repository. Without this, git would look for a repo relative to wherever our tool was launched from — which is our project directory, not the target repo.

Tuple Return Type

Notice the return type: (bool Success, string Output, string Error). This is a C# value tuple. Instead of creating a dedicated class or throwing exceptions for non-fatal errors, we return a lightweight tuple with named elements. The caller can destructure it or access .Success, .Output, and .Error directly.

This pattern is particularly useful for "try" operations where failure is expected and normal — like checking if a branch exists. You don't want to throw an exception for that.

The using Declaration

using var process = Process.Start(psi);

The using declaration (introduced in C# 8) ensures the Process object is disposed when it goes out of scope. Process implements IDisposable and holds native OS handles. Without using, you'd leak handles, which on long-running processes could exhaust the OS handle limit.

Now use this method to verify the branch exists and run the diff:

// Check if branch exists
var branchCheck = RunGitCommand(repoPath,
    $"rev-parse --verify {branch}");
if (!branchCheck.Success)
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine($"Error: Branch '{branch}' does not exist.");
    Console.ResetColor();
    return 1;
}

// Get the diff
var diffResult = RunGitCommand(repoPath, $"diff {branch}...HEAD");
if (!diffResult.Success)
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine($"Error running git diff: {diffResult.Error}");
    Console.ResetColor();
    return 1;
}

if (string.IsNullOrWhiteSpace(diffResult.Output))
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine(
        "No changes found between HEAD and the base branch.");
    Console.ResetColor();
    return 0;
}

Git Three-Dot Diff

branch...HEAD

Shows changes on HEAD since it diverged from branch

↑ 3%dots matter

Why diff branch...HEAD with three dots? The three-dot syntax (...) shows the changes on HEAD since it diverged from the base branch. This is what PR reviewers care about — "what changed in this branch?" — as opposed to the two-dot syntax (..) which shows the symmetric difference between the two branch tips.

Step 4: Understanding the Unified Diff Format

Before we parse anything, you need to understand what git actually outputs. When you run git diff, you get unified diff format. Here's what it looks like:

diff --git a/src/Program.cs b/src/Program.cs
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -1,5 +1,7 @@
 using System;
+using System.Collections.Generic;

 namespace MyApp
 {
+    // Added a new class
     class Program
@@ -10,3 +12,4 @@
         Console.WriteLine("Hello");
+        Console.WriteLine("World");
     }
 }

Let's decode each piece:

Line 1

diff --git a/path b/path

Marks the start of a new file in the diff. The a/ prefix is the old version, b/ is the new version.

Lines 2-3

--- a/path and +++ b/path

The actual file paths. '--- /dev/null' means the file is new. '+++ /dev/null' means it was deleted.

Line 4

@@ -1,5 +1,7 @@

Hunk header. The old file starts at line 1 spanning 5 lines. The new file starts at line 1 spanning 7 lines.

Lines 5+

Context and change lines

Lines starting with space are context (unchanged). + prefix means added. - prefix means removed.

The key insight: every line in the diff body starts with exactly one of three characters:

  • Space — Context line (unchanged, shown for reference)
  • + — Addition (this line exists in the new version)
  • - — Deletion (this line existed in the old version)

And there are special markers we need to handle:

  • --- /dev/null — The file didn't exist before (it's new)
  • +++ /dev/null — The file no longer exists (it was deleted)
  • @@ — Hunk header (a section of changes within a file)

One file can have multiple hunks. A hunk represents a contiguous block of changes. If you edit lines 5-10 and lines 200-205 in the same file, git produces two hunks rather than dumping 200 lines of context between them.

Step 5: Building the Diff Parser

Now for the fun part. We need a data model and a parser. First, the model:

class FileDiff
{
    public string? OldPath { get; set; }
    public string? NewPath { get; set; }
    public bool IsNew { get; set; }
    public bool IsDeleted { get; set; }
    public int Additions { get; set; }
    public int Deletions { get; set; }
    public int HunkCount { get; set; }

    public string DisplayName => NewPath ?? OldPath ?? "(unknown)";
}

FileDiff represents one file's contribution to the diff. The DisplayName property uses null-coalescing to prefer the new path (since that's the file as it exists now), falling back to the old path (for deleted files), and finally a default if somehow neither exists.

Bar chart data
propertypurpose
OldPath90
NewPath90
IsNew70
IsDeleted70
Additions95
Deletions95
HunkCount60

Now the parser itself:

static List<FileDiff> ParseDiff(string diffOutput)
{
    var files = new List<FileDiff>();
    FileDiff? current = null;

    foreach (var line in diffOutput.Split('\n'))
    {
        // New file header
        if (line.StartsWith("diff --git"))
        {
            current = new FileDiff();
            files.Add(current);
            continue;
        }

        if (current == null)
            continue;

        // File paths
        if (line.StartsWith("--- a/"))
        {
            current.OldPath = line[6..];
        }
        else if (line.StartsWith("--- /dev/null"))
        {
            current.IsNew = true;
        }
        else if (line.StartsWith("+++ b/"))
        {
            current.NewPath = line[6..];
        }
        else if (line.StartsWith("+++ /dev/null"))
        {
            current.IsDeleted = true;
        }
        // Hunk header
        else if (line.StartsWith("@@"))
        {
            current.HunkCount++;
        }
        // Added line
        else if (line.StartsWith("+") && !line.StartsWith("+++"))
        {
            current.Additions++;
        }
        // Removed line
        else if (line.StartsWith("-") && !line.StartsWith("---"))
        {
            current.Deletions++;
        }
    }

    return files;
}

Let's walk through the design decisions.

State Machine Pattern

The parser is a simple state machine. The current variable tracks which file we're currently processing. When we hit a diff --git line, we create a new FileDiff and start filling it in. Everything until the next diff --git belongs to the current file.

Pie chart data
NameValue
File headers (diff --git)5
Path lines (--- / +++)10
Hunk headers (@@)10
Additions (+)35
Deletions (-)25
Context lines (space)15

Range Operator for Substring

current.OldPath = line[6..];

The [6..] syntax is C#'s range operator. It takes a substring starting at index 6 through the end. Since "--- a/" is exactly 6 characters, line[6..] gives us everything after it — the file path. This is cleaner and more performant than line.Substring(6) or line.Replace("--- a/", "").

Guard Against False Positives

else if (line.StartsWith("+") && !line.StartsWith("+++"))

We can't just check for + prefix. The +++ b/path line also starts with +, but it's a file path header, not an addition. The !line.StartsWith("+++") guard prevents us from counting path headers as added lines. Same logic applies for - and ---.

Null Safety

if (current == null)
    continue;

Before we've encountered the first diff --git line, current is null. Any other diff metadata lines (like index abc123..def456 100644) that appear before the first file header get safely skipped.

Step 6: Formatting the Output

A CLI tool is only as good as its output. We need two views: a high-level summary and a per-file breakdown.

Summary View

static void PrintSummary(List<FileDiff> files)
{
    var totalAdditions = files.Sum(f => f.Additions);
    var totalDeletions = files.Sum(f => f.Deletions);
    var newFiles = files.Count(f => f.IsNew);
    var deletedFiles = files.Count(f => f.IsDeleted);
    var modifiedFiles = files.Count - newFiles - deletedFiles;

    Console.ForegroundColor = ConsoleColor.Cyan;
    Console.WriteLine("-------------------------------------------------");
    Console.WriteLine("  DIFF SUMMARY");
    Console.WriteLine("-------------------------------------------------");
    Console.ResetColor();
    Console.WriteLine($"  Files changed:  {files.Count}");
    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine($"  Additions:      +{totalAdditions}");
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine($"  Deletions:      -{totalDeletions}");
    Console.ResetColor();
    Console.WriteLine($"  New files:      {newFiles}");
    Console.WriteLine($"  Deleted files:  {deletedFiles}");
    Console.WriteLine($"  Modified files: {modifiedFiles}");
    Console.WriteLine();
}

LINQ makes the aggregation trivial. files.Sum(f => f.Additions) is as readable as it gets. files.Count(f => f.IsNew) counts files matching a predicate. No loops, no accumulators, no temporary variables.

LINQ Power

6 Aggregations

Sum, Count, and OrderByDescending — all in one method

↑ 0%manual loops needed

Per-File Breakdown

static void PrintFileBreakdown(List<FileDiff> files)
{
    Console.ForegroundColor = ConsoleColor.Cyan;
    Console.WriteLine("-------------------------------------------------");
    Console.WriteLine("  PER-FILE BREAKDOWN");
    Console.WriteLine("-------------------------------------------------");
    Console.ResetColor();
    Console.WriteLine();

    Console.WriteLine($"  {"File",-45} {"Status",-10} " +
        $"{"+",-8} {"-",-8} {"Hunks",-6}");
    Console.WriteLine($"  {new string('-', 45)} " +
        $"{new string('-', 10)} {new string('-', 8)} " +
        $"{new string('-', 8)} {new string('-', 6)}");

    foreach (var file in files
        .OrderByDescending(f => f.Additions + f.Deletions))
    {
        var name = file.DisplayName;
        if (name.Length > 44)
            name = "..." + name[^41..];

        var status = file.IsNew ? "NEW"
            : file.IsDeleted ? "DELETED"
            : "MODIFIED";

        Console.Write($"  {name,-45} ");

        Console.ForegroundColor = file.IsNew
            ? ConsoleColor.Green
            : file.IsDeleted ? ConsoleColor.Red
            : ConsoleColor.Yellow;
        Console.Write($"{status,-10} ");
        Console.ResetColor();

        Console.ForegroundColor = ConsoleColor.Green;
        Console.Write($"+{file.Additions,-7} ");
        Console.ForegroundColor = ConsoleColor.Red;
        Console.Write($"-{file.Deletions,-7} ");
        Console.ResetColor();
        Console.WriteLine($"{file.HunkCount,-6}");
    }

    Console.WriteLine();
}

Several formatting techniques worth noting:

String interpolation alignment — $"{"File",-45}" left-aligns "File" in a 45-character-wide column. The - means left-align. Positive numbers right-align. This gives us table-like formatting without a table library.

Path truncation — name[^41..] uses the index-from-end operator (^41) combined with the range operator. For a path like src/Services/Authentication/OAuth/TokenRefreshHandler.cs, we get ...ces/Authentication/OAuth/TokenRefreshHandler.cs. The ... prefix signals truncation.

OrderByDescending — We sort files by total changes (additions plus deletions), putting the most heavily modified files first. In a real PR review, those are the files that need the most attention.

Conditional ternary for status — file.IsNew ? "NEW" : file.IsDeleted ? "DELETED" : "MODIFIED" is a chained ternary. Some teams ban these in code reviews (ironic, given what we're building). For three states, I find it readable. For more states, use a switch expression.

Color per status — New files get green, deleted files get red, modified files get yellow. This matches the color coding developers already expect from git status and GitHub's diff view.

Wiring It All Together

Now connect everything in Main. After getting the diff output and checking for empty results, call the parser and both output methods:

// Parse the diff
var files = ParseDiff(diffResult.Output);

// Print summary
PrintSummary(files);

// Print per-file breakdown
PrintFileBreakdown(files);

return 0;

That's it. Build and run:

dotnet build
dotnet run -- --help
Arg parsing100.0%
Repo validation100.0%
Git command execution100.0%
Diff parsing100.0%
Output formatting100.0%
Advertisement

Testing Your Tool

To test the tool against a real repository, you need a branch with changes. Here's how to create a test scenario:

# Create a test repo
mkdir /tmp/test-repo && cd /tmp/test-repo
git init
git checkout -b main

# Create initial files
echo "console.log('hello');" > app.js
echo "# Test Repo" > README.md
git add . && git commit -m "Initial commit"

# Create a feature branch with changes
git checkout -b feature/add-utils
echo "export function add(a, b) { return a + b; }" > utils.js
echo "console.log('hello world');" > app.js
echo "# Test Repo\n\nWith utils." > README.md
git add . && git commit -m "Add utilities"

Now run our tool against it:

cd /path/to/ai-pr-reviewer-csharp
dotnet run -- --repo /tmp/test-repo --branch main

You should see output like:

=================================================
  AI PR Reviewer — CrashBytes
  Part 1: Git Diff Analysis
=================================================

Repository: /tmp/test-repo
Base branch: main

-------------------------------------------------
  DIFF SUMMARY
-------------------------------------------------
  Files changed:  3
  Additions:      +4
  Deletions:      -2
  New files:      1
  Deleted files:  0
  Modified files: 2

-------------------------------------------------
  PER-FILE BREAKDOWN
-------------------------------------------------

  File                                          Status     +        -        Hunks
  --------------------------------------------- ---------- -------- -------- ------
  README.md                                     MODIFIED   +2       -1       1
  app.js                                        MODIFIED   +1       -1       1
  utils.js                                      NEW        +1       -0       1

Files in Our Project

2

AiPrReviewer.csproj + Program.cs

↑ 0%NuGet packages

The entire project is two files. The .csproj configuration and Program.cs with all the logic. For a beginner tutorial and a tool with a focused purpose, this is the right level of simplicity. We'll extract classes and add structure in Part 2 when complexity demands it.

Deep Dive: C# Patterns You Should Know

This project uses several modern C# features that are worth understanding deeply, especially if you're coming from C# 7 or earlier.

Nullable Reference Types

When we enabled <Nullable>enable</Nullable> in the project file, we opted into the compiler's null analysis. This means:

string? OldPath { get; set; }  // Can be null
string DisplayName => NewPath ?? OldPath ?? "(unknown)";  // Handles null chain

The ? after string tells the compiler "this value might be null." Without it, the compiler warns you if you try to assign null. This catches null reference exceptions at compile time instead of runtime.

Bar chart data
categorybeforeNullableafterNullable
NullReferenceException8515
Null check verbosity7030
API clarity4090
Compiler warnings2080

Value Tuples

static (bool Success, string Output, string Error) RunGitCommand(...)

Value tuples are lightweight structs. They don't allocate on the heap, they support named elements, and they can be destructured:

var (success, output, error) = RunGitCommand(repo, "status");
if (!success) Console.WriteLine(error);

Compare this to the old Tuple<bool, string, string> which required .Item1, .Item2, .Item3 — completely unreadable.

Range and Index Operators

line[6..]      // Everything from index 6 to end
name[^41..]    // Last 41 characters

The .. range operator and ^ index-from-end operator were introduced in C# 8. They make string slicing as clean as Python while remaining zero-allocation when used with Span<char> (though we're using string slicing here for simplicity).

Pattern Matching in Conditionals

Our diff parser uses classic if/else if chains with StartsWith, but C# offers pattern matching that can make certain code cleaner:

// We could also write status assignment as:
var status = file switch
{
    { IsNew: true } => "NEW",
    { IsDeleted: true } => "DELETED",
    _ => "MODIFIED"
};

The property pattern { IsNew: true } matches any object where the IsNew property is true. The _ discard pattern is the default case. We used the ternary chain in the actual code for brevity, but switch expressions scale better when you have more states.

Error Handling Philosophy

Our error handling strategy is deliberate: validate early, fail fast, report clearly.

Layer 1

Input Validation

Check for --help flag. Validate .git directory exists. Verify branch with rev-parse. Each failure returns a non-zero exit code.

Layer 2

Process Execution

Wrap Process.Start in try/catch. Return error tuple instead of throwing. Let the caller decide how to handle failure.

Layer 3

Edge Cases

Handle empty diff output (no changes). Handle null process start. Handle missing paths with null-coalescing.

We never throw exceptions for expected conditions. A missing branch isn't exceptional — it's a user input error. An empty diff isn't exceptional — it means there are no changes. We use return values to communicate these states and reserve exceptions for truly unexpected failures (like the git binary not being installed).

This philosophy carries directly into Part 2 where we'll add network calls to AWS Bedrock. Network failures are expected. API rate limits are expected. We'll handle them with the same pattern: return structured results, let the caller decide.

How This Connects to AI Code Review

You might be thinking: "Cool, we built a diff parser. Where's the AI?"

That's Part 2. But here's why this foundation matters: every AI code review tool — GitHub Copilot code review, Amazon CodeGuru, Sourcegraph's Cody — starts with exactly this step. They capture the diff, parse the structure, and then feed that structure to a language model.

Area chart data
stagepart1part2part3
Capture Diff100100100
Parse Structure100100100
Send to AI0100100
Format Response0100100
Post to PR00100
CI/CD Pipeline00100

The quality of the AI review depends directly on the quality of the diff parsing. If we miss hunks, the AI misses context. If we can't identify new vs. modified vs. deleted files, the AI gives generic advice instead of targeted feedback. If we can't handle large diffs, the AI hits token limits and produces truncated reviews.

My prediction on enterprise AI code review mandates projects that Fortune 500 companies will require AI code review for all production deployments by Q4 2026. When that happens, engineering teams will need to understand tools like this at a fundamental level — not just install a plugin and hope for the best.

The AWS Bedrock Getting Started tutorial covers the AI service side using Python. In Part 2 of this series, we'll do the same thing from C# using the Converse API, which gives us model-agnostic access to Claude, Llama, and other models through a single interface.

For a broader look at how AI is already transforming developer workflows, check out the deep dive on AI-driven code review transforming software quality. That article covers the enterprise landscape — what we're building here is the ground-level implementation.

Performance Considerations

For a CLI tool, startup time and execution time both matter. Let's look at where time goes in our tool:

Pie chart data
NameValue
.NET runtime startup40
git diff execution35
Diff parsing (string ops)15
Console output10

The two biggest costs are .NET runtime initialization and the git subprocess. Our parsing code processes diffs by scanning each line once — O(n) where n is the number of lines in the diff. For a typical PR with a few hundred changed lines, parsing takes microseconds. Even a massive 10,000-line diff parses in single-digit milliseconds.

If you wanted to optimize further, .NET 8 supports Native AOT (Ahead-of-Time) compilation. Adding these properties to the .csproj:

<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>

...and running dotnet publish -c Release would produce a native binary that starts in under 10ms instead of the ~100ms JIT startup. We won't do this now (it complicates the build for a tutorial), but it's good to know it's possible for production tooling.

Common Mistakes and Gotchas

After building this and watching people follow along, here are the most common issues:

1. Forgetting -- When Passing Args

# Wrong — dotnet consumes the --repo flag
dotnet run --repo /tmp/my-repo

# Correct — everything after -- goes to your app
dotnet run -- --repo /tmp/my-repo

2. Running from the Wrong Directory

If you run dotnet run without --repo, the tool uses Directory.GetCurrentDirectory(). That's your project directory, not the target repo. Either always pass --repo or cd into the target repo first.

3. Branch Name Mismatch

Some repos use master instead of main. Our default is main. If you're analyzing a repo with a master branch, use --branch master.

4. Detached HEAD State

If the target repo is in detached HEAD state (common after git checkout of a specific commit), git diff branch...HEAD still works — but the results might be unexpected. The diff shows everything between the branch tip and the detached commit, which could include changes that have already been merged.

Pro Tip

git status

Always check the target repo's state before running your tool

↑ 1%command to run first

5. Binary Files in Diffs

Our parser ignores binary files gracefully. Git outputs Binary files a/image.png and b/image.png differ for binary changes, which doesn't match any of our line-prefix patterns. The file will appear in the file list (from the diff --git header) but show 0 additions and 0 deletions. This is actually correct behavior — binary files don't have meaningful line-level diffs.

Project Structure Recap

Here's the final file tree for Part 1:

ai-pr-reviewer-csharp/
├── .gitignore           # .NET standard ignores
├── LICENSE              # MIT
├── README.md            # Full setup guide
├── AiPrReviewer.csproj  # net8.0 console app, no NuGet deps
└── Program.cs           # All logic — 262 lines
Bar chart data
filelines
Program.cs262
AiPrReviewer.csproj11
README.md113
.gitignore20
LICENSE21

Total: 262 lines of C# code that builds, runs, and does something useful. That's the beauty of focused CLI tools — they do one thing well without ceremony.

What Changes in Part 2

In the next tutorial, we'll take this foundation and add AI. Here's a preview of what changes:

New files:

  • DiffParser.cs — We'll extract the parsing logic from Program.cs into its own class
  • BedrockClient.cs — AWS Bedrock Converse API wrapper
  • Models/DiffResult.cs — Proper data model (replacing our inline FileDiff class)
  • Models/ReviewResult.cs — AI review response model
  • Prompts/V1-BasicPrompt.txt, V2-StructuredPrompt.txt, V3-ProductionPrompt.txt — Three prompt versions showing iterative prompt engineering

New NuGet packages:

  • AWSSDK.BedrockRuntime — AWS Bedrock Converse API
  • DotNetEnv — .env file loading for AWS credentials

New CLI flags:

  • --prompt v1|v2|v3 — Select prompt version
  • --model <id> — Override the Bedrock model

The prompt engineering progression is the most educational part. You'll see how going from "review this diff" (V1) to "categorize feedback into bugs, security, style, performance" (V2) to "return JSON with file, line, severity, category, message" (V3) dramatically improves the usefulness of the AI output.

Part 1 vs Part 2 Complexity

Part 1 (This Tutorial)

Files2 (csproj + Program.cs)
NuGet packages0
Lines of C#~262
External servicesNone
CLI flags2 (--repo, --branch)

Part 2 (Next Tutorial)

Files8 (including Models/, Prompts/)
NuGet packages2
Lines of C#~500
External servicesAWS Bedrock
CLI flags4 (adds --prompt, --model)

To follow along with Part 2, check out the intermediate branch:

git clone https://github.com/CrashBytes/ByteSizedExamples.git
cd ByteSizedExamples/ai-pr-reviewer-csharp
git checkout intermediate

Wrapping Up

You now have a working C# console application that captures git diffs, parses the unified diff format, and displays structured summaries with color-coded output. Zero external dependencies, 262 lines of code.

More importantly, you've learned patterns that apply far beyond this project:

  • Process execution — Running any external command from C# and capturing output
  • State machine parsing — Processing structured text line by line
  • CLI conventions — Exit codes, help flags, argument parsing, color output
  • Modern C# idioms — Nullable reference types, value tuples, ranges, LINQ aggregations

These patterns show up in every C# developer tool, build script, and automation project. The specific application here is git diffs, but the techniques are universal.

The complete code for all three parts is available in the ByteSizedExamples repository. Star the repo if you find it useful, and I'll see you in Part 2 where we add AI to the mix.

Further Reading

  • AWS Bedrock Getting Started with Python — The foundation for Part 2's AI integration
  • AI-Driven Code Review: Transforming Software Quality — Enterprise landscape for AI code review
  • GitHub Actions CI/CD Complete Guide — Context for Part 3's CI/CD integration
Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

C#TutorialAIDeveloper ToolsGit
Back to Articles
← PreviousThe Great AI Chip Diversification — Meta's Multibillion-Dollar Google TPU Deal Signals the End of Nvidia's Unchallenged DominanceNext →AI's Infrastructure Reckoning: How the $650 Billion AI Boom Is Making Everything More Expensive

From across the CrashBytes network

More than the blog — predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Tutorial and expand your knowledge.

📄Tutorial

Build an AI PR Reviewer in C# — Part 3: CI/CD Pipelines, GitHub Actions, and GitLab CI

Learn how to deploy your AI PR reviewer as an automated CI/CD pipeline using GitHub Actions and GitLab CI. Part 3 covers platform abstraction with interfaces, the Octokit SDK for posting inline review comments, token chunking for large diffs, rate limiting, unit testing with xUnit, and complete YAML pipeline configurations.

34 min readRead more
📄Tutorial

Building Your First MCP Server in TypeScript From Scratch

A complete hands-on tutorial for building a production-ready Model Context Protocol server in TypeScript. Learn to create tools, resources, and prompts, then connect your server to Claude Desktop, VS Code, and Cursor.

30 min readRead more
📄Tutorial

Build a Cost-Aware Multi-Model AI Router in TypeScript

A complete hands-on tutorial for routing prompts to the cheapest capable LLM in TypeScript. Build a classifier, a model registry, a fallback ladder, and per-request cost telemetry that survives the May 2026 price war.

25 min readRead more
📄Tutorial

Building a Production Async Agent Queue in TypeScript with Bun and Mistral Work Mode: A 2026 Tutorial

An end-to-end tutorial for engineers shipping long-running coding agents in production. We build a TypeScript queue on Bun that submits agent jobs to Mistral Le Chat Work mode and OpenAI background mode, polls or receives webhooks for completion, enforces per-job cost ceilings, and exposes a small status dashboard — the operating model behind the new async-coding paradigm.

26 min readRead more