detached e582440

feat(executor): port the shell selection, script files and command line of run steps

commit
e5824408414c93902d86135b741bdc2478995f2d
author
brice · · 2026-10-12 17:31 UTC
parent
3470aad
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +554 -0
A internal/executor/shell.go +315 -0
@@ -0,0 +1,315 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/google/uuid"
13+)
14+
15+// Shells and script files of `run:` steps: a port of ScriptHandlerHelpers.cs
16+// and of the parts of ScriptHandler.cs that build the command line
17+// (Runner.Worker/Handlers). Only the Linux behaviour is ported.
18+
19+// defaultArguments is ScriptHandlerHelpers._defaultArguments
20+// (ScriptHandlerHelpers.cs:13). Keys are compared case-insensitively.
21+var defaultArguments = map[string]string{
22+ "cmd": `/D /E:ON /V:OFF /S /C "CALL "{0}""`,
23+ "pwsh": `-command ". '{0}'"`,
24+ "powershell": `-command ". '{0}'"`,
25+ "bash": "--noprofile --norc -e -o pipefail {0}",
26+ "sh": "-e {0}",
27+ "python": "{0}",
28+}
29+
30+// scriptExtensions is ScriptHandlerHelpers._extensions (:23).
31+var scriptExtensions = map[string]string{
32+ "cmd": ".cmd",
33+ "pwsh": ".ps1",
34+ "powershell": ".ps1",
35+ "bash": ".sh",
36+ "sh": ".sh",
37+ "python": ".py",
38+}
39+
40+// ScriptArgumentsFormat is GetScriptArgumentsFormat (:33): the argument format
41+// of a built-in shell, "" for any other command.
42+func ScriptArgumentsFormat(shellType string) string {
43+ return defaultArguments[strings.ToLower(shellType)]
44+}
45+
46+// ScriptFileExtension is GetScriptFileExtension (:42): ".sh", ".py", ... or "".
47+func ScriptFileExtension(shellType string) string {
48+ return scriptExtensions[strings.ToLower(shellType)]
49+}
50+
51+// FixUpScriptContents is ScriptHandlerHelpers.FixUpScriptContents (:51). The
52+// switch is case-sensitive, unlike the two lookups above.
53+func FixUpScriptContents(shellType, contents string) string {
54+ switch shellType {
55+ case "cmd":
56+ // Use @echo off rather than /Q: with /Q, echo can't be turned on.
57+ return "@echo off\n" + contents
58+ case "powershell", "pwsh":
59+ return "$ErrorActionPreference = 'stop'\n" + contents + "\n" +
60+ `if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }`
61+ }
62+ return contents
63+}
64+
65+// ParseShellOption is ParseShellOptionString (:70): "COMMAND [..ARGS]" split at
66+// the first space.
67+func ParseShellOption(shell string) (command, args string) {
68+ command, args, _ = strings.Cut(shell, " ")
69+ return command, args
70+}
71+
72+// ErrInvalidShell is the error of a shell whose argument format has no {0}
73+// (ScriptHandler.cs:245).
74+var ErrInvalidShell = errors.New("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'")
75+
76+// Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs):
77+// in PATH with the directories added by GITHUB_PATH in front.
78+type Lookup struct {
79+ // Path is the PATH of the executor process (not of the step).
80+ Path string
81+ // Prepend is Global.PrependPath in the order of the AddPath calls.
82+ Prepend []string
83+}
84+
85+// NotFoundError is the FileNotFoundException of Which(require: true).
86+type NotFoundError struct{ Command string }
87+
88+func (e *NotFoundError) Error() string { return e.Command + ": command not found" }
89+
90+// Which returns the path of command, or "" when it is not found.
91+func (l Lookup) Which(command string) string {
92+ if filepath.IsAbs(command) {
93+ if isFile(command) {
94+ return command
95+ }
96+ }
97+ path := PrependedPath(l.Prepend, l.Path)
98+ for _, seg := range strings.Split(path, ":") {
99+ if seg == "" {
100+ continue
101+ }
102+ seg = os.ExpandEnv(seg)
103+ // Directory.EnumerateFiles(seg, command): a plain file name in the directory.
104+ if strings.ContainsRune(command, '/') {
105+ continue
106+ }
107+ p := filepath.Join(seg, command)
108+ if isFile(p) {
109+ return p
110+ }
111+ }
112+ return ""
113+}
114+
115+// isFile reports whether p is a file, or a symbolic link whose target exists
116+// (WhichUtil.IsPathValid).
117+func isFile(p string) bool {
118+ st, err := os.Stat(p)
119+ return err == nil && !st.IsDir()
120+}
121+
122+// Require is Which(command, require: true).
123+func (l Lookup) Require(command string) (string, error) {
124+ if p := l.Which(command); p != "" {
125+ return p, nil
126+ }
127+ return "", &NotFoundError{command}
128+}
129+
130+// ShellPlan is how a step's script is run: the shell chosen by the step, the
131+// job defaults, or the runner's default.
132+type ShellPlan struct {
133+ // Command is the shell command: "sh" for the default shell (whose path is
134+ // bash when found), else the first word of the shell option.
135+ Command string
136+ // CommandPath is the executable that is started, and shown on the
137+ // "shell:" line of the log.
138+ CommandPath string
139+ // ArgFormat is the argument format with {0} for the script path.
140+ ArgFormat string
141+}
142+
143+// PlanShell chooses the shell like ScriptHandler.RunAsync (:170-234) and
144+// PrintActionDetails (:65-117). shell is the step's `shell` or, when empty,
145+// defaults.run.shell (the caller does that merge, :171-180).
146+//
147+// With no shell: bash if it is in PATH, else sh, with the arguments of "sh"
148+// (-e {0}). "sh" is only the name used for the arguments and the extension.
149+func PlanShell(shell string, l Lookup) (ShellPlan, error) {
150+ if shell == "" {
151+ p, err := l.lookupBashOrSh()
152+ if err != nil {
153+ return ShellPlan{}, err
154+ }
155+ return checkFormat(ShellPlan{Command: "sh", CommandPath: p, ArgFormat: ScriptArgumentsFormat("sh")})
156+ }
157+ cmd, args := ParseShellOption(shell)
158+ p, err := l.Require(cmd)
159+ if err != nil {
160+ return ShellPlan{}, err
161+ }
162+ format := strings.TrimLeft(args, " \t\n\v\f\r")
163+ if format == "" {
164+ format = ScriptArgumentsFormat(cmd)
165+ }
166+ return checkFormat(ShellPlan{Command: cmd, CommandPath: p, ArgFormat: format})
167+}
168+
169+func (l Lookup) lookupBashOrSh() (string, error) {
170+ if p := l.Which("bash"); p != "" {
171+ return p, nil
172+ }
173+ return l.Require("sh")
174+}
175+
176+func checkFormat(p ShellPlan) (ShellPlan, error) {
177+ if p.ArgFormat == "" || !strings.Contains(p.ArgFormat, "{0}") {
178+ return p, ErrInvalidShell
179+ }
180+ return p, nil
181+}
182+
183+// ShellLine is the text after "shell: " in the step header (:121-126).
184+func (p ShellPlan) ShellLine() string { return p.CommandPath + " " + p.ArgFormat }
185+
186+// Extension is the extension of the script file, from the shell command.
187+func (p ShellPlan) Extension() string { return ScriptFileExtension(p.Command) }
188+
189+// FormatArguments is string.Format(argFormat, path) of .NET with one argument:
190+// {0} is the path, {{ and }} are braces; anything else is a format error. The
191+// script path has its double quotes escaped first (ScriptHandler.cs:252).
192+func FormatArguments(format, scriptPath string) (string, error) {
193+ path := strings.ReplaceAll(scriptPath, `"`, `\"`)
194+ var b strings.Builder
195+ for i := 0; i < len(format); i++ {
196+ switch c := format[i]; {
197+ case c == '{' && i+1 < len(format) && format[i+1] == '{':
198+ b.WriteByte('{')
199+ i++
200+ case c == '}' && i+1 < len(format) && format[i+1] == '}':
201+ b.WriteByte('}')
202+ i++
203+ case c == '{':
204+ end := strings.IndexByte(format[i:], '}')
205+ if end < 0 {
206+ return "", errors.New("Input string was not in a correct format.")
207+ }
208+ spec := format[i+1 : i+end]
209+ index, _, _ := strings.Cut(spec, ",")
210+ index, _, _ = strings.Cut(index, ":")
211+ if strings.TrimSpace(index) != "0" {
212+ return "", errors.New("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.")
213+ }
214+ b.WriteString(path)
215+ i += end
216+ case c == '}':
217+ return "", errors.New("Input string was not in a correct format.")
218+ default:
219+ b.WriteByte(c)
220+ }
221+ }
222+ return b.String(), nil
223+}
224+
225+// ParseArguments splits an argument string into arguments the way .NET does
226+// for Process.Start on Unix (Process.Unix.cs ParseArgumentsIntoList): spaces
227+// and tabs separate; double quotes group; backslashes only count before a
228+// quote.
229+func ParseArguments(s string) []string {
230+ var out []string
231+ for i := 0; i < len(s); i++ {
232+ for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
233+ i++
234+ }
235+ if i == len(s) {
236+ break
237+ }
238+ var cur strings.Builder
239+ inQuotes := false
240+ for i < len(s) {
241+ n := 0
242+ for i < len(s) && s[i] == '\\' {
243+ i++
244+ n++
245+ }
246+ if n > 0 {
247+ if i >= len(s) || s[i] != '"' {
248+ cur.WriteString(strings.Repeat(`\`, n))
249+ } else {
250+ cur.WriteString(strings.Repeat(`\`, n/2))
251+ if n%2 != 0 {
252+ cur.WriteByte('"')
253+ i++
254+ }
255+ }
256+ continue
257+ }
258+ c := s[i]
259+ if c == '"' {
260+ if inQuotes && i < len(s)-1 && s[i+1] == '"' {
261+ cur.WriteByte('"')
262+ i++
263+ } else {
264+ inQuotes = !inQuotes
265+ }
266+ i++
267+ continue
268+ }
269+ if (c == ' ' || c == '\t') && !inQuotes {
270+ break
271+ }
272+ cur.WriteByte(c)
273+ i++
274+ }
275+ out = append(out, cur.String())
276+ }
277+ return out
278+}
279+
280+// Script is a script written to the temp directory, ready to run.
281+type Script struct {
282+ // Path is the script file.
283+ Path string
284+ // Argv is the command: the shell and its arguments with the script path.
285+ Argv []string
286+}
287+
288+// WriteScript writes the script of a step to tempDir under a random name with
289+// the extension of the shell, and builds the command line
290+// (ScriptHandler.RunAsync :247-285). The file has no BOM and is not
291+// executable: the shell reads it.
292+func WriteScript(tempDir string, p ShellPlan, contents string) (Script, error) {
293+ path := filepath.Join(tempDir, uuid.NewString()+p.Extension())
294+ args, err := FormatArguments(p.ArgFormat, path)
295+ if err != nil {
296+ return Script{}, err
297+ }
298+ contents = FixUpScriptContents(p.Command, contents)
299+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
300+ return Script{}, fmt.Errorf("write script: %w", err)
301+ }
302+ return Script{Path: path, Argv: append([]string{p.CommandPath}, ParseArguments(args)...)}, nil
303+}
304+
305+// ResolveWorkingDirectory is Path.Combine(workspace, workingDirectory ?? "")
306+// (ScriptHandler.cs:168): an absolute directory replaces the workspace.
307+func ResolveWorkingDirectory(workspace, dir string) string {
308+ if filepath.IsAbs(dir) {
309+ return dir
310+ }
311+ if dir == "" {
312+ return workspace
313+ }
314+ return strings.TrimRight(workspace, "/") + "/" + dir
315+}
@@ -0,0 +1,315 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/google/uuid"
13+)
14+
15+// Shells and script files of `run:` steps: a port of ScriptHandlerHelpers.cs
16+// and of the parts of ScriptHandler.cs that build the command line
17+// (Runner.Worker/Handlers). Only the Linux behaviour is ported.
18+
19+// defaultArguments is ScriptHandlerHelpers._defaultArguments
20+// (ScriptHandlerHelpers.cs:13). Keys are compared case-insensitively.
21+var defaultArguments = map[string]string{
22+ "cmd": `/D /E:ON /V:OFF /S /C "CALL "{0}""`,
23+ "pwsh": `-command ". '{0}'"`,
24+ "powershell": `-command ". '{0}'"`,
25+ "bash": "--noprofile --norc -e -o pipefail {0}",
26+ "sh": "-e {0}",
27+ "python": "{0}",
28+}
29+
30+// scriptExtensions is ScriptHandlerHelpers._extensions (:23).
31+var scriptExtensions = map[string]string{
32+ "cmd": ".cmd",
33+ "pwsh": ".ps1",
34+ "powershell": ".ps1",
35+ "bash": ".sh",
36+ "sh": ".sh",
37+ "python": ".py",
38+}
39+
40+// ScriptArgumentsFormat is GetScriptArgumentsFormat (:33): the argument format
41+// of a built-in shell, "" for any other command.
42+func ScriptArgumentsFormat(shellType string) string {
43+ return defaultArguments[strings.ToLower(shellType)]
44+}
45+
46+// ScriptFileExtension is GetScriptFileExtension (:42): ".sh", ".py", ... or "".
47+func ScriptFileExtension(shellType string) string {
48+ return scriptExtensions[strings.ToLower(shellType)]
49+}
50+
51+// FixUpScriptContents is ScriptHandlerHelpers.FixUpScriptContents (:51). The
52+// switch is case-sensitive, unlike the two lookups above.
53+func FixUpScriptContents(shellType, contents string) string {
54+ switch shellType {
55+ case "cmd":
56+ // Use @echo off rather than /Q: with /Q, echo can't be turned on.
57+ return "@echo off\n" + contents
58+ case "powershell", "pwsh":
59+ return "$ErrorActionPreference = 'stop'\n" + contents + "\n" +
60+ `if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }`
61+ }
62+ return contents
63+}
64+
65+// ParseShellOption is ParseShellOptionString (:70): "COMMAND [..ARGS]" split at
66+// the first space.
67+func ParseShellOption(shell string) (command, args string) {
68+ command, args, _ = strings.Cut(shell, " ")
69+ return command, args
70+}
71+
72+// ErrInvalidShell is the error of a shell whose argument format has no {0}
73+// (ScriptHandler.cs:245).
74+var ErrInvalidShell = errors.New("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'")
75+
76+// Lookup finds executables like WhichUtil.Which (Runner.Sdk/Util/WhichUtil.cs):
77+// in PATH with the directories added by GITHUB_PATH in front.
78+type Lookup struct {
79+ // Path is the PATH of the executor process (not of the step).
80+ Path string
81+ // Prepend is Global.PrependPath in the order of the AddPath calls.
82+ Prepend []string
83+}
84+
85+// NotFoundError is the FileNotFoundException of Which(require: true).
86+type NotFoundError struct{ Command string }
87+
88+func (e *NotFoundError) Error() string { return e.Command + ": command not found" }
89+
90+// Which returns the path of command, or "" when it is not found.
91+func (l Lookup) Which(command string) string {
92+ if filepath.IsAbs(command) {
93+ if isFile(command) {
94+ return command
95+ }
96+ }
97+ path := PrependedPath(l.Prepend, l.Path)
98+ for _, seg := range strings.Split(path, ":") {
99+ if seg == "" {
100+ continue
101+ }
102+ seg = os.ExpandEnv(seg)
103+ // Directory.EnumerateFiles(seg, command): a plain file name in the directory.
104+ if strings.ContainsRune(command, '/') {
105+ continue
106+ }
107+ p := filepath.Join(seg, command)
108+ if isFile(p) {
109+ return p
110+ }
111+ }
112+ return ""
113+}
114+
115+// isFile reports whether p is a file, or a symbolic link whose target exists
116+// (WhichUtil.IsPathValid).
117+func isFile(p string) bool {
118+ st, err := os.Stat(p)
119+ return err == nil && !st.IsDir()
120+}
121+
122+// Require is Which(command, require: true).
123+func (l Lookup) Require(command string) (string, error) {
124+ if p := l.Which(command); p != "" {
125+ return p, nil
126+ }
127+ return "", &NotFoundError{command}
128+}
129+
130+// ShellPlan is how a step's script is run: the shell chosen by the step, the
131+// job defaults, or the runner's default.
132+type ShellPlan struct {
133+ // Command is the shell command: "sh" for the default shell (whose path is
134+ // bash when found), else the first word of the shell option.
135+ Command string
136+ // CommandPath is the executable that is started, and shown on the
137+ // "shell:" line of the log.
138+ CommandPath string
139+ // ArgFormat is the argument format with {0} for the script path.
140+ ArgFormat string
141+}
142+
143+// PlanShell chooses the shell like ScriptHandler.RunAsync (:170-234) and
144+// PrintActionDetails (:65-117). shell is the step's `shell` or, when empty,
145+// defaults.run.shell (the caller does that merge, :171-180).
146+//
147+// With no shell: bash if it is in PATH, else sh, with the arguments of "sh"
148+// (-e {0}). "sh" is only the name used for the arguments and the extension.
149+func PlanShell(shell string, l Lookup) (ShellPlan, error) {
150+ if shell == "" {
151+ p, err := l.lookupBashOrSh()
152+ if err != nil {
153+ return ShellPlan{}, err
154+ }
155+ return checkFormat(ShellPlan{Command: "sh", CommandPath: p, ArgFormat: ScriptArgumentsFormat("sh")})
156+ }
157+ cmd, args := ParseShellOption(shell)
158+ p, err := l.Require(cmd)
159+ if err != nil {
160+ return ShellPlan{}, err
161+ }
162+ format := strings.TrimLeft(args, " \t\n\v\f\r")
163+ if format == "" {
164+ format = ScriptArgumentsFormat(cmd)
165+ }
166+ return checkFormat(ShellPlan{Command: cmd, CommandPath: p, ArgFormat: format})
167+}
168+
169+func (l Lookup) lookupBashOrSh() (string, error) {
170+ if p := l.Which("bash"); p != "" {
171+ return p, nil
172+ }
173+ return l.Require("sh")
174+}
175+
176+func checkFormat(p ShellPlan) (ShellPlan, error) {
177+ if p.ArgFormat == "" || !strings.Contains(p.ArgFormat, "{0}") {
178+ return p, ErrInvalidShell
179+ }
180+ return p, nil
181+}
182+
183+// ShellLine is the text after "shell: " in the step header (:121-126).
184+func (p ShellPlan) ShellLine() string { return p.CommandPath + " " + p.ArgFormat }
185+
186+// Extension is the extension of the script file, from the shell command.
187+func (p ShellPlan) Extension() string { return ScriptFileExtension(p.Command) }
188+
189+// FormatArguments is string.Format(argFormat, path) of .NET with one argument:
190+// {0} is the path, {{ and }} are braces; anything else is a format error. The
191+// script path has its double quotes escaped first (ScriptHandler.cs:252).
192+func FormatArguments(format, scriptPath string) (string, error) {
193+ path := strings.ReplaceAll(scriptPath, `"`, `\"`)
194+ var b strings.Builder
195+ for i := 0; i < len(format); i++ {
196+ switch c := format[i]; {
197+ case c == '{' && i+1 < len(format) && format[i+1] == '{':
198+ b.WriteByte('{')
199+ i++
200+ case c == '}' && i+1 < len(format) && format[i+1] == '}':
201+ b.WriteByte('}')
202+ i++
203+ case c == '{':
204+ end := strings.IndexByte(format[i:], '}')
205+ if end < 0 {
206+ return "", errors.New("Input string was not in a correct format.")
207+ }
208+ spec := format[i+1 : i+end]
209+ index, _, _ := strings.Cut(spec, ",")
210+ index, _, _ = strings.Cut(index, ":")
211+ if strings.TrimSpace(index) != "0" {
212+ return "", errors.New("Index (zero based) must be greater than or equal to zero and less than the size of the argument list.")
213+ }
214+ b.WriteString(path)
215+ i += end
216+ case c == '}':
217+ return "", errors.New("Input string was not in a correct format.")
218+ default:
219+ b.WriteByte(c)
220+ }
221+ }
222+ return b.String(), nil
223+}
224+
225+// ParseArguments splits an argument string into arguments the way .NET does
226+// for Process.Start on Unix (Process.Unix.cs ParseArgumentsIntoList): spaces
227+// and tabs separate; double quotes group; backslashes only count before a
228+// quote.
229+func ParseArguments(s string) []string {
230+ var out []string
231+ for i := 0; i < len(s); i++ {
232+ for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
233+ i++
234+ }
235+ if i == len(s) {
236+ break
237+ }
238+ var cur strings.Builder
239+ inQuotes := false
240+ for i < len(s) {
241+ n := 0
242+ for i < len(s) && s[i] == '\\' {
243+ i++
244+ n++
245+ }
246+ if n > 0 {
247+ if i >= len(s) || s[i] != '"' {
248+ cur.WriteString(strings.Repeat(`\`, n))
249+ } else {
250+ cur.WriteString(strings.Repeat(`\`, n/2))
251+ if n%2 != 0 {
252+ cur.WriteByte('"')
253+ i++
254+ }
255+ }
256+ continue
257+ }
258+ c := s[i]
259+ if c == '"' {
260+ if inQuotes && i < len(s)-1 && s[i+1] == '"' {
261+ cur.WriteByte('"')
262+ i++
263+ } else {
264+ inQuotes = !inQuotes
265+ }
266+ i++
267+ continue
268+ }
269+ if (c == ' ' || c == '\t') && !inQuotes {
270+ break
271+ }
272+ cur.WriteByte(c)
273+ i++
274+ }
275+ out = append(out, cur.String())
276+ }
277+ return out
278+}
279+
280+// Script is a script written to the temp directory, ready to run.
281+type Script struct {
282+ // Path is the script file.
283+ Path string
284+ // Argv is the command: the shell and its arguments with the script path.
285+ Argv []string
286+}
287+
288+// WriteScript writes the script of a step to tempDir under a random name with
289+// the extension of the shell, and builds the command line
290+// (ScriptHandler.RunAsync :247-285). The file has no BOM and is not
291+// executable: the shell reads it.
292+func WriteScript(tempDir string, p ShellPlan, contents string) (Script, error) {
293+ path := filepath.Join(tempDir, uuid.NewString()+p.Extension())
294+ args, err := FormatArguments(p.ArgFormat, path)
295+ if err != nil {
296+ return Script{}, err
297+ }
298+ contents = FixUpScriptContents(p.Command, contents)
299+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
300+ return Script{}, fmt.Errorf("write script: %w", err)
301+ }
302+ return Script{Path: path, Argv: append([]string{p.CommandPath}, ParseArguments(args)...)}, nil
303+}
304+
305+// ResolveWorkingDirectory is Path.Combine(workspace, workingDirectory ?? "")
306+// (ScriptHandler.cs:168): an absolute directory replaces the workspace.
307+func ResolveWorkingDirectory(workspace, dir string) string {
308+ if filepath.IsAbs(dir) {
309+ return dir
310+ }
311+ if dir == "" {
312+ return workspace
313+ }
314+ return strings.TrimRight(workspace, "/") + "/" + dir
315+}
A internal/executor/shell_test.go +239 -0
@@ -0,0 +1,239 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "testing"
11+)
12+
13+func fakeBin(t *testing.T, names ...string) string {
14+ t.Helper()
15+ dir := t.TempDir()
16+ for _, n := range names {
17+ if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\n"), 0o755); err != nil {
18+ t.Fatal(err)
19+ }
20+ }
21+ return dir
22+}
23+
24+func TestBuiltinFormatsAndExtensions(t *testing.T) {
25+ // ScriptHandlerHelpers.cs _defaultArguments and _extensions (Linux rows).
26+ for shell, want := range map[string][2]string{
27+ "bash": {"--noprofile --norc -e -o pipefail {0}", ".sh"},
28+ "sh": {"-e {0}", ".sh"},
29+ "python": {"{0}", ".py"},
30+ "pwsh": {`-command ". '{0}'"`, ".ps1"},
31+ "powershell": {`-command ". '{0}'"`, ".ps1"},
32+ "cmd": {`/D /E:ON /V:OFF /S /C "CALL "{0}""`, ".cmd"},
33+ "BASH": {"--noprofile --norc -e -o pipefail {0}", ".sh"}, // case-insensitive tables
34+ "python3": {"", ""},
35+ "ruby": {"", ""},
36+ } {
37+ if got := ScriptArgumentsFormat(shell); got != want[0] {
38+ t.Errorf("format(%s) = %q", shell, got)
39+ }
40+ if got := ScriptFileExtension(shell); got != want[1] {
41+ t.Errorf("extension(%s) = %q", shell, got)
42+ }
43+ }
44+}
45+
46+func TestFixUpScriptContents(t *testing.T) {
47+ if got := FixUpScriptContents("cmd", "echo hi"); got != "@echo off\necho hi" {
48+ t.Fatal(got)
49+ }
50+ want := "$ErrorActionPreference = 'stop'\nGet-Date\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }"
51+ for _, s := range []string{"pwsh", "powershell"} {
52+ if got := FixUpScriptContents(s, "Get-Date"); got != want {
53+ t.Fatalf("%s: %q", s, got)
54+ }
55+ }
56+ // The switch is case-sensitive: PWSH is left alone.
57+ for _, s := range []string{"bash", "sh", "python", "PWSH", "Cmd"} {
58+ if got := FixUpScriptContents(s, "x"); got != "x" {
59+ t.Fatalf("%s: %q", s, got)
60+ }
61+ }
62+}
63+
64+func TestParseShellOption(t *testing.T) {
65+ for in, want := range map[string][2]string{
66+ "bash": {"bash", ""},
67+ "bash -x {0}": {"bash", "-x {0}"},
68+ "python3 {0}": {"python3", "{0}"},
69+ "perl -e {0}": {"perl", " -e {0}"}, // Split(" ", 2) keeps the second space
70+ "": {"", ""},
71+ "node -e \"x y\" {0}": {"node", "-e \"x y\" {0}"},
72+ } {
73+ c, a := ParseShellOption(in)
74+ if c != want[0] || a != want[1] {
75+ t.Errorf("%q: %q %q", in, c, a)
76+ }
77+ }
78+}
79+
80+func TestPlanShell(t *testing.T) {
81+ both := fakeBin(t, "bash", "sh", "python", "python3", "perl")
82+ onlySh := fakeBin(t, "sh")
83+ l := Lookup{Path: both}
84+ tests := []struct {
85+ name string
86+ shell string
87+ l Lookup
88+ want ShellPlan
89+ err string
90+ }{
91+ {"default uses bash with the arguments of sh", "", l, ShellPlan{"sh", filepath.Join(both, "bash"), "-e {0}"}, ""},
92+ {"default falls back to sh", "", Lookup{Path: onlySh}, ShellPlan{"sh", filepath.Join(onlySh, "sh"), "-e {0}"}, ""},
93+ {"bash", "bash", l, ShellPlan{"bash", filepath.Join(both, "bash"), "--noprofile --norc -e -o pipefail {0}"}, ""},
94+ {"sh", "sh", l, ShellPlan{"sh", filepath.Join(both, "sh"), "-e {0}"}, ""},
95+ {"python", "python", l, ShellPlan{"python", filepath.Join(both, "python"), "{0}"}, ""},
96+ {"custom with args", "python3 {0}", l, ShellPlan{"python3", filepath.Join(both, "python3"), "{0}"}, ""},
97+ {"custom args are left-trimmed", "perl -w {0}", l, ShellPlan{"perl", filepath.Join(both, "perl"), "-w {0}"}, ""},
98+ {"bash with explicit args", "bash -x {0}", l, ShellPlan{"bash", filepath.Join(both, "bash"), "-x {0}"}, ""},
99+ {"unknown command", "nope {0}", l, ShellPlan{}, "nope: command not found"},
100+ {"pwsh not installed", "pwsh", l, ShellPlan{}, "pwsh: command not found"},
101+ {"no sh at all", "", Lookup{Path: t.TempDir()}, ShellPlan{}, "sh: command not found"},
102+ {"custom without {0}", "perl -w", l, ShellPlan{}, "Invalid shell option"},
103+ {"no format for an unknown shell", "perl", l, ShellPlan{}, "Invalid shell option"},
104+ }
105+ for _, tc := range tests {
106+ t.Run(tc.name, func(t *testing.T) {
107+ got, err := PlanShell(tc.shell, tc.l)
108+ if tc.err != "" {
109+ if err == nil || !strings.Contains(err.Error(), tc.err) {
110+ t.Fatalf("err = %v, want %q", err, tc.err)
111+ }
112+ return
113+ }
114+ if err != nil || got != tc.want {
115+ t.Fatalf("got %+v, %v; want %+v", got, err, tc.want)
116+ }
117+ })
118+ }
119+}
120+
121+func TestPlanShellUsesPrependedPath(t *testing.T) {
122+ sys, extra := fakeBin(t, "sh"), fakeBin(t, "bash")
123+ p, err := PlanShell("", Lookup{Path: sys, Prepend: []string{extra}})
124+ if err != nil || p.CommandPath != filepath.Join(extra, "bash") {
125+ t.Fatalf("%+v %v", p, err)
126+ }
127+}
128+
129+func TestShellLine(t *testing.T) {
130+ p := ShellPlan{"sh", "/usr/bin/bash", "-e {0}"}
131+ if p.ShellLine() != "/usr/bin/bash -e {0}" || p.Extension() != ".sh" {
132+ t.Fatal(p.ShellLine(), p.Extension())
133+ }
134+}
135+
136+func TestFormatArguments(t *testing.T) {
137+ for _, tc := range []struct{ format, path, want, err string }{
138+ {"-e {0}", "/t/a.sh", "-e /t/a.sh", ""},
139+ {"{0}", "/t/a b.sh", "/t/a b.sh", ""},
140+ {"-c '{0}' {0}", "/t/x", "-c '/t/x' /t/x", ""},
141+ {"{{0}} {0}", "/t/x", "{0} /t/x", ""},
142+ {"x }}", "/t/x", "x }", ""},
143+ {"{0}", `/t/a"b`, `/t/a\"b`, ""}, // double quotes in the path are escaped
144+ {"{0:D}", "/t/x", "/t/x", ""},
145+ {"{1}", "/t/x", "", "Index"},
146+ {"{0", "/t/x", "", "not in a correct format"},
147+ {"a } b", "/t/x", "", "not in a correct format"},
148+ } {
149+ got, err := FormatArguments(tc.format, tc.path)
150+ if tc.err != "" {
151+ if err == nil || !strings.Contains(err.Error(), tc.err) {
152+ t.Errorf("%q: err = %v", tc.format, err)
153+ }
154+ continue
155+ }
156+ if err != nil || got != tc.want {
157+ t.Errorf("%q: %q %v", tc.format, got, err)
158+ }
159+ }
160+}
161+
162+func TestParseArguments(t *testing.T) {
163+ for in, want := range map[string][]string{
164+ "": nil,
165+ " ": nil,
166+ "-e /t/a.sh": {"-e", "/t/a.sh"},
167+ "--noprofile --norc -e -o pipefail /t/a.sh": {"--noprofile", "--norc", "-e", "-o", "pipefail", "/t/a.sh"},
168+ `-c "a b" c`: {"-c", "a b", "c"},
169+ `a\"b`: {`a"b`},
170+ `a\\"b c"`: {`a\b c`},
171+ `a\\b`: {`a\\b`},
172+ `"a""b"`: {`a"b`},
173+ "a\tb": {"a", "b"},
174+ `-command ". '/t/x.ps1'"`: {"-command", ". '/t/x.ps1'"},
175+ `""`: {""},
176+ } {
177+ if got := ParseArguments(in); !reflect.DeepEqual(got, want) {
178+ t.Errorf("%q: %q, want %q", in, got, want)
179+ }
180+ }
181+}
182+
183+func TestWriteScript(t *testing.T) {
184+ dir := t.TempDir()
185+ bin := fakeBin(t, "bash", "sh", "python")
186+ l := Lookup{Path: bin}
187+ for _, tc := range []struct {
188+ shell, ext string
189+ args []string
190+ }{
191+ {"", ".sh", []string{"-e"}},
192+ {"bash", ".sh", []string{"--noprofile", "--norc", "-e", "-o", "pipefail"}},
193+ {"python", ".py", nil},
194+ } {
195+ plan, err := PlanShell(tc.shell, l)
196+ if err != nil {
197+ t.Fatal(err)
198+ }
199+ s, err := WriteScript(dir, plan, "echo hi\n")
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ if filepath.Dir(s.Path) != dir || filepath.Ext(s.Path) != tc.ext {
204+ t.Fatalf("%s: path %s", tc.shell, s.Path)
205+ }
206+ want := append(append([]string{plan.CommandPath}, tc.args...), s.Path)
207+ if !reflect.DeepEqual(s.Argv, want) {
208+ t.Fatalf("%s: argv %q, want %q", tc.shell, s.Argv, want)
209+ }
210+ b, _ := os.ReadFile(s.Path)
211+ if string(b) != "echo hi\n" {
212+ t.Fatalf("contents %q", b)
213+ }
214+ }
215+ // A custom shell without an extension in the table: no extension.
216+ plan, _ := PlanShell("python {0}", l)
217+ if s, _ := WriteScript(dir, plan, "x"); filepath.Ext(s.Path) != ".py" {
218+ t.Fatalf("python {0}: %s", s.Path)
219+ }
220+ bin2 := fakeBin(t, "perl")
221+ plan, _ = PlanShell("perl {0}", Lookup{Path: bin2})
222+ if s, _ := WriteScript(dir, plan, "x"); filepath.Ext(s.Path) != "" {
223+ t.Fatalf("perl: %s", s.Path)
224+ }
225+}
226+
227+func TestResolveWorkingDirectory(t *testing.T) {
228+ for _, tc := range [][3]string{
229+ {"/w/r/r", "", "/w/r/r"},
230+ {"/w/r/r", "sub", "/w/r/r/sub"},
231+ {"/w/r/r", "a/b", "/w/r/r/a/b"},
232+ {"/w/r/r", "/tmp", "/tmp"},
233+ {"/w/r/r", "..", "/w/r/r/.."},
234+ } {
235+ if got := ResolveWorkingDirectory(tc[0], tc[1]); got != tc[2] {
236+ t.Errorf("%v: %q", tc, got)
237+ }
238+ }
239+}
@@ -0,0 +1,239 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "testing"
11+)
12+
13+func fakeBin(t *testing.T, names ...string) string {
14+ t.Helper()
15+ dir := t.TempDir()
16+ for _, n := range names {
17+ if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\n"), 0o755); err != nil {
18+ t.Fatal(err)
19+ }
20+ }
21+ return dir
22+}
23+
24+func TestBuiltinFormatsAndExtensions(t *testing.T) {
25+ // ScriptHandlerHelpers.cs _defaultArguments and _extensions (Linux rows).
26+ for shell, want := range map[string][2]string{
27+ "bash": {"--noprofile --norc -e -o pipefail {0}", ".sh"},
28+ "sh": {"-e {0}", ".sh"},
29+ "python": {"{0}", ".py"},
30+ "pwsh": {`-command ". '{0}'"`, ".ps1"},
31+ "powershell": {`-command ". '{0}'"`, ".ps1"},
32+ "cmd": {`/D /E:ON /V:OFF /S /C "CALL "{0}""`, ".cmd"},
33+ "BASH": {"--noprofile --norc -e -o pipefail {0}", ".sh"}, // case-insensitive tables
34+ "python3": {"", ""},
35+ "ruby": {"", ""},
36+ } {
37+ if got := ScriptArgumentsFormat(shell); got != want[0] {
38+ t.Errorf("format(%s) = %q", shell, got)
39+ }
40+ if got := ScriptFileExtension(shell); got != want[1] {
41+ t.Errorf("extension(%s) = %q", shell, got)
42+ }
43+ }
44+}
45+
46+func TestFixUpScriptContents(t *testing.T) {
47+ if got := FixUpScriptContents("cmd", "echo hi"); got != "@echo off\necho hi" {
48+ t.Fatal(got)
49+ }
50+ want := "$ErrorActionPreference = 'stop'\nGet-Date\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }"
51+ for _, s := range []string{"pwsh", "powershell"} {
52+ if got := FixUpScriptContents(s, "Get-Date"); got != want {
53+ t.Fatalf("%s: %q", s, got)
54+ }
55+ }
56+ // The switch is case-sensitive: PWSH is left alone.
57+ for _, s := range []string{"bash", "sh", "python", "PWSH", "Cmd"} {
58+ if got := FixUpScriptContents(s, "x"); got != "x" {
59+ t.Fatalf("%s: %q", s, got)
60+ }
61+ }
62+}
63+
64+func TestParseShellOption(t *testing.T) {
65+ for in, want := range map[string][2]string{
66+ "bash": {"bash", ""},
67+ "bash -x {0}": {"bash", "-x {0}"},
68+ "python3 {0}": {"python3", "{0}"},
69+ "perl -e {0}": {"perl", " -e {0}"}, // Split(" ", 2) keeps the second space
70+ "": {"", ""},
71+ "node -e \"x y\" {0}": {"node", "-e \"x y\" {0}"},
72+ } {
73+ c, a := ParseShellOption(in)
74+ if c != want[0] || a != want[1] {
75+ t.Errorf("%q: %q %q", in, c, a)
76+ }
77+ }
78+}
79+
80+func TestPlanShell(t *testing.T) {
81+ both := fakeBin(t, "bash", "sh", "python", "python3", "perl")
82+ onlySh := fakeBin(t, "sh")
83+ l := Lookup{Path: both}
84+ tests := []struct {
85+ name string
86+ shell string
87+ l Lookup
88+ want ShellPlan
89+ err string
90+ }{
91+ {"default uses bash with the arguments of sh", "", l, ShellPlan{"sh", filepath.Join(both, "bash"), "-e {0}"}, ""},
92+ {"default falls back to sh", "", Lookup{Path: onlySh}, ShellPlan{"sh", filepath.Join(onlySh, "sh"), "-e {0}"}, ""},
93+ {"bash", "bash", l, ShellPlan{"bash", filepath.Join(both, "bash"), "--noprofile --norc -e -o pipefail {0}"}, ""},
94+ {"sh", "sh", l, ShellPlan{"sh", filepath.Join(both, "sh"), "-e {0}"}, ""},
95+ {"python", "python", l, ShellPlan{"python", filepath.Join(both, "python"), "{0}"}, ""},
96+ {"custom with args", "python3 {0}", l, ShellPlan{"python3", filepath.Join(both, "python3"), "{0}"}, ""},
97+ {"custom args are left-trimmed", "perl -w {0}", l, ShellPlan{"perl", filepath.Join(both, "perl"), "-w {0}"}, ""},
98+ {"bash with explicit args", "bash -x {0}", l, ShellPlan{"bash", filepath.Join(both, "bash"), "-x {0}"}, ""},
99+ {"unknown command", "nope {0}", l, ShellPlan{}, "nope: command not found"},
100+ {"pwsh not installed", "pwsh", l, ShellPlan{}, "pwsh: command not found"},
101+ {"no sh at all", "", Lookup{Path: t.TempDir()}, ShellPlan{}, "sh: command not found"},
102+ {"custom without {0}", "perl -w", l, ShellPlan{}, "Invalid shell option"},
103+ {"no format for an unknown shell", "perl", l, ShellPlan{}, "Invalid shell option"},
104+ }
105+ for _, tc := range tests {
106+ t.Run(tc.name, func(t *testing.T) {
107+ got, err := PlanShell(tc.shell, tc.l)
108+ if tc.err != "" {
109+ if err == nil || !strings.Contains(err.Error(), tc.err) {
110+ t.Fatalf("err = %v, want %q", err, tc.err)
111+ }
112+ return
113+ }
114+ if err != nil || got != tc.want {
115+ t.Fatalf("got %+v, %v; want %+v", got, err, tc.want)
116+ }
117+ })
118+ }
119+}
120+
121+func TestPlanShellUsesPrependedPath(t *testing.T) {
122+ sys, extra := fakeBin(t, "sh"), fakeBin(t, "bash")
123+ p, err := PlanShell("", Lookup{Path: sys, Prepend: []string{extra}})
124+ if err != nil || p.CommandPath != filepath.Join(extra, "bash") {
125+ t.Fatalf("%+v %v", p, err)
126+ }
127+}
128+
129+func TestShellLine(t *testing.T) {
130+ p := ShellPlan{"sh", "/usr/bin/bash", "-e {0}"}
131+ if p.ShellLine() != "/usr/bin/bash -e {0}" || p.Extension() != ".sh" {
132+ t.Fatal(p.ShellLine(), p.Extension())
133+ }
134+}
135+
136+func TestFormatArguments(t *testing.T) {
137+ for _, tc := range []struct{ format, path, want, err string }{
138+ {"-e {0}", "/t/a.sh", "-e /t/a.sh", ""},
139+ {"{0}", "/t/a b.sh", "/t/a b.sh", ""},
140+ {"-c '{0}' {0}", "/t/x", "-c '/t/x' /t/x", ""},
141+ {"{{0}} {0}", "/t/x", "{0} /t/x", ""},
142+ {"x }}", "/t/x", "x }", ""},
143+ {"{0}", `/t/a"b`, `/t/a\"b`, ""}, // double quotes in the path are escaped
144+ {"{0:D}", "/t/x", "/t/x", ""},
145+ {"{1}", "/t/x", "", "Index"},
146+ {"{0", "/t/x", "", "not in a correct format"},
147+ {"a } b", "/t/x", "", "not in a correct format"},
148+ } {
149+ got, err := FormatArguments(tc.format, tc.path)
150+ if tc.err != "" {
151+ if err == nil || !strings.Contains(err.Error(), tc.err) {
152+ t.Errorf("%q: err = %v", tc.format, err)
153+ }
154+ continue
155+ }
156+ if err != nil || got != tc.want {
157+ t.Errorf("%q: %q %v", tc.format, got, err)
158+ }
159+ }
160+}
161+
162+func TestParseArguments(t *testing.T) {
163+ for in, want := range map[string][]string{
164+ "": nil,
165+ " ": nil,
166+ "-e /t/a.sh": {"-e", "/t/a.sh"},
167+ "--noprofile --norc -e -o pipefail /t/a.sh": {"--noprofile", "--norc", "-e", "-o", "pipefail", "/t/a.sh"},
168+ `-c "a b" c`: {"-c", "a b", "c"},
169+ `a\"b`: {`a"b`},
170+ `a\\"b c"`: {`a\b c`},
171+ `a\\b`: {`a\\b`},
172+ `"a""b"`: {`a"b`},
173+ "a\tb": {"a", "b"},
174+ `-command ". '/t/x.ps1'"`: {"-command", ". '/t/x.ps1'"},
175+ `""`: {""},
176+ } {
177+ if got := ParseArguments(in); !reflect.DeepEqual(got, want) {
178+ t.Errorf("%q: %q, want %q", in, got, want)
179+ }
180+ }
181+}
182+
183+func TestWriteScript(t *testing.T) {
184+ dir := t.TempDir()
185+ bin := fakeBin(t, "bash", "sh", "python")
186+ l := Lookup{Path: bin}
187+ for _, tc := range []struct {
188+ shell, ext string
189+ args []string
190+ }{
191+ {"", ".sh", []string{"-e"}},
192+ {"bash", ".sh", []string{"--noprofile", "--norc", "-e", "-o", "pipefail"}},
193+ {"python", ".py", nil},
194+ } {
195+ plan, err := PlanShell(tc.shell, l)
196+ if err != nil {
197+ t.Fatal(err)
198+ }
199+ s, err := WriteScript(dir, plan, "echo hi\n")
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ if filepath.Dir(s.Path) != dir || filepath.Ext(s.Path) != tc.ext {
204+ t.Fatalf("%s: path %s", tc.shell, s.Path)
205+ }
206+ want := append(append([]string{plan.CommandPath}, tc.args...), s.Path)
207+ if !reflect.DeepEqual(s.Argv, want) {
208+ t.Fatalf("%s: argv %q, want %q", tc.shell, s.Argv, want)
209+ }
210+ b, _ := os.ReadFile(s.Path)
211+ if string(b) != "echo hi\n" {
212+ t.Fatalf("contents %q", b)
213+ }
214+ }
215+ // A custom shell without an extension in the table: no extension.
216+ plan, _ := PlanShell("python {0}", l)
217+ if s, _ := WriteScript(dir, plan, "x"); filepath.Ext(s.Path) != ".py" {
218+ t.Fatalf("python {0}: %s", s.Path)
219+ }
220+ bin2 := fakeBin(t, "perl")
221+ plan, _ = PlanShell("perl {0}", Lookup{Path: bin2})
222+ if s, _ := WriteScript(dir, plan, "x"); filepath.Ext(s.Path) != "" {
223+ t.Fatalf("perl: %s", s.Path)
224+ }
225+}
226+
227+func TestResolveWorkingDirectory(t *testing.T) {
228+ for _, tc := range [][3]string{
229+ {"/w/r/r", "", "/w/r/r"},
230+ {"/w/r/r", "sub", "/w/r/r/sub"},
231+ {"/w/r/r", "a/b", "/w/r/r/a/b"},
232+ {"/w/r/r", "/tmp", "/tmp"},
233+ {"/w/r/r", "..", "/w/r/r/.."},
234+ } {
235+ if got := ResolveWorkingDirectory(tc[0], tc[1]); got != tc[2] {
236+ t.Errorf("%v: %q", tc, got)
237+ }
238+ }
239+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close