cmd/gitbay/local.go

0f51fba689b235cc0a456b938f3fa4f210f603f5
gitbay/cmd/gitbay/local.go history · blame · raw

319 lines · 8722 bytes

  1package main
  2
  3import (
  4	"fmt"
  5	"os"
  6	"os/exec"
  7	"path/filepath"
  8	"strings"
  9
 10	"golang.org/x/term"
 11
 12	"gitbay.org/gitbay/internal/cliconfig"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/toolpath"
 15)
 16
 17// hasBodyFlag reports whether args already carry body/message input.
 18func hasBodyFlag(args []string) bool {
 19	for _, a := range args {
 20		if a == "--body" || a == "--message" || a == "--file" {
 21			return true
 22		}
 23	}
 24	return false
 25}
 26
 27// maybeEditor opens $EDITOR for long text when the command usually wants a
 28// body, none was given, and we are on a terminal. The result is passed to
 29// the server via --file - on stdin. Returns the (possibly extended) args,
 30// the stdin to use, and ok=false if the user aborted.
 31func maybeEditor(args []string, kind string, prefill func() string) ([]string, *strings.Reader, bool, error) {
 32	if hasBodyFlag(args) || !term.IsTerminal(int(os.Stdin.Fd())) {
 33		return args, nil, true, nil
 34	}
 35	editor := os.Getenv("EDITOR")
 36	if editor == "" {
 37		// No editor configured: proceed with an empty body rather than
 38		// failing — bodies are optional everywhere.
 39		return args, nil, true, nil
 40	}
 41	f, err := os.CreateTemp("", "gitbay-"+kind+"-*.md")
 42	if err != nil {
 43		return nil, nil, false, err
 44	}
 45	defer os.Remove(f.Name())
 46	if prefill != nil {
 47		if body := prefill(); body != "" {
 48			fmt.Fprintf(f, "%s\n", strings.TrimRight(body, "\n"))
 49		}
 50	}
 51	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)
 52	f.Close()
 53
 54	ed := exec.Command(toolpath.Look("sh"), "-c", editor+" "+shellQuote(f.Name()))
 55	ed.Stdin, ed.Stdout, ed.Stderr = os.Stdin, os.Stdout, os.Stderr
 56	if err := ed.Run(); err != nil {
 57		return nil, nil, false, fmt.Errorf("editor: %w", err)
 58	}
 59	raw, err := os.ReadFile(f.Name())
 60	if err != nil {
 61		return nil, nil, false, err
 62	}
 63	var body strings.Builder
 64	for _, line := range strings.Split(string(raw), "\n") {
 65		if strings.HasPrefix(line, "#") {
 66			continue
 67		}
 68		body.WriteString(line + "\n")
 69	}
 70	text := strings.TrimSpace(body.String())
 71	if text == "" {
 72		return args, nil, true, nil
 73	}
 74	return append(args, "--file", "-"), strings.NewReader(text + "\n"), true, nil
 75}
 76
 77func runGitLocal(args ...string) int {
 78	cmd := exec.Command(toolpath.Look("git"), args...)
 79	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
 80	if err := cmd.Run(); err != nil {
 81		if ee, ok := err.(*exec.ExitError); ok {
 82			return ee.ExitCode()
 83		}
 84		fmt.Fprintln(os.Stderr, "gitbay:", err)
 85		return protocol.ExitFailure
 86	}
 87	return 0
 88}
 89
 90// cmdRepoClone implements `forge repo clone <owner/name> [dir]`.
 91func cmdRepoClone(args []string) int {
 92	if len(args) < 1 || strings.HasPrefix(args[0], "-") {
 93		fmt.Fprintln(os.Stderr, "usage: gitbay repo clone <owner/name> [dir]")
 94		return protocol.ExitUsage
 95	}
 96	t, err := resolveTarget()
 97	if err != nil {
 98		fmt.Fprintln(os.Stderr, "gitbay:", err)
 99		return protocol.ExitFailure
100	}
101	gitArgs := append([]string{"clone", t.inst.CloneURL(args[0])}, args[1:]...)
102	if len(t.inst.SSHOptions) > 0 {
103		os.Setenv("GIT_SSH_COMMAND", "ssh "+strings.Join(quoteAll(t.inst.SSHOptions), " "))
104	}
105	return runGitLocal(gitArgs...)
106}
107
108// cmdMRCheckout implements `forge mr checkout <n>`: fetch the MR head from
109// origin and check it out as a local branch.
110func cmdMRCheckout(args []string) int {
111	if len(args) != 1 {
112		fmt.Fprintln(os.Stderr, "usage: gitbay mr checkout <n>")
113		return protocol.ExitUsage
114	}
115	n := args[0]
116	ref := "refs/merge-requests/" + n + "/head"
117	if code := runGitLocal("fetch", "origin", ref); code != 0 {
118		return code
119	}
120	return runGitLocal("checkout", "-B", "mr/"+n, "FETCH_HEAD")
121}
122
123// cmdInit implements `forge init [name] [--private]`: git init if needed,
124// create the repository on the default instance, and point origin at it.
125func cmdInit(args []string) int {
126	var name string
127	private := false
128	for _, a := range args {
129		switch {
130		case a == "--private":
131			private = true
132		case strings.HasPrefix(a, "-"):
133			fmt.Fprintln(os.Stderr, "usage: gitbay init [name] [--private]")
134			return protocol.ExitUsage
135		default:
136			name = a
137		}
138	}
139	if name == "" {
140		wd, err := os.Getwd()
141		if err != nil {
142			fmt.Fprintln(os.Stderr, "gitbay:", err)
143			return protocol.ExitFailure
144		}
145		name = filepath.Base(wd)
146	}
147
148	cfg, err := cliconfig.Load()
149	if err != nil {
150		fmt.Fprintln(os.Stderr, "gitbay:", err)
151		return protocol.ExitFailure
152	}
153	inst, _, err := cfg.DefaultInstance()
154	if err != nil {
155		fmt.Fprintln(os.Stderr, "gitbay:", err)
156		return protocol.ExitFailure
157	}
158	t := target{inst: inst}
159
160	// The server requires owner = the authenticated user; ask who that is.
161	whoami, code := captureSSH(t, []string{"whoami"})
162	if code != 0 {
163		return code
164	}
165	username := strings.TrimSpace(whoami)
166	repoPath := username + "/" + name
167
168	createArgs := []string{"repo", "create", repoPath}
169	if private {
170		createArgs = append(createArgs, "--private")
171	}
172	if code := runSSH(t, createArgs, strings.NewReader("")); code != 0 {
173		return code
174	}
175
176	if _, err := os.Stat(".git"); os.IsNotExist(err) {
177		if code := runGitLocal("init", "-q", "-b", "main"); code != 0 {
178			return code
179		}
180	}
181	url := inst.CloneURL(repoPath)
182	if code := runGitLocal("remote", "add", "origin", url); code != 0 {
183		return code
184	}
185	fmt.Printf("origin -> %s\npush with: git push -u origin main\n", url)
186	return 0
187}
188
189// captureSSH runs a server command and returns its stdout.
190func captureSSH(t target, serverArgv []string) (string, int) {
191	args := sshArgs(t.inst)
192	quoted := quoteAll(serverArgv)
193	args = append(args, t.inst.SSHUser()+"@"+t.inst.Host, "--", strings.Join(quoted, " "))
194	cmd := exec.Command(toolpath.Look("ssh"), args...)
195	cmd.Stderr = os.Stderr
196	out, err := cmd.Output()
197	if err != nil {
198		if ee, ok := err.(*exec.ExitError); ok {
199			return "", ee.ExitCode()
200		}
201		fmt.Fprintln(os.Stderr, "gitbay: running ssh:", err)
202		return "", protocol.ExitProtocol
203	}
204	return string(out), 0
205}
206
207func quoteAll(args []string) []string {
208	out := make([]string, len(args))
209	for i, a := range args {
210		out[i] = shellQuote(a)
211	}
212	return out
213}
214
215// cmdRemoteAdd implements `gitbay remote add <name> <host> [flags]`.
216func cmdRemoteAdd(args []string) int {
217	var name, host, user string
218	var port int
219	var setDefault bool
220	var sshOptions []string
221	i := 0
222	for i < len(args) {
223		a := args[i]
224		switch a {
225		case "--port":
226			if i+1 >= len(args) {
227				fmt.Fprintln(os.Stderr, "--port requires a value")
228				return protocol.ExitUsage
229			}
230			fmt.Sscanf(args[i+1], "%d", &port)
231			i += 2
232		case "--user":
233			if i+1 >= len(args) {
234				fmt.Fprintln(os.Stderr, "--user requires a value")
235				return protocol.ExitUsage
236			}
237			user = args[i+1]
238			i += 2
239		case "--ssh-option":
240			if i+1 >= len(args) {
241				fmt.Fprintln(os.Stderr, "--ssh-option requires a value")
242				return protocol.ExitUsage
243			}
244			sshOptions = append(sshOptions, args[i+1])
245			i += 2
246		case "--default":
247			setDefault = true
248			i++
249		default:
250			if name == "" {
251				name = a
252			} else if host == "" {
253				host = a
254			} else {
255				fmt.Fprintln(os.Stderr, "usage: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]")
256				return protocol.ExitUsage
257			}
258			i++
259		}
260	}
261	if name == "" || host == "" {
262		fmt.Fprintln(os.Stderr, "usage: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option opt]... [--default]")
263		return protocol.ExitUsage
264	}
265	cfg, err := cliconfig.Load()
266	if err != nil {
267		fmt.Fprintln(os.Stderr, "gitbay:", err)
268		return protocol.ExitFailure
269	}
270	cfg.Instances[name] = cliconfig.Instance{Host: host, Port: port, User: user, SSHOptions: sshOptions}
271	if setDefault || cfg.Default == "" {
272		cfg.Default = name
273	}
274	if err := cliconfig.Save(cfg); err != nil {
275		fmt.Fprintln(os.Stderr, "gitbay:", err)
276		return protocol.ExitFailure
277	}
278	fmt.Printf("added instance %s (%s)\n", name, host)
279	return 0
280}
281
282func cmdRemoteList() int {
283	cfg, err := cliconfig.Load()
284	if err != nil {
285		fmt.Fprintln(os.Stderr, "gitbay:", err)
286		return protocol.ExitFailure
287	}
288	for name, inst := range cfg.Instances {
289		def := ""
290		if name == cfg.Default {
291			def = " (default)"
292		}
293		port := inst.Port
294		if port == 0 {
295			port = 22
296		}
297		fmt.Printf("%s\t%s@%s:%d%s\n", name, inst.SSHUser(), inst.Host, port, def)
298	}
299	return 0
300}
301
302// currentBranch is the checked-out branch of the working directory's
303// clone, or "" outside a clone or on a detached HEAD.
304func currentBranch() string {
305	out, err := exec.Command(toolpath.Look("git"), "symbolic-ref", "--quiet", "--short", "HEAD").Output()
306	if err != nil {
307		return ""
308	}
309	return strings.TrimSpace(string(out))
310}
311
312func hasFlag(args []string, flag string) bool {
313	for _, a := range args {
314		if a == flag || strings.HasPrefix(a, flag+"=") {
315			return true
316		}
317	}
318	return false
319}