diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..fb1befa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,66 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + - os: ubuntu-latest + rid: linux-x64 + - os: macos-latest + rid: osx-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - run: dotnet build Stampeded.slnx -c Release + - run: dotnet test Stampeded.slnx -c Release --no-build + + # Framework-dependent on purpose: the app needs an installed .NET SDK at runtime + # anyway (Roslyn's MSBuild workspace, dotnet build/test), so bundling a runtime + # would only grow the artifact. RID-specific so an apphost exists to launch. + - run: dotnet publish src/Stampeded/Stampeded.csproj -c Release -r ${{ matrix.rid }} --no-self-contained -o publish/${{ matrix.rid }} + + - name: Zip (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Compress-Archive -Path publish/win-x64/* -DestinationPath Stampeded-win-x64.zip + + # Info-ZIP keeps the execute bit; Compress-Archive and upload-artifact drop it. + - name: Zip (Linux) + if: runner.os == 'Linux' + shell: bash + run: cd publish/linux-x64 && zip -r -q ../../Stampeded-linux-x64.zip . + + # Unsigned and unnotarized: first launch needs `xattr -dr com.apple.quarantine Stampeded.app`. + - name: dmg (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + mkdir dmg + cp -R publish/osx-arm64/Stampeded.app dmg/ + ln -s /Applications dmg/Applications + hdiutil create -volname Stampeded -srcfolder dmg -ov -format UDZO Stampeded-macos-arm64.dmg + + - uses: actions/upload-artifact@v7 + with: + name: Stampeded-${{ matrix.rid }} + path: | + Stampeded-*.zip + Stampeded-*.dmg + if-no-files-found: error diff --git a/src/Stampeded/Assets/macos/Info.plist b/src/Stampeded/Assets/macos/Info.plist new file mode 100644 index 0000000..e4bba71 --- /dev/null +++ b/src/Stampeded/Assets/macos/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleName + Stampeded + CFBundleDisplayName + Stampeded + CFBundleIdentifier + net.icsharpcode.stampeded + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + CFBundlePackageType + APPL + CFBundleExecutable + Stampeded + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/src/Stampeded/Stampeded.csproj b/src/Stampeded/Stampeded.csproj index 5ae27ef..54617e9 100644 --- a/src/Stampeded/Stampeded.csproj +++ b/src/Stampeded/Stampeded.csproj @@ -24,4 +24,22 @@ + + + + <_MacBundleContents>$(PublishDir)$(AssemblyName).app/Contents + + + <_MacPublishFiles Include="$(PublishDir)**/*" Exclude="$(PublishDir)$(AssemblyName).app/**" /> + + + + + + + diff --git a/tests/Stampeded.Core.Tests/BuildArtifactCleanupTests.cs b/tests/Stampeded.Core.Tests/BuildArtifactCleanupTests.cs index 4858646..74a5827 100644 --- a/tests/Stampeded.Core.Tests/BuildArtifactCleanupTests.cs +++ b/tests/Stampeded.Core.Tests/BuildArtifactCleanupTests.cs @@ -18,13 +18,7 @@ public void SetUp() [TearDown] public void TearDown() { - try - { - Directory.Delete(root, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(root); } static void WriteFile(string directory, string name) diff --git a/tests/Stampeded.Core.Tests/DecompilationServiceTests.cs b/tests/Stampeded.Core.Tests/DecompilationServiceTests.cs index 5a0e0e8..a71db5a 100644 --- a/tests/Stampeded.Core.Tests/DecompilationServiceTests.cs +++ b/tests/Stampeded.Core.Tests/DecompilationServiceTests.cs @@ -72,7 +72,7 @@ Third line. } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } } diff --git a/tests/Stampeded.Core.Tests/DraftEditTests.cs b/tests/Stampeded.Core.Tests/DraftEditTests.cs index 6c7ec02..3995bf8 100644 --- a/tests/Stampeded.Core.Tests/DraftEditTests.cs +++ b/tests/Stampeded.Core.Tests/DraftEditTests.cs @@ -31,7 +31,7 @@ public void RewritesADraftInPlace() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -54,7 +54,7 @@ public void SurvivesReopeningTheReview() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -74,7 +74,7 @@ public void IgnoresADraftThatIsNotThere() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -99,7 +99,7 @@ public void AReplyKeepsTheThreadItAnswersAcrossSessions() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } } diff --git a/tests/Stampeded.Core.Tests/EnclosingMemberTests.cs b/tests/Stampeded.Core.Tests/EnclosingMemberTests.cs index 1df43e2..659a8a2 100644 --- a/tests/Stampeded.Core.Tests/EnclosingMemberTests.cs +++ b/tests/Stampeded.Core.Tests/EnclosingMemberTests.cs @@ -61,7 +61,7 @@ public class TypeInferenceTests } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -98,7 +98,7 @@ public void Dispose() { } } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } diff --git a/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs b/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs index 2405de3..b7eb357 100644 --- a/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs +++ b/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs @@ -19,13 +19,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } diff --git a/tests/Stampeded.Core.Tests/GitBlobReaderTests.cs b/tests/Stampeded.Core.Tests/GitBlobReaderTests.cs index 553fef9..e48ecf9 100644 --- a/tests/Stampeded.Core.Tests/GitBlobReaderTests.cs +++ b/tests/Stampeded.Core.Tests/GitBlobReaderTests.cs @@ -28,13 +28,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } diff --git a/tests/Stampeded.Core.Tests/GitInterdiffTests.cs b/tests/Stampeded.Core.Tests/GitInterdiffTests.cs index 5dd89a3..e3a9ad9 100644 --- a/tests/Stampeded.Core.Tests/GitInterdiffTests.cs +++ b/tests/Stampeded.Core.Tests/GitInterdiffTests.cs @@ -30,13 +30,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } diff --git a/tests/Stampeded.Core.Tests/GitMergeStateTests.cs b/tests/Stampeded.Core.Tests/GitMergeStateTests.cs index a7baa19..fb68a72 100644 --- a/tests/Stampeded.Core.Tests/GitMergeStateTests.cs +++ b/tests/Stampeded.Core.Tests/GitMergeStateTests.cs @@ -30,13 +30,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } @@ -160,10 +154,11 @@ public async Task RemovesTheWorktreeThatHoldsTheBranch() await Git("branch", "already-in"); string worktree = NewDirectory(); await Git("worktree", "add", "--quiet", worktree, "already-in"); + string worktreeAsGitReportsIt = await AsGitReports(worktree); var deletion = await new GitService(repo).DeleteBranchAsync("already-in"); - Assert.That(deletion.RemovedWorktree, Is.EqualTo(worktree)); + Assert.That(deletion.RemovedWorktree, Is.EqualTo(worktreeAsGitReportsIt)); Assert.That(Directory.Exists(worktree), Is.False); Assert.That(await Git("branch", "--format=%(refname:short)"), Does.Not.Contain("already-in")); } @@ -205,13 +200,14 @@ public async Task RemovesAWorktreeWithSubmodulesThatGitRefusesToTouch() await Git("branch", "already-in"); string worktree = NewDirectory(); await Git("worktree", "add", "--quiet", worktree, "already-in"); + string worktreeAsGitReportsIt = await AsGitReports(worktree); var deletion = await new GitService(repo).DeleteBranchAsync("already-in"); - Assert.That(deletion.RemovedWorktree, Is.EqualTo(worktree)); + Assert.That(deletion.RemovedWorktree, Is.EqualTo(worktreeAsGitReportsIt)); Assert.That(Directory.Exists(worktree), Is.False); Assert.That(await Git("branch", "--format=%(refname:short)"), Does.Not.Contain("already-in")); - Assert.That(await Git("worktree", "list", "--porcelain"), Does.Not.Contain(worktree), + Assert.That(await Git("worktree", "list", "--porcelain"), Does.Not.Contain(worktreeAsGitReportsIt), "the administrative entry has to go with the directory"); } @@ -273,4 +269,9 @@ async Task Commit(string fileName, string content) } Task Git(params string[] args) => ExternalTool.RunAsync("git", args, repo); + + /// The path in the form git prints it - forward slashes on Windows, symlinks + /// resolved on macOS - which is the form the service passes on unchanged. + static async Task AsGitReports(string dir) + => (await ExternalTool.RunAsync("git", ["rev-parse", "--show-toplevel"], dir)).Trim(); } diff --git a/tests/Stampeded.Core.Tests/GitPushTests.cs b/tests/Stampeded.Core.Tests/GitPushTests.cs index 76900cf..58c5023 100644 --- a/tests/Stampeded.Core.Tests/GitPushTests.cs +++ b/tests/Stampeded.Core.Tests/GitPushTests.cs @@ -35,13 +35,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } diff --git a/tests/Stampeded.Core.Tests/GitRebaseTests.cs b/tests/Stampeded.Core.Tests/GitRebaseTests.cs index 3fdcce8..a440e4e 100644 --- a/tests/Stampeded.Core.Tests/GitRebaseTests.cs +++ b/tests/Stampeded.Core.Tests/GitRebaseTests.cs @@ -36,13 +36,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } @@ -67,6 +61,7 @@ public async Task RebasesABranchThatIsCheckedOutInAWorktree() { string worktree = NewDirectory(); await Git(repo, "worktree", "add", "--quiet", worktree, "topic"); + string worktreeAsGitReportsIt = await AsGitReports(worktree); var git = new GitService(repo); var result = await git.RebaseBranchAsync("topic", "main"); @@ -75,14 +70,14 @@ public async Task RebasesABranchThatIsCheckedOutInAWorktree() "topic should now sit on top of main"); // The checkout that holds the branch has to move with it, or its index and working // tree describe a commit the branch no longer points at. - Assert.That(result.Checkout, Is.EqualTo(worktree)); + Assert.That(result.Checkout, Is.EqualTo(worktreeAsGitReportsIt)); Assert.That((await Git(worktree, "rev-parse", "HEAD")).Trim(), Is.EqualTo(await RevParse("topic"))); Assert.That((await Git(worktree, "status", "--porcelain")).Trim(), Is.Empty); Assert.That(File.Exists(Path.Combine(worktree, "main.txt")), Is.True, "the rebased checkout should have main's file"); // The recovery the UI offers has to work here, and git branch -f would be refused. - Assert.That(result.RecoveryCommand("topic"), Is.EqualTo($"git -C {worktree} reset --hard {result.Before[..9]}")); + Assert.That(result.RecoveryCommand("topic"), Is.EqualTo($"git -C {worktreeAsGitReportsIt} reset --hard {result.Before[..9]}")); await Git(worktree, "reset", "--hard", result.Before); Assert.That(await RevParse("topic"), Is.EqualTo(result.Before)); } @@ -176,6 +171,11 @@ async Task Commit(string fileName, string content) Task Git(string dir, params string[] args) => ExternalTool.RunAsync("git", args, dir); + /// The path in the form git prints it - forward slashes on Windows, symlinks + /// resolved on macOS - which is the form the service passes on unchanged. + static async Task AsGitReports(string dir) + => (await ExternalTool.RunAsync("git", ["rev-parse", "--show-toplevel"], dir)).Trim(); + async Task RevParse(string reference) => (await Git(repo, "rev-parse", reference)).Trim(); async Task MergeBase(string a, string b) => (await Git(repo, "merge-base", a, b)).Trim(); diff --git a/tests/Stampeded.Core.Tests/ReReviewTests.cs b/tests/Stampeded.Core.Tests/ReReviewTests.cs index d922973..344d878 100644 --- a/tests/Stampeded.Core.Tests/ReReviewTests.cs +++ b/tests/Stampeded.Core.Tests/ReReviewTests.cs @@ -43,7 +43,7 @@ public void StoreCapturesSupersededStateOnHeadMove() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -73,7 +73,7 @@ public void ThePreviousHeadOutlivesTheOpenThatDiscoveredTheMove() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -101,7 +101,7 @@ public void StateWrittenBeforeTheBaselineExistedStillLoads() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } } diff --git a/tests/Stampeded.Core.Tests/SideBySideBuilderTests.cs b/tests/Stampeded.Core.Tests/SideBySideBuilderTests.cs index 204921e..401b5b8 100644 --- a/tests/Stampeded.Core.Tests/SideBySideBuilderTests.cs +++ b/tests/Stampeded.Core.Tests/SideBySideBuilderTests.cs @@ -7,20 +7,21 @@ namespace Stampeded.Core.Tests; [TestFixture] public class SideBySideBuilderTests { - const string OldText = """ + // Raw literals take the line endings of the checkout; the builder speaks LF. + static readonly string OldText = """ line a line b line c line d - """; + """.ReplaceLineEndings("\n"); - const string NewText = """ + static readonly string NewText = """ line a line b CHANGED line c inserted line line d - """; + """.ReplaceLineEndings("\n"); [Test] public void SidesHaveEqualLineCounts() diff --git a/tests/Stampeded.Core.Tests/SolutionTargetTests.cs b/tests/Stampeded.Core.Tests/SolutionTargetTests.cs index 66a4646..aaad564 100644 --- a/tests/Stampeded.Core.Tests/SolutionTargetTests.cs +++ b/tests/Stampeded.Core.Tests/SolutionTargetTests.cs @@ -21,7 +21,7 @@ public void SetUp() public void TearDown() { if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); + TempDirectory.Delete(root); } void Write(string name, int size) => File.WriteAllText(Path.Combine(root, name), new string('x', size)); diff --git a/tests/Stampeded.Core.Tests/TempDirectory.cs b/tests/Stampeded.Core.Tests/TempDirectory.cs new file mode 100644 index 0000000..b369c52 --- /dev/null +++ b/tests/Stampeded.Core.Tests/TempDirectory.cs @@ -0,0 +1,29 @@ +namespace Stampeded.Core.Tests; + +/// Removes a directory a test created under the temp folder. git writes its loose +/// objects read-only, and on Windows Directory.Delete refuses a read-only file, so the flag is +/// cleared first. Removal is best effort: a directory that cannot go is a leak in the temp +/// folder, not a verdict on what the test asserted. +static class TempDirectory +{ + public static void Delete(string dir) + { + if (!Directory.Exists(dir)) + return; + try + { + // Reparse points are skipped so a symlink a test planted is not followed out of + // the directory being cleaned. + var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint }; + foreach (var file in Directory.EnumerateFiles(dir, "*", options)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(dir, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/tests/Stampeded.Core.Tests/WorkspacePathTests.cs b/tests/Stampeded.Core.Tests/WorkspacePathTests.cs index ceed746..089e409 100644 --- a/tests/Stampeded.Core.Tests/WorkspacePathTests.cs +++ b/tests/Stampeded.Core.Tests/WorkspacePathTests.cs @@ -31,7 +31,7 @@ public async Task RoundTripsAPathWithTheSeparatorsGitUses() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -53,8 +53,8 @@ public async Task RejectsAPathOutsideTheWorktree() } finally { - Directory.Delete(root, recursive: true); - Directory.Delete(sibling, recursive: true); + TempDirectory.Delete(root); + TempDirectory.Delete(sibling); } } } diff --git a/tests/Stampeded.Core.Tests/WorktreeCacheTests.cs b/tests/Stampeded.Core.Tests/WorktreeCacheTests.cs index 307f205..f16f4f8 100644 --- a/tests/Stampeded.Core.Tests/WorktreeCacheTests.cs +++ b/tests/Stampeded.Core.Tests/WorktreeCacheTests.cs @@ -34,13 +34,7 @@ public void RemoveTemporaryDirectories() Environment.SetEnvironmentVariable("XDG_CACHE_HOME", null); foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); }