krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
d6d78eea2febd692e88894d6833a8f303785d515
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:30:35Z
cmd/forge/local.go | 298 +++++++++++++++++++++++++++++++++++ cmd/forge/main.go | 258 ++++++++++++++++++++++-------- cmd/forge/ssh.go | 123 +++++++++++++++ e2e/cli_test.go | 205 ++++++++++++++++++++++++ go.mod | 4 + go.sum | 4 + internal/cliconfig/cliconfig.go | 120 ++++++++++++++ internal/cliconfig/cliconfig_test.go | 46 ++++++ 8 files changed, 996 insertions(+), 62 deletions(-) new file mode 100644 @@ -0,0 +1,298 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/term" + + "github.com/krazywarez/forge/internal/cliconfig" + "github.com/krazywarez/forge/internal/protocol" +) + +// hasBodyFlag reports whether args already carry body/message input. +func hasBodyFlag(args []string) bool { + for _, a := range args { + if a == "--body" || a == "--message" || a == "--file" { + return true + } + } + return false +} + +// maybeEditor opens $EDITOR for long text when the command usually wants a +// body, none was given, and we are on a terminal. The result is passed to +// the server via --file - on stdin. Returns the (possibly extended) args, +// the stdin to use, and ok=false if the user aborted. +func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, error) { + if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) { + return args, nil, true, nil + } + editor := os.Getenv("EDITOR") + if editor == "" { + // No editor configured: proceed with an empty body rather than + // failing — bodies are optional everywhere. + return args, nil, true, nil + } + f, err := os.CreateTemp("", "forge-"+kind+"-*.md") + if err != nil { + return nil, nil, false, err + } + defer os.Remove(f.Name()) + fmt.Fprintf(f, "\n# Write the %s body above. Lines starting with '#' are ignored.\n# Save an empty file to skip the body.\n", kind) + f.Close() + + ed := exec.Command("sh", "-c", editor+" "+shellQuote(f.Name())) + ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := ed.Run(); err != nil { + return nil, nil, false, fmt.Errorf("editor: %w", err) + } + raw, err := os.ReadFile(f.Name()) + if err != nil { + return nil, nil, false, err + } + var body strings.Builder + for _, line := range strings.Split(string(raw), "\n") { + if strings.HasPrefix(line, "#") { + continue + } + body.WriteString(line + "\n") + } + text := strings.TrimSpace(body.String()) + if text == "" { + return args, nil, true, nil + } + return append(args, "--file", "-"), strings.NewReader(text + "\n"), true, nil +} + +func runGitLocal(args ...string) int { + cmd := exec.Command("git", args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode() + } + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + return 0 +} + +// cmdRepoClone implements `forge repo clone <owner/name> [dir]`. +func cmdRepoClone(args []string) int { + if len(args) < 1 || strings.HasPrefix(args[0], "-") { + fmt.Fprintln(os.Stderr, "usage: forge repo clone <owner/name> [dir]") + return protocol.ExitUsage + } + t, err := resolveTarget() + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + gitArgs := append([]string{"clone", t.inst.CloneURL(args[0])}, args[1:]...) + if len(t.inst.SSHOptions) > 0 { + os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " ")) + } + return runGitLocal(gitArgs...) +} + +// cmdMRCheckout implements `forge mr checkout <n>`: fetch the MR head from +// origin and check it out as a local branch. +func cmdMRCheckout(args []string) int { + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: forge mr checkout <n>") + return protocol.ExitUsage + } + n := args[0] + ref := "refs/merge-requests/" + n + "/head" + if code := runGitLocal("fetch", "origin", ref); code != 0 { + return code + } + return runGitLocal("checkout", "-B", "mr/"+n, "FETCH_HEAD") +} + +// cmdInit implements `forge init [name] [--private]`: git init if needed, +// create the repository on the default instance, and point origin at it. +func cmdInit(args []string) int { + var name string + private := false + for _, a := range args { + switch { + case a == "--private": + private = true + case strings.HasPrefix(a, "-"): + fmt.Fprintln(os.Stderr, "usage: forge init [name] [--private]") + return protocol.ExitUsage + default: + name = a + } + } + if name == "" { + wd, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + name = filepath.Base(wd) + } + + cfg, err := cliconfig.Load() + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + inst, _, err := cfg.DefaultInstance() + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + t := target{inst: inst} + + // The server requires owner = the authenticated user; ask who that is. + whoami, code := captureSSH(t, []string{"whoami"}) + if code != 0 { + return code + } + username := strings.TrimSpace(whoami) + repoPath := username + "/" + name + + createArgs := []string{"repo", "create", repoPath} + if private { + createArgs = append(createArgs, "--private") + } + if code := runSSH(t, createArgs, strings.NewReader("")); code != 0 { + return code + } + + if _, err := os.Stat(".git"); os.IsNotExist(err) { + if code := runGitLocal("init", "-q", "-b", "main"); code != 0 { + return code + } + } + url := inst.CloneURL(repoPath) + if code := runGitLocal("remote", "add", "origin", url); code != 0 { + return code + } + fmt.Printf("origin -> %s\npush with: git push -u origin main\n", url) + return 0 +} + +// captureSSH runs a server command and returns its stdout. +func captureSSH(t target, serverArgv []string) (string, int) { + args := []string{} + if t.inst.Port != 0 && t.inst.Port != 22 { + args = append(args, "-p", fmt.Sprint(t.inst.Port)) + } + args = append(args, t.inst.SSHOptions...) + quoted := quoteAll(serverArgv) + args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) + cmd := exec.Command("ssh", args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return "", ee.ExitCode() + } + fmt.Fprintln(os.Stderr, "forge: running ssh:", err) + return "", protocol.ExitProtocol + } + return string(out), 0 +} + +func quoteAll(args []string) []string { + out := make([]string, len(args)) + for i, a := range args { + out[i] = shellQuote(a) + } + return out +} + +// cmdRemoteAdd implements `forge remote add <name> <host> [flags]`. +func cmdRemoteAdd(args []string) int { + var name, host, user string + var port int + var setDefault bool + var sshOptions []string + i := 0 + for i < len(args) { + a := args[i] + switch a { + case "--port": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "--port requires a value") + return protocol.ExitUsage + } + fmt.Sscanf(args[i+1], "%d", &port) + i += 2 + case "--user": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "--user requires a value") + return protocol.ExitUsage + } + user = args[i+1] + i += 2 + case "--ssh-option": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "--ssh-option requires a value") + return protocol.ExitUsage + } + sshOptions = append(sshOptions, args[i+1]) + i += 2 + case "--default": + setDefault = true + i++ + default: + if name == "" { + name = a + } else if host == "" { + host = a + } else { + fmt.Fprintln(os.Stderr, "usage: forge remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]") + return protocol.ExitUsage + } + i++ + } + } + if name == "" || host == "" { + fmt.Fprintln(os.Stderr, "usage: forge remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]") + return protocol.ExitUsage + } + cfg, err := cliconfig.Load() + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + cfg.Instances[name] = cliconfig.Instance{Host: host, Port: port, User: user, SSHOptions: sshOptions} + if setDefault || cfg.Default == "" { + cfg.Default = name + } + if err := cliconfig.Save(cfg); err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + fmt.Printf("added instance %s (%s)\n", name, host) + return 0 +} + +func cmdRemoteList() int { + cfg, err := cliconfig.Load() + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + for name, inst := range cfg.Instances { + def := "" + if name == cfg.Default { + def = " (default)" + } + port := inst.Port + if port == 0 { + port = 22 + } + fmt.Printf("%s\t%s@%s:%d%s\n", name, inst.SSHUser(), inst.Host, port, def) + } + return 0 +} @@ -1,13 +1,17 @@ -// forge is the client CLI. It speaks to a forge server over the system ssh -// binary; it is ergonomics on top of a control plane that is fully usable -// from bare OpenSSH. +// forge is the client CLI. It is ergonomics over a control plane that is +// fully usable from bare OpenSSH: most commands pass through to the server +// over the system ssh binary, adding instance resolution, repo inference +// from the origin remote, and $EDITOR for long text. package main import ( "fmt" + "io" "os" + "strings" "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" "github.com/krazywarez/forge/internal/protocol" ) @@ -19,8 +23,6 @@ func main() { SilenceUsage: true, SilenceErrors: true, } - root.PersistentFlags().Bool("json", false, "machine-readable output") - root.PersistentFlags().String("repo", "", "owner/name (default: inferred from the origin remote)") root.AddCommand( authCmd(), @@ -28,116 +30,248 @@ func main() { issueCmd(), mrCmd(), webCmd(), - adminCmd(), remoteCmd(), initCmd(), + manCmd(root), ) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, "forge:", err) - os.Exit(protocol.ExitFailure) + os.Exit(protocol.ExitUsage) } } -// stub returns a leaf command that fails until its milestone lands. -func stub(use, short string) *cobra.Command { +// passOpts describes how one CLI command maps onto the server command. +type passOpts struct { + server []string // server-side command path + needsRepo bool // prepend inferred owner/name unless given + stdinOK bool // wire local stdin through (keys add, --file -) + editor string // open $EDITOR for a body when none given +} + +// pass builds a passthrough command. Flags are parsed by the server, which +// is the single source of truth for them; the CLI stays thin. +func pass(use, short string, o passOpts) *cobra.Command { return &cobra.Command{ - Use: use, - Short: short, + Use: use, + Short: short, + DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("not implemented") + // cobra still owns `forge <cmd> --help`. + for _, a := range args { + if a == "--help" || a == "-h" { + return cmd.Help() + } + } + os.Exit(runPass(o, args)) + return nil }, } } +func runPass(o passOpts, args []string) int { + t, err := resolveTarget() + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + if o.needsRepo { + args, err = withRepo(t, args) + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitUsage + } + } + + var stdin io.Reader = strings.NewReader("") + if o.editor != "" { + extended, body, ok, err := maybeEditor(args, o.editor) + if err != nil { + fmt.Fprintln(os.Stderr, "forge:", err) + return protocol.ExitFailure + } + if !ok { + return protocol.ExitFailure + } + args = extended + if body != nil { + stdin = body + } + } + if stdin == nil || isEmptyReader(stdin) { + if o.stdinOK && usesStdin(args) { + stdin = os.Stdin + } + } + return runSSH(t, append(o.server, args...), stdin) +} + +func isEmptyReader(r io.Reader) bool { + sr, ok := r.(*strings.Reader) + return ok && sr.Len() == 0 +} + +// usesStdin reports whether the arguments request stdin content. +func usesStdin(args []string) bool { + for i, a := range args { + if a == "--file" && i+1 < len(args) && args[i+1] == "-" { + return true + } + } + return false +} + func group(use, short string, subs ...*cobra.Command) *cobra.Command { c := &cobra.Command{Use: use, Short: short} c.AddCommand(subs...) return c } +// local wraps a locally-implemented command (git plumbing, config). +func local(use, short string, fn func(args []string) int) *cobra.Command { + return &cobra.Command{ + Use: use, + Short: short, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + for _, a := range args { + if a == "--help" || a == "-h" { + return cmd.Help() + } + } + os.Exit(fn(args)) + return nil + }, + } +} + func authCmd() *cobra.Command { - return group("auth", "identity: keys, emails, whoami", - stub("whoami", "show the authenticated account"), + keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)", + passOpts{server: []string{"keys", "add"}, stdinOK: true}) + // keys add always reads stdin on the server; wire it through directly. + keysAdd.RunE = func(cmd *cobra.Command, args []string) error { + t, err := resolveTarget() + if err != nil { + return err + } + os.Exit(runSSH(t, append([]string{"keys", "add"}, args...), os.Stdin)) + return nil + } + pgpAdd := &cobra.Command{ + Use: "add", Short: "register an OpenPGP public key (armored, on stdin)", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + t, err := resolveTarget() + if err != nil { + return err + } + os.Exit(runSSH(t, append([]string{"pgp", "add"}, args...), os.Stdin)) + return nil + }, + } + return group("auth", "identity: whoami, SSH and PGP keys", + pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}), group("keys", "manage SSH keys", - stub("list", "list registered SSH keys"), - stub("add", "register an SSH key"), - stub("remove", "remove an SSH key"), + pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}), + keysAdd, + pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}), ), group("pgp", "manage OpenPGP keys", - stub("list", "list registered PGP keys"), - stub("add", "register a PGP key"), - stub("remove", "remove a PGP key"), - ), - group("email", "manage email addresses", - stub("add", "add an address"), - stub("verify", "confirm a verification code"), + pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}), + pgpAdd, + pass("remove", "remove a PGP key by fingerprint", passOpts{server: []string{"pgp", "remove"}}), ), ) } func repoCmd() *cobra.Command { return group("repo", "create and manage repositories", - stub("create", "create a repository"), - stub("list", "list repositories"), - stub("show", "show repository details"), - stub("clone", "clone via ssh"), - stub("rename", "rename a repository"), - stub("delete", "delete a repository"), - stub("fork", "fork a repository"), - stub("import", "server-side mirror from a foreign URL"), - stub("settings", "get or set repository settings"), + pass("create", "create a repository: forge repo create <owner/name> [--private]", + passOpts{server: []string{"repo", "create"}}), + pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}), + pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), + pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), + pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), + pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), + local("clone", "clone via ssh: forge repo clone <owner/name> [dir]", cmdRepoClone), + group("access", "manage access grants", + pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}), + pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}), + pass("list", "list access grants", passOpts{server: []string{"repo", "access", "list"}, needsRepo: true}), + ), + group("settings", "repository settings", + pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), + pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), + pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), + pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), + pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}), + ), ) } func issueCmd() *cobra.Command { return group("issue", "issues", - stub("create", "open an issue"), - stub("list", "list issues"), - stub("show", "show an issue"), - stub("comment", "comment on an issue"), - stub("close", "close an issue"), - stub("reopen", "reopen an issue"), - stub("label", "add or remove labels"), - stub("assign", "assign users"), + pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]", + passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}), + pass("list", "list issues [--state open|closed|all]", passOpts{server: []string{"issue", "list"}, needsRepo: true}), + pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}), + pass("comment", "comment on an issue [--message|--file -|$EDITOR]", + passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), + pass("close", "close an issue", passOpts{server: []string{"issue", "close"}, needsRepo: true}), + pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), + pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}), + pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}), ) } func mrCmd() *cobra.Command { return group("mr", "merge requests", - stub("create", "open a merge request"), - stub("list", "list merge requests"), - stub("show", "show a merge request"), - stub("diff", "show the diff"), - stub("checkout", "fetch and check out the MR head locally"), - stub("comment", "comment on a merge request"), - stub("review", "approve or request changes"), - stub("merge", "merge (fast-forward or merge-commit)"), - stub("close", "close without merging"), + pass("create", "open a merge request: --source <branch> --target <branch> --title <t>", + passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}), + pass("list", "list merge requests [--state ...]", passOpts{server: []string{"mr", "list"}, needsRepo: true}), + pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}), + pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), + local("checkout", "fetch and check out the MR head locally: forge mr checkout <n>", cmdMRCheckout), + pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), + pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), + pass("merge", "merge (fast-forward or merge-commit): [--strategy ff|merge]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), + pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}), ) } func webCmd() *cobra.Command { return group("web", "browser session", - stub("login", "mint a one-time browser login URL over ssh"), - ) -} - -func adminCmd() *cobra.Command { - return group("admin", "instance administration (admin accounts only)", - stub("user", "manage users"), - stub("invite", "issue registration invites"), - stub("stats", "instance statistics"), + pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}), ) } func remoteCmd() *cobra.Command { return group("remote", "local instance profiles (no server contact)", - stub("add", "add a named forge instance"), - stub("list", "list configured instances"), + local("add", "add a named forge instance: forge remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]", + cmdRemoteAdd), + local("list", "list configured instances", func([]string) int { return cmdRemoteList() }), ) } func initCmd() *cobra.Command { - return stub("init", "git init + repo create + set origin, in one step") + return local("init", "git init + repo create + set origin, in one step: forge init [name] [--private]", cmdInit) +} + +// manCmd generates man pages; a CLI-first tool without man pages is not +// CLI-first. +func manCmd(root *cobra.Command) *cobra.Command { + var dir string + cmd := &cobra.Command{ + Use: "man", + Short: "generate man pages into a directory", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return doc.GenManTree(root, &doc.GenManHeader{Title: "FORGE", Section: "1"}, dir) + }, + } + cmd.Flags().StringVar(&dir, "dir", "man", "output directory") + return cmd } new file mode 100644 @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + + "github.com/krazywarez/forge/internal/cliconfig" + "github.com/krazywarez/forge/internal/protocol" +) + +// context is the resolved target for a command: which instance to talk to +// and, when run inside a clone of a forge repo, which repository. +type target struct { + inst cliconfig.Instance + repo string // owner/name, "" when not inferable +} + +// resolveTarget picks the instance and repo. Inside a git repo whose origin +// remote points at a configured (or any ssh) forge host, that wins; +// otherwise the configured default instance. +func resolveTarget() (target, error) { + cfg, err := cliconfig.Load() + if err != nil { + return target{}, err + } + + if url := originURL(); url != "" { + if parsed, repo, ok := cliconfig.ParseRemoteURL(url); ok { + // Prefer a configured instance for the same host+port: it may + // carry ssh_options the bare URL cannot express. + norm := func(p int) int { + if p == 0 { + return 22 + } + return p + } + for _, inst := range cfg.Instances { + if inst.Host == parsed.Host && norm(inst.Port) == norm(parsed.Port) { + return target{inst: inst, repo: repo}, nil + } + } + return target{inst: parsed, repo: repo}, nil + } + } + + inst, _, err := cfg.DefaultInstance() + if err != nil { + return target{}, err + } + return target{inst: inst}, nil +} + +func originURL() string { + out, err := exec.Command("git", "remote", "get-url", "origin").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// bareWord matches arguments that need no quoting for the server-side +// POSIX tokenizer. +var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`) + +// shellQuote quotes one argument for the SSH command string; the server +// tokenizes with POSIX rules and no expansion. +func shellQuote(arg string) string { + if arg != "" && bareWord.MatchString(arg) { + return arg + } + return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" +} + +// runSSH executes the server command over the system ssh binary, wiring +// stdio through. It returns the remote exit code. +func runSSH(t target, serverArgv []string, stdin io.Reader) int { + args := []string{} + if t.inst.Port != 0 && t.inst.Port != 22 { + args = append(args, "-p", strconv.Itoa(t.inst.Port)) + } + args = append(args, t.inst.SSHOptions...) + quoted := make([]string, len(serverArgv)) + for i, a := range serverArgv { + quoted[i] = shellQuote(a) + } + args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) + + cmd := exec.Command("ssh", args...) + cmd.Stdin = stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err == nil { + return 0 + } + if ee, ok := err.(*exec.ExitError); ok { + code := ee.ExitCode() + if code == 255 { // ssh-level failure (connection, auth, host key) + return protocol.ExitProtocol + } + return code + } + fmt.Fprintln(os.Stderr, "forge: running ssh:", err) + return protocol.ExitProtocol +} + +// withRepo prepends the repo path to args unless the user already gave one +// explicitly (a first argument containing '/'). Commands' server parsers +// accept the path at any position, so the front is always safe. +func withRepo(t target, args []string) ([]string, error) { + if len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/") { + return args, nil // explicit owner/name + } + if t.repo == "" { + return nil, fmt.Errorf("no repository given and none inferable: pass <owner/name> or run inside a clone of a forge repository") + } + return append([]string{t.repo}, args...), nil +} new file mode 100644 @@ -0,0 +1,205 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func buildForgeCLI(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "forge") + cmd := exec.Command("go", "build", "-o", bin, "github.com/krazywarez/forge/cmd/forge") + cmd.Dir = ".." + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build forge: %v\n%s", err, out) + } + return bin +} + +// cli runs the forge binary with an isolated config home. +type cli struct { + bin string + configDir string + inst *instance + key string +} + +func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, string, int) { + t.Helper() + cmd := exec.Command(c.bin, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "XDG_CONFIG_HOME="+c.configDir, + "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", + "EDITOR=", // no editor in tests: bodies come from flags + ) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var out, errOut strings.Builder + cmd.Stdout = &out + cmd.Stderr = &errOut + err := cmd.Run() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("forge %v: %v", args, err) + } + return out.String(), errOut.String(), code +} + +func (c *cli) must(t *testing.T, dir, stdin string, args ...string) string { + t.Helper() + out, errOut, code := c.run(t, dir, stdin, args...) + if code != 0 { + t.Fatalf("forge %v: exit %d\nstdout: %s\nstderr: %s", args, code, out, errOut) + } + return out +} + +func TestCLI(t *testing.T) { + inst := startInstance(t) + aliceKey := inst.newKey(t, "alice") + inst.admin(t, "admin", "user", "create", "alice", + "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") + + c := &cli{ + bin: buildForgeCLI(t), + configDir: t.TempDir(), + inst: inst, + key: aliceKey, + } + + // Configure the instance, with ssh options so the test's throwaway key + // and known_hosts are used. + c.must(t, "", "", "remote", "add", "test", "127.0.0.1", + "--port", fmt.Sprint(inst.port), + "--ssh-option", "-i", "--ssh-option", aliceKey, + "--ssh-option", "-oIdentitiesOnly=yes", + "--ssh-option", "-oStrictHostKeyChecking=no", + "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"), + "--ssh-option", "-oBatchMode=yes", + "--default") + if out := c.must(t, "", "", "remote", "list"); !strings.Contains(out, "test\tgit@127.0.0.1") || !strings.Contains(out, "(default)") { + t.Fatalf("remote list: %s", out) + } + + // whoami through the CLI, JSON passthrough intact. + out := c.must(t, "", "", "auth", "whoami", "--json") + var env struct { + ProtocolVersion int `json:"protocol_version"` + Data struct { + Username string `json:"username"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Username != "alice" || env.ProtocolVersion != 1 { + t.Fatalf("whoami via CLI: %v %s", err, out) + } + + // Repo create + clone through the CLI. + c.must(t, "", "", "repo", "create", "alice/proj") + work := t.TempDir() + c.must(t, work, "", "repo", "clone", "alice/proj") + dir := filepath.Join(work, "proj") + if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { + t.Fatal("clone did not produce a repo") + } + + // Push some content (plain git, using the clone's remote). + cliGitEnv := inst.gitEnv(aliceKey) + os.WriteFile(filepath.Join(dir, "README"), []byte("hi\n"), 0o644) + mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "main") + mustGit(t, dir, cliGitEnv, "add", ".") + mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "init") + mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "main") + + // Inside the clone, the repo argument is inferred from origin. + c.must(t, dir, "", "issue", "create", "--title", "inferred repo works", "--body", "body") + out = c.must(t, dir, "", "issue", "list") + if !strings.Contains(out, "inferred repo works") { + t.Fatalf("issue list in clone: %s", out) + } + // Explicit owner/name still works from anywhere. + out = c.must(t, "", "", "issue", "show", "alice/proj", "1") + if !strings.Contains(out, "inferred repo works") { + t.Fatalf("issue show explicit: %s", out) + } + // Outside a clone with no explicit repo: usage error, not a hang. + _, errOut, code := c.run(t, "", "", "issue", "list") + if code != 2 || !strings.Contains(errOut, "none inferable") { + t.Fatalf("bare issue list outside clone: exit %d, %s", code, errOut) + } + + // Exit codes pass through: missing issue is 3. + if _, _, code := c.run(t, dir, "", "issue", "show", "99"); code != 3 { + t.Fatalf("missing issue via CLI: exit %d, want 3", code) + } + + // MR flow: branch, push, create (inferred), checkout, merge. + mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "feature") + os.WriteFile(filepath.Join(dir, "f.txt"), []byte("feature\n"), 0o644) + mustGit(t, dir, cliGitEnv, "add", ".") + mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "feature work") + mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "feature") + c.must(t, dir, "", "mr", "create", "--source", "feature", "--target", "main", "--title", "via cli") + + // mr checkout uses the clone's own git; the MR ref comes from origin. + mustGit(t, dir, cliGitEnv, "checkout", "-q", "main") + cmd := exec.Command(c.bin, "mr", "checkout", "1") + cmd.Dir = dir + cmd.Env = append(cliGitEnv, "XDG_CONFIG_HOME="+c.configDir) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("mr checkout: %v\n%s", err, out) + } + branch := strings.TrimSpace(mustGit(t, dir, cliGitEnv, "rev-parse", "--abbrev-ref", "HEAD")) + if branch != "mr/1" { + t.Fatalf("mr checkout branch = %s", branch) + } + if _, err := os.Stat(filepath.Join(dir, "f.txt")); err != nil { + t.Fatal("mr checkout content missing") + } + + c.must(t, dir, "", "mr", "merge", "1") + out = c.must(t, dir, "", "mr", "show", "1") + if !strings.Contains(out, "merged") { + t.Fatalf("mr not merged: %s", out) + } + + // keys add reads the public key from CLI stdin. + secondKey := inst.newKey(t, "alice2") + pub, _ := os.ReadFile(secondKey + ".pub") + c.must(t, "", string(pub), "auth", "keys", "add", "--scope", "git") + if out = c.must(t, "", "", "auth", "keys", "list"); len(strings.Split(strings.TrimSpace(out), "\n")) != 2 { + t.Fatalf("keys list: %s", out) + } + + // forge init: new local project, repo created server-side, origin set. + proj := filepath.Join(t.TempDir(), "newthing") + os.MkdirAll(proj, 0o755) + c.must(t, proj, "", "init", "--private") + originOut := mustGit(t, proj, cliGitEnv, "remote", "get-url", "origin") + if !strings.Contains(originOut, "/alice/newthing.git") { + t.Fatalf("init origin: %s", originOut) + } + if out = c.must(t, "", "", "repo", "show", "alice/newthing"); !strings.Contains(out, "private") { + t.Fatalf("init-created repo: %s", out) + } + + // Man pages and completions generate. + manDir := t.TempDir() + c.must(t, "", "", "man", "--dir", manDir) + if entries, _ := os.ReadDir(manDir); len(entries) < 10 { + t.Fatalf("man pages: only %d generated", len(entries)) + } + if out = c.must(t, "", "", "completion", "zsh"); !strings.Contains(out, "compdef") { + t.Fatal("zsh completion missing") + } +} @@ -9,11 +9,13 @@ require ( github.com/spf13/cobra v1.10.2 github.com/yuin/goldmark v1.8.5 golang.org/x/crypto v0.55.0 + golang.org/x/term v0.45.0 modernc.org/sqlite v1.57.0 ) require ( github.com/cloudflare/circl v1.6.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect @@ -21,7 +23,9 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect @@ -10,6 +10,7 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= @@ -31,6 +32,7 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -38,6 +40,7 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= @@ -51,6 +54,7 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= new file mode 100644 @@ -0,0 +1,120 @@ +// Package cliconfig manages the client-side configuration: named forge +// instances at ~/.config/forge/config.toml, and parsing of origin remote +// URLs so commands run inside a clone need no --repo argument. +package cliconfig + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/BurntSushi/toml" +) + +type Instance struct { + Host string `toml:"host"` + Port int `toml:"port,omitempty"` + User string `toml:"user,omitempty"` + // SSHOptions are extra arguments passed to the ssh binary verbatim, + // e.g. ["-i", "~/.ssh/forge_ed25519"]. Most setups need none: the + // system ssh already honors ~/.ssh/config and the agent. + SSHOptions []string `toml:"ssh_options,omitempty"` +} + +func (i Instance) SSHUser() string { + if i.User != "" { + return i.User + } + return "git" +} + +// CloneURL returns the ssh:// URL for owner/name on this instance. +func (i Instance) CloneURL(repo string) string { + hostport := i.Host + if i.Port != 0 && i.Port != 22 { + hostport = fmt.Sprintf("%s:%d", i.Host, i.Port) + } + return fmt.Sprintf("ssh://%s@%s/%s.git", i.SSHUser(), hostport, repo) +} + +type Config struct { + Default string `toml:"default,omitempty"` + Instances map[string]Instance `toml:"instances"` +} + +func Path() string { + if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { + return filepath.Join(x, "forge", "config.toml") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "forge", "config.toml") +} + +func Load() (Config, error) { + cfg := Config{Instances: map[string]Instance{}} + raw, err := os.ReadFile(Path()) + if os.IsNotExist(err) { + return cfg, nil + } + if err != nil { + return cfg, err + } + if err := toml.Unmarshal(raw, &cfg); err != nil { + return cfg, fmt.Errorf("%s: %w", Path(), err) + } + if cfg.Instances == nil { + cfg.Instances = map[string]Instance{} + } + return cfg, nil +} + +func Save(cfg Config) error { + p := Path() + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + var b strings.Builder + if err := toml.NewEncoder(&b).Encode(cfg); err != nil { + return err + } + return os.WriteFile(p, []byte(b.String()), 0o600) +} + +// DefaultInstance returns the configured default (or the only) instance. +func (c Config) DefaultInstance() (Instance, string, error) { + if c.Default != "" { + if inst, ok := c.Instances[c.Default]; ok { + return inst, c.Default, nil + } + return Instance{}, "", fmt.Errorf("default instance %q is not configured", c.Default) + } + if len(c.Instances) == 1 { + for name, inst := range c.Instances { + return inst, name, nil + } + } + return Instance{}, "", fmt.Errorf("no forge instance configured; run: forge remote add <name> <host>") +} + +var ( + sshURLPat = regexp.MustCompile(`^ssh://(?:([^@/]+)@)?([^:/]+)(?::(\d+))?/(.+?)(?:\.git)?/?$`) + scpPat = regexp.MustCompile(`^(?:([^@/]+)@)?([^:/]+):(.+?)(?:\.git)?$`) +) + +// ParseRemoteURL extracts the instance coordinates and owner/name from a +// git remote URL in ssh:// or scp-like form. +func ParseRemoteURL(url string) (Instance, string, bool) { + if m := sshURLPat.FindStringSubmatch(url); m != nil { + inst := Instance{Host: m[2], User: m[1]} + if m[3] != "" { + fmt.Sscanf(m[3], "%d", &inst.Port) + } + return inst, strings.Trim(m[4], "/"), true + } + if m := scpPat.FindStringSubmatch(url); m != nil && !strings.Contains(url, "://") { + return Instance{Host: m[2], User: m[1]}, strings.Trim(m[3], "/"), true + } + return Instance{}, "", false +} new file mode 100644 @@ -0,0 +1,46 @@ +package cliconfig + +import "testing" + +func TestParseRemoteURL(t *testing.T) { + cases := []struct { + url string + host string + port int + user string + repo string + ok bool + }{ + {"ssh://git@forge.example/alice/proj.git", "forge.example", 0, "git", "alice/proj", true}, + {"ssh://git@forge.example:2222/alice/proj.git", "forge.example", 2222, "git", "alice/proj", true}, + {"ssh://forge.example/alice/proj", "forge.example", 0, "", "alice/proj", true}, + {"git@forge.example:alice/proj.git", "forge.example", 0, "git", "alice/proj", true}, + {"git@forge.example:alice/proj", "forge.example", 0, "git", "alice/proj", true}, + {"https://forge.example/alice/proj.git", "", 0, "", "", false}, + {"/local/path/repo.git", "", 0, "", "", false}, + } + for _, tc := range cases { + inst, repo, ok := ParseRemoteURL(tc.url) + if ok != tc.ok { + t.Errorf("%s: ok = %v, want %v", tc.url, ok, tc.ok) + continue + } + if !ok { + continue + } + if inst.Host != tc.host || inst.Port != tc.port || inst.User != tc.user || repo != tc.repo { + t.Errorf("%s: got host=%s port=%d user=%s repo=%s", tc.url, inst.Host, inst.Port, inst.User, repo) + } + } +} + +func TestCloneURL(t *testing.T) { + i := Instance{Host: "forge.example"} + if got := i.CloneURL("a/b"); got != "ssh://git@forge.example/a/b.git" { + t.Errorf("CloneURL = %s", got) + } + i = Instance{Host: "forge.example", Port: 2222, User: "u"} + if got := i.CloneURL("a/b"); got != "ssh://u@forge.example:2222/a/b.git" { + t.Errorf("CloneURL = %s", got) + } +}