detached 5004296

main.go RawHistoryFolder

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.
10package main
11
12import (
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
30const defaultAPI = "https://api.github.com/graphql"
31
32const mutation = `mutation($input: CreateCommitOnBranchInput!) {
33  createCommitOnBranch(input: $input) {
34    commit { oid }
35  }
36}`
37
38type branchInput struct {
39	RepositoryNameWithOwner string `json:"repositoryNameWithOwner"`
40	BranchName              string `json:"branchName"`
41}
42
43type messageInput struct {
44	Headline string `json:"headline"`
45	Body     string `json:"body,omitempty"`
46}
47
48type addition struct {
49	Path     string `json:"path"`
50	Contents string `json:"contents"`
51}
52
53type deletion struct {
54	Path string `json:"path"`
55}
56
57type fileChanges struct {
58	Additions []addition `json:"additions"`
59	Deletions []deletion `json:"deletions"`
60}
61
62type commitInput struct {
63	Branch          branchInput  `json:"branch"`
64	ExpectedHeadOid string       `json:"expectedHeadOid"`
65	Message         messageInput `json:"message"`
66	FileChanges     fileChanges  `json:"fileChanges"`
67}
68
69type request struct {
70	Query     string                 `json:"query"`
71	Variables map[string]commitInput `json:"variables"`
72}
73
74type 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
87func 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
94func 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
154func 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.
170func 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.Mode()&os.ModeSymlink != 0 {
204			return fileChanges{}, fmt.Errorf("%s is a symbolic link, which cannot be committed through the API", p)
205		}
206		if info.IsDir() {
207			continue // e.g. a submodule; not representable
208		}
209		data, err := os.ReadFile(full)
210		if err != nil {
211			return fileChanges{}, err
212		}
213		delete(dels, p)
214		changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)})
215	}
216	for _, p := range sortedKeys(dels) {
217		changes.Deletions = append(changes.Deletions, deletion{Path: p})
218	}
219	return changes, nil
220}
221
222func sortedKeys(m map[string]bool) []string {
223	keys := make([]string, 0, len(m))
224	for k := range m {
225		keys = append(keys, k)
226	}
227	sort.Strings(keys)
228	return keys
229}
230
231func splitMessage(msg string) (headline, body string) {
232	msg = strings.TrimSpace(msg)
233	headline, body, _ = strings.Cut(msg, "\n")
234	return strings.TrimSpace(headline), strings.TrimSpace(body)
235}
236
237func post(ctx context.Context, api, token string, payload []byte) (string, error) {
238	ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
239	defer cancel()
240	req, err := http.NewRequestWithContext(ctx, http.MethodPost, api, bytes.NewReader(payload))
241	if err != nil {
242		return "", err
243	}
244	req.Header.Set("Authorization", "Bearer "+token)
245	req.Header.Set("Content-Type", "application/json")
246	req.Header.Set("User-Agent", "wlbcommit")
247	resp, err := http.DefaultClient.Do(req)
248	if err != nil {
249		return "", err
250	}
251	defer resp.Body.Close()
252	data, err := io.ReadAll(resp.Body)
253	if err != nil {
254		return "", err
255	}
256	if resp.StatusCode != http.StatusOK {
257		return "", fmt.Errorf("GitHub API returned %s: %s", resp.Status, truncate(string(data)))
258	}
259	var out response
260	if err := json.Unmarshal(data, &out); err != nil {
261		return "", fmt.Errorf("decode response: %w", err)
262	}
263	if len(out.Errors) > 0 {
264		msgs := make([]string, len(out.Errors))
265		for i, e := range out.Errors {
266			msgs[i] = e.Message
267		}
268		return "", fmt.Errorf("GraphQL errors: %s", strings.Join(msgs, "; "))
269	}
270	oid := out.Data.CreateCommitOnBranch.Commit.Oid
271	if oid == "" {
272		return "", errors.New("response contains no commit oid")
273	}
274	return oid, nil
275}
276
277func truncate(s string) string {
278	const max = 500
279	if len(s) > max {
280		return s[:max] + "..."
281	}
282	return s
283}

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