From 2e080bade35b278c7e16c726e2120d79797a1040 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 11 Aug 2026 18:32:56 +1000 Subject: [PATCH] chore: update ktsu.Sdk to 2.21.1 [patch] Bumps ktsu.Sdk and its variants to 2.21.1 in global.json. File headers are migrated to the single line from COPYRIGHT.md, which is what the SDK writes into .editorconfig as file_header_template and IDE0073 enforces. --- .editorconfig | 13 +- .gitattributes | 23 +- .runsettings | 21 +- Directory.Packages.props | 2 +- SyncFileContents/Arguments.cs | 4 +- SyncFileContents/Settings.cs | 6 +- SyncFileContents/SyncFileContents.cs | 298 ++++++++--------------- SyncFileContents/SyncFileContents.csproj | 2 +- global.json | 18 +- 9 files changed, 142 insertions(+), 245 deletions(-) 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 d67e40c..a0bea35 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,20 +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 -# csharp files to match .editorconfig -*.cs 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. 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/Directory.Packages.props b/Directory.Packages.props index 115e806..f6f7fa8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + diff --git a/SyncFileContents/Arguments.cs b/SyncFileContents/Arguments.cs index d6d5985..65347c3 100644 --- a/SyncFileContents/Arguments.cs +++ b/SyncFileContents/Arguments.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.SyncFileContents; diff --git a/SyncFileContents/Settings.cs b/SyncFileContents/Settings.cs index 8092503..8f6b665 100644 --- a/SyncFileContents/Settings.cs +++ b/SyncFileContents/Settings.cs @@ -1,11 +1,7 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.SyncFileContents; internal sealed class Settings : AppDataStorage.AppData { - public string Username { get; set; } = string.Empty; - public string Token { get; set; } = string.Empty; } diff --git a/SyncFileContents/SyncFileContents.cs b/SyncFileContents/SyncFileContents.cs index 206f3a6..9eaa868 100644 --- a/SyncFileContents/SyncFileContents.cs +++ b/SyncFileContents/SyncFileContents.cs @@ -1,6 +1,4 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors [assembly: CLSCompliant(true)] [assembly: System.Runtime.InteropServices.ComVisible(false)] @@ -20,8 +18,6 @@ namespace ktsu.SyncFileContents; using ktsu.Extensions; using ktsu.Semantics.Paths; -using LibGit2Sharp; - using PrettyPrompt; internal static class SyncFileContents @@ -34,12 +30,6 @@ private static async Task Main(string[] args) Settings = Settings.LoadOrCreate(); - GlobalSettings.LogConfiguration = new(LogLevel.Info, new((level, message) => - { - string logMessage = $"[{level}] {message}"; - Console.WriteLine($"Git: {logMessage}"); - })); - _ = await Parser.Default.ParseArguments(args).WithParsedAsync(Sync).ConfigureAwait(false); } @@ -53,8 +43,6 @@ internal static async Task Sync(Arguments args) { try { - await EnsureCredentialsAsync(); - string applicationDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), nameof(SyncFileContents)); _ = Directory.CreateDirectory(applicationDataPath); @@ -76,9 +64,9 @@ internal static async Task Sync(Arguments args) (HashSet commitDirectories, HashSet expandedFilesToSync) = await FindAndSyncFilesAsync(filesToSync, path).ConfigureAwait(false); - CommitChangedFiles(commitDirectories, expandedFilesToSync, path); + await CommitChangedFilesAsync(commitDirectories, expandedFilesToSync, path).ConfigureAwait(false); - PushToRemote(commitDirectories, path); + await PushToRemoteAsync(commitDirectories, path).ConfigureAwait(false); Console.WriteLine(); Console.WriteLine("Press any key..."); @@ -96,54 +84,6 @@ internal static async Task Sync(Arguments args) while (string.IsNullOrWhiteSpace(args.Path) || string.IsNullOrWhiteSpace(args.Filename)); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Console UI method where synchronization context is not relevant")] - private static async Task EnsureCredentialsAsync() - { - if (string.IsNullOrEmpty(Settings.Username)) - { - Console.WriteLine("Enter your git username:"); - await using Prompt prompt = new(); - - while (true) - { - PromptResult response = await prompt.ReadLineAsync().ConfigureAwait(false); - if (response.IsSuccess) - { - Settings.Username = response.Text; - Settings.Save(); - break; - } - - if (response.CancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException("User aborted credential entry."); - } - } - } - - if (string.IsNullOrEmpty(Settings.Token)) - { - Console.WriteLine("Enter your git token:"); - await using Prompt prompt = new(); - - while (true) - { - PromptResult response = await prompt.ReadLineAsync().ConfigureAwait(false); - if (response.IsSuccess) - { - Settings.Token = response.Text; - Settings.Save(); - break; - } - - if (response.CancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException("User aborted credential entry."); - } - } - } - } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Console UI method where synchronization context is not relevant")] private static async Task PromptForPathAsync(string path, string applicationDataPath) { @@ -443,7 +383,7 @@ private static void SyncFilesToHash( } } - private static void CommitChangedFiles( + private static async Task CommitChangedFilesAsync( HashSet commitDirectories, HashSet expandedFilesToSync, string path) @@ -455,15 +395,20 @@ private static void CommitChangedFiles( foreach (string dir in commitDirectories) { string directoryPath = Path.Combine(path, dir); - string repoPath = Repository.Discover(directoryPath); - if (repoPath?.EndsWith(".git\\", StringComparison.Ordinal) ?? false) + string repoRoot = await GitCli.DiscoverRootAsync(directoryPath).ConfigureAwait(false); + if (!string.IsNullOrEmpty(repoRoot)) { - using Repository repo = new(repoPath); foreach (string uniqueFilename in expandedFilesToSync) { string filePath = Path.Combine(directoryPath, uniqueFilename); - FileStatus fileStatus = repo.RetrieveStatus(filePath); - if (fileStatus is FileStatus.ModifiedInWorkdir or FileStatus.NewInWorkdir) + + // --porcelain prints nothing for a path that matches HEAD, so any output means + // the file is either modified or untracked. + GitResult status = await GitCli + .RunInAsync(repoRoot, "status", "--porcelain", "--", filePath) + .ConfigureAwait(false); + + if (status.Succeeded && status.OutputText.Length > 0) { commitFiles.Add(filePath); Console.WriteLine($"{filePath} has outstanding changes"); @@ -482,73 +427,93 @@ private static void CommitChangedFiles( Console.WriteLine(); foreach (string filePath in commitFiles) { - CommitFile(filePath); + await CommitFileAsync(filePath).ConfigureAwait(false); } } } } - private static void CommitFile(string filePath) + private static async Task CommitFileAsync(string filePath) { Console.WriteLine($"Committing: {filePath}"); - string repoPath = Repository.Discover(filePath); - if (!string.IsNullOrEmpty(repoPath)) + + string repoRoot = await GitCli.DiscoverRootAsync(filePath).ConfigureAwait(false); + if (string.IsNullOrEmpty(repoRoot)) { - using Repository repo = new(repoPath); - string relativeFilePath = filePath.Replace(repoPath.Replace(".git\\", "", StringComparison.Ordinal), "", StringComparison.Ordinal); - repo.Index.Add(relativeFilePath); - repo.Index.Write(); - try - { - _ = repo.Commit($"Sync {relativeFilePath}", new Signature(nameof(SyncFileContents), nameof(SyncFileContents), DateTimeOffset.Now), new Signature(nameof(SyncFileContents), nameof(SyncFileContents), DateTimeOffset.Now)); - } - catch (EmptyCommitException) - { - } - catch (UnmergedIndexEntriesException) - { - } + return; + } + + // Staging through git, rather than writing the index directly, is what lets the clean + // filter run so an LFS-tracked file is committed as a pointer instead of raw bytes. + GitResult staged = await GitCli.RunInAsync(repoRoot, "add", "--", filePath).ConfigureAwait(false); + if (!staged.Succeeded) + { + Console.WriteLine($"Failed to stage: {staged.FailureText}"); + return; + } + + string relativeFilePath = Path.GetRelativePath(repoRoot, filePath); + + // The identity is supplied per invocation so the commit is attributed to the tool without + // depending on, or disturbing, the repository's own configuration. + GitResult committed = await GitCli + .RunInAsync( + repoRoot, + "-c", $"user.name={nameof(SyncFileContents)}", + "-c", $"user.email={nameof(SyncFileContents)}", + "commit", + "-m", $"Sync {relativeFilePath}", + "--", filePath) + .ConfigureAwait(false); + + // A file already matching HEAD leaves nothing staged, which git reports as a failure but + // is the ordinary no-op case here. + if (!committed.Succeeded + && !committed.Output.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase) + && !committed.Output.Contains("nothing added to commit", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"Failed to commit: {committed.FailureText}"); } } - private static void PushToRemote(HashSet commitDirectories, string path) + private static async Task PushToRemoteAsync(HashSet commitDirectories, string path) { Collection pushDirectories = []; - IEnumerable commitRepos = commitDirectories.Select(f => Repository.Discover(Path.Combine(path, f))).Distinct(); - foreach (string repoPath in commitRepos) + HashSet seenRoots = []; + + foreach (string dir in commitDirectories) { - if (!string.IsNullOrEmpty(repoPath) && repoPath.EndsWith(".git\\", StringComparison.Ordinal)) + string repoRoot = await GitCli.DiscoverRootAsync(Path.Combine(path, dir)).ConfigureAwait(false); + if (string.IsNullOrEmpty(repoRoot) || !seenRoots.Add(repoRoot)) { - using Repository repo = new(repoPath); - string repoRoot = repoPath.Replace(".git\\", "", StringComparison.Ordinal); - Branch localBranch = repo.Branches[repo.Head.FriendlyName]; - int aheadBy = localBranch?.TrackingDetails.AheadBy ?? 0; - - int commitIndex = 0; - bool canPush = true; - foreach (Commit? commit in repo.Head.Commits) - { - if (commitIndex < aheadBy) - { - if (commit.Author.Name != nameof(SyncFileContents)) - { - canPush = false; - break; - } - } - else - { - break; - } + continue; + } - ++commitIndex; - } + // @{u} is the configured upstream. Without one, rev-list fails and there is nothing + // meaningful to push, so treat that as zero commits ahead. + GitResult ahead = await GitCli + .RunInAsync(repoRoot, "rev-list", "--count", "@{u}..HEAD") + .ConfigureAwait(false); - if (aheadBy > 0 && canPush) - { - pushDirectories.Add(repoRoot); - Console.WriteLine($"{repoRoot} can be pushed automatically"); - } + if (!ahead.Succeeded || !int.TryParse(ahead.OutputText, out int aheadBy) || aheadBy == 0) + { + continue; + } + + // Only push when every unpushed commit is one this tool made, so a user's own work is + // never pushed on their behalf. + GitResult authors = await GitCli + .RunInAsync(repoRoot, "log", "--format=%an", $"-{aheadBy}", "HEAD") + .ConfigureAwait(false); + + bool canPush = authors.Succeeded + && authors.OutputLines.Count == aheadBy + && authors.OutputLines.TrueForAll(author => author == nameof(SyncFileContents)); + + if (canPush) + { + pushDirectories.Add(repoRoot); + Console.WriteLine($"{repoRoot} can be pushed automatically"); } } @@ -560,94 +525,39 @@ private static void PushToRemote(HashSet commitDirectories, string path) if (Console.ReadLine()?.ToUpperInvariant() == "Y") { Console.WriteLine(); - foreach (string dir in pushDirectories) + foreach (string repoRoot in pushDirectories) { - PushDirectory(dir, path); + await PushDirectoryAsync(repoRoot).ConfigureAwait(false); } } } } - private static void PushDirectory(string dir, string path) + private static async Task PushDirectoryAsync(string repoRoot) { - Console.WriteLine($"Pushing: {dir}"); - string directoryPath = Path.Combine(path, dir); - string repoPath = Repository.Discover(directoryPath); - - UsernamePasswordCredentials credentials = new() - { - Username = Settings.Username, - Password = Settings.Token, - }; + Console.WriteLine($"Pushing: {repoRoot}"); - PushOptions pushOptions = new() + // Credentials are left to git, which uses the platform credential helper. That removes the + // need to prompt for a token and store it, and it means SSH remotes work too. + Console.WriteLine("Checking for remote changes..."); + GitResult pull = await GitCli.RunInAsync(repoRoot, "pull", "--ff-only").ConfigureAwait(false); + if (!pull.Succeeded) { - CredentialsProvider = (url, user, creds) => credentials, - OnPushStatusError = (pushStatusErrors) => Console.WriteLine($"Error pushing: {pushStatusErrors.Message}"), - OnPushTransferProgress = (current, total, bytes) => - { - Console.WriteLine($"Progress: {current} / {total} ({bytes} bytes)"); - return true; - }, - }; + Console.WriteLine($"Error during pull: {pull.FailureText}"); + Console.WriteLine("Skipping push so a divergent branch is resolved manually."); + return; + } - using Repository repo = new(repoPath); - try + // Pushing through git runs the LFS pre-push hook, which uploads the objects that the + // committed pointers refer to. + GitResult push = await GitCli.RunInAsync(repoRoot, "push").ConfigureAwait(false); + if (push.Succeeded) { - Console.WriteLine("Checking for remote changes..."); - Remote remote = repo.Network.Remotes["origin"]; - IEnumerable refSpecs = remote.FetchRefSpecs.Select(x => x.Specification); - - FetchOptions fetchOptions = new() - { - CredentialsProvider = (url, user, creds) => credentials, - }; - - try - { - Commands.Fetch(repo, remote.Name, refSpecs, fetchOptions, "Fetched latest changes"); - - Branch trackingBranch = repo.Head.TrackedBranch; - if (trackingBranch != null) - { - Commit remoteBranchTip = trackingBranch.Tip; - MergeResult mergeResult = repo.Merge(trackingBranch, new Signature(nameof(SyncFileContents), nameof(SyncFileContents), DateTimeOffset.Now)); - - if (mergeResult.Status == MergeStatus.UpToDate) - { - Console.WriteLine("Local branch is up to date with remote."); - } - else if (mergeResult.Status == MergeStatus.FastForward) - { - Console.WriteLine("Fast-forwarded local branch to remote changes."); - } - else if (mergeResult.Status == MergeStatus.NonFastForward) - { - Console.WriteLine("Merged remote changes with local branch (non-fast-forward)."); - } - else if (mergeResult.Status == MergeStatus.Conflicts) - { - Console.WriteLine("Cannot automatically merge due to conflicts. Please resolve conflicts manually."); - return; - } - } - } - catch (LibGit2SharpException ex) - { - Console.WriteLine($"Error during pull: {ex.Message}"); - if (ex.InnerException != null) - { - Console.WriteLine($"Inner error: {ex.InnerException.Message}"); - } - - Console.WriteLine("Continuing with push..."); - } - - repo.Network.Push(repo.Head, pushOptions); + Console.WriteLine($"Successfully pushed: {repoRoot}"); } - catch (LibGit2SharpException e) + else { - Console.WriteLine($"Error pushing: {e.Message}"); + Console.WriteLine($"Error pushing: {push.FailureText}"); } } diff --git a/SyncFileContents/SyncFileContents.csproj b/SyncFileContents/SyncFileContents.csproj index c777c6b..2f6c804 100644 --- a/SyncFileContents/SyncFileContents.csproj +++ b/SyncFileContents/SyncFileContents.csproj @@ -10,7 +10,7 @@ - + diff --git a/global.json b/global.json index 15c86b6..39f7683 100644 --- a/global.json +++ b/global.json @@ -5,15 +5,15 @@ }, "msbuild-sdks": { "MSTest.Sdk": "4.3.3", - "ktsu.Sdk": "2.18.0", - "ktsu.Sdk.ConsoleApp": "2.18.0", - "ktsu.Sdk.Tool": "2.18.0", - "ktsu.Sdk.App": "2.18.0", - "ktsu.Sdk.Windows": "2.18.0", - "ktsu.Sdk.Linux": "2.18.0", - "ktsu.Sdk.macOS": "2.18.0", - "ktsu.Sdk.iOS": "2.18.0", - "ktsu.Sdk.Android": "2.18.0" + "ktsu.Sdk": "2.21.1", + "ktsu.Sdk.ConsoleApp": "2.21.1", + "ktsu.Sdk.Tool": "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"