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();
}