Single commit page showing message, metadata and full diff.

barerepo / cli / 7b321bc
barerepo · 1mo · 10 files · +797 -0 · signed
barerepo
@@ -0,0 +1,7 @@
1+ [repo]
2+ visibility = "public"
3+ description = "an optional shortcut for the git commands"
4+
5+ [access]
6+ allow_force_push = ["master"]
7+ require_signed_commits = true
@@ -0,0 +1,4 @@
1+ /br
2+ .DS_Store
3+ go.work
4+ go.work.sum
@@ -0,0 +1,21 @@
1+ MIT License
2+
3+ Copyright (c) 2026 BareRepo
4+
5+ Permission is hereby granted, free of charge, to any person obtaining a copy
6+ of this software and associated documentation files (the "Software"), to deal
7+ in the Software without restriction, including without limitation the rights
8+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+ copies of the Software, and to permit persons to whom the Software is
10+ furnished to do so, subject to the following conditions:
11+
12+ The above copyright notice and this permission notice shall be included in all
13+ copies or substantial portions of the Software.
14+
15+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+ SOFTWARE.
@@ -0,0 +1,47 @@
1+ # br
2+
3+ A shortcut for the git commands a barerepo already understands.
4+
5+ go build ./cmd/br
6+
7+ One binary, nothing outside the standard library. It needs `git` on your
8+ PATH, and `ssh-keygen` for `br auth`.
9+
10+ ## Commands
11+
12+ | Command | What it runs |
13+ |---|---|
14+ | `br auth <name> [server]` | `ssh-keygen -Y sign -n barerepo-auth`, then prints a one-time sign in link |
15+ | `br propose [remote]` | `git push <remote> HEAD:refs/proposals/new` |
16+ | `br fetch <n>` | `git fetch origin refs/proposals/<n>:prop-<n>` |
17+ | `br notes` | `git fetch origin "refs/notes/*:refs/notes/*"` |
18+ | `br threads` | `git for-each-ref refs/notes/threads/` |
19+ | `br thread <n>` | `git log --show-notes=threads/<n>` |
20+ | `br reply <n> -m <msg>` | `git notes --ref=threads/<n> append`, then a push |
21+
22+ Nothing here is required. Every command prints the git it runs before it
23+ runs it, so reading the output teaches the command that replaces it. You
24+ can delete this program and lose access to nothing.
25+
26+ The server is read from `origin`. An ssh remote in either spelling maps to
27+ https on the same host, because the ssh port says nothing about the web
28+ port. Pass the server yourself to override that.
29+
30+ ## Replies that collide
31+
32+ Two people replying at once is ordinary, so `br reply` handles it. On a
33+ rejected push it takes what the server holds, writes your reply after it,
34+ and pushes again. A union merge is the obvious route and the wrong one: it
35+ joins two notes with no record separator, which welds two comments into
36+ one. Anything your copy held that the server has not seen is parked at
37+ `refs/notes/before-reply/threads/<n>` rather than reset away.
38+
39+ ## Running the cross repo tests
40+
41+ The tests that drive a real server live behind a build tag, because they need
42+ all three checkouts side by side. Without the tag this repository builds and
43+ tests on its own and depends on nothing.
44+
45+ git clone https://barerepo.com/barerepo/server ../server
46+ printf 'go 1.24\n\nuse (\n\t./cli\n\t./runner\n\t./server\n)\n' > ../go.work
47+ go test -tags crossrepo ./...
@@ -0,0 +1,388 @@
1+ package main
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "net/url"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "strconv"
16+ "strings"
17+ )
18+
19+ func gitShow(args ...string) {
20+ fmt.Fprintln(os.Stderr, "+ git "+strings.Join(args, " "))
21+ }
22+
23+ func gitRun(ctx context.Context, args ...string) error {
24+ gitShow(args...)
25+ cmd := exec.CommandContext(ctx, "git", args...)
26+ cmd.Stdin = os.Stdin
27+ cmd.Stdout = os.Stdout
28+ cmd.Stderr = os.Stderr
29+ return cmd.Run()
30+ }
31+
32+ func gitQuiet(ctx context.Context, args ...string) (string, error) {
33+ var out, errb bytes.Buffer
34+ cmd := exec.CommandContext(ctx, "git", args...)
35+ cmd.Stdout = &out
36+ cmd.Stderr = &errb
37+ if err := cmd.Run(); err != nil {
38+ msg := strings.TrimSpace(errb.String())
39+ if msg == "" {
40+ msg = err.Error()
41+ }
42+ return out.String(), errors.New(msg)
43+ }
44+ return out.String(), nil
45+ }
46+
47+ func inWorkTree(ctx context.Context) error {
48+ if _, err := gitQuiet(ctx, "rev-parse", "--git-dir"); err != nil {
49+ return errors.New("this is not a git repository. cd into one first")
50+ }
51+ return nil
52+ }
53+
54+ func remoteOr(args []string) string {
55+ if len(args) > 0 && args[0] != "" {
56+ return args[0]
57+ }
58+ return "origin"
59+ }
60+
61+ func threadNumber(args []string, what string) (int, error) {
62+ if len(args) == 0 {
63+ return 0, fmt.Errorf("which %s? give its number, for example: forge %s 47", what, what)
64+ }
65+ n, err := strconv.Atoi(strings.TrimPrefix(args[0], "#"))
66+ if err != nil || n < 1 {
67+ return 0, fmt.Errorf("%q is not a %s number", args[0], what)
68+ }
69+ return n, nil
70+ }
71+
72+ func cmdPropose(ctx context.Context, args []string) error {
73+ if err := inWorkTree(ctx); err != nil {
74+ return err
75+ }
76+ return gitRun(ctx, "push", remoteOr(args), "HEAD:refs/proposals/new")
77+ }
78+
79+ func cmdFetchProposal(ctx context.Context, args []string) error {
80+ if err := inWorkTree(ctx); err != nil {
81+ return err
82+ }
83+ n, err := threadNumber(args, "proposal")
84+ if err != nil {
85+ return err
86+ }
87+ remote := remoteOr(args[1:])
88+ spec := fmt.Sprintf("refs/proposals/%d:prop-%d", n, n)
89+ if err := gitRun(ctx, "fetch", remote, spec); err != nil {
90+ return err
91+ }
92+ fmt.Printf("proposal %d is now the local branch prop-%d\n", n, n)
93+ fmt.Printf(" git log master..prop-%d\n", n)
94+ fmt.Printf(" git diff master...prop-%d\n", n)
95+ return nil
96+ }
97+
98+ func cmdNotes(ctx context.Context, args []string) error {
99+ if err := inWorkTree(ctx); err != nil {
100+ return err
101+ }
102+ remote := remoteOr(args)
103+ if err := gitRun(ctx, "fetch", remote, "refs/notes/*:refs/notes/*"); err == nil {
104+ return nil
105+ }
106+ fmt.Fprintln(os.Stderr, "\nyou have replies of your own here. merging instead of overwriting.")
107+ if err := gitRun(ctx, "fetch", remote, "+refs/notes/*:refs/notes/incoming/*"); err != nil {
108+ return err
109+ }
110+ out, err := gitQuiet(ctx, "for-each-ref", "--format=%(refname:lstrip=3)", "refs/notes/incoming/")
111+ if err != nil {
112+ return err
113+ }
114+ for _, name := range strings.Fields(out) {
115+ if err := gitRun(ctx, "notes", "--ref="+name, "merge", "-s", "union", "refs/notes/incoming/"+name); err != nil {
116+ return err
117+ }
118+ }
119+ for _, name := range strings.Fields(out) {
120+ if err := gitRun(ctx, "update-ref", "-d", "refs/notes/incoming/"+name); err != nil {
121+ return err
122+ }
123+ }
124+ return nil
125+ }
126+
127+ func cmdThreads(ctx context.Context, args []string) error {
128+ if err := inWorkTree(ctx); err != nil {
129+ return err
130+ }
131+ format := "%(refname:lstrip=3)"
132+ gitShow("for-each-ref", "--format="+format, "refs/notes/threads/")
133+ out, err := gitQuiet(ctx, "for-each-ref", "--format="+format, "refs/notes/threads/")
134+ if err != nil {
135+ return err
136+ }
137+ names := strings.Fields(out)
138+ if len(names) == 0 {
139+ fmt.Println("no threads here yet. run forge notes to fetch them.")
140+ return nil
141+ }
142+ for _, name := range names {
143+ fmt.Printf("%s\tgit log --show-notes=threads/%s\n", name, name)
144+ }
145+ fmt.Fprintln(os.Stderr, "\nclosed threads look the same here. the closed list lives on the server.")
146+ return nil
147+ }
148+
149+ func cmdThread(ctx context.Context, args []string) error {
150+ if err := inWorkTree(ctx); err != nil {
151+ return err
152+ }
153+ n, err := threadNumber(args, "thread")
154+ if err != nil {
155+ return err
156+ }
157+ ref := fmt.Sprintf("refs/notes/threads/%d", n)
158+ if _, err := gitQuiet(ctx, "rev-parse", "--verify", "--quiet", ref); err != nil {
159+ return fmt.Errorf("thread %d is not here. run forge notes to fetch it", n)
160+ }
161+ return gitRun(ctx, "log", fmt.Sprintf("--show-notes=threads/%d", n))
162+ }
163+
164+ func cmdReply(ctx context.Context, args []string) error {
165+ if err := inWorkTree(ctx); err != nil {
166+ return err
167+ }
168+ fs := flag.NewFlagSet("reply", flag.ContinueOnError)
169+ fs.SetOutput(os.Stderr)
170+ msg := fs.String("m", "", "the comment to append, or leave it out and type on stdin")
171+ rest, err := splitNumberFirst(args, fs)
172+ if err != nil {
173+ return err
174+ }
175+ n, err := threadNumber(rest, "thread")
176+ if err != nil {
177+ return err
178+ }
179+ body := strings.TrimSpace(*msg)
180+ if body == "" {
181+ typed, err := io.ReadAll(os.Stdin)
182+ if err != nil {
183+ return err
184+ }
185+ body = strings.TrimSpace(string(typed))
186+ }
187+ if body == "" {
188+ return errors.New("an empty reply says nothing. use -m, or type the comment on stdin")
189+ }
190+
191+ ref := fmt.Sprintf("threads/%d", n)
192+ full := "refs/notes/" + ref
193+ base, _ := gitQuiet(ctx, "rev-parse", "--verify", "--quiet", full)
194+ base = strings.TrimSpace(base)
195+ if err := gitRun(ctx, "notes", "--ref="+ref, "append", "-m", body); err != nil {
196+ return err
197+ }
198+ remote := remoteOr(nil)
199+ if err := gitRun(ctx, "push", remote, full); err == nil {
200+ return nil
201+ }
202+
203+ fmt.Fprintln(os.Stderr, "\nsomebody replied first. taking their reply, then writing yours after it.")
204+ if err := gitRun(ctx, "fetch", remote, full); err != nil {
205+ return err
206+ }
207+ head, err := gitQuiet(ctx, "rev-parse", "FETCH_HEAD")
208+ if err != nil {
209+ return err
210+ }
211+ head = strings.TrimSpace(head)
212+ if base != "" {
213+ if _, err := gitQuiet(ctx, "merge-base", "--is-ancestor", base, head); err != nil {
214+ saved := "refs/notes/before-reply/" + ref
215+ if err := gitRun(ctx, "update-ref", saved, base); err != nil {
216+ return err
217+ }
218+ fmt.Fprintln(os.Stderr, "your copy held replies the server has not. it is kept at "+saved)
219+ }
220+ }
221+ if err := gitRun(ctx, "update-ref", full, head); err != nil {
222+ return err
223+ }
224+ if err := gitRun(ctx, "notes", "--ref="+ref, "append", "-m", body); err != nil {
225+ return err
226+ }
227+ return gitRun(ctx, "push", remote, full)
228+ }
229+
230+ func splitNumberFirst(args []string, fs *flag.FlagSet) ([]string, error) {
231+ var plain, flags []string
232+ for i := 0; i < len(args); i++ {
233+ if strings.HasPrefix(args[i], "-") {
234+ flags = append(flags, args[i])
235+ if args[i] == "-m" && i+1 < len(args) {
236+ i++
237+ flags = append(flags, args[i])
238+ }
239+ continue
240+ }
241+ plain = append(plain, args[i])
242+ }
243+ if err := fs.Parse(flags); err != nil {
244+ return nil, err
245+ }
246+ return plain, nil
247+ }
248+
249+ func cmdAuth(ctx context.Context, args []string) error {
250+ if len(args) == 0 {
251+ return errors.New("which account? for example: forge auth john")
252+ }
253+ name := args[0]
254+ base, err := serverURL(ctx, args[1:])
255+ if err != nil {
256+ return err
257+ }
258+ key, err := signingKey()
259+ if err != nil {
260+ return err
261+ }
262+
263+ nonce, err := ask(ctx, base+"/auth/challenge", url.Values{"name": {name}})
264+ if err != nil {
265+ return err
266+ }
267+ fmt.Fprintf(os.Stderr, "+ printf '%%s' '%s' | ssh-keygen -Y sign -f %s -n barerepo-auth -\n", nonce, key)
268+ sig, err := sign(ctx, key, nonce)
269+ if err != nil {
270+ return err
271+ }
272+ answer, err := ask(ctx, base+"/auth/verify", url.Values{
273+ "name": {name}, "nonce": {nonce}, "signature": {sig},
274+ })
275+ if err != nil {
276+ return err
277+ }
278+ who, link, ok := strings.Cut(answer, "\n")
279+ if !ok {
280+ return errors.New(answer)
281+ }
282+ fmt.Fprintln(os.Stderr, "signed in as "+strings.TrimSpace(who))
283+ fmt.Fprint(os.Stderr, "\nopen this to sign in the browser. it works once:\n\n")
284+ fmt.Println(strings.TrimSpace(link))
285+ return nil
286+ }
287+
288+ func serverURL(ctx context.Context, args []string) (string, error) {
289+ raw := ""
290+ if len(args) > 0 && args[0] != "" {
291+ raw = args[0]
292+ } else {
293+ out, err := gitQuiet(ctx, "remote", "get-url", "origin")
294+ if err != nil {
295+ return "", errors.New("which forge? cd into a clone, or name it: forge auth john https://barerepo.example")
296+ }
297+ raw = strings.TrimSpace(out)
298+ }
299+ return webBase(raw)
300+ }
301+
302+ func webBase(raw string) (string, error) {
303+ raw = strings.TrimSpace(raw)
304+ if raw == "" {
305+ return "", errors.New("that remote names no server")
306+ }
307+ if !strings.Contains(raw, "://") {
308+ host, _, ok := strings.Cut(raw, ":")
309+ if !ok {
310+ return "", fmt.Errorf("%q is not a url forge can reach", raw)
311+ }
312+ if _, after, at := strings.Cut(host, "@"); at {
313+ host = after
314+ }
315+ if host == "" {
316+ return "", fmt.Errorf("%q is not a url forge can reach", raw)
317+ }
318+ return "https://" + host, nil
319+ }
320+ u, err := url.Parse(raw)
321+ if err != nil || u.Hostname() == "" {
322+ return "", fmt.Errorf("%q is not a url forge can reach", raw)
323+ }
324+ if u.Scheme == "ssh" {
325+ return "https://" + u.Hostname(), nil
326+ }
327+ u.User = nil
328+ u.RawQuery = ""
329+ u.Fragment = ""
330+ u.Path = ""
331+ return strings.TrimSuffix(u.String(), "/"), nil
332+ }
333+
334+ func signingKey() (string, error) {
335+ home, err := os.UserHomeDir()
336+ if err != nil {
337+ return "", err
338+ }
339+ for _, name := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} {
340+ path := filepath.Join(home, ".ssh", name)
341+ if _, err := os.Stat(path); err == nil {
342+ return path, nil
343+ }
344+ }
345+ return "", errors.New("no ssh key in ~/.ssh. make one with: ssh-keygen -t ed25519")
346+ }
347+
348+ func sign(ctx context.Context, key, nonce string) (string, error) {
349+ var out, errb bytes.Buffer
350+ cmd := exec.CommandContext(ctx, "ssh-keygen", "-Y", "sign", "-f", key, "-n", "barerepo-auth", "-")
351+ cmd.Stdin = strings.NewReader(nonce)
352+ cmd.Stdout = &out
353+ cmd.Stderr = &errb
354+ if err := cmd.Run(); err != nil {
355+ msg := strings.TrimSpace(errb.String())
356+ if msg == "" {
357+ msg = err.Error()
358+ }
359+ return "", errors.New("ssh-keygen: " + msg)
360+ }
361+ return out.String(), nil
362+ }
363+
364+ func ask(ctx context.Context, endpoint string, form url.Values) (string, error) {
365+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
366+ if err != nil {
367+ return "", err
368+ }
369+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
370+ req.Header.Set("Accept", "text/plain")
371+ resp, err := http.DefaultClient.Do(req)
372+ if err != nil {
373+ return "", err
374+ }
375+ defer resp.Body.Close()
376+ body, err := io.ReadAll(resp.Body)
377+ if err != nil {
378+ return "", err
379+ }
380+ text := strings.TrimSpace(string(body))
381+ if resp.StatusCode != http.StatusOK {
382+ if text == "" {
383+ text = resp.Status
384+ }
385+ return "", errors.New(text)
386+ }
387+ return text, nil
388+ }
@@ -0,0 +1,26 @@
1+ package main
2+
3+ import "testing"
4+
5+ func TestWebBaseFindsTheServerBehindEveryRemoteForm(t *testing.T) {
6+ good := [][2]string{
7+ {"https://barerepo.example/john/johnbot", "https://barerepo.example"},
8+ {"https://barerepo.example/john/johnbot.git", "https://barerepo.example"},
9+ {"http://x:gt_live_abc@127.0.0.1:3999/john/johnbot", "http://127.0.0.1:3999"},
10+ {"git@barerepo.example:john/johnbot.git", "https://barerepo.example"},
11+ {"barerepo.example:john/johnbot", "https://barerepo.example"},
12+ {"ssh://git@barerepo.example/john/johnbot", "https://barerepo.example"},
13+ {"ssh://git@barerepo.example:2222/john/johnbot", "https://barerepo.example"},
14+ }
15+ for _, c := range good {
16+ got, err := webBase(c[0])
17+ if err != nil || got != c[1] {
18+ t.Errorf("webBase(%q) = %q, %v; want %q", c[0], got, err, c[1])
19+ }
20+ }
21+ for _, bad := range []string{"", " ", "johnbot", "https:///john/johnbot"} {
22+ if got, err := webBase(bad); err == nil {
23+ t.Errorf("webBase(%q) = %q, want an error", bad, got)
24+ }
25+ }
26+ }
@@ -0,0 +1,67 @@
1+ package main
2+
3+ import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // Every comment in this tree is one line, which is a standing instruction, so a test holds it.
11+ func TestEveryCommentIsOneLine(t *testing.T) {
12+ root, err := filepath.Abs("../..")
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ skip := map[string]bool{".git": true, "plans": true, "misc": true, ".playwright-mcp": true}
17+ err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
18+ if err != nil {
19+ return nil
20+ }
21+ if d.IsDir() {
22+ if skip[d.Name()] {
23+ return filepath.SkipDir
24+ }
25+ return nil
26+ }
27+ switch filepath.Ext(path) {
28+ case ".go", ".js", ".css":
29+ default:
30+ return nil
31+ }
32+ body, err := os.ReadFile(path)
33+ if err != nil {
34+ return nil
35+ }
36+ where := strings.TrimPrefix(path, root+string(filepath.Separator))
37+ run, start := 0, 0
38+ for i, line := range strings.Split(string(body), "\n") {
39+ if strings.HasPrefix(strings.TrimSpace(line), "//") {
40+ if run == 0 {
41+ start = i + 1
42+ }
43+ run++
44+ continue
45+ }
46+ if run > 1 {
47+ t.Errorf("%s:%d has a %d line comment block, and every comment here is one line", where, start, run)
48+ }
49+ run = 0
50+ }
51+ if run > 1 {
52+ t.Errorf("%s:%d has a %d line comment block, and every comment here is one line", where, start, run)
53+ }
54+ if strings.Contains(string(body), "/*") && filepath.Ext(path) == ".css" {
55+ for i, line := range strings.Split(string(body), "\n") {
56+ open := strings.Contains(line, "/*")
57+ if open && !strings.Contains(line[strings.Index(line, "/*"):], "*/") {
58+ t.Errorf("%s:%d opens a css comment it does not close on the same line", where, i+1)
59+ }
60+ }
61+ }
62+ return nil
63+ })
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ }
@@ -0,0 +1,57 @@
1+ package main
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os"
7+ )
8+
9+ const usage = `br
10+
11+ br auth <name> [server] sign in, prints a link to open
12+ br propose [remote] push HEAD to refs/proposals/new
13+ br fetch <n> fetch proposal n to the branch prop-n
14+ br notes fetch every note ref
15+ br threads list the threads you have fetched
16+ br thread <n> print thread n
17+ br reply <n> -m <msg> append a comment and push the note
18+
19+ Every command prints the git it runs before it runs it. Nothing here is
20+ required: each one is a shortcut for a command you can type yourself.
21+ `
22+
23+ func main() {
24+ if err := run(os.Args[1:]); err != nil {
25+ fmt.Fprintln(os.Stderr, "br: "+err.Error())
26+ os.Exit(1)
27+ }
28+ }
29+
30+ func run(args []string) error {
31+ if len(args) == 0 {
32+ fmt.Print(usage)
33+ return nil
34+ }
35+ ctx := context.Background()
36+ switch args[0] {
37+ case "auth":
38+ return cmdAuth(ctx, args[1:])
39+ case "propose":
40+ return cmdPropose(ctx, args[1:])
41+ case "fetch":
42+ return cmdFetchProposal(ctx, args[1:])
43+ case "notes":
44+ return cmdNotes(ctx, args[1:])
45+ case "threads":
46+ return cmdThreads(ctx, args[1:])
47+ case "thread":
48+ return cmdThread(ctx, args[1:])
49+ case "reply":
50+ return cmdReply(ctx, args[1:])
51+ case "help", "-h", "--help":
52+ fmt.Print(usage)
53+ return nil
54+ default:
55+ return fmt.Errorf("%q is not a br command. run br with no arguments to see them", args[0])
56+ }
57+ }
@@ -0,0 +1,177 @@
1+ //go:build crossrepo
2+
3+ package cli_test
4+
5+ import (
6+ "context"
7+ "fmt"
8+ "net/http"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "testing"
14+
15+ "github.com/barerepo/server/testserver"
16+ )
17+
18+ var binary string
19+
20+ func TestMain(m *testing.M) {
21+ dir, err := os.MkdirTemp("", "br-e2e-")
22+ if err != nil {
23+ panic(err)
24+ }
25+ binary = filepath.Join(dir, "br")
26+ if out, err := exec.Command("go", "build", "-o", binary, "./cmd/br").CombinedOutput(); err != nil {
27+ fmt.Fprintf(os.Stderr, "building br: %v\n%s", err, out)
28+ os.Exit(1)
29+ }
30+ code := m.Run()
31+ os.RemoveAll(dir)
32+ os.Exit(code)
33+ }
34+
35+ func needs(t *testing.T, names ...string) {
36+ t.Helper()
37+ for _, name := range names {
38+ if _, err := exec.LookPath(name); err != nil {
39+ t.Skipf("%s is not installed", name)
40+ }
41+ }
42+ }
43+
44+ func run(t *testing.T, dir, name string, args ...string) string {
45+ t.Helper()
46+ out, err := try(t, dir, name, args...)
47+ if err != nil {
48+ t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out)
49+ }
50+ return out
51+ }
52+
53+ func try(t *testing.T, dir, name string, args ...string) (string, error) {
54+ t.Helper()
55+ cmd := exec.Command(name, args...)
56+ cmd.Dir = dir
57+ cmd.Env = append(os.Environ(),
58+ "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x",
59+ "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x",
60+ "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_COUNT=1",
61+ "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=")
62+ out, err := cmd.CombinedOutput()
63+ return strings.TrimSpace(string(out)), err
64+ }
65+
66+ // br talks to a real barerepo, so only a test that runs both halves catches them drifting apart.
67+ func TestBrDrivesARealServer(t *testing.T) {
68+ needs(t, "git", "ssh-keygen", "go")
69+ in := testserver.New(t)
70+ john := in.Account(t, "john")
71+ in.Repo(t, john, "john", "johnbot")
72+
73+ work := filepath.Join(t.TempDir(), "c")
74+ run(t, "", "git", "clone", "-q", in.URLFor(john, "/john/johnbot"), work)
75+ run(t, work, "git", "checkout", "-q", "-b", "my-fix")
76+ if err := os.WriteFile(filepath.Join(work, "config.go"),
77+ []byte("package main\n\nfunc main() {}\n"), 0o600); err != nil {
78+ t.Fatal(err)
79+ }
80+ run(t, work, "git", "commit", "-qam", "fix the spin")
81+
82+ if out := run(t, work, binary, "propose"); !strings.Contains(out, "proposal 1 opened") {
83+ t.Fatalf("br propose did not open a proposal: %s", out)
84+ }
85+
86+ other := filepath.Join(t.TempDir(), "r")
87+ run(t, "", "git", "clone", "-q", in.URLFor(john, "/john/johnbot"), other)
88+ if out := run(t, other, binary, "fetch", "1"); !strings.Contains(out, "prop-1") {
89+ t.Errorf("br fetch did not make prop-1: %s", out)
90+ }
91+ if got := run(t, other, "git", "log", "-1", "--format=%s", "prop-1"); got != "fix the spin" {
92+ t.Errorf("prop-1 is at %q, want the proposed commit", got)
93+ }
94+
95+ if out := run(t, other, binary, "notes"); !strings.Contains(out, "refs/notes/threads/1") {
96+ t.Errorf("br notes did not fetch the proposal's thread: %s", out)
97+ }
98+ if out := run(t, other, binary, "threads"); !strings.Contains(out, "git log --show-notes=threads/1") {
99+ t.Errorf("br threads did not list thread 1: %s", out)
100+ }
101+ run(t, other, binary, "reply", "1", "-m", "reads fine to me")
102+ run(t, work, binary, "notes")
103+ if out := run(t, work, binary, "thread", "1"); !strings.Contains(out, "reads fine to me") {
104+ t.Errorf("a reply pushed by br did not come back: %s", out)
105+ }
106+ }
107+
108+ // br auth is the one protocol the client speaks alone, so it is the one that can drift unseen.
109+ func TestBrAuthTradesASignatureForOneSignInLink(t *testing.T) {
110+ needs(t, "git", "ssh-keygen", "go")
111+ in := testserver.New(t)
112+
113+ home := t.TempDir()
114+ if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil {
115+ t.Fatal(err)
116+ }
117+ key := filepath.Join(home, ".ssh", "id_ed25519")
118+ run(t, "", "ssh-keygen", "-t", "ed25519", "-N", "", "-C", "june", "-f", key, "-q")
119+ pub, err := os.ReadFile(key + ".pub")
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ if _, err := in.DB.CreateAccount(context.Background(), "june", string(pub), false); err != nil {
124+ t.Fatal(err)
125+ }
126+
127+ cmd := exec.Command(binary, "auth", "june", in.URL)
128+ cmd.Dir = t.TempDir()
129+ cmd.Env = append(os.Environ(), "HOME="+home)
130+ raw, err := cmd.CombinedOutput()
131+ if err != nil {
132+ t.Fatalf("br auth: %v\n%s", err, raw)
133+ }
134+ out := string(raw)
135+ if !strings.Contains(out, "signed in as june") {
136+ t.Fatalf("br auth did not sign in: %s", out)
137+ }
138+ link := ""
139+ for _, word := range strings.Fields(out) {
140+ if strings.Contains(word, "/auth/claim?c=") {
141+ link = word
142+ }
143+ }
144+ if link == "" {
145+ t.Fatalf("br auth printed no claim link: %s", out)
146+ }
147+
148+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
149+ return http.ErrUseLastResponse
150+ }}
151+ resp, err := client.Get(link)
152+ if err != nil {
153+ t.Fatal(err)
154+ }
155+ resp.Body.Close()
156+ if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/june" {
157+ t.Fatalf("the claim link answered %d to %q", resp.StatusCode, resp.Header.Get("Location"))
158+ }
159+ signed := false
160+ for _, c := range resp.Cookies() {
161+ if c.Name == "barerepo_session" && c.Value != "" {
162+ signed = true
163+ }
164+ }
165+ if !signed {
166+ t.Error("the claim link set no session")
167+ }
168+
169+ again, err := client.Get(link)
170+ if err != nil {
171+ t.Fatal(err)
172+ }
173+ again.Body.Close()
174+ if again.StatusCode != http.StatusUnauthorized {
175+ t.Errorf("the claim link answered %d the second time, want it spent", again.StatusCode)
176+ }
177+ }
@@ -0,0 +1,3 @@
1+ module github.com/barerepo/cli
2+
3+ go 1.24
reachable from master
barerepo / clibarerepo 0.1.0