detached 5004296

main_test.go RawHistoryFolder

1package main
2
3import (
4	"bytes"
5	"context"
6	"encoding/base64"
7	"encoding/json"
8	"net/http"
9	"net/http/httptest"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"strings"
14	"testing"
15)
16
17// isolateGit makes git invocations hermetic: inherited GIT_* variables are
18// dropped and no user or system configuration is read.
19func isolateGit(t *testing.T) {
20	t.Helper()
21	if _, err := exec.LookPath("git"); err != nil {
22		t.Skip("git not installed")
23	}
24	for _, kv := range os.Environ() {
25		if k, _, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "GIT_") {
26			t.Setenv(k, "") // registers restoration of the original value
27			os.Unsetenv(k)
28		}
29	}
30	t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
31	t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
32}
33
34func gitT(t *testing.T, dir string, args ...string) string {
35	t.Helper()
36	full := append([]string{
37		"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
38		"-c", "user.name=test", "-c", "user.email=test@example.com",
39	}, args...)
40	out, err := git(context.Background(), dir, full...)
41	if err != nil {
42		t.Fatal(err)
43	}
44	return out
45}
46
47func writeFile(t *testing.T, dir, name, content string) {
48	t.Helper()
49	p := filepath.Join(dir, filepath.FromSlash(name))
50	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
51		t.Fatal(err)
52	}
53	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
54		t.Fatal(err)
55	}
56}
57
58func newRepo(t *testing.T) (dir, head string) {
59	t.Helper()
60	isolateGit(t)
61	dir = t.TempDir()
62	gitT(t, dir, "init", "-q")
63	writeFile(t, dir, "keep.txt", "keep\n")
64	writeFile(t, dir, "modify.txt", "old\n")
65	writeFile(t, dir, "delete.txt", "bye\n")
66	writeFile(t, dir, "sub/rename-me.txt", "rename content that is long enough to be detected as a rename\n")
67	gitT(t, dir, "add", "-A")
68	gitT(t, dir, "commit", "-q", "-m", "init")
69	return dir, strings.TrimSpace(gitT(t, dir, "rev-parse", "HEAD"))
70}
71
72func env(m map[string]string) func(string) string {
73	return func(k string) string { return m[k] }
74}
75
76func TestRunRequestShape(t *testing.T) {
77	dir, head := newRepo(t)
78	writeFile(t, dir, "modify.txt", "new\n")
79	writeFile(t, dir, "added/dir/new.bin", "\x00\x01binary")
80	if err := os.Remove(filepath.Join(dir, "delete.txt")); err != nil {
81		t.Fatal(err)
82	}
83	gitT(t, dir, "mv", "sub/rename-me.txt", "renamed.txt")
84
85	var gotAuth string
86	var got request
87	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88		gotAuth = r.Header.Get("Authorization")
89		if r.Method != http.MethodPost {
90			t.Errorf("method = %s", r.Method)
91		}
92		if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
93			t.Error(err)
94		}
95		_, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"abc123"}}}}`))
96	}))
97	defer srv.Close()
98
99	var out bytes.Buffer
100	err := run(context.Background(), []string{
101		"-repo", "o/r", "-branch", "main", "-api", srv.URL,
102		"-message", "feat: headline\n\nbody line 1\nbody line 2\n",
103	}, env(map[string]string{"GITHUB_TOKEN": "tok"}), &out, dir)
104	if err != nil {
105		t.Fatal(err)
106	}
107	if out.String() != "abc123\n" {
108		t.Errorf("stdout = %q", out.String())
109	}
110	if gotAuth != "Bearer tok" {
111		t.Errorf("auth = %q", gotAuth)
112	}
113	if !strings.Contains(got.Query, "createCommitOnBranch") {
114		t.Errorf("query = %q", got.Query)
115	}
116	in := got.Variables["input"]
117	if in.Branch.RepositoryNameWithOwner != "o/r" || in.Branch.BranchName != "main" {
118		t.Errorf("branch = %+v", in.Branch)
119	}
120	if in.ExpectedHeadOid != head {
121		t.Errorf("expectedHeadOid = %q, want %q", in.ExpectedHeadOid, head)
122	}
123	if in.Message.Headline != "feat: headline" || in.Message.Body != "body line 1\nbody line 2" {
124		t.Errorf("message = %+v", in.Message)
125	}
126
127	adds := map[string]string{}
128	for _, a := range in.FileChanges.Additions {
129		b, err := base64.StdEncoding.DecodeString(a.Contents)
130		if err != nil {
131			t.Fatal(err)
132		}
133		adds[a.Path] = string(b)
134	}
135	wantAdds := map[string]string{
136		"modify.txt":        "new\n",
137		"added/dir/new.bin": "\x00\x01binary",
138		"renamed.txt":       "rename content that is long enough to be detected as a rename\n",
139	}
140	if len(adds) != len(wantAdds) {
141		t.Errorf("additions = %v", adds)
142	}
143	for p, c := range wantAdds {
144		if adds[p] != c {
145			t.Errorf("addition %s = %q, want %q", p, adds[p], c)
146		}
147	}
148	var dels []string
149	for _, d := range in.FileChanges.Deletions {
150		dels = append(dels, d.Path)
151	}
152	if strings.Join(dels, ",") != "delete.txt,sub/rename-me.txt" {
153		t.Errorf("deletions = %v", dels)
154	}
155}
156
157func TestRunEmptyBodyOmitted(t *testing.T) {
158	dir, _ := newRepo(t)
159	writeFile(t, dir, "x.txt", "x")
160	var raw map[string]any
161	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
162		_ = json.NewDecoder(r.Body).Decode(&raw)
163		_, _ = w.Write([]byte(`{"data":{"createCommitOnBranch":{"commit":{"oid":"o"}}}}`))
164	}))
165	defer srv.Close()
166	err := run(context.Background(), []string{"-repo", "o/r", "-branch", "main", "-api", srv.URL, "-message", "only headline"},
167		env(map[string]string{"GITHUB_TOKEN": "t"}), &bytes.Buffer{}, dir)
168	if err != nil {
169		t.Fatal(err)
170	}
171	msg := raw["variables"].(map[string]any)["input"].(map[string]any)["message"].(map[string]any)
172	if _, ok := msg["body"]; ok {
173		t.Errorf("body should be omitted: %v", msg)
174	}
175}
176
177func TestRunErrors(t *testing.T) {
178	ok := env(map[string]string{"GITHUB_TOKEN": "t"})
179	base := func(api string) []string {
180		return []string{"-repo", "o/r", "-branch", "main", "-api", api, "-message", "m"}
181	}
182	serve := func(status int, body string) *httptest.Server {
183		return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184			w.WriteHeader(status)
185			_, _ = w.Write([]byte(body))
186		}))
187	}
188
189	t.Run("missing token", func(t *testing.T) {
190		dir, _ := newRepo(t)
191		writeFile(t, dir, "x.txt", "x")
192		err := run(context.Background(), base("http://127.0.0.1:1"), env(nil), &bytes.Buffer{}, dir)
193		if err == nil || !strings.Contains(err.Error(), "GITHUB_TOKEN") {
194			t.Errorf("err = %v", err)
195		}
196	})
197	t.Run("missing flags", func(t *testing.T) {
198		dir, _ := newRepo(t)
199		err := run(context.Background(), []string{"-repo", "o/r"}, ok, &bytes.Buffer{}, dir)
200		if err == nil {
201			t.Error("expected error")
202		}
203	})
204	t.Run("no changes", func(t *testing.T) {
205		dir, _ := newRepo(t)
206		called := false
207		srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
208		defer srv.Close()
209		err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
210		if err == nil || !strings.Contains(err.Error(), "no changes") {
211			t.Errorf("err = %v", err)
212		}
213		if called {
214			t.Error("API called despite no changes")
215		}
216	})
217	t.Run("symlink", func(t *testing.T) {
218		dir, _ := newRepo(t)
219		if err := os.Symlink("keep.txt", filepath.Join(dir, "link")); err != nil {
220			t.Skipf("symlinks unsupported: %v", err)
221		}
222		called := false
223		srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
224		defer srv.Close()
225		err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
226		if err == nil || !strings.Contains(err.Error(), "symbolic link") {
227			t.Errorf("err = %v", err)
228		}
229		if called {
230			t.Error("API called despite symlink")
231		}
232	})
233	t.Run("non-200", func(t *testing.T) {
234		dir, _ := newRepo(t)
235		writeFile(t, dir, "x.txt", "x")
236		srv := serve(http.StatusUnauthorized, `{"message":"Bad credentials"}`)
237		defer srv.Close()
238		err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
239		if err == nil || !strings.Contains(err.Error(), "401") {
240			t.Errorf("err = %v", err)
241		}
242	})
243	t.Run("graphql errors", func(t *testing.T) {
244		dir, _ := newRepo(t)
245		writeFile(t, dir, "x.txt", "x")
246		srv := serve(http.StatusOK, `{"data":null,"errors":[{"message":"Expected head oid mismatch"}]}`)
247		defer srv.Close()
248		var out bytes.Buffer
249		err := run(context.Background(), base(srv.URL), ok, &out, dir)
250		if err == nil || !strings.Contains(err.Error(), "Expected head oid mismatch") {
251			t.Errorf("err = %v", err)
252		}
253		if out.Len() != 0 {
254			t.Errorf("stdout = %q", out.String())
255		}
256	})
257	t.Run("no oid", func(t *testing.T) {
258		dir, _ := newRepo(t)
259		writeFile(t, dir, "x.txt", "x")
260		srv := serve(http.StatusOK, `{"data":{}}`)
261		defer srv.Close()
262		if err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir); err == nil {
263			t.Error("expected error")
264		}
265	})
266}

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