From bb743925340d9a7f244c3757553b38fe89d745ea Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 11 Apr 2026 11:11:49 +1000 Subject: [PATCH] Skip forked repositories by default when scanning directories Forked repos (detected by having an "upstream" git remote) are now skipped during recursive directory scanning. Targeting a fork's directory directly still updates it. This prevents unintended package updates in repositories the user doesn't own. --- claude.md | 9 +- readme.md | 13 +++ readme.source.md | 13 +++ src/Directory.Build.props | 2 +- src/PackageUpdate/ForkDetector.cs | 56 ++++++++++ src/PackageUpdate/Program.cs | 6 ++ src/Tests/ForkDetectorTests.cs | 168 ++++++++++++++++++++++++++++++ 7 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 src/PackageUpdate/ForkDetector.cs create mode 100644 src/Tests/ForkDetectorTests.cs diff --git a/claude.md b/claude.md index 04a97c6..99aee06 100644 --- a/claude.md +++ b/claude.md @@ -93,9 +93,10 @@ packageupdate --build ### Core Workflow (Program.cs) 1. **Solution Discovery** (`FileSystem.FindSolutions`): Recursively scans target directory for `*.sln` and `*.slnx` files -2. **Solution Validation**: Checks for `Directory.Packages.props` (CPM requirement) and applies exclusion rules -3. **Package Update** (`Updater.Update`): Updates package versions in `Directory.Packages.props` -4. **Optional Build** (`DotnetStarter.Build`): Builds solution after update if `--build` flag is provided +2. **Fork Detection** (`ForkDetector.ShouldSkip`): Skips forked repos (those with an "upstream" remote) unless explicitly targeted +3. **Solution Validation**: Checks for `Directory.Packages.props` (CPM requirement) and applies exclusion rules +4. **Package Update** (`Updater.Update`): Updates package versions in `Directory.Packages.props` +5. **Optional Build** (`DotnetStarter.Build`): Builds solution after update if `--build` flag is provided ### Key Components @@ -110,6 +111,8 @@ packageupdate --build - **PackageSourceReader.cs**: Reads NuGet sources from NuGet.config hierarchy using NuGet settings infrastructure +- **ForkDetector.cs**: Detects forked git repos by checking for an "upstream" remote in `.git/config`. Skips forks found via recursive scanning; allows forks when explicitly targeted + - **Excluder.cs**: Solution filtering via `PackageUpdateIgnores` environment variable (comma-separated list) - **FileSystem.cs**: Safe recursive directory traversal with UnauthorizedAccessException handling diff --git a/readme.md b/readme.md index 465291f..34cc8ed 100644 --- a/readme.md +++ b/readme.md @@ -120,6 +120,19 @@ setx PackageUpdateIgnores "AspNetCore,EntityFrameworkCore" The value is comma separated. +## Fork Skipping + +When scanning a parent directory that contains multiple repositories, forked repositories are automatically skipped. A repository is detected as a fork if it has a git remote named `upstream` in its `.git/config`. + +To update a forked repository, target its directory directly: + +```ps +packageupdate C:\Code\MyForkedRepo +``` + +This ensures forks are only updated intentionally, preventing unexpected changes to repositories you don't own. + + ## Add to Windows Explorer Use [context-menu.reg](/src/context-menu.reg) to add PackageUpdate to the Windows Explorer context menu. diff --git a/readme.source.md b/readme.source.md index fd3cd10..955d546 100644 --- a/readme.source.md +++ b/readme.source.md @@ -113,6 +113,19 @@ setx PackageUpdateIgnores "AspNetCore,EntityFrameworkCore" The value is comma separated. +## Fork Skipping + +When scanning a parent directory that contains multiple repositories, forked repositories are automatically skipped. A repository is detected as a fork if it has a git remote named `upstream` in its `.git/config`. + +To update a forked repository, target its directory directly: + +```ps +packageupdate C:\Code\MyForkedRepo +``` + +This ensures forks are only updated intentionally, preventing unexpected changes to repositories you don't own. + + ## Add to Windows Explorer Use [context-menu.reg](/src/context-menu.reg) to add PackageUpdate to the Windows Explorer context menu. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1f10cd3..e4b4426 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@ - 4.1.7 + 4.2.0 preview NU1608 1.0.0 diff --git a/src/PackageUpdate/ForkDetector.cs b/src/PackageUpdate/ForkDetector.cs new file mode 100644 index 0000000..ff3f0af --- /dev/null +++ b/src/PackageUpdate/ForkDetector.cs @@ -0,0 +1,56 @@ +static class ForkDetector +{ + static ConcurrentDictionary cache = new(StringComparer.OrdinalIgnoreCase); + + public static bool ShouldSkip(string targetDirectory, string solutionPath) + { + var solutionDir = Path.GetDirectoryName(solutionPath)!; + var gitRoot = FindGitRoot(solutionDir); + if (gitRoot == null) + { + return false; + } + + if (!cache.GetOrAdd(gitRoot, HasUpstreamRemote)) + { + return false; + } + + var normalizedTarget = Path.GetFullPath(targetDirectory); + var normalizedGitRoot = Path.GetFullPath(gitRoot); + + // Skip if target is above the git root (fork was discovered via scanning) + // Don't skip if target is at or below the git root (fork was explicitly targeted) + return !normalizedTarget.Equals(normalizedGitRoot, StringComparison.OrdinalIgnoreCase) && + !normalizedTarget.StartsWith(normalizedGitRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + static string? FindGitRoot(string directory) + { + var current = new DirectoryInfo(directory); + while (current != null) + { + var gitPath = Path.Combine(current.FullName, ".git"); + if (Directory.Exists(gitPath) || File.Exists(gitPath)) + { + return current.FullName; + } + + current = current.Parent; + } + + return null; + } + + static bool HasUpstreamRemote(string gitRoot) + { + var configPath = Path.Combine(gitRoot, ".git", "config"); + if (!File.Exists(configPath)) + { + return false; + } + + var content = File.ReadAllText(configPath); + return content.Contains("[remote \"upstream\"]", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/PackageUpdate/Program.cs b/src/PackageUpdate/Program.cs index a8ae6cb..d4b4c83 100644 --- a/src/PackageUpdate/Program.cs +++ b/src/PackageUpdate/Program.cs @@ -22,6 +22,12 @@ static async Task Inner(string directory, string? package, bool build) }; foreach (var solution in FileSystem.FindSolutions(directory)) { + if (ForkDetector.ShouldSkip(directory, solution)) + { + Log.Information(" Skipping fork: {Solution}", solution); + continue; + } + await TryProcessSolution(cache, solution, package, build); } diff --git a/src/Tests/ForkDetectorTests.cs b/src/Tests/ForkDetectorTests.cs new file mode 100644 index 0000000..ffdf33f --- /dev/null +++ b/src/Tests/ForkDetectorTests.cs @@ -0,0 +1,168 @@ +public class ForkDetectorTests +{ + [Test] + public async Task NoGitRepo_ShouldNotSkip() + { + using var temp = new TempDir(); + var solutionPath = Path.Combine(temp.Path, "test.sln"); + + await Assert.That(ForkDetector.ShouldSkip(temp.Path, solutionPath)).IsFalse(); + } + + [Test] + public async Task NotAFork_ShouldNotSkip() + { + using var temp = new TempDir(); + var repoDir = Path.Combine(temp.Path, "repo"); + CreateGitConfig( + repoDir, + """ + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/user/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* + """); + + var solutionPath = Path.Combine(repoDir, "test.sln"); + + await Assert.That(ForkDetector.ShouldSkip(temp.Path, solutionPath)).IsFalse(); + } + + [Test] + public async Task Fork_DiscoveredViaScanning_ShouldSkip() + { + using var temp = new TempDir(); + var repoDir = Path.Combine(temp.Path, "forked-repo"); + CreateGitConfig( + repoDir, + """ + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/myuser/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* + [remote "upstream"] + url = https://github.com/original/repo.git + fetch = +refs/heads/*:refs/remotes/upstream/* + """); + + var solutionPath = Path.Combine(repoDir, "test.sln"); + + // Target is the parent directory, fork was discovered via scanning + await Assert.That(ForkDetector.ShouldSkip(temp.Path, solutionPath)).IsTrue(); + } + + [Test] + public async Task Fork_ExplicitlyTargeted_ShouldNotSkip() + { + using var temp = new TempDir(); + var repoDir = Path.Combine(temp.Path, "forked-repo"); + CreateGitConfig( + repoDir, + """ + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/myuser/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* + [remote "upstream"] + url = https://github.com/original/repo.git + fetch = +refs/heads/*:refs/remotes/upstream/* + """); + + var solutionPath = Path.Combine(repoDir, "test.sln"); + + // Target is the repo root itself, fork is explicitly targeted + await Assert.That(ForkDetector.ShouldSkip(repoDir, solutionPath)).IsFalse(); + } + + [Test] + public async Task Fork_TargetInsideRepo_ShouldNotSkip() + { + using var temp = new TempDir(); + var repoDir = Path.Combine(temp.Path, "forked-repo"); + var srcDir = Path.Combine(repoDir, "src"); + Directory.CreateDirectory(srcDir); + CreateGitConfig( + repoDir, + """ + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/myuser/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* + [remote "upstream"] + url = https://github.com/original/repo.git + fetch = +refs/heads/*:refs/remotes/upstream/* + """); + + var solutionPath = Path.Combine(srcDir, "test.sln"); + + // Target is inside the repo, fork is explicitly targeted + await Assert.That(ForkDetector.ShouldSkip(srcDir, solutionPath)).IsFalse(); + } + + [Test] + public async Task Fork_NoGitConfig_ShouldNotSkip() + { + using var temp = new TempDir(); + var repoDir = Path.Combine(temp.Path, "repo"); + var gitDir = Path.Combine(repoDir, ".git"); + Directory.CreateDirectory(gitDir); + // No config file inside .git + + var solutionPath = Path.Combine(repoDir, "test.sln"); + + await Assert.That(ForkDetector.ShouldSkip(temp.Path, solutionPath)).IsFalse(); + } + + [Test] + public async Task SolutionInSubdirectory_Fork_ShouldSkip() + { + using var temp = new TempDir(); + var repoDir = Path.Combine(temp.Path, "forked-repo"); + var srcDir = Path.Combine(repoDir, "src", "MyProject"); + Directory.CreateDirectory(srcDir); + CreateGitConfig( + repoDir, + """ + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/myuser/repo.git + [remote "upstream"] + url = https://github.com/original/repo.git + """); + + var solutionPath = Path.Combine(srcDir, "test.sln"); + + // Target is above the git root, fork discovered via scanning + await Assert.That(ForkDetector.ShouldSkip(temp.Path, solutionPath)).IsTrue(); + } + + static void CreateGitConfig(string repoDir, string configContent) + { + var gitDir = Path.Combine(repoDir, ".git"); + Directory.CreateDirectory(gitDir); + File.WriteAllText(Path.Combine(gitDir, "config"), configContent); + } + + sealed class TempDir : + IDisposable + { + public string Path { get; } + + public TempDir() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "PackageUpdateTests", + Guid.NewGuid().ToString()); + Directory.CreateDirectory(Path); + } + + public void Dispose() => + Directory.Delete(Path, true); + } +}