From ffbe2c6f123df0a46aa01cdba94274292907d7e7 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 11 Aug 2026 18:56:04 +1000 Subject: [PATCH] [minor] Replace LibGit2Sharp with the git command line libgit2 implements neither the clean and smudge filters nor the hooks, and Git LFS is built entirely out of those. For this application the half that bites is the smudge filter: cloning through LibGit2Sharp writes each LFS pointer to disk as its literal text, so a repository that tracks binaries comes down looking like a set of three-line files rather than its content. All git work now goes through GitCli, which starts the git command through ktsu.RunCommand. Arguments are passed as a list rather than as one command string, so paths containing spaces need no quoting, and RunIn uses git -C so the process working directory is never mutated -- which is what keeps the background fetches safe to run concurrently. Notable call site changes: - Clone, fetch and status move across directly. Pull becomes --ff-only. The old code performed a real merge, committed it unattended, and swallowed CheckoutConflictException, which left the working tree mid-conflict with nothing said about it. - Repository discovery takes the parent of each .git directory rather than asking libgit2 for the working directory, and reads the remote with git remote get-url. - The index enumeration behind the repository diff becomes git ls-files -z. NUL separation turns off the quoting git otherwise applies to unusual paths, so names arrive exactly as recorded. - RepositoryNotFoundException was driving control flow in four places. Those become an explicit GitCli.IsRepository check. - The log panel carried libgit2's debug trace. It now carries what git reported for each clone, fetch and pull, which is what someone watching that panel actually wants to see. There are no credentials in this code any more: git uses the platform credential helper, which also makes SSH remotes work. Add ProjectDirector.Test, the repository's first test project. GitCliTests pins both halves of the LFS guarantee -- a tracked binary is committed as a pointer and comes back out of a clone as its content -- plus the repository detection, tracked-file listing and remote lookup that replaced the exception handling. Also correct the solution configuration, which mapped Release to Debug, so building Release produced a Debug binary. Rebased onto main (2026-08-20), dropping the obsolete "[patch] Sync to ktsu.Sdk 2.21.1" commit this branch was originally stacked on -- main is on 2.27.2 and already carries the header, .gitattributes and .editorconfig migrations that commit was making. Resolved against current main: - Directory.Packages.props / ProjectDirector.csproj kept main's package versions, added ktsu.RunCommand and dropped LibGit2Sharp. - Removed the Microsoft.Testing.Extensions.CodeCoverage and .TrxReport pins. MSTest.Sdk injects both itself, so they became duplicate PackageVersion items (NU1506) the moment this branch added the repository's first test project. - README.md, DESCRIPTION.md and TAGS.md still described the application as using LibGit2Sharp; updated to match. --- CLAUDE.md | 20 +- DESCRIPTION.md | 2 +- Directory.Packages.props | 4 +- ProjectDirector.Test/GitCliTests.cs | 258 ++++++++++++++++++ .../ProjectDirector.Test.csproj | 14 + ProjectDirector.sln | 10 +- ProjectDirector/AssemblyInfo.cs | 3 + ProjectDirector/GitCli.cs | 179 ++++++++++++ ProjectDirector/GitRepository.cs | 7 +- ProjectDirector/ProjectDirector.cs | 216 ++++++--------- ProjectDirector/ProjectDirector.csproj | 2 +- README.md | 2 +- TAGS.md | 2 +- 13 files changed, 572 insertions(+), 147 deletions(-) create mode 100644 ProjectDirector.Test/GitCliTests.cs create mode 100644 ProjectDirector.Test/ProjectDirector.Test.csproj create mode 100644 ProjectDirector/AssemblyInfo.cs create mode 100644 ProjectDirector/GitCli.cs 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/DESCRIPTION.md b/DESCRIPTION.md index ff2401f..f400e39 100644 --- a/DESCRIPTION.md +++ b/DESCRIPTION.md @@ -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. diff --git a/Directory.Packages.props b/Directory.Packages.props index ffcae63..931d1ed 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,18 +12,16 @@ + - - - 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/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/GitRepository.cs b/ProjectDirector/GitRepository.cs index 6339ae5..6bf6a13 100644 --- a/ProjectDirector/GitRepository.cs +++ b/ProjectDirector/GitRepository.cs @@ -4,7 +4,6 @@ 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 @@ -41,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/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 02574b6..1991353 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -85,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)) @@ -110,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; @@ -199,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(); } @@ -293,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, @@ -339,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(); @@ -625,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) { @@ -664,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 { @@ -741,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 edec7a3..349308e 100644 --- a/ProjectDirector/ProjectDirector.csproj +++ b/ProjectDirector/ProjectDirector.csproj @@ -19,9 +19,9 @@ + - diff --git a/README.md b/README.md index c831855..b457c22 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ folder, so subsequent runs start where you left off. | Package | Used for | | --- | --- | -| [LibGit2Sharp](https://github.com/libgit2/libgit2sharp) | Git operations — clone, fetch, pull, status | +| [ktsu.RunCommand](https://github.com/ktsu-dev/RunCommand) | Runs the git command line — clone, fetch, pull, status | | [Octokit](https://github.com/octokit/octokit.net) | GitHub API access | | [DiffPlex](https://github.com/mmanela/diffplex) | Line-by-line file diffing | | [ktsu.ImGuiApp](https://github.com/ktsu-dev/ImGuiApp) | Application shell and window management | diff --git a/TAGS.md b/TAGS.md index 1217d1e..7c22816 100644 --- a/TAGS.md +++ b/TAGS.md @@ -1 +1 @@ -project director;project management;solution management;git;multi repository;repository management;bulk git;fetch;pull;diff;file propagation;libgit2sharp;octokit;diffplex;github;azure devops;dear imgui;imgui;desktop application;dotnet;csharp +project director;project management;solution management;git;multi repository;repository management;bulk git;fetch;pull;diff;file propagation;git cli;git lfs;octokit;diffplex;github;azure devops;dear imgui;imgui;desktop application;dotnet;csharp