Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/ls-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"just-git": patch
---

Add `git ls-tree` with `-r`, `-d`, `-t`, `--name-only`, `--name-status`, `--full-tree`, `-z` and `--abbrev[=<n>]`.
24 changes: 24 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <number> Use <n> digits to display object names
```

## git merge

```
Expand Down
2 changes: 1 addition & 1 deletion docs/FILE_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/commands/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,7 +78,6 @@ export const KNOWN_UNIMPLEMENTED_COMMANDS = new Set([
"interpret-trailers",
"log--hierarchical",
"ls-remote",
"ls-tree",
"maintenance",
"merge-base",
"merge-tree",
Expand Down Expand Up @@ -147,6 +147,7 @@ const COMMAND_REGISTRY: Record<GitCommandName, (git: Command, ext?: GitExtension
stash: (g, e) => 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),
Expand Down
276 changes: 276 additions & 0 deletions src/commands/ls-tree.ts
Original file line number Diff line number Diff line change
@@ -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 [<options>] <tree-ish> [<path>...]\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 <n> 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 `<rev>:<path>` 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<ObjectId | { error: string }> {
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);
// `:<path>` 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<ObjectId | null> {
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<void> {
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<number, string> = {
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;
}
1 change: 1 addition & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type GitCommandName =
| "stash"
| "rev-parse"
| "ls-files"
| "ls-tree"
| "clean"
| "switch"
| "restore"
Expand Down
2 changes: 1 addition & 1 deletion src/lib/tree-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TreeEntry[]> {
export async function readTreeEntries(ctx: GitRepo, treeHash: ObjectId): Promise<TreeEntry[]> {
const raw = await readObject(ctx, treeHash);
if (raw.type !== "tree") throw new Error(`Expected tree object, got ${raw.type}`);
return parseTree(raw.content).entries;
Expand Down
Loading
Loading