Skip to content

Commit 986b37f

Browse files
authored
Handle quoted args in reclone commands (#618)
1 parent 7626fc2 commit 986b37f

File tree

3 files changed

+129
-2
lines changed

3 files changed

+129
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
1010
### Deprecated
1111
### Removed
1212
### Fixed
13+
- Quotes within reclone commands; thanks @batagy
1314
### Security
1415

1516
## [1.11.8] - 2/1/26

cmd/reclone.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,45 @@ func sanitizeCmd(cmd string) string {
132132
return cmd
133133
}
134134

135+
// splitCommandArgs splits a command string into arguments, handling shell-like
136+
// quoting (double quotes and single quotes). Quoted sections are treated as a
137+
// single argument with the quotes stripped. This is necessary because reclone.yaml
138+
// commands may contain quoted flag values (e.g., --match-regex "(foo|bar)") that
139+
// must be passed to cobra without the literal quote characters.
140+
func splitCommandArgs(cmd string) []string {
141+
var args []string
142+
var current strings.Builder
143+
inDoubleQuote := false
144+
inSingleQuote := false
145+
146+
for i := 0; i < len(cmd); i++ {
147+
ch := cmd[i]
148+
149+
switch {
150+
case ch == '"' && !inSingleQuote:
151+
inDoubleQuote = !inDoubleQuote
152+
case ch == '\'' && !inDoubleQuote:
153+
inSingleQuote = !inSingleQuote
154+
case ch == ' ' && !inDoubleQuote && !inSingleQuote:
155+
if current.Len() > 0 {
156+
args = append(args, current.String())
157+
current.Reset()
158+
}
159+
default:
160+
current.WriteByte(ch)
161+
}
162+
}
163+
164+
if current.Len() > 0 {
165+
args = append(args, current.String())
166+
}
167+
168+
return args
169+
}
170+
135171
func runReClone(rc ReClone, rcIdentifier string) {
136172
// make sure command starts with ghorg clone
137-
splitCommand := strings.Split(rc.Cmd, " ")
173+
splitCommand := splitCommandArgs(rc.Cmd)
138174
ghorg, clone, remainingCommand := splitCommand[0], splitCommand[1], splitCommand[1:]
139175

140176
if ghorg != "ghorg" || clone != "clone" {

cmd/reclone_test.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,96 @@
11
package cmd
22

3-
import "testing"
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func Test_splitCommandArgs(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
cmd string
12+
want []string
13+
}{
14+
{
15+
name: "simple command without quotes",
16+
cmd: "ghorg clone my-org --scm=gitlab",
17+
want: []string{"ghorg", "clone", "my-org", "--scm=gitlab"},
18+
},
19+
{
20+
name: "double-quoted flag value with spaces",
21+
cmd: `ghorg clone my-org --match-regex "(foo|bar)"`,
22+
want: []string{"ghorg", "clone", "my-org", "--match-regex", "(foo|bar)"},
23+
},
24+
{
25+
name: "double-quoted flag value with equals",
26+
cmd: `ghorg clone my-org --match-regex="(foo|bar)"`,
27+
want: []string{"ghorg", "clone", "my-org", "--match-regex=(foo|bar)"},
28+
},
29+
{
30+
name: "single-quoted flag value",
31+
cmd: `ghorg clone my-org --match-regex '(foo|bar)'`,
32+
want: []string{"ghorg", "clone", "my-org", "--match-regex", "(foo|bar)"},
33+
},
34+
{
35+
name: "gitlab-group-exclude-match-regex with double quotes (reported bug)",
36+
cmd: `ghorg clone group1/group2 --gitlab-group-exclude-match-regex "(subgroup1|subgroup2|subgroup3|helm-charts)"`,
37+
want: []string{"ghorg", "clone", "group1/group2", "--gitlab-group-exclude-match-regex", "(subgroup1|subgroup2|subgroup3|helm-charts)"},
38+
},
39+
{
40+
name: "gitlab-group-match-regex with double quotes",
41+
cmd: `ghorg clone group1/group2 --gitlab-group-match-regex "(subgroup1|subgroup2)"`,
42+
want: []string{"ghorg", "clone", "group1/group2", "--gitlab-group-match-regex", "(subgroup1|subgroup2)"},
43+
},
44+
{
45+
name: "multiple quoted arguments",
46+
cmd: `ghorg clone my-org --match-regex "(foo|bar)" --exclude-match-regex "(baz|qux)"`,
47+
want: []string{"ghorg", "clone", "my-org", "--match-regex", "(foo|bar)", "--exclude-match-regex", "(baz|qux)"},
48+
},
49+
{
50+
name: "quoted value containing spaces",
51+
cmd: `ghorg clone my-org --output-dir "my output dir"`,
52+
want: []string{"ghorg", "clone", "my-org", "--output-dir", "my output dir"},
53+
},
54+
{
55+
name: "no quotes at all",
56+
cmd: "ghorg clone my-org --token=abc123 --scm=github",
57+
want: []string{"ghorg", "clone", "my-org", "--token=abc123", "--scm=github"},
58+
},
59+
{
60+
name: "complex regex with backslashes and anchors",
61+
cmd: `ghorg clone my-group --gitlab-group-exclude-match-regex ".*\/subgroup-a($|\/.*$)"`,
62+
want: []string{"ghorg", "clone", "my-group", "--gitlab-group-exclude-match-regex", `.*\/subgroup-a($|\/.*$)`},
63+
},
64+
{
65+
name: "mixed quoted and unquoted flags",
66+
cmd: `ghorg clone my-org --scm=gitlab --base-url=https://gitlab.example.com --token=secret --gitlab-group-match-regex "(team-a|team-b)" --output-dir=my-repos`,
67+
want: []string{"ghorg", "clone", "my-org", "--scm=gitlab", "--base-url=https://gitlab.example.com", "--token=secret", "--gitlab-group-match-regex", "(team-a|team-b)", "--output-dir=my-repos"},
68+
},
69+
{
70+
name: "multiple spaces between arguments",
71+
cmd: "ghorg clone my-org",
72+
want: []string{"ghorg", "clone", "my-org"},
73+
},
74+
{
75+
name: "single quotes inside double quotes are preserved",
76+
cmd: `ghorg clone my-org --match-regex "it's-a-test"`,
77+
want: []string{"ghorg", "clone", "my-org", "--match-regex", "it's-a-test"},
78+
},
79+
{
80+
name: "double quotes inside single quotes are preserved",
81+
cmd: `ghorg clone my-org --match-regex 'say "hello"'`,
82+
want: []string{"ghorg", "clone", "my-org", "--match-regex", `say "hello"`},
83+
},
84+
}
85+
for _, tt := range tests {
86+
t.Run(tt.name, func(t *testing.T) {
87+
got := splitCommandArgs(tt.cmd)
88+
if !reflect.DeepEqual(got, tt.want) {
89+
t.Errorf("splitCommandArgs() = %v, want %v", got, tt.want)
90+
}
91+
})
92+
}
93+
}
494

595
func Test_sanitizeCmd(t *testing.T) {
696
type args struct {

0 commit comments

Comments
 (0)