From 46cc9e42d44d681e18c6547acd5b1c1c421a8ffd Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 14:50:31 +1030 Subject: [PATCH 1/5] Fix #12: add git ls-tree Lists a tree-ish with -r, -d, -t, --name-only, --name-status, --full-tree, -z and --abbrev, using git's literal path matching, cwd-relative paths and C-style quoting. --- .changeset/ls-tree.md | 5 + docs/CLI.md | 24 ++++ src/commands/git.ts | 3 +- src/commands/ls-tree.ts | 203 +++++++++++++++++++++++++++++++++ src/git.ts | 1 + test/commands/ls-tree.test.ts | 209 ++++++++++++++++++++++++++++++++++ 6 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 .changeset/ls-tree.md create mode 100644 src/commands/ls-tree.ts create mode 100644 test/commands/ls-tree.test.ts diff --git a/.changeset/ls-tree.md b/.changeset/ls-tree.md new file mode 100644 index 00000000..d54215e7 --- /dev/null +++ b/.changeset/ls-tree.md @@ -0,0 +1,5 @@ +--- +"just-git": patch +--- + +Add `git ls-tree` with `-r`, `-d`, `-t`, `--name-only`, `--name-status`, `--full-tree`, `-z` and `--abbrev[=]`. diff --git a/docs/CLI.md b/docs/CLI.md index e52b0db0..abd95807 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -40,6 +40,7 @@ Commands: stash Stash the changes in a dirty working directory away rev-parse Pick out and massage parameters ls-files Show information about files in the index and the working tree + ls-tree List the contents of a tree object clean Remove untracked files from the working tree switch Switch branches restore Restore working tree files @@ -639,6 +640,29 @@ Options: -t, --show-tags Show status tags ``` +## git ls-tree + +``` +git ls-tree - List the contents of a tree object + +Usage: + git ls-tree [options] [path...] + +Arguments: + tree-ish Tree, commit or tag to list (required) + path... + +Options: + -d, --trees-only Only show trees + -r, --recursive Recurse into subtrees + -t, --show-trees Show trees when recursing + -z, --nul-terminate Terminate entries with NUL byte + --name-only List only filenames + --name-status List only filenames + --full-tree List entire tree; not just current directory + --abbrev Use digits to display object names +``` + ## git merge ``` diff --git a/src/commands/git.ts b/src/commands/git.ts index dc292aa9..da9f9e9e 100644 --- a/src/commands/git.ts +++ b/src/commands/git.ts @@ -19,6 +19,7 @@ import { registerGrepCommand } from "./grep.ts"; import { registerInitCommand } from "./init.ts"; import { registerLogCommand } from "./log.ts"; import { registerLsFilesCommand } from "./ls-files.ts"; +import { registerLsTreeCommand } from "./ls-tree.ts"; import { registerMergeCommand } from "./merge.ts"; import { registerMvCommand } from "./mv.ts"; import { registerPullCommand } from "./pull.ts"; @@ -77,7 +78,6 @@ export const KNOWN_UNIMPLEMENTED_COMMANDS = new Set([ "interpret-trailers", "log--hierarchical", "ls-remote", - "ls-tree", "maintenance", "merge-base", "merge-tree", @@ -147,6 +147,7 @@ const COMMAND_REGISTRY: Record registerStashCommand(g, e), "rev-parse": (g, e) => registerRevParseCommand(g, e), "ls-files": (g, e) => registerLsFilesCommand(g, e), + "ls-tree": (g, e) => registerLsTreeCommand(g, e), clean: (g, e) => registerCleanCommand(g, e), switch: (g, e) => registerSwitchCommand(g, e), restore: (g, e) => registerRestoreCommand(g, e), diff --git a/src/commands/ls-tree.ts b/src/commands/ls-tree.ts new file mode 100644 index 00000000..1fd1828e --- /dev/null +++ b/src/commands/ls-tree.ts @@ -0,0 +1,203 @@ +import type { GitExtensions } from "../git.ts"; +import { + DEFAULT_ABBREV, + fatal, + getCwdPrefix, + isCommandError, + requireGitContext, + uniqueAbbrev, +} from "../lib/command-utils.ts"; +import { readObject } from "../lib/object-db.ts"; +import { parseCommit } from "../lib/objects/commit.ts"; +import { parseTag } from "../lib/objects/tag.ts"; +import { parseTree } from "../lib/objects/tree.ts"; +import { join, relative } from "../lib/path.ts"; +import { resolveRevision } from "../lib/rev-parse.ts"; +import { FileMode, type GitRepo, type ObjectId, type TreeEntry } from "../lib/types.ts"; +import { a, type Command, f, o } from "../parse/index.ts"; + +interface WalkOptions { + recursive: boolean; + showTrees: boolean; + treesOnly: boolean; + /** + * Literal repo-relative paths, never globs. A trailing `/` selects a + * directory's contents rather than the directory entry itself, and `""` + * selects everything. + */ + specs: string[]; +} + +interface ListedEntry extends TreeEntry { + path: string; +} + +export function registerLsTreeCommand(parent: Command, ext?: GitExtensions): void { + parent.command("ls-tree", { + description: "List the contents of a tree object", + args: [ + a.string().name("tree-ish").describe("Tree, commit or tag to list"), + a.string().name("path").variadic().optional(), + ], + options: { + treesOnly: f().alias("d").describe("Only show trees"), + recursive: f().alias("r").describe("Recurse into subtrees"), + showTrees: f().alias("t").describe("Show trees when recursing"), + nulTerminate: f().alias("z").describe("Terminate entries with NUL byte"), + nameOnly: f().describe("List only filenames"), + nameStatus: f().describe("List only filenames"), + fullTree: f().describe("List entire tree; not just current directory"), + abbrev: o + .number() + .impliedValue(String(DEFAULT_ABBREV)) + .describe("Use digits to display object names"), + }, + handler: async (args, ctx, meta) => { + const gitCtxOrError = await requireGitContext(ctx.fs, ctx.cwd, ext); + if (isCommandError(gitCtxOrError)) return gitCtxOrError; + const gitCtx = gitCtxOrError; + + const resolved = await resolveRevision(gitCtx, args["tree-ish"]); + if (!resolved) return fatal(`Not a valid object name ${args["tree-ish"]}`); + const treeHash = await peelToTree(gitCtx, resolved); + if (!treeHash) return fatal("not a tree object"); + + const prefix = args.fullTree ? "" : getCwdPrefix(gitCtx, ctx.cwd); + const rawPaths = [...(args.path ?? []), ...meta.passthrough]; + const specs: string[] = []; + for (const raw of rawPaths) { + const spec = resolveSpec(raw, prefix, gitCtx.workTree); + if (spec === null) { + return fatal( + `${raw}: '${raw}' is outside repository at '${gitCtx.workTree ?? gitCtx.gitDir}'`, + ); + } + specs.push(spec); + } + if (specs.length === 0 && prefix !== "") specs.push(`${prefix}/`); + + const entries: ListedEntry[] = []; + await collect(gitCtx, treeHash, "", entries, { + recursive: args.recursive, + showTrees: args.showTrees || (args.recursive && args.treesOnly), + treesOnly: args.treesOnly, + specs, + }); + + const nameOnly = args.nameOnly || args.nameStatus; + const terminator = args.nulTerminate ? "\0" : "\n"; + let stdout = ""; + for (const entry of entries) { + const displayPath = prefix ? relative(prefix, entry.path) || "./" : entry.path; + const name = args.nulTerminate ? displayPath : quotePath(displayPath); + if (nameOnly) { + stdout += name + terminator; + continue; + } + const oid = args.abbrev ? await uniqueAbbrev(gitCtx, entry.hash, args.abbrev) : entry.hash; + stdout += `${entry.mode} ${objectType(entry.mode)} ${oid}\t${name}${terminator}`; + } + return { stdout, stderr: "", exitCode: 0 }; + }, + }); +} + +async function peelToTree(ctx: GitRepo, hash: ObjectId): Promise { + let current = hash; + for (;;) { + const raw = await readObject(ctx, current); + if (raw.type === "tree") return current; + if (raw.type === "commit") return parseCommit(raw.content).tree; + if (raw.type !== "tag") return null; + current = parseTag(raw.content).object; + } +} + +/** Resolve a cwd-relative path argument to a spec, or null when it leaves the repository. */ +function resolveSpec(raw: string, prefix: string, workTree: string | null): string | null { + const selectsContents = raw.endsWith("/") || /(^|\/)\.\.?$/.test(raw); + let path: string; + if (raw.startsWith("/")) { + if (!workTree) return null; + path = relative(workTree, raw); + } else { + path = join(prefix, raw); + } + path = path.replace(/\/$/, ""); + if (path === ".") path = ""; + if (path === ".." || path.startsWith("../")) return null; + return selectsContents && path !== "" ? `${path}/` : path; +} + +async function collect( + ctx: GitRepo, + treeHash: ObjectId, + base: string, + out: ListedEntry[], + opts: WalkOptions, +): Promise { + const raw = await readObject(ctx, treeHash); + for (const entry of parseTree(raw.content).entries) { + const path = base ? `${base}/${entry.name}` : entry.name; + if (opts.specs.length > 0 && !opts.specs.some((spec) => specMatches(spec, path, entry.mode))) { + continue; + } + if (entry.mode === FileMode.DIRECTORY) { + const descend = opts.recursive || opts.specs.some((spec) => spec.startsWith(`${path}/`)); + if (!descend || opts.showTrees) out.push({ ...entry, path }); + if (descend) await collect(ctx, entry.hash, path, out, opts); + continue; + } + if (opts.treesOnly && entry.mode !== FileMode.SUBMODULE) continue; + out.push({ ...entry, path }); + } +} + +function specMatches(spec: string, path: string, mode: string): boolean { + if (spec === "") return true; + const selectsContents = spec.endsWith("/"); + const specPath = selectsContents ? spec.slice(0, -1) : spec; + if (path === specPath) { + return !selectsContents || mode === FileMode.DIRECTORY || mode === FileMode.SUBMODULE; + } + if (path.startsWith(`${specPath}/`)) return true; + return mode === FileMode.DIRECTORY && specPath.startsWith(`${path}/`); +} + +function objectType(mode: string): "tree" | "commit" | "blob" { + if (mode === FileMode.DIRECTORY) return "tree"; + if (mode === FileMode.SUBMODULE) return "commit"; + return "blob"; +} + +const C_ESCAPES: Record = { + 0x07: "\\a", + 0x08: "\\b", + 0x09: "\\t", + 0x0a: "\\n", + 0x0b: "\\v", + 0x0c: "\\f", + 0x0d: "\\r", + 0x22: '\\"', + 0x5c: "\\\\", +}; + +/** Quote a path like git's `quote_c_style` with `core.quotePath` enabled. */ +function quotePath(path: string): string { + const bytes = new TextEncoder().encode(path); + let out = ""; + let quoted = false; + for (const byte of bytes) { + const escape = C_ESCAPES[byte]; + if (escape) { + out += escape; + quoted = true; + } else if (byte < 0x20 || byte >= 0x7f) { + out += `\\${byte.toString(8).padStart(3, "0")}`; + quoted = true; + } else { + out += String.fromCharCode(byte); + } + } + return quoted ? `"${out}"` : path; +} diff --git a/src/git.ts b/src/git.ts index c8a45863..ba03f7fe 100644 --- a/src/git.ts +++ b/src/git.ts @@ -82,6 +82,7 @@ export type GitCommandName = | "stash" | "rev-parse" | "ls-files" + | "ls-tree" | "clean" | "switch" | "restore" diff --git a/test/commands/ls-tree.test.ts b/test/commands/ls-tree.test.ts new file mode 100644 index 00000000..4ca92a4d --- /dev/null +++ b/test/commands/ls-tree.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "bun:test"; +import { TEST_ENV } from "../fixtures"; +import { runScenario } from "../util"; + +const README = "78981922613b2afb6025042ff6bd878ac1994e85"; +const LINK = "42061c01a1c70097d1e4579f29a5adf40abdec95"; +const RUN_SH = "1a2485251c33a70432394c93fb89330ef214bfc9"; +const SRC = "1cc3974b97e5c169b3cb036573064b47f6658a8a"; +const SRC_LIB = "e4ee6728ab8362c0569f8fbbe72224e351560992"; +const MATH = "f2ad6c76f0115a6ba5b00456a849810e7ec0af20"; +const MAIN = "61780798228d17af2d34fce4cfbdf35556832472"; + +const FILES = { + "/repo/README.md": "a\n", + "/repo/src/main.ts": "b\n", + "/repo/src/lib/math.ts": "c\n", + "/repo/run.sh": "#!/bin/sh\n", +}; + +async function lsTree(command: string, cwd = "/repo") { + const { results } = await runScenario( + [ + "git init", + "chmod +x run.sh", + "ln -s README.md link", + "git add -A", + 'git commit -m "initial"', + `cd ${cwd} && ${command}`, + ], + { files: FILES, env: TEST_ENV, cwd: "/repo" }, + ); + for (const r of results.slice(0, -1)) expect(r.exitCode).toBe(0); + return results.at(-1)!; +} + +function lines(...entries: string[]): string { + return entries.map((e) => `${e}\n`).join(""); +} + +describe("git ls-tree", () => { + test("lists the root tree with modes for executables, symlinks and trees", async () => { + const r = await lsTree("git ls-tree HEAD"); + expect(r.stdout).toBe( + lines( + `100644 blob ${README}\tREADME.md`, + `120000 blob ${LINK}\tlink`, + `100755 blob ${RUN_SH}\trun.sh`, + `040000 tree ${SRC}\tsrc`, + ), + ); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); + }); + + test("-r recurses and omits tree entries", async () => { + const r = await lsTree("git ls-tree -r HEAD"); + expect(r.stdout).toBe( + lines( + `100644 blob ${README}\tREADME.md`, + `120000 blob ${LINK}\tlink`, + `100755 blob ${RUN_SH}\trun.sh`, + `100644 blob ${MATH}\tsrc/lib/math.ts`, + `100644 blob ${MAIN}\tsrc/main.ts`, + ), + ); + expect(r.exitCode).toBe(0); + }); + + test("-r -t shows trees while recursing", async () => { + const r = await lsTree("git ls-tree -r -t HEAD"); + expect(r.stdout).toBe( + lines( + `100644 blob ${README}\tREADME.md`, + `120000 blob ${LINK}\tlink`, + `100755 blob ${RUN_SH}\trun.sh`, + `040000 tree ${SRC}\tsrc`, + `040000 tree ${SRC_LIB}\tsrc/lib`, + `100644 blob ${MATH}\tsrc/lib/math.ts`, + `100644 blob ${MAIN}\tsrc/main.ts`, + ), + ); + }); + + test("-d shows only trees", async () => { + const r = await lsTree("git ls-tree -d HEAD"); + expect(r.stdout).toBe(lines(`040000 tree ${SRC}\tsrc`)); + }); + + test("-r -d implies -t", async () => { + const r = await lsTree("git ls-tree -r -d HEAD"); + expect(r.stdout).toBe(lines(`040000 tree ${SRC}\tsrc`, `040000 tree ${SRC_LIB}\tsrc/lib`)); + }); + + test("--name-only and --name-status print paths only", async () => { + const expected = lines("README.md", "link", "run.sh", "src/lib/math.ts", "src/main.ts"); + expect((await lsTree("git ls-tree -r --name-only HEAD")).stdout).toBe(expected); + expect((await lsTree("git ls-tree -r --name-status HEAD")).stdout).toBe(expected); + }); + + test("a directory path without a trailing slash shows the tree entry itself", async () => { + const r = await lsTree("git ls-tree HEAD src"); + expect(r.stdout).toBe(lines(`040000 tree ${SRC}\tsrc`)); + }); + + test("a directory path with a trailing slash lists its contents", async () => { + const r = await lsTree("git ls-tree HEAD src/"); + expect(r.stdout).toBe( + lines(`040000 tree ${SRC_LIB}\tsrc/lib`, `100644 blob ${MAIN}\tsrc/main.ts`), + ); + }); + + test("a file path after -- reports its mode and blob without -r", async () => { + const r = await lsTree("git ls-tree HEAD -- src/lib/math.ts"); + expect(r.stdout).toBe(lines(`100644 blob ${MATH}\tsrc/lib/math.ts`)); + }); + + test("-t with a nested path shows the leading trees", async () => { + const r = await lsTree("git ls-tree -t HEAD src/lib/math.ts"); + expect(r.stdout).toBe( + lines( + `040000 tree ${SRC}\tsrc`, + `040000 tree ${SRC_LIB}\tsrc/lib`, + `100644 blob ${MATH}\tsrc/lib/math.ts`, + ), + ); + }); + + test("paths are literal, not globs", async () => { + const r = await lsTree("git ls-tree HEAD 'R*'"); + expect(r.stdout).toBe(""); + expect(r.exitCode).toBe(0); + }); + + test("a path with no match prints nothing and succeeds", async () => { + const r = await lsTree("git ls-tree HEAD missing"); + expect(r.stdout).toBe(""); + expect(r.exitCode).toBe(0); + }); + + test("from a subdirectory, lists that directory relative to the cwd", async () => { + const r = await lsTree("git ls-tree HEAD", "/repo/src"); + expect(r.stdout).toBe(lines(`040000 tree ${SRC_LIB}\tlib`, `100644 blob ${MAIN}\tmain.ts`)); + }); + + test("from a subdirectory, paths outside the cwd are shown with ../", async () => { + const r = await lsTree("git ls-tree HEAD ../run.sh", "/repo/src"); + expect(r.stdout).toBe(lines(`100755 blob ${RUN_SH}\t../run.sh`)); + }); + + test("--full-tree lists from the root with full paths", async () => { + const r = await lsTree("git ls-tree --full-tree HEAD src/", "/repo/src"); + expect(r.stdout).toBe( + lines(`040000 tree ${SRC_LIB}\tsrc/lib`, `100644 blob ${MAIN}\tsrc/main.ts`), + ); + }); + + test("-z terminates entries with NUL", async () => { + const r = await lsTree("git ls-tree -z HEAD src/"); + expect(r.stdout).toBe( + `040000 tree ${SRC_LIB}\tsrc/lib\x00100644 blob ${MAIN}\tsrc/main.ts\x00`, + ); + }); + + test("--abbrev shortens object names", async () => { + const r = await lsTree("git ls-tree --abbrev HEAD"); + expect(r.stdout).toBe( + lines( + `100644 blob ${README.slice(0, 7)}\tREADME.md`, + `120000 blob ${LINK.slice(0, 7)}\tlink`, + `100755 blob ${RUN_SH.slice(0, 7)}\trun.sh`, + `040000 tree ${SRC.slice(0, 7)}\tsrc`, + ), + ); + }); + + test("--abbrev= uses n digits", async () => { + const r = await lsTree("git ls-tree --abbrev=4 -r HEAD src/"); + expect(r.stdout).toBe( + lines( + `100644 blob ${MATH.slice(0, 4)}\tsrc/lib/math.ts`, + `100644 blob ${MAIN.slice(0, 4)}\tsrc/main.ts`, + ), + ); + }); + + test("accepts a tree object as the tree-ish", async () => { + const r = await lsTree(`git ls-tree ${SRC}`); + expect(r.stdout).toBe(lines(`040000 tree ${SRC_LIB}\tlib`, `100644 blob ${MAIN}\tmain.ts`)); + }); + + test("an invalid tree-ish exits 128", async () => { + const r = await lsTree("git ls-tree bogus"); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe("fatal: Not a valid object name bogus\n"); + expect(r.exitCode).toBe(128); + }); + + test("a blob is not a tree object", async () => { + const r = await lsTree(`git ls-tree ${README}`); + expect(r.stderr).toBe("fatal: not a tree object\n"); + expect(r.exitCode).toBe(128); + }); + + test("a path outside the repository is fatal", async () => { + const r = await lsTree("git ls-tree HEAD ../../x", "/repo/src"); + expect(r.stderr).toBe("fatal: ../../x: '../../x' is outside repository at '/repo'\n"); + expect(r.exitCode).toBe(128); + }); +}); From cb68e830152111ad5d6f2695209977dcba754bf6 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 14:50:31 +1030 Subject: [PATCH 2/5] Fix #12: compare git ls-tree output with real git Covers gitlinks, quoted names, subdirectory cwds and every supported flag against the real binary. --- test/interop/ls-tree.test.ts | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 test/interop/ls-tree.test.ts diff --git a/test/interop/ls-tree.test.ts b/test/interop/ls-tree.test.ts new file mode 100644 index 00000000..295122a0 --- /dev/null +++ b/test/interop/ls-tree.test.ts @@ -0,0 +1,91 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { chmodSync, symlinkSync } from "fs"; +import { join } from "path"; +import { createSandbox, jg, justBash, realGit, removeSandbox, writeToSandbox } from "./util"; + +const GITLINK = "1111111111111111111111111111111111111111"; + +const CASES: { cwd: string; args: string }[] = [ + { cwd: "", args: "HEAD" }, + { cwd: "", args: "-r HEAD" }, + { cwd: "", args: "-d HEAD" }, + { cwd: "", args: "-r -d HEAD" }, + { cwd: "", args: "-t HEAD" }, + { cwd: "", args: "-r -t HEAD" }, + { cwd: "", args: "-r --name-only HEAD" }, + { cwd: "", args: "--name-status HEAD" }, + { cwd: "", args: "-r -z HEAD" }, + { cwd: "", args: "-r --name-only -z HEAD" }, + { cwd: "", args: "--abbrev HEAD" }, + { cwd: "", args: "--abbrev=4 -r HEAD" }, + { cwd: "", args: "--abbrev=0 HEAD" }, + { cwd: "", args: "HEAD src" }, + { cwd: "", args: "HEAD src/" }, + { cwd: "", args: "HEAD -- src/lib/math.ts" }, + { cwd: "", args: "-t HEAD src/lib/math.ts" }, + { cwd: "", args: "-d -t HEAD src/lib/math.ts" }, + { cwd: "", args: "HEAD src docs/guide.md" }, + { cwd: "", args: "-d HEAD src/" }, + { cwd: "", args: "HEAD vendor/mod" }, + { cwd: "", args: "HEAD vendor/mod/" }, + { cwd: "", args: "-d HEAD vendor/" }, + { cwd: "", args: "HEAD README.md/" }, + { cwd: "", args: "HEAD 'sp*'" }, + { cwd: "", args: "HEAD nope" }, + { cwd: "", args: "HEAD^{tree} docs/" }, + { cwd: "", args: "v1 docs/" }, + { cwd: "", args: "bogus" }, + { cwd: "src", args: "HEAD" }, + { cwd: "src", args: "-r HEAD" }, + { cwd: "src", args: "-r -t HEAD" }, + { cwd: "src", args: "HEAD ." }, + { cwd: "src", args: "HEAD .." }, + { cwd: "src", args: "HEAD lib" }, + { cwd: "src", args: "HEAD ../docs/" }, + { cwd: "src", args: "--full-tree HEAD" }, + { cwd: "src", args: "--full-tree HEAD src/" }, + { cwd: "src", args: "-r --full-tree HEAD lib" }, + { cwd: "src/lib", args: "-r --name-only HEAD ../../" }, +]; + +describe("interop: git ls-tree matches real git", () => { + let sandbox: string; + beforeAll(async () => { + sandbox = createSandbox(); + writeToSandbox(sandbox, "README.md", "a\n"); + writeToSandbox(sandbox, "src/main.ts", "b\n"); + writeToSandbox(sandbox, "src/lib/math.ts", "c\n"); + writeToSandbox(sandbox, "docs/guide.md", "d\n"); + writeToSandbox(sandbox, "sp ace.txt", "e\n"); + writeToSandbox(sandbox, "é.txt", "f\n"); + writeToSandbox(sandbox, 'q"uote.txt', "g\n"); + writeToSandbox(sandbox, "tab\tname", "h\n"); + writeToSandbox(sandbox, "run.sh", "#!/bin/sh\n"); + chmodSync(join(sandbox, "run.sh"), 0o755); + symlinkSync("README.md", join(sandbox, "link")); + for (const cmd of [ + "init", + "add -A", + `update-index --add --cacheinfo 160000,${GITLINK},vendor/mod`, + "commit -m initial", + "tag -a v1 -m v1", + ]) { + const r = await realGit(sandbox, cmd); + expect(r.exitCode).toBe(0); + } + }); + afterAll(() => removeSandbox(sandbox)); + + for (const { cwd, args } of CASES) { + test(`${cwd || "."}: git ls-tree ${args}`, async () => { + const expected = await realGit(sandbox, `${cwd ? `-C ${cwd} ` : ""}ls-tree ${args}`); + const actual = await jg( + justBash(sandbox), + `${cwd ? `cd ${cwd} && ` : ""}git ls-tree ${args}`, + ); + expect({ stdout: actual.stdout, stderr: actual.stderr, exitCode: actual.exitCode }).toEqual( + expected, + ); + }); + } +}); From 27bd0b41722856e3112728c614cf4c648057ec44 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:16:13 +1030 Subject: [PATCH 3/5] Fix #12: type ls-tree path filters Replaces the trailing-slash string encoding with a { path, listContents } filter so the matching rule lives in the type. --- src/commands/ls-tree.ts | 57 ++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/commands/ls-tree.ts b/src/commands/ls-tree.ts index 1fd1828e..caa221e1 100644 --- a/src/commands/ls-tree.ts +++ b/src/commands/ls-tree.ts @@ -20,12 +20,12 @@ interface WalkOptions { recursive: boolean; showTrees: boolean; treesOnly: boolean; - /** - * Literal repo-relative paths, never globs. A trailing `/` selects a - * directory's contents rather than the directory entry itself, and `""` - * selects everything. - */ - specs: string[]; + filters: PathFilter[]; +} + +interface PathFilter { + path: string; + listContents: boolean; } interface ListedEntry extends TreeEntry { @@ -64,24 +64,24 @@ export function registerLsTreeCommand(parent: Command, ext?: GitExtensions): voi const prefix = args.fullTree ? "" : getCwdPrefix(gitCtx, ctx.cwd); const rawPaths = [...(args.path ?? []), ...meta.passthrough]; - const specs: string[] = []; + const filters: PathFilter[] = []; for (const raw of rawPaths) { - const spec = resolveSpec(raw, prefix, gitCtx.workTree); - if (spec === null) { + const filter = resolvePathFilter(raw, prefix, gitCtx.workTree); + if (filter === null) { return fatal( `${raw}: '${raw}' is outside repository at '${gitCtx.workTree ?? gitCtx.gitDir}'`, ); } - specs.push(spec); + filters.push(filter); } - if (specs.length === 0 && prefix !== "") specs.push(`${prefix}/`); + if (filters.length === 0 && prefix !== "") filters.push({ path: prefix, listContents: true }); const entries: ListedEntry[] = []; await collect(gitCtx, treeHash, "", entries, { recursive: args.recursive, showTrees: args.showTrees || (args.recursive && args.treesOnly), treesOnly: args.treesOnly, - specs, + filters, }); const nameOnly = args.nameOnly || args.nameStatus; @@ -113,9 +113,12 @@ async function peelToTree(ctx: GitRepo, hash: ObjectId): Promise 0 && !opts.specs.some((spec) => specMatches(spec, path, entry.mode))) { + if (opts.filters.length > 0 && !opts.filters.some((f) => filterMatches(f, path, entry.mode))) { continue; } if (entry.mode === FileMode.DIRECTORY) { - const descend = opts.recursive || opts.specs.some((spec) => spec.startsWith(`${path}/`)); + const descend = + opts.recursive || + opts.filters.some( + (f) => f.path.startsWith(`${path}/`) || (f.listContents && f.path === path), + ); if (!descend || opts.showTrees) out.push({ ...entry, path }); if (descend) await collect(ctx, entry.hash, path, out, opts); continue; @@ -153,15 +160,12 @@ async function collect( } } -function specMatches(spec: string, path: string, mode: string): boolean { - if (spec === "") return true; - const selectsContents = spec.endsWith("/"); - const specPath = selectsContents ? spec.slice(0, -1) : spec; - if (path === specPath) { - return !selectsContents || mode === FileMode.DIRECTORY || mode === FileMode.SUBMODULE; +function filterMatches(filter: PathFilter, path: string, mode: string): boolean { + if (filter.path === "" || path.startsWith(`${filter.path}/`)) return true; + if (path === filter.path) { + return !filter.listContents || mode === FileMode.DIRECTORY || mode === FileMode.SUBMODULE; } - if (path.startsWith(`${specPath}/`)) return true; - return mode === FileMode.DIRECTORY && specPath.startsWith(`${path}/`); + return mode === FileMode.DIRECTORY && filter.path.startsWith(`${path}/`); } function objectType(mode: string): "tree" | "commit" | "blob" { @@ -182,7 +186,6 @@ const C_ESCAPES: Record = { 0x5c: "\\\\", }; -/** Quote a path like git's `quote_c_style` with `core.quotePath` enabled. */ function quotePath(path: string): string { const bytes = new TextEncoder().encode(path); let out = ""; From d9ba5b3afc8733a1e469f61c39819f3cf4bc8000 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:29:53 +1030 Subject: [PATCH 4/5] Fix #12: match git for ls-tree ancestor paths, empty paths and -- before the tree-ish From a nested cwd, trees that contain the cwd now print as ../ (and ./), not ..; an empty path is fatal like git; the tree-ish may follow --, and no tree-ish prints usage with exit 129. Errors follow git's order. The walk reuses readTreeEntries (now exported) so a tree entry pointing at a blob fails instead of listing nothing. --- docs/CLI.md | 4 +- docs/FILE_REFERENCE.md | 2 +- src/commands/ls-tree.ts | 40 ++++++++++++----- src/lib/tree-ops.ts | 2 +- test/commands/ls-tree.test.ts | 85 +++++++++++++++++++++++++++++++++++ test/interop/ls-tree.test.ts | 30 +++++++++++++ 6 files changed, 148 insertions(+), 15 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index abd95807..b032c78e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -646,10 +646,10 @@ Options: git ls-tree - List the contents of a tree object Usage: - git ls-tree [options] [path...] + git ls-tree [options] [tree-ish] [path...] Arguments: - tree-ish Tree, commit or tag to list (required) + tree-ish Tree, commit or tag to list path... Options: diff --git a/docs/FILE_REFERENCE.md b/docs/FILE_REFERENCE.md index 3811d1df..663ab370 100644 --- a/docs/FILE_REFERENCE.md +++ b/docs/FILE_REFERENCE.md @@ -254,7 +254,7 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts ### lib/tree-ops.ts -- `buildTreeFromIndex`, `flattenTree`, `flattenTreeToMap`, `diffTrees` +- `buildTreeFromIndex`, `flattenTree`, `flattenTreeToMap`, `diffTrees`, `readTreeEntries` - **interfaces**: `FlatTreeEntry` ### lib/types.ts diff --git a/src/commands/ls-tree.ts b/src/commands/ls-tree.ts index caa221e1..ac05e80f 100644 --- a/src/commands/ls-tree.ts +++ b/src/commands/ls-tree.ts @@ -10,12 +10,14 @@ import { import { readObject } from "../lib/object-db.ts"; import { parseCommit } from "../lib/objects/commit.ts"; import { parseTag } from "../lib/objects/tag.ts"; -import { parseTree } from "../lib/objects/tree.ts"; import { join, relative } from "../lib/path.ts"; import { resolveRevision } from "../lib/rev-parse.ts"; +import { readTreeEntries } from "../lib/tree-ops.ts"; import { FileMode, type GitRepo, type ObjectId, type TreeEntry } from "../lib/types.ts"; import { a, type Command, f, o } from "../parse/index.ts"; +const USAGE = "usage: git ls-tree [] [...]\n"; + interface WalkOptions { recursive: boolean; showTrees: boolean; @@ -36,7 +38,7 @@ export function registerLsTreeCommand(parent: Command, ext?: GitExtensions): voi parent.command("ls-tree", { description: "List the contents of a tree object", args: [ - a.string().name("tree-ish").describe("Tree, commit or tag to list"), + a.string().name("tree-ish").describe("Tree, commit or tag to list").optional(), a.string().name("path").variadic().optional(), ], options: { @@ -57,13 +59,21 @@ export function registerLsTreeCommand(parent: Command, ext?: GitExtensions): voi if (isCommandError(gitCtxOrError)) return gitCtxOrError; const gitCtx = gitCtxOrError; - const resolved = await resolveRevision(gitCtx, args["tree-ish"]); - if (!resolved) return fatal(`Not a valid object name ${args["tree-ish"]}`); - const treeHash = await peelToTree(gitCtx, resolved); - if (!treeHash) return fatal("not a tree object"); + const [treeIsh, ...rawPaths] = + args["tree-ish"] === undefined + ? meta.passthrough + : [args["tree-ish"], ...(args.path ?? []), ...meta.passthrough]; + if (treeIsh === undefined) return { stdout: "", stderr: USAGE, exitCode: 129 }; + + const resolved = await resolveRevision(gitCtx, treeIsh); + if (!resolved) return fatal(`Not a valid object name ${treeIsh}`); + if (rawPaths.includes("")) { + return fatal( + "empty string is not a valid pathspec. please use . instead if you meant to match all paths", + ); + } const prefix = args.fullTree ? "" : getCwdPrefix(gitCtx, ctx.cwd); - const rawPaths = [...(args.path ?? []), ...meta.passthrough]; const filters: PathFilter[] = []; for (const raw of rawPaths) { const filter = resolvePathFilter(raw, prefix, gitCtx.workTree); @@ -76,6 +86,9 @@ export function registerLsTreeCommand(parent: Command, ext?: GitExtensions): voi } if (filters.length === 0 && prefix !== "") filters.push({ path: prefix, listContents: true }); + const treeHash = await peelToTree(gitCtx, resolved); + if (!treeHash) return fatal("not a tree object"); + const entries: ListedEntry[] = []; await collect(gitCtx, treeHash, "", entries, { recursive: args.recursive, @@ -88,8 +101,8 @@ export function registerLsTreeCommand(parent: Command, ext?: GitExtensions): voi const terminator = args.nulTerminate ? "\0" : "\n"; let stdout = ""; for (const entry of entries) { - const displayPath = prefix ? relative(prefix, entry.path) || "./" : entry.path; - const name = args.nulTerminate ? displayPath : quotePath(displayPath); + const shown = displayPath(prefix, entry.path); + const name = args.nulTerminate ? shown : quotePath(shown); if (nameOnly) { stdout += name + terminator; continue; @@ -139,8 +152,7 @@ async function collect( out: ListedEntry[], opts: WalkOptions, ): Promise { - const raw = await readObject(ctx, treeHash); - for (const entry of parseTree(raw.content).entries) { + for (const entry of await readTreeEntries(ctx, treeHash)) { const path = base ? `${base}/${entry.name}` : entry.name; if (opts.filters.length > 0 && !opts.filters.some((f) => filterMatches(f, path, entry.mode))) { continue; @@ -168,6 +180,12 @@ function filterMatches(filter: PathFilter, path: string, mode: string): boolean return mode === FileMode.DIRECTORY && filter.path.startsWith(`${path}/`); } +function displayPath(prefix: string, path: string): string { + if (!prefix) return path; + const rel = relative(prefix, path) || "."; + return prefix === path || prefix.startsWith(`${path}/`) ? `${rel}/` : rel; +} + function objectType(mode: string): "tree" | "commit" | "blob" { if (mode === FileMode.DIRECTORY) return "tree"; if (mode === FileMode.SUBMODULE) return "commit"; diff --git a/src/lib/tree-ops.ts b/src/lib/tree-ops.ts index 014a4301..e422e970 100644 --- a/src/lib/tree-ops.ts +++ b/src/lib/tree-ops.ts @@ -154,7 +154,7 @@ export async function diffTrees( return results.sort((a, b) => comparePaths(a.path, b.path)); } -async function readTreeEntries(ctx: GitRepo, treeHash: ObjectId): Promise { +export async function readTreeEntries(ctx: GitRepo, treeHash: ObjectId): Promise { const raw = await readObject(ctx, treeHash); if (raw.type !== "tree") throw new Error(`Expected tree object, got ${raw.type}`); return parseTree(raw.content).entries; diff --git a/test/commands/ls-tree.test.ts b/test/commands/ls-tree.test.ts index 4ca92a4d..456b2ba4 100644 --- a/test/commands/ls-tree.test.ts +++ b/test/commands/ls-tree.test.ts @@ -147,6 +147,26 @@ describe("git ls-tree", () => { expect(r.stdout).toBe(lines(`100755 blob ${RUN_SH}\t../run.sh`)); }); + test("from a nested subdirectory, ancestor trees are shown as ../ and ./", async () => { + const r = await lsTree("git ls-tree -r -d HEAD ../../", "/repo/src/lib"); + expect(r.stdout).toBe(lines(`040000 tree ${SRC}\t../`, `040000 tree ${SRC_LIB}\t./`)); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); + }); + + test("from a nested subdirectory, the root listing shows the cwd's parent as ../", async () => { + const r = await lsTree("git ls-tree HEAD ../../", "/repo/src/lib"); + expect(r.stdout).toBe( + lines( + `100644 blob ${README}\t../../README.md`, + `120000 blob ${LINK}\t../../link`, + `100755 blob ${RUN_SH}\t../../run.sh`, + `040000 tree ${SRC}\t../`, + ), + ); + expect(r.exitCode).toBe(0); + }); + test("--full-tree lists from the root with full paths", async () => { const r = await lsTree("git ls-tree --full-tree HEAD src/", "/repo/src"); expect(r.stdout).toBe( @@ -201,6 +221,71 @@ describe("git ls-tree", () => { expect(r.exitCode).toBe(128); }); + test("an empty path is fatal", async () => { + const r = await lsTree("git ls-tree HEAD ''"); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe( + "fatal: empty string is not a valid pathspec. please use . instead if you meant to match all paths\n", + ); + expect(r.exitCode).toBe(128); + }); + + test("an empty path is rejected before other paths and the tree type are checked", async () => { + const expected = + "fatal: empty string is not a valid pathspec. please use . instead if you meant to match all paths\n"; + expect((await lsTree("git ls-tree HEAD ../../x ''", "/repo/src")).stderr).toBe(expected); + expect((await lsTree(`git ls-tree ${README} ''`)).stderr).toBe(expected); + }); + + test("a path outside the repository is reported before the tree type", async () => { + const r = await lsTree(`git ls-tree ${README} ../x`); + expect(r.stderr).toBe("fatal: ../x: '../x' is outside repository at '/repo'\n"); + expect(r.exitCode).toBe(128); + }); + + test("the tree-ish may follow --", async () => { + const r = await lsTree("git ls-tree -- HEAD"); + expect(r.stdout).toBe( + lines( + `100644 blob ${README}\tREADME.md`, + `120000 blob ${LINK}\tlink`, + `100755 blob ${RUN_SH}\trun.sh`, + `040000 tree ${SRC}\tsrc`, + ), + ); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); + }); + + test("-r with the tree-ish after -- recurses", async () => { + const r = await lsTree("git ls-tree -r -- HEAD"); + expect(r.stdout).toBe( + lines( + `100644 blob ${README}\tREADME.md`, + `120000 blob ${LINK}\tlink`, + `100755 blob ${RUN_SH}\trun.sh`, + `100644 blob ${MATH}\tsrc/lib/math.ts`, + `100644 blob ${MAIN}\tsrc/main.ts`, + ), + ); + expect(r.exitCode).toBe(0); + }); + + test("paths after a tree-ish given after -- filter the listing", async () => { + const r = await lsTree("git ls-tree -- HEAD src"); + expect(r.stdout).toBe(lines(`040000 tree ${SRC}\tsrc`)); + expect(r.exitCode).toBe(0); + }); + + test("no tree-ish prints usage and exits 129", async () => { + for (const command of ["git ls-tree", "git ls-tree -r --"]) { + const r = await lsTree(command); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe("usage: git ls-tree [] [...]\n"); + expect(r.exitCode).toBe(129); + } + }); + test("a path outside the repository is fatal", async () => { const r = await lsTree("git ls-tree HEAD ../../x", "/repo/src"); expect(r.stderr).toBe("fatal: ../../x: '../../x' is outside repository at '/repo'\n"); diff --git a/test/interop/ls-tree.test.ts b/test/interop/ls-tree.test.ts index 295122a0..977886d5 100644 --- a/test/interop/ls-tree.test.ts +++ b/test/interop/ls-tree.test.ts @@ -46,6 +46,16 @@ const CASES: { cwd: string; args: string }[] = [ { cwd: "src", args: "--full-tree HEAD src/" }, { cwd: "src", args: "-r --full-tree HEAD lib" }, { cwd: "src/lib", args: "-r --name-only HEAD ../../" }, + { cwd: "src/lib", args: "HEAD ../../" }, + { cwd: "src/lib", args: "-r -d HEAD ../../" }, + { cwd: "src/lib", args: "-r -t HEAD .." }, + { cwd: "", args: "HEAD ''" }, + { cwd: "src", args: "HEAD ../../x ''" }, + { cwd: "", args: "-- HEAD" }, + { cwd: "", args: "-r -- HEAD" }, + { cwd: "", args: "-- HEAD src" }, + { cwd: "", args: "-- HEAD -- src" }, + { cwd: "", args: "-- bogus" }, ]; describe("interop: git ls-tree matches real git", () => { @@ -88,4 +98,24 @@ describe("interop: git ls-tree matches real git", () => { ); }); } + + test("a tree entry that points at a blob fails like git", async () => { + const readmeBlob = (await realGit(sandbox, "rev-parse HEAD:README.md")).stdout.trim(); + writeToSandbox( + sandbox, + ".git/bad-tree", + Buffer.concat([Buffer.from("40000 bad\0"), Buffer.from(readmeBlob, "hex")]), + ); + const badTree = ( + await realGit(sandbox, "hash-object -t tree -w --literally .git/bad-tree") + ).stdout.trim(); + const expected = await realGit(sandbox, `ls-tree -r ${badTree}`); + const actual = await jg(justBash(sandbox), `git ls-tree -r ${badTree}`); + expect(expected).toMatchObject({ stdout: "", exitCode: 1 }); + expect({ stdout: actual.stdout, stderr: actual.stderr, exitCode: actual.exitCode }).toEqual({ + stdout: "", + stderr: "Expected tree object, got blob", + exitCode: 1, + }); + }); }); From 597c81e43dba16a91877d16735880c387c639326 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Wed, 14 Oct 2026 12:56:16 +1030 Subject: [PATCH 5/5] Fix #12: resolve : tree-ishes in ls-tree git ls-tree HEAD:src listed nothing and failed with 'Not a valid object name'. Resolve the path in the revision's tree the way git does: relative to the root, or to the cwd for ./ and ../, with one trailing slash allowed. A path that leaves the repository fails with git's 'outside repository' message. --- src/commands/ls-tree.ts | 60 ++++++++++++++++++++++++++++++++--- test/commands/ls-tree.test.ts | 57 +++++++++++++++++++++++++++++++++ test/interop/ls-tree.test.ts | 23 ++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/commands/ls-tree.ts b/src/commands/ls-tree.ts index ac05e80f..6b3e1b41 100644 --- a/src/commands/ls-tree.ts +++ b/src/commands/ls-tree.ts @@ -10,10 +10,17 @@ import { import { readObject } from "../lib/object-db.ts"; import { parseCommit } from "../lib/objects/commit.ts"; import { parseTag } from "../lib/objects/tag.ts"; +import { parseTree } from "../lib/objects/tree.ts"; import { join, relative } from "../lib/path.ts"; import { resolveRevision } from "../lib/rev-parse.ts"; import { readTreeEntries } from "../lib/tree-ops.ts"; -import { FileMode, type GitRepo, type ObjectId, type TreeEntry } from "../lib/types.ts"; +import { + FileMode, + type GitContext, + type GitRepo, + type ObjectId, + type TreeEntry, +} from "../lib/types.ts"; import { a, type Command, f, o } from "../parse/index.ts"; const USAGE = "usage: git ls-tree [] [...]\n"; @@ -65,15 +72,16 @@ export function registerLsTreeCommand(parent: Command, ext?: GitExtensions): voi : [args["tree-ish"], ...(args.path ?? []), ...meta.passthrough]; if (treeIsh === undefined) return { stdout: "", stderr: USAGE, exitCode: 129 }; - const resolved = await resolveRevision(gitCtx, treeIsh); - if (!resolved) return fatal(`Not a valid object name ${treeIsh}`); + const cwdPrefix = getCwdPrefix(gitCtx, ctx.cwd); + const resolved = await resolveTreeIsh(gitCtx, treeIsh, cwdPrefix); + if (typeof resolved !== "string") return fatal(resolved.error); if (rawPaths.includes("")) { return fatal( "empty string is not a valid pathspec. please use . instead if you meant to match all paths", ); } - const prefix = args.fullTree ? "" : getCwdPrefix(gitCtx, ctx.cwd); + const prefix = args.fullTree ? "" : cwdPrefix; const filters: PathFilter[] = []; for (const raw of rawPaths) { const filter = resolvePathFilter(raw, prefix, gitCtx.workTree); @@ -115,6 +123,50 @@ export function registerLsTreeCommand(parent: Command, ext?: GitExtensions): voi }); } +/** + * Resolve the tree-ish argument, including git's `:` form. The path is relative to the + * repository root unless it starts with `./` or `../`, which are relative to the cwd. One trailing + * slash is allowed; empty, `.` and `..` components elsewhere are not, as in git. + */ +async function resolveTreeIsh( + ctx: GitContext, + treeIsh: string, + cwdPrefix: string, +): Promise { + const invalid = { error: `Not a valid object name ${treeIsh}` }; + const colon = treeIsh.indexOf(":"); + if (colon < 0) return (await resolveRevision(ctx, treeIsh)) ?? invalid; + const rev = treeIsh.slice(0, colon); + // `:` names an index entry, and the index holds no trees. + if (rev === "") return invalid; + const revHash = await resolveRevision(ctx, rev); + if (!revHash) return invalid; + let hash = await peelToTree(ctx, revHash); + if (!hash) return invalid; + + const rawPath = treeIsh.slice(colon + 1); + let path = rawPath; + if (path.startsWith("./") || path.startsWith("../")) { + path = join(cwdPrefix, path); + if (path === ".") path = ""; + if (path === ".." || path.startsWith("../")) { + return { error: `'${rawPath}' is outside repository at '${ctx.workTree ?? ctx.gitDir}'` }; + } + } + if (path.endsWith("/")) path = path.slice(0, -1); + if (path === "") return hash; + + for (const name of path.split("/")) { + if (name === "" || name === "." || name === "..") return invalid; + const raw = await readObject(ctx, hash); + if (raw.type !== "tree") return invalid; + const entry = parseTree(raw.content).entries.find((e) => e.name === name); + if (!entry) return invalid; + hash = entry.hash; + } + return hash; +} + async function peelToTree(ctx: GitRepo, hash: ObjectId): Promise { let current = hash; for (;;) { diff --git a/test/commands/ls-tree.test.ts b/test/commands/ls-tree.test.ts index 456b2ba4..560e8985 100644 --- a/test/commands/ls-tree.test.ts +++ b/test/commands/ls-tree.test.ts @@ -291,4 +291,61 @@ describe("git ls-tree", () => { expect(r.stderr).toBe("fatal: ../../x: '../../x' is outside repository at '/repo'\n"); expect(r.exitCode).toBe(128); }); + + describe(": tree-ish", () => { + test("lists the subtree at the path", async () => { + const r = await lsTree("git ls-tree HEAD:src"); + expect(r).toMatchObject({ + stdout: lines(`040000 tree ${SRC_LIB}\tlib`, `100644 blob ${MAIN}\tmain.ts`), + stderr: "", + exitCode: 0, + }); + }); + + test("recurses from the subtree, with paths relative to it", async () => { + const r = await lsTree("git ls-tree -r HEAD:src/"); + expect(r.stdout).toBe( + lines(`100644 blob ${MATH}\tlib/math.ts`, `100644 blob ${MAIN}\tmain.ts`), + ); + }); + + test("an empty path is the root tree", async () => { + const r = await lsTree("git ls-tree --name-only HEAD:"); + expect(r.stdout).toBe(lines("README.md", "link", "run.sh", "src")); + }); + + test("./ and ../ are relative to the cwd", async () => { + expect((await lsTree("git ls-tree --full-tree HEAD:./lib", "/repo/src")).stdout).toBe( + lines(`100644 blob ${MATH}\tmath.ts`), + ); + expect((await lsTree("git ls-tree --full-tree HEAD:../src/lib", "/repo/src")).stdout).toBe( + lines(`100644 blob ${MATH}\tmath.ts`), + ); + }); + + test("a missing or non-canonical path is not a valid object name", async () => { + for (const spec of ["HEAD:nope", "HEAD:/src", "HEAD:src//lib", "HEAD:src/./lib", ":src"]) { + const r = await lsTree(`git ls-tree ${spec}`); + expect(r).toMatchObject({ + stdout: "", + stderr: `fatal: Not a valid object name ${spec}\n`, + exitCode: 128, + }); + } + }); + + test("a path to a blob is not a tree object", async () => { + const r = await lsTree("git ls-tree HEAD:README.md"); + expect(r).toMatchObject({ stdout: "", stderr: "fatal: not a tree object\n", exitCode: 128 }); + }); + + test("a ../ path that leaves the repository is fatal", async () => { + const r = await lsTree("git ls-tree HEAD:../src"); + expect(r).toMatchObject({ + stdout: "", + stderr: "fatal: '../src' is outside repository at '/repo'\n", + exitCode: 128, + }); + }); + }); }); diff --git a/test/interop/ls-tree.test.ts b/test/interop/ls-tree.test.ts index 977886d5..66fd50cf 100644 --- a/test/interop/ls-tree.test.ts +++ b/test/interop/ls-tree.test.ts @@ -6,6 +6,29 @@ import { createSandbox, jg, justBash, realGit, removeSandbox, writeToSandbox } f const GITLINK = "1111111111111111111111111111111111111111"; const CASES: { cwd: string; args: string }[] = [ + { cwd: "", args: "HEAD:" }, + { cwd: "", args: "HEAD:src" }, + { cwd: "", args: "HEAD:src/" }, + { cwd: "", args: "-r HEAD:src" }, + { cwd: "", args: "-r -t HEAD:src" }, + { cwd: "", args: "HEAD:src/lib" }, + { cwd: "", args: "HEAD^{tree}:src" }, + { cwd: "", args: "v1:src" }, + { cwd: "", args: "HEAD:src lib" }, + { cwd: "", args: "HEAD:nope" }, + { cwd: "", args: "HEAD:README.md" }, + { cwd: "", args: "HEAD:/src" }, + { cwd: "", args: "HEAD:src//lib" }, + { cwd: "", args: "HEAD:src/./lib" }, + { cwd: "", args: "HEAD:./src" }, + { cwd: "", args: ":src" }, + { cwd: "src", args: "HEAD:./lib" }, + { cwd: "src", args: "--full-tree HEAD:./lib" }, + { cwd: "src", args: "--full-tree HEAD:./" }, + { cwd: "src", args: "--full-tree HEAD:../docs" }, + { cwd: "src", args: "HEAD:../README.md" }, + { cwd: "src", args: "HEAD:lib" }, + { cwd: "src", args: "HEAD:." }, { cwd: "", args: "HEAD" }, { cwd: "", args: "-r HEAD" }, { cwd: "", args: "-d HEAD" },