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..b032c78e 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] [tree-ish] [path...] + +Arguments: + tree-ish Tree, commit or tag to list + 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/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/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..6b3e1b41 --- /dev/null +++ b/src/commands/ls-tree.ts @@ -0,0 +1,276 @@ +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 { readTreeEntries } from "../lib/tree-ops.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"; + +interface WalkOptions { + recursive: boolean; + showTrees: boolean; + treesOnly: boolean; + filters: PathFilter[]; +} + +interface PathFilter { + path: string; + listContents: boolean; +} + +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").optional(), + 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 [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 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 ? "" : cwdPrefix; + const filters: PathFilter[] = []; + for (const raw of rawPaths) { + const filter = resolvePathFilter(raw, prefix, gitCtx.workTree); + if (filter === null) { + return fatal( + `${raw}: '${raw}' is outside repository at '${gitCtx.workTree ?? gitCtx.gitDir}'`, + ); + } + filters.push(filter); + } + 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, + showTrees: args.showTrees || (args.recursive && args.treesOnly), + treesOnly: args.treesOnly, + filters, + }); + + const nameOnly = args.nameOnly || args.nameStatus; + const terminator = args.nulTerminate ? "\0" : "\n"; + let stdout = ""; + for (const entry of entries) { + const shown = displayPath(prefix, entry.path); + const name = args.nulTerminate ? shown : quotePath(shown); + 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 }; + }, + }); +} + +/** + * 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 (;;) { + 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; + } +} + +function resolvePathFilter( + raw: string, + prefix: string, + workTree: string | null, +): PathFilter | null { + const listContents = 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 { path, listContents: listContents || path === "" }; +} + +async function collect( + ctx: GitRepo, + treeHash: ObjectId, + base: string, + out: ListedEntry[], + opts: WalkOptions, +): Promise { + 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; + } + if (entry.mode === FileMode.DIRECTORY) { + 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; + } + if (opts.treesOnly && entry.mode !== FileMode.SUBMODULE) continue; + out.push({ ...entry, path }); + } +} + +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; + } + 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"; + return "blob"; +} + +const C_ESCAPES: Record = { + 0x07: "\\a", + 0x08: "\\b", + 0x09: "\\t", + 0x0a: "\\n", + 0x0b: "\\v", + 0x0c: "\\f", + 0x0d: "\\r", + 0x22: '\\"', + 0x5c: "\\\\", +}; + +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/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 new file mode 100644 index 00000000..560e8985 --- /dev/null +++ b/test/commands/ls-tree.test.ts @@ -0,0 +1,351 @@ +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("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( + 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("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"); + 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 new file mode 100644 index 00000000..66fd50cf --- /dev/null +++ b/test/interop/ls-tree.test.ts @@ -0,0 +1,144 @@ +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: "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" }, + { 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 ../../" }, + { 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", () => { + 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, + ); + }); + } + + 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, + }); + }); +});