-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Add support for multi-line git commit messages in auto-approve #8792
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
52fb8e7
129ec91
de425a7
4010b6f
db9abab
c4ad884
18e5cfd
6b892a0
ab10bc2
93c182a
bc5bf83
e7cdab9
7bdd67b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| // npx vitest src/utils/__tests__/command-validation-quote-protection.spec.ts | ||
|
|
||
| import { | ||
| protectNewlinesInQuotes, | ||
| NEWLINE_PLACEHOLDER, | ||
| CARRIAGE_RETURN_PLACEHOLDER, | ||
| } from "../command-validation-quote-protection" | ||
|
|
||
| describe("protectNewlinesInQuotes", () => { | ||
| const newlinePlaceholder = NEWLINE_PLACEHOLDER | ||
| const crPlaceholder = CARRIAGE_RETURN_PLACEHOLDER | ||
|
|
||
| describe("basic quote handling", () => { | ||
| it("protects newlines in double quotes", () => { | ||
| const input = 'echo "hello\nworld"' | ||
| const expected = `echo "hello${newlinePlaceholder}world"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("protects newlines in single quotes", () => { | ||
| const input = "echo 'hello\nworld'" | ||
| const expected = `echo 'hello${newlinePlaceholder}world'` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("does not protect newlines outside quotes", () => { | ||
| const input = "echo hello\necho world" | ||
| const expected = "echo hello\necho world" | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
| }) | ||
|
|
||
| describe("quote concatenation", () => { | ||
| it("handles quote concatenation where content between quotes is NOT quoted", () => { | ||
| // In bash: echo '"'A'"' prints "A" (A is not quoted) | ||
| const input = `echo '"'A\n'"'` | ||
| // The newline after A is NOT inside quotes, so it should NOT be protected | ||
| const expected = `echo '"'A\n'"'` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("handles alternating quotes correctly", () => { | ||
| // echo "hello"world"test" -> hello is quoted, world is not, test is quoted | ||
| const input = `echo "hello\n"world\n"test\n"` | ||
| const expected = `echo "hello${newlinePlaceholder}"world\n"test${newlinePlaceholder}"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("handles single quote after double quote", () => { | ||
| const input = `echo "hello"'world\n'` | ||
| const expected = `echo "hello"'world${newlinePlaceholder}'` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("handles double quote after single quote", () => { | ||
| const input = `echo 'hello'"world\n"` | ||
| const expected = `echo 'hello'"world${newlinePlaceholder}"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
| }) | ||
|
|
||
| describe("escaped quotes", () => { | ||
| it("handles escaped double quotes in double-quoted strings", () => { | ||
| const input = 'echo "hello\\"world\n"' | ||
| const expected = `echo "hello\\"world${newlinePlaceholder}"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("does not treat backslash as escape in single quotes", () => { | ||
| // In single quotes, backslash is literal (except for \' in some shells) | ||
| const input = "echo 'hello\\'world\n'" | ||
| // The \\ is literal, the ' ends the quote, so world\n is outside quotes | ||
| const expected = "echo 'hello\\'world\n'" | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
| }) | ||
|
|
||
| describe("edge cases", () => { | ||
| it("handles unclosed quotes", () => { | ||
| const input = 'echo "unclosed\n' | ||
| const expected = `echo "unclosed${newlinePlaceholder}` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("handles empty string", () => { | ||
| expect(protectNewlinesInQuotes("", newlinePlaceholder, crPlaceholder)).toBe("") | ||
| }) | ||
|
|
||
| it("handles string with no quotes", () => { | ||
| const input = "echo hello\nworld" | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(input) | ||
| }) | ||
|
|
||
| it("handles multiple newlines in quotes", () => { | ||
| const input = 'echo "line1\nline2\nline3"' | ||
| const expected = `echo "line1${newlinePlaceholder}line2${newlinePlaceholder}line3"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("handles carriage returns", () => { | ||
| const input = 'echo "hello\rworld"' | ||
| const expected = `echo "hello${crPlaceholder}world"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("handles CRLF", () => { | ||
| const input = 'echo "hello\r\nworld"' | ||
| const expected = `echo "hello${crPlaceholder}${newlinePlaceholder}world"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
| }) | ||
|
|
||
| describe("real-world git commit examples", () => { | ||
| it("protects newlines in git commit message", () => { | ||
| const input = `git commit -m "feat: title\n\n- point a\n- point b"` | ||
| const expected = `git commit -m "feat: title${newlinePlaceholder}${newlinePlaceholder}- point a${newlinePlaceholder}- point b"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
|
|
||
| it("handles complex git command with multiple quoted sections", () => { | ||
| const input = `git add . && git commit -m "feat: title\n\n- point a" && echo "done\n"` | ||
| const expected = `git add . && git commit -m "feat: title${newlinePlaceholder}${newlinePlaceholder}- point a" && echo "done${newlinePlaceholder}"` | ||
| expect(protectNewlinesInQuotes(input, newlinePlaceholder, crPlaceholder)).toBe(expected) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| /** | ||
| * Placeholders used to protect newlines within quoted strings during command parsing. | ||
| * These constants are used by the protectNewlinesInQuotes function to temporarily replace | ||
| * newlines that appear inside quotes, preventing them from being treated as command separators. | ||
| * We use separate placeholders for \n and \r to preserve the original line ending type. | ||
| */ | ||
| export const NEWLINE_PLACEHOLDER = "___NEWLINE___" | ||
| export const CARRIAGE_RETURN_PLACEHOLDER = "___CARRIAGE_RETURN___" | ||
|
|
||
| /** | ||
| * Protect newlines inside quoted strings by replacing them with placeholders. | ||
| * This handles proper shell quoting rules where quotes can be concatenated. | ||
| * Uses separate placeholders for \n and \r to preserve the original line ending type. | ||
| * | ||
| * Examples: | ||
| * - "hello\nworld" -> newline is protected (inside double quotes) | ||
| * - 'hello\nworld' -> newline is protected (inside single quotes) | ||
| * - echo '"'A'"' -> A is NOT quoted (quote concatenation) | ||
| * - "hello"world -> world is NOT quoted | ||
| * | ||
| * @param command - The command string to process | ||
| * @param newlinePlaceholder - The placeholder string to use for \n characters | ||
| * @param carriageReturnPlaceholder - The placeholder string to use for \r characters | ||
| * @returns The command with newlines in quotes replaced by placeholders | ||
| */ | ||
| export function protectNewlinesInQuotes( | ||
| command: string, | ||
| newlinePlaceholder: string, | ||
| carriageReturnPlaceholder: string, | ||
| ): string { | ||
| let result = "" | ||
| let i = 0 | ||
|
|
||
| while (i < command.length) { | ||
| const char = command[i] | ||
|
|
||
| if (char === '"') { | ||
| // Start of double-quoted string | ||
| result += char | ||
| i++ | ||
|
|
||
| // Process until we find the closing unescaped quote | ||
| while (i < command.length) { | ||
| const quoteChar = command[i] | ||
| const prevChar = i > 0 ? command[i - 1] : "" | ||
|
|
||
| if (quoteChar === '"' && prevChar !== "\\") { | ||
| // Found closing quote | ||
| result += quoteChar | ||
| i++ | ||
| break | ||
|
Comment on lines
+47
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The escaped quote detection doesn't handle consecutive backslashes correctly. When you have an escaped backslash followed by a quote (e.g., |
||
| } else if (quoteChar === "\n") { | ||
| // Replace \n inside double quotes | ||
| result += newlinePlaceholder | ||
| i++ | ||
| } else if (quoteChar === "\r") { | ||
| // Replace \r inside double quotes | ||
| result += carriageReturnPlaceholder | ||
| i++ | ||
| } else { | ||
| result += quoteChar | ||
| i++ | ||
| } | ||
| } | ||
| } else if (char === "'") { | ||
| // Start of single-quoted string | ||
| result += char | ||
| i++ | ||
|
|
||
| // Process until we find the closing quote | ||
| // Note: In single quotes, backslash does NOT escape (except for \' in some shells) | ||
| while (i < command.length) { | ||
| const quoteChar = command[i] | ||
|
|
||
| if (quoteChar === "'") { | ||
| // Found closing quote | ||
| result += quoteChar | ||
| i++ | ||
| break | ||
| } else if (quoteChar === "\n") { | ||
| // Replace \n inside single quotes | ||
| result += newlinePlaceholder | ||
| i++ | ||
| } else if (quoteChar === "\r") { | ||
| // Replace \r inside single quotes | ||
| result += carriageReturnPlaceholder | ||
| i++ | ||
| } else { | ||
| result += quoteChar | ||
| i++ | ||
| } | ||
| } | ||
| } else { | ||
| // Not in quotes, keep character as-is | ||
| result += char | ||
| i++ | ||
| } | ||
| } | ||
|
|
||
| return result | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The escape check (
prev char !== '\') only looks at the immediate previous character. For robust handling of multiple consecutive backslashes, consider counting the number of preceding backslashes.