cmd/gitbay/local.go
415 lines · 12408 bytes
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10
11 "golang.org/x/term"
12
13 "gitbay.org/gitbay/internal/cliconfig"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/toolpath"
16)
17
18// hasBodyFlag reports whether args already carry body/message input.
19func hasBodyFlag(args []string) bool {
20 for _, a := range args {
21 if a == "--body" || a == "--message" || a == "--file" {
22 return true
23 }
24 }
25 return false
26}
27
28// maybeEditor opens $EDITOR for long text when the command usually wants a
29// body, none was given, and we are on a terminal. The result is passed to
30// the server via --file - on stdin. Returns the (possibly extended) args,
31// the stdin to use, and ok=false if the user aborted.
32func maybeEditor(args []string, kind string, prefill func() string) ([]string, *strings.Reader, bool, error) {
33 if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) {
34 return args, nil, true, nil
35 }
36 editor := os.Getenv("EDITOR")
37 if editor == "" {
38 // No editor configured: proceed with an empty body rather than
39 // failing — bodies are optional everywhere.
40 return args, nil, true, nil
41 }
42 f, err := os.CreateTemp("", "gitbay-"+kind+"-*.md")
43 if err != nil {
44 return nil, nil, false, err
45 }
46 defer os.Remove(f.Name())
47 if prefill != nil {
48 if body := prefill(); body != "" {
49 fmt.Fprintf(f, "%s\n", strings.TrimRight(body, "\n"))
50 }
51 }
52 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)
53 f.Close()
54
55 ed := exec.Command(toolpath.Look("sh"), "-c", editor+" "+shellQuote(f.Name()))
56 ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr
57 if err := ed.Run(); err != nil {
58 return nil, nil, false, fmt.Errorf("editor: %w", err)
59 }
60 raw, err := os.ReadFile(f.Name())
61 if err != nil {
62 return nil, nil, false, err
63 }
64 var body strings.Builder
65 for _, line := range strings.Split(string(raw), "\n") {
66 if strings.HasPrefix(line, "#") {
67 continue
68 }
69 body.WriteString(line + "\n")
70 }
71 text := strings.TrimSpace(body.String())
72 if text == "" {
73 return args, nil, true, nil
74 }
75 return append(args, "--file", "-"), strings.NewReader(text + "\n"), true, nil
76}
77
78func runGitLocal(args ...string) int {
79 cmd := exec.Command(toolpath.Look("git"), args...)
80 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
81 if err := cmd.Run(); err != nil {
82 if ee, ok := err.(*exec.ExitError); ok {
83 return ee.ExitCode()
84 }
85 fmt.Fprintln(os.Stderr, "gitbay:", err)
86 return protocol.ExitFailure
87 }
88 return 0
89}
90
91// cmdRepoClone implements `forge repo clone <owner/name> [dir]`.
92func cmdRepoClone(args []string) int {
93 if len(args) < 1 || strings.HasPrefix(args[0], "-") {
94 fmt.Fprintln(os.Stderr, "usage: gitbay repo clone <owner/name> [dir]")
95 return protocol.ExitUsage
96 }
97 t, err := resolveTarget()
98 if err != nil {
99 fmt.Fprintln(os.Stderr, "gitbay:", err)
100 return protocol.ExitFailure
101 }
102 gitArgs := append([]string{"clone", t.inst.CloneURL(args[0])}, args[1:]...)
103 if len(t.inst.SSHOptions) > 0 {
104 os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " "))
105 }
106 return runGitLocal(gitArgs...)
107}
108
109// cmdMRCheckout implements `forge mr checkout <n>`: fetch the MR head from
110// origin and check it out as a local branch.
111func cmdMRCheckout(args []string) int {
112 if len(args) != 1 {
113 fmt.Fprintln(os.Stderr, "usage: gitbay mr checkout <n>")
114 return protocol.ExitUsage
115 }
116 n := args[0]
117 ref := "refs/merge-requests/" + n + "/head"
118 if code := runGitLocal("fetch", "origin", ref); code != 0 {
119 return code
120 }
121 return runGitLocal("checkout", "-B", "mr/"+n, "FETCH_HEAD")
122}
123
124// cmdInit implements `forge init [name] [--private]`: git init if needed,
125// create the repository on the default instance, and point origin at it.
126func cmdInit(args []string) int {
127 var name string
128 private := false
129 for _, a := range args {
130 switch {
131 case a == "--private":
132 private = true
133 case strings.HasPrefix(a, "-"):
134 fmt.Fprintln(os.Stderr, "usage: gitbay init [name] [--private]")
135 return protocol.ExitUsage
136 default:
137 name = a
138 }
139 }
140 if name == "" {
141 wd, err := os.Getwd()
142 if err != nil {
143 fmt.Fprintln(os.Stderr, "gitbay:", err)
144 return protocol.ExitFailure
145 }
146 name = filepath.Base(wd)
147 }
148
149 cfg, err := cliconfig.Load()
150 if err != nil {
151 fmt.Fprintln(os.Stderr, "gitbay:", err)
152 return protocol.ExitFailure
153 }
154 inst, _, err := cfg.DefaultInstance()
155 if err != nil {
156 fmt.Fprintln(os.Stderr, "gitbay:", err)
157 return protocol.ExitFailure
158 }
159 t := target{inst: inst}
160
161 // The server requires owner = the authenticated user; ask who that is.
162 whoami, code := captureSSH(t, []string{"whoami"})
163 if code != 0 {
164 return code
165 }
166 username := strings.TrimSpace(whoami)
167 repoPath := username + "/" + name
168
169 createArgs := []string{"repo", "create", repoPath}
170 if private {
171 createArgs = append(createArgs, "--private")
172 }
173 if code := runSSH(t, createArgs, strings.NewReader("")); code != 0 {
174 return code
175 }
176
177 if _, err := os.Stat(".git"); os.IsNotExist(err) {
178 if code := runGitLocal("init", "-q", "-b", "main"); code != 0 {
179 return code
180 }
181 }
182 url := inst.CloneURL(repoPath)
183 if code := runGitLocal("remote", "add", "origin", url); code != 0 {
184 return code
185 }
186 fmt.Printf("origin -> %s\npush with: git push -u origin main\n", url)
187 return 0
188}
189
190// captureSSH runs a server command and returns its stdout.
191func captureSSH(t target, serverArgv []string) (string, int) {
192 args := sshArgs(t.inst)
193 quoted := quoteAll(serverArgv)
194 args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
195 cmd := exec.Command(toolpath.Look("ssh"), args...)
196 cmd.Stderr = os.Stderr
197 out, err := cmd.Output()
198 if err != nil {
199 if ee, ok := err.(*exec.ExitError); ok {
200 return "", ee.ExitCode()
201 }
202 fmt.Fprintln(os.Stderr, "gitbay: running ssh:", err)
203 return "", protocol.ExitProtocol
204 }
205 return string(out), 0
206}
207
208func quoteAll(args []string) []string {
209 out := make([]string, len(args))
210 for i, a := range args {
211 out[i] = shellQuote(a)
212 }
213 return out
214}
215
216// cmdRemoteAdd implements `gitbay remote add <name> <host> [flags]`.
217func cmdRemoteAdd(args []string) int {
218 var name, host, user string
219 var port int
220 var setDefault bool
221 var sshOptions []string
222 i := 0
223 for i < len(args) {
224 a := args[i]
225 switch a {
226 case "--port":
227 if i+1 >= len(args) {
228 fmt.Fprintln(os.Stderr, "--port requires a value")
229 return protocol.ExitUsage
230 }
231 fmt.Sscanf(args[i+1], "%d", &port)
232 i += 2
233 case "--user":
234 if i+1 >= len(args) {
235 fmt.Fprintln(os.Stderr, "--user requires a value")
236 return protocol.ExitUsage
237 }
238 user = args[i+1]
239 i += 2
240 case "--ssh-option":
241 if i+1 >= len(args) {
242 fmt.Fprintln(os.Stderr, "--ssh-option requires a value")
243 return protocol.ExitUsage
244 }
245 sshOptions = append(sshOptions, args[i+1])
246 i += 2
247 case "--default":
248 setDefault = true
249 i++
250 default:
251 if name == "" {
252 name = a
253 } else if host == "" {
254 host = a
255 } else {
256 fmt.Fprintln(os.Stderr, "usage: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]")
257 return protocol.ExitUsage
258 }
259 i++
260 }
261 }
262 if name == "" || host == "" {
263 fmt.Fprintln(os.Stderr, "usage: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]")
264 return protocol.ExitUsage
265 }
266 cfg, err := cliconfig.Load()
267 if err != nil {
268 fmt.Fprintln(os.Stderr, "gitbay:", err)
269 return protocol.ExitFailure
270 }
271 cfg.Instances[name] = cliconfig.Instance{Host: host, Port: port, User: user, SSHOptions: sshOptions}
272 if setDefault || cfg.Default == "" {
273 cfg.Default = name
274 }
275 if err := cliconfig.Save(cfg); err != nil {
276 fmt.Fprintln(os.Stderr, "gitbay:", err)
277 return protocol.ExitFailure
278 }
279 fmt.Printf("added instance %s (%s)\n", name, host)
280 return 0
281}
282
283func cmdRemoteList() int {
284 cfg, err := cliconfig.Load()
285 if err != nil {
286 fmt.Fprintln(os.Stderr, "gitbay:", err)
287 return protocol.ExitFailure
288 }
289 for name, inst := range cfg.Instances {
290 def := ""
291 if name == cfg.Default {
292 def = " (default)"
293 }
294 port := inst.Port
295 if port == 0 {
296 port = 22
297 }
298 fmt.Printf("%s\t%s@%s:%d%s\n", name, inst.SSHUser(), inst.Host, port, def)
299 }
300 return 0
301}
302
303// currentBranch is the checked-out branch of the working directory's
304// clone, or "" outside a clone or on a detached HEAD.
305func currentBranch() string {
306 out, err := exec.Command(toolpath.Look("git"), "symbolic-ref", "--quiet", "--short", "HEAD").Output()
307 if err != nil {
308 return ""
309 }
310 return strings.TrimSpace(string(out))
311}
312
313func hasFlag(args []string, flag string) bool {
314 for _, a := range args {
315 if a == flag || strings.HasPrefix(a, flag+"=") {
316 return true
317 }
318 }
319 return false
320}
321
322// cmdMRRebase implements `gitbay mr rebase <n>`: replay the merge
323// request's source branch onto its target and re-push it.
324//
325// A repository requiring signed commits accepts only fast-forward merges,
326// because a squash or merge commit is server-created and unsigned. The
327// refusal names the manual procedure — rebase locally, re-push, merge
328// again — and this is that procedure. The git work is local so the
329// replayed commits are signed by whatever key the user's own git config
330// signs with; the server is never asked to vouch for a commit it did not
331// receive already signed (#175).
332func cmdMRRebase(args []string) int {
333 if len(args) != 1 {
334 fmt.Fprintln(os.Stderr, "usage: gitbay mr rebase <n>")
335 return protocol.ExitUsage
336 }
337 n := args[0]
338 // Cheapest check first: a dirty tree stops the rebase anyway, and
339 // saying so costs no round trip.
340 if dirty, err := worktreeDirty(); err != nil {
341 fmt.Fprintln(os.Stderr, "gitbay:", err)
342 return protocol.ExitFailure
343 } else if dirty {
344 fmt.Fprintln(os.Stderr, "gitbay: working tree has uncommitted changes; commit or stash them first")
345 return protocol.ExitFailure
346 }
347 t, err := resolveTarget()
348 if err != nil {
349 fmt.Fprintln(os.Stderr, "gitbay:", err)
350 return protocol.ExitFailure
351 }
352 if t.repo == "" {
353 fmt.Fprintln(os.Stderr, "gitbay: run this in a clone of the repository the merge request targets")
354 return protocol.ExitUsage
355 }
356 out, code := captureSSH(t, []string{"mr", "show", t.repo, n, "--json"})
357 if code != 0 {
358 return code
359 }
360 var env struct {
361 Data struct {
362 Source string `json:"source"`
363 TargetRef string `json:"target_ref"`
364 State string `json:"state"`
365 } `json:"data"`
366 }
367 if err := json.Unmarshal([]byte(out), &env); err != nil {
368 fmt.Fprintln(os.Stderr, "gitbay: reading merge request:", err)
369 return protocol.ExitProtocol
370 }
371 source, target, state := env.Data.Source, env.Data.TargetRef, env.Data.State
372 if state != "open" {
373 fmt.Fprintf(os.Stderr, "gitbay: !%s is %s\n", n, state)
374 return protocol.ExitUsage
375 }
376 // A fork's branch lives in a repository this clone does not push to,
377 // and guessing which remote that is would be worse than saying so.
378 if strings.Contains(source, ":") {
379 fmt.Fprintf(os.Stderr,
380 "gitbay: !%s comes from %s; rebase it in a clone of that repository and push there\n", n, source)
381 return protocol.ExitUsage
382 }
383 // git talks to origin here, so it needs the instance's ssh options the
384 // same way `repo clone` does — without them a configured key or port
385 // is used by the CLI and not by the fetch and push it runs.
386 if len(t.inst.SSHOptions) > 0 {
387 os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " "))
388 }
389 if code := runGitLocal("fetch", "origin"); code != 0 {
390 return code
391 }
392 // git rebase checks the branch out itself, so a conflict leaves the
393 // rebase in progress on the right branch for the person to finish.
394 if code := runGitLocal("rebase", "origin/"+target, source); code != 0 {
395 fmt.Fprintf(os.Stderr,
396 "gitbay: rebase stopped; resolve it, then: git push --force-with-lease origin %s\n", source)
397 return code
398 }
399 if code := runGitLocal("push", "--force-with-lease", "origin", source); code != 0 {
400 return code
401 }
402 fmt.Printf("rebased %s onto %s; merge with: gitbay mr merge %s\n", source, target, n)
403 return 0
404}
405
406// worktreeDirty reports whether the working tree has changes a rebase
407// would refuse to run over.
408func worktreeDirty() (bool, error) {
409 cmd := exec.Command(toolpath.Look("git"), "status", "--porcelain")
410 out, err := cmd.Output()
411 if err != nil {
412 return false, fmt.Errorf("git status: %w", err)
413 }
414 return strings.TrimSpace(string(out)) != "", nil
415}