cmd/gitbay/local.go

v1.24.0
gitbay/cmd/gitbay/local.go history · blame · raw

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}