[Bug][Backend][CodeExecution] GitService stages files by absolute path — 2 unit tests fail on macOS (/var→/private/var symlink), and prod breaks if Git:RepositoryPath is ever a symlinked path
#604
GitServicePerUserRepositoryTests.CreateBranchAndCommitAsync_UsesSingleUserRepositoryWithLanguageScopedPath and ...UsesSandboxBranchNameForFreeplaySubmissions fail on macOS. Both surface as result.Success == false with result.Error == "Failed to initialize repository", which swallows the real cause.
Surfaced the swallowed exception (temporary rethrow in InitializeRepositoryAsync's catch):
System.ArgumentException: Unable to process file
'/var/folders/.../spikersoft-git-<guid>/user-123/README.md'.
This file is not located under the working directory of the repository
('/private/var/folders/.../spikersoft-git-<guid>/user-123/').
Root cause
GitService.CreateInitialCommitAsync (and the submission-commit path) stage files with absolute paths:
The test's Git:RepositoryPath comes from Path.GetTempPath() → /var/folders/.... On macOS /var is a symlink to /private/var, and LibGit2Sharp canonicalizes the repository working directory to /private/var/folders/.... Commands.Stage then rejects the /var/... path as "not located under the working directory," because string-wise it isn't. The exception is caught and flattened to Success=false, so the test just sees a false.
Why CI is green but local macOS is red
On Linux CI, Path.GetTempPath() (/tmp/...) is not a symlink and Git:RepositoryPath in production is a real configured path, so the absolute path matches the canonicalized working dir and staging succeeds. This is why the team (which merges on CI) never sees it. It reproduces reliably on macOS/arm64 dev machines.
This is also a latent production bug
It's not purely a test artifact. If Git:RepositoryPath is ever configured to a path that contains a symlink component (e.g. a bind-mounted volume that resolves elsewhere, or /var-style aliasing), the same Commands.Stage rejection fires in production and every submission commit fails — silently, because the catch turns it into Success=false.
Proposed fix
Stage by repository-relative path instead of absolute:
Commands.Stage(repo,"README.md");Commands.Stage(repo,".gitignore");// submission path: stage the path relative to repo.Info.WorkingDirectory
LibGit2Sharp resolves relative paths against the canonicalized working directory, sidestepping the symlink mismatch entirely, and is more correct regardless of how Git:RepositoryPath is configured. Separately, InitializeRepositoryAsync should not flatten the exception to a bare bool — put ex.Message on the returned error so this class of failure is diagnosable without a rethrow.
Guard
The existing two tests already exercise this once staging is fixed — no new test needed to prove the fix, but they should be kept (they'd have caught it if staging had ever been relative). Verify the fix by running the suite on macOS.
Scope note
Found while working #594 (Serilog config binding) — unrelated subsystem, filed separately rather than bundled. dotnet build SpikerSoft.UnitTests.slnf is clean; these are the only 2 unit-test failures on macOS.
## Summary
`GitServicePerUserRepositoryTests.CreateBranchAndCommitAsync_UsesSingleUserRepositoryWithLanguageScopedPath` and `...UsesSandboxBranchNameForFreeplaySubmissions` fail on macOS. Both surface as `result.Success == false` with `result.Error == "Failed to initialize repository"`, which swallows the real cause.
Surfaced the swallowed exception (temporary rethrow in `InitializeRepositoryAsync`'s catch):
```
System.ArgumentException: Unable to process file
'/var/folders/.../spikersoft-git-<guid>/user-123/README.md'.
This file is not located under the working directory of the repository
('/private/var/folders/.../spikersoft-git-<guid>/user-123/').
```
## Root cause
`GitService.CreateInitialCommitAsync` (and the submission-commit path) stage files with **absolute** paths:
```csharp
Commands.Stage(repo, readmePath); // readmePath = Path.Combine(uploadPath, "README.md")
Commands.Stage(repo, gitignorePath);
```
The test's `Git:RepositoryPath` comes from `Path.GetTempPath()` → `/var/folders/...`. On macOS `/var` is a symlink to `/private/var`, and LibGit2Sharp canonicalizes the repository working directory to `/private/var/folders/...`. `Commands.Stage` then rejects the `/var/...` path as "not located under the working directory," because string-wise it isn't. The exception is caught and flattened to `Success=false`, so the test just sees a false.
## Why CI is green but local macOS is red
On Linux CI, `Path.GetTempPath()` (`/tmp/...`) is not a symlink and `Git:RepositoryPath` in production is a real configured path, so the absolute path matches the canonicalized working dir and staging succeeds. This is why the team (which merges on CI) never sees it. It reproduces reliably on macOS/arm64 dev machines.
## This is also a latent production bug
It's not purely a test artifact. If `Git:RepositoryPath` is ever configured to a path that contains a symlink component (e.g. a bind-mounted volume that resolves elsewhere, or `/var`-style aliasing), the same `Commands.Stage` rejection fires in production and every submission commit fails — silently, because the catch turns it into `Success=false`.
## Proposed fix
Stage by **repository-relative** path instead of absolute:
```csharp
Commands.Stage(repo, "README.md");
Commands.Stage(repo, ".gitignore");
// submission path: stage the path relative to repo.Info.WorkingDirectory
```
LibGit2Sharp resolves relative paths against the canonicalized working directory, sidestepping the symlink mismatch entirely, and is more correct regardless of how `Git:RepositoryPath` is configured. Separately, `InitializeRepositoryAsync` should not flatten the exception to a bare bool — put `ex.Message` on the returned error so this class of failure is diagnosable without a rethrow.
## Guard
The existing two tests already exercise this once staging is fixed — no new test needed to prove the fix, but they should be kept (they'd have caught it if staging had ever been relative). Verify the fix by running the suite on macOS.
## Scope note
Found while working #594 (Serilog config binding) — unrelated subsystem, filed separately rather than bundled. `dotnet build SpikerSoft.UnitTests.slnf` is clean; these are the only 2 unit-test failures on macOS.
Confirmed live on macOS/arm64 and fixed — PR spikersoft-backend#308 (open, awaiting merge).
Reproduced
Ran the two named tests on this Mac before touching anything — both fail exactly as reported:
Failed ...CreateBranchAndCommitAsync_UsesSandboxBranchNameForFreeplaySubmissions
Failed ...CreateBranchAndCommitAsync_UsesSingleUserRepositoryWithLanguageScopedPath
Expected result.Success to be True because Failed to initialize repository, but found False.
Failed! - Failed: 2, Passed: 0
The because Failed to initialize repository text confirms the diagnostic-flattening you flagged: the real Commands.Stage exception is swallowed by InitializeRepositoryAsync's catch (GitService.cs:279-283) into a bare Success=false.
Root cause — confirmed, exactly as filed
Four absolute-path staging sites in SpikerSoft.EventHandlers.CodeExecution/Services/GitService.cs:
781/782 — Commands.Stage(repo, readmePath/gitignorePath) (the site the two tests actually hit, via CreateInitialCommitAsync)
341/348 — Commands.Stage(repo, submissionFile/submissionCsprojFile) (same latent bug on the submission path)
Staged by working-dir-relative pathspec instead: "README.md", ".gitignore", the already-computed relativePath, and $"{canonical}/{bucket}/Submission.csproj". LibGit2Sharp resolves these against the canonicalised working dir, sidestepping the /var→/private/var (and any symlinked Git:RepositoryPath) mismatch.
Your secondary ask (stop flattening the exception to a bare bool; surface ex.Message) is not in this PR because it changes the public IGitService.InitializeRepositoryAsync bool contract — broader than an isolated bugfix. Options for the follow-up: (a) change the return to a small result/tuple carrying the error, or (b) let the exception propagate to CreateBranchAndCommitAsync's outer catch (which already sets result.Error = ex.Message) and drop the internal swallow. Happy to do either as a separate PR. Leaving this ticket open until #308 merges.
**Confirmed live on macOS/arm64 and fixed** — PR spikersoft-backend#308 (open, awaiting merge).
### Reproduced
Ran the two named tests on this Mac before touching anything — both fail exactly as reported:
```
Failed ...CreateBranchAndCommitAsync_UsesSandboxBranchNameForFreeplaySubmissions
Failed ...CreateBranchAndCommitAsync_UsesSingleUserRepositoryWithLanguageScopedPath
Expected result.Success to be True because Failed to initialize repository, but found False.
Failed! - Failed: 2, Passed: 0
```
The `because Failed to initialize repository` text confirms the diagnostic-flattening you flagged: the real `Commands.Stage` exception is swallowed by `InitializeRepositoryAsync`'s catch (`GitService.cs:279-283`) into a bare `Success=false`.
### Root cause — confirmed, exactly as filed
Four absolute-path staging sites in `SpikerSoft.EventHandlers.CodeExecution/Services/GitService.cs`:
- `781`/`782` — `Commands.Stage(repo, readmePath/gitignorePath)` (the site the two tests actually hit, via `CreateInitialCommitAsync`)
- `341`/`348` — `Commands.Stage(repo, submissionFile/submissionCsprojFile)` (same latent bug on the submission path)
### Fix (PR #308)
Staged by working-dir-relative pathspec instead: `"README.md"`, `".gitignore"`, the already-computed `relativePath`, and `$"{canonical}/{bucket}/Submission.csproj"`. LibGit2Sharp resolves these against the canonicalised working dir, sidestepping the `/var`→`/private/var` (and any symlinked `Git:RepositoryPath`) mismatch.
### Verified
- Both tests now pass (`Failed: 0, Passed: 2`).
- `dotnet build SpikerSoft.UnitTests.slnf` clean (0 errors).
### Deferred half — please advise
Your secondary ask (stop flattening the exception to a bare bool; surface `ex.Message`) is **not** in this PR because it changes the public `IGitService.InitializeRepositoryAsync` bool contract — broader than an isolated bugfix. Options for the follow-up: (a) change the return to a small result/tuple carrying the error, or (b) let the exception propagate to `CreateBranchAndCommitAsync`'s outer catch (which already sets `result.Error = ex.Message`) and drop the internal swallow. Happy to do either as a separate PR. Leaving this ticket **open** until #308 merges.
PR spikersoft-backend#308 merged to master (merged: true). Both GitServicePerUserRepositoryTests pass on macOS and the latent symlinked-Git:RepositoryPath prod failure is closed. Closing. (Diagnostic-flattening follow-up remains available as a separate PR if you want it — say the word and I'll open one.)
PR spikersoft-backend#308 merged to `master` (`merged: true`). Both `GitServicePerUserRepositoryTests` pass on macOS and the latent symlinked-`Git:RepositoryPath` prod failure is closed. Closing. (Diagnostic-flattening follow-up remains available as a separate PR if you want it — say the word and I'll open one.)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
GitServicePerUserRepositoryTests.CreateBranchAndCommitAsync_UsesSingleUserRepositoryWithLanguageScopedPathand...UsesSandboxBranchNameForFreeplaySubmissionsfail on macOS. Both surface asresult.Success == falsewithresult.Error == "Failed to initialize repository", which swallows the real cause.Surfaced the swallowed exception (temporary rethrow in
InitializeRepositoryAsync's catch):Root cause
GitService.CreateInitialCommitAsync(and the submission-commit path) stage files with absolute paths:The test's
Git:RepositoryPathcomes fromPath.GetTempPath()→/var/folders/.... On macOS/varis a symlink to/private/var, and LibGit2Sharp canonicalizes the repository working directory to/private/var/folders/....Commands.Stagethen rejects the/var/...path as "not located under the working directory," because string-wise it isn't. The exception is caught and flattened toSuccess=false, so the test just sees a false.Why CI is green but local macOS is red
On Linux CI,
Path.GetTempPath()(/tmp/...) is not a symlink andGit:RepositoryPathin production is a real configured path, so the absolute path matches the canonicalized working dir and staging succeeds. This is why the team (which merges on CI) never sees it. It reproduces reliably on macOS/arm64 dev machines.This is also a latent production bug
It's not purely a test artifact. If
Git:RepositoryPathis ever configured to a path that contains a symlink component (e.g. a bind-mounted volume that resolves elsewhere, or/var-style aliasing), the sameCommands.Stagerejection fires in production and every submission commit fails — silently, because the catch turns it intoSuccess=false.Proposed fix
Stage by repository-relative path instead of absolute:
LibGit2Sharp resolves relative paths against the canonicalized working directory, sidestepping the symlink mismatch entirely, and is more correct regardless of how
Git:RepositoryPathis configured. Separately,InitializeRepositoryAsyncshould not flatten the exception to a bare bool — putex.Messageon the returned error so this class of failure is diagnosable without a rethrow.Guard
The existing two tests already exercise this once staging is fixed — no new test needed to prove the fix, but they should be kept (they'd have caught it if staging had ever been relative). Verify the fix by running the suite on macOS.
Scope note
Found while working #594 (Serilog config binding) — unrelated subsystem, filed separately rather than bundled.
dotnet build SpikerSoft.UnitTests.slnfis clean; these are the only 2 unit-test failures on macOS.Confirmed live on macOS/arm64 and fixed — PR spikersoft-backend#308 (open, awaiting merge).
Reproduced
Ran the two named tests on this Mac before touching anything — both fail exactly as reported:
The
because Failed to initialize repositorytext confirms the diagnostic-flattening you flagged: the realCommands.Stageexception is swallowed byInitializeRepositoryAsync's catch (GitService.cs:279-283) into a bareSuccess=false.Root cause — confirmed, exactly as filed
Four absolute-path staging sites in
SpikerSoft.EventHandlers.CodeExecution/Services/GitService.cs:781/782—Commands.Stage(repo, readmePath/gitignorePath)(the site the two tests actually hit, viaCreateInitialCommitAsync)341/348—Commands.Stage(repo, submissionFile/submissionCsprojFile)(same latent bug on the submission path)Fix (PR #308)
Staged by working-dir-relative pathspec instead:
"README.md",".gitignore", the already-computedrelativePath, and$"{canonical}/{bucket}/Submission.csproj". LibGit2Sharp resolves these against the canonicalised working dir, sidestepping the/var→/private/var(and any symlinkedGit:RepositoryPath) mismatch.Verified
Failed: 0, Passed: 2).dotnet build SpikerSoft.UnitTests.slnfclean (0 errors).Deferred half — please advise
Your secondary ask (stop flattening the exception to a bare bool; surface
ex.Message) is not in this PR because it changes the publicIGitService.InitializeRepositoryAsyncbool contract — broader than an isolated bugfix. Options for the follow-up: (a) change the return to a small result/tuple carrying the error, or (b) let the exception propagate toCreateBranchAndCommitAsync's outer catch (which already setsresult.Error = ex.Message) and drop the internal swallow. Happy to do either as a separate PR. Leaving this ticket open until #308 merges.PR spikersoft-backend#308 merged to
master(merged: true). BothGitServicePerUserRepositoryTestspass on macOS and the latent symlinked-Git:RepositoryPathprod failure is closed. Closing. (Diagnostic-flattening follow-up remains available as a separate PR if you want it — say the word and I'll open one.)