Skip to content

Commit 082cdac

Browse files
authored
Parallelize test suite to speed up CI (#578)
* Speed up race-enabled tests with isolated process shards * Separate runtime optimizations and harden test runner * Rename test runner to scripts/test
1 parent a644af6 commit 082cdac

16 files changed

Lines changed: 1506 additions & 146 deletions

‎.github/copilot-instructions.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ Repository operations require Git 2.36+.
1010
go mod download # install deps
1111
go build -o gh-stack . # build
1212
go vet ./... # static analysis. Always run before tests.
13-
go test -race -count=1 ./... # tests with race detection
13+
go run ./scripts/test -workers 4 # full race-enabled suite in isolated processes
1414
```
1515

16+
Run vet before tests. See [CONTRIBUTING.md](../CONTRIBUTING.md#test) for canonical/targeted commands and runner details. Do not add `t.Parallel` to tests that change Git mocks, cwd, or environment variables.
17+
1618
No Makefile, no code generation, no external linter config. Standard Go toolchain only.
1719

1820
## Project layout

‎.github/workflows/test.yml‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ on:
44
push:
55
branches: [main]
66
pull_request:
7-
branches: [main]
87

98
permissions:
109
contents: read
@@ -14,7 +13,17 @@ jobs:
1413
strategy:
1514
fail-fast: false
1615
matrix:
17-
os: [ubuntu-latest, windows-latest, macos-latest]
16+
include:
17+
- {os: ubuntu-latest, shard: 0, shards: 1, workers: 2}
18+
- {os: windows-latest, shard: 0, shards: 4, workers: 1}
19+
- {os: windows-latest, shard: 1, shards: 4, workers: 1}
20+
- {os: windows-latest, shard: 2, shards: 4, workers: 1}
21+
- {os: windows-latest, shard: 3, shards: 4, workers: 1}
22+
- {os: macos-latest, shard: 0, shards: 4, workers: 1}
23+
- {os: macos-latest, shard: 1, shards: 4, workers: 1}
24+
- {os: macos-latest, shard: 2, shards: 4, workers: 1}
25+
- {os: macos-latest, shard: 3, shards: 4, workers: 1}
26+
name: test (${{ matrix.os }}, shard ${{ matrix.shard }})
1827
runs-on: ${{ matrix.os }}
1928
steps:
2029
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -26,5 +35,39 @@ jobs:
2635
- name: Vet
2736
run: go vet ./...
2837
- name: Test
29-
# The real-Git worktree cases can exceed Go's 10-minute package limit on Windows.
30-
run: go test -race -count=1 -timeout=30m ./...
38+
run: >-
39+
go run ./scripts/test
40+
-workers ${{ matrix.workers }}
41+
-shard ${{ matrix.shard }}
42+
-shards ${{ matrix.shards }}
43+
-output "${{ runner.temp }}/test-results"
44+
- name: Upload test results
45+
if: always()
46+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
47+
with:
48+
name: tests-${{ matrix.os }}-${{ matrix.shard }}
49+
path: ${{ runner.temp }}/test-results
50+
if-no-files-found: error
51+
retention-days: 14
52+
53+
test-complete:
54+
name: Tests complete
55+
if: always()
56+
needs: test
57+
runs-on: ubuntu-latest
58+
steps:
59+
- name: Require successful shards
60+
if: needs.test.result != 'success'
61+
run: |
62+
echo "A required test shard failed, was cancelled, or did not run."
63+
exit 1
64+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
65+
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
66+
with:
67+
go-version-file: go.mod
68+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
69+
with:
70+
pattern: tests-*
71+
path: ${{ runner.temp }}/test-results
72+
- name: Verify full platform inventories
73+
run: go run ./scripts/test -verify "${{ runner.temp }}/test-results"

‎AGENTS.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ Repository operations require Git 2.36 or later.
1010
go mod download # install dependencies
1111
go build -o gh-stack . # build (produces ./gh-stack binary)
1212
go vet ./... # static analysis. Run before tests.
13-
go test -race -count=1 ./... # all tests with race detection
13+
go run ./scripts/test -workers 4 # full race-enabled suite in isolated processes
1414
```
1515

16-
Always run `go vet` before `go test`. CI runs both on every push/PR across ubuntu, windows, and macOS (`test.yml`).
16+
Always run `go vet` before tests. See [CONTRIBUTING.md](CONTRIBUTING.md#test) for the alternative canonical `go test` command, targeted tests, and timing artifacts. CI runs the full platform-applicable suite with race detection on Linux, macOS, and Windows, with a cross-shard completion gate.
1717

1818
There is no Makefile, linter config, or code generation step. The standard Go toolchain is all that's needed.
1919

@@ -134,7 +134,7 @@ if errors.As(err, &exitErr) { ... }
134134

135135
| Workflow | Trigger | What it does |
136136
|----------|---------|-------------|
137-
| `test.yml` | push to main, PRs | `go vet` + `go test -race -count=1 ./...` on 3 OS matrix |
137+
| `test.yml` | push to main, PRs | `go vet` + full race-enabled process shards on 3 OSes; verified completion gate |
138138
| `release.yml` | `v*` tags | Cross-platform precompiled binaries via `cli/gh-extension-precompile` |
139139
| `docs.yml` | push to main (docs/**) | Builds Astro/Starlight docs, deploys to GitHub Pages |
140140

‎CONTRIBUTING.md‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,53 @@ This produces a `gh-stack` binary in the current directory.
3535

3636
## Test
3737

38+
Run static analysis first, then choose **one** full-suite command.
39+
40+
```sh
41+
go vet ./...
42+
```
43+
44+
For process-isolated parallel execution:
45+
46+
```sh
47+
go run ./scripts/test -workers 4
48+
```
49+
50+
Alternatively, use the canonical Go command:
51+
3852
```sh
39-
# Run all tests with race detection
40-
go test -race -count=1 ./...
53+
go test -race -count=1 -timeout=30m ./...
4154
```
4255

56+
The parallel runner executes the complete platform-applicable suite, including
57+
native Windows regressions. It compiles race-enabled test binaries once and
58+
isolates Git mocks, environment changes, and working directories between workers.
59+
Tests within a worker still need to restore their state. `-workers` bounds process
60+
concurrency; each child uses `-test.parallel=1`. Choose fewer workers on constrained
61+
machines. External deadlines also cover initialization and `TestMain`, with a
62+
five-second grace period for native timeout diagnostics and race-runtime shutdown.
63+
64+
Results are saved in a printed temporary directory, or under the directory supplied
65+
with `-output`. Each run contains JSON event logs and a `report.json` with the test
66+
inventory, assignments, results, runner/toolchain metadata, and build/test timings.
67+
Top-level timings include their subtests; do not add both together.
68+
69+
To run a package or a specific regression:
70+
71+
```sh
72+
go vet ./internal/modify
73+
go run ./scripts/test -workers 4 ./internal/modify
74+
75+
go vet ./cmd
76+
go test -race -count=1 -timeout=30m -run '^TestRebase_Worktrees' ./cmd
77+
```
78+
79+
CI partitions the full suite across Windows and macOS jobs. The `Tests complete`
80+
check verifies that every platform has a complete, consistent set of successful
81+
shards. Missing results, failures, or cancelled shards fail the check. Test
82+
membership comes from Go's test binaries, not the advisory scheduling weights in
83+
`scripts/test/timings.go`; newly added tests are included automatically.
84+
4385
## Vet
4486

4587
```sh
@@ -79,8 +121,8 @@ npm run build # Production build to docs/dist/
79121
1. [Fork][fork] and clone the repository
80122
1. Create a new branch: `git checkout -b my-branch-name`
81123
1. Make your change and add tests
82-
1. Make sure tests pass: `go test -race -count=1 ./...`
83124
1. Make sure vet passes: `go vet ./...`
125+
1. Make sure tests pass: `go run ./scripts/test -workers 4` (or the canonical command above)
84126
1. Push to your fork and [submit a pull request][pr]
85127

86128
Here are a few things you can do that will increase the likelihood of your pull request being accepted:

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,10 @@ export GH_STACK_THEME=light && gh stack view
770770
| 7 | Rebase already in progress |
771771
| 8 | Stack is locked by another process |
772772

773+
## Contributing
774+
775+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and testing instructions.
776+
773777
## License
774778

775779
This project is licensed under the terms of the MIT open source license. Please refer to the [LICENSE](LICENSE) file for the full terms.

‎cmd/view_test.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,14 @@ func TestTimeAgo(t *testing.T) {
4141
}
4242

4343
func TestViewJSON(t *testing.T) {
44-
git.SetOps(&git.MockOps{
44+
original := git.CurrentOps()
45+
t.Cleanup(func() { assert.Same(t, original, git.CurrentOps()) })
46+
restore := git.SetOps(&git.MockOps{
4547
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
4648
return true, nil // all branches are linear
4749
},
4850
})
51+
defer restore()
4952

5053
tests := []struct {
5154
name string
@@ -139,7 +142,9 @@ func TestViewJSON(t *testing.T) {
139142
}
140143

141144
func TestViewJSON_BranchFields(t *testing.T) {
142-
git.SetOps(&git.MockOps{
145+
original := git.CurrentOps()
146+
t.Cleanup(func() { assert.Same(t, original, git.CurrentOps()) })
147+
restore := git.SetOps(&git.MockOps{
143148
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
144149
// feat/02 needs rebase
145150
if descendant == "feat/02" {
@@ -148,6 +153,7 @@ func TestViewJSON_BranchFields(t *testing.T) {
148153
return true, nil
149154
},
150155
})
156+
defer restore()
151157

152158
s := &stack.Stack{
153159
Trunk: stack.BranchRef{Branch: "main", Head: "aaa111"},

‎internal/git/gitops_test.go‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ func writeFile(t *testing.T, dir, name, content string) {
8181
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644))
8282
}
8383

84+
func setupLocalRepo(t *testing.T) string {
85+
t.Helper()
86+
dir := t.TempDir()
87+
gitExec(t, dir, "init", "-q", "-b", "main")
88+
writeFile(t, dir, "init.txt", "hello")
89+
gitExec(t, dir, "add", ".")
90+
gitExec(t, dir, "commit", "-qm", "initial commit")
91+
return dir
92+
}
93+
8494
// withGitDir temporarily sets the git client to operate in dir by changing
8595
// the working directory. Returns a restore function.
8696
func withGitDir(t *testing.T, dir string) func() {
@@ -694,14 +704,13 @@ func TestIntegration_CherryPickQuitLeavesIndexUnmerged(t *testing.T) {
694704

695705
func setupWorktreeRepo(t *testing.T) (*defaultOps, string) {
696706
t.Helper()
697-
_, dir := setupBareAndClone(t)
707+
dir := setupLocalRepo(t)
698708
gitExec(t, dir, "config", "user.name", "Test")
699709
gitExec(t, dir, "config", "user.email", "test@test.com")
700710
gitExec(t, dir, "config", "commit.gpgsign", "false")
701711
gitExec(t, dir, "config", "rerere.enabled", "false")
702712
gitExec(t, dir, "config", "merge.conflictStyle", "merge")
703713
gitExec(t, dir, "config", "rebase.backend", "merge")
704-
t.Cleanup(withGitDir(t, dir))
705714
return &defaultOps{client: &cligit.Client{RepoDir: dir}}, dir
706715
}
707716

@@ -790,15 +799,14 @@ func TestIntegration_WorktreeDirectoriesAndDiscovery(t *testing.T) {
790799
}
791800

792801
func TestIntegration_WorktreePathsPreserveWhitespace(t *testing.T) {
802+
t.Parallel()
793803
for _, name := range []string{"space and \u03bb", " leading and trailing ", "embedded\nand trailing\n", `quotes " and backslash \`} {
794804
t.Run(name, func(t *testing.T) {
795805
if runtime.GOOS == "windows" && (strings.ContainsAny(name, "\n\"\\") || strings.HasSuffix(name, " ")) {
796806
t.Skip("these filename characters are not supported on Windows")
797807
}
798808
root, dir := setupWorktreeRepo(t)
799809
mainPath := filepath.Join(filepath.Dir(dir), "main "+name)
800-
// Windows cannot rename the process's current directory.
801-
require.NoError(t, os.Chdir(filepath.Dir(dir)))
802810
require.NoError(t, os.Rename(dir, mainPath))
803811
root.client.RepoDir = mainPath
804812
linkedPath := filepath.Join(t.TempDir(), name)
@@ -850,12 +858,12 @@ func setupSeparateGitRepo(t *testing.T) (*defaultOps, string, string) {
850858
writeFile(t, dir, "init.txt", "initial")
851859
gitExec(t, dir, "add", ".")
852860
gitExec(t, dir, "commit", "-m", "initial")
853-
t.Cleanup(withGitDir(t, dir))
854861
root := &defaultOps{client: &cligit.Client{RepoDir: dir}}
855862
return root, dir, gitDir
856863
}
857864

858865
func TestIntegration_SeparateGitDirectory(t *testing.T) {
866+
t.Parallel()
859867
root, dir, gitDir := setupSeparateGitRepo(t)
860868
linked, linkedPath := addTestWorktree(t, root, dir, "feature")
861869
for _, scope := range []Ops{root, linked} {
@@ -890,6 +898,7 @@ func TestIntegration_SeparateGitDirectory(t *testing.T) {
890898
}
891899

892900
func TestIntegration_SeparateGitDirectoryBacklink(t *testing.T) {
901+
t.Parallel()
893902
tests := []struct {
894903
name string
895904
relative bool
@@ -948,6 +957,7 @@ func TestIntegration_SeparateGitDirectoryBacklink(t *testing.T) {
948957
}
949958

950959
func TestIntegration_SeparateGitDirectoryMissingBacklink(t *testing.T) {
960+
t.Parallel()
951961
root, dir, gitDir := setupSeparateGitRepo(t)
952962
linked, linkedPath := addTestWorktree(t, root, dir, "feature")
953963
worktrees, err := linked.Worktrees()
@@ -1022,6 +1032,7 @@ func TestIntegration_ForWorktreeRejectsInvalidContext(t *testing.T) {
10221032
}
10231033

10241034
func TestIntegration_ForWorktreeIndependentExecutors(t *testing.T) {
1035+
t.Parallel()
10251036
root, dir := setupWorktreeRepo(t)
10261037
first, _ := addTestWorktree(t, root, dir, "first")
10271038
second, _ := addTestWorktree(t, root, dir, "second")
@@ -1048,6 +1059,7 @@ func TestIntegration_ForWorktreeIndependentExecutors(t *testing.T) {
10481059
}
10491060

10501061
func TestIntegration_WorktreeMissingCheckoutDoesNotReportStagedChanges(t *testing.T) {
1062+
t.Parallel()
10511063
root, dir := setupWorktreeRepo(t)
10521064
linked, path := addTestWorktree(t, root, dir, "feature")
10531065
require.NoError(t, os.Rename(path, path+"-moved"))
@@ -1058,6 +1070,7 @@ func TestIntegration_WorktreeMissingCheckoutDoesNotReportStagedChanges(t *testin
10581070
}
10591071

10601072
func TestIntegration_WorktreeStateQueriesRejectChangedScope(t *testing.T) {
1073+
t.Parallel()
10611074
for _, change := range []string{"missing checkout", "removed gitfile", "replaced gitfile"} {
10621075
t.Run(change, func(t *testing.T) {
10631076
root, dir := setupWorktreeRepo(t)
@@ -1096,6 +1109,10 @@ func TestIntegration_WorktreeStateQueriesRejectChangedScope(t *testing.T) {
10961109
child, err := selected.ForWorktree(otherPath)
10971110
require.Error(t, err)
10981111
assert.Nil(t, child)
1112+
_, err = selected.GitDir()
1113+
require.Error(t, err)
1114+
_, err = selected.CommonDir()
1115+
require.Error(t, err)
10991116
require.Error(t, selected.StageAll())
11001117
require.Error(t, selected.ResetHard("HEAD"))
11011118
require.True(t, IsRebaseStartError(selected.Rebase("main", RebaseOpts{})))
@@ -1210,6 +1227,7 @@ func TestIntegration_GitStateQueriesSurfaceFilesystemErrors(t *testing.T) {
12101227
}
12111228

12121229
func TestIntegration_WorktreeRebasePreservesOtherRefsAndConfig(t *testing.T) {
1230+
t.Parallel()
12131231
for _, onto := range []bool{false, true} {
12141232
for _, dates := range []bool{false, true} {
12151233
t.Run(fmt.Sprintf("onto=%t/dates=%t", onto, dates), func(t *testing.T) {
@@ -1612,6 +1630,11 @@ func TestIntegration_WorktreeLocalMutations(t *testing.T) {
16121630
defaultBranch, err := linked.DefaultBranch()
16131631
require.NoError(t, err)
16141632
assert.Equal(t, "main", defaultBranch)
1633+
gitExec(t, dir, "update-ref", "refs/remotes/origin/remote-trunk", "main")
1634+
gitExec(t, dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/remote-trunk")
1635+
defaultBranch, err = linked.DefaultBranch()
1636+
require.NoError(t, err)
1637+
assert.Equal(t, "remote-trunk", defaultBranch, "a remote HEAD takes precedence over local branch-name fallbacks")
16151638
initial := gitExec(t, dir, "rev-parse", "HEAD")
16161639
writeFile(t, dir, "new.txt", "new main commit\n")
16171640
gitExec(t, dir, "add", ".")

‎internal/git/rebase_start_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
func rebaseTestRepo(t *testing.T) string {
1313
t.Helper()
14-
_, clone := setupBareAndClone(t)
14+
clone := setupLocalRepo(t)
1515

1616
gitExec(t, clone, "checkout", "-b", "feature")
1717
writeFile(t, clone, "feature.txt", "feature")

0 commit comments

Comments
 (0)