detached 891299a

ci: add test workflow, daily upstream sync and wlbcommit tool

commit
891299a9cd9bab354d1403e63c5980f626b269ad
author
brice · · 2026-10-01 03:27 UTC
parent
7ad88b7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +639 -0
A .github/workflows/ci.yml +33 -0
@@ -0,0 +1,33 @@
1+name: ci
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+
8+permissions:
9+ contents: read
10+
11+jobs:
12+ test:
13+ runs-on: ubuntu-latest
14+ steps:
15+ - uses: actions/checkout@v4
16+ - uses: actions/setup-go@v5
17+ with:
18+ go-version-file: go.mod
19+ - name: Install Wayland libraries
20+ run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils
21+ - name: Vet
22+ run: go vet ./...
23+ - name: Staticcheck
24+ run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
25+ - name: Test (CGO disabled)
26+ run: CGO_ENABLED=0 go test ./...
27+ - name: Test (race)
28+ run: go test -race ./...
29+ - name: Generated code is up to date
30+ run: |
31+ go generate ./...
32+ git diff --exit-code
33+ test -z "$(git status --porcelain)"
@@ -0,0 +1,33 @@
1+name: ci
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+
8+permissions:
9+ contents: read
10+
11+jobs:
12+ test:
13+ runs-on: ubuntu-latest
14+ steps:
15+ - uses: actions/checkout@v4
16+ - uses: actions/setup-go@v5
17+ with:
18+ go-version-file: go.mod
19+ - name: Install Wayland libraries
20+ run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils
21+ - name: Vet
22+ run: go vet ./...
23+ - name: Staticcheck
24+ run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
25+ - name: Test (CGO disabled)
26+ run: CGO_ENABLED=0 go test ./...
27+ - name: Test (race)
28+ run: go test -race ./...
29+ - name: Generated code is up to date
30+ run: |
31+ go generate ./...
32+ git diff --exit-code
33+ test -z "$(git status --porcelain)"
A .github/workflows/sync.yml +90 -0
@@ -0,0 +1,90 @@
1+name: sync
2+
3+on:
4+ schedule:
5+ - cron: '30 4 * * *'
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: write
10+
11+concurrency:
12+ group: sync
13+ cancel-in-progress: false
14+
15+jobs:
16+ sync:
17+ runs-on: ubuntu-latest
18+ steps:
19+ - uses: actions/checkout@v4
20+ with:
21+ fetch-depth: 0
22+ - uses: actions/setup-go@v5
23+ with:
24+ go-version-file: go.mod
25+ - name: Configure git
26+ run: |
27+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
28+ git config --global core.autocrlf false
29+ - name: Install Wayland libraries
30+ run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils
31+ - name: Sync upstream protocols
32+ id: sync
33+ run: |
34+ go run ./cmd/wlbsync -notes "$RUNNER_TEMP/notes.md"
35+ if [ -z "$(git status --porcelain)" ]; then
36+ echo "up to date"
37+ echo "changed=false" >> "$GITHUB_OUTPUT"
38+ else
39+ echo "changed=true" >> "$GITHUB_OUTPUT"
40+ fi
41+ - name: Generate
42+ if: steps.sync.outputs.changed == 'true'
43+ run: go generate ./...
44+ - name: Vet
45+ if: steps.sync.outputs.changed == 'true'
46+ run: go vet ./...
47+ - name: Test (CGO disabled)
48+ if: steps.sync.outputs.changed == 'true'
49+ run: CGO_ENABLED=0 go test ./...
50+ - name: Test (race)
51+ if: steps.sync.outputs.changed == 'true'
52+ run: go test -race ./...
53+ - name: Compute next version
54+ id: version
55+ if: steps.sync.outputs.changed == 'true'
56+ run: |
57+ latest="$(git tag -l 'v0.*' --sort=-v:refname | grep -E '^v0\.[0-9]+\.[0-9]+$' | sed -n 1p || true)"
58+ if [ -z "$latest" ]; then
59+ tag="v0.1.0"
60+ else
61+ minor="${latest#v0.}"
62+ minor="${minor%%.*}"
63+ tag="v0.$((minor + 1)).0"
64+ fi
65+ echo "latest tag: ${latest:-none}; next tag: $tag"
66+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
67+ - name: Commit via GitHub API
68+ id: commit
69+ if: steps.sync.outputs.changed == 'true'
70+ env:
71+ GITHUB_TOKEN: ${{ github.token }}
72+ run: |
73+ message="$(printf 'feat(spec): sync upstream protocols\n\n'; head -n 20 "$RUNNER_TEMP/notes.md")"
74+ sha="$(go run ./cmd/wlbcommit -repo "$GITHUB_REPOSITORY" -branch main -message "$message")"
75+ echo "commit: $sha"
76+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
77+ - name: Create release
78+ if: steps.sync.outputs.changed == 'true'
79+ env:
80+ GH_TOKEN: ${{ github.token }}
81+ TAG: ${{ steps.version.outputs.tag }}
82+ SHA: ${{ steps.commit.outputs.sha }}
83+ run: |
84+ gh release create "$TAG" --target "$SHA" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md"
85+ - name: Warm Go module proxy
86+ if: steps.sync.outputs.changed == 'true'
87+ env:
88+ TAG: ${{ steps.version.outputs.tag }}
89+ run: |
90+ curl -fsS "https://proxy.golang.org/github.com/bnema/go-wayland-bindings/@v/$TAG.info" || true
@@ -0,0 +1,90 @@
1+name: sync
2+
3+on:
4+ schedule:
5+ - cron: '30 4 * * *'
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: write
10+
11+concurrency:
12+ group: sync
13+ cancel-in-progress: false
14+
15+jobs:
16+ sync:
17+ runs-on: ubuntu-latest
18+ steps:
19+ - uses: actions/checkout@v4
20+ with:
21+ fetch-depth: 0
22+ - uses: actions/setup-go@v5
23+ with:
24+ go-version-file: go.mod
25+ - name: Configure git
26+ run: |
27+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
28+ git config --global core.autocrlf false
29+ - name: Install Wayland libraries
30+ run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils
31+ - name: Sync upstream protocols
32+ id: sync
33+ run: |
34+ go run ./cmd/wlbsync -notes "$RUNNER_TEMP/notes.md"
35+ if [ -z "$(git status --porcelain)" ]; then
36+ echo "up to date"
37+ echo "changed=false" >> "$GITHUB_OUTPUT"
38+ else
39+ echo "changed=true" >> "$GITHUB_OUTPUT"
40+ fi
41+ - name: Generate
42+ if: steps.sync.outputs.changed == 'true'
43+ run: go generate ./...
44+ - name: Vet
45+ if: steps.sync.outputs.changed == 'true'
46+ run: go vet ./...
47+ - name: Test (CGO disabled)
48+ if: steps.sync.outputs.changed == 'true'
49+ run: CGO_ENABLED=0 go test ./...
50+ - name: Test (race)
51+ if: steps.sync.outputs.changed == 'true'
52+ run: go test -race ./...
53+ - name: Compute next version
54+ id: version
55+ if: steps.sync.outputs.changed == 'true'
56+ run: |
57+ latest="$(git tag -l 'v0.*' --sort=-v:refname | grep -E '^v0\.[0-9]+\.[0-9]+$' | sed -n 1p || true)"
58+ if [ -z "$latest" ]; then
59+ tag="v0.1.0"
60+ else
61+ minor="${latest#v0.}"
62+ minor="${minor%%.*}"
63+ tag="v0.$((minor + 1)).0"
64+ fi
65+ echo "latest tag: ${latest:-none}; next tag: $tag"
66+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
67+ - name: Commit via GitHub API
68+ id: commit
69+ if: steps.sync.outputs.changed == 'true'
70+ env:
71+ GITHUB_TOKEN: ${{ github.token }}
72+ run: |
73+ message="$(printf 'feat(spec): sync upstream protocols\n\n'; head -n 20 "$RUNNER_TEMP/notes.md")"
74+ sha="$(go run ./cmd/wlbcommit -repo "$GITHUB_REPOSITORY" -branch main -message "$message")"
75+ echo "commit: $sha"
76+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
77+ - name: Create release
78+ if: steps.sync.outputs.changed == 'true'
79+ env:
80+ GH_TOKEN: ${{ github.token }}
81+ TAG: ${{ steps.version.outputs.tag }}
82+ SHA: ${{ steps.commit.outputs.sha }}
83+ run: |
84+ gh release create "$TAG" --target "$SHA" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md"
85+ - name: Warm Go module proxy
86+ if: steps.sync.outputs.changed == 'true'
87+ env:
88+ TAG: ${{ steps.version.outputs.tag }}
89+ run: |
90+ curl -fsS "https://proxy.golang.org/github.com/bnema/go-wayland-bindings/@v/$TAG.info" || true
A cmd/wlbcommit/main.go +285 -0
@@ -0,0 +1,285 @@
1+// Command wlbcommit commits the working-tree changes of the current git
2+// repository to a branch on GitHub through the GraphQL createCommitOnBranch
3+// mutation, so that the resulting commit is signed (verified) by GitHub.
4+//
5+// Usage:
6+//
7+// GITHUB_TOKEN=... wlbcommit -repo owner/name -branch main -message "headline\n\nbody"
8+//
9+// The new commit oid is printed on stdout.
10+package main
11+
12+import (
13+ "bytes"
14+ "context"
15+ "encoding/base64"
16+ "encoding/json"
17+ "errors"
18+ "flag"
19+ "fmt"
20+ "io"
21+ "net/http"
22+ "os"
23+ "os/exec"
24+ "path/filepath"
25+ "sort"
26+ "strings"
27+ "time"
28+)
29+
30+const defaultAPI = "https://api.github.com/graphql"
31+
32+const mutation = `mutation($input: CreateCommitOnBranchInput!) {
33+ createCommitOnBranch(input: $input) {
34+ commit { oid }
35+ }
36+}`
37+
38+type branchInput struct {
39+ RepositoryNameWithOwner string `json:"repositoryNameWithOwner"`
40+ BranchName string `json:"branchName"`
41+}
42+
43+type messageInput struct {
44+ Headline string `json:"headline"`
45+ Body string `json:"body,omitempty"`
46+}
47+
48+type addition struct {
49+ Path string `json:"path"`
50+ Contents string `json:"contents"`
51+}
52+
53+type deletion struct {
54+ Path string `json:"path"`
55+}
56+
57+type fileChanges struct {
58+ Additions []addition `json:"additions"`
59+ Deletions []deletion `json:"deletions"`
60+}
61+
62+type commitInput struct {
63+ Branch branchInput `json:"branch"`
64+ ExpectedHeadOid string `json:"expectedHeadOid"`
65+ Message messageInput `json:"message"`
66+ FileChanges fileChanges `json:"fileChanges"`
67+}
68+
69+type request struct {
70+ Query string `json:"query"`
71+ Variables map[string]commitInput `json:"variables"`
72+}
73+
74+type response struct {
75+ Data struct {
76+ CreateCommitOnBranch struct {
77+ Commit struct {
78+ Oid string `json:"oid"`
79+ } `json:"commit"`
80+ } `json:"createCommitOnBranch"`
81+ } `json:"data"`
82+ Errors []struct {
83+ Message string `json:"message"`
84+ } `json:"errors"`
85+}
86+
87+func main() {
88+ if err := run(context.Background(), os.Args[1:], os.Getenv, os.Stdout, "."); err != nil {
89+ fmt.Fprintln(os.Stderr, "wlbcommit:", err)
90+ os.Exit(1)
91+ }
92+}
93+
94+func run(ctx context.Context, args []string, getenv func(string) string, stdout io.Writer, dir string) error {
95+ fs := flag.NewFlagSet("wlbcommit", flag.ContinueOnError)
96+ repo := fs.String("repo", "", "repository as owner/name")
97+ branch := fs.String("branch", "", "branch to commit to")
98+ message := fs.String("message", "", "commit message (first line is the headline)")
99+ api := fs.String("api", defaultAPI, "GraphQL endpoint")
100+ if err := fs.Parse(args); err != nil {
101+ return err
102+ }
103+ if *repo == "" || *branch == "" || strings.TrimSpace(*message) == "" {
104+ return errors.New("-repo, -branch and -message are required")
105+ }
106+ token := getenv("GITHUB_TOKEN")
107+ if token == "" {
108+ return errors.New("GITHUB_TOKEN is not set")
109+ }
110+
111+ root, err := git(ctx, dir, "rev-parse", "--show-toplevel")
112+ if err != nil {
113+ return err
114+ }
115+ root = strings.TrimSpace(root)
116+ head, err := git(ctx, root, "rev-parse", "HEAD")
117+ if err != nil {
118+ return err
119+ }
120+ status, err := git(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=all")
121+ if err != nil {
122+ return err
123+ }
124+ changes, err := collectChanges(root, status)
125+ if err != nil {
126+ return err
127+ }
128+ if len(changes.Additions) == 0 && len(changes.Deletions) == 0 {
129+ return errors.New("no changes to commit")
130+ }
131+
132+ headline, body := splitMessage(*message)
133+ payload, err := json.Marshal(request{
134+ Query: mutation,
135+ Variables: map[string]commitInput{"input": {
136+ Branch: branchInput{RepositoryNameWithOwner: *repo, BranchName: *branch},
137+ ExpectedHeadOid: strings.TrimSpace(head),
138+ Message: messageInput{Headline: headline, Body: body},
139+ FileChanges: changes,
140+ }},
141+ })
142+ if err != nil {
143+ return err
144+ }
145+
146+ oid, err := post(ctx, *api, token, payload)
147+ if err != nil {
148+ return err
149+ }
150+ _, err = fmt.Fprintln(stdout, oid)
151+ return err
152+}
153+
154+func git(ctx context.Context, dir string, args ...string) (string, error) {
155+ cmd := exec.CommandContext(ctx, "git", args...)
156+ cmd.Dir = dir
157+ var stdout, stderr bytes.Buffer
158+ cmd.Stdout = &stdout
159+ cmd.Stderr = &stderr
160+ if err := cmd.Run(); err != nil {
161+ return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
162+ }
163+ return stdout.String(), nil
164+}
165+
166+// collectChanges turns `git status --porcelain=v1 -z` output into GraphQL file
167+// changes. A path present in the working tree is an addition (which also
168+// covers modifications); an absent one is a deletion. Renames and copies yield
169+// an addition for the new path and, for renames, a deletion of the old one.
170+func collectChanges(root, status string) (fileChanges, error) {
171+ adds := map[string]bool{}
172+ dels := map[string]bool{}
173+ entries := strings.Split(status, "\x00")
174+ for i := 0; i < len(entries); i++ {
175+ e := entries[i]
176+ if len(e) < 4 {
177+ continue
178+ }
179+ x, y, path := e[0], e[1], e[3:]
180+ adds[path] = true
181+ if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
182+ i++
183+ if i >= len(entries) {
184+ return fileChanges{}, errors.New("malformed git status output")
185+ }
186+ if x == 'R' || y == 'R' {
187+ dels[entries[i]] = true
188+ }
189+ }
190+ }
191+
192+ changes := fileChanges{Additions: []addition{}, Deletions: []deletion{}}
193+ for _, p := range sortedKeys(adds) {
194+ full := filepath.Join(root, filepath.FromSlash(p))
195+ info, err := os.Lstat(full)
196+ if err != nil {
197+ if os.IsNotExist(err) {
198+ dels[p] = true
199+ continue
200+ }
201+ return fileChanges{}, err
202+ }
203+ if info.IsDir() {
204+ continue // e.g. a submodule; not representable
205+ }
206+ data, err := os.ReadFile(full)
207+ if err != nil {
208+ return fileChanges{}, err
209+ }
210+ delete(dels, p)
211+ changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)})
212+ }
213+ for _, p := range sortedKeys(dels) {
214+ if adds[p] {
215+ if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(p))); err == nil {
216+ continue
217+ }
218+ }
219+ changes.Deletions = append(changes.Deletions, deletion{Path: p})
220+ }
221+ return changes, nil
222+}
223+
224+func sortedKeys(m map[string]bool) []string {
225+ keys := make([]string, 0, len(m))
226+ for k := range m {
227+ keys = append(keys, k)
228+ }
229+ sort.Strings(keys)
230+ return keys
231+}
232+
233+func splitMessage(msg string) (headline, body string) {
234+ msg = strings.TrimSpace(msg)
235+ headline, body, _ = strings.Cut(msg, "\n")
236+ return strings.TrimSpace(headline), strings.TrimSpace(body)
237+}
238+
239+func post(ctx context.Context, api, token string, payload []byte) (string, error) {
240+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
241+ defer cancel()
242+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, api, bytes.NewReader(payload))
243+ if err != nil {
244+ return "", err
245+ }
246+ req.Header.Set("Authorization", "Bearer "+token)
247+ req.Header.Set("Content-Type", "application/json")
248+ req.Header.Set("User-Agent", "wlbcommit")
249+ resp, err := http.DefaultClient.Do(req)
250+ if err != nil {
251+ return "", err
252+ }
253+ defer resp.Body.Close()
254+ data, err := io.ReadAll(resp.Body)
255+ if err != nil {
256+ return "", err
257+ }
258+ if resp.StatusCode != http.StatusOK {
259+ return "", fmt.Errorf("GitHub API returned %s: %s", resp.Status, truncate(string(data)))
260+ }
261+ var out response
262+ if err := json.Unmarshal(data, &out); err != nil {
263+ return "", fmt.Errorf("decode response: %w", err)
264+ }
265+ if len(out.Errors) > 0 {
266+ msgs := make([]string, len(out.Errors))
267+ for i, e := range out.Errors {
268+ msgs[i] = e.Message
269+ }
270+ return "", fmt.Errorf("GraphQL errors: %s", strings.Join(msgs, "; "))
271+ }
272+ oid := out.Data.CreateCommitOnBranch.Commit.Oid
273+ if oid == "" {
274+ return "", errors.New("response contains no commit oid")
275+ }
276+ return oid, nil
277+}
278+
279+func truncate(s string) string {
280+ const max = 500
281+ if len(s) > max {
282+ return s[:max] + "..."
283+ }
284+ return s
285+}
@@ -0,0 +1,285 @@
1+// Command wlbcommit commits the working-tree changes of the current git
2+// repository to a branch on GitHub through the GraphQL createCommitOnBranch
3+// mutation, so that the resulting commit is signed (verified) by GitHub.
4+//
5+// Usage:
6+//
7+// GITHUB_TOKEN=... wlbcommit -repo owner/name -branch main -message "headline\n\nbody"
8+//
9+// The new commit oid is printed on stdout.
10+package main
11+
12+import (
13+ "bytes"
14+ "context"
15+ "encoding/base64"
16+ "encoding/json"
17+ "errors"
18+ "flag"
19+ "fmt"
20+ "io"
21+ "net/http"
22+ "os"
23+ "os/exec"
24+ "path/filepath"
25+ "sort"
26+ "strings"
27+ "time"
28+)
29+
30+const defaultAPI = "https://api.github.com/graphql"
31+
32+const mutation = `mutation($input: CreateCommitOnBranchInput!) {
33+ createCommitOnBranch(input: $input) {
34+ commit { oid }
35+ }
36+}`
37+
38+type branchInput struct {
39+ RepositoryNameWithOwner string `json:"repositoryNameWithOwner"`
40+ BranchName string `json:"branchName"`
41+}
42+
43+type messageInput struct {
44+ Headline string `json:"headline"`
45+ Body string `json:"body,omitempty"`
46+}
47+
48+type addition struct {
49+ Path string `json:"path"`
50+ Contents string `json:"contents"`
51+}
52+
53+type deletion struct {
54+ Path string `json:"path"`
55+}
56+
57+type fileChanges struct {
58+ Additions []addition `json:"additions"`
59+ Deletions []deletion `json:"deletions"`
60+}
61+
62+type commitInput struct {
63+ Branch branchInput `json:"branch"`
64+ ExpectedHeadOid string `json:"expectedHeadOid"`
65+ Message messageInput `json:"message"`
66+ FileChanges fileChanges `json:"fileChanges"`
67+}
68+
69+type request struct {
70+ Query string `json:"query"`
71+ Variables map[string]commitInput `json:"variables"`
72+}
73+
74+type response struct {
75+ Data struct {
76+ CreateCommitOnBranch struct {
77+ Commit struct {
78+ Oid string `json:"oid"`
79+ } `json:"commit"`
80+ } `json:"createCommitOnBranch"`
81+ } `json:"data"`
82+ Errors []struct {
83+ Message string `json:"message"`
84+ } `json:"errors"`
85+}
86+
87+func main() {
88+ if err := run(context.Background(), os.Args[1:], os.Getenv, os.Stdout, "."); err != nil {
89+ fmt.Fprintln(os.Stderr, "wlbcommit:", err)
90+ os.Exit(1)
91+ }
92+}
93+
94+func run(ctx context.Context, args []string, getenv func(string) string, stdout io.Writer, dir string) error {
95+ fs := flag.NewFlagSet("wlbcommit", flag.ContinueOnError)
96+ repo := fs.String("repo", "", "repository as owner/name")
97+ branch := fs.String("branch", "", "branch to commit to")
98+ message := fs.String("message", "", "commit message (first line is the headline)")
99+ api := fs.String("api", defaultAPI, "GraphQL endpoint")
100+ if err := fs.Parse(args); err != nil {
101+ return err
102+ }
103+ if *repo == "" || *branch == "" || strings.TrimSpace(*message) == "" {
104+ return errors.New("-repo, -branch and -message are required")
105+ }
106+ token := getenv("GITHUB_TOKEN")
107+ if token == "" {
108+ return errors.New("GITHUB_TOKEN is not set")
109+ }
110+
111+ root, err := git(ctx, dir, "rev-parse", "--show-toplevel")
112+ if err != nil {
113+ return err
114+ }
115+ root = strings.TrimSpace(root)
116+ head, err := git(ctx, root, "rev-parse", "HEAD")
117+ if err != nil {
118+ return err
119+ }
120+ status, err := git(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=all")
121+ if err != nil {
122+ return err
123+ }
124+ changes, err := collectChanges(root, status)
125+ if err != nil {
126+ return err
127+ }
128+ if len(changes.Additions) == 0 && len(changes.Deletions) == 0 {
129+ return errors.New("no changes to commit")
130+ }
131+
132+ headline, body := splitMessage(*message)
133+ payload, err := json.Marshal(request{
134+ Query: mutation,
135+ Variables: map[string]commitInput{"input": {
136+ Branch: branchInput{RepositoryNameWithOwner: *repo, BranchName: *branch},
137+ ExpectedHeadOid: strings.TrimSpace(head),
138+ Message: messageInput{Headline: headline, Body: body},
139+ FileChanges: changes,
140+ }},
141+ })
142+ if err != nil {
143+ return err
144+ }
145+
146+ oid, err := post(ctx, *api, token, payload)
147+ if err != nil {
148+ return err
149+ }
150+ _, err = fmt.Fprintln(stdout, oid)
151+ return err
152+}
153+
154+func git(ctx context.Context, dir string, args ...string) (string, error) {
155+ cmd := exec.CommandContext(ctx, "git", args...)
156+ cmd.Dir = dir
157+ var stdout, stderr bytes.Buffer
158+ cmd.Stdout = &stdout
159+ cmd.Stderr = &stderr
160+ if err := cmd.Run(); err != nil {
161+ return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
162+ }
163+ return stdout.String(), nil
164+}
165+
166+// collectChanges turns `git status --porcelain=v1 -z` output into GraphQL file
167+// changes. A path present in the working tree is an addition (which also
168+// covers modifications); an absent one is a deletion. Renames and copies yield
169+// an addition for the new path and, for renames, a deletion of the old one.
170+func collectChanges(root, status string) (fileChanges, error) {
171+ adds := map[string]bool{}
172+ dels := map[string]bool{}
173+ entries := strings.Split(status, "\x00")
174+ for i := 0; i < len(entries); i++ {
175+ e := entries[i]
176+ if len(e) < 4 {
177+ continue
178+ }
179+ x, y, path := e[0], e[1], e[3:]
180+ adds[path] = true
181+ if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
182+ i++
183+ if i >= len(entries) {
184+ return fileChanges{}, errors.New("malformed git status output")
185+ }
186+ if x == 'R' || y == 'R' {
187+ dels[entries[i]] = true
188+ }
189+ }
190+ }
191+
192+ changes := fileChanges{Additions: []addition{}, Deletions: []deletion{}}
193+ for _, p := range sortedKeys(adds) {
194+ full := filepath.Join(root, filepath.FromSlash(p))
195+ info, err := os.Lstat(full)
196+ if err != nil {
197+ if os.IsNotExist(err) {
198+ dels[p] = true
199+ continue
200+ }
201+ return fileChanges{}, err
202+ }
203+ if info.IsDir() {
204+ continue // e.g. a submodule; not representable
205+ }
206+ data, err := os.ReadFile(full)
207+ if err != nil {
208+ return fileChanges{}, err
209+ }
210+ delete(dels, p)
211+ changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)})
212+ }
213+ for _, p := range sortedKeys(dels) {
214+ if adds[p] {
215+ if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(p))); err == nil {
216+ continue
217+ }
218+ }
219+ changes.Deletions = append(changes.Deletions, deletion{Path: p})
220+ }
221+ return changes, nil
222+}
223+
224+func sortedKeys(m map[string]bool) []string {
225+ keys := make([]string, 0, len(m))
226+ for k := range m {
227+ keys = append(keys, k)
228+ }
229+ sort.Strings(keys)
230+ return keys
231+}
232+
233+func splitMessage(msg string) (headline, body string) {
234+ msg = strings.TrimSpace(msg)
235+ headline, body, _ = strings.Cut(msg, "\n")
236+ return strings.TrimSpace(headline), strings.TrimSpace(body)
237+}
238+
239+func post(ctx context.Context, api, token string, payload []byte) (string, error) {
240+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
241+ defer cancel()
242+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, api, bytes.NewReader(payload))
243+ if err != nil {
244+ return "", err
245+ }
246+ req.Header.Set("Authorization", "Bearer "+token)
247+ req.Header.Set("Content-Type", "application/json")
248+ req.Header.Set("User-Agent", "wlbcommit")
249+ resp, err := http.DefaultClient.Do(req)
250+ if err != nil {
251+ return "", err
252+ }
253+ defer resp.Body.Close()
254+ data, err := io.ReadAll(resp.Body)
255+ if err != nil {
256+ return "", err
257+ }
258+ if resp.StatusCode != http.StatusOK {
259+ return "", fmt.Errorf("GitHub API returned %s: %s", resp.Status, truncate(string(data)))
260+ }
261+ var out response
262+ if err := json.Unmarshal(data, &out); err != nil {
263+ return "", fmt.Errorf("decode response: %w", err)
264+ }
265+ if len(out.Errors) > 0 {
266+ msgs := make([]string, len(out.Errors))
267+ for i, e := range out.Errors {
268+ msgs[i] = e.Message
269+ }
270+ return "", fmt.Errorf("GraphQL errors: %s", strings.Join(msgs, "; "))
271+ }
272+ oid := out.Data.CreateCommitOnBranch.Commit.Oid
273+ if oid == "" {
274+ return "", errors.New("response contains no commit oid")
275+ }
276+ return oid, nil
277+}
278+
279+func truncate(s string) string {
280+ const max = 500
281+ if len(s) > max {
282+ return s[:max] + "..."
283+ }
284+ return s
285+}
A cmd/wlbcommit/main_test.go +231 -0
@@ -0,0 +1,231 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/base64"
7+ "encoding/json"
8+ "net/http"
9+ "net/http/httptest"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+ "testing"
14+)
15+
16+func gitT(t *testing.T, dir string, args ...string) string {
17+ t.Helper()
18+ full := append([]string{
19+ "-c", "commit.gpgsign=false",
20+ "-c", "user.name=test", "-c", "user.email=test@example.com",
21+ }, args...)
22+ out, err := git(context.Background(), dir, full...)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ return out
27+}
28+
29+func writeFile(t *testing.T, dir, name, content string) {
30+ t.Helper()
31+ p := filepath.Join(dir, filepath.FromSlash(name))
32+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
33+ t.Fatal(err)
34+ }
35+ if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+}
39+
40+func newRepo(t *testing.T) (dir, head string) {
41+ t.Helper()
42+ dir = t.TempDir()
43+ gitT(t, dir, "init", "-q")
44+ writeFile(t, dir, "keep.txt", "keep\n")
45+ writeFile(t, dir, "modify.txt", "old\n")
46+ writeFile(t, dir, "delete.txt", "bye\n")
47+ writeFile(t, dir, "sub/rename-me.txt", "rename content that is long enough to be detected as a rename\n")
48+ gitT(t, dir, "add", "-A")
49+ gitT(t, dir, "commit", "-q", "-m", "init")
50+ return dir, strings.TrimSpace(gitT(t, dir, "rev-parse", "HEAD"))
51+}
52+
53+func env(m map[string]string) func(string) string {
54+ return func(k string) string { return m[k] }
55+}
56+
57+func TestRunRequestShape(t *testing.T) {
58+ dir, head := newRepo(t)
59+ writeFile(t, dir, "modify.txt", "new\n")
60+ writeFile(t, dir, "added/dir/new.bin", "\x00\x01binary")
61+ if err := os.Remove(filepath.Join(dir, "delete.txt")); err != nil {
62+ t.Fatal(err)
63+ }
64+ gitT(t, dir, "mv", "sub/rename-me.txt", "renamed.txt")
65+
66+ var gotAuth string
67+ var got request
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ gotAuth = r.Header.Get("Authorization")
70+ if r.Method != http.MethodPost {
71+ t.Errorf("method = %s", r.Method)
72+ }
73+ if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
74+ t.Error(err)
75+ }
76+ _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"abc123"}}}}`))
77+ }))
78+ defer srv.Close()
79+
80+ var out bytes.Buffer
81+ err := run(context.Background(), []string{
82+ "-repo", "o/r", "-branch", "main", "-api", srv.URL,
83+ "-message", "feat: headline\n\nbody line 1\nbody line 2\n",
84+ }, env(map[string]string{"GITHUB_TOKEN": "tok"}), &out, dir)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if out.String() != "abc123\n" {
89+ t.Errorf("stdout = %q", out.String())
90+ }
91+ if gotAuth != "Bearer tok" {
92+ t.Errorf("auth = %q", gotAuth)
93+ }
94+ if !strings.Contains(got.Query, "createCommitOnBranch") {
95+ t.Errorf("query = %q", got.Query)
96+ }
97+ in := got.Variables["input"]
98+ if in.Branch.RepositoryNameWithOwner != "o/r" || in.Branch.BranchName != "main" {
99+ t.Errorf("branch = %+v", in.Branch)
100+ }
101+ if in.ExpectedHeadOid != head {
102+ t.Errorf("expectedHeadOid = %q, want %q", in.ExpectedHeadOid, head)
103+ }
104+ if in.Message.Headline != "feat: headline" || in.Message.Body != "body line 1\nbody line 2" {
105+ t.Errorf("message = %+v", in.Message)
106+ }
107+
108+ adds := map[string]string{}
109+ for _, a := range in.FileChanges.Additions {
110+ b, err := base64.StdEncoding.DecodeString(a.Contents)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ adds[a.Path] = string(b)
115+ }
116+ wantAdds := map[string]string{
117+ "modify.txt": "new\n",
118+ "added/dir/new.bin": "\x00\x01binary",
119+ "renamed.txt": "rename content that is long enough to be detected as a rename\n",
120+ }
121+ if len(adds) != len(wantAdds) {
122+ t.Errorf("additions = %v", adds)
123+ }
124+ for p, c := range wantAdds {
125+ if adds[p] != c {
126+ t.Errorf("addition %s = %q, want %q", p, adds[p], c)
127+ }
128+ }
129+ var dels []string
130+ for _, d := range in.FileChanges.Deletions {
131+ dels = append(dels, d.Path)
132+ }
133+ if strings.Join(dels, ",") != "delete.txt,sub/rename-me.txt" {
134+ t.Errorf("deletions = %v", dels)
135+ }
136+}
137+
138+func TestRunEmptyBodyOmitted(t *testing.T) {
139+ dir, _ := newRepo(t)
140+ writeFile(t, dir, "x.txt", "x")
141+ var raw map[string]any
142+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143+ _ = json.NewDecoder(r.Body).Decode(&raw)
144+ _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"o"}}}}`))
145+ }))
146+ defer srv.Close()
147+ err := run(context.Background(), []string{"-repo", "o/r", "-branch", "main", "-api", srv.URL, "-message", "only headline"},
148+ env(map[string]string{"GITHUB_TOKEN": "t"}), &bytes.Buffer{}, dir)
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ msg := raw["variables"].(map[string]any)["input"].(map[string]any)["message"].(map[string]any)
153+ if _, ok := msg["body"]; ok {
154+ t.Errorf("body should be omitted: %v", msg)
155+ }
156+}
157+
158+func TestRunErrors(t *testing.T) {
159+ ok := env(map[string]string{"GITHUB_TOKEN": "t"})
160+ base := func(api string) []string {
161+ return []string{"-repo", "o/r", "-branch", "main", "-api", api, "-message", "m"}
162+ }
163+ serve := func(status int, body string) *httptest.Server {
164+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165+ w.WriteHeader(status)
166+ _, _ = w.Write([]byte(body))
167+ }))
168+ }
169+
170+ t.Run("missing token", func(t *testing.T) {
171+ dir, _ := newRepo(t)
172+ writeFile(t, dir, "x.txt", "x")
173+ err := run(context.Background(), base("http://127.0.0.1:1"), env(nil), &bytes.Buffer{}, dir)
174+ if err == nil || !strings.Contains(err.Error(), "GITHUB_TOKEN") {
175+ t.Errorf("err = %v", err)
176+ }
177+ })
178+ t.Run("missing flags", func(t *testing.T) {
179+ dir, _ := newRepo(t)
180+ err := run(context.Background(), []string{"-repo", "o/r"}, ok, &bytes.Buffer{}, dir)
181+ if err == nil {
182+ t.Error("expected error")
183+ }
184+ })
185+ t.Run("no changes", func(t *testing.T) {
186+ dir, _ := newRepo(t)
187+ called := false
188+ srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
189+ defer srv.Close()
190+ err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
191+ if err == nil || !strings.Contains(err.Error(), "no changes") {
192+ t.Errorf("err = %v", err)
193+ }
194+ if called {
195+ t.Error("API called despite no changes")
196+ }
197+ })
198+ t.Run("non-200", func(t *testing.T) {
199+ dir, _ := newRepo(t)
200+ writeFile(t, dir, "x.txt", "x")
201+ srv := serve(http.StatusUnauthorized, `{"message":"Bad credentials"}`)
202+ defer srv.Close()
203+ err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
204+ if err == nil || !strings.Contains(err.Error(), "401") {
205+ t.Errorf("err = %v", err)
206+ }
207+ })
208+ t.Run("graphql errors", func(t *testing.T) {
209+ dir, _ := newRepo(t)
210+ writeFile(t, dir, "x.txt", "x")
211+ srv := serve(http.StatusOK, `{"data":null,"errors":[{"message":"Expected head oid mismatch"}]}`)
212+ defer srv.Close()
213+ var out bytes.Buffer
214+ err := run(context.Background(), base(srv.URL), ok, &out, dir)
215+ if err == nil || !strings.Contains(err.Error(), "Expected head oid mismatch") {
216+ t.Errorf("err = %v", err)
217+ }
218+ if out.Len() != 0 {
219+ t.Errorf("stdout = %q", out.String())
220+ }
221+ })
222+ t.Run("no oid", func(t *testing.T) {
223+ dir, _ := newRepo(t)
224+ writeFile(t, dir, "x.txt", "x")
225+ srv := serve(http.StatusOK, `{"data":{}}`)
226+ defer srv.Close()
227+ if err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir); err == nil {
228+ t.Error("expected error")
229+ }
230+ })
231+}
@@ -0,0 +1,231 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/base64"
7+ "encoding/json"
8+ "net/http"
9+ "net/http/httptest"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+ "testing"
14+)
15+
16+func gitT(t *testing.T, dir string, args ...string) string {
17+ t.Helper()
18+ full := append([]string{
19+ "-c", "commit.gpgsign=false",
20+ "-c", "user.name=test", "-c", "user.email=test@example.com",
21+ }, args...)
22+ out, err := git(context.Background(), dir, full...)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ return out
27+}
28+
29+func writeFile(t *testing.T, dir, name, content string) {
30+ t.Helper()
31+ p := filepath.Join(dir, filepath.FromSlash(name))
32+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
33+ t.Fatal(err)
34+ }
35+ if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+}
39+
40+func newRepo(t *testing.T) (dir, head string) {
41+ t.Helper()
42+ dir = t.TempDir()
43+ gitT(t, dir, "init", "-q")
44+ writeFile(t, dir, "keep.txt", "keep\n")
45+ writeFile(t, dir, "modify.txt", "old\n")
46+ writeFile(t, dir, "delete.txt", "bye\n")
47+ writeFile(t, dir, "sub/rename-me.txt", "rename content that is long enough to be detected as a rename\n")
48+ gitT(t, dir, "add", "-A")
49+ gitT(t, dir, "commit", "-q", "-m", "init")
50+ return dir, strings.TrimSpace(gitT(t, dir, "rev-parse", "HEAD"))
51+}
52+
53+func env(m map[string]string) func(string) string {
54+ return func(k string) string { return m[k] }
55+}
56+
57+func TestRunRequestShape(t *testing.T) {
58+ dir, head := newRepo(t)
59+ writeFile(t, dir, "modify.txt", "new\n")
60+ writeFile(t, dir, "added/dir/new.bin", "\x00\x01binary")
61+ if err := os.Remove(filepath.Join(dir, "delete.txt")); err != nil {
62+ t.Fatal(err)
63+ }
64+ gitT(t, dir, "mv", "sub/rename-me.txt", "renamed.txt")
65+
66+ var gotAuth string
67+ var got request
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ gotAuth = r.Header.Get("Authorization")
70+ if r.Method != http.MethodPost {
71+ t.Errorf("method = %s", r.Method)
72+ }
73+ if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
74+ t.Error(err)
75+ }
76+ _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"abc123"}}}}`))
77+ }))
78+ defer srv.Close()
79+
80+ var out bytes.Buffer
81+ err := run(context.Background(), []string{
82+ "-repo", "o/r", "-branch", "main", "-api", srv.URL,
83+ "-message", "feat: headline\n\nbody line 1\nbody line 2\n",
84+ }, env(map[string]string{"GITHUB_TOKEN": "tok"}), &out, dir)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if out.String() != "abc123\n" {
89+ t.Errorf("stdout = %q", out.String())
90+ }
91+ if gotAuth != "Bearer tok" {
92+ t.Errorf("auth = %q", gotAuth)
93+ }
94+ if !strings.Contains(got.Query, "createCommitOnBranch") {
95+ t.Errorf("query = %q", got.Query)
96+ }
97+ in := got.Variables["input"]
98+ if in.Branch.RepositoryNameWithOwner != "o/r" || in.Branch.BranchName != "main" {
99+ t.Errorf("branch = %+v", in.Branch)
100+ }
101+ if in.ExpectedHeadOid != head {
102+ t.Errorf("expectedHeadOid = %q, want %q", in.ExpectedHeadOid, head)
103+ }
104+ if in.Message.Headline != "feat: headline" || in.Message.Body != "body line 1\nbody line 2" {
105+ t.Errorf("message = %+v", in.Message)
106+ }
107+
108+ adds := map[string]string{}
109+ for _, a := range in.FileChanges.Additions {
110+ b, err := base64.StdEncoding.DecodeString(a.Contents)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ adds[a.Path] = string(b)
115+ }
116+ wantAdds := map[string]string{
117+ "modify.txt": "new\n",
118+ "added/dir/new.bin": "\x00\x01binary",
119+ "renamed.txt": "rename content that is long enough to be detected as a rename\n",
120+ }
121+ if len(adds) != len(wantAdds) {
122+ t.Errorf("additions = %v", adds)
123+ }
124+ for p, c := range wantAdds {
125+ if adds[p] != c {
126+ t.Errorf("addition %s = %q, want %q", p, adds[p], c)
127+ }
128+ }
129+ var dels []string
130+ for _, d := range in.FileChanges.Deletions {
131+ dels = append(dels, d.Path)
132+ }
133+ if strings.Join(dels, ",") != "delete.txt,sub/rename-me.txt" {
134+ t.Errorf("deletions = %v", dels)
135+ }
136+}
137+
138+func TestRunEmptyBodyOmitted(t *testing.T) {
139+ dir, _ := newRepo(t)
140+ writeFile(t, dir, "x.txt", "x")
141+ var raw map[string]any
142+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143+ _ = json.NewDecoder(r.Body).Decode(&raw)
144+ _, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"o"}}}}`))
145+ }))
146+ defer srv.Close()
147+ err := run(context.Background(), []string{"-repo", "o/r", "-branch", "main", "-api", srv.URL, "-message", "only headline"},
148+ env(map[string]string{"GITHUB_TOKEN": "t"}), &bytes.Buffer{}, dir)
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ msg := raw["variables"].(map[string]any)["input"].(map[string]any)["message"].(map[string]any)
153+ if _, ok := msg["body"]; ok {
154+ t.Errorf("body should be omitted: %v", msg)
155+ }
156+}
157+
158+func TestRunErrors(t *testing.T) {
159+ ok := env(map[string]string{"GITHUB_TOKEN": "t"})
160+ base := func(api string) []string {
161+ return []string{"-repo", "o/r", "-branch", "main", "-api", api, "-message", "m"}
162+ }
163+ serve := func(status int, body string) *httptest.Server {
164+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165+ w.WriteHeader(status)
166+ _, _ = w.Write([]byte(body))
167+ }))
168+ }
169+
170+ t.Run("missing token", func(t *testing.T) {
171+ dir, _ := newRepo(t)
172+ writeFile(t, dir, "x.txt", "x")
173+ err := run(context.Background(), base("http://127.0.0.1:1"), env(nil), &bytes.Buffer{}, dir)
174+ if err == nil || !strings.Contains(err.Error(), "GITHUB_TOKEN") {
175+ t.Errorf("err = %v", err)
176+ }
177+ })
178+ t.Run("missing flags", func(t *testing.T) {
179+ dir, _ := newRepo(t)
180+ err := run(context.Background(), []string{"-repo", "o/r"}, ok, &bytes.Buffer{}, dir)
181+ if err == nil {
182+ t.Error("expected error")
183+ }
184+ })
185+ t.Run("no changes", func(t *testing.T) {
186+ dir, _ := newRepo(t)
187+ called := false
188+ srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
189+ defer srv.Close()
190+ err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
191+ if err == nil || !strings.Contains(err.Error(), "no changes") {
192+ t.Errorf("err = %v", err)
193+ }
194+ if called {
195+ t.Error("API called despite no changes")
196+ }
197+ })
198+ t.Run("non-200", func(t *testing.T) {
199+ dir, _ := newRepo(t)
200+ writeFile(t, dir, "x.txt", "x")
201+ srv := serve(http.StatusUnauthorized, `{"message":"Bad credentials"}`)
202+ defer srv.Close()
203+ err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
204+ if err == nil || !strings.Contains(err.Error(), "401") {
205+ t.Errorf("err = %v", err)
206+ }
207+ })
208+ t.Run("graphql errors", func(t *testing.T) {
209+ dir, _ := newRepo(t)
210+ writeFile(t, dir, "x.txt", "x")
211+ srv := serve(http.StatusOK, `{"data":null,"errors":[{"message":"Expected head oid mismatch"}]}`)
212+ defer srv.Close()
213+ var out bytes.Buffer
214+ err := run(context.Background(), base(srv.URL), ok, &out, dir)
215+ if err == nil || !strings.Contains(err.Error(), "Expected head oid mismatch") {
216+ t.Errorf("err = %v", err)
217+ }
218+ if out.Len() != 0 {
219+ t.Errorf("stdout = %q", out.String())
220+ }
221+ })
222+ t.Run("no oid", func(t *testing.T) {
223+ dir, _ := newRepo(t)
224+ writeFile(t, dir, "x.txt", "x")
225+ srv := serve(http.StatusOK, `{"data":{}}`)
226+ defer srv.Close()
227+ if err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir); err == nil {
228+ t.Error("expected error")
229+ }
230+ })
231+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 1

↑↓ moveenter openesc close