package main import ( "bytes" "context" "errors" "flag" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" ) func gitShow(args ...string) { fmt.Fprintln(os.Stderr, "+ git "+strings.Join(args, " ")) } func gitRun(ctx context.Context, args ...string) error { gitShow(args...) cmd := exec.CommandContext(ctx, "git", args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func gitQuiet(ctx context.Context, args ...string) (string, error) { var out, errb bytes.Buffer cmd := exec.CommandContext(ctx, "git", args...) cmd.Stdout = &out cmd.Stderr = &errb if err := cmd.Run(); err != nil { msg := strings.TrimSpace(errb.String()) if msg == "" { msg = err.Error() } return out.String(), errors.New(msg) } return out.String(), nil } func inWorkTree(ctx context.Context) error { if _, err := gitQuiet(ctx, "rev-parse", "--git-dir"); err != nil { return errors.New("this is not a git repository. cd into one first") } return nil } func remoteOr(args []string) string { if len(args) > 0 && args[0] != "" { return args[0] } return "origin" } func threadNumber(args []string, what string) (int, error) { if len(args) == 0 { return 0, fmt.Errorf("which %s? give its number, for example: forge %s 47", what, what) } n, err := strconv.Atoi(strings.TrimPrefix(args[0], "#")) if err != nil || n < 1 { return 0, fmt.Errorf("%q is not a %s number", args[0], what) } return n, nil } func cmdPropose(ctx context.Context, args []string) error { if err := inWorkTree(ctx); err != nil { return err } return gitRun(ctx, "push", remoteOr(args), "HEAD:refs/proposals/new") } func cmdFetchProposal(ctx context.Context, args []string) error { if err := inWorkTree(ctx); err != nil { return err } n, err := threadNumber(args, "proposal") if err != nil { return err } remote := remoteOr(args[1:]) spec := fmt.Sprintf("refs/proposals/%d:prop-%d", n, n) if err := gitRun(ctx, "fetch", remote, spec); err != nil { return err } fmt.Printf("proposal %d is now the local branch prop-%d\n", n, n) fmt.Printf(" git log master..prop-%d\n", n) fmt.Printf(" git diff master...prop-%d\n", n) return nil } func cmdNotes(ctx context.Context, args []string) error { if err := inWorkTree(ctx); err != nil { return err } remote := remoteOr(args) if err := gitRun(ctx, "fetch", remote, "refs/notes/*:refs/notes/*"); err == nil { return nil } fmt.Fprintln(os.Stderr, "\nyou have replies of your own here. merging instead of overwriting.") if err := gitRun(ctx, "fetch", remote, "+refs/notes/*:refs/notes/incoming/*"); err != nil { return err } out, err := gitQuiet(ctx, "for-each-ref", "--format=%(refname:lstrip=3)", "refs/notes/incoming/") if err != nil { return err } for _, name := range strings.Fields(out) { if err := gitRun(ctx, "notes", "--ref="+name, "merge", "-s", "union", "refs/notes/incoming/"+name); err != nil { return err } } for _, name := range strings.Fields(out) { if err := gitRun(ctx, "update-ref", "-d", "refs/notes/incoming/"+name); err != nil { return err } } return nil } func cmdThreads(ctx context.Context, args []string) error { if err := inWorkTree(ctx); err != nil { return err } format := "%(refname:lstrip=3)" gitShow("for-each-ref", "--format="+format, "refs/notes/threads/") out, err := gitQuiet(ctx, "for-each-ref", "--format="+format, "refs/notes/threads/") if err != nil { return err } names := strings.Fields(out) if len(names) == 0 { fmt.Println("no threads here yet. run forge notes to fetch them.") return nil } for _, name := range names { fmt.Printf("%s\tgit log --show-notes=threads/%s\n", name, name) } fmt.Fprintln(os.Stderr, "\nclosed threads look the same here. the closed list lives on the server.") return nil } func cmdThread(ctx context.Context, args []string) error { if err := inWorkTree(ctx); err != nil { return err } n, err := threadNumber(args, "thread") if err != nil { return err } ref := fmt.Sprintf("refs/notes/threads/%d", n) if _, err := gitQuiet(ctx, "rev-parse", "--verify", "--quiet", ref); err != nil { return fmt.Errorf("thread %d is not here. run forge notes to fetch it", n) } return gitRun(ctx, "log", fmt.Sprintf("--show-notes=threads/%d", n)) } func cmdReply(ctx context.Context, args []string) error { if err := inWorkTree(ctx); err != nil { return err } fs := flag.NewFlagSet("reply", flag.ContinueOnError) fs.SetOutput(os.Stderr) msg := fs.String("m", "", "the comment to append, or leave it out and type on stdin") rest, err := splitNumberFirst(args, fs) if err != nil { return err } n, err := threadNumber(rest, "thread") if err != nil { return err } body := strings.TrimSpace(*msg) if body == "" { typed, err := io.ReadAll(os.Stdin) if err != nil { return err } body = strings.TrimSpace(string(typed)) } if body == "" { return errors.New("an empty reply says nothing. use -m, or type the comment on stdin") } ref := fmt.Sprintf("threads/%d", n) full := "refs/notes/" + ref base, _ := gitQuiet(ctx, "rev-parse", "--verify", "--quiet", full) base = strings.TrimSpace(base) if err := gitRun(ctx, "notes", "--ref="+ref, "append", "-m", body); err != nil { return err } remote := remoteOr(nil) if err := gitRun(ctx, "push", remote, full); err == nil { return nil } fmt.Fprintln(os.Stderr, "\nsomebody replied first. taking their reply, then writing yours after it.") if err := gitRun(ctx, "fetch", remote, full); err != nil { return err } head, err := gitQuiet(ctx, "rev-parse", "FETCH_HEAD") if err != nil { return err } head = strings.TrimSpace(head) if base != "" { if _, err := gitQuiet(ctx, "merge-base", "--is-ancestor", base, head); err != nil { saved := "refs/notes/before-reply/" + ref if err := gitRun(ctx, "update-ref", saved, base); err != nil { return err } fmt.Fprintln(os.Stderr, "your copy held replies the server has not. it is kept at "+saved) } } if err := gitRun(ctx, "update-ref", full, head); err != nil { return err } if err := gitRun(ctx, "notes", "--ref="+ref, "append", "-m", body); err != nil { return err } return gitRun(ctx, "push", remote, full) } func splitNumberFirst(args []string, fs *flag.FlagSet) ([]string, error) { var plain, flags []string for i := 0; i < len(args); i++ { if strings.HasPrefix(args[i], "-") { flags = append(flags, args[i]) if args[i] == "-m" && i+1 < len(args) { i++ flags = append(flags, args[i]) } continue } plain = append(plain, args[i]) } if err := fs.Parse(flags); err != nil { return nil, err } return plain, nil } func cmdAuth(ctx context.Context, args []string) error { if len(args) == 0 { return errors.New("which account? for example: forge auth john") } name := args[0] base, err := serverURL(ctx, args[1:]) if err != nil { return err } key, err := signingKey() if err != nil { return err } nonce, err := ask(ctx, base+"/auth/challenge", url.Values{"name": {name}}) if err != nil { return err } fmt.Fprintf(os.Stderr, "+ printf '%%s' '%s' | ssh-keygen -Y sign -f %s -n barerepo-auth -\n", nonce, key) sig, err := sign(ctx, key, nonce) if err != nil { return err } answer, err := ask(ctx, base+"/auth/verify", url.Values{ "name": {name}, "nonce": {nonce}, "signature": {sig}, }) if err != nil { return err } who, link, ok := strings.Cut(answer, "\n") if !ok { return errors.New(answer) } fmt.Fprintln(os.Stderr, "signed in as "+strings.TrimSpace(who)) fmt.Fprint(os.Stderr, "\nopen this to sign in the browser. it works once:\n\n") fmt.Println(strings.TrimSpace(link)) return nil } func serverURL(ctx context.Context, args []string) (string, error) { raw := "" if len(args) > 0 && args[0] != "" { raw = args[0] } else { out, err := gitQuiet(ctx, "remote", "get-url", "origin") if err != nil { return "", errors.New("which forge? cd into a clone, or name it: forge auth john https://barerepo.example") } raw = strings.TrimSpace(out) } return webBase(raw) } func webBase(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", errors.New("that remote names no server") } if !strings.Contains(raw, "://") { host, _, ok := strings.Cut(raw, ":") if !ok { return "", fmt.Errorf("%q is not a url forge can reach", raw) } if _, after, at := strings.Cut(host, "@"); at { host = after } if host == "" { return "", fmt.Errorf("%q is not a url forge can reach", raw) } return "https://" + host, nil } u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { return "", fmt.Errorf("%q is not a url forge can reach", raw) } if u.Scheme == "ssh" { return "https://" + u.Hostname(), nil } u.User = nil u.RawQuery = "" u.Fragment = "" u.Path = "" return strings.TrimSuffix(u.String(), "/"), nil } func signingKey() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } for _, name := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} { path := filepath.Join(home, ".ssh", name) if _, err := os.Stat(path); err == nil { return path, nil } } return "", errors.New("no ssh key in ~/.ssh. make one with: ssh-keygen -t ed25519") } func sign(ctx context.Context, key, nonce string) (string, error) { var out, errb bytes.Buffer cmd := exec.CommandContext(ctx, "ssh-keygen", "-Y", "sign", "-f", key, "-n", "barerepo-auth", "-") cmd.Stdin = strings.NewReader(nonce) cmd.Stdout = &out cmd.Stderr = &errb if err := cmd.Run(); err != nil { msg := strings.TrimSpace(errb.String()) if msg == "" { msg = err.Error() } return "", errors.New("ssh-keygen: " + msg) } return out.String(), nil } func ask(ctx context.Context, endpoint string, form url.Values) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "text/plain") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } text := strings.TrimSpace(string(body)) if resp.StatusCode != http.StatusOK { if text == "" { text = resp.Status } return "", errors.New(text) } return text, nil }