Skip to content

Commit 40142c7

Browse files
Merge pull request #19 from kbdevs/fix/inline-python-rewrite
fix: avoid rewriting quoted inline commands
2 parents 1e6b710 + 53f07da commit 40142c7

File tree

2 files changed

+126
-18
lines changed

2 files changed

+126
-18
lines changed

internal/initcmd/init.go

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,75 @@ fi
2222
2323
set -euo pipefail
2424
25+
leading_ws_len() {
26+
local input="$1"
27+
local len=${#input}
28+
local i=0
29+
30+
while [ $i -lt $len ]; do
31+
case "${input:$i:1}" in
32+
[[:space:]]) i=$((i + 1)) ;;
33+
*) break ;;
34+
esac
35+
done
36+
37+
printf '%s' "$i"
38+
}
39+
40+
trailing_ws_len() {
41+
local input="$1"
42+
local i=$((${#input} - 1))
43+
local count=0
44+
45+
while [ $i -ge 0 ]; do
46+
case "${input:$i:1}" in
47+
[[:space:]])
48+
count=$((count + 1))
49+
i=$((i - 1))
50+
;;
51+
*) break ;;
52+
esac
53+
done
54+
55+
printf '%s' "$count"
56+
}
57+
58+
extract_first_segment() {
59+
local input="$1"
60+
local len=${#input}
61+
local i=0
62+
local quote=""
63+
local ch
64+
65+
while [ $i -lt $len ]; do
66+
ch="${input:$i:1}"
67+
68+
if [ -n "$quote" ]; then
69+
if [ "$ch" = "\\" ] && [ "$quote" = '"' ]; then
70+
i=$((i + 2))
71+
continue
72+
fi
73+
74+
if [ "$ch" = "$quote" ]; then
75+
quote=""
76+
fi
77+
78+
i=$((i + 1))
79+
continue
80+
fi
81+
82+
case "$ch" in
83+
"'") quote="'" ;;
84+
'"') quote='"' ;;
85+
';'|'|'|'&') break ;;
86+
esac
87+
88+
i=$((i + 1))
89+
done
90+
91+
printf '%s' "${input:0:i}"
92+
}
93+
2594
INPUT=$(cat)
2695
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
2796
@@ -30,17 +99,29 @@ if [ -z "$CMD" ]; then
3099
exit 0
31100
fi
32101
33-
# Extract the first command (before && or | or ;)
34-
# head -1 prevents xargs from seeing heredoc body lines with unmatched quotes
35-
FIRST_CMD=$(echo "$CMD" | head -1 | sed 's/[;&|].*//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
102+
# Extract the first command segment, ignoring separators inside quotes.
103+
# head -1 keeps heredoc bodies out of the scan.
104+
FIRST_LINE=$(printf '%s\n' "$CMD" | head -1)
105+
FIRST_SEGMENT=$(extract_first_segment "$FIRST_LINE")
106+
LEADING_WS_LEN=$(leading_ws_len "$FIRST_SEGMENT")
107+
TRAILING_WS_LEN=$(trailing_ws_len "$FIRST_SEGMENT")
108+
FIRST_CMD_LEN=$((${#FIRST_SEGMENT} - LEADING_WS_LEN - TRAILING_WS_LEN))
109+
if [ $FIRST_CMD_LEN -lt 0 ]; then
110+
FIRST_CMD_LEN=0
111+
fi
112+
FIRST_PREFIX="${FIRST_SEGMENT:0:LEADING_WS_LEN}"
113+
FIRST_CMD="${FIRST_SEGMENT:LEADING_WS_LEN:FIRST_CMD_LEN}"
114+
FIRST_SUFFIX_START=$((LEADING_WS_LEN + FIRST_CMD_LEN))
115+
FIRST_SUFFIX="${FIRST_SEGMENT:FIRST_SUFFIX_START}"
36116
37117
# Skip if already using snip
38118
case "$FIRST_CMD" in
39119
snip\ *|*/snip\ *) exit 0 ;;
40120
esac
41121
42122
# Strip leading env var assignments (e.g. CGO_ENABLED=0 go test)
43-
BARE_CMD=$(echo "$FIRST_CMD" | sed 's/^[A-Za-z_][A-Za-z0-9_]*=[^ ]* *//')
123+
ENV_PREFIX=$(printf '%s' "$FIRST_CMD" | sed -E 's/^(([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]*)*).*/\1/')
124+
BARE_CMD="${FIRST_CMD:${#ENV_PREFIX}}"
44125
45126
# Extract the base command name
46127
BASE=$(echo "$BARE_CMD" | awk '{print $1}')
@@ -52,7 +133,8 @@ case "$BASE" in
52133
# Rewrite: prefix with "snip --" so flags like --help or --version in the
53134
# original command are passed verbatim to the underlying tool, not parsed
54135
# by snip itself.
55-
REWRITE=$(echo "$CMD" | sed "s|$BARE_CMD|snip -- $BARE_CMD|")
136+
REST="${CMD:${#FIRST_SEGMENT}}"
137+
REWRITE="${FIRST_PREFIX}${ENV_PREFIX}snip -- ${BARE_CMD}${FIRST_SUFFIX}${REST}"
56138
;;
57139
esac
58140

internal/initcmd/init_test.go

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -196,38 +196,33 @@ func TestUnpatchPreservesOtherHooks(t *testing.T) {
196196
}
197197
}
198198

199-
// TestHookScriptMultilineCommand verifies that the installed hook script handles
200-
// multiline commands (e.g. git commit with a heredoc) without error.
201-
// Previously, xargs was used to trim whitespace from FIRST_CMD and would fail
202-
// with exit 1 on unmatched quotes present in heredoc body lines.
203-
func TestHookScriptMultilineCommand(t *testing.T) {
199+
func runHookScript(t *testing.T, cmd string) string {
200+
t.Helper()
201+
204202
if _, err := exec.LookPath("bash"); err != nil {
205203
t.Skip("bash not available")
206204
}
207205
if _, err := exec.LookPath("jq"); err != nil {
208206
t.Skip("jq not available")
209207
}
210-
if _, err := exec.LookPath("snip"); err != nil {
211-
t.Skip("snip not available")
212-
}
213208

214-
// Write the hook to a temp file (simulates snip init).
215209
dir := t.TempDir()
216210
hookPath := filepath.Join(dir, "snip-rewrite.sh")
217211
if err := os.WriteFile(hookPath, []byte(hookScript), 0755); err != nil {
218212
t.Fatalf("write hook: %v", err)
219213
}
214+
snipPath := filepath.Join(dir, "snip")
215+
if err := os.WriteFile(snipPath, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
216+
t.Fatalf("write fake snip: %v", err)
217+
}
220218

221-
// Simulate the JSON Claude Code sends for a heredoc-style git commit.
222-
// The multiline command contains an unmatched `)"` on the last line,
223-
// which caused xargs to exit 1 (unmatched double quote).
224-
cmd := "git add file.go && git commit -m \"$(cat <<'EOF'\n fix: something\n\n Co-Authored-By: Bot <bot@example.com>\n EOF\n )\""
225219
payload, _ := json.Marshal(map[string]any{
226220
"tool_name": "Bash",
227221
"tool_input": map[string]any{"command": cmd},
228222
})
229223

230224
proc := exec.Command("bash", hookPath)
225+
proc.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"))
231226
proc.Stdin = strings.NewReader(string(payload))
232227
output, runErr := proc.Output()
233228
if runErr != nil {
@@ -242,12 +237,43 @@ func TestHookScriptMultilineCommand(t *testing.T) {
242237
hookOut, _ := result["hookSpecificOutput"].(map[string]any)
243238
updated, _ := hookOut["updatedInput"].(map[string]any)
244239
rewritten, _ := updated["command"].(string)
240+
return rewritten
241+
}
242+
243+
// TestHookScriptMultilineCommand verifies that the installed hook script handles
244+
// multiline commands (e.g. git commit with a heredoc) without error.
245+
// Previously, xargs was used to trim whitespace from FIRST_CMD and would fail
246+
// with exit 1 on unmatched quotes present in heredoc body lines.
247+
func TestHookScriptMultilineCommand(t *testing.T) {
248+
// Simulate the JSON Claude Code sends for a heredoc-style git commit.
249+
// The multiline command contains an unmatched `)"` on the last line,
250+
// which caused xargs to exit 1 (unmatched double quote).
251+
cmd := "git add file.go && git commit -m \"$(cat <<'EOF'\n fix: something\n\n Co-Authored-By: Bot <bot@example.com>\n EOF\n )\""
252+
rewritten := runHookScript(t, cmd)
245253

246254
if !strings.HasPrefix(rewritten, "snip -- git add ") {
247255
t.Errorf("expected rewritten command to start with 'snip -- git add', got: %s", rewritten)
248256
}
249257
}
250258

259+
func TestHookScriptInlinePythonDoesNotRewriteQuotedSemicolons(t *testing.T) {
260+
cmd := "git commit -m \"$(python3 -c \\\"from pathlib import Path; import sys; print(Path('.').name); print(sys.version)\\\")\" && git status"
261+
rewritten := runHookScript(t, cmd)
262+
263+
if !strings.HasPrefix(rewritten, "snip -- git commit ") {
264+
t.Fatalf("expected rewritten command to start with 'snip -- git commit', got: %s", rewritten)
265+
}
266+
if strings.Count(rewritten, "snip --") != 1 {
267+
t.Fatalf("expected exactly one snip injection, got: %s", rewritten)
268+
}
269+
if strings.Contains(rewritten, "; snip") {
270+
t.Fatalf("expected inline python to stay unchanged, got: %s", rewritten)
271+
}
272+
if strings.Contains(rewritten, "python3 snip") {
273+
t.Fatalf("expected inline python command to stay unchanged, got: %s", rewritten)
274+
}
275+
}
276+
251277
func readSettings(t *testing.T, path string) map[string]any {
252278
t.Helper()
253279
data, err := os.ReadFile(path)

0 commit comments

Comments
 (0)