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"