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
20 changes: 18 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ dotnet run
dotnet publish --configuration Release --output ./staging
```

This project has no test suite.
Tests live in `ProjectDirector.Test` (MSTest, via `MSTest.Sdk` + `ktsu.Sdk`). The app exposes its internals to the test project through `InternalsVisibleTo` in `ProjectDirector/AssemblyInfo.cs`. `GitCliTests` drives `GitCli` against throwaway repositories under the temp directory; the ImGui layer is not unit-tested.

```powershell
dotnet test --configuration Release
```

## Architecture

Expand All @@ -43,9 +47,21 @@ This project has no test suite.
- Concrete implementations: `GitHubRepository`, `AzureDevOpsRepository`
- Tracks: remote/local paths, fetch timing, diff results against other repos

**[GitCli.cs](ProjectDirector/GitCli.cs)** - Git access
- `GitResult` (exit code plus both streams) and the runner that produces it, built on `ktsu.RunCommand`
- Arguments are passed as a list rather than as a command string, so paths containing spaces need no quoting
- `RunIn` uses `git -C <repo>`, which never touches the process working directory and so stays safe while repositories are fetched concurrently
- Queries answer from git's exit code rather than by searching its output for "fatal"

### Why the git command line rather than a library

Git LFS is a pair of filters plus a set of hooks, and all of them belong to the git command. A library that reads and writes the object database directly bypasses them: a commit stores raw bytes where a pointer belongs, and a clone or checkout lands the pointer text on disk where the file belongs. This application clones, fetches and pulls, so it is the checkout side that matters here. `ProjectDirector.Test` pins both halves down.

Authentication follows from the same decision. There are no credentials in this code, because git uses the platform credential helper, which is also what makes SSH remotes work.

### Key Dependencies

- **LibGit2Sharp** - Git operations (clone, fetch, pull, status)
- **ktsu.RunCommand** - Starts the git command line, which is how all git work is done (see below)
- **Octokit** - GitHub API (list repos, user info)
- **DiffPlex** - Line-by-line file diffing
- **Hexa.NET.ImGui** - Immediate mode GUI framework
Expand Down
2 changes: 1 addition & 1 deletion DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
A .NET desktop application for managing and comparing many Git repositories side by side. Scans a local development directory, browses GitHub and Azure DevOps remotes, fetches and pulls in bulk, diffs individual files across repositories, and propagates a chosen version of a file to the others. Built on Dear ImGui with a three-panel layout and persistent options, using LibGit2Sharp, Octokit, and DiffPlex underneath.
A .NET desktop application for managing and comparing many Git repositories side by side. Scans a local development directory, browses GitHub and Azure DevOps remotes, fetches and pulls in bulk, diffs individual files across repositories, and propagates a chosen version of a file to the others. Built on Dear ImGui with a three-panel layout and persistent options, driving the git command line directly so Git LFS and the platform credential helper keep working, with Octokit and DiffPlex underneath.
4 changes: 1 addition & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,16 @@
<PackageVersion Include="ktsu.ImGui.Popups" Version="3.9.1" />
<PackageVersion Include="ktsu.ImGui.Styler" Version="3.9.1" />
<PackageVersion Include="ktsu.ImGui.Widgets" Version="3.9.1" />
<PackageVersion Include="ktsu.RunCommand" Version="1.5.1" />
<PackageVersion Include="ktsu.Semantics.Paths" Version="3.1.1" />
<PackageVersion Include="ktsu.Semantics.Strings" Version="3.1.1" />
<PackageVersion Include="LibGit2Sharp" Version="0.32.0" />
<PackageVersion Include="Microsoft.SourceLink.AzureRepos.Git" Version="8.0.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.14.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.CrashDump" Version="1.7.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.Fakes" Version="17.14.1" />
<PackageVersion Include="Microsoft.Testing.Extensions.HangDump" Version="1.7.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.HotReload" Version="1.7.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.Retry" Version="1.7.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.TrxReport" Version="1.7.2" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="OpenAI" Version="2.13.0" />
<PackageVersion Include="Polyfill" Version="11.2.0" />
Expand Down
258 changes: 258 additions & 0 deletions ProjectDirector.Test/GitCliTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ProjectDirector.Test;

using System;
using System.Collections.ObjectModel;
using System.IO;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Guards the reason this application runs the git command line instead of binding libgit2.
/// </summary>
/// <remarks>
/// Git LFS is a pair of filters plus a set of hooks, and all of them belong to the git command. A
/// library reading and writing the object database directly bypasses them, so a clone lands pointer
/// files where the real content should be and a commit stores raw bytes where a pointer should be.
/// ProjectDirector clones, fetches and pulls, which is exactly the half of that the smudge filter
/// covers, so these tests pin the behaviour down rather than trusting it.
/// </remarks>
[TestClass]
public sealed class GitCliTests
{
private const string LfsPointerPrefix = "version https://git-lfs.github.com/spec/v1";

private static bool IsLfsAvailable() => GitCli.Run("lfs", "version").Succeeded;

private static string CreateRepository(bool trackBinariesWithLfs)
{
string root = Path.Combine(Path.GetTempPath(), $"ktsu_pd_{Guid.NewGuid():N}");
_ = Directory.CreateDirectory(root);

Assert.IsTrue(GitCli.Run("init", root).Succeeded, "git init failed.");

// Scope identity to this throwaway repository so the test neither depends on nor disturbs
// whatever global configuration the machine happens to carry.
Assert.IsTrue(GitCli.RunIn(root, "config", "user.name", "ProjectDirector").Succeeded);
Assert.IsTrue(GitCli.RunIn(root, "config", "user.email", "ProjectDirector@ktsu.dev").Succeeded);

if (trackBinariesWithLfs)
{
Assert.IsTrue(GitCli.RunIn(root, "lfs", "install", "--local").Succeeded, "git lfs install failed.");
File.WriteAllText(Path.Combine(root, ".gitattributes"), "*.bin filter=lfs diff=lfs merge=lfs -text\n");
}

return root;
}

private static void CommitAll(string root, string message)
{
Assert.IsTrue(GitCli.RunIn(root, "add", "--all").Succeeded, "git add failed.");

GitResult committed = GitCli.RunIn(root, "commit", "-m", message);
Assert.IsTrue(committed.Succeeded, $"git commit failed: {committed.FailureText}");
}

[TestMethod]
public void CloningAnLfsRepositoryRestoresTheFileContentRatherThanThePointer()
{
if (!IsLfsAvailable())
{
Assert.Inconclusive("git-lfs is not installed, so the filters cannot run.");
return;
}

string origin = CreateRepository(trackBinariesWithLfs: true);
string clone = Path.Combine(Path.GetTempPath(), $"ktsu_pd_clone_{Guid.NewGuid():N}");

try
{
// Bytes that are unmistakably not text, so a pointer left in their place is obvious.
byte[] payload = new byte[2048];
for (int i = 0; i < payload.Length; i++)
{
payload[i] = (byte)(i % 256);
}

File.WriteAllBytes(Path.Combine(origin, "asset.bin"), payload);
CommitAll(origin, "Add asset.bin");

// The committed object must be a pointer, which is the clean filter having run.
GitResult blob = GitCli.RunIn(origin, "cat-file", "-p", "HEAD:asset.bin");
Assert.IsTrue(blob.Succeeded, $"git cat-file failed: {blob.FailureText}");
Assert.StartsWith(LfsPointerPrefix, blob.OutputText, "The committed blob should be an LFS pointer, not the file's bytes.");

GitResult cloned = GitCli.Run("clone", origin, clone);
Assert.IsTrue(cloned.Succeeded, $"git clone failed: {cloned.FailureText}");

// And the checked-out file must be the content again, which is the smudge filter
// having run. This is the half libgit2 could not do: a clone through it lands the
// pointer text on disk in place of the file.
byte[] checkedOut = File.ReadAllBytes(Path.Combine(clone, "asset.bin"));
CollectionAssert.AreEqual(payload, checkedOut, "The clone should contain the file, not its LFS pointer.");

Check warning on line 93 in ProjectDirector.Test/GitCliTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ProjectDirector&issues=AaAeBYqPgMFe4KVqlXhY&open=AaAeBYqPgMFe4KVqlXhY&pullRequest=377
}
finally
{
TryDeleteDirectory(origin);
TryDeleteDirectory(clone);
}
}

[TestMethod]
public void AFileOutsideAnyLfsPatternIsStoredVerbatim()
{
if (!IsLfsAvailable())
{
Assert.Inconclusive("git-lfs is not installed, so the filters cannot run.");
return;
}

string root = CreateRepository(trackBinariesWithLfs: true);

try
{
// The pattern covers *.bin only. Without this half of the pair, a runner that turned
// everything into a pointer would still pass the test above.
File.WriteAllText(Path.Combine(root, "notes.txt"), "plain content\n");
CommitAll(root, "Add notes.txt");

GitResult blob = GitCli.RunIn(root, "cat-file", "-p", "HEAD:notes.txt");

Assert.IsTrue(blob.Succeeded, $"git cat-file failed: {blob.FailureText}");
Assert.AreEqual("plain content", blob.OutputText);
}
finally
{
TryDeleteDirectory(root);
}
}

[TestMethod]
public void RepositoryDetectionDistinguishesAWorkingTreeFromAPlainDirectory()
{
string root = CreateRepository(trackBinariesWithLfs: false);
string outside = Path.Combine(Path.GetTempPath(), $"ktsu_pd_norepo_{Guid.NewGuid():N}");
_ = Directory.CreateDirectory(outside);

try
{
Assert.IsTrue(GitCli.IsRepository(root));
Assert.IsFalse(GitCli.IsRepository(outside));
Assert.IsFalse(GitCli.IsRepository(Path.Combine(outside, "does-not-exist")));
Assert.IsFalse(GitCli.IsRepository(string.Empty));
}
finally
{
TryDeleteDirectory(root);
TryDeleteDirectory(outside);
}
}

[TestMethod]
public void TrackedFilesAreListedWithForwardSlashesAndSurviveSpacesInPaths()
{
string root = CreateRepository(trackBinariesWithLfs: false);

try
{
string nested = Path.Combine(root, "a directory with spaces");
_ = Directory.CreateDirectory(nested);
File.WriteAllText(Path.Combine(nested, "a file with spaces.txt"), "content\n");
File.WriteAllText(Path.Combine(root, "root.txt"), "content\n");
CommitAll(root, "Add files");

Collection<string> tracked = GitCli.ListTrackedFiles(root);

// Paths arrive exactly as git records them, which is what the diff view then joins onto
// each repository root. Passing arguments as a list is what keeps the spaces intact.
Assert.Contains("root.txt", tracked);
Assert.Contains("a directory with spaces/a file with spaces.txt", tracked);
}
finally
{
TryDeleteDirectory(root);
}
}

[TestMethod]
public void TrackedFilesAreEmptyOutsideARepository()
{
string outside = Path.Combine(Path.GetTempPath(), $"ktsu_pd_norepo_{Guid.NewGuid():N}");
_ = Directory.CreateDirectory(outside);

try
{
Assert.IsEmpty(GitCli.ListTrackedFiles(outside));
}
finally
{
TryDeleteDirectory(outside);
}
}

[TestMethod]
public void UncommittedChangesAreDetected()
{
string root = CreateRepository(trackBinariesWithLfs: false);

try
{
File.WriteAllText(Path.Combine(root, "notes.txt"), "content\n");
CommitAll(root, "Add notes.txt");

Assert.IsFalse(GitCli.HasUncommittedChanges(root), "A freshly committed tree should be clean.");

File.WriteAllText(Path.Combine(root, "notes.txt"), "changed\n");

Assert.IsTrue(GitCli.HasUncommittedChanges(root));
}
finally
{
TryDeleteDirectory(root);
}
}

[TestMethod]
public void RemoteUrlIsReadBackAndAbsentRemotesReportEmpty()
{
string root = CreateRepository(trackBinariesWithLfs: false);

try
{
Assert.IsEmpty(GitCli.GetRemoteUrl(root, "origin"));

Assert.IsTrue(GitCli.RunIn(root, "remote", "add", "origin", "https://github.com/ktsu-dev/ProjectDirector.git").Succeeded);

Assert.AreEqual("https://github.com/ktsu-dev/ProjectDirector.git", GitCli.GetRemoteUrl(root, "origin"));
Assert.IsEmpty(GitCli.GetRemoteUrl(root, "upstream"));
}
finally
{
TryDeleteDirectory(root);
}
}

private static void TryDeleteDirectory(string path)
{
try
{
// Git marks objects read-only, which blocks a plain recursive delete on Windows.
foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
File.SetAttributes(file, FileAttributes.Normal);
}

Directory.Delete(path, recursive: true);
}
catch (IOException)
{
// Covers a missing directory too. A best-effort cleanup of a temp directory is not
// worth failing a test over.
}
catch (UnauthorizedAccessException)
{
// As above.
}
}
}
14 changes: 14 additions & 0 deletions ProjectDirector.Test/ProjectDirector.Test.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project>
<Sdk Name="MSTest.Sdk" />
<Sdk Name="ktsu.Sdk" />

<PropertyGroup>
<IsTestProject>true</IsTestProject>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\ProjectDirector\ProjectDirector.csproj" />
</ItemGroup>
</Project>
10 changes: 8 additions & 2 deletions ProjectDirector.sln
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ VisualStudioVersion = 17.8.34316.72
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector", "ProjectDirector\ProjectDirector.csproj", "{E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector.Test", "ProjectDirector.Test\ProjectDirector.Test.csproj", "{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -13,8 +15,12 @@ Global
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Release|Any CPU.ActiveCfg = Debug|Any CPU
{E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Release|Any CPU.Build.0 = Debug|Any CPU
{E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Release|Any CPU.Build.0 = Release|Any CPU
{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
3 changes: 3 additions & 0 deletions ProjectDirector/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.Test")]
Loading