[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

Closed
opened 2026-07-14 23:01:09 +00:00 by spikerj · 2 comments
Owner

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:

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:

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.
Author
Owner

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/782Commands.Stage(repo, readmePath/gitignorePath) (the site the two tests actually hit, via CreateInitialCommitAsync)
  • 341/348Commands.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.

**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.
Author
Owner

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.)
Sign in to join this conversation.