Quick Takeaways
What you'll learn in this article
- 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
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:
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.
AWS Bedrock Integration
Send parsed diffs to Claude via AWS Bedrock Converse API. Iterative prompt engineering from basic feedback to structured JSON output.
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
Python / Node.js
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:
| tool | required |
|---|---|
| .NET 8 SDK | 100 |
| Git | 100 |
| VS Code + C# Dev Kit | 85 |
| Terminal/Shell | 100 |
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:
| Name | Value |
|---|---|
| OutputType: Exe | 20 |
| TargetFramework: net8.0 | 25 |
| RootNamespace | 15 |
| ImplicitUsings | 20 |
| Nullable | 20 |
- 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).
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.
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.
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
Why It Matters
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
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:
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.
--- a/path and +++ b/path
The actual file paths. '--- /dev/null' means the file is new. '+++ /dev/null' means it was deleted.
@@ -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.
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.
| property | purpose |
|---|---|
| OldPath | 90 |
| NewPath | 90 |
| IsNew | 70 |
| IsDeleted | 70 |
| Additions | 95 |
| Deletions | 95 |
| HunkCount | 60 |
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.
| Name | Value |
|---|---|
| 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
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
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
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.
| category | beforeNullable | afterNullable |
|---|---|---|
| NullReferenceException | 85 | 15 |
| Null check verbosity | 70 | 30 |
| API clarity | 40 | 90 |
| Compiler warnings | 20 | 80 |
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.
Input Validation
Check for --help flag. Validate .git directory exists. Verify branch with rev-parse. Each failure returns a non-zero exit code.
Process Execution
Wrap Process.Start in try/catch. Return error tuple instead of throwing. Let the caller decide how to handle failure.
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.
| stage | part1 | part2 | part3 |
|---|---|---|---|
| Capture Diff | 100 | 100 | 100 |
| Parse Structure | 100 | 100 | 100 |
| Send to AI | 0 | 100 | 100 |
| Format Response | 0 | 100 | 100 |
| Post to PR | 0 | 0 | 100 |
| CI/CD Pipeline | 0 | 0 | 100 |
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:
| Name | Value |
|---|---|
| .NET runtime startup | 40 |
| git diff execution | 35 |
| Diff parsing (string ops) | 15 |
| Console output | 10 |
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
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
| file | lines |
|---|---|
| Program.cs | 262 |
| AiPrReviewer.csproj | 11 |
| README.md | 113 |
| .gitignore | 20 |
| LICENSE | 21 |
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)
Part 2 (Next Tutorial)
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

