Skip to content

Commit 8be95f0

Browse files
committed
Implement git command handler and improve subshell normalization
Adds a specialized handler for git to normalize common flags (message, author, count) and stash references. Also improves general shell normalization for herestrings, line continuations, and recursive subshell processing.
1 parent 1625ac9 commit 8be95f0

7 files changed

Lines changed: 552 additions & 10 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ about specific commands where positional arguments have known semantics:
8383

8484
### Subshell safety
8585

86-
Handlers preserve `$(...)` verbatim. `echo hello` and `echo $(rm -rf /)`
86+
Subshell expressions like `$(...)` are recursively normalized but never
87+
collapsed to a data placeholder. `echo hello` and `echo $(rm -rf /)`
8788
always produce different shapes. This is enforced by tests on every handler.
8889

8990
## Use as a library

handler.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ var redirectConsumeNext = map[string]bool{
88
">": true, ">>": true, "<": true,
99
"&>": true, "&>>": true,
1010
"2>": true, "2>>": true, "1>": true,
11+
"<<<": true,
1112
}
1213

1314
var redirectStandalone = map[string]bool{
@@ -29,7 +30,11 @@ func splitRedirects(tokens []string) (args, redirects []string) {
2930

3031
if redirectConsumeNext[tok] {
3132
if i+1 < len(tokens) {
32-
redirects = append(redirects, tok, "<path>")
33+
placeholder := "<path>"
34+
if tok == "<<<" {
35+
placeholder = "<str>"
36+
}
37+
redirects = append(redirects, tok, placeholder)
3338
i += 2
3439
} else {
3540
redirects = append(redirects, tok)

handler_git.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package shellshape
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
func init() {
9+
Register("git", handleGit, HandlerOptions{HasSubcommands: true})
10+
}
11+
12+
var stashRefRE = regexp.MustCompile(`^stash@\{\d+\}$`)
13+
14+
// handleGit handles git subcommand arguments.
15+
// Since git is in subcommandExecutables, the subcommand (commit, push, log, etc.)
16+
// is already consumed before this handler is called.
17+
//
18+
// Message flags (-m, --message) collapse their argument to <str>.
19+
// Value flags (--author, --format, --since, --onto, etc.) collapse to <val>.
20+
// Numeric flags (-n, --depth, -U) collapse to N.
21+
// Path flags (-C, -f, --pathspec-from-file) collapse to <path>.
22+
// Global config flags (-c key=value) collapse to <val>.
23+
// Boolean flags are kept verbatim.
24+
// Stash references (stash@{N}) normalize the index to N.
25+
// Remaining positionals use classifyToken.
26+
func handleGit(subcommand string, tokens []string) []string {
27+
args, redirects := splitRedirects(tokens)
28+
29+
// Long flags that can appear with = and should collapse the value.
30+
eqValFlags := map[string]bool{
31+
"--pretty": true,
32+
"--format": true,
33+
}
34+
35+
// Flags whose next token is a message string.
36+
msgFlags := map[string]bool{
37+
"-m": true, "--message": true,
38+
}
39+
40+
// Flags whose next token is a generic value.
41+
valFlags := map[string]bool{
42+
"--author": true,
43+
"--date": true,
44+
"--format": true,
45+
"--pretty": true,
46+
"--since": true, "--after": true,
47+
"--until": true, "--before": true,
48+
"--grep": true,
49+
"--diff-filter": true,
50+
"--onto": true,
51+
"--set-upstream-to": true,
52+
"--strategy": true, "-s": true,
53+
"--strategy-option": true, "-X": true,
54+
"--remote": true,
55+
"-c": true,
56+
"--cleanup": true,
57+
"-C": true,
58+
"--fixup": true,
59+
"--squash": true,
60+
"--trailer": true,
61+
"--output": true, "-o": true,
62+
}
63+
64+
// Flags whose next token is a number.
65+
numericFlags := map[string]bool{
66+
"-n": true, "--max-count": true,
67+
"--depth": true, "--deepen": true,
68+
"-U": true, "--unified": true,
69+
"-j": true, "--jobs": true,
70+
"--skip": true,
71+
"--abbrev": true,
72+
}
73+
74+
// Flags whose next token is a path.
75+
pathFlags := map[string]bool{
76+
"-f": true, "--file": true,
77+
"--pathspec-from-file": true,
78+
"--work-tree": true,
79+
"--git-dir": true,
80+
"--template": true,
81+
}
82+
83+
// Fused short flags where -m is combined with other flags (e.g., -am).
84+
// When we see -am, we know -m consumes the next token as a message.
85+
isFusedMsgFlag := func(tok string) bool {
86+
if len(tok) < 3 || tok[0] != '-' || tok[1] == '-' {
87+
return false
88+
}
89+
// -am, -sm, etc. — ends with 'm'
90+
return tok[len(tok)-1] == 'm'
91+
}
92+
93+
var result []string
94+
configKeysSeen := 0
95+
i := 0
96+
for i < len(args) {
97+
tok := args[i]
98+
99+
if isSubshellToken(tok) {
100+
result = append(result, tok)
101+
i++
102+
continue
103+
}
104+
105+
// Stash references: stash@{0}, stash@{2} → stash@{N}
106+
if stashRefRE.MatchString(tok) {
107+
result = append(result, "stash@{N}")
108+
i++
109+
continue
110+
}
111+
112+
if msgFlags[tok] {
113+
result = append(result, tok)
114+
i++
115+
if i < len(args) {
116+
if isSubshellToken(args[i]) {
117+
result = append(result, args[i])
118+
} else {
119+
result = append(result, "<str>")
120+
}
121+
i++
122+
}
123+
continue
124+
}
125+
126+
// Fused message flags like -am
127+
if isFusedMsgFlag(tok) {
128+
result = append(result, tok)
129+
i++
130+
if i < len(args) {
131+
if isSubshellToken(args[i]) {
132+
result = append(result, args[i])
133+
} else {
134+
result = append(result, "<str>")
135+
}
136+
i++
137+
}
138+
continue
139+
}
140+
141+
if valFlags[tok] {
142+
result = append(result, tok)
143+
i++
144+
if i < len(args) {
145+
if isSubshellToken(args[i]) {
146+
result = append(result, args[i])
147+
} else {
148+
result = append(result, "<val>")
149+
}
150+
i++
151+
}
152+
continue
153+
}
154+
155+
if numericFlags[tok] {
156+
result = append(result, tok)
157+
i++
158+
if i < len(args) {
159+
if isSubshellToken(args[i]) {
160+
result = append(result, args[i])
161+
} else {
162+
result = append(result, "N")
163+
}
164+
i++
165+
}
166+
continue
167+
}
168+
169+
if pathFlags[tok] {
170+
result = append(result, tok)
171+
i++
172+
if i < len(args) {
173+
if isSubshellToken(args[i]) {
174+
result = append(result, args[i])
175+
} else {
176+
result = append(result, "<path>")
177+
}
178+
i++
179+
}
180+
continue
181+
}
182+
183+
// Handle --flag=value forms for known flags.
184+
// Use classifyToken for consistent treatment with the generic path.
185+
if strings.HasPrefix(tok, "--") && strings.Contains(tok, "=") {
186+
eqIdx := strings.Index(tok, "=")
187+
flagName := tok[:eqIdx]
188+
if eqValFlags[flagName] || valFlags[flagName] || msgFlags[flagName] || pathFlags[flagName] || numericFlags[flagName] {
189+
result = append(result, flagName+"=<val>")
190+
i++
191+
continue
192+
}
193+
}
194+
195+
if isFlagToken(tok) {
196+
result = append(result, tok)
197+
i++
198+
continue
199+
}
200+
201+
// Config subcommand: second positional (after the key) is a value.
202+
if subcommand == "config" && configKeysSeen > 0 {
203+
result = append(result, "<val>")
204+
i++
205+
continue
206+
}
207+
208+
// Positional: classify generically
209+
classified := classifyToken(tok)
210+
if subcommand == "config" && classified == "<dotted-id>" {
211+
configKeysSeen++
212+
}
213+
result = append(result, classified)
214+
i++
215+
}
216+
217+
result = append(result, redirects...)
218+
return result
219+
}

handler_git_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package shellshape
2+
3+
import "testing"
4+
5+
func TestGit(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
input string
9+
want string
10+
}{
11+
// commit
12+
{"commit with message", "git commit -m 'fix login bug'", "git commit -m <str>"},
13+
{"commit with long message flag", "git commit --message 'update readme'", "git commit --message <str>"},
14+
{"commit with author", "git commit --author 'John Doe <john@example.com>'", "git commit --author <val>"},
15+
{"commit amend no edit", "git commit --amend --no-edit", "git commit --amend --no-edit"},
16+
{"commit specific files", "git commit -m 'fix' src/main.go src/util.go", "git commit -m <str> <path>+"},
17+
{"commit all with message", "git commit -am 'quick fix'", "git commit -am <str>"},
18+
19+
// add
20+
{"add single file", "git add src/main.go", "git add <path>"},
21+
{"add multiple files", "git add file1.js file2.js file3.js", "git add <path>+"},
22+
{"add all", "git add -A", "git add -A"},
23+
{"add patch", "git add -p src/main.go", "git add -p <path>"},
24+
25+
// push / pull / fetch
26+
{"push simple", "git push", "git push"},
27+
{"push remote branch", "git push origin main", "git push origin main"},
28+
{"push force", "git push --force-with-lease origin feature/foo", "git push --force-with-lease origin <path>"},
29+
{"pull", "git pull origin main", "git pull origin main"},
30+
{"pull rebase", "git pull --rebase", "git pull --rebase"},
31+
{"fetch", "git fetch --all", "git fetch --all"},
32+
{"fetch prune", "git fetch --prune origin", "git fetch --prune origin"},
33+
34+
// log
35+
{"log simple", "git log --oneline", "git log --oneline"},
36+
{"log with count", "git log -n 10 --oneline", "git log -n N --oneline"},
37+
{"log with format", "git log --format '%H %s'", "git log --format <val>"},
38+
{"log with pretty", "git log --pretty=oneline", "git log --pretty=<val>"},
39+
{"log with author filter", "git log --author john", "git log --author <val>"},
40+
{"log since", "git log --since '2024-01-01'", "git log --since <val>"},
41+
42+
// diff
43+
{"diff simple", "git diff", "git diff"},
44+
{"diff staged", "git diff --staged", "git diff --staged"},
45+
{"diff with file", "git diff HEAD src/main.go", "git diff HEAD <path>"},
46+
{"diff rev range", "git diff main...feature", "git diff <range>"},
47+
{"diff context lines", "git diff -U 5", "git diff -U N"},
48+
49+
// clone
50+
{"clone https", "git clone https://github.com/user/repo.git", "git clone <https-uri>"},
51+
{"clone ssh", "git clone git@github.com:user/repo.git", "git clone <git-uri>"},
52+
{"clone with dir", "git clone https://github.com/user/repo.git mydir", "git clone <https-uri> mydir"},
53+
{"clone depth", "git clone --depth 1 https://github.com/user/repo.git", "git clone --depth N <https-uri>"},
54+
55+
// checkout / switch / branch
56+
{"checkout branch", "git checkout main", "git checkout main"},
57+
{"checkout new branch", "git checkout -b new-feature", "git checkout -b new-feature"},
58+
{"switch branch", "git switch main", "git switch main"},
59+
{"branch list", "git branch", "git branch"},
60+
{"branch delete", "git branch -d old-branch", "git branch -d old-branch"},
61+
62+
// rebase / merge / reset
63+
{"rebase onto", "git rebase --onto main feature", "git rebase --onto <val> feature"},
64+
{"rebase interactive", "git rebase -i HEAD~3", "git rebase -i <rev>"},
65+
{"merge branch", "git merge feature-branch", "git merge feature-branch"},
66+
{"reset soft", "git reset --soft HEAD~1", "git reset --soft <rev>"},
67+
{"reset file", "git reset HEAD src/main.go", "git reset HEAD <path>"},
68+
69+
// stash
70+
{"stash push", "git stash push -m 'work in progress'", "git stash push -m <str>"},
71+
{"stash pop", "git stash pop", "git stash pop"},
72+
{"stash drop index", "git stash drop stash@{2}", "git stash drop stash@{N}"},
73+
74+
// tag
75+
{"tag annotated", "git tag -a v1.0.0 -m 'release 1.0'", "git tag -a v1.0.0 -m <str>"},
76+
{"tag delete", "git tag -d v1.0.0", "git tag -d v1.0.0"},
77+
78+
// remote
79+
{"remote add", "git remote add origin git@github.com:user/repo.git", "git remote add origin <git-uri>"},
80+
{"remote remove", "git remote remove upstream", "git remote remove upstream"},
81+
82+
// config
83+
{"config set", "git config user.name 'John Doe'", "git config <dotted-id> <val>"},
84+
{"config get", "git config --global user.email", "git config --global <dotted-id>"},
85+
86+
// show
87+
{"show commit", "git show abc1234", "git show <hash>"},
88+
{"show head", "git show HEAD", "git show HEAD"},
89+
90+
// cherry-pick / revert
91+
{"cherry-pick", "git cherry-pick abc1234def", "git cherry-pick <hash>"},
92+
{"revert", "git revert HEAD~2", "git revert <rev>"},
93+
94+
// rm
95+
{"rm file", "git rm src/old.go", "git rm <path>"},
96+
{"rm cached", "git rm --cached secrets.env", "git rm --cached <path>"},
97+
98+
// global flags
99+
{"global -C flag", "git -C /path/to/repo status", "git -C <path> status"},
100+
101+
// redirects
102+
{"log with redirect", "git log --oneline > output.txt", "git log --oneline > <path>"},
103+
}
104+
105+
for _, tt := range tests {
106+
t.Run(tt.name, func(t *testing.T) {
107+
got := Normalize(tt.input)
108+
if got != tt.want {
109+
t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
110+
}
111+
})
112+
}
113+
114+
// COLLISION TESTS
115+
t.Run("different commit messages collide", func(t *testing.T) {
116+
a := Normalize("git commit -m 'fix login bug'")
117+
b := Normalize("git commit -m 'update readme section'")
118+
if a != b {
119+
t.Errorf("expected %q == %q", a, b)
120+
}
121+
})
122+
123+
t.Run("different clone URLs collide", func(t *testing.T) {
124+
a := Normalize("git clone https://github.com/user/repo1.git")
125+
b := Normalize("git clone https://github.com/other/repo2.git")
126+
if a != b {
127+
t.Errorf("expected %q == %q", a, b)
128+
}
129+
})
130+
131+
t.Run("different file paths in add collide", func(t *testing.T) {
132+
a := Normalize("git add src/main.go")
133+
b := Normalize("git add lib/util.ts")
134+
if a != b {
135+
t.Errorf("expected %q == %q", a, b)
136+
}
137+
})
138+
139+
// SAFETY TEST
140+
t.Run("subshell not collapsed", func(t *testing.T) {
141+
benign := Normalize("git add literal-arg")
142+
subshell := Normalize("git add $(dangerous-command)")
143+
if benign == subshell {
144+
t.Error("subshell must produce different shape than literal")
145+
}
146+
})
147+
}

0 commit comments

Comments
 (0)