diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d07b4751..1ce8453f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,9 +10,11 @@ Repository operations require Git 2.36+. go mod download # install deps go build -o gh-stack . # build go vet ./... # static analysis. Always run before tests. -go test -race -count=1 ./... # tests with race detection +go run ./scripts/test -workers 4 # full race-enabled suite in isolated processes ``` +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. + No Makefile, no code generation, no external linter config. Standard Go toolchain only. ## Project layout diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6e24dd86..f0a88bad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read @@ -14,7 +13,17 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + include: + - {os: ubuntu-latest, shard: 0, shards: 1, workers: 2} + - {os: windows-latest, shard: 0, shards: 4, workers: 1} + - {os: windows-latest, shard: 1, shards: 4, workers: 1} + - {os: windows-latest, shard: 2, shards: 4, workers: 1} + - {os: windows-latest, shard: 3, shards: 4, workers: 1} + - {os: macos-latest, shard: 0, shards: 4, workers: 1} + - {os: macos-latest, shard: 1, shards: 4, workers: 1} + - {os: macos-latest, shard: 2, shards: 4, workers: 1} + - {os: macos-latest, shard: 3, shards: 4, workers: 1} + name: test (${{ matrix.os }}, shard ${{ matrix.shard }}) runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -26,5 +35,39 @@ jobs: - name: Vet run: go vet ./... - name: Test - # The real-Git worktree cases can exceed Go's 10-minute package limit on Windows. - run: go test -race -count=1 -timeout=30m ./... + run: >- + go run ./scripts/test + -workers ${{ matrix.workers }} + -shard ${{ matrix.shard }} + -shards ${{ matrix.shards }} + -output "${{ runner.temp }}/test-results" + - name: Upload test results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: tests-${{ matrix.os }}-${{ matrix.shard }} + path: ${{ runner.temp }}/test-results + if-no-files-found: error + retention-days: 14 + + test-complete: + name: Tests complete + if: always() + needs: test + runs-on: ubuntu-latest + steps: + - name: Require successful shards + if: needs.test.result != 'success' + run: | + echo "A required test shard failed, was cancelled, or did not run." + exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: tests-* + path: ${{ runner.temp }}/test-results + - name: Verify full platform inventories + run: go run ./scripts/test -verify "${{ runner.temp }}/test-results" diff --git a/AGENTS.md b/AGENTS.md index f7b92cf8..bff19886 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,10 @@ Repository operations require Git 2.36 or later. go mod download # install dependencies go build -o gh-stack . # build (produces ./gh-stack binary) go vet ./... # static analysis. Run before tests. -go test -race -count=1 ./... # all tests with race detection +go run ./scripts/test -workers 4 # full race-enabled suite in isolated processes ``` -Always run `go vet` before `go test`. CI runs both on every push/PR across ubuntu, windows, and macOS (`test.yml`). +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. There is no Makefile, linter config, or code generation step. The standard Go toolchain is all that's needed. @@ -134,7 +134,7 @@ if errors.As(err, &exitErr) { ... } | Workflow | Trigger | What it does | |----------|---------|-------------| -| `test.yml` | push to main, PRs | `go vet` + `go test -race -count=1 ./...` on 3 OS matrix | +| `test.yml` | push to main, PRs | `go vet` + full race-enabled process shards on 3 OSes; verified completion gate | | `release.yml` | `v*` tags | Cross-platform precompiled binaries via `cli/gh-extension-precompile` | | `docs.yml` | push to main (docs/**) | Builds Astro/Starlight docs, deploys to GitHub Pages | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58a6c916..1f55b87d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,11 +35,53 @@ This produces a `gh-stack` binary in the current directory. ## Test +Run static analysis first, then choose **one** full-suite command. + +```sh +go vet ./... +``` + +For process-isolated parallel execution: + +```sh +go run ./scripts/test -workers 4 +``` + +Alternatively, use the canonical Go command: + ```sh -# Run all tests with race detection -go test -race -count=1 ./... +go test -race -count=1 -timeout=30m ./... ``` +The parallel runner executes the complete platform-applicable suite, including +native Windows regressions. It compiles race-enabled test binaries once and +isolates Git mocks, environment changes, and working directories between workers. +Tests within a worker still need to restore their state. `-workers` bounds process +concurrency; each child uses `-test.parallel=1`. Choose fewer workers on constrained +machines. External deadlines also cover initialization and `TestMain`, with a +five-second grace period for native timeout diagnostics and race-runtime shutdown. + +Results are saved in a printed temporary directory, or under the directory supplied +with `-output`. Each run contains JSON event logs and a `report.json` with the test +inventory, assignments, results, runner/toolchain metadata, and build/test timings. +Top-level timings include their subtests; do not add both together. + +To run a package or a specific regression: + +```sh +go vet ./internal/modify +go run ./scripts/test -workers 4 ./internal/modify + +go vet ./cmd +go test -race -count=1 -timeout=30m -run '^TestRebase_Worktrees' ./cmd +``` + +CI partitions the full suite across Windows and macOS jobs. The `Tests complete` +check verifies that every platform has a complete, consistent set of successful +shards. Missing results, failures, or cancelled shards fail the check. Test +membership comes from Go's test binaries, not the advisory scheduling weights in +`scripts/test/timings.go`; newly added tests are included automatically. + ## Vet ```sh @@ -79,8 +121,8 @@ npm run build # Production build to docs/dist/ 1. [Fork][fork] and clone the repository 1. Create a new branch: `git checkout -b my-branch-name` 1. Make your change and add tests -1. Make sure tests pass: `go test -race -count=1 ./...` 1. Make sure vet passes: `go vet ./...` +1. Make sure tests pass: `go run ./scripts/test -workers 4` (or the canonical command above) 1. Push to your fork and [submit a pull request][pr] Here are a few things you can do that will increase the likelihood of your pull request being accepted: diff --git a/README.md b/README.md index 06ec23c4..9c4ea0da 100644 --- a/README.md +++ b/README.md @@ -770,6 +770,10 @@ export GH_STACK_THEME=light && gh stack view | 7 | Rebase already in progress | | 8 | Stack is locked by another process | +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and testing instructions. + ## License This project is licensed under the terms of the MIT open source license. Please refer to the [LICENSE](LICENSE) file for the full terms. diff --git a/cmd/view_test.go b/cmd/view_test.go index ff640506..d0cbd45d 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -41,11 +41,14 @@ func TestTimeAgo(t *testing.T) { } func TestViewJSON(t *testing.T) { - git.SetOps(&git.MockOps{ + original := git.CurrentOps() + t.Cleanup(func() { assert.Same(t, original, git.CurrentOps()) }) + restore := git.SetOps(&git.MockOps{ IsAncestorFn: func(ancestor, descendant string) (bool, error) { return true, nil // all branches are linear }, }) + defer restore() tests := []struct { name string @@ -139,7 +142,9 @@ func TestViewJSON(t *testing.T) { } func TestViewJSON_BranchFields(t *testing.T) { - git.SetOps(&git.MockOps{ + original := git.CurrentOps() + t.Cleanup(func() { assert.Same(t, original, git.CurrentOps()) }) + restore := git.SetOps(&git.MockOps{ IsAncestorFn: func(ancestor, descendant string) (bool, error) { // feat/02 needs rebase if descendant == "feat/02" { @@ -148,6 +153,7 @@ func TestViewJSON_BranchFields(t *testing.T) { return true, nil }, }) + defer restore() s := &stack.Stack{ Trunk: stack.BranchRef{Branch: "main", Head: "aaa111"}, diff --git a/internal/git/gitops_test.go b/internal/git/gitops_test.go index 09661120..a6a055fc 100644 --- a/internal/git/gitops_test.go +++ b/internal/git/gitops_test.go @@ -81,6 +81,16 @@ func writeFile(t *testing.T, dir, name, content string) { require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) } +func setupLocalRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitExec(t, dir, "init", "-q", "-b", "main") + writeFile(t, dir, "init.txt", "hello") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-qm", "initial commit") + return dir +} + // withGitDir temporarily sets the git client to operate in dir by changing // the working directory. Returns a restore function. func withGitDir(t *testing.T, dir string) func() { @@ -694,14 +704,13 @@ func TestIntegration_CherryPickQuitLeavesIndexUnmerged(t *testing.T) { func setupWorktreeRepo(t *testing.T) (*defaultOps, string) { t.Helper() - _, dir := setupBareAndClone(t) + dir := setupLocalRepo(t) gitExec(t, dir, "config", "user.name", "Test") gitExec(t, dir, "config", "user.email", "test@test.com") gitExec(t, dir, "config", "commit.gpgsign", "false") gitExec(t, dir, "config", "rerere.enabled", "false") gitExec(t, dir, "config", "merge.conflictStyle", "merge") gitExec(t, dir, "config", "rebase.backend", "merge") - t.Cleanup(withGitDir(t, dir)) return &defaultOps{client: &cligit.Client{RepoDir: dir}}, dir } @@ -790,6 +799,7 @@ func TestIntegration_WorktreeDirectoriesAndDiscovery(t *testing.T) { } func TestIntegration_WorktreePathsPreserveWhitespace(t *testing.T) { + t.Parallel() for _, name := range []string{"space and \u03bb", " leading and trailing ", "embedded\nand trailing\n", `quotes " and backslash \`} { t.Run(name, func(t *testing.T) { if runtime.GOOS == "windows" && (strings.ContainsAny(name, "\n\"\\") || strings.HasSuffix(name, " ")) { @@ -797,8 +807,6 @@ func TestIntegration_WorktreePathsPreserveWhitespace(t *testing.T) { } root, dir := setupWorktreeRepo(t) mainPath := filepath.Join(filepath.Dir(dir), "main "+name) - // Windows cannot rename the process's current directory. - require.NoError(t, os.Chdir(filepath.Dir(dir))) require.NoError(t, os.Rename(dir, mainPath)) root.client.RepoDir = mainPath linkedPath := filepath.Join(t.TempDir(), name) @@ -850,12 +858,12 @@ func setupSeparateGitRepo(t *testing.T) (*defaultOps, string, string) { writeFile(t, dir, "init.txt", "initial") gitExec(t, dir, "add", ".") gitExec(t, dir, "commit", "-m", "initial") - t.Cleanup(withGitDir(t, dir)) root := &defaultOps{client: &cligit.Client{RepoDir: dir}} return root, dir, gitDir } func TestIntegration_SeparateGitDirectory(t *testing.T) { + t.Parallel() root, dir, gitDir := setupSeparateGitRepo(t) linked, linkedPath := addTestWorktree(t, root, dir, "feature") for _, scope := range []Ops{root, linked} { @@ -890,6 +898,7 @@ func TestIntegration_SeparateGitDirectory(t *testing.T) { } func TestIntegration_SeparateGitDirectoryBacklink(t *testing.T) { + t.Parallel() tests := []struct { name string relative bool @@ -948,6 +957,7 @@ func TestIntegration_SeparateGitDirectoryBacklink(t *testing.T) { } func TestIntegration_SeparateGitDirectoryMissingBacklink(t *testing.T) { + t.Parallel() root, dir, gitDir := setupSeparateGitRepo(t) linked, linkedPath := addTestWorktree(t, root, dir, "feature") worktrees, err := linked.Worktrees() @@ -1022,6 +1032,7 @@ func TestIntegration_ForWorktreeRejectsInvalidContext(t *testing.T) { } func TestIntegration_ForWorktreeIndependentExecutors(t *testing.T) { + t.Parallel() root, dir := setupWorktreeRepo(t) first, _ := addTestWorktree(t, root, dir, "first") second, _ := addTestWorktree(t, root, dir, "second") @@ -1048,6 +1059,7 @@ func TestIntegration_ForWorktreeIndependentExecutors(t *testing.T) { } func TestIntegration_WorktreeMissingCheckoutDoesNotReportStagedChanges(t *testing.T) { + t.Parallel() root, dir := setupWorktreeRepo(t) linked, path := addTestWorktree(t, root, dir, "feature") require.NoError(t, os.Rename(path, path+"-moved")) @@ -1058,6 +1070,7 @@ func TestIntegration_WorktreeMissingCheckoutDoesNotReportStagedChanges(t *testin } func TestIntegration_WorktreeStateQueriesRejectChangedScope(t *testing.T) { + t.Parallel() for _, change := range []string{"missing checkout", "removed gitfile", "replaced gitfile"} { t.Run(change, func(t *testing.T) { root, dir := setupWorktreeRepo(t) @@ -1096,6 +1109,10 @@ func TestIntegration_WorktreeStateQueriesRejectChangedScope(t *testing.T) { child, err := selected.ForWorktree(otherPath) require.Error(t, err) assert.Nil(t, child) + _, err = selected.GitDir() + require.Error(t, err) + _, err = selected.CommonDir() + require.Error(t, err) require.Error(t, selected.StageAll()) require.Error(t, selected.ResetHard("HEAD")) require.True(t, IsRebaseStartError(selected.Rebase("main", RebaseOpts{}))) @@ -1210,6 +1227,7 @@ func TestIntegration_GitStateQueriesSurfaceFilesystemErrors(t *testing.T) { } func TestIntegration_WorktreeRebasePreservesOtherRefsAndConfig(t *testing.T) { + t.Parallel() for _, onto := range []bool{false, true} { for _, dates := range []bool{false, true} { t.Run(fmt.Sprintf("onto=%t/dates=%t", onto, dates), func(t *testing.T) { @@ -1612,6 +1630,11 @@ func TestIntegration_WorktreeLocalMutations(t *testing.T) { defaultBranch, err := linked.DefaultBranch() require.NoError(t, err) assert.Equal(t, "main", defaultBranch) + gitExec(t, dir, "update-ref", "refs/remotes/origin/remote-trunk", "main") + gitExec(t, dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/remote-trunk") + defaultBranch, err = linked.DefaultBranch() + require.NoError(t, err) + assert.Equal(t, "remote-trunk", defaultBranch, "a remote HEAD takes precedence over local branch-name fallbacks") initial := gitExec(t, dir, "rev-parse", "HEAD") writeFile(t, dir, "new.txt", "new main commit\n") gitExec(t, dir, "add", ".") diff --git a/internal/git/rebase_start_test.go b/internal/git/rebase_start_test.go index fc551e28..b9da0298 100644 --- a/internal/git/rebase_start_test.go +++ b/internal/git/rebase_start_test.go @@ -11,7 +11,7 @@ import ( func rebaseTestRepo(t *testing.T) string { t.Helper() - _, clone := setupBareAndClone(t) + clone := setupLocalRepo(t) gitExec(t, clone, "checkout", "-b", "feature") writeFile(t, clone, "feature.txt", "feature") diff --git a/internal/modify/apply_test.go b/internal/modify/apply_test.go index a9e12053..f2c93712 100644 --- a/internal/modify/apply_test.go +++ b/internal/modify/apply_test.go @@ -3464,8 +3464,11 @@ func setupDistributedModify(t *testing.T, conflicting bool) distributedModifyRep // Git expands Windows short paths and uses forward slashes in diagnostics. repo.owners[branch] = runModifyGit(t, path, "rev-parse", "--show-toplevel") } - for _, branch := range []string{"A", "B", "C"} { - repo.refs[branch] = runModifyGit(t, root, "rev-parse", branch) + branches := []string{"A", "B", "C"} + refs := strings.Fields(runModifyGit(t, root, append([]string{"rev-parse"}, branches...)...)) + require.Len(t, refs, len(branches)) + for i, branch := range branches { + repo.refs[branch] = refs[i] } return repo } @@ -3479,141 +3482,163 @@ func insertedModifyNode(name string) modifyview.ModifyBranchNode { } } -func TestDistributedModify_AllActions(t *testing.T) { - tests := []struct { - name string - nodes func([]modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode - order []string - files []string - renamedA bool - renamedB bool - }{ - { - name: "rename", - nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { - nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"} - return nodes - }, - order: []string{"new-A", "B", "C"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, renamedA: true, +type distributedModifyActionCase struct { + name string + nodes func([]modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode + order []string + files []string + renamedA bool + renamedB bool +} + +func TestDistributedModify_Rename(t *testing.T) { + testDistributedModifyAction(t, distributedModifyActionCase{ + name: "rename", + nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"} + return nodes }, - { - name: "insert", - nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { - return []modifyview.ModifyBranchNode{nodes[0], insertedModifyNode("inserted"), nodes[1], nodes[2]} - }, - order: []string{"A", "inserted", "B", "C"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, + order: []string{"new-A", "B", "C"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, renamedA: true, + }) +} + +func TestDistributedModify_Insert(t *testing.T) { + testDistributedModifyAction(t, distributedModifyActionCase{ + name: "insert", + nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { + return []modifyview.ModifyBranchNode{nodes[0], insertedModifyNode("inserted"), nodes[1], nodes[2]} }, - { - name: "drop", - nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { - nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} - nodes[0].Removed = true - return nodes - }, - order: []string{"B", "C"}, files: []string{"b.txt", "base.txt", "c.txt"}, + order: []string{"A", "inserted", "B", "C"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, + }) +} + +func TestDistributedModify_Drop(t *testing.T) { + testDistributedModifyAction(t, distributedModifyActionCase{ + name: "drop", + nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[0].Removed = true + return nodes }, - { - name: "fold down", - nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { - nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown} - nodes[1].Removed = true - return nodes - }, - order: []string{"A", "C"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, + order: []string{"B", "C"}, files: []string{"b.txt", "base.txt", "c.txt"}, + }) +} + +func TestDistributedModify_FoldDown(t *testing.T) { + testDistributedModifyAction(t, distributedModifyActionCase{ + name: "fold down", + nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown} + nodes[1].Removed = true + return nodes }, - { - name: "fold up", - nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { - nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldUp} - nodes[1].Removed = true - return nodes - }, - order: []string{"A", "C"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, + order: []string{"A", "C"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, + }) +} + +func TestDistributedModify_FoldUp(t *testing.T) { + testDistributedModifyAction(t, distributedModifyActionCase{ + name: "fold up", + nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldUp} + nodes[1].Removed = true + return nodes }, - { - name: "reorder", - nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { - return []modifyview.ModifyBranchNode{nodes[2], nodes[1], nodes[0]} - }, - order: []string{"C", "B", "A"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, + order: []string{"A", "C"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, + }) +} + +func TestDistributedModify_Reorder(t *testing.T) { + testDistributedModifyAction(t, distributedModifyActionCase{ + name: "reorder", + nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { + return []modifyview.ModifyBranchNode{nodes[2], nodes[1], nodes[0]} }, - { - name: "rename insert and drop", - nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { - nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} - nodes[0].Removed = true - nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-B"} - return []modifyview.ModifyBranchNode{nodes[0], nodes[1], insertedModifyNode("inserted"), nodes[2]} - }, - order: []string{"new-B", "inserted", "C"}, files: []string{"b.txt", "base.txt", "c.txt"}, renamedB: true, + order: []string{"C", "B", "A"}, files: []string{"a.txt", "b.txt", "base.txt", "c.txt"}, + }) +} + +func TestDistributedModify_RenameInsertAndDrop(t *testing.T) { + testDistributedModifyAction(t, distributedModifyActionCase{ + name: "rename insert and drop", + nodes: func(nodes []modifyview.ModifyBranchNode) []modifyview.ModifyBranchNode { + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[0].Removed = true + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-B"} + return []modifyview.ModifyBranchNode{nodes[0], nodes[1], insertedModifyNode("inserted"), nodes[2]} }, - } - for _, tt := range tests { - for _, unoccupied := range []bool{false, true} { - layout := "occupied" + order: []string{"new-B", "inserted", "C"}, files: []string{"b.txt", "base.txt", "c.txt"}, renamedB: true, + }) +} + +func testDistributedModifyAction(t *testing.T, tt distributedModifyActionCase) { + t.Helper() + for _, unoccupied := range []bool{false, true} { + layout := "occupied" + if unoccupied { + layout = "mixed" + } + t.Run(layout, func(t *testing.T) { + repo := setupDistributedModify(t, false) if unoccupied { - layout = "mixed" + runModifyGit(t, repo.owners["B"], "checkout", "-qb", "b-observer", "main") + require.NoError(t, os.WriteFile(filepath.Join(repo.owners["B"], "unrelated.txt"), []byte("keep"), 0644)) } - t.Run(tt.name+"/"+layout, func(t *testing.T) { - repo := setupDistributedModify(t, false) - if unoccupied { - runModifyGit(t, repo.owners["B"], "checkout", "-qb", "b-observer", "main") - require.NoError(t, os.WriteFile(filepath.Join(repo.owners["B"], "unrelated.txt"), []byte("keep"), 0644)) - } - require.NoError(t, os.WriteFile(filepath.Join(repo.caller, "unrelated.txt"), []byte("keep"), 0644)) - beforeTrees := runModifyGit(t, repo.root, "worktree", "list", "--porcelain") - restore := git.SetOps(requireWorktree(t, git.CurrentOps(), repo.origin)) - defer restore() - cfg, _, _ := config.NewTestConfig() - defer cfg.Out.Close() - defer cfg.Err.Close() - result, conflict, err := ApplyPlan(cfg, repo.common, &repo.sf.Stacks[0], repo.sf, - tt.nodes(makeNodes(&repo.sf.Stacks[0])), "C", noopUpdateBaseSHAs) - require.NoError(t, err) - assert.Nil(t, conflict) - require.NotNil(t, result) - saved, err := stack.Load(repo.common) - require.NoError(t, err) - assert.Equal(t, tt.order, saved.Stacks[0].BranchNames()) - top := tt.order[len(tt.order)-1] - assert.Equal(t, tt.files, strings.Fields(runModifyGit(t, repo.root, "ls-tree", "--name-only", top))) - for i, branch := range tt.order { - parent := "main" - if i > 0 { - parent = tt.order[i-1] - } - runModifyGit(t, repo.root, "merge-base", "--is-ancestor", parent, branch) - } - aName, bName := "A", "B" - if tt.renamedA { - aName = "new-A" - } - if tt.renamedB { - bName = "new-B" - } - if unoccupied { - bName = "b-observer" - assert.Equal(t, "?? unrelated.txt", runModifyGit(t, repo.owners["B"], "status", "--porcelain")) - } - assert.Equal(t, aName, runModifyGit(t, repo.owners["A"], "branch", "--show-current")) - assert.Equal(t, bName, runModifyGit(t, repo.owners["B"], "branch", "--show-current")) - assert.Equal(t, "C", runModifyGit(t, repo.origin, "branch", "--show-current")) - assert.Equal(t, "observer", runModifyGit(t, repo.caller, "branch", "--show-current")) - afterTrees := runModifyGit(t, repo.root, "worktree", "list", "--porcelain") - assert.Equal(t, strings.Count(beforeTrees, "worktree "), strings.Count(afterTrees, "worktree ")) - if tt.name == "drop" { - assert.Equal(t, repo.refs["A"], runModifyGit(t, repo.root, "rev-parse", "A")) - } - if tt.name == "fold down" || tt.name == "fold up" { - assert.Equal(t, repo.refs["B"], runModifyGit(t, repo.root, "rev-parse", "B")) - } - if tt.name == "rename insert and drop" { - assert.Equal(t, runModifyGit(t, repo.root, "rev-parse", "new-B"), runModifyGit(t, repo.root, "rev-parse", "inserted"), - "an inserted empty layer must not resurrect the dropped parent's commits") + require.NoError(t, os.WriteFile(filepath.Join(repo.caller, "unrelated.txt"), []byte("keep"), 0644)) + beforeTrees := runModifyGit(t, repo.root, "worktree", "list", "--porcelain") + restore := git.SetOps(requireWorktree(t, git.CurrentOps(), repo.origin)) + defer restore() + cfg, outR, errR := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + defer outR.Close() + defer errR.Close() + result, conflict, err := ApplyPlan(cfg, repo.common, &repo.sf.Stacks[0], repo.sf, + tt.nodes(makeNodes(&repo.sf.Stacks[0])), "C", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + saved, err := stack.Load(repo.common) + require.NoError(t, err) + assert.Equal(t, tt.order, saved.Stacks[0].BranchNames()) + top := tt.order[len(tt.order)-1] + assert.Equal(t, tt.files, strings.Fields(runModifyGit(t, repo.root, "ls-tree", "--name-only", top))) + for i, branch := range tt.order { + parent := "main" + if i > 0 { + parent = tt.order[i-1] } - assert.False(t, StateExists(repo.common)) - }) - } + runModifyGit(t, repo.root, "merge-base", "--is-ancestor", parent, branch) + } + aName, bName := "A", "B" + if tt.renamedA { + aName = "new-A" + } + if tt.renamedB { + bName = "new-B" + } + if unoccupied { + bName = "b-observer" + assert.Equal(t, "?? unrelated.txt", runModifyGit(t, repo.owners["B"], "status", "--porcelain")) + } + assert.Equal(t, aName, runModifyGit(t, repo.owners["A"], "branch", "--show-current")) + assert.Equal(t, bName, runModifyGit(t, repo.owners["B"], "branch", "--show-current")) + assert.Equal(t, "C", runModifyGit(t, repo.origin, "branch", "--show-current")) + assert.Equal(t, "observer", runModifyGit(t, repo.caller, "branch", "--show-current")) + afterTrees := runModifyGit(t, repo.root, "worktree", "list", "--porcelain") + assert.Equal(t, strings.Count(beforeTrees, "worktree "), strings.Count(afterTrees, "worktree ")) + if tt.name == "drop" { + assert.Equal(t, repo.refs["A"], runModifyGit(t, repo.root, "rev-parse", "A")) + } + if tt.name == "fold down" || tt.name == "fold up" { + assert.Equal(t, repo.refs["B"], runModifyGit(t, repo.root, "rev-parse", "B")) + } + if tt.name == "rename insert and drop" { + assert.Equal(t, runModifyGit(t, repo.root, "rev-parse", "new-B"), runModifyGit(t, repo.root, "rev-parse", "inserted"), + "an inserted empty layer must not resurrect the dropped parent's commits") + } + assert.False(t, StateExists(repo.common)) + }) } } diff --git a/scripts/test/main.go b/scripts/test/main.go new file mode 100644 index 00000000..a2c3eca1 --- /dev/null +++ b/scripts/test/main.go @@ -0,0 +1,384 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "go/token" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "syscall" + "time" +) + +// Allow native timeout diagnostics and race-runtime shutdown bounded grace. +const testExitGrace = 5 * time.Second + +type options struct { + workers, shard, shards int + output, verify string + timeout time.Duration +} + +type testPackage struct { + ImportPath string + Dir string + TestGoFiles []string + XTestGoFiles []string + binary string + tests []string +} + +type event struct { + Time time.Time + Action string + Package string + Test string + Elapsed float64 + Output string +} + +type testResult struct { + Package string + Test string + Action string + Elapsed float64 +} + +type report struct { + Revision string + OS string + Arch string + Go string + Git string + Image string + CPUs int + Shard int + Shards int + Workers int + Packages []string + Inventory []testID + Assignments [][]batch + Results []testResult + BuildTime float64 + TestTime float64 + Complete bool + Success bool + Error string +} + +func main() { + opts := options{} + flag.IntVar(&opts.workers, "workers", min(4, runtime.NumCPU()), "maximum concurrent test processes") + flag.IntVar(&opts.shard, "shard", 0, "zero-based CI shard index") + flag.IntVar(&opts.shards, "shards", 1, "number of CI shards") + flag.StringVar(&opts.output, "output", "", "parent directory for result artifacts (default: system temporary directory)") + flag.StringVar(&opts.verify, "verify", "", "verify downloaded reports for all three CI platforms instead of running tests") + flag.DurationVar(&opts.timeout, "timeout", 30*time.Minute, "timeout for each test process") + flag.Parse() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + var err error + if opts.verify != "" { + err = verifyReports(opts.verify, []string{"linux", "darwin", "windows"}, os.Stdout) + } else { + err = run(ctx, opts, flag.Args(), os.Stdout, os.Stderr) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func (o options) validate() error { + if err := validatePartitions(o.workers, o.shards, o.shard); err != nil { + return err + } + if o.timeout <= 0 { + return errors.New("timeout must be positive") + } + return nil +} + +func command(ctx context.Context, dir string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = dir + configureProcess(cmd) + return cmd +} + +func output(ctx context.Context, dir string, args ...string) ([]byte, error) { + data, err := command(ctx, dir, args...).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%s: %w\n%s", strings.Join(args, " "), err, data) + } + return data, nil +} + +func run(ctx context.Context, opts options, patterns []string, stdout, stderr io.Writer) (err error) { + if err := opts.validate(); err != nil { + return err + } + if len(patterns) == 0 { + patterns = []string{"./..."} + } + if opts.output != "" { + if err := os.MkdirAll(opts.output, 0755); err != nil { + return err + } + } + dir, err := os.MkdirTemp(opts.output, "gh-stack-tests-") + if err != nil { + return err + } + fmt.Fprintf(stdout, "Test results: %s\n", dir) + rep := report{ + OS: runtime.GOOS, Arch: runtime.GOARCH, Go: runtime.Version(), CPUs: runtime.NumCPU(), + Image: os.Getenv("ImageOS") + " " + os.Getenv("ImageVersion"), + Shard: opts.shard, Shards: opts.shards, Workers: opts.workers, + } + defer func() { + if err != nil { + rep.Error = err.Error() + } + rep.Success = err == nil && rep.Complete + data, marshalErr := json.MarshalIndent(rep, "", " ") + if marshalErr != nil { + err = errors.Join(err, marshalErr) + return + } + err = errors.Join(err, os.WriteFile(filepath.Join(dir, "report.json"), append(data, '\n'), 0644)) + }() + sha, err := output(ctx, "", "git", "rev-parse", "HEAD") + if err != nil { + return err + } + rep.Revision = strings.TrimSpace(string(sha)) + version, err := output(ctx, "", "git", "--version") + if err != nil { + return err + } + rep.Git = strings.TrimSpace(string(version)) + data, err := output(ctx, "", append([]string{"go", "list", "-race", "-json"}, patterns...)...) + if err != nil { + return err + } + var packages []testPackage + decoder := json.NewDecoder(strings.NewReader(string(data))) + for { + var pkg testPackage + if err := decoder.Decode(&pkg); err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + if pkg.Dir == "" || pkg.ImportPath == "" { + return errors.New("go list returned a package without its directory or import path") + } + packages = append(packages, pkg) + } + if len(packages) == 0 { + return errors.New("no packages selected") + } + slices.SortFunc(packages, func(a, b testPackage) int { return strings.Compare(a.ImportPath, b.ImportPath) }) + buildDir, err := os.MkdirTemp("", "gh-stack-test-binaries-") + if err != nil { + return err + } + defer func() { err = errors.Join(err, os.RemoveAll(buildDir)) }() + start := time.Now() + err = parallel(len(packages), opts.workers, func(i int) error { + pkg := &packages[i] + name := fmt.Sprintf("%d.test", i) + if runtime.GOOS == "windows" { + name += ".exe" + } + pkg.binary = filepath.Join(buildDir, name) + if _, err := output(ctx, pkg.Dir, "go", "test", "-race", "-p=1", "-c", "-o", pkg.binary, "."); err != nil { + return err + } + if len(pkg.TestGoFiles)+len(pkg.XTestGoFiles) == 0 { + pkg.binary = "" + return nil + } + var err error + pkg.tests, err = discoverTests(ctx, *pkg, opts.timeout) + return err + }) + rep.BuildTime = time.Since(start).Seconds() + if err != nil { + return err + } + byPackage := make(map[string]testPackage, len(packages)) + for _, pkg := range packages { + rep.Packages = append(rep.Packages, pkg.ImportPath) + byPackage[pkg.ImportPath] = pkg + for _, name := range pkg.tests { + rep.Inventory = append(rep.Inventory, testID{pkg.ImportPath, name}) + } + } + rep.Assignments = schedule(packages, opts.shards*opts.workers) + if err := validateAssignments(rep.Inventory, rep.Assignments); err != nil { + return err + } + // Publish an incomplete manifest before execution; cancellation cannot look successful. + initial, err := json.MarshalIndent(rep, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "report.json"), initial, 0644); err != nil { + return err + } + var mu sync.Mutex + start = time.Now() + err = parallel(opts.workers, opts.workers, func(worker int) error { + partition := opts.shard*opts.workers + worker + var failures []error + for index, group := range rep.Assignments[partition] { + path := filepath.Join(dir, fmt.Sprintf("worker-%d-package-%d.jsonl", worker, index)) + results, runErr := runBatch(ctx, byPackage[group.Package], group, opts.timeout, path) + mu.Lock() + rep.Results = append(rep.Results, results...) + if runErr != nil { + fmt.Fprintf(stderr, "FAIL %s: %v\n", group.Package, runErr) + } else { + fmt.Fprintf(stdout, "PASS %s (%d top-level tests)\n", group.Package, len(group.Tests)) + } + mu.Unlock() + failures = append(failures, runErr) + if ctx.Err() != nil { + break + } + } + return errors.Join(failures...) + }) + rep.TestTime = time.Since(start).Seconds() + rep.Complete = true + if parityErr := validateResults(rep); parityErr != nil { + err = errors.Join(err, parityErr) + } + printSlowTests(stdout, rep.Results) + fmt.Fprintf(stdout, "Build/discovery: %.2fs; tests: %.2fs; shard %d/%d\n", rep.BuildTime, rep.TestTime, opts.shard+1, opts.shards) + return err +} + +func parallel(count, workers int, fn func(int) error) error { + errs := make([]error, count) + jobs := make(chan int) + var wg sync.WaitGroup + for range min(count, workers) { + wg.Go(func() { + for i := range jobs { + errs[i] = fn(i) + } + }) + } + for i := range count { + jobs <- i + } + close(jobs) + wg.Wait() + return errors.Join(errs...) +} + +func discoverTests(ctx context.Context, pkg testPackage, timeout time.Duration) ([]string, error) { + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(timeout).Add(testExitGrace)) + defer cancel() + data, err := output(ctx, pkg.Dir, pkg.binary, "-test.list=.") + if err != nil { + return nil, errors.Join(err, ctx.Err()) + } + var names []string + for _, line := range strings.Split(string(data), "\n") { + name := strings.TrimSpace(line) + if token.IsIdentifier(name) && (strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Example") || strings.HasPrefix(name, "Fuzz")) { + names = append(names, name) + } + } + slices.Sort(names) + return names, nil +} + +func runBatch(ctx context.Context, pkg testPackage, group batch, timeout time.Duration, path string) ([]testResult, error) { + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(timeout).Add(testExitGrace)) + defer cancel() + log, err := os.Create(path) + if err != nil { + return nil, err + } + defer log.Close() + cmd := command(ctx, pkg.Dir, "go", "tool", "test2json", "-t", "-p", pkg.ImportPath, pkg.binary, + "-test.v=test2json", "-test.count=1", "-test.parallel=1", "-test.timeout="+timeout.String(), "-test.run="+selector(group.Tests)) + stream, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + var diagnostics strings.Builder + cmd.Stderr = &diagnostics + if err := cmd.Start(); err != nil { + return nil, err + } + decoder := json.NewDecoder(io.TeeReader(stream, log)) + var results []testResult + var messages strings.Builder + var decodeErr error + packagePassed := false + for { + var e event + if err := decoder.Decode(&e); err != nil { + if !errors.Is(err, io.EOF) { + decodeErr = err + _, drainErr := io.Copy(log, stream) + decodeErr = errors.Join(decodeErr, drainErr) + } + break + } + messages.WriteString(e.Output) + if e.Test == "" && e.Action == "pass" { + packagePassed = true + } + if e.Test != "" && (e.Action == "pass" || e.Action == "fail" || e.Action == "skip") { + results = append(results, testResult{e.Package, e.Test, e.Action, e.Elapsed}) + } + } + err = errors.Join(cmd.Wait(), ctx.Err(), decodeErr, log.Close()) + if !packagePassed { + err = errors.Join(err, errors.New("test process did not report package success")) + } + if err != nil { + return results, fmt.Errorf("%w\n%s%s\nJSON log: %s", err, messages.String(), diagnostics.String(), path) + } + return results, nil +} + +func printSlowTests(w io.Writer, results []testResult) { + top := make([]testResult, 0) + for _, result := range results { + if !strings.Contains(result.Test, "/") { + top = append(top, result) + } + } + slices.SortFunc(top, func(a, b testResult) int { + if a.Elapsed > b.Elapsed { + return -1 + } + if a.Elapsed < b.Elapsed { + return 1 + } + return strings.Compare(a.Package+"/"+a.Test, b.Package+"/"+b.Test) + }) + fmt.Fprintln(w, "Slowest top-level tests (including their subtests):") + for _, result := range top[:min(10, len(top))] { + fmt.Fprintf(w, " %.2fs %s/%s\n", result.Elapsed, result.Package, result.Test) + } +} diff --git a/scripts/test/main_test.go b/scripts/test/main_test.go new file mode 100644 index 00000000..87f2a690 --- /dev/null +++ b/scripts/test/main_test.go @@ -0,0 +1,453 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestOptions(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + opts options + }{ + {"no workers", options{shards: 1, timeout: time.Second}}, + {"no shards", options{workers: 1, timeout: time.Second}}, + {"negative shard", options{workers: 1, shards: 1, shard: -1, timeout: time.Second}}, + {"missing shard", options{workers: 1, shards: 1, shard: 1, timeout: time.Second}}, + {"too many partitions", options{workers: 256, shards: 2, timeout: time.Second}}, + {"no timeout", options{workers: 1, shards: 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Error(t, tc.opts.validate()) + }) + } + require.NoError(t, (options{workers: 4, shards: 4, shard: 3, timeout: time.Minute}).validate()) +} + +func TestPackageMetadata(t *testing.T) { + t.Parallel() + var pkg testPackage + require.NoError(t, json.Unmarshal([]byte(`{"ImportPath":"example/pkg","Dir":"/example/pkg","TestGoFiles":["pkg_test.go"]}`), &pkg)) + assert.Equal(t, "/example/pkg", pkg.Dir) + assert.Equal(t, "example/pkg", pkg.ImportPath) + assert.Equal(t, []string{"pkg_test.go"}, pkg.TestGoFiles) +} + +func TestSchedule(t *testing.T) { + t.Parallel() + modify := "github.com/github/gh-stack/internal/modify" + packages := []testPackage{ + {ImportPath: modify, binary: "modify.test", tests: []string{"TestDistributedModify_FoldThenRebaseRecovery", "TestDistributedModify_RebaseRecovery", "TestNewRegression"}}, + {ImportPath: "new/package", binary: "new.test", tests: []string{"ExampleThing", "FuzzThing", "TestThing"}}, + {ImportPath: "helper/package", binary: "helper.test"}, + {ImportPath: "no/tests"}, + } + var inventory []testID + for _, pkg := range packages { + for _, name := range pkg.tests { + inventory = append(inventory, testID{pkg.ImportPath, name}) + } + } + for _, count := range []int{1, 2, 4, 8} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + assignments := schedule(packages, count) + require.NoError(t, validateAssignments(inventory, assignments)) + shuffled := slices.Clone(packages) + slices.Reverse(shuffled) + assert.Equal(t, assignments, schedule(shuffled, count)) + helpers := 0 + for _, groups := range assignments { + for _, group := range groups { + if group.Package == "helper/package" { + helpers++ + } + assert.NotEqual(t, "no/tests", group.Package) + } + } + assert.Equal(t, 1, helpers, "a helper-only package still executes its TestMain") + }) + } +} + +func TestSelectors(t *testing.T) { + t.Parallel() + names := []string{"TestOne", "TestOneMore", "Test_λ", "ExampleThing", "FuzzInput"} + rx := regexp.MustCompile(selector([]string{"TestOne", "Test_λ"})) + for _, name := range names { + assert.Equal(t, name == "TestOne" || name == "Test_λ", rx.MatchString(name)) + } + assert.False(t, rx.MatchString("prefixTestOne")) + assert.False(t, regexp.MustCompile(selector(nil)).MatchString("TestAnything")) + assert.Equal(t, `^(Test\+special)$`, selector([]string{"Test+special"})) +} + +func TestScheduleBoundsArguments(t *testing.T) { + t.Parallel() + pkg := testPackage{ImportPath: "large/package", binary: "large.test"} + var inventory []testID + for i := range 500 { + name := fmt.Sprintf("Test_%s_%d", strings.Repeat("λ", 40), i) + pkg.tests = append(pkg.tests, name) + inventory = append(inventory, testID{pkg.ImportPath, name}) + } + assignments := schedule([]testPackage{pkg}, 1) + require.Greater(t, len(assignments[0]), 1) + require.NoError(t, validateAssignments(inventory, assignments)) + for _, group := range assignments[0] { + assert.LessOrEqual(t, len(selector(group.Tests)), 8000) + } +} + +func TestAssignmentValidation(t *testing.T) { + t.Parallel() + inventory := []testID{{"pkg", "TestOne"}, {"pkg", "TestTwo"}} + for _, tc := range []struct { + name string + groups [][]batch + }{ + {"missing", [][]batch{{{Package: "pkg", Tests: []string{"TestOne"}}}}}, + {"duplicate", [][]batch{{{Package: "pkg", Tests: []string{"TestOne", "TestTwo", "TestTwo"}}}}}, + {"unknown", [][]batch{{{Package: "pkg", Tests: []string{"TestOne", "TestTwo", "TestThree"}}}}}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Error(t, validateAssignments(inventory, tc.groups)) + }) + } + require.Error(t, validateAssignments(append(inventory, inventory[0]), nil)) +} + +func TestParallelBound(t *testing.T) { + t.Parallel() + var active, maximum atomic.Int32 + err := parallel(30, 3, func(i int) error { + n := active.Add(1) + defer active.Add(-1) + for old := maximum.Load(); n > old; old = maximum.Load() { + if maximum.CompareAndSwap(old, n) { + break + } + } + if i == 4 { + return fmt.Errorf("task failure") + } + return nil + }) + require.ErrorContains(t, err, "task failure") + assert.LessOrEqual(t, maximum.Load(), int32(3)) + assert.Zero(t, active.Load()) +} + +func sampleReport() report { + return report{ + Revision: "revision", OS: "linux", Arch: "amd64", Go: "go-test", + Shards: 2, Workers: 1, Packages: []string{"pkg"}, + Inventory: []testID{{"pkg", "TestOne"}, {"pkg", "TestTwo"}}, + Assignments: [][]batch{ + {{Package: "pkg", Tests: []string{"TestOne"}}}, + {{Package: "pkg", Tests: []string{"TestTwo"}}}, + }, + Results: []testResult{{Package: "pkg", Test: "TestOne", Action: "pass"}}, + Complete: true, Success: true, + } +} + +func TestResultValidation(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + change func(*report) + }{ + {"missing", func(r *report) { r.Results = nil }}, + {"duplicate", func(r *report) { r.Results = append(r.Results, r.Results[0]) }}, + {"unassigned", func(r *report) { r.Results[0].Test = "TestTwo" }}, + {"failed", func(r *report) { r.Results[0].Action = "fail" }}, + {"missing parent", func(r *report) { r.Results[0].Test += "/child" }}, + {"invalid partition count", func(r *report) { r.Assignments = nil }}, + } { + t.Run(tc.name, func(t *testing.T) { + rep := sampleReport() + tc.change(&rep) + require.Error(t, validateResults(rep)) + }) + } + rep := sampleReport() + rep.Results = append(rep.Results, testResult{Package: "pkg", Test: "TestOne/child", Action: "skip"}) + require.NoError(t, validateResults(rep)) +} + +func TestVerifyReports(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + change func([]report) []report + }{ + {"complete", func(reps []report) []report { return reps }}, + {"missing shard", func(reps []report) []report { return reps[:1] }}, + {"duplicate shard", func(reps []report) []report { return append(reps, reps[0]) }}, + {"failed shard", func(reps []report) []report { reps[1].Success = false; return reps }}, + {"cancelled shard", func(reps []report) []report { reps[1].Complete = false; return reps }}, + {"missing test", func(reps []report) []report { reps[1].Results = nil; return reps }}, + {"different revision", func(reps []report) []report { reps[1].Revision = "other"; return reps }}, + {"different inventory", func(reps []report) []report { + reps[1].Inventory = append(reps[1].Inventory, testID{"pkg", "TestNew"}) + return reps + }}, + {"different packages", func(reps []report) []report { + reps[1].Packages = append(reps[1].Packages, "new/package") + return reps + }}, + {"wrong OS", func(reps []report) []report { reps[1].OS = "windows"; return reps }}, + } { + t.Run(tc.name, func(t *testing.T) { + reps := []report{sampleReport(), sampleReport()} + reps[1].Shard = 1 + reps[1].Results[0].Test = "TestTwo" + reps = tc.change(reps) + dir := t.TempDir() + for i, rep := range reps { + target := filepath.Join(dir, fmt.Sprint(i)) + require.NoError(t, os.Mkdir(target, 0755)) + data, err := json.Marshal(rep) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(target, "report.json"), data, 0644)) + } + var out bytes.Buffer + err := verifyReports(dir, []string{"linux"}, &out) + if tc.name == "complete" { + require.NoError(t, err) + assert.Contains(t, out.String(), "2 top-level tests") + } else { + require.Error(t, err) + } + }) + } +} + +func TestWorkflowMatrix(t *testing.T) { + t.Parallel() + data, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "test.yml")) + require.NoError(t, err) + var workflow struct { + On map[string]struct { + Branches []string `yaml:"branches"` + BranchesIgnore []string `yaml:"branches-ignore"` + } `yaml:"on"` + Jobs map[string]struct { + Strategy struct { + Matrix struct { + Include []struct { + OS string `yaml:"os"` + Shard int `yaml:"shard"` + Shards int `yaml:"shards"` + Workers int `yaml:"workers"` + } `yaml:"include"` + } `yaml:"matrix"` + } `yaml:"strategy"` + } `yaml:"jobs"` + } + require.NoError(t, yaml.Unmarshal(data, &workflow)) + pullRequest, exists := workflow.On["pull_request"] + require.True(t, exists) + assert.Empty(t, pullRequest.Branches, "stacked PRs target another layer, not necessarily main") + assert.Empty(t, pullRequest.BranchesIgnore) + seen := make(map[string]map[int]bool) + counts := make(map[string]int) + for _, entry := range workflow.Jobs["test"].Strategy.Matrix.Include { + require.NoError(t, validatePartitions(entry.Workers, entry.Shards, entry.Shard)) + if seen[entry.OS] == nil { + seen[entry.OS] = make(map[int]bool) + counts[entry.OS] = entry.Shards + } + require.Equal(t, counts[entry.OS], entry.Shards) + require.False(t, seen[entry.OS][entry.Shard], "duplicate %s shard %d", entry.OS, entry.Shard) + seen[entry.OS][entry.Shard] = true + } + require.Len(t, seen, 3) + for _, platform := range []string{"ubuntu-latest", "windows-latest", "macos-latest"} { + require.NotEmpty(t, seen[platform], platform) + require.Len(t, seen[platform], counts[platform], platform) + for i := range counts[platform] { + require.True(t, seen[platform][i], "missing %s shard %d", platform, i) + } + } + _, gate := workflow.Jobs["test-complete"] + require.True(t, gate, "the completion gate must not be removed") +} + +func TestRunnerEndToEnd(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + t.Chdir(repo) + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + require.NoError(t, os.WriteFile("go.mod", []byte("module fixture\n\ngo 1.26.0\n"), 0644)) + require.NoError(t, os.WriteFile("fixture.go", []byte("package fixture\n"), 0644)) + require.NoError(t, os.WriteFile("race_test.go", []byte("//go:build race\n\npackage fixture\nimport \"testing\"\nfunc TestRaceOnly(t *testing.T) {}\n"), 0644)) + _, err := output(ctx, repo, "git", "init", "-q", "-b", "main") + require.NoError(t, err) + _, err = output(ctx, repo, "git", "-c", "user.name=Test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", "fixture") + require.NoError(t, err) + _, err = output(ctx, repo, "go", "vet", "-race", "./...") + require.NoError(t, err) + dir := t.TempDir() + var stdout, stderr bytes.Buffer + for shard := range 2 { + err := run(ctx, options{workers: 1, shards: 2, shard: shard, timeout: time.Minute, output: dir}, + []string{"./..."}, &stdout, &stderr) + require.NoError(t, err, stderr.String()) + } + require.NoError(t, verifyReports(dir, []string{runtime.GOOS}, &stdout)) + assert.Contains(t, stdout.String(), "1 top-level tests") + assert.Empty(t, stderr.String()) + reports, err := filepath.Glob(filepath.Join(dir, "gh-stack-tests-*", "report.json")) + require.NoError(t, err) + require.Len(t, reports, 2) + executed := 0 + for _, path := range reports { + data, err := os.ReadFile(path) + require.NoError(t, err) + var rep report + require.NoError(t, json.Unmarshal(data, &rep)) + require.Equal(t, []testID{{"fixture", "TestRaceOnly"}}, rep.Inventory) + for _, result := range rep.Results { + require.Equal(t, "TestRaceOnly", result.Test) + require.Equal(t, "pass", result.Action) + executed++ + } + } + assert.Equal(t, 1, executed) +} + +func TestRunBatch(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fixture\n\ngo 1.26.0\n"), 0644)) + source := `package fixture +import ("fmt"; "os"; "testing"; "time") +func block(phase string) { + if os.Getenv("GH_STACK_TESTSUITE_BLOCK_PHASE") == phase { + fmt.Println("blocking", phase) + for { time.Sleep(time.Hour) } + } +} +func init() { block("init") } +func TestMain(m *testing.M) { + if os.Getenv("GH_STACK_TESTSUITE_EARLY_EXIT") == "1" { os.Exit(0) } + block("before") + code := m.Run() + block("after") + os.Exit(code) +} +func TestPass(t *testing.T) { t.Run("child", func(t *testing.T) {}) } +func TestFailure(t *testing.T) { t.Fatal("intentional child failure") } +func TestRace(t *testing.T) { + var value int + done := make(chan struct{}) + go func() { value++; close(done) }() + value++ + <-done + t.Log(value) +} +func TestSlow(t *testing.T) { select {} } +func Thing() { fmt.Println("example") } +func ExampleThing() { + Thing() + // Output: example +} +func FuzzSeed(f *testing.F) { + f.Add("seed") + f.Fuzz(func(t *testing.T, input string) { + if input == "" { t.Fatal("empty seed") } + }) +} +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "fixture_test.go"), []byte(source), 0644)) + binary := filepath.Join(dir, "fixture.test") + if runtime.GOOS == "windows" { + binary += ".exe" + } + ctx := context.Background() + _, err := output(ctx, dir, "go", "vet", "./...") + require.NoError(t, err) + _, err = output(ctx, dir, "go", "test", "-race", "-c", "-o", binary, ".") + require.NoError(t, err) + pkg := testPackage{ImportPath: "fixture", Dir: dir, binary: binary} + for _, tc := range []struct { + name, test string + timeout time.Duration + wantError string + results []string + }{ + {"success", "TestPass", time.Minute, "", []string{"TestPass/child", "TestPass"}}, + {"example", "ExampleThing", time.Minute, "", []string{"ExampleThing"}}, + {"fuzz seed", "FuzzSeed", time.Minute, "", []string{"FuzzSeed/seed#0", "FuzzSeed"}}, + {"failure", "TestFailure", time.Minute, "intentional child failure", nil}, + {"race", "TestRace", time.Minute, "DATA RACE", nil}, + {"timeout", "TestSlow", 100 * time.Millisecond, "test timed out", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "events.jsonl") + results, err := runBatch(ctx, pkg, batch{pkg.ImportPath, []string{tc.test}}, tc.timeout, path) + if tc.wantError != "" { + require.ErrorContains(t, err, tc.wantError) + } else { + require.NoError(t, err) + names := make([]string, len(results)) + for i, result := range results { + names[i] = result.Test + } + assert.Equal(t, tc.results, names) + } + data, readErr := os.ReadFile(path) + require.NoError(t, readErr) + assert.Contains(t, string(data), `"Action"`) + }) + } + t.Run("early successful exit is not a completed suite", func(t *testing.T) { + t.Setenv("GH_STACK_TESTSUITE_EARLY_EXIT", "1") + results, err := runBatch(ctx, pkg, batch{pkg.ImportPath, []string{"TestPass"}}, time.Minute, filepath.Join(t.TempDir(), "events.jsonl")) + require.NoError(t, err) + rep := sampleReport() + rep.Results = results + require.ErrorContains(t, validateResults(rep), "missing result") + }) + t.Run("cancellation", func(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond) + defer cancel() + _, err := runBatch(ctx, pkg, batch{pkg.ImportPath, []string{"TestSlow"}}, time.Minute, filepath.Join(t.TempDir(), "events.jsonl")) + require.Error(t, err) + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + }) + for _, phase := range []string{"init", "before", "after"} { + t.Run("external deadline/"+phase, func(t *testing.T) { + t.Setenv("GH_STACK_TESTSUITE_BLOCK_PHASE", phase) + path := filepath.Join(t.TempDir(), "events.jsonl") + _, err := runBatch(ctx, pkg, batch{pkg.ImportPath, []string{"TestPass"}}, 100*time.Millisecond, path) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Contains(t, err.Error(), "blocking "+phase, "the fixture must reach the blocked lifecycle phase") + }) + } + t.Run("discovery deadline", func(t *testing.T) { + t.Setenv("GH_STACK_TESTSUITE_BLOCK_PHASE", "after") + names, err := discoverTests(ctx, pkg, 100*time.Millisecond) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Nil(t, names) + assert.Contains(t, err.Error(), "blocking after", "list mode must not bypass the external deadline") + }) +} diff --git a/scripts/test/process_unix.go b/scripts/test/process_unix.go new file mode 100644 index 00000000..611802e3 --- /dev/null +++ b/scripts/test/process_unix.go @@ -0,0 +1,23 @@ +//go:build !windows + +package main + +import ( + "errors" + "os" + "os/exec" + "syscall" + "time" +) + +func configureProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + cmd.WaitDelay = 5 * time.Second +} diff --git a/scripts/test/process_windows.go b/scripts/test/process_windows.go new file mode 100644 index 00000000..11cef968 --- /dev/null +++ b/scripts/test/process_windows.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "os/exec" + "strconv" + "time" +) + +func configureProcess(cmd *exec.Cmd) { + cmd.Cancel = func() error { + // Killing only test2json would orphan the test binary and its Git children. + out, err := exec.Command("taskkill", "/PID", strconv.Itoa(cmd.Process.Pid), "/T", "/F").CombinedOutput() + if err != nil { + return fmt.Errorf("stopping test process tree: %w: %s", err, out) + } + return nil + } + cmd.WaitDelay = 5 * time.Second +} diff --git a/scripts/test/report.go b/scripts/test/report.go new file mode 100644 index 00000000..cc9bf92d --- /dev/null +++ b/scripts/test/report.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" +) + +func validateResults(rep report) error { + if err := validatePartitions(rep.Workers, rep.Shards, rep.Shard); err != nil { + return err + } + if len(rep.Assignments) != rep.Shards*rep.Workers { + return errors.New("worker partition count does not match the manifest") + } + if err := validateAssignments(rep.Inventory, rep.Assignments); err != nil { + return err + } + expected := make(map[testID]bool) + for _, groups := range rep.Assignments[rep.Shard*rep.Workers : (rep.Shard+1)*rep.Workers] { + for _, group := range groups { + for _, name := range group.Tests { + expected[testID{group.Package, name}] = true + } + } + } + seen := make(map[testID]bool) + for _, result := range rep.Results { + if result.Action != "pass" && result.Action != "skip" { + return fmt.Errorf("unsuccessful test: %s/%s (%s)", result.Package, result.Test, result.Action) + } + root, _, _ := strings.Cut(result.Test, "/") + if !expected[testID{result.Package, root}] { + return fmt.Errorf("unassigned test executed: %s/%s", result.Package, result.Test) + } + id := testID{result.Package, result.Test} + if seen[id] { + return fmt.Errorf("duplicate result: %s/%s", result.Package, result.Test) + } + seen[id] = true + } + for id := range expected { + if !seen[id] { + return fmt.Errorf("missing result: %s/%s", id.Package, id.Test) + } + } + return nil +} + +func verifyReports(dir string, platforms []string, stdout io.Writer) error { + byOS := make(map[string]map[int]report) + revision := "" + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || entry.Name() != "report.json" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var rep report + if err := json.Unmarshal(data, &rep); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if !rep.Complete || !rep.Success || rep.Error != "" { + return fmt.Errorf("%s: incomplete or failed shard: %s", path, rep.Error) + } + if len(rep.Packages) == 0 || len(rep.Inventory) == 0 { + return fmt.Errorf("%s: empty CI suite inventory", path) + } + if rep.Revision == "" || (revision != "" && revision != rep.Revision) { + return errors.New("reports do not identify the same source revision") + } + revision = rep.Revision + if err := validateResults(rep); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if byOS[rep.OS] == nil { + byOS[rep.OS] = make(map[int]report) + } + if _, exists := byOS[rep.OS][rep.Shard]; exists { + return fmt.Errorf("duplicate %s shard %d", rep.OS, rep.Shard) + } + byOS[rep.OS][rep.Shard] = rep + return nil + }) + if err != nil { + return err + } + if len(byOS) != len(platforms) { + return fmt.Errorf("expected %d platforms, received %d", len(platforms), len(byOS)) + } + for _, platform := range platforms { + shards := byOS[platform] + first, exists := shards[0] + if !exists || len(shards) != first.Shards { + return fmt.Errorf("missing shards for %s", platform) + } + count := 0 + for i := range first.Shards { + rep, exists := shards[i] + if !exists || rep.Shards != first.Shards || rep.Workers != first.Workers || + rep.Arch != first.Arch || rep.Go != first.Go || + !reflect.DeepEqual(rep.Packages, first.Packages) || + !reflect.DeepEqual(rep.Inventory, first.Inventory) || + !reflect.DeepEqual(rep.Assignments, first.Assignments) { + return fmt.Errorf("inconsistent %s shard %d manifest", platform, i) + } + count += len(rep.Results) + } + fmt.Fprintf(stdout, "%s: %d shards, %d top-level tests, %d test/subtest results\n", + platform, first.Shards, len(first.Inventory), count) + } + return nil +} diff --git a/scripts/test/schedule.go b/scripts/test/schedule.go new file mode 100644 index 00000000..08c74f37 --- /dev/null +++ b/scripts/test/schedule.go @@ -0,0 +1,158 @@ +package main + +import ( + "errors" + "fmt" + "regexp" + "slices" + "strings" +) + +const maxSelectorBytes = 8000 + +type testID struct { + Package string + Test string +} + +type batch struct { + Package string + Tests []string +} + +func validatePartitions(workers, shards, shard int) error { + if workers < 1 || shards < 1 || shards > 256 || workers > 256/shards { + return errors.New("workers and shards must be positive, with at most 256 total worker partitions") + } + if shard < 0 || shard >= shards { + return errors.New("shard must be between zero and shards-1") + } + return nil +} + +func selector(names []string) string { + if len(names) == 0 { + return "^$" + } + escaped := make([]string, len(names)) + for i, name := range names { + escaped[i] = regexp.QuoteMeta(name) + } + return "^(" + strings.Join(escaped, "|") + ")$" +} + +func weight(id testID) float64 { + name := strings.TrimPrefix(id.Package, "github.com/github/gh-stack/") + "/" + id.Test + if seconds := timingHints[name]; seconds > 0 { + return seconds + } + return 0.1 +} + +func schedule(packages []testPackage, partitions int) [][]batch { + type unit struct { + batch + cost float64 + } + var units []unit + for _, pkg := range packages { + if pkg.binary == "" { + continue + } + // Only scheduling granularity is hinted. Every discovered package/test is assigned. + switch strings.TrimPrefix(pkg.ImportPath, "github.com/github/gh-stack/") { + case "cmd", "internal/git", "internal/modify": + for _, name := range pkg.tests { + units = append(units, unit{batch{pkg.ImportPath, []string{name}}, weight(testID{pkg.ImportPath, name})}) + } + if len(pkg.tests) == 0 { + units = append(units, unit{batch{Package: pkg.ImportPath}, 0.1}) + } + default: + cost := 0.1 + for _, name := range pkg.tests { + cost += weight(testID{pkg.ImportPath, name}) + } + units = append(units, unit{batch{pkg.ImportPath, slices.Clone(pkg.tests)}, cost}) + } + } + slices.SortFunc(units, func(a, b unit) int { + if a.cost > b.cost { + return -1 + } + if a.cost < b.cost { + return 1 + } + return strings.Compare(a.Package+"/"+strings.Join(a.Tests, ","), b.Package+"/"+strings.Join(b.Tests, ",")) + }) + result := make([][]batch, partitions) + load := make([]float64, partitions) + for _, item := range units { + target := 0 + for i := range partitions { + if load[i] < load[target] { + target = i + } + } + load[target] += item.cost + merged := false + for i := range result[target] { + group := &result[target][i] + if group.Package == item.Package { + group.Tests = append(group.Tests, item.Tests...) + merged = true + break + } + } + if !merged { + result[target] = append(result[target], item.batch) + } + } + for i, groups := range result { + var bounded []batch + for _, group := range groups { + slices.Sort(group.Tests) + current := batch{Package: group.Package} + for _, name := range group.Tests { + // Leave ample space below Windows' command-line limit, including UTF-16 paths. + if len(current.Tests) > 0 && len(selector(current.Tests))+len(regexp.QuoteMeta(name))+1 > maxSelectorBytes { + bounded = append(bounded, current) + current = batch{Package: group.Package} + } + current.Tests = append(current.Tests, name) + } + bounded = append(bounded, current) + } + result[i] = bounded + } + return result +} + +func validateAssignments(inventory []testID, assignments [][]batch) error { + expected := make(map[testID]bool, len(inventory)) + for _, id := range inventory { + if expected[id] { + return fmt.Errorf("duplicate discovery: %s/%s", id.Package, id.Test) + } + expected[id] = true + } + seen := make(map[testID]bool, len(inventory)) + for _, groups := range assignments { + for _, group := range groups { + if len(selector(group.Tests)) > maxSelectorBytes { + return fmt.Errorf("test selector exceeds the argument limit for %s", group.Package) + } + for _, name := range group.Tests { + id := testID{group.Package, name} + if !expected[id] || seen[id] { + return fmt.Errorf("unexpected or duplicate assignment: %s/%s", id.Package, id.Test) + } + seen[id] = true + } + } + } + if len(seen) != len(expected) { + return fmt.Errorf("incomplete assignments: assigned %d of %d tests", len(seen), len(expected)) + } + return nil +} diff --git a/scripts/test/timings.go b/scripts/test/timings.go new file mode 100644 index 00000000..dd8c98e3 --- /dev/null +++ b/scripts/test/timings.go @@ -0,0 +1,53 @@ +package main + +// Relative scheduling weights from a local race-enabled baseline. These are +// advisory; discovery, not this map, determines which tests run on each OS. +var timingHints = map[string]float64{ + "internal/modify/TestDistributedModify_Rename": 18, + "internal/modify/TestDistributedModify_Insert": 16, + "internal/modify/TestDistributedModify_Drop": 18, + "internal/modify/TestDistributedModify_FoldDown": 20, + "internal/modify/TestDistributedModify_FoldUp": 14, + "internal/modify/TestDistributedModify_Reorder": 24, + "internal/modify/TestDistributedModify_RenameInsertAndDrop": 35, + "internal/modify/TestDistributedModify_RebaseRecovery": 55, + "internal/modify/TestDistributedModify_FoldThenRebaseRecovery": 51, + "internal/modify/TestModifyRecovery_FromAnotherWorktree": 32, + "internal/modify/TestDistributedModify_FoldUpDropNormalizationRecovery": 16, + "internal/modify/TestApplyPlan_SeparateGitDirOrigin": 16, + "internal/modify/TestDistributedModify_MetadataSaveFailureRetainsRecovery": 13, + "internal/modify/TestDistributedModify_RetainsOriginForForeignSurvivor": 12, + "internal/modify/TestDistributedModify_MultipleFoldsKeepOriginalCutoffs": 12, + "internal/modify/TestDistributedModify_PreservesNewCommitOnRemainingOwner": 10, + "internal/modify/TestDistributedModify_FoldUpAcrossMultipleDroppedRanges": 9, + "internal/modify/TestDistributedModify_AbortPreservesChangedPendingRef": 8, + "internal/modify/TestDistributedModify_MergedOwnerIsNotTouched": 8, + "internal/modify/TestModifyRecovery_PreservesExternalCommitAfterSaveFailure": 7, + "internal/modify/TestDistributedModify_DropLeavesDirtySourceUntouched": 7, + "internal/modify/TestDistributedModify_FoldUpAcrossDropExcludesDroppedHistory": 6, + "internal/modify/TestApplyPlan_LinkedWorktreeWithForeignTrunk": 6, + "internal/modify/TestDistributedModify_PreflightsAllTargets": 6, + "cmd/TestRebase_SharedTrunkSelectionRecoveryPreservesOrigin": 11, + "cmd/TestRebase_WorktreesAbortRetainsRecoveryForNewEdits": 7, + "cmd/TestRebase_ContinueWithRemainingBranchInConflictWorktree": 6, + "cmd/TestSync_WorktreesConflictRestoresWithoutPush": 6, + "cmd/TestRebase_WorktreesConflictContinueFromDifferentWorktree": 5, + "cmd/TestStackSelection_RewritePreflightPreservesCheckout": 5, + "cmd/TestSync_SharedTrunkSelectionRestoresOrigin": 5, + "cmd/TestIntegration_AmendedParentPushThenRebase": 4, + "cmd/TestIntegration_AmendedParentWithoutForkPointFailsSafely": 4, + "cmd/TestRebase_RecoveryFindsMovedOrigin": 4, + "cmd/TestRebase_WorktreesExplicitTargetRecoverySelectsCorrectStack": 4, + "cmd/TestIntegration_AdoptedBranchRebasesFromCommonAncestor": 4, + "cmd/TestSync_WorktreesPublishesFromLinkedWorktree": 4, + "cmd/TestIntegration_AmendedParentRecoversCorruptedBase": 4, + "cmd/TestRebase_WorktreesPreserveCheckouts": 4, + "cmd/TestRebase_WorktreesUpstackDoesNotRequireDirtyLowerWorktree": 3, + "internal/git/TestIntegration_WorktreeRebaseRecovery": 11, + "internal/git/TestIntegration_WorktreeRebasePreservesOtherRefsAndConfig": 4, + "internal/git/TestIntegration_SeparateGitDirectoryBacklink": 3, + "internal/git/TestIntegration_WorktreeRebaseNeverAutostashes": 3, + "internal/git/TestIntegration_WorktreeCherryPickRecovery": 3, + "internal/git/TestIntegration_WorktreePathsPreserveWhitespace": 2, + "internal/git/TestIntegration_WorktreeRerereAutoContinuesMultipleCommits": 2, +}