diff --git a/.editorconfig b/.editorconfig
index c04a279..2cd5f5b 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -9,7 +9,9 @@ charset = utf-8
indent_style = space
indent_size = 4
tab_width = 4
-end_of_line = crlf
+# LF on every platform, matching the `* text=auto eol=lf` default in .gitattributes.
+# Overrides below must stay in step with the eol pins in that file.
+end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
@@ -17,7 +19,7 @@ trim_trailing_whitespace = true
[*.{cs,csx,cake,fs,fsx,vb,vbx}]
indent_style = tab
-file_header_template = Copyright (c) ktsu.dev\nAll rights reserved.\nLicensed under the MIT license.
+file_header_template = Copyright (c) 2023-2026 ktsu-dev contributors
# Default severity for all .NET Code Style rules
dotnet_analyzer_diagnostic.severity = error
@@ -503,4 +505,9 @@ indent_style = tab
# Shell scripts
[*.sh]
end_of_line = lf
-indent_size = 2
\ No newline at end of file
+indent_size = 2
+
+# Windows batch scripts and Visual Studio solution files keep CRLF on every platform.
+# These match the eol=crlf pins in .gitattributes.
+[*.{cmd,bat,sln}]
+end_of_line = crlf
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
index b272e2b..a0bea35 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,17 +4,25 @@
# Git Line Endings #
###############################
-# Set default behaviour to automatically normalize line endings.
-* text=auto
+# Normalize all text files to LF in the repository, and check them out as LF on every
+# platform. The explicit eol overrides each machine's core.autocrlf, so the working tree
+# is byte-identical on Windows, Linux and macOS. This must stay in step with the
+# end_of_line settings in .editorconfig.
+* text=auto eol=lf
+
+# Force bash scripts to always use LF line endings so that if a repo is accessed
+# in Unix via a file share from Windows, the scripts will work. Redundant with the
+# default above, kept explicit because these files break outright with CRLF.
+*.sh text eol=lf
# Force batch scripts to always use CRLF line endings so that if a repo is accessed
# in Windows via a file share from Linux, the scripts will work.
-*.{cmd,[cC][mM][dD]} text eol=crlf
-*.{bat,[bB][aA][tT]} text eol=crlf
+*.cmd text eol=crlf
+*.bat text eol=crlf
-# Force bash scripts to always use LF line endings so that if a repo is accessed
-# in Unix via a file share from Windows, the scripts will work.
-*.sh text eol=lf
+# Visual Studio rewrites solution files with CRLF regardless of the checkout, so pin
+# them to avoid a spurious whole-file diff every time the solution is opened.
+*.sln text eol=crlf
###############################
# Git Large File System (LFS) #
diff --git a/.runsettings b/.runsettings
index 3c3169d..9bd9f05 100644
--- a/.runsettings
+++ b/.runsettings
@@ -1,25 +1,6 @@
-
- .\coverage
+ TestResults
-
-
- .\coverage
-
-
-
-
-
-
-
- opencover
- coverage.opencover.xml
- [*Test*]*,[*Tests*]*
- **/obj/**/*
-
-
-
-
diff --git a/CLAUDE.md b/CLAUDE.md
index 0710140..05e47a2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
@@ -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 `, 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
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ca491fc..af2af78 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,65 +3,22 @@
true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/ProjectDirector.Test/GitCliTests.cs b/ProjectDirector.Test/GitCliTests.cs
new file mode 100644
index 0000000..f504be2
--- /dev/null
+++ b/ProjectDirector.Test/GitCliTests.cs
@@ -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;
+
+///
+/// Guards the reason this application runs the git command line instead of binding libgit2.
+///
+///
+/// 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.
+///
+[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.");
+ }
+ 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 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.
+ }
+ }
+}
diff --git a/ProjectDirector.Test/ProjectDirector.Test.csproj b/ProjectDirector.Test/ProjectDirector.Test.csproj
new file mode 100644
index 0000000..2536def
--- /dev/null
+++ b/ProjectDirector.Test/ProjectDirector.Test.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+ true
+ net10.0
+
+
+
+
+
+
+
diff --git a/ProjectDirector.sln b/ProjectDirector.sln
index ec49775..308d12d 100644
--- a/ProjectDirector.sln
+++ b/ProjectDirector.sln
@@ -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
@@ -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
diff --git a/ProjectDirector/AssemblyInfo.cs b/ProjectDirector/AssemblyInfo.cs
new file mode 100644
index 0000000..4d64088
--- /dev/null
+++ b/ProjectDirector/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.Test")]
diff --git a/ProjectDirector/AzureDevOpsRepository.cs b/ProjectDirector/AzureDevOpsRepository.cs
index e4a1731..c05d23f 100644
--- a/ProjectDirector/AzureDevOpsRepository.cs
+++ b/ProjectDirector/AzureDevOpsRepository.cs
@@ -1,6 +1,4 @@
-// Copyright (c) ktsu.dev
-// All rights reserved.
-// Licensed under the MIT license.
+// Copyright (c) 2023-2026 ktsu-dev contributors
namespace ktsu.ProjectDirector;
diff --git a/ProjectDirector/DictionaryOfHashSets.cs b/ProjectDirector/DictionaryOfHashSets.cs
index d4336d8..f0911db 100644
--- a/ProjectDirector/DictionaryOfHashSets.cs
+++ b/ProjectDirector/DictionaryOfHashSets.cs
@@ -1,6 +1,4 @@
-// Copyright (c) ktsu.dev
-// All rights reserved.
-// Licensed under the MIT license.
+// Copyright (c) 2023-2026 ktsu-dev contributors
namespace ktsu.ProjectDirector;
diff --git a/ProjectDirector/GitCli.cs b/ProjectDirector/GitCli.cs
new file mode 100644
index 0000000..f8d4409
--- /dev/null
+++ b/ProjectDirector/GitCli.cs
@@ -0,0 +1,179 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.ProjectDirector;
+
+using System.Collections.ObjectModel;
+using System.Text;
+
+using ktsu.RunCommand;
+
+///
+/// The result of running a git command: its exit code plus whatever it wrote to each stream.
+///
+/// The process exit code, where zero means success.
+/// The raw standard output.
+/// The raw standard error.
+internal sealed record GitResult(int ExitCode, string Output, string Error)
+{
+ ///
+ /// Gets a value indicating whether git reported success.
+ ///
+ internal bool Succeeded => ExitCode == 0;
+
+ ///
+ /// Gets the standard output trimmed, which is what single-value queries want.
+ ///
+ internal string OutputText => Output.Trim();
+
+ ///
+ /// Gets whichever stream explains a failure, preferring standard error.
+ ///
+ internal string FailureText => Error.Trim().Length > 0 ? Error.Trim() : Output.Trim();
+
+ ///
+ /// Gets both streams as trimmed, non-empty lines, which is what the log panel displays.
+ /// Transfer commands report their whole progress narrative on standard error.
+ ///
+ internal Collection AllLines
+ {
+ get
+ {
+ Collection lines = [];
+ foreach (string stream in (string[])[Output, Error])
+ {
+ if (string.IsNullOrEmpty(stream))
+ {
+ continue;
+ }
+
+ foreach (string line in stream.Split('\n'))
+ {
+ string trimmed = line.Trim();
+ if (trimmed.Length > 0)
+ {
+ lines.Add(trimmed);
+ }
+ }
+ }
+
+ return lines;
+ }
+ }
+}
+
+///
+/// Runs the git command line.
+///
+///
+/// Shelling out to git rather than binding libgit2 is what makes Git LFS work. The clean filter that
+/// turns a tracked binary into a pointer, the smudge filter that turns it back on checkout, and the
+/// hooks that transfer the objects those pointers refer to are all features of the git command. A
+/// library reading and writing the object database directly silently bypasses them, so a clone lands
+/// pointer files where the real content should be.
+///
+internal static class GitCli
+{
+ ///
+ /// Runs git with the given arguments, each passed separately so paths need no quoting.
+ ///
+ /// The arguments to pass to git.
+ /// The exit code and captured output.
+ internal static GitResult Run(params string[] arguments)
+ {
+ Ensure.NotNull(arguments);
+
+ StringBuilder output = new();
+ StringBuilder error = new();
+
+ // The raw handler is deliberate: the line-splitting handler drops a trailing fragment that
+ // was never newline terminated, and git does not always terminate its final line.
+ OutputHandler handler = new(
+ onStandardOutput: data => output.Append(data),
+ onStandardError: data => error.Append(data));
+
+ int exitCode = RunCommand.Execute("git", arguments, handler);
+
+ return new GitResult(exitCode, output.ToString(), error.ToString());
+ }
+
+ ///
+ /// Runs git against a specific repository using -C, which leaves the process working
+ /// directory untouched and so stays safe when repositories are fetched concurrently.
+ ///
+ /// The working tree to operate on.
+ /// The arguments to pass to git.
+ /// The exit code and captured output.
+ internal static GitResult RunIn(string repositoryPath, params string[] arguments)
+ {
+ Ensure.NotNull(repositoryPath);
+ Ensure.NotNull(arguments);
+
+ return Run(["-C", repositoryPath, .. arguments]);
+ }
+
+ ///
+ /// Determines whether the given directory is inside a git working tree.
+ ///
+ /// The directory to test.
+ /// if the path is inside a working tree.
+ internal static bool IsRepository(string path) =>
+ !string.IsNullOrEmpty(path)
+ && Directory.Exists(path)
+ && RunIn(path, "rev-parse", "--is-inside-work-tree").Succeeded;
+
+ ///
+ /// Gets the URL configured for a remote, or an empty string when the remote does not exist.
+ ///
+ /// The working tree to query.
+ /// The remote to look up.
+ /// The remote URL, or an empty string.
+ internal static string GetRemoteUrl(string repositoryPath, string remoteName)
+ {
+ GitResult result = RunIn(repositoryPath, "remote", "get-url", remoteName);
+
+ return result.Succeeded ? result.OutputText : string.Empty;
+ }
+
+ ///
+ /// Lists the repository-relative paths of every tracked file, using forward slashes as git
+ /// reports them.
+ ///
+ /// The working tree to query.
+ /// The tracked paths, or an empty collection when the path is not a repository.
+ internal static Collection ListTrackedFiles(string repositoryPath)
+ {
+ // -z separates entries with NUL and turns off the quoting git otherwise applies to paths
+ // holding unusual characters, so the names arrive exactly as recorded.
+ GitResult result = RunIn(repositoryPath, "ls-files", "-z");
+
+ Collection files = [];
+ if (!result.Succeeded)
+ {
+ return files;
+ }
+
+ foreach (string entry in result.Output.Split('\0'))
+ {
+ if (entry.Length > 0)
+ {
+ files.Add(entry);
+ }
+ }
+
+ return files;
+ }
+
+ ///
+ /// Determines whether the working tree has any uncommitted change, tracked or otherwise.
+ ///
+ /// The working tree to query.
+ /// if anything differs from HEAD.
+ internal static bool HasUncommittedChanges(string repositoryPath)
+ {
+ GitResult result = RunIn(repositoryPath, "status", "--porcelain");
+
+ // Standard output alone. git reports line-ending conversion as a warning on standard
+ // error, and treating one of those as a change would mark every clean repository dirty.
+ return result.Succeeded && result.Output.Trim().Length > 0;
+ }
+}
diff --git a/ProjectDirector/GitHubRepository.cs b/ProjectDirector/GitHubRepository.cs
index e590bdd..1a77a11 100644
--- a/ProjectDirector/GitHubRepository.cs
+++ b/ProjectDirector/GitHubRepository.cs
@@ -1,6 +1,4 @@
-// Copyright (c) ktsu.dev
-// All rights reserved.
-// Licensed under the MIT license.
+// Copyright (c) 2023-2026 ktsu-dev contributors
namespace ktsu.ProjectDirector;
diff --git a/ProjectDirector/GitRepository.cs b/ProjectDirector/GitRepository.cs
index d77e9a9..6bf6a13 100644
--- a/ProjectDirector/GitRepository.cs
+++ b/ProjectDirector/GitRepository.cs
@@ -1,12 +1,9 @@
-// Copyright (c) ktsu.dev
-// All rights reserved.
-// Licensed under the MIT license.
+// Copyright (c) 2023-2026 ktsu-dev contributors
namespace ktsu.ProjectDirector;
using System.Text.Json.Serialization;
using DiffPlex.Model;
-using LibGit2Sharp;
using Semantics.Paths;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
@@ -43,12 +40,8 @@ public abstract class GitRepository
internal void UpdateStatus()
{
- IsDirty = false;
+ IsDirty = GitCli.HasUncommittedChanges(LocalPath);
IsOutOfDate = false;
-
- using Repository repo = new(LocalPath);
- RepositoryStatus status = repo.RetrieveStatus();
- IsDirty = status.IsDirty;
// work out if the repository is behind the remote
}
}
diff --git a/ProjectDirector/PopupPropagateFile.cs b/ProjectDirector/PopupPropagateFile.cs
index 8be2d97..4ef6530 100644
--- a/ProjectDirector/PopupPropagateFile.cs
+++ b/ProjectDirector/PopupPropagateFile.cs
@@ -1,6 +1,4 @@
-// Copyright (c) ktsu.dev
-// All rights reserved.
-// Licensed under the MIT license.
+// Copyright (c) 2023-2026 ktsu-dev contributors
namespace ktsu.ProjectDirector;
diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs
index 0326daa..1991353 100644
--- a/ProjectDirector/ProjectDirector.cs
+++ b/ProjectDirector/ProjectDirector.cs
@@ -1,6 +1,4 @@
-// Copyright (c) ktsu.dev
-// All rights reserved.
-// Licensed under the MIT license.
+// Copyright (c) 2023-2026 ktsu-dev contributors
namespace ktsu.ProjectDirector;
@@ -87,12 +85,6 @@ public ProjectDirector()
RestoreDividerStates();
- LibGit2Sharp.GlobalSettings.LogConfiguration = new(LibGit2Sharp.LogLevel.Debug, new((level, message) =>
- {
- string logMessage = $"[{level} {DateTimeOffset.Now}] {message}";
- QueueLog(logMessage);
- }));
-
GitHubClient = new(new ProductHeaderValue("ktsu.ProjectDirector"));
if (!string.IsNullOrEmpty(Options.GitHubLogin) && !string.IsNullOrEmpty(Options.GitHubToken))
@@ -112,6 +104,24 @@ private void QueueLog(string logMessage)
}
}
+ ///
+ /// Reports what git did in the log panel. This is what the panel carries now that libgit2's
+ /// debug trace is gone, and it is considerably more useful: the panel shows the transfer
+ /// progress and any failure git reported, rather than library internals.
+ ///
+ ///
+ /// Safe to call from the background tasks that run fetch and pull, because the queue behind it
+ /// is concurrent.
+ ///
+ private void QueueGitLog(string description, GitResult result)
+ {
+ QueueLog($"[{DateTimeOffset.Now}] {description}{(result.Succeeded ? string.Empty : " failed")}");
+ foreach (string line in result.AllLines)
+ {
+ QueueLog($" {line}");
+ }
+ }
+
private void WindowResized()
{
Options.WindowState = ImGuiApp.WindowState;
@@ -201,42 +211,21 @@ private void FetchRepo(GitRepository repo)
repo.LastFetchTime = DateTime.UtcNow;
QueueSaveOptions();
- Task task = new(() =>
- {
- LibGit2Sharp.Repository localRepo = new(repoPath);
- LibGit2Sharp.FetchOptions fetchOptions = new();
- LibGit2Sharp.Remote origin = localRepo.Network.Remotes["origin"];
- IEnumerable refSpecs = origin.FetchRefSpecs.Select(x => x.Specification);
- LibGit2Sharp.Commands.Fetch(localRepo, "origin", refSpecs, fetchOptions, $"Fetching {repo.RemotePath}");
- });
+ // Authentication is the platform credential helper's job now, which is also what makes SSH
+ // remotes work without any configuration here.
+ Task task = new(() => QueueGitLog($"Fetching {repo.RemotePath}", GitCli.RunIn(repoPath, "fetch", "origin")));
task.Start();
}
- private static void PullRepo(GitRepository repo)
+ private void PullRepo(GitRepository repo)
{
FullyQualifiedLocalRepoPath repoPath = repo.LocalPath;
- Task task = new(() =>
- {
- LibGit2Sharp.Repository localRepo = new(repoPath);
- LibGit2Sharp.FetchOptions fetchOptions = new();
- LibGit2Sharp.Remote origin = localRepo.Network.Remotes["origin"];
- IEnumerable refSpecs = origin.FetchRefSpecs.Select(x => x.Specification);
- try
- {
- _ = LibGit2Sharp.Commands.Pull(localRepo, new("ProjectDirector", "ProjectDirector@ktsu.dev", DateTimeOffset.Now), new()
- {
- FetchOptions = new(),
- MergeOptions = new()
- {
- CommitOnSuccess = true,
- },
- });
- }
- catch (LibGit2Sharp.CheckoutConflictException)
- {
- }
- });
+ // --ff-only rather than a real merge. The previous code committed a merge unattended and
+ // swallowed the conflict exception, which left the working tree mid-conflict with nothing
+ // said about it. Refusing to advance a divergent branch, and reporting why in the log
+ // panel, is the safer default for an unattended background pull.
+ Task task = new(() => QueueGitLog($"Pulling {repo.RemotePath}", GitCli.RunIn(repoPath, "pull", "--ff-only")));
task.Start();
}
@@ -295,7 +284,7 @@ private void ShowTopPanel(float dt)
{
if (ImGui.Button("Clone", new Vector2(FieldWidth, 0)))
{
- Task.Run(() => _ = LibGit2Sharp.Repository.Clone(repo.RemotePath, repo.LocalPath))
+ Task.Run(() => QueueGitLog($"Cloning {repo.RemotePath}", GitCli.Run("clone", repo.RemotePath.ToString(), repo.LocalPath.ToString())))
.ContinueWith((t) => RefreshPage(),
new CancellationToken(),
TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously,
@@ -341,17 +330,8 @@ private void ShowTopPanel(float dt)
if (ImGui.Button("Pull", new Vector2(FieldWidth, 0)))
{
- // TODO: check if there are any uncommitted changes and warn the user
- //var task = new Task(() =>
- //{
- // var localRepo = new LibGit2Sharp.Repository(repoPath);
- // var fetchOptions = new LibGit2Sharp.FetchOptions();
- // var origin = localRepo.Network.Remotes["origin"];
- // var refSpecs = origin.FetchRefSpecs.Select(x => x.Specification);
- // LibGit2Sharp.Commands.Pull(localRepo
- //});
-
- //task.Start();
+ // TODO: check if there are any uncommitted changes and warn the user before
+ // calling PullRepo(repo), which is otherwise ready to be wired up here.
}
ImGui.SameLine();
@@ -539,7 +519,7 @@ private void ShowRepos(GitHubOwnerName owner)
if (gitHubRepo.OwnerName == owner)
{
bool isCloned = Options.ClonedRepos.ContainsKey(repo.LocalPath);
- ImGuiWidgets.ColorIndicator(Color.Palette.Basic.Green, isCloned);
+ ImGuiWidgets.ColorIndicator(Palette.Basic.Green, isCloned);
ImGui.SameLine();
bool isSelected = Options.BaseRepo == repoName;
if (ImGui.Selectable(gitHubRepo.RepoName, ref isSelected))
@@ -627,15 +607,7 @@ private bool UpdateClonedStatus(GitRepository repo)
{
FullyQualifiedLocalRepoPath repoPath = repo.LocalPath;
bool wasCloned = Options.ClonedRepos.ContainsKey(repoPath);
- bool isCloned = true;
- try
- {
- using LibGit2Sharp.Repository _ = new(repoPath);
- }
- catch (LibGit2Sharp.RepositoryNotFoundException)
- {
- isCloned = false;
- }
+ bool isCloned = GitCli.IsRepository(repoPath);
if (isCloned)
{
@@ -666,9 +638,24 @@ private void ScanDevDirectoryForOwnersAndRepos()
IEnumerable gitDirs = Directory.EnumerateDirectories(Options.DevDirectory, ".git", SearchOption.AllDirectories);
foreach (string gitDir in gitDirs)
{
- using LibGit2Sharp.Repository localRepo = new(gitDir);
- FullyQualifiedLocalRepoPath localPath = MakeFullyQualifyLocalRepoPath(AbsoluteDirectoryPath.Create(localRepo.Info.WorkingDirectory));
- GitRemotePath remoteUrl = GitRemotePath.Create(localRepo.Network.Remotes["origin"].Url);
+ // The working tree is the parent of the .git directory, so there is nothing to ask git
+ // for here. Enumerating directories already skips worktrees and submodules, where .git
+ // is a file rather than a directory.
+ string workingDirectory = Directory.GetParent(gitDir)?.FullName ?? string.Empty;
+ if (!GitCli.IsRepository(workingDirectory))
+ {
+ continue;
+ }
+
+ string originUrl = GitCli.GetRemoteUrl(workingDirectory, "origin");
+ if (string.IsNullOrEmpty(originUrl))
+ {
+ // A repository with no origin has no remote to track against.
+ continue;
+ }
+
+ FullyQualifiedLocalRepoPath localPath = MakeFullyQualifyLocalRepoPath(AbsoluteDirectoryPath.Create(workingDirectory));
+ GitRemotePath remoteUrl = GitRemotePath.Create(originUrl);
try
{
@@ -743,91 +730,58 @@ private void UpdateSimilarRepos(GitRepository repo)
private static Dictionary DiffRepos(GitRepository repoA, GitRepository repoB)
{
Dictionary diffs = [];
- try
- {
- using LibGit2Sharp.Repository gitRepo = new(repoA.LocalPath);
- IEnumerable fileList = gitRepo.Index.Select(x => x.Path);
- if (repoA != repoB)
- {
- try
- {
- using LibGit2Sharp.Repository otherGitRepo = new(repoB.LocalPath);
- IEnumerable otherFileList = otherGitRepo.Index.Select(x => x.Path);
- Collection matches = fileList.Intersect(otherFileList).ToCollection();
- Dictionary fileContents = matches.ToDictionary(x => x, x =>
- {
- try
- {
- return File.ReadAllText(Path.Combine(repoA.LocalPath, x));
- }
- catch (FileNotFoundException)
- {
- return string.Empty;
- }
- });
- Dictionary otherFileContents = matches.ToDictionary(x => x, x =>
- {
- try
- {
- return File.ReadAllText(Path.Combine(repoB.LocalPath, x));
- }
- catch (FileNotFoundException)
- {
- return string.Empty;
- }
- catch (DirectoryNotFoundException)
- {
- return string.Empty;
- }
- });
- foreach (string? match in matches)
- {
- diffs[RelativeFilePath.Create(match)] = Differ.Instance.CreateLineDiffs(fileContents[match], otherFileContents[match], ignoreWhitespace: false, ignoreCase: false);
- }
- }
- catch (LibGit2Sharp.RepositoryNotFoundException)
- {
- // skip this repo
- }
- }
+ if (repoA == repoB || !GitCli.IsRepository(repoA.LocalPath) || !GitCli.IsRepository(repoB.LocalPath))
+ {
+ return diffs;
}
- catch (LibGit2Sharp.RepositoryNotFoundException)
+
+ Collection matches = GitCli.ListTrackedFiles(repoA.LocalPath)
+ .Intersect(GitCli.ListTrackedFiles(repoB.LocalPath))
+ .ToCollection();
+
+ Dictionary fileContents = matches.ToDictionary(x => x, x => ReadFileOrEmpty(repoA.LocalPath, x));
+ Dictionary otherFileContents = matches.ToDictionary(x => x, x => ReadFileOrEmpty(repoB.LocalPath, x));
+
+ foreach (string match in matches)
{
- // skip this repo
+ diffs[RelativeFilePath.Create(match)] = Differ.Instance.CreateLineDiffs(fileContents[match], otherFileContents[match], ignoreWhitespace: false, ignoreCase: false);
}
return diffs;
}
- private static DiffResult DiffSingleFile(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath)
+ ///
+ /// Reads a tracked file from a working tree, treating anything missing on disk as empty. A file
+ /// can be tracked and still be absent, and a diff against nothing is the useful answer.
+ ///
+ private static string ReadFileOrEmpty(string repoPath, string relativePath)
{
try
{
- using LibGit2Sharp.Repository gitRepo = new(repoA.LocalPath);
-
- if (repoA != repoB)
- {
- try
- {
- using LibGit2Sharp.Repository otherGitRepo = new(repoB.LocalPath);
-
- string fileContents = File.ReadAllText(Path.Combine(repoA.LocalPath, filePath));
- string otherFileContents = File.ReadAllText(Path.Combine(repoB.LocalPath, filePath));
- return Differ.Instance.CreateLineDiffs(fileContents, otherFileContents, ignoreWhitespace: false, ignoreCase: false);
- }
- catch (LibGit2Sharp.RepositoryNotFoundException)
- {
- // skip this repo
- }
- }
+ return File.ReadAllText(Path.Combine(repoPath, relativePath));
}
- catch (LibGit2Sharp.RepositoryNotFoundException)
+ catch (FileNotFoundException)
{
- // skip this repo
+ return string.Empty;
}
+ catch (DirectoryNotFoundException)
+ {
+ return string.Empty;
+ }
+ }
+
+ private static DiffResult DiffSingleFile(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath)
+ {
+ if (repoA == repoB || !GitCli.IsRepository(repoA.LocalPath) || !GitCli.IsRepository(repoB.LocalPath))
+ {
+ return new([], [], []);
+ }
+
+ string fileContents = ReadFileOrEmpty(repoA.LocalPath, filePath);
+ string otherFileContents = ReadFileOrEmpty(repoB.LocalPath, filePath);
- return new([], [], []);
+ return Differ.Instance.CreateLineDiffs(fileContents, otherFileContents, ignoreWhitespace: false, ignoreCase: false);
}
private static void RefreshFileDiff(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath)
diff --git a/ProjectDirector/ProjectDirector.csproj b/ProjectDirector/ProjectDirector.csproj
index 29e681b..b1b42c3 100644
--- a/ProjectDirector/ProjectDirector.csproj
+++ b/ProjectDirector/ProjectDirector.csproj
@@ -18,7 +18,10 @@
-
+
+
+
+
diff --git a/ProjectDirector/ProjectDirectorOptions.cs b/ProjectDirector/ProjectDirectorOptions.cs
index d0eed23..b624423 100644
--- a/ProjectDirector/ProjectDirectorOptions.cs
+++ b/ProjectDirector/ProjectDirectorOptions.cs
@@ -1,6 +1,4 @@
-// Copyright (c) ktsu.dev
-// All rights reserved.
-// Licensed under the MIT license.
+// Copyright (c) 2023-2026 ktsu-dev contributors
namespace ktsu.ProjectDirector;
diff --git a/global.json b/global.json
index b6fc523..a6c6219 100644
--- a/global.json
+++ b/global.json
@@ -5,14 +5,14 @@
},
"msbuild-sdks": {
"MSTest.Sdk": "4.3.3",
- "ktsu.Sdk": "2.19.0",
- "ktsu.Sdk.ConsoleApp": "2.19.0",
- "ktsu.Sdk.App": "2.19.0",
- "ktsu.Sdk.Windows": "2.19.0",
- "ktsu.Sdk.Linux": "2.19.0",
- "ktsu.Sdk.macOS": "2.19.0",
- "ktsu.Sdk.iOS": "2.19.0",
- "ktsu.Sdk.Android": "2.19.0"
+ "ktsu.Sdk": "2.21.1",
+ "ktsu.Sdk.ConsoleApp": "2.21.1",
+ "ktsu.Sdk.App": "2.21.1",
+ "ktsu.Sdk.Windows": "2.21.1",
+ "ktsu.Sdk.Linux": "2.21.1",
+ "ktsu.Sdk.macOS": "2.21.1",
+ "ktsu.Sdk.iOS": "2.21.1",
+ "ktsu.Sdk.Android": "2.21.1"
},
"test": {
"runner": "Microsoft.Testing.Platform"
diff --git a/icon.png b/icon.png
index 91246f6..4372ef9 100644
Binary files a/icon.png and b/icon.png differ