Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Opencode",
"image": "oven/bun:1.3.0"
}
55 changes: 53 additions & 2 deletions packages/app/src/context/file/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,61 @@ describe("file path helpers", () => {
expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
})

test("unquotes all named escape sequences", () => {
expect(unquoteGitPath('"new\\nline"')).toBe("new\nline")
expect(unquoteGitPath('"tab\\tend"')).toBe("tab\tend")
expect(unquoteGitPath('"cr\\rend"')).toBe("cr\rend")
expect(unquoteGitPath('"backspace\\bend"')).toBe("backspace\bend")
expect(unquoteGitPath('"formfeed\\fend"')).toBe("formfeed\fend")
expect(unquoteGitPath('"vtab\\vend"')).toBe("vtab\vend")
expect(unquoteGitPath('"back\\\\slash"')).toBe("back\\slash")
expect(unquoteGitPath('"quo\\"te"')).toBe('quo"te')
})

test("returns input unchanged when not fully wrapped in quotes", () => {
expect(unquoteGitPath('"unterminated')).toBe('"unterminated')
expect(unquoteGitPath('unwrapped"')).toBe('unwrapped"')
expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
})

test("preserves a trailing lone backslash", () => {
expect(unquoteGitPath('"trailing\\"')).toBe("trailing\\")
})

test("unquotes git escaped octal path strings", () => {
expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
})

test("unquotes single and double digit octal escapes", () => {
expect(unquoteGitPath('"\\7"')).toBe("\u0007")
expect(unquoteGitPath('"\\41"')).toBe("!")
})

test("unquotes multiple octal escapes in one string", () => {
expect(unquoteGitPath('"\\303\\251\\303\\251.txt"')).toBe("\u00e9\u00e9.txt")
})

test("stops octal match at a non-octal digit", () => {
// "8" is not a valid octal digit, so \18 should decode \1 as octal
// and then treat "8" as a separate literal character
expect(unquoteGitPath('"\\18"')).toBe("\u00018")
})

test("returns the literal character for unrecognized escape sequences", () => {
expect(unquoteGitPath('"foo\\xbar"')).toBe("fooxbar")
})

test("returns the literal character for unrecognized escape sequences with digits outside octal range", () => {
expect(unquoteGitPath('"foo\\8bar"')).toBe("foo8bar")
expect(unquoteGitPath('"foo\\9bar"')).toBe("foo9bar")
})

test("preserves a trailing lone backslash", () => {
expect(unquoteGitPath('"trailing\\"')).toBe("trailing\\")
})

test("preserves a trailing lone backslash after other content", () => {
expect(unquoteGitPath('"a\\\\b\\"')).toBe("a\\b\\")
})
})

Expand Down
36 changes: 12 additions & 24 deletions packages/app/src/context/file/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export function unquoteGitPath(input: string) {
if (!input.endsWith('"')) return input
const body = input.slice(1, -1)
const bytes: number[] = []
const ESCAPE_MAP: Record<string, string> = {
"n": "\n",
"r": "\r",
"t": "\t",
"b": "\b",
"f": "\f",
"v": "\v",
"\\": "\\",
'"': '"',
};

for (let i = 0; i < body.length; i++) {
const char = body[i]!
Expand All @@ -37,35 +47,13 @@ export function unquoteGitPath(input: string) {

if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
const match = chunk.match(/^[0-7]{1,3}/)!
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}

const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined

bytes.push((escaped ?? next).charCodeAt(0))
bytes.push((ESCAPE_MAP[next] ?? next).charCodeAt(0))
i++
}

Expand Down
Loading