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