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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/git-full-history-clone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cloudflare/computer": minor
---

`git clone` now fetches the full history by default instead of a single commit. The shallow default was faster, but a caller who cloned a repository and then pushed it somewhere else sent only the one commit it had fetched: the push reported success and the remote's tip matched, while every earlier commit was missing. Pass `--depth` to ask for a shallow clone when the history genuinely is not needed.

`git cat-file` gained `-t` and `-s` to report an object's type and size, alongside the existing `-p`. Exactly one of the three is required, as in real git.

`git log` gained `--format` and its alias `--pretty`, expanding the placeholders `%H`, `%h`, `%s`, `%b`, `%an`, `%ae`, `%ad`, `%cn`, `%ce`, `%cd`, and `%%`, plus the named format `oneline`. A placeholder outside that set is left as written so it is visible in the output rather than silently dropped.

`git help <command>` now prints the usage line for one command instead of ignoring its argument and reprinting the full list. Only the flags this wrapper accepts are listed, so the output says what works here rather than what real git would take.
28 changes: 23 additions & 5 deletions docs/13_git_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ clean fetch reset rev-parse switch
update-ref
```

`git help` lists these, and `git help <command>` prints the usage line for
one of them, covering only the flags this wrapper accepts. Asking for a
command that is not supported reports that rather than reprinting the list.

Global options accepted before the subcommand:

- **`-C <path>`** — run the subcommand as though invoked from
Expand Down Expand Up @@ -412,6 +416,7 @@ ws.git.log({
| `-n <N>` | `depth` |
| `-<N>` (e.g. `-1`, `-5`) | `depth` |
| `--oneline` | (CLI formatter) |
| `--format=<spec>` / `--pretty=<spec>` | (CLI formatter) |
| `<ref>` (positional) | `ref` |

The positional `<ref>` accepts revision suffixes (`HEAD~2`,
Expand All @@ -423,6 +428,12 @@ blocks; `--oneline` collapses each entry to `<short-oid>
message, tree, parent, author, committer) so callers can
format their own way.

`--format=<spec>` (and its alias `--pretty=<spec>`) expands the
placeholders `%H`, `%h`, `%s`, `%b`, `%an`, `%ae`, `%ad`, `%cn`, `%ce`,
`%cd`, and `%%`, plus the named format `oneline`. A placeholder outside
that set is left as written, so an unsupported one is visible in the
output rather than silently dropped.

*Not mapped:* `--graph`, `--all`, `--since`, `--until`,
`-p`, `--stat`, `--follow`, `--reverse`.

Expand Down Expand Up @@ -545,7 +556,7 @@ ws.git.clone({
dir?: string,
ref?: string,
paths?: string[], // partial checkout only
depth?: number, // default 1
depth?: number, // default: full history
singleBranch?: boolean,
noTags?: boolean,
headers?: Record<string, string>,
Expand All @@ -564,6 +575,11 @@ ws.git.clone({
| `<url>` (positional) | `url` |
| `<dir>` (positional) | `dir` |

Clone fetches the full history unless `--depth` asks otherwise. A shallow
clone is faster, but it can only push the commits it actually fetched: the
push succeeds and the remote's tip matches, while every earlier commit is
missing. Ask for a shallow clone when the history genuinely is not needed.

When `<dir>` is omitted, the CLI derives it from the last path
segment of the URL, stripping a trailing `.git` — `git clone
https://github.com/owner/repo.git` lands in `./repo`, matching
Expand Down Expand Up @@ -869,19 +885,21 @@ readback that the typed API serves better directly.
### `cat-file`

```
git cat-file -p <oid>[:<path>]
git cat-file (-p|-t|-s) <oid>[:<path>]
```

```ts
ws.git.catFile({
dir?: string,
oid: string,
filepath?: string,
}): Promise<{ oid: string; bytes: Uint8Array }>
}): Promise<{ oid: string; bytes: Uint8Array; type?: string }>
```

Supports the `<oid>:<path>` shorthand for tree subreads.
*Not mapped:* `-t` type, `-s` size, `--batch`.
Supports the `<oid>:<path>` shorthand for tree subreads. `-p` prints the
object's bytes, `-t` its type, and `-s` its size in bytes; exactly one of
the three is required, as in real git.
*Not mapped:* `--batch`.

### `update-ref`

Expand Down
185 changes: 183 additions & 2 deletions packages/computer/src/git/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1754,11 +1754,11 @@ describe("runGitCli — cat-file", () => {
});
});

it("without -p is an error", async () => {
it("without a mode flag is an error", async () => {
const { client } = fakeClient();
const res = await runGitCli(client, { argv: ["cat-file", "a".repeat(40)] });
expect(res.exitCode).toBe(129);
expect(res.stderr).toContain("only -p is supported");
expect(res.stderr).toContain("one of -p, -t, or -s is required");
});
});

Expand Down Expand Up @@ -2340,3 +2340,184 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => {
expect(res.stdout).toBe("");
});
});

describe("runGitCli — cat-file type and size", () => {
it("-t reports the object type", async () => {
const { client } = fakeClient(
{},
{
catFile: () => ({
oid: "a".repeat(40),
bytes: new TextEncoder().encode("hello\n"),
type: "commit" as const,
}),
},
);
const res = await runGitCli(client, { argv: ["cat-file", "-t", "a".repeat(40)] });
expect(res.exitCode).toBe(0);
expect(res.stdout).toBe("commit\n");
});

it("-s reports the object size in bytes", async () => {
const { client } = fakeClient(
{},
{
catFile: () => ({
oid: "a".repeat(40),
bytes: new TextEncoder().encode("hello\n"),
}),
},
);
const res = await runGitCli(client, { argv: ["cat-file", "-s", "a".repeat(40)] });
expect(res.exitCode).toBe(0);
expect(res.stdout).toBe("6\n");
});

it("rejects combining -p and -t", async () => {
const { client } = fakeClient();
const res = await runGitCli(client, { argv: ["cat-file", "-p", "-t", "a".repeat(40)] });
expect(res.exitCode).toBe(129);
expect(res.stderr).toMatch(/mutually exclusive/);
});
});

