cmd/gitbay/local.go
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}