Fix #12: add git ls-tree

Merged
HA
5d ago
+805-3

Fixes #12 (https://github.com/blindmansion/just-git/issues/12)

Summary

git ls-tree now works. Before this change it sat in KNOWN_UNIMPLEMENTED_COMMANDS, so git ls-tree -r HEAD printed "'ls-tree' is not implemented" and exited 1. Scripts and agents that check a committed file's mode or list files at a revision had to fall back to ls-files -s (index only) or git show <rev>:<dir>.

Supported surface:

git ls-tree [-r] [-d] [-t] [--name-only | --name-status] [--full-tree] [-z] [--abbrev[=<n>]] [--] <tree-ish> [[--] <path>...]

The tree-ish can be a commit, an annotated tag or a tree id, and <rev>^{tree} works too. So does git's <rev>:<path> form (HEAD:src, v1:src/lib): the path is relative to the root, or to the cwd when it starts with ./ or ../, and one trailing slash is allowed. A missing or non-canonical path (HEAD:nope, HEAD:/src, HEAD:src//lib, :src) fails with Not a valid object name, a blob path with not a tree object, and a ../ path that leaves the repository with git's outside repository message. Output is <mode> SP <type> SP <object> TAB <path>, with 040000 tree, 160000 commit for gitlinks, and 100755/120000 blobs.

Before and after

$ git ls-tree -r HEAD; echo rc=$?
# before (1.9.0): git: 'ls-tree' is not implemented. Run 'git help' for available commands.   rc=1
# after:          100755 blob 1a2485251c33a70432394c93fb89330ef214bfc9	run.sh   rc=0  (same as git)

How it works

src/commands/ls-tree.ts resolves the tree-ish with resolveRevision and peels tags and commits down to a tree. It then walks the tree with readTreeEntries from tree-ops.ts, the reader diffTrees already uses, which this PR exports. flattenTree can't serve -t and -d because it drops directory entries. Going through readTreeEntries also means a tree entry that points at a blob stops the walk with "Expected tree object, got blob" and exit 1. Git exits 1 there too, with error: Object <id> not a tree.

The tree-ish is the first operand whether it comes before or after --, so git ls-tree -- HEAD and git ls-tree -r -- HEAD work like git. With no tree-ish at all it prints usage: git ls-tree [<options>] <tree-ish> [<path>...] and exits 129. Git prints the option list after that line. just-git prints the usage line only, like its merge and rebase usage errors.

Errors come in git's order. An unknown tree-ish fails first. An empty path comes next (fatal: empty string is not a valid pathspec. please use . instead if you meant to match all paths, 128), checked across every path before any of them is resolved. A path outside the repository comes after that, and not a tree object comes last.

Path handling follows builtin/ls-tree.c rather than the shared pathspec code, because ls-tree treats paths differently from other commands:

  • Paths are literal. git ls-tree HEAD 'sp*' matches nothing, same as git.
  • src shows the src tree entry itself. src/ lists its contents.
  • Without -r, the walk still descends into a tree when a path points inside it (git's show_recursive). git ls-tree HEAD src/lib/math.ts prints the blob, and -t adds the src and src/lib trees above it.
  • -d drops blobs but keeps gitlinks. -r -d implies -t.
  • From a subdirectory with no paths, it lists that directory. Output paths are relative to the cwd (../run.sh). A tree that contains the cwd prints with a trailing slash, so from src/lib the src/lib tree is ./ and the src tree is ../. --full-tree lists from the root with full paths.
  • A path that leaves the repository fails with git's message and exit 128.

Paths are C-quoted the way git does with core.quotePath on ("q\"uote.txt", "\303\251.txt", "tab\tname"), and printed raw under -z. --abbrev uses uniqueAbbrev, --abbrev=<n> clamps to 4, and --abbrev=0 prints full ids, all matching git.

Outside the command file, the PR exports readTreeEntries from src/lib/tree-ops.ts. Registration touches src/commands/git.ts (registry entry, removal from KNOWN_UNIMPLEMENTED_COMMANDS) and adds | "ls-tree" to the GitCommandName union in src/git.ts. The registry is typed Record<GitCommandName, ...>, so the command can't register without that one line.

Tests

  • test/commands/ls-tree.test.ts has 38 in-memory tests with exact stdout, stderr and exit codes. They cover the root listing, -r, -d, -r -d, -r -t, --name-only/--name-status, a directory path with and without a trailing slash, a file path after --, -t with a nested path, literal (non-glob) paths, a subdirectory cwd with and without --full-tree, ../ paths, ./ and ../ for trees that contain the cwd, -z, --abbrev and --abbrev=4, a tree id as the tree-ish, the tree-ish after --, no tree-ish (usage, 129), an invalid tree-ish (fatal: Not a valid object name bogus, 128), a blob (fatal: not a tree object, 128), an empty path, a path outside the repository, and the order of those errors. Seven more cover <rev>:<path>: a subtree, -r from a subtree, HEAD: as the root tree, ./ and ../ from a subdirectory, invalid paths, a blob path, and a ../ path that leaves the repository.
  • test/interop/ls-tree.test.ts builds a repo with real git (executable, symlink, gitlink via update-index --cacheinfo 160000, names with a space, a quote, a tab and é, an annotated tag). It then runs 73 invocations through both real git and just-git and asserts identical stdout, stderr and exit code. I checked that it catches defects by breaking quoting and the gitlink type. 17 of the first 40 cases failed. A separate test writes a tree whose 040000 entry points at a blob and checks that -r fails with exit 1, as git does.
  • Every test added for nested-cwd display, empty paths, the tree-ish after -- and the bad tree entry failed before its fix.
  • bun test: 3570 pass, 29 skip, 0 fail (baseline 3488 pass).

Not included

  • <rev>:<path> as a tree-ish fails with fatal: Not a valid object name HEAD:dir (exit 128). resolveRevision doesn't parse <rev>:<path> at all. git show parses it on its own and only finds blobs, so git show HEAD:dir has the same gap. The fix belongs in the shared rev-parse code.
  • The empty tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 is built into git but doesn't resolve as a revision in just-git, so git ls-tree 4b825dc... prints fatal: Not a valid object name 4b825dc... where git prints nothing and exits 0. This is already true on main. git rev-parse and git diff fail on it the same way.
  • An empty tree-ish (git ls-tree '') exits 1 with an EISDIR error from ref lookup, where git prints fatal: Not a valid object name and exits 128. git show '' does the same on main, so this also belongs in the shared rev-parse code.
  • -l/--long, --full-name, --object-only and --format are not implemented and fail as unknown options.
  • core.quotePath=false is not read. Non-ASCII names are always octal-quoted.