describe("runGitCli — help for one command", () => {
it("prints usage for a named command", async () => {
const { client } = fakeClient();
const res = await runGitCli(client, { argv: ["help", "log"] });
expect(res.exitCode).toBe(0);
expect(res.stdout).toMatch(/^usage: git log /);
expect(res.stdout).toMatch(/--oneline/);
});

it("still prints the command list when given no argument", async () => {
const { client } = fakeClient();
const res = await runGitCli(client, { argv: ["help"] });
expect(res.exitCode).toBe(0);
expect(res.stdout).toMatch(/Supported workspace git commands/);
});

it("reports an unknown command rather than reprinting the list", async () => {
const { client } = fakeClient();
const res = await runGitCli(client, { argv: ["help", "rebase"] });
expect(res.exitCode).toBe(1);
expect(res.stderr).toMatch(/no help available for 'rebase'/);
});
});

describe("runGitCli — log --format", () => {
const sample = (oid: string, msg: string): CommitView => ({
oid,
message: msg,
tree: "",
parent: [],
author: { name: "A", email: "a@x", timestamp: 1_700_000_000, timezoneOffset: 0 },
committer: { name: "C", email: "c@x", timestamp: 1_700_000_000, timezoneOffset: 0 },
});

it("expands the common placeholders", async () => {
const { client } = fakeClient({}, { log: () => [sample("a".repeat(40), "second")] });
const res = await runGitCli(client, { argv: ["log", "--format=%h %s (%an)"] });
expect(res.exitCode).toBe(0);
expect(res.stdout).toBe("aaaaaaa second (A)\n");
});

it("expands the full hash and the committer separately from the author", async () => {
const { client } = fakeClient({}, { log: () => [sample("a".repeat(40), "x")] });
const res = await runGitCli(client, { argv: ["log", "--format=%H|%an|%cn"] });
expect(res.stdout).toBe(`${"a".repeat(40)}|A|C\n`);
});

it("treats --pretty as an alias and understands the oneline format", async () => {
const { client } = fakeClient({}, { log: () => [sample("a".repeat(40), "second")] });
const res = await runGitCli(client, { argv: ["log", "--pretty=oneline"] });
expect(res.exitCode).toBe(0);
expect(res.stdout).toBe("aaaaaaa second\n");
});

it("leaves an unknown placeholder as written", async () => {
const { client } = fakeClient({}, { log: () => [sample("a".repeat(40), "x")] });
const res = await runGitCli(client, { argv: ["log", "--format=%h %zz"] });
expect(res.stdout).toBe("aaaaaaa %zz\n");
});

it("lets the last of --format and --pretty win, in either order", async () => {
const { client } = fakeClient({}, { log: () => [sample("a".repeat(40), "release")] });
const formatThenPretty = await runGitCli(client, {
argv: ["log", "--format=%s", "--pretty=%h"],
});
expect(formatThenPretty.stdout).toBe("aaaaaaa\n");
const prettyThenFormat = await runGitCli(client, {
argv: ["log", "--pretty=%h", "--format=%s"],
});
expect(prettyThenFormat.stdout).toBe("release\n");
});

it("rejects --format combined with --oneline", async () => {
const { client } = fakeClient();
const res = await runGitCli(client, { argv: ["log", "--oneline", "--format=%h"] });
expect(res.exitCode).toBe(129);
});
});

describe("runGitCli — help stays honest about the flags each command takes", () => {
// The usage lines are maintained by hand, so they can drift from the
// parsers they describe. Rather than re-check every line by eye, take
// each long flag the help advertises and confirm the command does not
// reject it as unknown or unsupported. A flag that no longer exists,
// or was never accepted, fails here instead of misleading a caller.
const commands = [
"add",
"branch",
"cat-file",
"checkout",
"clean",
"clone",
"commit",
"config",
"diff",
"fetch",
"hash-object",
"init",
"log",
"ls-files",
"ls-tree",
"merge",
"pull",
"push",
"remote",
"reset",
"rev-parse",
"rm",
"show",
"stash",
"status",
"switch",
"symbolic-ref",
"tag",
"update-ref",
];

it("advertises only flags the command's parser accepts", async () => {
const offenders: string[] = [];
for (const command of commands) {
const { client } = fakeClient();
const help = await runGitCli(client, { argv: ["help", command] });
expect(help.exitCode, `git help ${command}`).toBe(0);

for (const flag of help.stdout.match(/--[a-z][a-z-]*/g) ?? []) {
const { client: probe } = fakeClient();
const res = await runGitCli(probe, { argv: [command, flag] });
// The parser rejects an unknown flag at 129 with a message naming
// it. Any other failure means the flag was understood and the
// command merely wanted different arguments, which is fine here.
const unknown =
res.stderr.includes(`unknown option '${flag}'`) ||
res.stderr.includes(`${flag} is not supported`) ||
res.stderr.includes(`only --stdin is supported`);
if (unknown) offenders.push(`${command} ${flag}: ${res.stderr.trim()}`);
}
}
expect(offenders).toEqual([]);
});
});
Loading
Loading