Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 47 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Are we dropping main as a branch argument here to be more permissive so that CI is run for any and all base branches, not solely main anymore? Asking to confirm intent!

On the one hand, I can see this being beneficial for stacked PRs within this repo, like the companion PR to follow this: #579

On the other hand, possibility of increasing compute cost? 😅


docs: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#example-including-branches

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I figured might as well make it more permissive so it runs on all PRs, stacked or not. No particular need for this tbh, but can add the restriction back if CI compute gets out of hand.


permissions:
contents: read
Expand All @@ -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
Expand All @@ -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"
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |

Expand Down
48 changes: 45 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♥️ We already auto-link in the repository surface since CONTRIBUTING.md is present, but never hurts to be explicit in the README itself


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.
Expand Down
10 changes: 8 additions & 2 deletions cmd/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" {
Expand All @@ -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"},
Expand Down
33 changes: 28 additions & 5 deletions internal/git/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -790,15 +799,14 @@ 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, " ")) {
t.Skip("these filename characters are not supported on Windows")
}
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)
Expand Down Expand Up @@ -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} {
Expand Down Expand Up @@ -890,6 +898,7 @@ func TestIntegration_SeparateGitDirectory(t *testing.T) {
}

func TestIntegration_SeparateGitDirectoryBacklink(t *testing.T) {
t.Parallel()
tests := []struct {
name string
relative bool
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand All @@ -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"))
Expand All @@ -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)
Expand Down Expand Up @@ -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{})))
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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", ".")
Expand Down
2 changes: 1 addition & 1 deletion internal/git/rebase_start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading