@@ -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+ }