Commit d6d78eea2f
Verified · cmc
Layout: unified · split
cmd/forge/local.go added +298
| @@ -0,0 +1,298 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "os/exec" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "golang.org/x/term" | ||
| 11 | |||
| 12 | "github.com/krazywarez/forge/internal/cliconfig" | ||
| 13 | "github.com/krazywarez/forge/internal/protocol" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // hasBodyFlag reports whether args already carry body/message input. | ||
| 17 | func hasBodyFlag(args []string) bool { | ||
| 18 | for _, a := range args { | ||
| 19 | if a == "--body" || a == "--message" || a == "--file" { | ||
| 20 | return true | ||
| 21 | } | ||
| 22 | } | ||
| 23 | return false | ||
| 24 | } | ||
| 25 | |||
| 26 | // maybeEditor opens $EDITOR for long text when the command usually wants a | ||
| 27 | // body, none was given, and we are on a terminal. The result is passed to | ||
| 28 | // the server via --file - on stdin. Returns the (possibly extended) args, | ||
| 29 | // the stdin to use, and ok=false if the user aborted. | ||
| 30 | func maybeEditor(args []string, kind string) ([]string, *strings.Reader, bool, error) { | ||
| 31 | if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) { | ||
| 32 | return args, nil, true, nil | ||
| 33 | } | ||
| 34 | editor := os.Getenv("EDITOR") | ||
| 35 | if editor == "" { | ||
| 36 | // No editor configured: proceed with an empty body rather than | ||
| 37 | // failing — bodies are optional everywhere. | ||
| 38 | return args, nil, true, nil | ||
| 39 | } | ||
| 40 | f, err := os.CreateTemp("", "forge-"+kind+"-*.md") | ||
| 41 | if err != nil { | ||
| 42 | return nil, nil, false, err | ||
| 43 | } | ||
| 44 | defer os.Remove(f.Name()) | ||
| 45 | 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) | ||
| 46 | f.Close() | ||
| 47 | |||
| 48 | ed := exec.Command("sh", "-c", editor+" "+shellQuote(f.Name())) | ||
| 49 | ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr | ||
| 50 | if err := ed.Run(); err != nil { | ||
| 51 | return nil, nil, false, fmt.Errorf("editor: %w", err) | ||
| 52 | } | ||
| 53 | raw, err := os.ReadFile(f.Name()) | ||
| 54 | if err != nil { | ||
| 55 | return nil, nil, false, err | ||
| 56 | } | ||
| 57 | var body strings.Builder | ||
| 58 | for _, line := range strings.Split(string(raw), "\n") { | ||
| 59 | if strings.HasPrefix(line, "#") { | ||
| 60 | continue | ||
| 61 | } | ||
| 62 | body.WriteString(line + "\n") | ||
| 63 | } | ||
| 64 | text := strings.TrimSpace(body.String()) | ||
| 65 | if text == "" { | ||
| 66 | return args, nil, true, nil | ||
| 67 | } | ||
| 68 | return append(args, "--file", "-"), strings.NewReader(text + "\n"), true, nil | ||
| 69 | } | ||
| 70 | |||
| 71 | func runGitLocal(args ...string) int { | ||
| 72 | cmd := exec.Command("git", args...) | ||
| 73 | cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr | ||
| 74 | if err := cmd.Run(); err != nil { | ||
| 75 | if ee, ok := err.(*exec.ExitError); ok { | ||
| 76 | return ee.ExitCode() | ||
| 77 | } | ||
| 78 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 79 | return protocol.ExitFailure | ||
| 80 | } | ||
| 81 | return 0 | ||
| 82 | } | ||
| 83 | |||
| 84 | // cmdRepoClone implements `forge repo clone <owner/name> [dir]`. | ||
| 85 | func cmdRepoClone(args []string) int { | ||
| 86 | if len(args) < 1 || strings.HasPrefix(args[0], "-") { | ||
| 87 | fmt.Fprintln(os.Stderr, "usage: forge repo clone <owner/name> [dir]") | ||
| 88 | return protocol.ExitUsage | ||
| 89 | } | ||
| 90 | t, err := resolveTarget() | ||
| 91 | if err != nil { | ||
| 92 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 93 | return protocol.ExitFailure | ||
| 94 | } | ||
| 95 | gitArgs := append([]string{"clone", t.inst.CloneURL(args[0])}, args[1:]...) | ||
| 96 | if len(t.inst.SSHOptions) > 0 { | ||
| 97 | os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " ")) | ||
| 98 | } | ||
| 99 | return runGitLocal(gitArgs...) | ||
| 100 | } | ||
| 101 | |||
| 102 | // cmdMRCheckout implements `forge mr checkout <n>`: fetch the MR head from | ||
| 103 | // origin and check it out as a local branch. | ||
| 104 | func cmdMRCheckout(args []string) int { | ||
| 105 | if len(args) != 1 { | ||
| 106 | fmt.Fprintln(os.Stderr, "usage: forge mr checkout <n>") | ||
| 107 | return protocol.ExitUsage | ||
| 108 | } | ||
| 109 | n := args[0] | ||
| 110 | ref := "refs/merge-requests/" + n + "/head" | ||
| 111 | if code := runGitLocal("fetch", "origin", ref); code != 0 { | ||
| 112 | return code | ||
| 113 | } | ||
| 114 | return runGitLocal("checkout", "-B", "mr/"+n, "FETCH_HEAD") | ||
| 115 | } | ||
| 116 | |||
| 117 | // cmdInit implements `forge init [name] [--private]`: git init if needed, | ||
| 118 | // create the repository on the default instance, and point origin at it. | ||
| 119 | func cmdInit(args []string) int { | ||
| 120 | var name string | ||
| 121 | private := false | ||
| 122 | for _, a := range args { | ||
| 123 | switch { | ||
| 124 | case a == "--private": | ||
| 125 | private = true | ||
| 126 | case strings.HasPrefix(a, "-"): | ||
| 127 | fmt.Fprintln(os.Stderr, "usage: forge init [name] [--private]") | ||
| 128 | return protocol.ExitUsage | ||
| 129 | default: | ||
| 130 | name = a | ||
| 131 | } | ||
| 132 | } | ||
| 133 | if name == "" { | ||
| 134 | wd, err := os.Getwd() | ||
| 135 | if err != nil { | ||
| 136 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 137 | return protocol.ExitFailure | ||
| 138 | } | ||
| 139 | name = filepath.Base(wd) | ||
| 140 | } | ||
| 141 | |||
| 142 | cfg, err := cliconfig.Load() | ||
| 143 | if err != nil { | ||
| 144 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 145 | return protocol.ExitFailure | ||
| 146 | } | ||
| 147 | inst, _, err := cfg.DefaultInstance() | ||
| 148 | if err != nil { | ||
| 149 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 150 | return protocol.ExitFailure | ||
| 151 | } | ||
| 152 | t := target{inst: inst} | ||
| 153 | |||
| 154 | // The server requires owner = the authenticated user; ask who that is. | ||
| 155 | whoami, code := captureSSH(t, []string{"whoami"}) | ||
| 156 | if code != 0 { | ||
| 157 | return code | ||
| 158 | } | ||
| 159 | username := strings.TrimSpace(whoami) | ||
| 160 | repoPath := username + "/" + name | ||
| 161 | |||
| 162 | createArgs := []string{"repo", "create", repoPath} | ||
| 163 | if private { | ||
| 164 | createArgs = append(createArgs, "--private") | ||
| 165 | } | ||
| 166 | if code := runSSH(t, createArgs, strings.NewReader("")); code != 0 { | ||
| 167 | return code | ||
| 168 | } | ||
| 169 | |||
| 170 | if _, err := os.Stat(".git"); os.IsNotExist(err) { | ||
| 171 | if code := runGitLocal("init", "-q", "-b", "main"); code != 0 { | ||
| 172 | return code | ||
| 173 | } | ||
| 174 | } | ||
| 175 | url := inst.CloneURL(repoPath) | ||
| 176 | if code := runGitLocal("remote", "add", "origin", url); code != 0 { | ||
| 177 | return code | ||
| 178 | } | ||
| 179 | fmt.Printf("origin -> %s\npush with: git push -u origin main\n", url) | ||
| 180 | return 0 | ||
| 181 | } | ||
| 182 | |||
| 183 | // captureSSH runs a server command and returns its stdout. | ||
| 184 | func captureSSH(t target, serverArgv []string) (string, int) { | ||
| 185 | args := []string{} | ||
| 186 | if t.inst.Port != 0 && t.inst.Port != 22 { | ||
| 187 | args = append(args, "-p", fmt.Sprint(t.inst.Port)) | ||
| 188 | } | ||
| 189 | args = append(args, t.inst.SSHOptions...) | ||
| 190 | quoted := quoteAll(serverArgv) | ||
| 191 | args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) | ||
| 192 | cmd := exec.Command("ssh", args...) | ||
| 193 | cmd.Stderr = os.Stderr | ||
| 194 | out, err := cmd.Output() | ||
| 195 | if err != nil { | ||
| 196 | if ee, ok := err.(*exec.ExitError); ok { | ||
| 197 | return "", ee.ExitCode() | ||
| 198 | } | ||
| 199 | fmt.Fprintln(os.Stderr, "forge: running ssh:", err) | ||
| 200 | return "", protocol.ExitProtocol | ||
| 201 | } | ||
| 202 | return string(out), 0 | ||
| 203 | } | ||
| 204 | |||
| 205 | func quoteAll(args []string) []string { | ||
| 206 | out := make([]string, len(args)) | ||
| 207 | for i, a := range args { | ||
| 208 | out[i] = shellQuote(a) | ||
| 209 | } | ||
| 210 | return out | ||
| 211 | } | ||
| 212 | |||
| 213 | // cmdRemoteAdd implements `forge remote add <name> <host> [flags]`. | ||
| 214 | func cmdRemoteAdd(args []string) int { | ||
| 215 | var name, host, user string | ||
| 216 | var port int | ||
| 217 | var setDefault bool | ||
| 218 | var sshOptions []string | ||
| 219 | i := 0 | ||
| 220 | for i < len(args) { | ||
| 221 | a := args[i] | ||
| 222 | switch a { | ||
| 223 | case "--port": | ||
| 224 | if i+1 >= len(args) { | ||
| 225 | fmt.Fprintln(os.Stderr, "--port requires a value") | ||
| 226 | return protocol.ExitUsage | ||
| 227 | } | ||
| 228 | fmt.Sscanf(args[i+1], "%d", &port) | ||
| 229 | i += 2 | ||
| 230 | case "--user": | ||
| 231 | if i+1 >= len(args) { | ||
| 232 | fmt.Fprintln(os.Stderr, "--user requires a value") | ||
| 233 | return protocol.ExitUsage | ||
| 234 | } | ||
| 235 | user = args[i+1] | ||
| 236 | i += 2 | ||
| 237 | case "--ssh-option": | ||
| 238 | if i+1 >= len(args) { | ||
| 239 | fmt.Fprintln(os.Stderr, "--ssh-option requires a value") | ||
| 240 | return protocol.ExitUsage | ||
| 241 | } | ||
| 242 | sshOptions = append(sshOptions, args[i+1]) | ||
| 243 | i += 2 | ||
| 244 | case "--default": | ||
| 245 | setDefault = true | ||
| 246 | i++ | ||
| 247 | default: | ||
| 248 | if name == "" { | ||
| 249 | name = a | ||
| 250 | } else if host == "" { | ||
| 251 | host = a | ||
| 252 | } else { | ||
| 253 | fmt.Fprintln(os.Stderr, "usage: forge remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]") | ||
| 254 | return protocol.ExitUsage | ||
| 255 | } | ||
| 256 | i++ | ||
| 257 | } | ||
| 258 | } | ||
| 259 | if name == "" || host == "" { | ||
| 260 | fmt.Fprintln(os.Stderr, "usage: forge remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]") | ||
| 261 | return protocol.ExitUsage | ||
| 262 | } | ||
| 263 | cfg, err := cliconfig.Load() | ||
| 264 | if err != nil { | ||
| 265 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 266 | return protocol.ExitFailure | ||
| 267 | } | ||
| 268 | cfg.Instances[name] = cliconfig.Instance{Host: host, Port: port, User: user, SSHOptions: sshOptions} | ||
| 269 | if setDefault || cfg.Default == "" { | ||
| 270 | cfg.Default = name | ||
| 271 | } | ||
| 272 | if err := cliconfig.Save(cfg); err != nil { | ||
| 273 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 274 | return protocol.ExitFailure | ||
| 275 | } | ||
| 276 | fmt.Printf("added instance %s (%s)\n", name, host) | ||
| 277 | return 0 | ||
| 278 | } | ||
| 279 | |||
| 280 | func cmdRemoteList() int { | ||
| 281 | cfg, err := cliconfig.Load() | ||
| 282 | if err != nil { | ||
| 283 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 284 | return protocol.ExitFailure | ||
| 285 | } | ||
| 286 | for name, inst := range cfg.Instances { | ||
| 287 | def := "" | ||
| 288 | if name == cfg.Default { | ||
| 289 | def = " (default)" | ||
| 290 | } | ||
| 291 | port := inst.Port | ||
| 292 | if port == 0 { | ||
| 293 | port = 22 | ||
| 294 | } | ||
| 295 | fmt.Printf("%s\t%s@%s:%d%s\n", name, inst.SSHUser(), inst.Host, port, def) | ||
| 296 | } | ||
| 297 | return 0 | ||
| 298 | } | ||
cmd/forge/main.go +196 −62
| @@ -1,13 +1,17 @@ | |||
| 1 | // forge is the client CLI. It speaks to a forge server over the system ssh | 1 | // forge is the client CLI. It is ergonomics over a control plane that is |
| 2 | // binary; it is ergonomics on top of a control plane that is fully usable | 2 | // fully usable from bare OpenSSH: most commands pass through to the server |
| 3 | // from bare OpenSSH. | 3 | // over the system ssh binary, adding instance resolution, repo inference |
| 4 | // from the origin remote, and $EDITOR for long text. | ||
| 4 | package main | 5 | package main |
| 5 | 6 | ||
| 6 | import ( | 7 | import ( |
| 7 | "fmt" | 8 | "fmt" |
| 9 | "io" | ||
| 8 | "os" | 10 | "os" |
| 11 | "strings" | ||
| 9 | 12 | ||
| 10 | "github.com/spf13/cobra" | 13 | "github.com/spf13/cobra" |
| 14 | "github.com/spf13/cobra/doc" | ||
| 11 | 15 | ||
| 12 | "github.com/krazywarez/forge/internal/protocol" | 16 | "github.com/krazywarez/forge/internal/protocol" |
| 13 | ) | 17 | ) |
| @@ -19,8 +23,6 @@ func main() { | |||
| 19 | SilenceUsage: true, | 23 | SilenceUsage: true, |
| 20 | SilenceErrors: true, | 24 | SilenceErrors: true, |
| 21 | } | 25 | } |
| 22 | root.PersistentFlags().Bool("json", false, "machine-readable output") | ||
| 23 | root.PersistentFlags().String("repo", "", "owner/name (default: inferred from the origin remote)") | ||
| 24 | 26 | ||
| 25 | root.AddCommand( | 27 | root.AddCommand( |
| 26 | authCmd(), | 28 | authCmd(), |
| @@ -28,116 +30,248 @@ func main() { | |||
| 28 | issueCmd(), | 30 | issueCmd(), |
| 29 | mrCmd(), | 31 | mrCmd(), |
| 30 | webCmd(), | 32 | webCmd(), |
| 31 | adminCmd(), | ||
| 32 | remoteCmd(), | 33 | remoteCmd(), |
| 33 | initCmd(), | 34 | initCmd(), |
| 35 | manCmd(root), | ||
| 34 | ) | 36 | ) |
| 35 | 37 | ||
| 36 | if err := root.Execute(); err != nil { | 38 | if err := root.Execute(); err != nil { |
| 37 | fmt.Fprintln(os.Stderr, "forge:", err) | 39 | fmt.Fprintln(os.Stderr, "forge:", err) |
| 38 | os.Exit(protocol.ExitFailure) | 40 | os.Exit(protocol.ExitUsage) |
| 39 | } | 41 | } |
| 40 | } | 42 | } |
| 41 | 43 | ||
| 42 | // stub returns a leaf command that fails until its milestone lands. | 44 | // passOpts describes how one CLI command maps onto the server command. |
| 43 | func stub(use, short string) *cobra.Command { | 45 | type passOpts struct { |
| 46 | server []string // server-side command path | ||
| 47 | needsRepo bool // prepend inferred owner/name unless given | ||
| 48 | stdinOK bool // wire local stdin through (keys add, --file -) | ||
| 49 | editor string // open $EDITOR for a body when none given | ||
| 50 | } | ||
| 51 | |||
| 52 | // pass builds a passthrough command. Flags are parsed by the server, which | ||
| 53 | // is the single source of truth for them; the CLI stays thin. | ||
| 54 | func pass(use, short string, o passOpts) *cobra.Command { | ||
| 44 | return &cobra.Command{ | 55 | return &cobra.Command{ |
| 45 | Use: use, | 56 | Use: use, |
| 46 | Short: short, | 57 | Short: short, |
| 58 | DisableFlagParsing: true, | ||
| 47 | RunE: func(cmd *cobra.Command, args []string) error { | 59 | RunE: func(cmd *cobra.Command, args []string) error { |
| 48 | return fmt.Errorf("not implemented") | 60 | // cobra still owns `forge <cmd> --help`. |
| 61 | for _, a := range args { | ||
| 62 | if a == "--help" || a == "-h" { | ||
| 63 | return cmd.Help() | ||
| 64 | } | ||
| 65 | } | ||
| 66 | os.Exit(runPass(o, args)) | ||
| 67 | return nil | ||
| 49 | }, | 68 | }, |
| 50 | } | 69 | } |
| 51 | } | 70 | } |
| 52 | 71 | ||
| 72 | func runPass(o passOpts, args []string) int { | ||
| 73 | t, err := resolveTarget() | ||
| 74 | if err != nil { | ||
| 75 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 76 | return protocol.ExitFailure | ||
| 77 | } | ||
| 78 | if o.needsRepo { | ||
| 79 | args, err = withRepo(t, args) | ||
| 80 | if err != nil { | ||
| 81 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 82 | return protocol.ExitUsage | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | var stdin io.Reader = strings.NewReader("") | ||
| 87 | if o.editor != "" { | ||
| 88 | extended, body, ok, err := maybeEditor(args, o.editor) | ||
| 89 | if err != nil { | ||
| 90 | fmt.Fprintln(os.Stderr, "forge:", err) | ||
| 91 | return protocol.ExitFailure | ||
| 92 | } | ||
| 93 | if !ok { | ||
| 94 | return protocol.ExitFailure | ||
| 95 | } | ||
| 96 | args = extended | ||
| 97 | if body != nil { | ||
| 98 | stdin = body | ||
| 99 | } | ||
| 100 | } | ||
| 101 | if stdin == nil || isEmptyReader(stdin) { | ||
| 102 | if o.stdinOK && usesStdin(args) { | ||
| 103 | stdin = os.Stdin | ||
| 104 | } | ||
| 105 | } | ||
| 106 | return runSSH(t, append(o.server, args...), stdin) | ||
| 107 | } | ||
| 108 | |||
| 109 | func isEmptyReader(r io.Reader) bool { | ||
| 110 | sr, ok := r.(*strings.Reader) | ||
| 111 | return ok && sr.Len() == 0 | ||
| 112 | } | ||
| 113 | |||
| 114 | // usesStdin reports whether the arguments request stdin content. | ||
| 115 | func usesStdin(args []string) bool { | ||
| 116 | for i, a := range args { | ||
| 117 | if a == "--file" && i+1 < len(args) && args[i+1] == "-" { | ||
| 118 | return true | ||
| 119 | } | ||
| 120 | } | ||
| 121 | return false | ||
| 122 | } | ||
| 123 | |||
| 53 | func group(use, short string, subs ...*cobra.Command) *cobra.Command { | 124 | func group(use, short string, subs ...*cobra.Command) *cobra.Command { |
| 54 | c := &cobra.Command{Use: use, Short: short} | 125 | c := &cobra.Command{Use: use, Short: short} |
| 55 | c.AddCommand(subs...) | 126 | c.AddCommand(subs...) |
| 56 | return c | 127 | return c |
| 57 | } | 128 | } |
| 58 | 129 | ||
| 130 | // local wraps a locally-implemented command (git plumbing, config). | ||
| 131 | func local(use, short string, fn func(args []string) int) *cobra.Command { | ||
| 132 | return &cobra.Command{ | ||
| 133 | Use: use, | ||
| 134 | Short: short, | ||
| 135 | DisableFlagParsing: true, | ||
| 136 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 137 | for _, a := range args { | ||
| 138 | if a == "--help" || a == "-h" { | ||
| 139 | return cmd.Help() | ||
| 140 | } | ||
| 141 | } | ||
| 142 | os.Exit(fn(args)) | ||
| 143 | return nil | ||
| 144 | }, | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 59 | func authCmd() *cobra.Command { | 148 | func authCmd() *cobra.Command { |
| 60 | return group("auth", "identity: keys, emails, whoami", | 149 | keysAdd := pass("add", "register an SSH public key (reads the key from stdin or --file -)", |
| 61 | stub("whoami", "show the authenticated account"), | 150 | passOpts{server: []string{"keys", "add"}, stdinOK: true}) |
| 151 | // keys add always reads stdin on the server; wire it through directly. | ||
| 152 | keysAdd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 153 | t, err := resolveTarget() | ||
| 154 | if err != nil { | ||
| 155 | return err | ||
| 156 | } | ||
| 157 | os.Exit(runSSH(t, append([]string{"keys", "add"}, args...), os.Stdin)) | ||
| 158 | return nil | ||
| 159 | } | ||
| 160 | pgpAdd := &cobra.Command{ | ||
| 161 | Use: "add", Short: "register an OpenPGP public key (armored, on stdin)", | ||
| 162 | DisableFlagParsing: true, | ||
| 163 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 164 | t, err := resolveTarget() | ||
| 165 | if err != nil { | ||
| 166 | return err | ||
| 167 | } | ||
| 168 | os.Exit(runSSH(t, append([]string{"pgp", "add"}, args...), os.Stdin)) | ||
| 169 | return nil | ||
| 170 | }, | ||
| 171 | } | ||
| 172 | return group("auth", "identity: whoami, SSH and PGP keys", | ||
| 173 | pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}), | ||
| 62 | group("keys", "manage SSH keys", | 174 | group("keys", "manage SSH keys", |
| 63 | stub("list", "list registered SSH keys"), | 175 | pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}), |
| 64 | stub("add", "register an SSH key"), | 176 | keysAdd, |
| 65 | stub("remove", "remove an SSH key"), | 177 | pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}), |
| 66 | ), | 178 | ), |
| 67 | group("pgp", "manage OpenPGP keys", | 179 | group("pgp", "manage OpenPGP keys", |
| 68 | stub("list", "list registered PGP keys"), | 180 | pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}), |
| 69 | stub("add", "register a PGP key"), | 181 | pgpAdd, |
| 70 | stub("remove", "remove a PGP key"), | 182 | pass("remove", "remove a PGP key by fingerprint", passOpts{server: []string{"pgp", "remove"}}), |
| 71 | ), | ||
| 72 | group("email", "manage email addresses", | ||
| 73 | stub("add", "add an address"), | ||
| 74 | stub("verify", "confirm a verification code"), | ||
| 75 | ), | 183 | ), |
| 76 | ) | 184 | ) |
| 77 | } | 185 | } |
| 78 | 186 | ||
| 79 | func repoCmd() *cobra.Command { | 187 | func repoCmd() *cobra.Command { |
| 80 | return group("repo", "create and manage repositories", | 188 | return group("repo", "create and manage repositories", |
| 81 | stub("create", "create a repository"), | 189 | pass("create", "create a repository: forge repo create <owner/name> [--private]", |
| 82 | stub("list", "list repositories"), | 190 | passOpts{server: []string{"repo", "create"}}), |
| 83 | stub("show", "show repository details"), | 191 | pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}), |
| 84 | stub("clone", "clone via ssh"), | 192 | pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), |
| 85 | stub("rename", "rename a repository"), | 193 | pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), |
| 86 | stub("delete", "delete a repository"), | 194 | pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), |
| 87 | stub("fork", "fork a repository"), | 195 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 88 | stub("import", "server-side mirror from a foreign URL"), | 196 | local("clone", "clone via ssh: forge repo clone <owner/name> [dir]", cmdRepoClone), |
| 89 | stub("settings", "get or set repository settings"), | 197 | group("access", "manage access grants", |
| 198 | pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}), | ||
| 199 | pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}), | ||
| 200 | pass("list", "list access grants", passOpts{server: []string{"repo", "access", "list"}, needsRepo: true}), | ||
| 201 | ), | ||
| 202 | group("settings", "repository settings", | ||
| 203 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), | ||
| 204 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), | ||
| 205 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), | ||
| 206 | pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), | ||
| 207 | pass("git-daemon", "expose over git://: ... on|off", passOpts{server: []string{"repo", "settings", "git-daemon"}, needsRepo: true}), | ||
| 208 | ), | ||
| 90 | ) | 209 | ) |
| 91 | } | 210 | } |
| 92 | 211 | ||
| 93 | func issueCmd() *cobra.Command { | 212 | func issueCmd() *cobra.Command { |
| 94 | return group("issue", "issues", | 213 | return group("issue", "issues", |
| 95 | stub("create", "open an issue"), | 214 | pass("create", "open an issue: --title <t> [--body|--file -|$EDITOR]", |
| 96 | stub("list", "list issues"), | 215 | passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}), |
| 97 | stub("show", "show an issue"), | 216 | pass("list", "list issues [--state open|closed|all]", passOpts{server: []string{"issue", "list"}, needsRepo: true}), |
| 98 | stub("comment", "comment on an issue"), | 217 | pass("show", "show an issue with comments", passOpts{server: []string{"issue", "show"}, needsRepo: true}), |
| 99 | stub("close", "close an issue"), | 218 | pass("comment", "comment on an issue [--message|--file -|$EDITOR]", |
| 100 | stub("reopen", "reopen an issue"), | 219 | passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 101 | stub("label", "add or remove labels"), | 220 | pass("close", "close an issue", passOpts{server: []string{"issue", "close"}, needsRepo: true}), |
| 102 | stub("assign", "assign users"), | 221 | pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), |
| 222 | pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}), | ||
| 223 | pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}), | ||
| 103 | ) | 224 | ) |
| 104 | } | 225 | } |
| 105 | 226 | ||
| 106 | func mrCmd() *cobra.Command { | 227 | func mrCmd() *cobra.Command { |
| 107 | return group("mr", "merge requests", | 228 | return group("mr", "merge requests", |
| 108 | stub("create", "open a merge request"), | 229 | pass("create", "open a merge request: --source <branch> --target <branch> --title <t>", |
| 109 | stub("list", "list merge requests"), | 230 | passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request"}), |
| 110 | stub("show", "show a merge request"), | 231 | pass("list", "list merge requests [--state ...]", passOpts{server: []string{"mr", "list"}, needsRepo: true}), |
| 111 | stub("diff", "show the diff"), | 232 | pass("show", "show a merge request", passOpts{server: []string{"mr", "show"}, needsRepo: true}), |
| 112 | stub("checkout", "fetch and check out the MR head locally"), | 233 | pass("diff", "show the diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), |
| 113 | stub("comment", "comment on a merge request"), | 234 | local("checkout", "fetch and check out the MR head locally: forge mr checkout <n>", cmdMRCheckout), |
| 114 | stub("review", "approve or request changes"), | 235 | pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 115 | stub("merge", "merge (fast-forward or merge-commit)"), | 236 | pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), |
| 116 | stub("close", "close without merging"), | 237 | pass("merge", "merge (fast-forward or merge-commit): [--strategy ff|merge]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), |
| 238 | pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}), | ||
| 117 | ) | 239 | ) |
| 118 | } | 240 | } |
| 119 | 241 | ||
| 120 | func webCmd() *cobra.Command { | 242 | func webCmd() *cobra.Command { |
| 121 | return group("web", "browser session", | 243 | return group("web", "browser session", |
| 122 | stub("login", "mint a one-time browser login URL over ssh"), | 244 | pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}), |
| 123 | ) | ||
| 124 | } | ||
| 125 | |||
| 126 | func adminCmd() *cobra.Command { | ||
| 127 | return group("admin", "instance administration (admin accounts only)", | ||
| 128 | stub("user", "manage users"), | ||
| 129 | stub("invite", "issue registration invites"), | ||
| 130 | stub("stats", "instance statistics"), | ||
| 131 | ) | 245 | ) |
| 132 | } | 246 | } |
| 133 | 247 | ||
| 134 | func remoteCmd() *cobra.Command { | 248 | func remoteCmd() *cobra.Command { |
| 135 | return group("remote", "local instance profiles (no server contact)", | 249 | return group("remote", "local instance profiles (no server contact)", |
| 136 | stub("add", "add a named forge instance"), | 250 | local("add", "add a named forge instance: forge remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]", |
| 137 | stub("list", "list configured instances"), | 251 | cmdRemoteAdd), |
| 252 | local("list", "list configured instances", func([]string) int { return cmdRemoteList() }), | ||
| 138 | ) | 253 | ) |
| 139 | } | 254 | } |
| 140 | 255 | ||
| 141 | func initCmd() *cobra.Command { | 256 | func initCmd() *cobra.Command { |
| 142 | return stub("init", "git init + repo create + set origin, in one step") | 257 | return local("init", "git init + repo create + set origin, in one step: forge init [name] [--private]", cmdInit) |
| 258 | } | ||
| 259 | |||
| 260 | // manCmd generates man pages; a CLI-first tool without man pages is not | ||
| 261 | // CLI-first. | ||
| 262 | func manCmd(root *cobra.Command) *cobra.Command { | ||
| 263 | var dir string | ||
| 264 | cmd := &cobra.Command{ | ||
| 265 | Use: "man", | ||
| 266 | Short: "generate man pages into a directory", | ||
| 267 | Hidden: true, | ||
| 268 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 269 | if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| 270 | return err | ||
| 271 | } | ||
| 272 | return doc.GenManTree(root, &doc.GenManHeader{Title: "FORGE", Section: "1"}, dir) | ||
| 273 | }, | ||
| 274 | } | ||
| 275 | cmd.Flags().StringVar(&dir, "dir", "man", "output directory") | ||
| 276 | return cmd | ||
| 143 | } | 277 | } |
cmd/forge/ssh.go added +123
| @@ -0,0 +1,123 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "io" | ||
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | "regexp" | ||
| 9 | "strconv" | ||
| 10 | "strings" | ||
| 11 | |||
| 12 | "github.com/krazywarez/forge/internal/cliconfig" | ||
| 13 | "github.com/krazywarez/forge/internal/protocol" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // context is the resolved target for a command: which instance to talk to | ||
| 17 | // and, when run inside a clone of a forge repo, which repository. | ||
| 18 | type target struct { | ||
| 19 | inst cliconfig.Instance | ||
| 20 | repo string // owner/name, "" when not inferable | ||
| 21 | } | ||
| 22 | |||
| 23 | // resolveTarget picks the instance and repo. Inside a git repo whose origin | ||
| 24 | // remote points at a configured (or any ssh) forge host, that wins; | ||
| 25 | // otherwise the configured default instance. | ||
| 26 | func resolveTarget() (target, error) { | ||
| 27 | cfg, err := cliconfig.Load() | ||
| 28 | if err != nil { | ||
| 29 | return target{}, err | ||
| 30 | } | ||
| 31 | |||
| 32 | if url := originURL(); url != "" { | ||
| 33 | if parsed, repo, ok := cliconfig.ParseRemoteURL(url); ok { | ||
| 34 | // Prefer a configured instance for the same host+port: it may | ||
| 35 | // carry ssh_options the bare URL cannot express. | ||
| 36 | norm := func(p int) int { | ||
| 37 | if p == 0 { | ||
| 38 | return 22 | ||
| 39 | } | ||
| 40 | return p | ||
| 41 | } | ||
| 42 | for _, inst := range cfg.Instances { | ||
| 43 | if inst.Host == parsed.Host && norm(inst.Port) == norm(parsed.Port) { | ||
| 44 | return target{inst: inst, repo: repo}, nil | ||
| 45 | } | ||
| 46 | } | ||
| 47 | return target{inst: parsed, repo: repo}, nil | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | inst, _, err := cfg.DefaultInstance() | ||
| 52 | if err != nil { | ||
| 53 | return target{}, err | ||
| 54 | } | ||
| 55 | return target{inst: inst}, nil | ||
| 56 | } | ||
| 57 | |||
| 58 | func originURL() string { | ||
| 59 | out, err := exec.Command("git", "remote", "get-url", "origin").Output() | ||
| 60 | if err != nil { | ||
| 61 | return "" | ||
| 62 | } | ||
| 63 | return strings.TrimSpace(string(out)) | ||
| 64 | } | ||
| 65 | |||
| 66 | // bareWord matches arguments that need no quoting for the server-side | ||
| 67 | // POSIX tokenizer. | ||
| 68 | var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`) | ||
| 69 | |||
| 70 | // shellQuote quotes one argument for the SSH command string; the server | ||
| 71 | // tokenizes with POSIX rules and no expansion. | ||
| 72 | func shellQuote(arg string) string { | ||
| 73 | if arg != "" && bareWord.MatchString(arg) { | ||
| 74 | return arg | ||
| 75 | } | ||
| 76 | return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" | ||
| 77 | } | ||
| 78 | |||
| 79 | // runSSH executes the server command over the system ssh binary, wiring | ||
| 80 | // stdio through. It returns the remote exit code. | ||
| 81 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { | ||
| 82 | args := []string{} | ||
| 83 | if t.inst.Port != 0 && t.inst.Port != 22 { | ||
| 84 | args = append(args, "-p", strconv.Itoa(t.inst.Port)) | ||
| 85 | } | ||
| 86 | args = append(args, t.inst.SSHOptions...) | ||
| 87 | quoted := make([]string, len(serverArgv)) | ||
| 88 | for i, a := range serverArgv { | ||
| 89 | quoted[i] = shellQuote(a) | ||
| 90 | } | ||
| 91 | args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " ")) | ||
| 92 | |||
| 93 | cmd := exec.Command("ssh", args...) | ||
| 94 | cmd.Stdin = stdin | ||
| 95 | cmd.Stdout = os.Stdout | ||
| 96 | cmd.Stderr = os.Stderr | ||
| 97 | err := cmd.Run() | ||
| 98 | if err == nil { | ||
| 99 | return 0 | ||
| 100 | } | ||
| 101 | if ee, ok := err.(*exec.ExitError); ok { | ||
| 102 | code := ee.ExitCode() | ||
| 103 | if code == 255 { // ssh-level failure (connection, auth, host key) | ||
| 104 | return protocol.ExitProtocol | ||
| 105 | } | ||
| 106 | return code | ||
| 107 | } | ||
| 108 | fmt.Fprintln(os.Stderr, "forge: running ssh:", err) | ||
| 109 | return protocol.ExitProtocol | ||
| 110 | } | ||
| 111 | |||
| 112 | // withRepo prepends the repo path to args unless the user already gave one | ||
| 113 | // explicitly (a first argument containing '/'). Commands' server parsers | ||
| 114 | // accept the path at any position, so the front is always safe. | ||
| 115 | func withRepo(t target, args []string) ([]string, error) { | ||
| 116 | if len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/") { | ||
| 117 | return args, nil // explicit owner/name | ||
| 118 | } | ||
| 119 | if t.repo == "" { | ||
| 120 | return nil, fmt.Errorf("no repository given and none inferable: pass <owner/name> or run inside a clone of a forge repository") | ||
| 121 | } | ||
| 122 | return append([]string{t.repo}, args...), nil | ||
| 123 | } | ||
e2e/cli_test.go added +205
| @@ -0,0 +1,205 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func buildForgeCLI(t *testing.T) string { | ||
| 14 | t.Helper() | ||
| 15 | bin := filepath.Join(t.TempDir(), "forge") | ||
| 16 | cmd := exec.Command("go", "build", "-o", bin, "github.com/krazywarez/forge/cmd/forge") | ||
| 17 | cmd.Dir = ".." | ||
| 18 | if out, err := cmd.CombinedOutput(); err != nil { | ||
| 19 | t.Fatalf("build forge: %v\n%s", err, out) | ||
| 20 | } | ||
| 21 | return bin | ||
| 22 | } | ||
| 23 | |||
| 24 | // cli runs the forge binary with an isolated config home. | ||
| 25 | type cli struct { | ||
| 26 | bin string | ||
| 27 | configDir string | ||
| 28 | inst *instance | ||
| 29 | key string | ||
| 30 | } | ||
| 31 | |||
| 32 | func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, string, int) { | ||
| 33 | t.Helper() | ||
| 34 | cmd := exec.Command(c.bin, args...) | ||
| 35 | cmd.Dir = dir | ||
| 36 | cmd.Env = append(os.Environ(), | ||
| 37 | "XDG_CONFIG_HOME="+c.configDir, | ||
| 38 | "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", | ||
| 39 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", | ||
| 40 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", | ||
| 41 | "EDITOR=", // no editor in tests: bodies come from flags | ||
| 42 | ) | ||
| 43 | if stdin != "" { | ||
| 44 | cmd.Stdin = strings.NewReader(stdin) | ||
| 45 | } | ||
| 46 | var out, errOut strings.Builder | ||
| 47 | cmd.Stdout = &out | ||
| 48 | cmd.Stderr = &errOut | ||
| 49 | err := cmd.Run() | ||
| 50 | code := 0 | ||
| 51 | if ee, ok := err.(*exec.ExitError); ok { | ||
| 52 | code = ee.ExitCode() | ||
| 53 | } else if err != nil { | ||
| 54 | t.Fatalf("forge %v: %v", args, err) | ||
| 55 | } | ||
| 56 | return out.String(), errOut.String(), code | ||
| 57 | } | ||
| 58 | |||
| 59 | func (c *cli) must(t *testing.T, dir, stdin string, args ...string) string { | ||
| 60 | t.Helper() | ||
| 61 | out, errOut, code := c.run(t, dir, stdin, args...) | ||
| 62 | if code != 0 { | ||
| 63 | t.Fatalf("forge %v: exit %d\nstdout: %s\nstderr: %s", args, code, out, errOut) | ||
| 64 | } | ||
| 65 | return out | ||
| 66 | } | ||
| 67 | |||
| 68 | func TestCLI(t *testing.T) { | ||
| 69 | inst := startInstance(t) | ||
| 70 | aliceKey := inst.newKey(t, "alice") | ||
| 71 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 72 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 73 | |||
| 74 | c := &cli{ | ||
| 75 | bin: buildForgeCLI(t), | ||
| 76 | configDir: t.TempDir(), | ||
| 77 | inst: inst, | ||
| 78 | key: aliceKey, | ||
| 79 | } | ||
| 80 | |||
| 81 | // Configure the instance, with ssh options so the test's throwaway key | ||
| 82 | // and known_hosts are used. | ||
| 83 | c.must(t, "", "", "remote", "add", "test", "127.0.0.1", | ||
| 84 | "--port", fmt.Sprint(inst.port), | ||
| 85 | "--ssh-option", "-i", "--ssh-option", aliceKey, | ||
| 86 | "--ssh-option", "-oIdentitiesOnly=yes", | ||
| 87 | "--ssh-option", "-oStrictHostKeyChecking=no", | ||
| 88 | "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"), | ||
| 89 | "--ssh-option", "-oBatchMode=yes", | ||
| 90 | "--default") | ||
| 91 | if out := c.must(t, "", "", "remote", "list"); !strings.Contains(out, "test\tgit@127.0.0.1") || !strings.Contains(out, "(default)") { | ||
| 92 | t.Fatalf("remote list: %s", out) | ||
| 93 | } | ||
| 94 | |||
| 95 | // whoami through the CLI, JSON passthrough intact. | ||
| 96 | out := c.must(t, "", "", "auth", "whoami", "--json") | ||
| 97 | var env struct { | ||
| 98 | ProtocolVersion int `json:"protocol_version"` | ||
| 99 | Data struct { | ||
| 100 | Username string `json:"username"` | ||
| 101 | } `json:"data"` | ||
| 102 | } | ||
| 103 | if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Username != "alice" || env.ProtocolVersion != 1 { | ||
| 104 | t.Fatalf("whoami via CLI: %v %s", err, out) | ||
| 105 | } | ||
| 106 | |||
| 107 | // Repo create + clone through the CLI. | ||
| 108 | c.must(t, "", "", "repo", "create", "alice/proj") | ||
| 109 | work := t.TempDir() | ||
| 110 | c.must(t, work, "", "repo", "clone", "alice/proj") | ||
| 111 | dir := filepath.Join(work, "proj") | ||
| 112 | if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { | ||
| 113 | t.Fatal("clone did not produce a repo") | ||
| 114 | } | ||
| 115 | |||
| 116 | // Push some content (plain git, using the clone's remote). | ||
| 117 | cliGitEnv := inst.gitEnv(aliceKey) | ||
| 118 | os.WriteFile(filepath.Join(dir, "README"), []byte("hi\n"), 0o644) | ||
| 119 | mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "main") | ||
| 120 | mustGit(t, dir, cliGitEnv, "add", ".") | ||
| 121 | mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "init") | ||
| 122 | mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "main") | ||
| 123 | |||
| 124 | // Inside the clone, the repo argument is inferred from origin. | ||
| 125 | c.must(t, dir, "", "issue", "create", "--title", "inferred repo works", "--body", "body") | ||
| 126 | out = c.must(t, dir, "", "issue", "list") | ||
| 127 | if !strings.Contains(out, "inferred repo works") { | ||
| 128 | t.Fatalf("issue list in clone: %s", out) | ||
| 129 | } | ||
| 130 | // Explicit owner/name still works from anywhere. | ||
| 131 | out = c.must(t, "", "", "issue", "show", "alice/proj", "1") | ||
| 132 | if !strings.Contains(out, "inferred repo works") { | ||
| 133 | t.Fatalf("issue show explicit: %s", out) | ||
| 134 | } | ||
| 135 | // Outside a clone with no explicit repo: usage error, not a hang. | ||
| 136 | _, errOut, code := c.run(t, "", "", "issue", "list") | ||
| 137 | if code != 2 || !strings.Contains(errOut, "none inferable") { | ||
| 138 | t.Fatalf("bare issue list outside clone: exit %d, %s", code, errOut) | ||
| 139 | } | ||
| 140 | |||
| 141 | // Exit codes pass through: missing issue is 3. | ||
| 142 | if _, _, code := c.run(t, dir, "", "issue", "show", "99"); code != 3 { | ||
| 143 | t.Fatalf("missing issue via CLI: exit %d, want 3", code) | ||
| 144 | } | ||
| 145 | |||
| 146 | // MR flow: branch, push, create (inferred), checkout, merge. | ||
| 147 | mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "feature") | ||
| 148 | os.WriteFile(filepath.Join(dir, "f.txt"), []byte("feature\n"), 0o644) | ||
| 149 | mustGit(t, dir, cliGitEnv, "add", ".") | ||
| 150 | mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "feature work") | ||
| 151 | mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "feature") | ||
| 152 | c.must(t, dir, "", "mr", "create", "--source", "feature", "--target", "main", "--title", "via cli") | ||
| 153 | |||
| 154 | // mr checkout uses the clone's own git; the MR ref comes from origin. | ||
| 155 | mustGit(t, dir, cliGitEnv, "checkout", "-q", "main") | ||
| 156 | cmd := exec.Command(c.bin, "mr", "checkout", "1") | ||
| 157 | cmd.Dir = dir | ||
| 158 | cmd.Env = append(cliGitEnv, "XDG_CONFIG_HOME="+c.configDir) | ||
| 159 | if out, err := cmd.CombinedOutput(); err != nil { | ||
| 160 | t.Fatalf("mr checkout: %v\n%s", err, out) | ||
| 161 | } | ||
| 162 | branch := strings.TrimSpace(mustGit(t, dir, cliGitEnv, "rev-parse", "--abbrev-ref", "HEAD")) | ||
| 163 | if branch != "mr/1" { | ||
| 164 | t.Fatalf("mr checkout branch = %s", branch) | ||
| 165 | } | ||
| 166 | if _, err := os.Stat(filepath.Join(dir, "f.txt")); err != nil { | ||
| 167 | t.Fatal("mr checkout content missing") | ||
| 168 | } | ||
| 169 | |||
| 170 | c.must(t, dir, "", "mr", "merge", "1") | ||
| 171 | out = c.must(t, dir, "", "mr", "show", "1") | ||
| 172 | if !strings.Contains(out, "merged") { | ||
| 173 | t.Fatalf("mr not merged: %s", out) | ||
| 174 | } | ||
| 175 | |||
| 176 | // keys add reads the public key from CLI stdin. | ||
| 177 | secondKey := inst.newKey(t, "alice2") | ||
| 178 | pub, _ := os.ReadFile(secondKey + ".pub") | ||
| 179 | c.must(t, "", string(pub), "auth", "keys", "add", "--scope", "git") | ||
| 180 | if out = c.must(t, "", "", "auth", "keys", "list"); len(strings.Split(strings.TrimSpace(out), "\n")) != 2 { | ||
| 181 | t.Fatalf("keys list: %s", out) | ||
| 182 | } | ||
| 183 | |||
| 184 | // forge init: new local project, repo created server-side, origin set. | ||
| 185 | proj := filepath.Join(t.TempDir(), "newthing") | ||
| 186 | os.MkdirAll(proj, 0o755) | ||
| 187 | c.must(t, proj, "", "init", "--private") | ||
| 188 | originOut := mustGit(t, proj, cliGitEnv, "remote", "get-url", "origin") | ||
| 189 | if !strings.Contains(originOut, "/alice/newthing.git") { | ||
| 190 | t.Fatalf("init origin: %s", originOut) | ||
| 191 | } | ||
| 192 | if out = c.must(t, "", "", "repo", "show", "alice/newthing"); !strings.Contains(out, "private") { | ||
| 193 | t.Fatalf("init-created repo: %s", out) | ||
| 194 | } | ||
| 195 | |||
| 196 | // Man pages and completions generate. | ||
| 197 | manDir := t.TempDir() | ||
| 198 | c.must(t, "", "", "man", "--dir", manDir) | ||
| 199 | if entries, _ := os.ReadDir(manDir); len(entries) < 10 { | ||
| 200 | t.Fatalf("man pages: only %d generated", len(entries)) | ||
| 201 | } | ||
| 202 | if out = c.must(t, "", "", "completion", "zsh"); !strings.Contains(out, "compdef") { | ||
| 203 | t.Fatal("zsh completion missing") | ||
| 204 | } | ||
| 205 | } | ||
go.mod +4
| @@ -9,11 +9,13 @@ require ( | |||
| 9 | github.com/spf13/cobra v1.10.2 | 9 | github.com/spf13/cobra v1.10.2 |
| 10 | github.com/yuin/goldmark v1.8.5 | 10 | github.com/yuin/goldmark v1.8.5 |
| 11 | golang.org/x/crypto v0.55.0 | 11 | golang.org/x/crypto v0.55.0 |
| 12 | golang.org/x/term v0.45.0 | ||
| 12 | modernc.org/sqlite v1.57.0 | 13 | modernc.org/sqlite v1.57.0 |
| 13 | ) | 14 | ) |
| 14 | 15 | ||
| 15 | require ( | 16 | require ( |
| 16 | github.com/cloudflare/circl v1.6.2 // indirect | 17 | github.com/cloudflare/circl v1.6.2 // indirect |
| 18 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect | ||
| 17 | github.com/dlclark/regexp2/v2 v2.2.1 // indirect | 19 | github.com/dlclark/regexp2/v2 v2.2.1 // indirect |
| 18 | github.com/dustin/go-humanize v1.0.1 // indirect | 20 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 19 | github.com/google/uuid v1.6.0 // indirect | 21 | github.com/google/uuid v1.6.0 // indirect |
| @@ -21,7 +23,9 @@ require ( | |||
| 21 | github.com/mattn/go-isatty v0.0.24 // indirect | 23 | github.com/mattn/go-isatty v0.0.24 // indirect |
| 22 | github.com/ncruces/go-strftime v1.0.0 // indirect | 24 | github.com/ncruces/go-strftime v1.0.0 // indirect |
| 23 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | 25 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| 26 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||
| 24 | github.com/spf13/pflag v1.0.9 // indirect | 27 | github.com/spf13/pflag v1.0.9 // indirect |
| 28 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 25 | golang.org/x/sys v0.47.0 // indirect | 29 | golang.org/x/sys v0.47.0 // indirect |
| 26 | modernc.org/libc v1.74.4 // indirect | 30 | modernc.org/libc v1.74.4 // indirect |
| 27 | modernc.org/mathutil v1.7.1 // indirect | 31 | modernc.org/mathutil v1.7.1 // indirect |
go.sum +4
| @@ -10,6 +10,7 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs | |||
| 10 | github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= | 10 | github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= |
| 11 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= | 11 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= |
| 12 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= | 12 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= |
| 13 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= | ||
| 13 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | 14 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= |
| 14 | github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= | 15 | github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= |
| 15 | github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= | 16 | 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 | |||
| 31 | github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= | 32 | github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| 32 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | 33 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| 33 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | 34 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| 35 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= | ||
| 34 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | 36 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= |
| 35 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= | 37 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= |
| 36 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= | 38 | 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= | |||
| 38 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | 40 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= |
| 39 | github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= | 41 | github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= |
| 40 | github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= | 42 | github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= |
| 43 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | ||
| 41 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | 44 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= |
| 42 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= | 45 | golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| 43 | golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= | 46 | 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= | |||
| 51 | golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= | 54 | golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= |
| 52 | golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= | 55 | golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| 53 | golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= | 56 | golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| 57 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | ||
| 54 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 55 | modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= | 59 | modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= |
| 56 | modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= | 60 | modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
internal/cliconfig/cliconfig.go added +120
| @@ -0,0 +1,120 @@ | |||
| 1 | // Package cliconfig manages the client-side configuration: named forge | ||
| 2 | // instances at ~/.config/forge/config.toml, and parsing of origin remote | ||
| 3 | // URLs so commands run inside a clone need no --repo argument. | ||
| 4 | package cliconfig | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "fmt" | ||
| 8 | "os" | ||
| 9 | "path/filepath" | ||
| 10 | "regexp" | ||
| 11 | "strings" | ||
| 12 | |||
| 13 | "github.com/BurntSushi/toml" | ||
| 14 | ) | ||
| 15 | |||
| 16 | type Instance struct { | ||
| 17 | Host string `toml:"host"` | ||
| 18 | Port int `toml:"port,omitempty"` | ||
| 19 | User string `toml:"user,omitempty"` | ||
| 20 | // SSHOptions are extra arguments passed to the ssh binary verbatim, | ||
| 21 | // e.g. ["-i", "~/.ssh/forge_ed25519"]. Most setups need none: the | ||
| 22 | // system ssh already honors ~/.ssh/config and the agent. | ||
| 23 | SSHOptions []string `toml:"ssh_options,omitempty"` | ||
| 24 | } | ||
| 25 | |||
| 26 | func (i Instance) SSHUser() string { | ||
| 27 | if i.User != "" { | ||
| 28 | return i.User | ||
| 29 | } | ||
| 30 | return "git" | ||
| 31 | } | ||
| 32 | |||
| 33 | // CloneURL returns the ssh:// URL for owner/name on this instance. | ||
| 34 | func (i Instance) CloneURL(repo string) string { | ||
| 35 | hostport := i.Host | ||
| 36 | if i.Port != 0 && i.Port != 22 { | ||
| 37 | hostport = fmt.Sprintf("%s:%d", i.Host, i.Port) | ||
| 38 | } | ||
| 39 | return fmt.Sprintf("ssh://%s@%s/%s.git", i.SSHUser(), hostport, repo) | ||
| 40 | } | ||
| 41 | |||
| 42 | type Config struct { | ||
| 43 | Default string `toml:"default,omitempty"` | ||
| 44 | Instances map[string]Instance `toml:"instances"` | ||
| 45 | } | ||
| 46 | |||
| 47 | func Path() string { | ||
| 48 | if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { | ||
| 49 | return filepath.Join(x, "forge", "config.toml") | ||
| 50 | } | ||
| 51 | home, _ := os.UserHomeDir() | ||
| 52 | return filepath.Join(home, ".config", "forge", "config.toml") | ||
| 53 | } | ||
| 54 | |||
| 55 | func Load() (Config, error) { | ||
| 56 | cfg := Config{Instances: map[string]Instance{}} | ||
| 57 | raw, err := os.ReadFile(Path()) | ||
| 58 | if os.IsNotExist(err) { | ||
| 59 | return cfg, nil | ||
| 60 | } | ||
| 61 | if err != nil { | ||
| 62 | return cfg, err | ||
| 63 | } | ||
| 64 | if err := toml.Unmarshal(raw, &cfg); err != nil { | ||
| 65 | return cfg, fmt.Errorf("%s: %w", Path(), err) | ||
| 66 | } | ||
| 67 | if cfg.Instances == nil { | ||
| 68 | cfg.Instances = map[string]Instance{} | ||
| 69 | } | ||
| 70 | return cfg, nil | ||
| 71 | } | ||
| 72 | |||
| 73 | func Save(cfg Config) error { | ||
| 74 | p := Path() | ||
| 75 | if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { | ||
| 76 | return err | ||
| 77 | } | ||
| 78 | var b strings.Builder | ||
| 79 | if err := toml.NewEncoder(&b).Encode(cfg); err != nil { | ||
| 80 | return err | ||
| 81 | } | ||
| 82 | return os.WriteFile(p, []byte(b.String()), 0o600) | ||
| 83 | } | ||
| 84 | |||
| 85 | // DefaultInstance returns the configured default (or the only) instance. | ||
| 86 | func (c Config) DefaultInstance() (Instance, string, error) { | ||
| 87 | if c.Default != "" { | ||
| 88 | if inst, ok := c.Instances[c.Default]; ok { | ||
| 89 | return inst, c.Default, nil | ||
| 90 | } | ||
| 91 | return Instance{}, "", fmt.Errorf("default instance %q is not configured", c.Default) | ||
| 92 | } | ||
| 93 | if len(c.Instances) == 1 { | ||
| 94 | for name, inst := range c.Instances { | ||
| 95 | return inst, name, nil | ||
| 96 | } | ||
| 97 | } | ||
| 98 | return Instance{}, "", fmt.Errorf("no forge instance configured; run: forge remote add <name> <host>") | ||
| 99 | } | ||
| 100 | |||
| 101 | var ( | ||
| 102 | sshURLPat = regexp.MustCompile(`^ssh://(?:([^@/]+)@)?([^:/]+)(?::(\d+))?/(.+?)(?:\.git)?/?$`) | ||
| 103 | scpPat = regexp.MustCompile(`^(?:([^@/]+)@)?([^:/]+):(.+?)(?:\.git)?$`) | ||
| 104 | ) | ||
| 105 | |||
| 106 | // ParseRemoteURL extracts the instance coordinates and owner/name from a | ||
| 107 | // git remote URL in ssh:// or scp-like form. | ||
| 108 | func ParseRemoteURL(url string) (Instance, string, bool) { | ||
| 109 | if m := sshURLPat.FindStringSubmatch(url); m != nil { | ||
| 110 | inst := Instance{Host: m[2], User: m[1]} | ||
| 111 | if m[3] != "" { | ||
| 112 | fmt.Sscanf(m[3], "%d", &inst.Port) | ||
| 113 | } | ||
| 114 | return inst, strings.Trim(m[4], "/"), true | ||
| 115 | } | ||
| 116 | if m := scpPat.FindStringSubmatch(url); m != nil && !strings.Contains(url, "://") { | ||
| 117 | return Instance{Host: m[2], User: m[1]}, strings.Trim(m[3], "/"), true | ||
| 118 | } | ||
| 119 | return Instance{}, "", false | ||
| 120 | } | ||
internal/cliconfig/cliconfig_test.go added +46
| @@ -0,0 +1,46 @@ | |||
| 1 | package cliconfig | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestParseRemoteURL(t *testing.T) { | ||
| 6 | cases := []struct { | ||
| 7 | url string | ||
| 8 | host string | ||
| 9 | port int | ||
| 10 | user string | ||
| 11 | repo string | ||
| 12 | ok bool | ||
| 13 | }{ | ||
| 14 | {"ssh://git@forge.example/alice/proj.git", "forge.example", 0, "git", "alice/proj", true}, | ||
| 15 | {"ssh://git@forge.example:2222/alice/proj.git", "forge.example", 2222, "git", "alice/proj", true}, | ||
| 16 | {"ssh://forge.example/alice/proj", "forge.example", 0, "", "alice/proj", true}, | ||
| 17 | {"git@forge.example:alice/proj.git", "forge.example", 0, "git", "alice/proj", true}, | ||
| 18 | {"git@forge.example:alice/proj", "forge.example", 0, "git", "alice/proj", true}, | ||
| 19 | {"https://forge.example/alice/proj.git", "", 0, "", "", false}, | ||
| 20 | {"/local/path/repo.git", "", 0, "", "", false}, | ||
| 21 | } | ||
| 22 | for _, tc := range cases { | ||
| 23 | inst, repo, ok := ParseRemoteURL(tc.url) | ||
| 24 | if ok != tc.ok { | ||
| 25 | t.Errorf("%s: ok = %v, want %v", tc.url, ok, tc.ok) | ||
| 26 | continue | ||
| 27 | } | ||
| 28 | if !ok { | ||
| 29 | continue | ||
| 30 | } | ||
| 31 | if inst.Host != tc.host || inst.Port != tc.port || inst.User != tc.user || repo != tc.repo { | ||
| 32 | t.Errorf("%s: got host=%s port=%d user=%s repo=%s", tc.url, inst.Host, inst.Port, inst.User, repo) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | func TestCloneURL(t *testing.T) { | ||
| 38 | i := Instance{Host: "forge.example"} | ||
| 39 | if got := i.CloneURL("a/b"); got != "ssh://git@forge.example/a/b.git" { | ||
| 40 | t.Errorf("CloneURL = %s", got) | ||
| 41 | } | ||
| 42 | i = Instance{Host: "forge.example", Port: 2222, User: "u"} | ||
| 43 | if got := i.CloneURL("a/b"); got != "ssh://u@forge.example:2222/a/b.git" { | ||
| 44 | t.Errorf("CloneURL = %s", got) | ||
| 45 | } | ||
| 46 | } | ||