From f19115ed3a58037525a06d16375123798d91de44 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:23:12 +0000 Subject: [PATCH 01/20] workspace: derive clone dir from URL when destination omitted A bare `git clone ` resolved the missing positional destination to cwd, so the working tree was unpacked directly into the current directory instead of into a subdirectory named after the repository. This surprised agents and scripts that relied on real git's behavior of cloning into `./`. Derive the destination from the last path segment of the URL, stripping a trailing `.git`, when no explicit destination is given. Reject a URL whose basename cannot produce a safe directory name so the failure is loud rather than silently falling back to cwd. An explicit destination still takes precedence and resolves relative to cwd as before. --- package-lock.json | 4 +-- packages/workspace/src/git/cli.test.ts | 43 +++++++++++++++++++++++-- packages/workspace/src/git/cli.ts | 44 +++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 46ec10fe..e1237992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13712,7 +13712,7 @@ }, "packages/workspace": { "name": "@cloudflare/workspace", - "version": "0.0.0-alpha.7", + "version": "0.0.0-alpha.8", "license": "MIT", "dependencies": { "capnweb": "^0.8.0", @@ -13753,7 +13753,7 @@ }, "packages/wsd": { "name": "@cloudflare/workspace-wsd", - "version": "0.0.0-alpha.7", + "version": "0.0.0-alpha.8", "license": "MIT", "dependencies": { "@cloudflare/dofs": "*", diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index ca46be31..119bf02f 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -341,18 +341,18 @@ describe("runGitCli — dispatch", () => { }); describe("runGitCli — clone argv parsing", () => { - it("forwards a bare URL with default options", async () => { + it("forwards a bare URL, deriving the dir from the URL basename", async () => { const { client, calls } = fakeClient(); const res = await runGitCli(client, { argv: ["clone", "https://example.test/r.git"], cwd: "/work", }); expect(res.exitCode).toBe(0); - expect(res.stdout).toContain("Cloning into '/work'"); + expect(res.stdout).toContain("Cloning into '/work/r'"); expect(calls.clone).toEqual([ { url: "https://example.test/r.git", - dir: "/work", + dir: "/work/r", ref: undefined, depth: undefined, singleBranch: undefined, @@ -361,6 +361,43 @@ describe("runGitCli — clone argv parsing", () => { ]); }); + it("derives the dir from the URL basename, stripping a .git suffix", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { + argv: ["clone", "https://github.com/cloudflare/workspace"], + cwd: "/workspace", + }); + expect(calls.clone[0].dir).toBe("/workspace/workspace"); + }); + + it("derives the dir from a URL with a trailing slash", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { + argv: ["clone", "https://github.com/cloudflare/workspace/"], + cwd: "/workspace", + }); + expect(calls.clone[0].dir).toBe("/workspace/workspace"); + }); + + it("prefers an explicit destination over the derived basename", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { + argv: ["clone", "https://github.com/cloudflare/workspace", "/dst/cf-workspace"], + cwd: "/work", + }); + expect(calls.clone[0].dir).toBe("/dst/cf-workspace"); + }); + + it("rejects a URL whose basename cannot produce a safe dir", async () => { + const { client } = fakeClient(); + const res = await runGitCli(client, { + argv: ["clone", "https://example.test/"], + cwd: "/work", + }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("could not derive"); + }); + it("forwards --depth, --branch (-b), --single-branch, --no-tags", async () => { const { client, calls } = fakeClient(); const res = await runGitCli(client, { diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index c0a94be9..a282af1d 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -226,7 +226,24 @@ async function runClone( exitCode: 1, }; } - const dir = resolveDir(dirArg, input.cwd); + let dir: string; + if (dirArg !== undefined && dirArg !== "") { + dir = resolveDir(dirArg, input.cwd); + } else { + // Real git derives the destination from the last path segment + // of the URL when no positional is given, so `git clone + // https://host/owner/repo.git` lands in `./repo` rather than + // splattering the working tree into cwd. + const name = repoNameFromUrl(url); + if (name === undefined) { + return { + stdout: "", + stderr: `git clone: could not derive a directory name from '${url}'. Pass an explicit destination.\n`, + exitCode: 129, + }; + } + dir = resolveDir(name, input.cwd); + } let depth: number | undefined; if (parsed.flags.depth !== undefined) { @@ -1757,6 +1774,31 @@ function joinPath(base: string, segment: string): string { return `${base}/${segment}`; } +/** + * Derive the default clone directory name from a repository URL, + * mirroring real git: take the last non-empty path segment and + * strip a trailing `.git`. Returns `undefined` when no usable + * name can be extracted (e.g. the URL ends in a bare host or a + * slash), so the caller can demand an explicit destination. + */ +function repoNameFromUrl(url: string): string | undefined { + // Trim a query/fragment and trailing slashes before splitting. + let s = url.split(/[?#]/, 1)[0]; + while (s.endsWith("/")) s = s.slice(0, -1); + // Drop the scheme + authority so a host with no path doesn't + // yield the host name as a repo name. + const schemeEnd = s.indexOf("://"); + const afterScheme = schemeEnd === -1 ? s : s.slice(schemeEnd + 3); + const firstSlash = afterScheme.indexOf("/"); + if (firstSlash === -1) return undefined; + const path = afterScheme.slice(firstSlash + 1); + const segment = path.split("/").pop(); + if (segment === undefined || segment === "") return undefined; + const name = segment.endsWith(".git") ? segment.slice(0, -4) : segment; + if (name === "" || name === "." || name === "..") return undefined; + return name; +} + function isSupportedRemoteUrl(url: string): boolean { return url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://"); } From c9a8c7ef13d5e5cf4345467c274c2d0b0f5324cd Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:24:48 +0000 Subject: [PATCH 02/20] workspace: accept top-level git -C Scripts and agents lean on `git -C ` to run a command against another working tree without changing the process directory. The dispatcher treated `-C` as a subcommand and failed with an unknown-command error. Parse a leading `-C ` off the front of argv before dispatch and use it as the effective cwd that each subcommand resolves its `dir` default against. A relative path joins onto the caller's cwd. A missing value or a second `-C` exits 129; agent use only needs a single occurrence. --- packages/workspace/src/git/cli.test.ts | 46 +++++++++++++++++++++++ packages/workspace/src/git/cli.ts | 52 +++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 119bf02f..9f55d8dc 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -340,6 +340,52 @@ describe("runGitCli — dispatch", () => { }); }); +describe("runGitCli — global -C ", () => { + it("runs the subcommand with cwd set to an absolute -C path", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { + argv: ["-C", "/repo", "status", "--short"], + cwd: "/elsewhere", + }); + expect(res.exitCode).toBe(0); + expect(calls.status[0].dir).toBe("/repo"); + }); + + it("resolves a relative -C path against cwd", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { + argv: ["-C", "sub", "status"], + cwd: "/work", + }); + expect(calls.status[0].dir).toBe("/work/sub"); + }); + + it("applies -C before a subcommand that takes its own dir default", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { + argv: ["-C", "/repo", "log", "-n", "1", "--oneline"], + cwd: "/elsewhere", + }); + expect(calls.log[0].dir).toBe("/repo"); + }); + + it("exits 129 when -C is missing its value", async () => { + const { client } = fakeClient(); + const res = await runGitCli(client, { argv: ["-C"] }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("-C"); + }); + + it("rejects a second -C as unsupported", async () => { + const { client } = fakeClient(); + const res = await runGitCli(client, { + argv: ["-C", "/a", "-C", "/b", "status"], + }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("-C"); + }); +}); + describe("runGitCli — clone argv parsing", () => { it("forwards a bare URL, deriving the dir from the URL basename", async () => { const { client, calls } = fakeClient(); diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index a282af1d..8b28f369 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -61,10 +61,20 @@ export async function runGitCli( input: GitCliInput, _options: RunGitCliOptions = {}, ): Promise { - const argv = input.argv; + // Strip leading global options (currently only `-C `) + // before the subcommand. Real git accepts these between `git` + // and the subcommand; agents lean on `-C` to avoid changing + // process cwd. The path rewrites the effective cwd that each + // subcommand's `dir` default resolves against. + const global = parseGlobalOptions(input.argv, input.cwd); + if ("error" in global) { + return { stdout: "", stderr: `git: ${global.error}\n`, exitCode: 129 }; + } + const argv = global.argv; if (argv.length === 0) { return printHelp(); } + input = global.cwd === input.cwd ? input : { ...input, cwd: global.cwd }; const [sub, ...rest] = argv; switch (sub) { case "help": @@ -1680,6 +1690,46 @@ type ParseResult = ParsedFlags | { error: string }; * fall through as a positional. Real git is laxer on this, but * the workspace surface is intentionally narrow. */ +interface GlobalOptions { + /** Argv with any leading global options removed. */ + argv: string[]; + /** Effective cwd after applying `-C`. */ + cwd: string | undefined; +} + +/** + * Pull leading global options off the front of argv. Only + * `-C ` is supported today: it sets the working directory + * the subcommand resolves its `dir` default against, matching + * real git's top-level `-C`. A relative path joins onto the + * current cwd. A second `-C` is rejected rather than stacked — + * agent use only needs one. + */ +function parseGlobalOptions( + argv: string[], + cwd: string | undefined, +): GlobalOptions | { error: string } { + let cwdOut = cwd; + let seenC = false; + let i = 0; + while (i < argv.length) { + const arg = argv[i]; + if (arg === "-C") { + if (seenC) return { error: "multiple -C options are not supported" }; + const value = argv[i + 1]; + if (value === undefined || value === "") { + return { error: "option '-C' requires a value" }; + } + cwdOut = value.startsWith("/") ? value : joinPath(cwdOut ?? "/", value); + seenC = true; + i += 2; + continue; + } + break; + } + return { argv: argv.slice(i), cwd: cwdOut }; +} + function parseFlags(args: string[], spec: Record): ParseResult { const flags: Record = {}; const positional: string[] = []; From 8bcbe18f4396ac9c3ee301a8658564a96dda1816 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:26:08 +0000 Subject: [PATCH 03/20] workspace: add branch --show-current and rev-parse --abbrev-ref Agents check the current branch with `git branch --show-current` or `git rev-parse --abbrev-ref HEAD`. Both were rejected as unknown options, leaving `symbolic-ref HEAD` as the only spelling. Wire both to the existing current-branch lookup. `branch --show-current` prints the checked-out branch or nothing on detached HEAD. `rev-parse --abbrev-ref HEAD` prints the branch name and falls back to the resolved oid when HEAD is detached, matching real git. --- packages/workspace/src/git/cli.test.ts | 38 ++++++++++++++++++++++++++ packages/workspace/src/git/cli.ts | 38 ++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 9f55d8dc..9f9714aa 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -903,6 +903,28 @@ describe("runGitCli — show / rev-parse / symbolic-ref", () => { expect(res.stderr).toContain("missing "); }); + it("rev-parse --abbrev-ref HEAD prints the current branch", async () => { + const { client, calls } = fakeClient({}, { currentBranch: () => "main" }); + const res = await runGitCli(client, { + argv: ["rev-parse", "--abbrev-ref", "HEAD"], + cwd: "/r", + }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe("main\n"); + expect(calls.currentBranch[0]).toMatchObject({ dir: "/r" }); + expect(calls.revParse).toEqual([]); + }); + + it("rev-parse --abbrev-ref on detached HEAD falls back to the oid", async () => { + const { client } = fakeClient( + {}, + { currentBranch: () => undefined, revParse: () => "c".repeat(40) }, + ); + const res = await runGitCli(client, { argv: ["rev-parse", "--abbrev-ref", "HEAD"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe(`${"c".repeat(40)}\n`); + }); + it("symbolic-ref HEAD prints the full ref by default", async () => { const { client, calls } = fakeClient({}, { currentBranch: () => "refs/heads/main" }); const res = await runGitCli(client, { argv: ["symbolic-ref", "HEAD"] }); @@ -1035,6 +1057,22 @@ describe("runGitCli — branch argv parsing", () => { await runGitCli(client, { argv: ["branch", "--force", "feature", "v1"] }); expect(calls.branch[0].force).toBe(true); }); + + it("branch --show-current prints the current branch", async () => { + const { client, calls } = fakeClient({}, { currentBranch: () => "main" }); + const res = await runGitCli(client, { argv: ["branch", "--show-current"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe("main\n"); + expect(calls.currentBranch[0]).toMatchObject({ dir: "/r" }); + expect(calls.branchList).toEqual([]); + }); + + it("branch --show-current prints nothing on detached HEAD", async () => { + const { client } = fakeClient({}, { currentBranch: () => undefined }); + const res = await runGitCli(client, { argv: ["branch", "--show-current"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe(""); + }); }); describe("runGitCli — tag argv parsing", () => { diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 8b28f369..626464db 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -705,7 +705,9 @@ async function runRevParse( args: string[], input: GitCliInput, ): Promise { - const parsed = parseFlags(args, {}); + const parsed = parseFlags(args, { + "abbrev-ref": { kind: "bool" }, + }); if ("error" in parsed) { return { stdout: "", stderr: `git rev-parse: ${parsed.error}\n`, exitCode: 129 }; } @@ -720,8 +722,28 @@ async function runRevParse( }; } const dir = resolveDir(undefined, input.cwd); + const ref = parsed.positional[0]; + + if (parsed.flags["abbrev-ref"] === true) { + // `--abbrev-ref HEAD` prints the symbolic branch name. On + // detached HEAD real git falls back to printing the resolved + // oid, so mirror that rather than erroring. + try { + if (ref === "HEAD") { + const current = await client.currentBranch({ dir }); + if (current !== undefined) { + return { stdout: `${current}\n`, stderr: "", exitCode: 0 }; + } + } + const oid = await client.revParse({ dir, ref }); + return { stdout: `${oid}\n`, stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError("rev-parse", cause); + } + } + try { - const oid = await client.revParse({ dir, ref: parsed.positional[0] }); + const oid = await client.revParse({ dir, ref }); return { stdout: `${oid}\n`, stderr: "", exitCode: 0 }; } catch (cause) { return mapGitError("rev-parse", cause); @@ -864,6 +886,7 @@ async function runBranch( D: { kind: "bool" }, delete: { kind: "bool" }, force: { kind: "bool", alias: ["f"] }, + "show-current": { kind: "bool" }, }); if ("error" in parsed) { return { stdout: "", stderr: `git branch: ${parsed.error}\n`, exitCode: 129 }; @@ -872,6 +895,17 @@ async function runBranch( parsed.flags.d === true || parsed.flags.D === true || parsed.flags.delete === true; const dir = resolveDir(undefined, input.cwd); + if (parsed.flags["show-current"] === true) { + // Print the checked-out branch name, or nothing on detached + // HEAD — matching real git's `branch --show-current`. + try { + const current = await client.currentBranch({ dir }); + return { stdout: current ? `${current}\n` : "", stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError("branch", cause); + } + } + if (wantDelete) { if (parsed.positional.length === 0) { return { From 8efa4f0ebf244bb8c9e20e3af925910da72e2efa Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:29:04 +0000 Subject: [PATCH 04/20] workspace: keep HEAD symbolic after clone After a clone, HEAD resolved as a detached oid rather than a symbolic ref to the checked-out branch, so `symbolic-ref HEAD` and `branch --show-current` reported nothing until an explicit checkout rewrote it. The detach came from the checkout phase: isomorphic-git's clone writes a symbolic HEAD, but `cloneWith` then checked out `HEAD`, which re-resolves to an oid and detaches because the ref does not expand to refs/heads/*. Pass noUpdateHead to the checkout phase so it materializes the working tree without rewriting HEAD, preserving the symbolic ref the clone left in place. --- packages/workspace/src/git/clone.test.ts | 34 ++++++++++++++++++++++++ packages/workspace/src/git/clone.ts | 9 +++++++ 2 files changed, 43 insertions(+) diff --git a/packages/workspace/src/git/clone.test.ts b/packages/workspace/src/git/clone.test.ts index 58901477..58ffba25 100644 --- a/packages/workspace/src/git/clone.test.ts +++ b/packages/workspace/src/git/clone.test.ts @@ -229,4 +229,38 @@ describe("cloneWith subset checkout (real isomorphic-git + memfs)", () => { // `checkout` writes only the requested filepaths to disk. await expect(memfs.promises.stat(`${DIR}/drop/file.txt`)).rejects.toThrow(); }); + + it("leaves HEAD as a symbolic ref after checkout", async () => { + // Reproduce the post-clone on-disk state: clone writes a + // symbolic HEAD pointing at the fetched branch, then leaves + // checkout to materialize the tree. Build that state with a + // real init + commit (which sets HEAD -> refs/heads/main), + // strip the working tree, and drive cloneWith's checkout + // phase against it. The checkout must not detach HEAD. + await memfs.promises.mkdir(DIR, { recursive: true }); + await git.init({ fs: memfs, dir: DIR, defaultBranch: "main" }); + await memfs.promises.writeFile(`${DIR}/README.md`, "readme\n"); + await git.add({ fs: memfs, dir: DIR, filepath: "README.md" }); + await git.commit({ fs: memfs, dir: DIR, message: "init", author: AUTHOR }); + await memfs.promises.rm(`${DIR}/README.md`); + + const fakeClone: IsomorphicGitClient = { + clone: vi.fn(async () => {}), + checkout: (args) => git.checkout({ ...args, fs: memfs }) as unknown as Promise, + }; + + await cloneWith({ + git: fakeClone, + http: fakeHttp, + fs: memfs, + url: "ignored — clone phase is faked", + dir: DIR, + }); + + const head = await memfs.promises.readFile(`${DIR}/.git/HEAD`, "utf8"); + expect(head.trim()).toBe("ref: refs/heads/main"); + expect(await git.currentBranch({ fs: memfs, dir: DIR })).toBe("main"); + // The working tree is still materialized. + expect(await memfs.promises.readFile(`${DIR}/README.md`, "utf8")).toBe("readme\n"); + }); }); diff --git a/packages/workspace/src/git/clone.ts b/packages/workspace/src/git/clone.ts index 3c03b00d..51a1aefb 100644 --- a/packages/workspace/src/git/clone.ts +++ b/packages/workspace/src/git/clone.ts @@ -41,6 +41,7 @@ export interface IsomorphicGitClient { ref: string; filepaths?: string[]; force?: boolean; + noUpdateHead?: boolean; cache?: object; }): Promise; } @@ -129,12 +130,20 @@ export async function cloneWith(opts: CloneWithDeps): Promise { // The working tree is empty after a noCheckout clone, so `force` // is safe — nothing real can conflict — and matches caller intent // ("populate this workspace from the remote"). + // + // `git.clone` already wrote HEAD as a symbolic ref to the fetched + // branch. Checking out `ref: "HEAD"` here would re-resolve it to + // an oid and detach HEAD, because isomorphic-git only writes a + // symbolic HEAD when the checkout ref expands to `refs/heads/*`. + // `noUpdateHead` materializes the working tree without touching + // HEAD, preserving the symbolic ref the clone left in place. await opts.git.checkout({ fs: opts.fs, dir, ref: ref ?? "HEAD", filepaths: opts.paths, force: true, + noUpdateHead: true, cache: opts.cache, }); } From a80dda28e0a9bba210b814f18447c0b68af55ae6 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:29:47 +0000 Subject: [PATCH 05/20] workspace: accept git log -N count shorthand `git log -1` and the wider `-` family are the muscle-memory spelling for limiting commit output, but the dispatcher rejected the bare numeric short option as unknown and only accepted `-n `. Rewrite `-` to `-n ` before flag parsing. `-0` and non-numeric forms still fail through the existing `-n` validation, which requires a positive integer. --- packages/workspace/src/git/cli.test.ts | 19 +++++++++++++++++++ packages/workspace/src/git/cli.ts | 25 ++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 9f9714aa..93e3ba31 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -874,6 +874,25 @@ describe("runGitCli — log argv parsing", () => { expect(res.exitCode).toBe(129); expect(res.stderr).toContain("-n"); }); + + it("-1 is shorthand for -n 1", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["log", "-1", "--oneline"] }); + expect(res.exitCode).toBe(0); + expect(calls.log[0].depth).toBe(1); + }); + + it("-5 is shorthand for -n 5", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { argv: ["log", "-5"] }); + expect(calls.log[0].depth).toBe(5); + }); + + it("-0 is rejected", async () => { + const { client } = fakeClient(); + const res = await runGitCli(client, { argv: ["log", "-0"] }); + expect(res.exitCode).toBe(129); + }); }); describe("runGitCli — show / rev-parse / symbolic-ref", () => { diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 626464db..ec8f46ef 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -589,16 +589,35 @@ async function runLog( args: string[], input: GitCliInput, ): Promise { - // `git log [-n ] [--oneline] []`. Default output is - // the full commit form; --oneline collapses each entry to a + // `git log [-n ] [-] [--oneline] []`. Default output + // is the full commit form; --oneline collapses each entry to a // single line. - const parsed = parseFlags(args, { + // + // Rewrite the `-` shorthand (e.g. `-1`, `-5`) to `-n ` + // before parsing — the generic parser would otherwise reject + // `-5` as an unknown short option. `-0` and non-numeric forms + // fall through to the `-n` validation below, which rejects + // them. + let shorthandDepth: string | undefined; + const rewritten: string[] = []; + for (const arg of args) { + const m = /^-(\d+)$/.exec(arg); + if (m) { + shorthandDepth = m[1]; + continue; + } + rewritten.push(arg); + } + const parsed = parseFlags(rewritten, { n: { kind: "value" }, oneline: { kind: "bool" }, }); if ("error" in parsed) { return { stdout: "", stderr: `git log: ${parsed.error}\n`, exitCode: 129 }; } + if (shorthandDepth !== undefined && parsed.flags.n === undefined) { + parsed.flags.n = shorthandDepth; + } if (parsed.positional.length > 1) { return { stdout: "", From eacef4de77aa902ab9bae0fb1df2f3c3871036b1 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:31:45 +0000 Subject: [PATCH 06/20] workspace: support git status --porcelain=v1 Most tooling parses porcelain v1, but `status` accepted only the short and porcelain v2 forms and rejected `--porcelain=v1` as an unsupported value. The internal short formatter is close but renders untracked files as ` ?` rather than the `??` two-char code v1 consumers expect. Add a dedicated v1 formatter that matches git's `XY ` output, including `??` for untracked, and route `--porcelain=v1` (and the `1` spelling) to it. The bare `--porcelain` default stays v2 so existing machine-readable consumers are unaffected. --- packages/workspace/src/git/cli.test.ts | 22 ++++++++++++++++++++++ packages/workspace/src/git/cli.ts | 21 ++++++++++++--------- packages/workspace/src/git/status.test.ts | 17 +++++++++++++++++ packages/workspace/src/git/status.ts | 19 +++++++++++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 93e3ba31..bea261aa 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -683,6 +683,28 @@ describe("runGitCli — status argv parsing", () => { expect(res.stdout).toBe("1 M a.txt\n"); }); + it("--porcelain=v1 selects the v1 (XY path) format", async () => { + const { client } = fakeClient( + {}, + { + status: () => [ + { path: "a.txt", index: "M", worktree: " " }, + { path: "b.txt", index: " ", worktree: "?" }, + ], + }, + ); + const res = await runGitCli(client, { argv: ["status", "--porcelain=v1"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe("M a.txt\n?? b.txt\n"); + }); + + it("--porcelain=v1 emits nothing for a clean tree", async () => { + const { client } = fakeClient({}, { status: () => [] }); + const res = await runGitCli(client, { argv: ["status", "--porcelain=v1"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe(""); + }); + it("--porcelain with an unknown value is an error", async () => { const { client } = fakeClient(); const res = await runGitCli(client, { argv: ["status", "--porcelain=v3"] }); diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index ec8f46ef..ba1e91d4 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -21,7 +21,7 @@ import { PathspecNotFoundError, } from "./errors.js"; import type { GitClient, GitIdentity } from "./index.js"; -import { formatPorcelainV2, formatShort } from "./status.js"; +import { formatPorcelainV1, formatPorcelainV2, formatShort } from "./status.js"; export interface GitCliInput { /** Argv as seen by the shell command. `argv[0]` is the subcommand. */ @@ -411,13 +411,16 @@ async function runStatus( exitCode: 129, }; } - // Format selection. Default is porcelain v2 — the typed CLI - // surface is intentionally machine-readable; the long-form - // human output is deferred. + // Format selection. The bare default is porcelain v2 — the + // typed CLI surface is intentionally machine-readable and the + // long-form human output is deferred. `--porcelain=v1` (and the + // `1` spelling git also accepts) selects the v1 `XY ` + // shape that the bulk of tooling parses. const porcelain = parsed.flags.porcelain; const useShort = parsed.flags.short === true; - const v2 = porcelain === undefined || porcelain === true || porcelain === "v2"; - if (porcelain !== undefined && porcelain !== true && porcelain !== "v2" && porcelain !== "1") { + const isV1 = porcelain === "v1" || porcelain === "1"; + const isV2 = porcelain === undefined || porcelain === true || porcelain === "v2"; + if (porcelain !== undefined && porcelain !== true && !isV1 && !isV2) { return { stdout: "", stderr: `git status: unsupported --porcelain value '${porcelain}'\n`, @@ -433,9 +436,9 @@ async function runStatus( } const stdout = useShort ? formatShort(entries) - : v2 - ? formatPorcelainV2(entries) - : formatShort(entries); + : isV1 + ? formatPorcelainV1(entries) + : formatPorcelainV2(entries); return { stdout, stderr: "", exitCode: 0 }; } diff --git a/packages/workspace/src/git/status.test.ts b/packages/workspace/src/git/status.test.ts index 84689f0d..a0089fca 100644 --- a/packages/workspace/src/git/status.test.ts +++ b/packages/workspace/src/git/status.test.ts @@ -7,6 +7,7 @@ import { fs as memfs, vol } from "memfs"; import { beforeEach, describe, expect, it } from "vitest"; import { + formatPorcelainV1, formatPorcelainV2, formatShort, type IsomorphicGitStatusClient, @@ -146,6 +147,22 @@ describe("formatShort", () => { }); }); +describe("formatPorcelainV1", () => { + it("empty list yields empty string", () => { + expect(formatPorcelainV1([])).toBe(""); + }); + + it("emits 'XY ' lines, with '??' for untracked", () => { + const entries: StatusEntry[] = [ + { path: "a.txt", index: "M", worktree: " " }, + { path: "b.txt", index: " ", worktree: "?" }, + { path: "c.txt", index: "A", worktree: "M" }, + { path: "d.txt", index: " ", worktree: "D" }, + ]; + expect(formatPorcelainV1(entries)).toBe("M a.txt\n?? b.txt\nAM c.txt\n D d.txt\n"); + }); +}); + // Sanity: the bare formatters take StatusMatrixRow-derived shapes // directly without needing isomorphic-git, useful when a caller // wants to format a status produced elsewhere. diff --git a/packages/workspace/src/git/status.ts b/packages/workspace/src/git/status.ts index 4d97a460..82353434 100644 --- a/packages/workspace/src/git/status.ts +++ b/packages/workspace/src/git/status.ts @@ -138,6 +138,25 @@ export function formatPorcelainV2(entries: StatusEntry[]): string { return `${lines.join("\n")}\n`; } +/** + * Render `entries` as porcelain v1 lines (`XY `). Identical + * to `--short` except untracked files use the `??` two-char code + * real git's porcelain v1 emits rather than the ` ?` short form, + * so a v1 parser sees the shape it expects. + */ +export function formatPorcelainV1(entries: StatusEntry[]): string { + if (entries.length === 0) return ""; + const lines: string[] = []; + for (const e of entries) { + if (e.worktree === "?") { + lines.push(`?? ${e.path}`); + } else { + lines.push(`${e.index}${e.worktree} ${e.path}`); + } + } + return `${lines.join("\n")}\n`; +} + /** Render `entries` as `--short` output. */ export function formatShort(entries: StatusEntry[]): string { if (entries.length === 0) return ""; From d616467e16448b1af9b126044829d7178f74f6f1 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:35:02 +0000 Subject: [PATCH 07/20] workspace: resolve revision suffix syntax in rev-parse `HEAD^`, `HEAD~1`, `HEAD~2`, and `~N` are ubiquitous in agent and CI workflows, but rev-parse accepted only a literal ref or an oid prefix and rejected any ancestry suffix. Parse the gitrevisions(7) suffix operators `^`, `^N`, and `~N` off the base ref, then walk commit parents from the resolved base oid. `~N` expands to N first-parent hops; `^` and `^N` select a parent by index. Walking past the root commit fails with a clear error. --- packages/workspace/src/git/reads.test.ts | 41 +++++++++++++ packages/workspace/src/git/reads.ts | 77 +++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/workspace/src/git/reads.test.ts b/packages/workspace/src/git/reads.test.ts index 41671e40..6f036e9b 100644 --- a/packages/workspace/src/git/reads.test.ts +++ b/packages/workspace/src/git/reads.test.ts @@ -98,6 +98,47 @@ describe("revParseWith", () => { const out = await revParseWith({ git: isogit, fs: memfs, dir: DIR, ref: "main" }); expect(out).toBe(oid); }); + + it("resolves HEAD^ to the first parent", async () => { + await init(); + const first = await commit("a.txt", "v1\n", "first"); + await commit("a.txt", "v2\n", "second"); + const out = await revParseWith({ git: isogit, fs: memfs, dir: DIR, ref: "HEAD^" }); + expect(out).toBe(first); + }); + + it("resolves HEAD~1 to the first parent", async () => { + await init(); + const first = await commit("a.txt", "v1\n", "first"); + await commit("a.txt", "v2\n", "second"); + const out = await revParseWith({ git: isogit, fs: memfs, dir: DIR, ref: "HEAD~1" }); + expect(out).toBe(first); + }); + + it("resolves HEAD~2 two commits back", async () => { + await init(); + const first = await commit("a.txt", "v1\n", "first"); + await commit("a.txt", "v2\n", "second"); + await commit("a.txt", "v3\n", "third"); + const out = await revParseWith({ git: isogit, fs: memfs, dir: DIR, ref: "HEAD~2" }); + expect(out).toBe(first); + }); + + it("resolves a branch name with a suffix", async () => { + await init(); + const first = await commit("a.txt", "v1\n", "first"); + await commit("a.txt", "v2\n", "second"); + const out = await revParseWith({ git: isogit, fs: memfs, dir: DIR, ref: "main~1" }); + expect(out).toBe(first); + }); + + it("throws when walking past the root commit", async () => { + await init(); + await commit("a.txt", "v1\n", "only"); + await expect( + revParseWith({ git: isogit, fs: memfs, dir: DIR, ref: "HEAD~5" }), + ).rejects.toThrow(); + }); }); describe("currentBranchWith", () => { diff --git a/packages/workspace/src/git/reads.ts b/packages/workspace/src/git/reads.ts index 3bde8513..63b65a25 100644 --- a/packages/workspace/src/git/reads.ts +++ b/packages/workspace/src/git/reads.ts @@ -179,13 +179,88 @@ export interface RevParseWithDeps extends GitRevParseOptions { export async function revParseWith(opts: RevParseWithDeps): Promise { const dir = opts.dir ?? "/"; try { - return await opts.git.resolveRef({ fs: opts.fs, dir, ref: opts.ref }); + const { base, steps } = parseRevision(opts.ref); + let oid = await opts.git.resolveRef({ fs: opts.fs, dir, ref: base }); + for (const step of steps) { + oid = await walkParent(opts, dir, oid, step); + } + return oid; } catch (cause) { + if (cause instanceof GitError) throw cause; if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); throw new GitError("EREVPARSEFAIL", `git rev-parse failed: ${errorMessage(cause)}`, { cause }); } } +/** + * A single ancestry step parsed off a revision spec. `^` and `~N` + * both walk toward parents; `n` records which parent (1-based) + * each step follows. `~N` expands to N first-parent steps; `^` + * is one step toward parent `n` (default 1); `^N` selects parent + * N. See gitrevisions(7). + */ +interface RevStep { + /** 1-based parent index. */ + n: number; +} + +/** + * Split a revision into its base ref and the ancestry walk that + * follows. Supports the common `gitrevisions(7)` suffixes: + * + * -> { base: , steps: [] } + * ^ -> one step to parent 1 + * ^N -> one step to parent N + * ~N -> N steps, each to parent 1 + * + * Suffixes chain left to right, so `HEAD~2^2` is two first-parent + * steps then a second-parent step. + */ +function parseRevision(ref: string): { base: string; steps: RevStep[] } { + // Find where the suffix operators begin. A bare oid or ref has + // none. We only treat trailing `^`/`~` runs as operators. + const match = /^(.*?)((?:[\^~][0-9]*)*)$/.exec(ref); + if (!match || match[2] === "") return { base: ref, steps: [] }; + const base = match[1]; + const suffix = match[2]; + const steps: RevStep[] = []; + const tokens = suffix.match(/[\^~][0-9]*/g) ?? []; + for (const token of tokens) { + const op = token[0]; + const num = token.slice(1); + if (op === "~") { + // `~` with no number means `~1`. `~N` is N first-parent + // hops. + const count = num === "" ? 1 : Number.parseInt(num, 10); + for (let i = 0; i < count; i++) steps.push({ n: 1 }); + } else { + // `^` with no number means parent 1. `^N` selects parent N. + const n = num === "" ? 1 : Number.parseInt(num, 10); + // `^0` means the commit itself — no walk. + if (n === 0) continue; + steps.push({ n }); + } + } + return { base, steps }; +} + +async function walkParent( + opts: RevParseWithDeps, + dir: string, + oid: string, + step: RevStep, +): Promise { + const { commit } = await opts.git.readCommit({ fs: opts.fs, dir, oid }); + const parent = commit.parent[step.n - 1]; + if (parent === undefined) { + throw new GitError( + "EREVPARSEFAIL", + `git rev-parse failed: ${oid.slice(0, 7)} has no parent ${step.n}`, + ); + } + return parent; +} + // --------------------------------------------------------------- // current-branch // --------------------------------------------------------------- From 12812c98c68161f298b05e3816df8fc52f8e1b62 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:36:56 +0000 Subject: [PATCH 08/20] workspace: resolve revision suffixes in show, diff, and log rev-parse learned the `HEAD^` / `HEAD~N` ancestry grammar, but show, diff, and log resolve their refs through resolveRef, which only understands literal refs and oids. A suffixed ref handed to any of the three failed to resolve. Pre-resolve a ref carrying an ancestry suffix to a concrete oid through rev-parse before forwarding it to the typed method. Plain refs pass through untouched so branch and tag resolution stays where it was. --- packages/workspace/src/git/cli.test.ts | 22 ++++++++++++++++ packages/workspace/src/git/cli.ts | 35 +++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index bea261aa..3d956c41 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -569,6 +569,14 @@ describe("runGitCli — diff argv parsing", () => { expect(calls.diff).toEqual([{ dir: "/r", ref: "v1", to: "v2", paths: undefined }]); }); + it("pre-resolves revision suffixes in from/to refs", async () => { + let n = 0; + const oids = ["a".repeat(40), "b".repeat(40)]; + const { client, calls } = fakeClient({}, { revParse: () => oids[n++] }); + await runGitCli(client, { argv: ["diff", "HEAD~2", "HEAD~1"], cwd: "/r" }); + expect(calls.diff[0]).toMatchObject({ ref: "a".repeat(40), to: "b".repeat(40) }); + }); + it("rejects three or more refs before '--'", async () => { const { client } = fakeClient(); const res = await runGitCli(client, { argv: ["diff", "a", "b", "c"] }); @@ -897,6 +905,13 @@ describe("runGitCli — log argv parsing", () => { expect(res.stderr).toContain("-n"); }); + it("pre-resolves a revision suffix in the positional ref", async () => { + const { client, calls } = fakeClient({}, { revParse: () => "e".repeat(40) }); + await runGitCli(client, { argv: ["log", "HEAD~2"], cwd: "/r" }); + expect(calls.revParse[0]).toMatchObject({ ref: "HEAD~2" }); + expect(calls.log[0].ref).toBe("e".repeat(40)); + }); + it("-1 is shorthand for -n 1", async () => { const { client, calls } = fakeClient(); const res = await runGitCli(client, { argv: ["log", "-1", "--oneline"] }); @@ -930,6 +945,13 @@ describe("runGitCli — show / rev-parse / symbolic-ref", () => { expect(calls.show[0].ref).toBe("HEAD"); }); + it("show pre-resolves a revision suffix to an oid", async () => { + const { client, calls } = fakeClient({}, { revParse: () => "d".repeat(40) }); + await runGitCli(client, { argv: ["show", "HEAD~1"], cwd: "/r" }); + expect(calls.revParse[0]).toMatchObject({ dir: "/r", ref: "HEAD~1" }); + expect(calls.show[0].ref).toBe("d".repeat(40)); + }); + it("rev-parse prints the resolved oid", async () => { const { client } = fakeClient({}, { revParse: () => "deadbeef".repeat(5) }); const res = await runGitCli(client, { argv: ["rev-parse", "HEAD"] }); diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index ba1e91d4..8eae8f24 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -334,10 +334,12 @@ async function runDiff( const [from, to] = refArgs; const dir = resolveDir(undefined, input.cwd); try { + const fromResolved = await resolveRevisionRef(client, dir, from); + const toResolved = await resolveRevisionRef(client, dir, to); const output = await client.diff({ dir, - ref: from, - to, + ref: fromResolved, + to: toResolved, paths: pathArgs.length > 0 ? pathArgs : undefined, }); return { stdout: output, stderr: "", exitCode: 0 }; @@ -642,7 +644,8 @@ async function runLog( } const dir = resolveDir(undefined, input.cwd); try { - const commits = await client.log({ dir, ref: parsed.positional[0], depth }); + const ref = await resolveRevisionRef(client, dir, parsed.positional[0]); + const commits = await client.log({ dir, ref, depth }); const stdout = parsed.flags.oneline ? formatLogOneline(commits) : formatLogFull(commits); return { stdout, stderr: "", exitCode: 0 }; } catch (cause) { @@ -711,7 +714,8 @@ async function runShow( } const dir = resolveDir(undefined, input.cwd); try { - const c = await client.show({ dir, ref }); + const resolved = (await resolveRevisionRef(client, dir, ref)) ?? ref; + const c = await client.show({ dir, ref: resolved }); return { stdout: formatLogFull([c]), stderr: "", exitCode: 0 }; } catch (cause) { return mapGitError("show", cause); @@ -1905,6 +1909,29 @@ function repoNameFromUrl(url: string): string | undefined { return name; } +/** True when `ref` carries a `gitrevisions(7)` ancestry suffix. */ +function hasRevisionSuffix(ref: string): boolean { + return /[\^~]/.test(ref); +} + +/** + * Resolve a ref that may carry a revision suffix (`HEAD^`, + * `HEAD~2`, ...) to a concrete oid via `rev-parse`, which owns + * the suffix-walking logic. A plain ref is returned untouched so + * the downstream method keeps resolving branch / tag names + * itself. Used by subcommands whose typed methods call + * `resolveRef` directly and so don't understand the suffix + * grammar. + */ +async function resolveRevisionRef( + client: GitClient, + dir: string, + ref: string | undefined, +): Promise { + if (ref === undefined || !hasRevisionSuffix(ref)) return ref; + return client.revParse({ dir, ref }); +} + function isSupportedRemoteUrl(url: string): boolean { return url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://"); } From 75085a84208aebaef0ee72ccac37be94c33e7f87 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:40:07 +0000 Subject: [PATCH 09/20] workspace: add git rev-parse --show-toplevel Scripts find the repository root with `git rev-parse --show-toplevel`, but rev-parse rejected the flag and exposed no way to discover the working-tree root. Add a repoRoot operation that walks up from the working directory until it finds a .git entry and returns that directory, and route `rev-parse --show-toplevel` to it. The walk fails with NotARepositoryError outside a repository, surfacing as exit 128 on the CLI. --- packages/workspace/src/git/cli.test.ts | 30 ++++++++++++++ packages/workspace/src/git/cli.ts | 14 +++++++ packages/workspace/src/git/index.ts | 8 ++++ packages/workspace/src/git/reads.test.ts | 25 ++++++++++++ packages/workspace/src/git/reads.ts | 51 ++++++++++++++++++++++++ 5 files changed, 128 insertions(+) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 3d956c41..7acca61d 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -61,6 +61,7 @@ import type { GitLogOptions, GitLsFilesOptions, GitLsTreeOptions, + GitRepoRootOptions, GitRevParseOptions, GitShowOptions, TreeEntryView, @@ -88,6 +89,7 @@ interface FakeCalls { log: GitLogOptions[]; show: GitShowOptions[]; revParse: GitRevParseOptions[]; + repoRoot: GitRepoRootOptions[]; currentBranch: GitCurrentBranchOptions[]; lsFiles: GitLsFilesOptions[]; lsTree: GitLsTreeOptions[]; @@ -119,6 +121,7 @@ function fakeClient( log?: () => CommitView[]; show?: () => CommitView; revParse?: () => string; + repoRoot?: () => string; currentBranch?: () => string | undefined; lsFiles?: () => string[]; lsTree?: () => TreeEntryView[]; @@ -147,6 +150,7 @@ function fakeClient( log: [], show: [], revParse: [], + repoRoot: [], currentBranch: [], lsFiles: [], lsTree: [], @@ -216,6 +220,10 @@ function fakeClient( calls.revParse.push(options); return fakes.revParse?.() ?? "a".repeat(40); }, + async repoRoot(options = {}) { + calls.repoRoot.push(options); + return fakes.repoRoot?.() ?? "/"; + }, async currentBranch(options = {}) { calls.currentBranch.push(options); return fakes.currentBranch?.(); @@ -988,6 +996,28 @@ describe("runGitCli — show / rev-parse / symbolic-ref", () => { expect(res.stdout).toBe(`${"c".repeat(40)}\n`); }); + it("rev-parse --show-toplevel prints the repo root", async () => { + const { client, calls } = fakeClient({}, { repoRoot: () => "/work/repo" }); + const res = await runGitCli(client, { + argv: ["rev-parse", "--show-toplevel"], + cwd: "/work/repo/sub", + }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe("/work/repo\n"); + expect(calls.repoRoot[0]).toMatchObject({ dir: "/work/repo/sub" }); + }); + + it("rev-parse --show-toplevel maps NotARepositoryError to exit 128", async () => { + const { client } = fakeClient({ + async repoRoot() { + throw new NotARepositoryError("/loose"); + }, + }); + const res = await runGitCli(client, { argv: ["rev-parse", "--show-toplevel"] }); + expect(res.exitCode).toBe(128); + expect(res.stderr).toContain("not a git repository"); + }); + it("symbolic-ref HEAD prints the full ref by default", async () => { const { client, calls } = fakeClient({}, { currentBranch: () => "refs/heads/main" }); const res = await runGitCli(client, { argv: ["symbolic-ref", "HEAD"] }); diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 8eae8f24..9b51953e 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -733,10 +733,24 @@ async function runRevParse( ): Promise { const parsed = parseFlags(args, { "abbrev-ref": { kind: "bool" }, + "show-toplevel": { kind: "bool" }, }); if ("error" in parsed) { return { stdout: "", stderr: `git rev-parse: ${parsed.error}\n`, exitCode: 129 }; } + + if (parsed.flags["show-toplevel"] === true) { + // Print the working-tree root, walking up from cwd. Takes no + // ref, so it short-circuits before the missing-ref check. + const dir = resolveDir(undefined, input.cwd); + try { + const root = await client.repoRoot({ dir }); + return { stdout: `${root}\n`, stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError("rev-parse", cause); + } + } + if (parsed.positional.length === 0) { return { stdout: "", stderr: "git rev-parse: missing \n", exitCode: 129 }; } diff --git a/packages/workspace/src/git/index.ts b/packages/workspace/src/git/index.ts index 6c5c87dc..2b726336 100644 --- a/packages/workspace/src/git/index.ts +++ b/packages/workspace/src/git/index.ts @@ -81,12 +81,14 @@ import { type GitLogOptions, type GitLsFilesOptions, type GitLsTreeOptions, + type GitRepoRootOptions, type GitRevParseOptions, type GitShowOptions, type IsomorphicGitReadsClient, logWith, lsFilesWith, lsTreeWith, + repoRootWith, revParseWith, showWith, type TreeEntryView, @@ -166,6 +168,7 @@ export type { GitLogOptions, GitLsFilesOptions, GitLsTreeOptions, + GitRepoRootOptions, GitRevParseOptions, GitShowOptions, TreeEntryView, @@ -222,6 +225,8 @@ export interface GitClient { show(options: GitShowOptions): Promise; /** Resolve a ref to its SHA-1 oid. */ revParse(options: GitRevParseOptions): Promise; + /** Find the repository root by walking up from `dir`. */ + repoRoot(options?: GitRepoRootOptions): Promise; /** Current branch name, or undefined on detached HEAD. */ currentBranch(options?: GitCurrentBranchOptions): Promise; /** List files in the index (or at a given ref). */ @@ -427,6 +432,9 @@ export function createGitClient({ git: await loadGit(), }); }, + async repoRoot(options = {}) { + return repoRootWith({ ...options, fs: await fs() }); + }, async currentBranch(options = {}) { return currentBranchWith({ ...options, diff --git a/packages/workspace/src/git/reads.test.ts b/packages/workspace/src/git/reads.test.ts index 6f036e9b..ef4f88c0 100644 --- a/packages/workspace/src/git/reads.test.ts +++ b/packages/workspace/src/git/reads.test.ts @@ -15,6 +15,7 @@ import { logWith, lsFilesWith, lsTreeWith, + repoRootWith, revParseWith, showWith, } from "./reads.js"; @@ -159,6 +160,30 @@ describe("currentBranchWith", () => { }); }); +describe("repoRootWith", () => { + beforeEach(() => vol.reset()); + + it("returns the repo root from the root dir itself", async () => { + await init(); + await commit("a.txt", "v1\n", "init"); + expect(await repoRootWith({ fs: memfs, dir: DIR })).toBe(DIR); + }); + + it("finds the repo root from a nested subdirectory", async () => { + await init(); + await commit("a.txt", "v1\n", "init"); + await memfs.promises.mkdir(`${DIR}/deep/nested`, { recursive: true }); + expect(await repoRootWith({ fs: memfs, dir: `${DIR}/deep/nested` })).toBe(DIR); + }); + + it("throws NotARepositoryError outside any repo", async () => { + await memfs.promises.mkdir("/loose", { recursive: true }); + await expect(repoRootWith({ fs: memfs, dir: "/loose" })).rejects.toBeInstanceOf( + NotARepositoryError, + ); + }); +}); + describe("lsFilesWith", () => { beforeEach(() => vol.reset()); diff --git a/packages/workspace/src/git/reads.ts b/packages/workspace/src/git/reads.ts index 63b65a25..740bb934 100644 --- a/packages/workspace/src/git/reads.ts +++ b/packages/workspace/src/git/reads.ts @@ -261,6 +261,57 @@ async function walkParent( return parent; } +// --------------------------------------------------------------- +// repo-root (rev-parse --show-toplevel) +// --------------------------------------------------------------- + +export interface GitRepoRootOptions extends BaseReadOptions {} + +/** Minimal `fs.promises` surface used to probe for `.git`. */ +interface StatFsClient { + promises: { + stat(path: string): Promise; + }; +} + +export interface RepoRootWithDeps extends GitRepoRootOptions { + fs: object; +} + +/** + * Walk upward from `dir` until a `.git` entry is found and return + * that directory — the equivalent of `git rev-parse + * --show-toplevel`. Paths are workspace-absolute and POSIX. Throws + * `NotARepositoryError` when the walk reaches the root without + * finding a `.git`. + */ +export async function repoRootWith(opts: RepoRootWithDeps): Promise { + const start = normalizeAbsolute(opts.dir ?? "/"); + const fs = opts.fs as StatFsClient; + let current = start; + while (true) { + const gitPath = current === "/" ? "/.git" : `${current}/.git`; + try { + await fs.promises.stat(gitPath); + return current; + } catch { + // Not here; climb one level. + } + if (current === "/") break; + const slash = current.lastIndexOf("/"); + current = slash <= 0 ? "/" : current.slice(0, slash); + } + throw new NotARepositoryError(start); +} + +function normalizeAbsolute(p: string): string { + // Collapse a trailing slash (except the root) so the parent + // walk doesn't stall on `/foo/`. + let out = p.startsWith("/") ? p : `/${p}`; + while (out.length > 1 && out.endsWith("/")) out = out.slice(0, -1); + return out; +} + // --------------------------------------------------------------- // current-branch // --------------------------------------------------------------- From c4d289e82dfce24d7b166cc913b311f41424adb4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:44:53 +0000 Subject: [PATCH 10/20] workspace: add git diff --stat, --name-only, --name-status Agents reach for `git diff --stat` to size a change before reading a full patch, and for `--name-only` / `--name-status` to get a changed-file list. All three were rejected as unknown options. Add a diffSummary operation that reuses the existing diff traversal to return per-file status and insertion/deletion counts, sharing the change set with the patch path so the two cannot drift. Route the three flags to it: --name-only prints paths, --name-status prefixes each with its status, and --stat renders a per-file bar with a files-changed summary footer. The flags honor ref-to-ref comparison and revision suffixes like the plain diff does. --- packages/workspace/src/git/cli.test.ts | 80 +++++++++++++++ packages/workspace/src/git/cli.ts | 89 +++++++++++++++- packages/workspace/src/git/diff.test.ts | 58 ++++++++++- packages/workspace/src/git/diff.ts | 130 ++++++++++++++++++------ packages/workspace/src/git/index.ts | 17 +++- 5 files changed, 334 insertions(+), 40 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 7acca61d..e70fc7a1 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -81,6 +81,7 @@ import type { GitStatusOptions, StatusEntry } from "./status.js"; interface FakeCalls { clone: GitCloneOptions[]; diff: GitDiffOptions[]; + diffSummary: GitDiffOptions[]; init: GitInitOptions[]; status: GitStatusOptions[]; add: GitAddOptions[]; @@ -122,6 +123,7 @@ function fakeClient( show?: () => CommitView; revParse?: () => string; repoRoot?: () => string; + diffSummary?: () => import("./diff.js").DiffSummaryEntry[]; currentBranch?: () => string | undefined; lsFiles?: () => string[]; lsTree?: () => TreeEntryView[]; @@ -142,6 +144,7 @@ function fakeClient( const calls: FakeCalls = { clone: [], diff: [], + diffSummary: [], init: [], status: [], add: [], @@ -182,6 +185,10 @@ function fakeClient( calls.diff.push(options); return ""; }, + async diffSummary(options = {}) { + calls.diffSummary.push(options); + return fakes.diffSummary?.() ?? []; + }, async init(options = {}) { calls.init.push(options); }, @@ -611,6 +618,79 @@ describe("runGitCli — diff argv parsing", () => { expect(res.exitCode).toBe(0); expect(res.stdout).toContain("--- a.txt"); }); + + it("--name-only lists changed paths, one per line", async () => { + const { client, calls } = fakeClient( + {}, + { + diffSummary: () => [ + { path: "a.txt", status: "M", insertions: 1, deletions: 1 }, + { path: "b.txt", status: "A", insertions: 2, deletions: 0 }, + ], + }, + ); + const res = await runGitCli(client, { argv: ["diff", "--name-only"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe("a.txt\nb.txt\n"); + expect(calls.diffSummary[0]).toMatchObject({ dir: "/r" }); + expect(calls.diff).toEqual([]); + }); + + it("--name-status prefixes each path with its status", async () => { + const { client } = fakeClient( + {}, + { + diffSummary: () => [ + { path: "a.txt", status: "M", insertions: 1, deletions: 1 }, + { path: "gone.txt", status: "D", insertions: 0, deletions: 3 }, + ], + }, + ); + const res = await runGitCli(client, { argv: ["diff", "--name-status"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe("M\ta.txt\nD\tgone.txt\n"); + }); + + it("--stat summarizes files with insertion/deletion counts", async () => { + const { client } = fakeClient( + {}, + { + diffSummary: () => [ + { path: "a.txt", status: "M", insertions: 3, deletions: 1 }, + { path: "b.txt", status: "A", insertions: 2, deletions: 0 }, + ], + }, + ); + const res = await runGitCli(client, { argv: ["diff", "--stat"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain("a.txt"); + expect(res.stdout).toContain("b.txt"); + // Summary footer: total files changed and line counts. + expect(res.stdout).toContain("2 files changed"); + expect(res.stdout).toContain("5 insertions(+)"); + expect(res.stdout).toContain("1 deletion(-)"); + }); + + it("--stat emits nothing for an empty change set", async () => { + const { client } = fakeClient({}, { diffSummary: () => [] }); + const res = await runGitCli(client, { argv: ["diff", "--stat"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe(""); + }); + + it("--name-only works with ref-to-ref and revision suffixes", async () => { + let n = 0; + const oids = ["a".repeat(40), "b".repeat(40)]; + const { client, calls } = fakeClient( + {}, + { + revParse: () => oids[n++], + diffSummary: () => [{ path: "x", status: "M", insertions: 1, deletions: 0 }], + }, + ); + await runGitCli(client, { argv: ["diff", "--name-only", "HEAD~2", "HEAD~1"], cwd: "/r" }); + expect(calls.diffSummary[0]).toMatchObject({ ref: "a".repeat(40), to: "b".repeat(40) }); + }); }); describe("runGitCli — init argv parsing", () => { diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 9b51953e..93ebd45e 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -308,13 +308,21 @@ async function runDiff( args: string[], input: GitCliInput, ): Promise { - // `git diff [ | ] [-- ...]`. Two refs - // before `--` switch to ref-to-ref mode; paths after `--` - // filter the output. - const parsed = parseFlags(args, {}); + // `git diff [--stat|--name-only|--name-status] [ | + // ] [-- ...]`. Two refs before `--` switch to + // ref-to-ref mode; paths after `--` filter the output. The + // summary flags swap the unified patch for a per-file summary. + const parsed = parseFlags(args, { + stat: { kind: "bool" }, + "name-only": { kind: "bool" }, + "name-status": { kind: "bool" }, + }); if ("error" in parsed) { return { stdout: "", stderr: `git diff: ${parsed.error}\n`, exitCode: 129 }; } + const wantStat = parsed.flags.stat === true; + const wantNameOnly = parsed.flags["name-only"] === true; + const wantNameStatus = parsed.flags["name-status"] === true; // Split positional on '--' — anything after is a path filter. // The parser already consumes '--' and treats the rest as // positional, so we need to remember where it was. Rebuild @@ -336,11 +344,28 @@ async function runDiff( try { const fromResolved = await resolveRevisionRef(client, dir, from); const toResolved = await resolveRevisionRef(client, dir, to); + const paths = pathArgs.length > 0 ? pathArgs : undefined; + + if (wantStat || wantNameOnly || wantNameStatus) { + const summary = await client.diffSummary({ + dir, + ref: fromResolved, + to: toResolved, + paths, + }); + const stdout = wantNameOnly + ? formatDiffNameOnly(summary) + : wantNameStatus + ? formatDiffNameStatus(summary) + : formatDiffStat(summary); + return { stdout, stderr: "", exitCode: 0 }; + } + const output = await client.diff({ dir, ref: fromResolved, to: toResolved, - paths: pathArgs.length > 0 ? pathArgs : undefined, + paths, }); return { stdout: output, stderr: "", exitCode: 0 }; } catch (cause) { @@ -348,6 +373,60 @@ async function runDiff( } } +type DiffSummary = import("./diff.js").DiffSummaryEntry; + +/** `--name-only`: one changed path per line. */ +function formatDiffNameOnly(entries: DiffSummary[]): string { + if (entries.length === 0) return ""; + return `${entries.map((e) => e.path).join("\n")}\n`; +} + +/** `--name-status`: `\t` per line. */ +function formatDiffNameStatus(entries: DiffSummary[]): string { + if (entries.length === 0) return ""; + return `${entries.map((e) => `${e.status}\t${e.path}`).join("\n")}\n`; +} + +/** + * `--stat`: a per-file line with a `+`/`-` bar plus a summary + * footer. The graph is scaled-down only when the widest file's + * total exceeds the column budget, mirroring real git closely + * enough for a human to read and a script to grep the footer. + */ +function formatDiffStat(entries: DiffSummary[]): string { + if (entries.length === 0) return ""; + const nameWidth = Math.max(...entries.map((e) => e.path.length)); + const maxTotal = Math.max(...entries.map((e) => e.insertions + e.deletions)); + // Cap the bar at 60 columns the way git's default terminal + // width does; scale proportionally when any file exceeds it. + const budget = 60; + const scale = maxTotal > budget ? budget / maxTotal : 1; + + const lines: string[] = []; + let totalIns = 0; + let totalDel = 0; + for (const e of entries) { + totalIns += e.insertions; + totalDel += e.deletions; + const total = e.insertions + e.deletions; + const plus = Math.round(e.insertions * scale); + const minus = Math.round(e.deletions * scale); + const bar = `${"+".repeat(plus)}${"-".repeat(minus)}`; + lines.push(` ${e.path.padEnd(nameWidth)} | ${String(total).padStart(4)} ${bar}`); + } + + const fileWord = entries.length === 1 ? "file" : "files"; + const parts = [`${entries.length} ${fileWord} changed`]; + if (totalIns > 0) { + parts.push(`${totalIns} ${totalIns === 1 ? "insertion(+)" : "insertions(+)"}`); + } + if (totalDel > 0) { + parts.push(`${totalDel} ${totalDel === 1 ? "deletion(-)" : "deletions(-)"}`); + } + lines.push(` ${parts.join(", ")}`); + return `${lines.join("\n")}\n`; +} + // --------------------------------------------------------------- // init // --------------------------------------------------------------- diff --git a/packages/workspace/src/git/diff.test.ts b/packages/workspace/src/git/diff.test.ts index 3eeab9d6..6f828d81 100644 --- a/packages/workspace/src/git/diff.test.ts +++ b/packages/workspace/src/git/diff.test.ts @@ -16,7 +16,7 @@ import git from "isomorphic-git"; import { fs as memfs, vol } from "memfs"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { diffWith, type IsomorphicGitDiffClient } from "./diff.js"; +import { diffSummaryWith, diffWith, type IsomorphicGitDiffClient } from "./diff.js"; const DIR = "/repo"; const AUTHOR = { name: "test", email: "test@example.test" }; @@ -259,3 +259,59 @@ describe("diffWith ref-to-ref and path filtering", () => { expect(out).not.toContain("--- top.txt"); }); }); + +describe("diffSummaryWith (real isomorphic-git + memfs)", () => { + beforeEach(() => vol.reset()); + + function summary(opts: { ref?: string; to?: string; paths?: string[] } = {}) { + return diffSummaryWith({ + git: isomorphicGit, + fs: memfs, + createPatch, + readFile: (path) => memfs.promises.readFile(path) as Promise, + dir: DIR, + ...opts, + }); + } + + it("returns an empty list for a clean working tree", async () => { + await init(); + await commitFile("a.txt", "hello\n", "init"); + expect(await summary()).toEqual([]); + }); + + it("reports a modified file with insertion / deletion counts", async () => { + await init(); + await commitFile("a.txt", "one\ntwo\n", "init"); + await memfs.promises.writeFile(`${DIR}/a.txt`, "one\ntwo\nthree\n"); + const entries = await summary(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ path: "a.txt", status: "M", insertions: 1, deletions: 0 }); + }); + + it("reports an added file", async () => { + await init(); + await commitFile("a.txt", "kept\n", "init"); + await memfs.promises.writeFile(`${DIR}/b.txt`, "new1\nnew2\n"); + const entries = await summary(); + expect(entries).toEqual([{ path: "b.txt", status: "A", insertions: 2, deletions: 0 }]); + }); + + it("reports a deleted file", async () => { + await init(); + await commitFile("gone.txt", "a\nb\n", "init"); + await memfs.promises.unlink(`${DIR}/gone.txt`); + const entries = await summary(); + expect(entries).toEqual([{ path: "gone.txt", status: "D", insertions: 0, deletions: 2 }]); + }); + + it("reports added and deleted files between two commits", async () => { + await init(); + const first = await commitFile("keep.txt", "keep\n", "v1"); + await memfs.promises.writeFile(`${DIR}/new.txt`, "x\n"); + await git.add({ fs: memfs, dir: DIR, filepath: "new.txt" }); + const second = await git.commit({ fs: memfs, dir: DIR, message: "add", author: AUTHOR }); + const entries = await summary({ ref: first, to: second }); + expect(entries).toEqual([{ path: "new.txt", status: "A", insertions: 1, deletions: 0 }]); + }); +}); diff --git a/packages/workspace/src/git/diff.ts b/packages/workspace/src/git/diff.ts index 1a553176..36a97d48 100644 --- a/packages/workspace/src/git/diff.ts +++ b/packages/workspace/src/git/diff.ts @@ -90,25 +90,74 @@ export interface DiffWithDeps extends GitDiffOptions { cache?: object; } +/** + * One changed path with both endpoints' text. The shared + * collector below produces these; `diffWith` renders them into a + * patch and `diffSummaryWith` counts lines off them, so the two + * surfaces walk identical change sets. + */ +interface DiffEntry { + path: string; + /** Single-char status: 'A' added, 'M' modified, 'D' deleted. */ + status: "A" | "M" | "D"; + /** Text on the "from" side ("" when absent / added). */ + oldText: string; + /** Text on the "to" side ("" when absent / deleted). */ + newText: string; +} + +/** Per-file change summary for `--stat` / `--name-status`. */ +export interface DiffSummaryEntry { + path: string; + status: "A" | "M" | "D"; + insertions: number; + deletions: number; +} + export async function diffWith(opts: DiffWithDeps): Promise { + const entries = await collectDiffEntries(opts); + const chunks: string[] = []; + for (const e of entries) { + const patch = opts.createPatch(e.path, e.oldText, e.newText, "", ""); + if (patch.trim().length > 0) chunks.push(patch); + } + return chunks.join("\n"); +} + +/** + * Per-file summary of the same change set `diffWith` renders, + * with insertion / deletion line counts derived from the patch. + * Backs `git diff --stat` / `--name-only` / `--name-status`. + */ +export async function diffSummaryWith(opts: DiffWithDeps): Promise { + const entries = await collectDiffEntries(opts); + return entries.map((e) => { + const { insertions, deletions } = countChanges( + opts.createPatch(e.path, e.oldText, e.newText, "", ""), + ); + return { path: e.path, status: e.status, insertions, deletions }; + }); +} + +// Shared traversal. Working-tree mode walks the status matrix; +// ref-to-ref mode walks the union of both trees' files. Both +// yield `DiffEntry`s with each side's text resolved. +async function collectDiffEntries(opts: DiffWithDeps): Promise { const dir = opts.dir ?? "/"; const ref = opts.ref ?? "HEAD"; - // Ref-to-ref diff: both endpoints are committed states, read - // through readBlob. The status-matrix walk is the wrong tool - // here — it always anchors against the working tree. if (opts.to !== undefined) { - return diffRefToRef(opts, dir, ref, opts.to); + return collectRefToRef(opts, dir, ref, opts.to); } let head: string; try { head = await opts.git.resolveRef({ fs: opts.fs, dir, ref }); } catch { - // Ref unresolvable (e.g. workspace never cloned). Empty - // string is a more useful signal than an exception for the - // common "diff after maybe-no-op" call site. - return ""; + // Ref unresolvable (e.g. workspace never cloned). An empty + // change set is a more useful signal than an exception for + // the common "diff after maybe-no-op" call site. + return []; } // Pass `ref` through so the matrix is computed against the @@ -117,47 +166,45 @@ export async function diffWith(opts: DiffWithDeps): Promise { // status walk silently skewed. const status = await opts.git.statusMatrix({ fs: opts.fs, dir, ref, cache: opts.cache }); const pathFilter = makePathFilter(opts.paths); - const chunks: string[] = []; + const entries: DiffEntry[] = []; for (const [filepath, headStatus, workdirStatus] of status) { // workdirStatus: 0 absent, 1 == HEAD, 2 differs. Skip // unchanged rows up front to avoid the blob/file reads. if (workdirStatus === 1) continue; if (!pathFilter(filepath)) continue; - const headText = + const oldText = headStatus === 1 ? await readBlobAsText(opts.git, opts.fs, dir, head, filepath, opts.cache) : ""; - const workdirText = + const newText = workdirStatus === 2 ? await readWorkdirAsText(opts.readFile, dir, filepath) : ""; - const patch = opts.createPatch(filepath, headText, workdirText, "", ""); - if (patch.trim().length > 0) chunks.push(patch); + // headStatus 0 -> not in the base -> added. workdirStatus 0 + // -> gone from the working tree -> deleted. Otherwise it's a + // content change. + const status: DiffEntry["status"] = headStatus === 0 ? "A" : workdirStatus === 0 ? "D" : "M"; + entries.push({ path: filepath, status, oldText, newText }); } - return chunks.join("\n"); + return entries; } -// Ref-to-ref diff. Walk the union of paths from each side's -// statusMatrix-against-HEAD output — that gives us a path list -// that's a superset of both trees — then resolve each path's -// blob in both commits and emit a patch when they differ. -async function diffRefToRef( +// Ref-to-ref collector. Walk the union of both commits' file +// lists — git's own object database, no working-tree probes — +// and resolve each path's blob in both commits. +async function collectRefToRef( opts: DiffWithDeps, dir: string, from: string, to: string, -): Promise { +): Promise { let fromOid: string; let toOid: string; try { fromOid = await opts.git.resolveRef({ fs: opts.fs, dir, ref: from }); toOid = await opts.git.resolveRef({ fs: opts.fs, dir, ref: to }); } catch { - return ""; + return []; } - // Listing files via listFiles({ref}) keeps the walk inside - // git's own object database — no working-tree probes. That's - // the contract for ref-to-ref: nothing on disk influences the - // result. const fromFiles = new Set( await listFilesAt(opts.git as unknown as IsomorphicGitDiffWithListFiles, opts.fs, dir, from), ); @@ -167,20 +214,37 @@ async function diffRefToRef( const union = new Set([...fromFiles, ...toFiles]); const pathFilter = makePathFilter(opts.paths); - const chunks: string[] = []; + const entries: DiffEntry[] = []; for (const filepath of [...union].sort()) { if (!pathFilter(filepath)) continue; - const a = fromFiles.has(filepath) + const inFrom = fromFiles.has(filepath); + const inTo = toFiles.has(filepath); + const a = inFrom ? await readBlobAsText(opts.git, opts.fs, dir, fromOid, filepath, opts.cache) : ""; - const b = toFiles.has(filepath) - ? await readBlobAsText(opts.git, opts.fs, dir, toOid, filepath, opts.cache) - : ""; + const b = inTo ? await readBlobAsText(opts.git, opts.fs, dir, toOid, filepath, opts.cache) : ""; if (a === b) continue; - const patch = opts.createPatch(filepath, a, b, "", ""); - if (patch.trim().length > 0) chunks.push(patch); + const status: DiffEntry["status"] = !inFrom ? "A" : !inTo ? "D" : "M"; + entries.push({ path: filepath, status, oldText: a, newText: b }); } - return chunks.join("\n"); + return entries; +} + +/** + * Count added / removed content lines in a unified patch. Skips + * the `+++` / `---` file headers; everything else prefixed `+` or + * `-` is a content line. Good enough for `--stat`'s numeric + * column, which is all the CLI needs. + */ +function countChanges(patch: string): { insertions: number; deletions: number } { + let insertions = 0; + let deletions = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("+++") || line.startsWith("---")) continue; + if (line.startsWith("+")) insertions++; + else if (line.startsWith("-")) deletions++; + } + return { insertions, deletions }; } interface IsomorphicGitDiffWithListFiles extends IsomorphicGitDiffClient { diff --git a/packages/workspace/src/git/index.ts b/packages/workspace/src/git/index.ts index 2b726336..7984bf2a 100644 --- a/packages/workspace/src/git/index.ts +++ b/packages/workspace/src/git/index.ts @@ -33,6 +33,8 @@ import { } from "./commit.js"; import { type CreatePatchFn, + type DiffSummaryEntry, + diffSummaryWith, diffWith, type GitDiffOptions, type IsomorphicGitDiffClient, @@ -128,7 +130,7 @@ import { export type { GitCliInput, GitCliResult } from "./cli.js"; export type { GitCloneOptions, MessageCallback, ProgressCallback } from "./clone.js"; export type { CommitResult, GitCommitOptions } from "./commit.js"; -export type { GitDiffOptions, StatusRow } from "./diff.js"; +export type { DiffSummaryEntry, GitDiffOptions, StatusRow } from "./diff.js"; export { AlreadyInitializedError, GitError, @@ -209,6 +211,8 @@ export interface GitClient { clone(options: GitCloneOptions): Promise; /** Unified diff between a ref (default HEAD) and the working tree. */ diff(options?: GitDiffOptions): Promise; + /** Per-file change summary backing `--stat` / `--name-only` / `--name-status`. */ + diffSummary(options?: GitDiffOptions): Promise; /** Initialise a new repository in the bound workspace. */ init(options?: GitInitOptions): Promise; /** Describe the working-tree / index / HEAD delta. */ @@ -369,6 +373,17 @@ export function createGitClient({ cache, }); }, + async diffSummary(options = {}) { + const f = await fs(); + return diffSummaryWith({ + ...options, + fs: f, + git: await loadGit(), + createPatch: await loadDiffPatch(), + readFile: readFileFrom(f), + cache, + }); + }, async init(options = {}) { await initWith({ ...options, From f2507714c669a82255604bcc1ad506b2d0ae66a4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:46:37 +0000 Subject: [PATCH 11/20] workspace: read commit identity from local config `git config user.name` and `user.email` wrote to the local config, but commit never read them back: identity resolved only from an explicit author, then the GIT_AUTHOR_* env, then the GitClient default. Configuring identity the way real git documents had no effect. Read user.name / user.email from the local repo config and slot them between the environment and the GitClient default in the precedence chain. An explicit author still wins and the environment still overrides config, matching real git's config-after-env order. --- packages/workspace/src/git/commit.test.ts | 44 ++++++++++++++++++++++- packages/workspace/src/git/commit.ts | 35 ++++++++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/workspace/src/git/commit.test.ts b/packages/workspace/src/git/commit.test.ts index cae3c9e9..22338633 100644 --- a/packages/workspace/src/git/commit.test.ts +++ b/packages/workspace/src/git/commit.test.ts @@ -64,7 +64,49 @@ describe("commitWith identity resolution", () => { expect(head.author).toMatchObject({ name: "Bob", email: "b@x" }); }); - it("falls back to defaultIdentity when env is absent", async () => { + it("reads user.name / user.email from local config when env is absent", async () => { + await init(); + await git.setConfig({ fs: memfs, dir: DIR, path: "user.name", value: "Config User" }); + await git.setConfig({ fs: memfs, dir: DIR, path: "user.email", value: "cfg@x" }); + await stage("a.txt", "hi\n"); + await commitWith({ git: isogit, fs: memfs, dir: DIR, message: "init" }); + const head = await readHead(); + expect(head.author).toMatchObject({ name: "Config User", email: "cfg@x" }); + }); + + it("prefers env over local config", async () => { + await init(); + await git.setConfig({ fs: memfs, dir: DIR, path: "user.name", value: "Config User" }); + await git.setConfig({ fs: memfs, dir: DIR, path: "user.email", value: "cfg@x" }); + await stage("a.txt", "hi\n"); + await commitWith({ + git: isogit, + fs: memfs, + dir: DIR, + message: "init", + env: { GIT_AUTHOR_NAME: "Env User", GIT_AUTHOR_EMAIL: "env@x" }, + }); + const head = await readHead(); + expect(head.author).toMatchObject({ name: "Env User", email: "env@x" }); + }); + + it("prefers local config over defaultIdentity", async () => { + await init(); + await git.setConfig({ fs: memfs, dir: DIR, path: "user.name", value: "Config User" }); + await git.setConfig({ fs: memfs, dir: DIR, path: "user.email", value: "cfg@x" }); + await stage("a.txt", "hi\n"); + await commitWith({ + git: isogit, + fs: memfs, + dir: DIR, + message: "init", + defaultIdentity: { name: "Default", email: "d@x" }, + }); + const head = await readHead(); + expect(head.author).toMatchObject({ name: "Config User", email: "cfg@x" }); + }); + + it("falls back to defaultIdentity when env and config are absent", async () => { await init(); await stage("a.txt", "hi\n"); await commitWith({ diff --git a/packages/workspace/src/git/commit.ts b/packages/workspace/src/git/commit.ts index 193a74df..5a541b90 100644 --- a/packages/workspace/src/git/commit.ts +++ b/packages/workspace/src/git/commit.ts @@ -5,9 +5,10 @@ // // 1. options.author / options.committer (call-site explicit) // 2. options.env's GIT_AUTHOR_* / GIT_COMMITTER_* if present -// 3. options.defaultIdentity (the GitClient-level default) +// 3. local repo config: user.name / user.email +// 4. options.defaultIdentity (the GitClient-level default) // -// If none of the three yield a name+email, throw +// If none of the four yield a name+email, throw // MissingIdentityError. The CLI dispatcher maps that to exit 128 // with a stderr line matching real git's wording. @@ -39,6 +40,8 @@ export interface IsomorphicGitCommitClient { amend?: boolean; cache?: object; }): Promise; + /** Read a single config value; used to resolve identity. */ + getConfig(args: { fs: object; dir: string; path: string }): Promise; } export interface GitCommitOptions { @@ -79,10 +82,19 @@ export async function commitWith(opts: CommitWithDeps): Promise { throw new GitError("EMSG", "commit message is required"); } + // Read the local config identity once; it sits between env and + // the GitClient default in precedence. A missing key resolves + // to undefined, so the fallback chain skips it cleanly. + const configName = await readConfigString(opts, dir, "user.name"); + const configEmail = await readConfigString(opts, dir, "user.email"); + const configIdent = + configName && configEmail ? { name: configName, email: configEmail } : undefined; + const author = resolveIdent( opts.author, opts.env?.GIT_AUTHOR_NAME, opts.env?.GIT_AUTHOR_EMAIL, + configIdent, opts.defaultIdentity, ); if (!author) throw new MissingIdentityError(); @@ -91,6 +103,7 @@ export async function commitWith(opts: CommitWithDeps): Promise { opts.committer, opts.env?.GIT_COMMITTER_NAME ?? opts.env?.GIT_AUTHOR_NAME, opts.env?.GIT_COMMITTER_EMAIL ?? opts.env?.GIT_AUTHOR_EMAIL, + configIdent, opts.defaultIdentity, ) ?? author; @@ -116,14 +129,32 @@ function resolveIdent( explicit: { name: string; email: string } | undefined, envName: string | undefined, envEmail: string | undefined, + config: { name: string; email: string } | undefined, fallback: { name: string; email: string } | undefined, ): { name: string; email: string } | undefined { if (explicit?.name && explicit.email) return explicit; if (envName && envEmail) return { name: envName, email: envEmail }; + if (config?.name && config.email) return config; if (fallback?.name && fallback.email) return fallback; return undefined; } +async function readConfigString( + opts: CommitWithDeps, + dir: string, + path: string, +): Promise { + try { + const value = await opts.git.getConfig({ fs: opts.fs, dir, path }); + return typeof value === "string" && value.length > 0 ? value : undefined; + } catch { + // A missing config file or unreadable key is not an error + // here; identity resolution just falls through to the next + // source. + return undefined; + } +} + function errorMessage(cause: unknown): string { if (cause instanceof Error) return cause.message; return String(cause); From 0b0e9d4e5f7771232ae944c098ac335ca7fea46f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:49:06 +0000 Subject: [PATCH 12/20] workspace: support git add -A / --all Agents stage everything with `git add -A` before committing, but add accepted only explicit pathspecs and rejected the bare flag. Add an all mode that walks the status matrix and stages every change: new and modified paths through add, worktree deletions through remove, which add alone cannot express. Wire `-A` and `--all` to it; the flag needs no pathspec and ignores any that are passed, matching real git. --- packages/workspace/src/git/cli.test.ts | 2 +- packages/workspace/src/git/cli.ts | 15 ++++- packages/workspace/src/git/staging.test.ts | 30 ++++++++++ packages/workspace/src/git/staging.ts | 66 ++++++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index e70fc7a1..98d3d729 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -831,7 +831,7 @@ describe("runGitCli — add argv parsing", () => { it("passes positional pathspecs through", async () => { const { client, calls } = fakeClient(); await runGitCli(client, { argv: ["add", "a.txt", "b.txt"], cwd: "/r" }); - expect(calls.add).toEqual([{ dir: "/r", paths: ["a.txt", "b.txt"], force: false }]); + expect(calls.add).toEqual([{ dir: "/r", paths: ["a.txt", "b.txt"], all: false, force: false }]); }); it("--force / -f flips the option", async () => { diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 93ebd45e..3cb0018e 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -532,19 +532,28 @@ async function runAdd( args: string[], input: GitCliInput, ): Promise { - // `git add [--force] ...` + // `git add [-A|--all] [--force] ...` const parsed = parseFlags(args, { force: { kind: "bool", alias: ["f"] }, + all: { kind: "bool", alias: ["A"] }, }); if ("error" in parsed) { return { stdout: "", stderr: `git add: ${parsed.error}\n`, exitCode: 129 }; } - if (parsed.positional.length === 0) { + const all = parsed.flags.all === true; + // `-A` stages the whole tree and needs no pathspec; without it a + // missing pathspec is the same no-op error real git prints. + if (!all && parsed.positional.length === 0) { return { stdout: "", stderr: "git add: nothing specified, nothing added.\n", exitCode: 129 }; } const dir = resolveDir(undefined, input.cwd); try { - await client.add({ dir, paths: parsed.positional, force: parsed.flags.force === true }); + await client.add({ + dir, + paths: all ? [] : parsed.positional, + all, + force: parsed.flags.force === true, + }); } catch (cause) { return mapGitError("add", cause); } diff --git a/packages/workspace/src/git/staging.test.ts b/packages/workspace/src/git/staging.test.ts index 1b0446d9..0ba6a769 100644 --- a/packages/workspace/src/git/staging.test.ts +++ b/packages/workspace/src/git/staging.test.ts @@ -70,6 +70,36 @@ describe("addWith", () => { // a.txt remains untracked: head=0, workdir=2, stage=0. expect(await statusOf("a.txt")).toEqual([0, 2, 0]); }); + + it("all: true stages new, modified, and deleted paths", async () => { + await init(); + // Commit a baseline with two files. + await memfs.promises.writeFile(`${DIR}/keep.txt`, "k1\n"); + await memfs.promises.writeFile(`${DIR}/gone.txt`, "g1\n"); + await git.add({ fs: memfs, dir: DIR, filepath: "keep.txt" }); + await git.add({ fs: memfs, dir: DIR, filepath: "gone.txt" }); + await git.commit({ fs: memfs, dir: DIR, message: "init", author: AUTHOR }); + + // Modify one, delete one, add one new untracked file. + await memfs.promises.writeFile(`${DIR}/keep.txt`, "k2 changed\n"); + await memfs.promises.unlink(`${DIR}/gone.txt`); + await memfs.promises.writeFile(`${DIR}/new.txt`, "n1\n"); + + await addWith({ + git: git as unknown as IsomorphicGitAddClient, + fs: memfs, + dir: DIR, + paths: [], + all: true, + }); + + // Modified file staged: workdir == stage. + expect(await statusOf("keep.txt")).toEqual([1, 2, 2]); + // New file staged. + expect(await statusOf("new.txt")).toEqual([0, 2, 2]); + // Deleted file unstaged from the index: stage=0. + expect(await statusOf("gone.txt")).toEqual([1, 0, 0]); + }); }); describe("rmWith", () => { diff --git a/packages/workspace/src/git/staging.ts b/packages/workspace/src/git/staging.ts index 17d4f9f9..47ed3862 100644 --- a/packages/workspace/src/git/staging.ts +++ b/packages/workspace/src/git/staging.ts @@ -21,6 +21,14 @@ export interface IsomorphicGitAddClient { cache?: object; force?: boolean; }): Promise; + /** Used by `all` mode to enumerate changed paths. */ + statusMatrix(args: { + fs: object; + dir: string; + cache?: object; + }): Promise>; + /** Used by `all` mode to stage deletions. */ + remove(args: { fs: object; dir: string; filepath: string; cache?: object }): Promise; } /** Subset of `isomorphic-git`'s API used for `rm`. */ @@ -45,6 +53,13 @@ export interface GitAddOptions { * override. */ force?: boolean; + /** + * Stage every change under the repository — new, modified, and + * deleted tracked files — the way `git add -A` / `--all` does. + * When set, `paths` is ignored. Deletions are staged through + * `remove`, which `add` alone cannot express. + */ + all?: boolean; } export interface AddWithDeps extends GitAddOptions { @@ -55,6 +70,9 @@ export interface AddWithDeps extends GitAddOptions { export async function addWith(opts: AddWithDeps): Promise { const dir = opts.dir ?? "/"; + if (opts.all) { + return addAll(opts, dir); + } if (opts.paths.length === 0) return; try { // isomorphic-git 1.27+ accepts an array; older versions only @@ -76,6 +94,54 @@ export async function addWith(opts: AddWithDeps): Promise { } } +/** + * Stage every working-tree change. Walks the status matrix and + * splits the work: present-but-changed paths go through `add`, + * worktree-deleted paths go through `remove` (which `add` cannot + * express). The status-matrix tuple is `[path, head, workdir, + * stage]`; `workdir === 0` means the file is gone from disk. + */ +async function addAll(opts: AddWithDeps, dir: string): Promise { + let matrix: Array<[string, number, number, number]>; + try { + matrix = await opts.git.statusMatrix({ fs: opts.fs, dir, cache: opts.cache }); + } catch (cause) { + if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); + throw new GitError("EADDFAIL", `git add failed: ${errorMessage(cause)}`, { cause }); + } + + const toAdd: string[] = []; + const toRemove: string[] = []; + for (const [filepath, head, workdir, stage] of matrix) { + if (workdir === 0) { + // Gone from the working tree. Only stage the deletion when + // it isn't already staged (head present, stage present). + if (head === 1 && stage !== 0) toRemove.push(filepath); + continue; + } + // Present on disk and differs from the staged copy. + if (workdir !== 1 || stage !== 1) toAdd.push(filepath); + } + + try { + if (toAdd.length > 0) { + await opts.git.add({ + fs: opts.fs, + dir, + filepath: toAdd, + cache: opts.cache, + force: opts.force, + }); + } + for (const filepath of toRemove) { + await opts.git.remove({ fs: opts.fs, dir, filepath, cache: opts.cache }); + } + } catch (cause) { + if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); + throw new GitError("EADDFAIL", `git add failed: ${errorMessage(cause)}`, { cause }); + } +} + export interface GitRmOptions { /** Working-tree directory inside the VFS. Defaults to `/`. */ dir?: string; From 34025cb4ef691f9f6782c5eb9afa4f420a4a7402 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:11:45 +0000 Subject: [PATCH 13/20] workspace: support git commit -a / -am Scripts stage and commit tracked changes in one step with `git commit -am`, but commit took no `-a` flag and the parser could not split the combined `-am` cluster. Stage tracked modifications and deletions through the add all path with a trackedOnly restriction that skips untracked files, then commit. Expand the `-am` short cluster into `-a -m` before parsing, leaving `-ma` for the parser since real git reads that as `-m` with value `a`. A staging failure aborts before the commit runs. --- packages/workspace/src/git/cli.test.ts | 33 ++++++++++++++++++++ packages/workspace/src/git/cli.ts | 36 ++++++++++++++++++++-- packages/workspace/src/git/staging.test.ts | 29 +++++++++++++++++ packages/workspace/src/git/staging.ts | 10 ++++++ 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 98d3d729..25048f4e 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -921,6 +921,39 @@ describe("runGitCli — commit argv parsing", () => { expect(calls.commit[0].amend).toBe(true); }); + it("-a stages tracked changes before committing", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["commit", "-a", "-m", "x"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.add).toEqual([{ dir: "/r", paths: [], all: true, trackedOnly: true }]); + expect(calls.commit).toHaveLength(1); + }); + + it("-am combines -a and -m", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["commit", "-am", "msg"] }); + expect(res.exitCode).toBe(0); + expect(calls.add[0]).toMatchObject({ all: true, trackedOnly: true }); + expect(calls.commit[0]).toMatchObject({ message: "msg" }); + }); + + it("commit without -a does not stage", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { argv: ["commit", "-m", "x"] }); + expect(calls.add).toEqual([]); + }); + + it("-a propagates a staging failure and does not commit", async () => { + const { client, calls } = fakeClient({ + async add() { + throw new NotARepositoryError("/r"); + }, + }); + const res = await runGitCli(client, { argv: ["commit", "-a", "-m", "x"] }); + expect(res.exitCode).toBe(128); + expect(calls.commit).toEqual([]); + }); + it("MissingIdentityError maps to exit 128", async () => { const { client } = fakeClient({ async commit() { diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 3cb0018e..755c4224 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -601,11 +601,17 @@ async function runCommit( args: string[], input: GitCliInput, ): Promise { - // `git commit -m [--amend] [--author="Name "]` - const parsed = parseFlags(args, { + // `git commit [-a] -m [--amend] [--author="Name "]` + // + // Expand a combined short cluster like `-am` into `-a -m` + // first; the generic parser treats `-am` as one unknown short + // option. Only the `-a`/`-m` combination matters here. + const expanded = expandCommitShortCluster(args); + const parsed = parseFlags(expanded, { message: { kind: "value", alias: ["m"] }, amend: { kind: "bool" }, author: { kind: "value" }, + all: { kind: "bool", alias: ["a"] }, }); if ("error" in parsed) { return { stdout: "", stderr: `git commit: ${parsed.error}\n`, exitCode: 129 }; @@ -640,6 +646,12 @@ async function runCommit( // Identity resolution happens inside commitWith via the typed // surface; mirror the same env shape here. try { + // `-a` stages tracked modifications and deletions (never + // untracked files) before the commit, matching `git commit + // -a`. A staging failure aborts before the commit runs. + if (parsed.flags.all === true) { + await client.add({ dir, paths: [], all: true, trackedOnly: true }); + } const { oid } = await client.commit({ dir, message, @@ -659,6 +671,26 @@ async function runCommit( } } +/** + * Expand the `-a`/`-m` short cluster (`-am`) into separate + * tokens (`-a -m`). `-m` takes a value, so it must be last in the + * cluster — only `-a…m` is expanded, leaving the message value to + * follow as the next argv token. `-ma` is left untouched: real + * git reads that as `-m` with the value `a`, and the generic + * parser handles it. + */ +function expandCommitShortCluster(args: string[]): string[] { + const out: string[] = []; + for (const arg of args) { + if (/^-a+m$/.test(arg)) { + for (const ch of arg.slice(1)) out.push(`-${ch}`); + continue; + } + out.push(arg); + } + return out; +} + function parseAuthorString(s: string): { name: string; email: string } | undefined { // `Name ` — the same shape `git -c user.email=...` // and `--author` accept. Anything else is rejected; the CLI diff --git a/packages/workspace/src/git/staging.test.ts b/packages/workspace/src/git/staging.test.ts index 0ba6a769..cdaf9e92 100644 --- a/packages/workspace/src/git/staging.test.ts +++ b/packages/workspace/src/git/staging.test.ts @@ -100,6 +100,35 @@ describe("addWith", () => { // Deleted file unstaged from the index: stage=0. expect(await statusOf("gone.txt")).toEqual([1, 0, 0]); }); + + it("all + trackedOnly stages tracked changes but leaves untracked files alone", async () => { + await init(); + await memfs.promises.writeFile(`${DIR}/keep.txt`, "k1\n"); + await memfs.promises.writeFile(`${DIR}/gone.txt`, "g1\n"); + await git.add({ fs: memfs, dir: DIR, filepath: "keep.txt" }); + await git.add({ fs: memfs, dir: DIR, filepath: "gone.txt" }); + await git.commit({ fs: memfs, dir: DIR, message: "init", author: AUTHOR }); + + await memfs.promises.writeFile(`${DIR}/keep.txt`, "k2 changed\n"); + await memfs.promises.unlink(`${DIR}/gone.txt`); + await memfs.promises.writeFile(`${DIR}/new.txt`, "n1\n"); + + await addWith({ + git: git as unknown as IsomorphicGitAddClient, + fs: memfs, + dir: DIR, + paths: [], + all: true, + trackedOnly: true, + }); + + // Tracked modification staged. + expect(await statusOf("keep.txt")).toEqual([1, 2, 2]); + // Tracked deletion staged. + expect(await statusOf("gone.txt")).toEqual([1, 0, 0]); + // Untracked file left unstaged: head=0, workdir=2, stage=0. + expect(await statusOf("new.txt")).toEqual([0, 2, 0]); + }); }); describe("rmWith", () => { diff --git a/packages/workspace/src/git/staging.ts b/packages/workspace/src/git/staging.ts index 47ed3862..c1515fc4 100644 --- a/packages/workspace/src/git/staging.ts +++ b/packages/workspace/src/git/staging.ts @@ -60,6 +60,13 @@ export interface GitAddOptions { * `remove`, which `add` alone cannot express. */ all?: boolean; + /** + * Restrict `all` mode to paths already tracked in HEAD — the + * `git commit -a` semantics, which stage modifications and + * deletions but never add untracked files. Ignored unless + * `all` is set. + */ + trackedOnly?: boolean; } export interface AddWithDeps extends GitAddOptions { @@ -113,6 +120,9 @@ async function addAll(opts: AddWithDeps, dir: string): Promise { const toAdd: string[] = []; const toRemove: string[] = []; for (const [filepath, head, workdir, stage] of matrix) { + // `commit -a` semantics: only touch paths already in HEAD, + // so untracked files (head === 0) are left alone. + if (opts.trackedOnly && head !== 1) continue; if (workdir === 0) { // Gone from the working tree. Only stage the deletion when // it isn't already staged (head present, stage present). From b11421d8448c83258c7e58c0bb8e251fe4d66814 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:14:14 +0000 Subject: [PATCH 14/20] workspace: add checkout -b and switch Agents create a branch and switch to it in one step with `git checkout -b` or `git switch -c`. checkout had no -b shortcut and switch was not a recognized command at all. Add -b to checkout and a switch subcommand with -c, both routed through a shared create-and-switch helper. The branch is created first, optionally at a start point, and HEAD moves only after the branch exists, so a name collision leaves the working tree untouched. Plain `switch ` moves HEAD like checkout. --- packages/workspace/src/git/cli.test.ts | 66 +++++++++++++++++++ packages/workspace/src/git/cli.ts | 91 ++++++++++++++++++++++++-- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 25048f4e..8bd573f3 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -28,6 +28,7 @@ import type { CommitResult, GitCommitOptions } from "./commit.js"; import type { GitDiffOptions } from "./diff.js"; import { AlreadyInitializedError, + GitError, MissingIdentityError, NotARepositoryError, PathspecNotFoundError, @@ -1339,6 +1340,71 @@ describe("runGitCli — checkout argv parsing", () => { expect(res.exitCode).toBe(129); expect(res.stderr).toContain("missing "); }); + + it("checkout -b creates a branch and switches to it", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["checkout", "-b", "feature"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.branch).toEqual([ + { dir: "/r", name: "feature", startPoint: undefined, force: false }, + ]); + expect(calls.checkout).toEqual([{ dir: "/r", ref: "feature" }]); + }); + + it("checkout -b creates the branch at the start point", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { argv: ["checkout", "-b", "feature", "v1"] }); + expect(calls.branch[0]).toMatchObject({ name: "feature", startPoint: "v1" }); + expect(calls.checkout[0]).toMatchObject({ ref: "feature" }); + }); + + it("checkout -b requires a branch name", async () => { + const { client } = fakeClient(); + const res = await runGitCli(client, { argv: ["checkout", "-b"] }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("requires a branch name"); + }); + + it("checkout -b does not switch if branch creation fails", async () => { + const { client, calls } = fakeClient({ + async branch() { + throw new GitError("EBRANCHFAIL", "branch 'feature' already exists"); + }, + }); + const res = await runGitCli(client, { argv: ["checkout", "-b", "feature"] }); + expect(res.exitCode).toBe(1); + expect(calls.checkout).toEqual([]); + }); +}); + +describe("runGitCli — switch argv parsing", () => { + it("switch moves HEAD", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["switch", "feature"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.checkout).toEqual([{ dir: "/r", ref: "feature" }]); + }); + + it("switch -c creates a branch and switches to it", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["switch", "-c", "feature"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.branch[0]).toMatchObject({ name: "feature" }); + expect(calls.checkout[0]).toMatchObject({ ref: "feature" }); + }); + + it("switch -c honors the start point", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { argv: ["switch", "-c", "feature", "main"] }); + expect(calls.branch[0]).toMatchObject({ name: "feature", startPoint: "main" }); + }); + + it("switch with no branch is an error", async () => { + const { client } = fakeClient(); + const res = await runGitCli(client, { argv: ["switch"] }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("missing "); + }); }); describe("runGitCli — fetch argv parsing", () => { diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 755c4224..07057423 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -116,6 +116,8 @@ export async function runGitCli( return await runTag(client, rest, input); case "checkout": return await runCheckout(client, rest, input); + case "switch": + return await runSwitch(client, rest, input); case "fetch": return await runFetch(client, rest, input); case "push": @@ -174,6 +176,7 @@ function printHelp(): GitCliResult { " rm Unstage paths from the index.", " show Read a single commit.", " status Describe the working-tree / index / HEAD delta.", + " switch Switch branches, or create one with -c.", " symbolic-ref Print the current branch name.", " tag Create, delete, or list tags.", " update-ref Write a ref directly.", @@ -1203,11 +1206,11 @@ async function runCheckout( args: string[], input: GitCliInput, ): Promise { - // `git checkout [-- ...]`. The -b shortcut for - // create-and-switch isn't covered here; agents who want it - // chain `branch && checkout ` themselves. + // `git checkout [-b ] [-- ...]`. `-b` creates + // a branch (optionally at a start point) and switches to it. const parsed = parseFlags(args, { force: { kind: "bool", alias: ["f"] }, + b: { kind: "bool" }, }); if ("error" in parsed) { return { stdout: "", stderr: `git checkout: ${parsed.error}\n`, exitCode: 129 }; @@ -1216,6 +1219,16 @@ async function runCheckout( const refArgs = sep === -1 ? parsed.positional : args.slice(0, sep).filter((a) => !a.startsWith("-")); const pathArgs = sep === -1 ? [] : args.slice(sep + 1); + const dir = resolveDir(undefined, input.cwd); + + if (parsed.flags.b === true) { + // Create-and-switch. `refArgs` is ` []`. + if (refArgs.length === 0) { + return { stdout: "", stderr: "git checkout: -b requires a branch name\n", exitCode: 129 }; + } + return createAndSwitch(client, dir, refArgs[0], refArgs[1], "checkout"); + } + if (refArgs.length === 0) { return { stdout: "", stderr: "git checkout: missing \n", exitCode: 129 }; } @@ -1226,7 +1239,6 @@ async function runCheckout( exitCode: 129, }; } - const dir = resolveDir(undefined, input.cwd); try { await client.checkout({ dir, @@ -1240,6 +1252,77 @@ async function runCheckout( } } +// --------------------------------------------------------------- +// switch +// --------------------------------------------------------------- + +async function runSwitch( + client: GitClient, + args: string[], + input: GitCliInput, +): Promise { + // `git switch [-c ] []`. The modern + // spelling of `checkout` for branch movement; `-c` is the + // `checkout -b` equivalent. + const parsed = parseFlags(args, { + c: { kind: "bool" }, + }); + if ("error" in parsed) { + return { stdout: "", stderr: `git switch: ${parsed.error}\n`, exitCode: 129 }; + } + const dir = resolveDir(undefined, input.cwd); + + if (parsed.flags.c === true) { + if (parsed.positional.length === 0) { + return { stdout: "", stderr: "git switch: -c requires a branch name\n", exitCode: 129 }; + } + return createAndSwitch(client, dir, parsed.positional[0], parsed.positional[1], "switch"); + } + + if (parsed.positional.length === 0) { + return { stdout: "", stderr: "git switch: missing \n", exitCode: 129 }; + } + if (parsed.positional.length > 1) { + return { + stdout: "", + stderr: `git switch: unexpected argument '${parsed.positional[1]}'\n`, + exitCode: 129, + }; + } + try { + await client.checkout({ dir, ref: parsed.positional[0] }); + return { stdout: "", stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError("switch", cause); + } +} + +/** + * Create a branch (optionally at a start point) and move HEAD to + * it — the shared core of `checkout -b` and `switch -c`. The + * branch is created first; only on success does HEAD move, so a + * name collision leaves the working tree untouched. + */ +async function createAndSwitch( + client: GitClient, + dir: string, + name: string, + startPoint: string | undefined, + subcommand: string, +): Promise { + try { + await client.branch({ dir, name, startPoint, force: false }); + } catch (cause) { + return mapGitError(subcommand, cause); + } + try { + await client.checkout({ dir, ref: name }); + return { stdout: "", stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError(subcommand, cause); + } +} + // --------------------------------------------------------------- // fetch // --------------------------------------------------------------- From d824478578855070953da6c8d4d56b8b0721191f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:24:55 +0000 Subject: [PATCH 15/20] workspace: add stash, reset, and clean Agents set aside changes, discard them, or sweep build output; none of stash, reset, or clean existed on the shell git surface. Add a worktree module wrapping isomorphic-git's stash (push with an optional message, list, pop) and resetIndex, plus a clean that derives the untracked set from a status-matrix walk and removes paths through the filesystem. Reset covers path unstaging and `--hard` restore-to-ref; --soft and --mixed are rejected as unsupported. Clean refuses to run without -f unless previewing with -n, and only descends into untracked directories with -d, matching real git. --- packages/workspace/src/git/cli.test.ts | 132 +++++++++ packages/workspace/src/git/cli.ts | 193 +++++++++++++ packages/workspace/src/git/index.ts | 69 +++++ packages/workspace/src/git/worktree.test.ts | 179 ++++++++++++ packages/workspace/src/git/worktree.ts | 295 ++++++++++++++++++++ 5 files changed, 868 insertions(+) create mode 100644 packages/workspace/src/git/worktree.test.ts create mode 100644 packages/workspace/src/git/worktree.ts diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 8bd573f3..3872c6d8 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -114,6 +114,11 @@ interface FakeCalls { updateRef: GitUpdateRefOptions[]; configGet: GitConfigGetOptions[]; configSet: GitConfigSetOptions[]; + stashPush: import("./worktree.js").StashPushOptions[]; + stashList: import("./worktree.js").BaseWorktreeOptions[]; + stashPop: import("./worktree.js").StashPopOptions[]; + reset: import("./worktree.js").ResetOptions[]; + clean: import("./worktree.js").CleanOptions[]; } function fakeClient( @@ -137,6 +142,8 @@ function fakeClient( hashObject?: () => string; catFile?: () => CatFileResult; configGet?: () => string | string[] | undefined; + stashList?: () => string[]; + clean?: () => string[]; } = {}, ): { client: GitClient; @@ -177,6 +184,11 @@ function fakeClient( updateRef: [], configGet: [], configSet: [], + stashPush: [], + stashList: [], + stashPop: [], + reset: [], + clean: [], }; const client: GitClient = { async clone(options) { @@ -315,6 +327,23 @@ function fakeClient( async configSet(options) { calls.configSet.push(options); }, + async stashPush(options = {}) { + calls.stashPush.push(options); + }, + async stashList(options = {}) { + calls.stashList.push(options); + return fakes.stashList?.() ?? []; + }, + async stashPop(options = {}) { + calls.stashPop.push(options); + }, + async reset(options = {}) { + calls.reset.push(options); + }, + async clean(options = {}) { + calls.clean.push(options); + return fakes.clean?.() ?? []; + }, async cli() { throw new Error("not reached in these tests"); }, @@ -1790,6 +1819,109 @@ describe("runGitCli — config", () => { }); }); +describe("runGitCli — stash argv parsing", () => { + it("bare stash is a push", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["stash"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.stashPush).toEqual([{ dir: "/r", message: undefined }]); + }); + + it("stash push -m forwards the message", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { argv: ["stash", "push", "-m", "wip"], cwd: "/r" }); + expect(calls.stashPush[0]).toMatchObject({ dir: "/r", message: "wip" }); + }); + + it("stash list prints entries", async () => { + const { client } = fakeClient({}, { stashList: () => ["stash@{0}: wip"] }); + const res = await runGitCli(client, { argv: ["stash", "list"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toBe("stash@{0}: wip\n"); + }); + + it("stash list with no entries prints nothing", async () => { + const { client } = fakeClient({}, { stashList: () => [] }); + const res = await runGitCli(client, { argv: ["stash", "list"] }); + expect(res.stdout).toBe(""); + }); + + it("stash pop restores the latest entry", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["stash", "pop"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.stashPop).toEqual([{ dir: "/r" }]); + }); + + it("unknown stash subcommand exits 129", async () => { + const { client } = fakeClient(); + const res = await runGitCli(client, { argv: ["stash", "bogus"] }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("unknown subcommand"); + }); +}); + +describe("runGitCli — reset argv parsing", () => { + it("path reset unstages the listed paths", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["reset", "--", "a.txt"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.reset[0]).toMatchObject({ dir: "/r", hard: false, paths: ["a.txt"] }); + }); + + it("bare positionals without -- are treated as paths", async () => { + const { client, calls } = fakeClient(); + await runGitCli(client, { argv: ["reset", "a.txt", "b.txt"] }); + expect(calls.reset[0]).toMatchObject({ paths: ["a.txt", "b.txt"], hard: false }); + }); + + it("--hard with a ref hard-resets to it", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["reset", "--hard", "HEAD"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.reset[0]).toMatchObject({ dir: "/r", hard: true, ref: "HEAD" }); + }); + + it("--hard resolves a revision suffix in the ref", async () => { + const { client, calls } = fakeClient({}, { revParse: () => "a".repeat(40) }); + await runGitCli(client, { argv: ["reset", "--hard", "HEAD~1"] }); + expect(calls.reset[0]).toMatchObject({ hard: true, ref: "a".repeat(40) }); + }); + + it("--soft is rejected as unsupported", async () => { + const { client } = fakeClient(); + const res = await runGitCli(client, { argv: ["reset", "--soft", "HEAD"] }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("--soft is not supported"); + }); +}); + +describe("runGitCli — clean argv parsing", () => { + it("refuses to run without -f", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["clean"] }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("refusing to clean without -f"); + expect(calls.clean).toEqual([]); + }); + + it("-fd removes untracked files and directories", async () => { + const { client, calls } = fakeClient({}, { clean: () => ["build", "junk.txt"] }); + const res = await runGitCli(client, { argv: ["clean", "-fd"], cwd: "/r" }); + expect(res.exitCode).toBe(0); + expect(calls.clean[0]).toMatchObject({ dir: "/r", directories: true, dryRun: false }); + expect(res.stdout).toBe("Removing build\nRemoving junk.txt\n"); + }); + + it("-n / --dry-run previews without -f", async () => { + const { client, calls } = fakeClient({}, { clean: () => ["junk.txt"] }); + const res = await runGitCli(client, { argv: ["clean", "-n", "-d"] }); + expect(res.exitCode).toBe(0); + expect(calls.clean[0]).toMatchObject({ dryRun: true, directories: true }); + expect(res.stdout).toBe("Would remove junk.txt\n"); + }); +}); + // --------------------------------------------------------------- // End-to-end: real Workspace + real isomorphic-git/diff, faked // clone phase. Matches the pattern in clone.test.ts. diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 07057423..4639aa7e 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -136,6 +136,12 @@ export async function runGitCli( return await runUpdateRef(client, rest, input); case "config": return await runConfig(client, rest, input); + case "stash": + return await runStash(client, rest, input); + case "reset": + return await runReset(client, rest, input); + case "clean": + return await runClean(client, rest, input); default: return { stdout: "", @@ -158,6 +164,7 @@ function printHelp(): GitCliResult { " branch Create, delete, or list branches.", " cat-file Read raw bytes for an object by oid.", " checkout Move HEAD to a ref, or restore paths.", + " clean Remove untracked files from the working tree.", " clone Clone a remote repository into the workspace.", " commit Write the current index to a new commit.", " config Read or write a config key.", @@ -172,9 +179,11 @@ function printHelp(): GitCliResult { " pull Fetch and merge in one step.", " push Push local refs to a remote.", " remote Manage configured remotes.", + " reset Unstage paths or hard-reset to a ref.", " rev-parse Resolve a ref to its SHA-1 oid.", " rm Unstage paths from the index.", " show Read a single commit.", + " stash Stash and restore working-tree changes.", " status Describe the working-tree / index / HEAD delta.", " switch Switch branches, or create one with -c.", " symbolic-ref Print the current branch name.", @@ -682,6 +691,25 @@ async function runCommit( * git reads that as `-m` with the value `a`, and the generic * parser handles it. */ +/** + * Expand a cluster of single-char boolean short flags (`-fd` -> + * `-f -d`) when every character is in `chars`. Clusters with a + * character outside the set are left untouched for the generic + * parser to handle or reject. Only safe for flags that take no + * value. + */ +function expandShortBoolCluster(args: string[], chars: Set): string[] { + const out: string[] = []; + for (const arg of args) { + if (/^-[a-z]{2,}$/i.test(arg) && [...arg.slice(1)].every((c) => chars.has(c))) { + for (const ch of arg.slice(1)) out.push(`-${ch}`); + continue; + } + out.push(arg); + } + return out; +} + function expandCommitShortCluster(args: string[]): string[] { const out: string[] = []; for (const arg of args) { @@ -1898,6 +1926,171 @@ async function runConfig( }; } +// --------------------------------------------------------------- +// stash +// --------------------------------------------------------------- + +async function runStash( + client: GitClient, + args: string[], + input: GitCliInput, +): Promise { + // `git stash [push [-m ]] | list | pop`. A bare `git + // stash` is `push`, matching real git. + const [sub, ...rest] = args; + const op = sub ?? "push"; + const dir = resolveDir(undefined, input.cwd); + + switch (op) { + case "push": { + const parsed = parseFlags(rest, { message: { kind: "value", alias: ["m"] } }); + if ("error" in parsed) { + return { stdout: "", stderr: `git stash: ${parsed.error}\n`, exitCode: 129 }; + } + try { + await client.stashPush({ dir, message: parsed.flags.message as string | undefined }); + return { stdout: "Saved working directory state\n", stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError("stash", cause); + } + } + case "list": { + try { + const entries = await client.stashList({ dir }); + return { + stdout: entries.length === 0 ? "" : `${entries.join("\n")}\n`, + stderr: "", + exitCode: 0, + }; + } catch (cause) { + return mapGitError("stash", cause); + } + } + case "pop": { + try { + await client.stashPop({ dir }); + return { stdout: "", stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError("stash", cause); + } + } + default: + return { + stdout: "", + stderr: `git stash: unknown subcommand '${op}'\n`, + exitCode: 129, + }; + } +} + +// --------------------------------------------------------------- +// reset +// --------------------------------------------------------------- + +async function runReset( + client: GitClient, + args: string[], + input: GitCliInput, +): Promise { + // `git reset [--hard] [] [-- ...]`. Path reset + // unstages; `--hard` restores tracked files to the ref. + const parsed = parseFlags(args, { + hard: { kind: "bool" }, + soft: { kind: "bool" }, + mixed: { kind: "bool" }, + }); + if ("error" in parsed) { + return { stdout: "", stderr: `git reset: ${parsed.error}\n`, exitCode: 129 }; + } + if (parsed.flags.soft === true) { + return { stdout: "", stderr: "git reset: --soft is not supported\n", exitCode: 129 }; + } + const sep = args.indexOf("--"); + const positional = + sep === -1 ? parsed.positional : args.slice(0, sep).filter((a) => !a.startsWith("-")); + const pathArgs = sep === -1 ? [] : args.slice(sep + 1); + const dir = resolveDir(undefined, input.cwd); + const hard = parsed.flags.hard === true; + + // A leading positional that isn't after `--` is the ref; the + // rest (or everything after `--`) are paths. + let ref: string | undefined; + let paths = pathArgs; + if (sep === -1) { + // No `--`: a single positional is the ref for `--hard`, or a + // pathspec otherwise. Real git is context-sensitive here; for + // the supported subset we treat positionals as the ref when + // `--hard`, else as paths. + if (hard) { + ref = positional[0]; + } else { + paths = positional; + } + } else { + ref = positional[0]; + } + + try { + const resolvedRef = await resolveRevisionRef(client, dir, ref); + await client.reset({ + dir, + hard, + ref: resolvedRef, + paths: paths.length > 0 ? paths : undefined, + }); + return { stdout: "", stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError("reset", cause); + } +} + +// --------------------------------------------------------------- +// clean +// --------------------------------------------------------------- + +async function runClean( + client: GitClient, + args: string[], + input: GitCliInput, +): Promise { + // `git clean -f [-d] [-n|--dry-run]`. Real git refuses to act + // without `-f`; mirror that so a bare `git clean` is a no-op + // error rather than a destructive surprise. The flags are all + // boolean, so expand any combined short cluster (`-fd`, `-fdn`) + // into separate tokens before parsing. + const expanded = expandShortBoolCluster(args, new Set(["f", "d", "n"])); + const parsed = parseFlags(expanded, { + force: { kind: "bool", alias: ["f"] }, + d: { kind: "bool" }, + "dry-run": { kind: "bool", alias: ["n"] }, + }); + if ("error" in parsed) { + return { stdout: "", stderr: `git clean: ${parsed.error}\n`, exitCode: 129 }; + } + const dryRun = parsed.flags["dry-run"] === true; + if (parsed.flags.force !== true && !dryRun) { + return { + stdout: "", + stderr: "git clean: refusing to clean without -f (or use -n to preview)\n", + exitCode: 129, + }; + } + const dir = resolveDir(undefined, input.cwd); + try { + const removed = await client.clean({ + dir, + directories: parsed.flags.d === true, + dryRun, + }); + if (removed.length === 0) return { stdout: "", stderr: "", exitCode: 0 }; + const verb = dryRun ? "Would remove" : "Removing"; + const lines = removed.map((p) => `${verb} ${p}`); + return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 }; + } catch (cause) { + return mapGitError("clean", cause); + } +} + // --------------------------------------------------------------- // shared error mapping // --------------------------------------------------------------- diff --git a/packages/workspace/src/git/index.ts b/packages/workspace/src/git/index.ts index 7984bf2a..8647a504 100644 --- a/packages/workspace/src/git/index.ts +++ b/packages/workspace/src/git/index.ts @@ -126,6 +126,21 @@ import { type StatusEntry, statusWith, } from "./status.js"; +import { + type BaseWorktreeOptions, + type CleanOptions, + cleanWith, + type IsomorphicGitCleanClient, + type IsomorphicGitResetClient, + type IsomorphicGitStashClient, + type ResetOptions, + resetWith, + type StashPopOptions, + type StashPushOptions, + stashListWith, + stashPopWith, + stashPushWith, +} from "./worktree.js"; export type { GitCliInput, GitCliResult } from "./cli.js"; export type { GitCloneOptions, MessageCallback, ProgressCallback } from "./clone.js"; @@ -186,6 +201,13 @@ export type { } from "./refs.js"; export type { GitAddOptions, GitRmOptions } from "./staging.js"; export type { GitStatusOptions, StatusEntry } from "./status.js"; +export type { + BaseWorktreeOptions, + CleanOptions, + ResetOptions, + StashPopOptions, + StashPushOptions, +} from "./worktree.js"; /** Duck-typed workspace handle. Only `.provider()` is required. */ export interface WorkspaceLike { @@ -275,6 +297,16 @@ export interface GitClient { configGet(options: GitConfigGetOptions): Promise; /** Write a single config key. */ configSet(options: GitConfigSetOptions): Promise; + /** Stash tracked working-tree changes. */ + stashPush(options?: StashPushOptions): Promise; + /** List stash entries, newest first. */ + stashList(options?: BaseWorktreeOptions): Promise; + /** Restore the latest stash entry. */ + stashPop(options?: StashPopOptions): Promise; + /** Unstage paths or hard-reset tracked files to a ref. */ + reset(options?: ResetOptions): Promise; + /** Remove untracked files (and directories with `directories`). */ + clean(options?: CleanOptions): Promise; /** * Argv-driven entry point. The worker-backend's `git` custom * command dispatches through this; in-process callers can use @@ -617,6 +649,43 @@ export function createGitClient({ git: await loadGit(), }); }, + async stashPush(options = {}) { + return stashPushWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async stashList(options = {}) { + return stashListWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async stashPop(options = {}) { + return stashPopWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async reset(options = {}) { + return resetWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async clean(options = {}) { + return cleanWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, async cli(input) { return runGitCli(client, input, { defaultIdentity }); }, diff --git a/packages/workspace/src/git/worktree.test.ts b/packages/workspace/src/git/worktree.test.ts new file mode 100644 index 00000000..eb099209 --- /dev/null +++ b/packages/workspace/src/git/worktree.test.ts @@ -0,0 +1,179 @@ +// Behavioural tests for the working-tree family: stash, reset, +// and clean. Drives real isomorphic-git + memfs so index and +// working-tree effects are observable. + +import git from "isomorphic-git"; +import { fs as memfs, vol } from "memfs"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { NotARepositoryError } from "./errors.js"; +import { + cleanWith, + type IsomorphicGitCleanClient, + type IsomorphicGitResetClient, + type IsomorphicGitStashClient, + resetWith, + stashListWith, + stashPopWith, + stashPushWith, +} from "./worktree.js"; + +const DIR = "/repo"; +const AUTHOR = { name: "t", email: "t@example.test" }; + +const stashClient = git as unknown as IsomorphicGitStashClient; +const resetClient = git as unknown as IsomorphicGitResetClient; + +async function init() { + await memfs.promises.mkdir(DIR, { recursive: true }); + await git.init({ fs: memfs, dir: DIR, defaultBranch: "main" }); + // stash creates a commit internally, so it needs an identity. + await git.setConfig({ fs: memfs, dir: DIR, path: "user.name", value: AUTHOR.name }); + await git.setConfig({ fs: memfs, dir: DIR, path: "user.email", value: AUTHOR.email }); +} + +async function commit(name: string, content: string, message: string): Promise { + await memfs.promises.writeFile(`${DIR}/${name}`, content); + await git.add({ fs: memfs, dir: DIR, filepath: name }); + return git.commit({ fs: memfs, dir: DIR, message, author: AUTHOR }); +} + +async function statusOf(path: string): Promise<[number, number, number] | undefined> { + const matrix = await git.statusMatrix({ fs: memfs, dir: DIR }); + const row = matrix.find((r) => r[0] === path); + return row ? [row[1], row[2], row[3]] : undefined; +} + +describe("stashPushWith / stashListWith / stashPopWith", () => { + beforeEach(() => vol.reset()); + + it("stashes tracked modifications and restores a clean tree", async () => { + await init(); + await commit("a.txt", "v1\n", "init"); + await memfs.promises.writeFile(`${DIR}/a.txt`, "v2 dirty\n"); + + await stashPushWith({ git: stashClient, fs: memfs, dir: DIR, message: "wip" }); + + // Working tree is back to the committed content. + expect(await memfs.promises.readFile(`${DIR}/a.txt`, "utf8")).toBe("v1\n"); + }); + + it("lists stash entries newest-first", async () => { + await init(); + await commit("a.txt", "v1\n", "init"); + await memfs.promises.writeFile(`${DIR}/a.txt`, "v2\n"); + await git.add({ fs: memfs, dir: DIR, filepath: "a.txt" }); + await stashPushWith({ git: stashClient, fs: memfs, dir: DIR, message: "first" }); + + const list = await stashListWith({ git: stashClient, fs: memfs, dir: DIR }); + expect(list).toHaveLength(1); + expect(list[0]).toContain("first"); + }); + + it("pops the latest stash back into the working tree", async () => { + await init(); + await commit("a.txt", "v1\n", "init"); + await memfs.promises.writeFile(`${DIR}/a.txt`, "v2 dirty\n"); + await stashPushWith({ git: stashClient, fs: memfs, dir: DIR }); + + await stashPopWith({ git: stashClient, fs: memfs, dir: DIR }); + expect(await memfs.promises.readFile(`${DIR}/a.txt`, "utf8")).toBe("v2 dirty\n"); + }); + + it("stash push surfaces a non-repo as an error", async () => { + await memfs.promises.mkdir("/loose", { recursive: true }); + await expect(stashPushWith({ git: stashClient, fs: memfs, dir: "/loose" })).rejects.toThrow(); + }); +}); + +describe("resetWith", () => { + beforeEach(() => vol.reset()); + + it("unstages a path (path reset against HEAD)", async () => { + await init(); + await commit("a.txt", "v1\n", "init"); + await memfs.promises.writeFile(`${DIR}/a.txt`, "v2\n"); + await git.add({ fs: memfs, dir: DIR, filepath: "a.txt" }); + // Staged modification: [head=1, workdir=2, stage=2]. + expect(await statusOf("a.txt")).toEqual([1, 2, 2]); + + await resetWith({ git: resetClient, fs: memfs, dir: DIR, paths: ["a.txt"] }); + // Unstaged: stage back to matching HEAD (workdir still differs). + expect(await statusOf("a.txt")).toEqual([1, 2, 1]); + }); + + it("hard reset restores tracked files to HEAD", async () => { + await init(); + await commit("a.txt", "v1\n", "init"); + await memfs.promises.writeFile(`${DIR}/a.txt`, "v2 dirty\n"); + await git.add({ fs: memfs, dir: DIR, filepath: "a.txt" }); + + await resetWith({ git: resetClient, fs: memfs, dir: DIR, hard: true }); + expect(await memfs.promises.readFile(`${DIR}/a.txt`, "utf8")).toBe("v1\n"); + // Back to clean: head == workdir == stage. + expect(await statusOf("a.txt")).toEqual([1, 1, 1]); + }); + + it("hard reset throws NotARepositoryError outside a repo", async () => { + await memfs.promises.mkdir("/loose", { recursive: true }); + await expect( + resetWith({ git: resetClient, fs: memfs, dir: "/loose", hard: true }), + ).rejects.toBeInstanceOf(NotARepositoryError); + }); +}); + +describe("cleanWith", () => { + beforeEach(() => vol.reset()); + + const cleanClient = git as unknown as IsomorphicGitCleanClient; + + it("dry run lists untracked files without removing them", async () => { + await init(); + await commit("tracked.txt", "t\n", "init"); + await memfs.promises.writeFile(`${DIR}/junk.txt`, "j\n"); + + const removed = await cleanWith({ + git: cleanClient, + fs: memfs, + dir: DIR, + directories: true, + dryRun: true, + }); + expect(removed).toEqual(["junk.txt"]); + // Still on disk. + expect(await memfs.promises.readFile(`${DIR}/junk.txt`, "utf8")).toBe("j\n"); + }); + + it("removes untracked files and directories", async () => { + await init(); + await commit("tracked.txt", "t\n", "init"); + await memfs.promises.writeFile(`${DIR}/junk.txt`, "j\n"); + await memfs.promises.mkdir(`${DIR}/build`, { recursive: true }); + await memfs.promises.writeFile(`${DIR}/build/out.o`, "o\n"); + + const removed = await cleanWith({ + git: cleanClient, + fs: memfs, + dir: DIR, + directories: true, + }); + expect(removed.sort()).toEqual(["build", "junk.txt"]); + await expect(memfs.promises.stat(`${DIR}/junk.txt`)).rejects.toThrow(); + await expect(memfs.promises.stat(`${DIR}/build`)).rejects.toThrow(); + // Tracked file untouched. + expect(await memfs.promises.readFile(`${DIR}/tracked.txt`, "utf8")).toBe("t\n"); + }); + + it("leaves untracked directories alone without directories: true", async () => { + await init(); + await commit("tracked.txt", "t\n", "init"); + await memfs.promises.mkdir(`${DIR}/build`, { recursive: true }); + await memfs.promises.writeFile(`${DIR}/build/out.o`, "o\n"); + await memfs.promises.writeFile(`${DIR}/junk.txt`, "j\n"); + + const removed = await cleanWith({ git: cleanClient, fs: memfs, dir: DIR }); + expect(removed).toEqual(["junk.txt"]); + // Directory survives. + expect(await memfs.promises.stat(`${DIR}/build`)).toBeTruthy(); + }); +}); diff --git a/packages/workspace/src/git/worktree.ts b/packages/workspace/src/git/worktree.ts new file mode 100644 index 00000000..038ba894 --- /dev/null +++ b/packages/workspace/src/git/worktree.ts @@ -0,0 +1,295 @@ +// Working-tree manipulation: stash, reset, and clean. +// +// `stash` and `reset` wrap isomorphic-git directly. `clean` has +// no isomorphic-git equivalent, so it derives the untracked set +// from a status-matrix walk and removes paths through the +// filesystem. Each wrapper centralises dir / cache plumbing and +// throws the typed NotARepositoryError when the gitdir is +// missing. + +import { GitError, isNotARepositoryCause, NotARepositoryError } from "./errors.js"; + +// --------------------------------------------------------------- +// stash +// --------------------------------------------------------------- + +/** Subset of `isomorphic-git`'s API used for `stash`. */ +export interface IsomorphicGitStashClient { + stash(args: { + fs: object; + dir: string; + op?: "push" | "pop" | "apply" | "drop" | "list" | "clear"; + message?: string; + refIdx?: number; + }): Promise; +} + +export interface BaseWorktreeOptions { + /** Working-tree directory inside the VFS. Defaults to `/`. */ + dir?: string; +} + +export interface StashPushOptions extends BaseWorktreeOptions { + /** Optional stash message. */ + message?: string; +} + +export interface StashPushWithDeps extends StashPushOptions { + git: IsomorphicGitStashClient; + fs: object; +} + +export async function stashPushWith(opts: StashPushWithDeps): Promise { + const dir = opts.dir ?? "/"; + try { + await opts.git.stash({ fs: opts.fs, dir, op: "push", message: opts.message }); + } catch (cause) { + if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); + throw new GitError("ESTASHFAIL", `git stash push failed: ${errorMessage(cause)}`, { cause }); + } +} + +export interface StashListWithDeps extends BaseWorktreeOptions { + git: IsomorphicGitStashClient; + fs: object; +} + +/** Stash entries, newest first, as `stash@{N}: ` strings. */ +export async function stashListWith(opts: StashListWithDeps): Promise { + const dir = opts.dir ?? "/"; + try { + const list = await opts.git.stash({ fs: opts.fs, dir, op: "list" }); + return Array.isArray(list) ? list : []; + } catch (cause) { + if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); + throw new GitError("ESTASHFAIL", `git stash list failed: ${errorMessage(cause)}`, { cause }); + } +} + +export interface StashPopOptions extends BaseWorktreeOptions { + /** Stash index to pop. Defaults to the latest (0). */ + index?: number; +} + +export interface StashPopWithDeps extends StashPopOptions { + git: IsomorphicGitStashClient; + fs: object; +} + +export async function stashPopWith(opts: StashPopWithDeps): Promise { + const dir = opts.dir ?? "/"; + try { + await opts.git.stash({ fs: opts.fs, dir, op: "pop", refIdx: opts.index ?? 0 }); + } catch (cause) { + if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); + throw new GitError("ESTASHFAIL", `git stash pop failed: ${errorMessage(cause)}`, { cause }); + } +} + +// --------------------------------------------------------------- +// reset +// --------------------------------------------------------------- + +/** Subset of `isomorphic-git`'s API used for `reset`. */ +export interface IsomorphicGitResetClient { + resetIndex(args: { + fs: object; + dir: string; + filepath: string; + ref?: string; + cache?: object; + }): Promise; + checkout(args: { + fs: object; + dir: string; + ref: string; + force?: boolean; + cache?: object; + }): Promise; +} + +export interface ResetOptions extends BaseWorktreeOptions { + /** + * Paths to unstage against `ref`. Mutually exclusive with + * `hard`; when set, the index entries for these paths are reset + * to `ref` (default HEAD) and the working tree is left alone. + */ + paths?: string[]; + /** + * Discard staged and working-tree changes, restoring tracked + * files to `ref`. Equivalent to `git reset --hard`. + */ + hard?: boolean; + /** Commit-ish to reset to. Defaults to HEAD. */ + ref?: string; +} + +export interface ResetWithDeps extends ResetOptions { + git: IsomorphicGitResetClient; + fs: object; + cache?: object; +} + +export async function resetWith(opts: ResetWithDeps): Promise { + const dir = opts.dir ?? "/"; + const ref = opts.ref ?? "HEAD"; + try { + if (opts.hard) { + // Hard reset: force-checkout the ref, which rewrites both + // the index and the working tree to match. + await opts.git.checkout({ fs: opts.fs, dir, ref, force: true, cache: opts.cache }); + return; + } + // Path reset: unstage each path back to `ref` without + // touching the working tree. + for (const filepath of opts.paths ?? []) { + await opts.git.resetIndex({ fs: opts.fs, dir, filepath, ref, cache: opts.cache }); + } + } catch (cause) { + if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); + throw new GitError("ERESETFAIL", `git reset failed: ${errorMessage(cause)}`, { cause }); + } +} + +// --------------------------------------------------------------- +// clean +// --------------------------------------------------------------- + +/** Subset of `isomorphic-git`'s API used for `clean`. */ +export interface IsomorphicGitCleanClient { + statusMatrix(args: { + fs: object; + dir: string; + cache?: object; + }): Promise>; +} + +/** `fs.promises` surface used to remove paths. */ +interface RemoveFsClient { + promises: { + rm?: (path: string, options?: { recursive?: boolean; force?: boolean }) => Promise; + unlink: (path: string) => Promise; + rmdir?: (path: string, options?: { recursive?: boolean }) => Promise; + }; +} + +export interface CleanOptions extends BaseWorktreeOptions { + /** Remove untracked directories too (`-d`). */ + directories?: boolean; + /** List what would be removed without removing it (`--dry-run`). */ + dryRun?: boolean; +} + +export interface CleanWithDeps extends CleanOptions { + git: IsomorphicGitCleanClient; + fs: object; + cache?: object; +} + +/** + * Remove untracked paths under the repository, mirroring `git + * clean -f [-d]`. Returns the repo-relative paths removed (or + * that would be removed under `dryRun`). Untracked files in + * tracked directories are always eligible; untracked directories + * and their contents only when `directories` is set, matching + * real git's refusal to descend into untracked directories + * without `-d`. Ignored-file handling is not modeled — every + * untracked path is a candidate. + */ +export async function cleanWith(opts: CleanWithDeps): Promise { + const dir = opts.dir ?? "/"; + let matrix: Array<[string, number, number, number]>; + try { + matrix = await opts.git.statusMatrix({ fs: opts.fs, dir, cache: opts.cache }); + } catch (cause) { + if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); + throw new GitError("ECLEANFAIL", `git clean failed: ${errorMessage(cause)}`, { cause }); + } + + // Tracked directories: every ancestor of a path present in HEAD + // or the index. The repo root (".") is tracked whenever any + // file is. Untracked files whose parent is tracked are loose + // files; those nested under an untracked directory are grouped + // under that directory. + const trackedDirs = new Set(["."]); + const untrackedFiles: string[] = []; + for (const [path, head, _workdir, stage] of matrix) { + if (head === 1 || stage !== 0) { + for (const ancestor of ancestorDirs(path)) trackedDirs.add(ancestor); + } else { + untrackedFiles.push(path); + } + } + + const looseFiles: string[] = []; + const untrackedTopDirs = new Set(); + for (const path of untrackedFiles) { + const parent = dirname(path); + if (trackedDirs.has(parent)) { + looseFiles.push(path); + } else { + untrackedTopDirs.add(topmostUntrackedDir(path, trackedDirs)); + } + } + + const removed = [...looseFiles]; + if (opts.directories) removed.push(...untrackedTopDirs); + removed.sort(); + + if (opts.dryRun) return removed; + + const fs = opts.fs as RemoveFsClient; + for (const rel of removed) { + const abs = dir === "/" ? `/${rel}` : `${dir}/${rel}`; + await removePath(fs, abs); + } + return removed; +} + +async function removePath(fs: RemoveFsClient, abs: string): Promise { + if (typeof fs.promises.rm === "function") { + await fs.promises.rm(abs, { recursive: true, force: true }); + return; + } + // Fallback for fs implementations without `rm`: try unlink, + // then a recursive rmdir. + try { + await fs.promises.unlink(abs); + } catch { + if (typeof fs.promises.rmdir === "function") { + await fs.promises.rmdir(abs, { recursive: true }); + } + } +} + +function ancestorDirs(path: string): string[] { + const out: string[] = []; + let p = dirname(path); + while (p !== ".") { + out.push(p); + p = dirname(p); + } + return out; +} + +function topmostUntrackedDir(path: string, trackedDirs: Set): string { + // Walk from the file up to the root; the first dir off the root + // that isn't tracked is the top of the untracked subtree. + const segments = path.split("/"); + let prefix = ""; + for (let i = 0; i < segments.length - 1; i++) { + prefix = prefix === "" ? segments[i] : `${prefix}/${segments[i]}`; + if (!trackedDirs.has(prefix)) return prefix; + } + return dirname(path); +} + +function dirname(path: string): string { + const i = path.lastIndexOf("/"); + return i === -1 ? "." : path.slice(0, i); +} + +function errorMessage(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return String(cause); +} From 6fd63a0efe5256d52ee0580097bdc68d40741589 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:30:21 +0000 Subject: [PATCH 16/20] docs: document the expanded shell git surface Bring docs/13_git_interface.md in line with the shell git command's new behavior: the global -C option; clone deriving its destination from the URL and leaving HEAD symbolic; status porcelain v1; commit reading identity from local config and the -a/-am staging flags; add -A; log -N; the rev-parse --abbrev-ref and --show-toplevel flags and revision-suffix grammar; diff --stat / --name-only / --name-status; checkout -b; and the new switch, reset, stash, and clean subcommands. Move the now-fixed items out of the known-gaps list and refresh the scope summary, identity precedence, and flag-to-option table. --- docs/13_git_interface.md | 306 +++++++++++++++++++++++++++------------ 1 file changed, 215 insertions(+), 91 deletions(-) diff --git a/docs/13_git_interface.md b/docs/13_git_interface.md index 5cf394d4..b68356a3 100644 --- a/docs/13_git_interface.md +++ b/docs/13_git_interface.md @@ -34,16 +34,21 @@ underlying methods. Supported subcommands today: ``` -add hash-object pull show -branch init push status -cat-file log remote symbolic-ref -checkout ls-files rev-parse tag -clone ls-tree rm update-ref -commit merge show -config fetch -diff +add clone init merge rm +branch commit log pull show +cat-file config ls-files push stash +checkout diff ls-tree remote status +clean fetch reset rev-parse switch + symbolic-ref tag + update-ref ``` +Global options accepted before the subcommand: + +- **`-C `** — run the subcommand as though invoked from + ``. A relative path joins onto the caller's cwd. A + single occurrence is supported; a second `-C` is rejected. + Deliberate omissions, with rationale: - **SSH transport.** `isomorphic-git` has none. `https://`, @@ -57,10 +62,10 @@ Deliberate omissions, with rationale: reachable from the tip tree is still fetched. - **`git gc`, `repack`, `prune`, hooks, worktrees, submodules.** No `isomorphic-git` surface for any of them. -- **Tier 2** — stash, `reset --hard|--soft|--mixed`, - `cherry-pick`, `revert`, `blame`. Out of scope until a - concrete caller needs them; each can land as its own - follow-up. +- **`reset --soft` / `--mixed`, `cherry-pick`, `revert`, + `blame`.** Out of scope until a concrete caller needs them; + each can land as its own follow-up. `reset` covers path + unstaging and `--hard`; the other modes exit 129. ## Known gaps @@ -68,45 +73,6 @@ The items below are intended to work but currently don't, or work in a way that differs from real git in a surprising way. Each is something to fix rather than a deliberate omission. -- **`clone` with no destination clones into `cwd` rather than - into a subdirectory named after the repo.** Real git derives - the destination from the last path segment of the URL when - no positional `` is given (so `git clone - https://github.com/owner/repo.git` lands in `./repo`). The - workspace CLI resolves the missing positional to `cwd` - itself, so the working tree is unpacked alongside whatever - else is already there. Workaround: pass an explicit - destination. - -- **`commit` does not read identity from `user.name` and - `user.email` in the local config.** `config user.email - "..."` writes to `/.git/config` and `config user.email` - reads it back, but `commit` resolves identity only from - explicit `options.author`, then `GIT_AUTHOR_*` / - `GIT_COMMITTER_*` env, then the `defaultIdentity` threaded - through `createGitClient`. The local config is never - consulted. Workaround: pass `--author "Name "`, set - the env vars, or configure `defaultGitIdentity` on the - `Workspace` constructor. - -- **Revision-suffix syntax is not supported.** `HEAD^`, - `HEAD~1`, `HEAD~2`, `^`, and the rest of git's - `gitrevisions(7)` walk syntax are not parsed by `rev-parse` - or any other subcommand. A literal `HEAD` or a full / short - oid is the only accepted spelling. Workaround: resolve the - ancestor with `log -n 2 --oneline` and pass the explicit - oid. - -- **Initial HEAD after `clone` resolves as a detached oid, - not a symbolic ref to `refs/heads/`.** - `symbolic-ref HEAD` fails with `ref HEAD is not a symbolic - ref` immediately after a clone, even though `branch` shows - the cloned branch as current. An explicit `checkout - ` rewrites HEAD as a symref and `symbolic-ref` - starts working. The underlying `isomorphic-git.clone` - writes HEAD this way; the wrapper does not re-attach it. - Workaround: `checkout ` once after `clone`. - - **The working tree is shared across branches; `checkout` updates HEAD and the index but does not reconcile untracked files.** A file created on `feature` and never @@ -121,10 +87,8 @@ Each is something to fix rather than a deliberate omission. `branch -a`, `show --stat`, `hash-object ` (the file-path form, only `--stdin` is supported), and a wider set of long-option flags listed in each command's *Not - mapped* block above. Real-git muscle memory invocations - like `git log -1` (the `-N` shorthand for `--max-count=N`) - are also rejected; use `git log -n 1` instead. The CLI - exits 129 with an `unknown option '...'` line on stderr. + mapped* block above. The CLI exits 129 with an `unknown + option '...'` line on stderr. - **Symlinks in a cloned tree are checked out as symlinks but the target may not resolve.** `clone` materializes @@ -205,12 +169,17 @@ committer in this order: `env` passed to the call (or to `cli({ env })`). The shell-side custom command flattens the just-bash env Map into this shape automatically. -3. `defaultIdentity` from `createGitClient` / `new Workspace({ +3. Local repo config `user.name` / `user.email`, as written + by `git config user.email "..."`. Only the local + `/.git/config` is consulted; there is no global + `~/.gitconfig` fallback. +4. `defaultIdentity` from `createGitClient` / `new Workspace({ defaultGitIdentity })`. -If none of the three yields a name and email, +If none of the four yields a name and email, `MissingIdentityError` fires. The CLI surfaces it as `git -commit: author identity unknown` with exit code 128. +commit: author identity unknown` with exit code 128. This +config-after-env order matches real git. ### Auth (`headers` and `onAuth`) @@ -327,11 +296,13 @@ interface StatusEntry { } ``` -The CLI default is porcelain v2. `--short` produces the -`XY ` short form. The typed surface returns the -underlying `StatusEntry[]`; format it with -`formatPorcelainV2` or `formatShort` from -`@cloudflare/workspace/git`. +The CLI default is porcelain v2. `--porcelain=v1` (and the +`1` spelling git also accepts) produces the v1 `XY ` +form with `??` for untracked files; `--short` / `-s` produces +the short form (` ?` for untracked). The typed surface returns +the underlying `StatusEntry[]`; format it with +`formatPorcelainV2`, `formatPorcelainV1`, or `formatShort` +from `@cloudflare/workspace/git`. *Not mapped:* `--branch`, `--ignored`, `--untracked-files`, the long human-readable form. The structured return is the @@ -340,24 +311,31 @@ primary surface. ### `add` ``` -git add [-f|--force] ... +git add [-A|--all] [-f|--force] ... ``` ```ts ws.git.add({ dir?: string, paths: string[], + all?: boolean, + trackedOnly?: boolean, force?: boolean, }): Promise ``` | Flag | TS field | |---|---| +| `-A` / `--all` | `all` | | `--force` / `-f` | `force` | | `...` (positional) | `paths` | -*Not mapped:* `-A` / `--all`, `--update`, `--intent-to-add`, -`-p` interactive. +`-A` stages every change under the repo — new, modified, and +deleted tracked files — and ignores any pathspec. `trackedOnly` +(no CLI flag of its own; set by `commit -a`) restricts `all` +mode to paths already in HEAD, so untracked files are left +alone. *Not mapped:* `--update`, `--intent-to-add`, `-p` +interactive. ### `rm` @@ -379,7 +357,7 @@ the working tree. `--cached` is the only mode supported. ### `commit` ``` -git commit -m [--amend] [--author="Name "] +git commit [-a] -m [--amend] [--author="Name "] ``` ```ts @@ -396,20 +374,25 @@ ws.git.commit({ | Flag | TS field | |---|---| | `-m ` / `--message ` | `message` | +| `-a` | (stages tracked changes first) | | `--amend` | `amend` | | `--author "Name "` | `author` | -Identity precedence: `options.author` → env → `defaultIdentity`. -The CLI prints `[] ` on success, matching -the first line of real git's commit summary. +Identity precedence: `options.author` → env → local config +(`user.name` / `user.email`) → `defaultIdentity`. `-a` (and the +`-am` cluster) stages tracked modifications and deletions — +never untracked files — before committing; a staging failure +aborts before the commit runs. The CLI prints `[] +` on success, matching the first line of real git's +commit summary. -*Not mapped:* `-a` / `--all`, `--no-edit`, `--signoff`, -`--gpg-sign`, `-F `. +*Not mapped:* `--no-edit`, `--signoff`, `--gpg-sign`, +`-F `. ### `log` ``` -git log [-n ] [--oneline] [] +git log [-n ] [-] [--oneline] [] ``` ```ts @@ -423,9 +406,13 @@ ws.git.log({ | Flag | TS field | |---|---| | `-n ` | `depth` | +| `-` (e.g. `-1`, `-5`) | `depth` | | `--oneline` | (CLI formatter) | | `` (positional) | `ref` | +The positional `` accepts revision suffixes (`HEAD~2`, +`^`); they resolve through `rev-parse` before the walk. + The CLI default emits `commit / Author / Date / message` blocks; `--oneline` collapses each entry to ` `. The typed surface returns `CommitView[]` (oid, @@ -456,7 +443,7 @@ real git's `show` produces — use `git log` plus `git diff ### `rev-parse` ``` -git rev-parse +git rev-parse [--abbrev-ref] [--show-toplevel] ``` ```ts @@ -464,12 +451,23 @@ ws.git.revParse({ dir?: string, ref: string, }): Promise + +ws.git.repoRoot({ dir?: string }): Promise ``` Resolves a ref (branch, tag, short oid prefix) to its full -SHA-1. *Not mapped:* the wide flag surface real git's -`rev-parse` carries (`--show-toplevel`, `--git-dir`, -`--abbrev-ref`, etc.). +SHA-1. Revision suffixes from `gitrevisions(7)` are supported: +`HEAD^`, `HEAD~N`, `^N`, and chained forms like `HEAD~2^2`. +`^`/`~N` follow first parents; `^N` selects parent N. Walking +past the root commit is an error. + +| Flag | Behavior | +|---|---| +| `--abbrev-ref HEAD` | Print the current branch; fall back to the oid on detached HEAD. | +| `--show-toplevel` | Print the working-tree root (`repoRoot`); walks up to find `.git`. Exits 128 outside a repo. | + +*Not mapped:* `--git-dir`, `--is-inside-work-tree`, and the +rest of `rev-parse`'s wide flag surface. ### `symbolic-ref` @@ -562,6 +560,14 @@ ws.git.clone({ | `` (positional) | `url` | | `` (positional) | `dir` | +When `` 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 +real git. A URL whose basename can't produce a safe directory +name exits 129; pass an explicit destination. After clone, HEAD +is a symbolic ref to the checked-out branch, so `branch +--show-current` and `symbolic-ref HEAD` work immediately. + Only `https://`, `http://`, and `file://` schemes are accepted. *Not mapped:* `--bare`, `--mirror`, `--recurse- submodules`, `--filter`, ssh URLs. @@ -569,7 +575,7 @@ submodules`, `--filter`, ssh URLs. ### `diff` ``` -git diff [ []] [-- ...] +git diff [--stat|--name-only|--name-status] [ []] [-- ...] ``` ```ts @@ -579,6 +585,20 @@ ws.git.diff({ to?: string, paths?: string[], }): Promise + +ws.git.diffSummary({ + dir?: string, + ref?: string, + to?: string, + paths?: string[], +}): Promise + +interface DiffSummaryEntry { + path: string; + status: "A" | "M" | "D"; + insertions: number; + deletions: number; +} ``` Three modes: @@ -588,15 +608,24 @@ Three modes: - `git diff ` — `` vs `` (commit pair). Paths after `--` filter the output. Matching is exact-or- -directory-prefix; globs are not supported. +directory-prefix; globs are not supported. The `` / `` +refs accept revision suffixes (`HEAD~1`). + +The summary flags share the same change set as the patch: + +| Flag | Output | +|---|---| +| `--stat` | Per-file `path \| total +++---` bar plus a `N files changed, …` footer. | +| `--name-only` | One changed path per line. | +| `--name-status` | `\t` per line. | -*Not mapped:* `--stat`, `--name-only`, `--cached`, `-U `, -the three-dot form. +*Not mapped:* `--cached`, `-U `, the three-dot form. ### `branch` ``` git branch # list +git branch --show-current # print the current branch git branch [] # create git branch -d # delete git branch --force # overwrite @@ -619,7 +648,8 @@ Bare `git branch` lists local branches with the current one prefixed `* `. The CLI mode is selected by positional shape: zero positionals lists, one creates at HEAD, two creates at a start point, `-d` switches to delete (and consumes one or -more positionals). +more positionals). `--show-current` prints the checked-out +branch (nothing on detached HEAD). ### `tag` @@ -649,6 +679,7 @@ preview. ``` git checkout [--force] [-- ...] +git checkout -b [] ``` ```ts @@ -663,11 +694,24 @@ ws.git.checkout({ With `paths` set, the working tree updates to match `ref` for those paths only; HEAD does not move (matching `git checkout -- `). Without `paths`, HEAD moves to -`ref`. +`ref`. `-b` creates a branch (optionally at a start point) +and switches to it; the branch is created first, so a name +collision leaves the working tree untouched. -*Not mapped:* `-b` create-and-switch (chain `branch ` -then `checkout `), `--detach`, `--orphan`, `--theirs`, -`--ours`. +*Not mapped:* `--detach`, `--orphan`, `--theirs`, `--ours`. + +### `switch` + +``` +git switch +git switch -c [] +``` + +The modern spelling of `checkout` for branch movement. `git +switch ` moves HEAD; `-c` is the `checkout -b` +equivalent. Both delegate to `checkout` / `branch` on the +typed surface. *Not mapped:* `--detach`, `-C` force-create, +`--orphan`. ### `fetch` @@ -883,38 +927,118 @@ A missing key returns `undefined` from `configGet` and exits --get`'s behavior. Only the local `/.git/config` file is read or written; global and system configs are not consulted. +### `reset` + +``` +git reset [-- ...] # unstage paths (against HEAD) +git reset --hard [] # restore tracked files to +``` + +```ts +ws.git.reset({ + dir?: string, + paths?: string[], + hard?: boolean, + ref?: string, // default "HEAD" +}): Promise +``` + +Path reset unstages the listed paths back to `ref` and leaves +the working tree alone (built on `resetIndex`). `--hard` +force-checks-out `ref`, rewriting both the index and working +tree. Without `--hard`, bare positionals are treated as +pathspecs; with `--hard`, a single positional is the ref. The +ref accepts revision suffixes (`HEAD~1`). *Not mapped:* +`--soft`, `--mixed` (both exit 129), `--merge`, `--keep`. + +### `stash` + +``` +git stash [push [-m ]] # stash tracked changes (bare = push) +git stash list # list entries +git stash pop # restore the latest entry +``` + +```ts +ws.git.stashPush({ dir?: string, message?: string }): Promise +ws.git.stashList({ dir?: string }): Promise +ws.git.stashPop({ dir?: string, index?: number }): Promise +``` + +`push` stashes tracked working-tree changes and restores a +clean tree; it creates a commit internally, so it needs a +resolvable identity (see [Identity](#identity)). `list` +returns `stash@{N}: ` entries newest-first. `pop` +restores the latest entry. *Not mapped:* `apply`, `drop`, +`clear`, `--include-untracked`, `stash@{N}` selectors on pop. + +### `clean` + +``` +git clean -f [-d] [-n|--dry-run] +``` + +```ts +ws.git.clean({ + dir?: string, + directories?: boolean, + dryRun?: boolean, +}): Promise // repo-relative paths removed +``` + +Removes untracked files under the repo and returns the paths +removed. Like real git, it refuses to act without `-f` unless +previewing with `-n` / `--dry-run`, and only descends into +untracked directories with `-d`. The untracked set is derived +from a status-matrix walk; ignored-file handling is not +modeled, so every untracked path is a candidate. *Not mapped:* +`-x` / `-X` ignore handling, pathspec limiting. + ## Flag-to-option mapping (alphabetical) | Flag | Subcommand | TS option | |---|---|---| -| `-A` / `--all` | (any list cmd) | *not mapped* | -| `-a` / `--all` (commit) | `commit` | *not mapped* | +| `-C ` | (global) | rewrites effective cwd | +| `-A` / `--all` | `add` | `all` | +| `--abbrev-ref` | `rev-parse` | (via `currentBranch`) | +| `-a` | `commit` | (stages tracked changes) | | `--amend` | `commit` | `amend` | | `--author` | `commit` | `author` | +| `-b ` | `checkout` | (create + switch) | | `-b ` (branch) | `clone`, `init` | `ref` / `defaultBranch` | | `--bare` | `init` | `bare` | | `--branch ` | `clone` | `ref` | +| `-c ` | `switch` | (create + switch) | | `--cached` | `rm` | (implicit) | +| `-d` | `clean` | `directories` | | `-d` / `--delete` | `branch`, `tag`, `push` | `branchDelete` / `tagDelete` / `delete` | | `--depth ` | `clone`, `fetch` | `depth` | +| `--dry-run` / `-n` | `clean` | `dryRun` | | `--ff-only` | `pull`, `merge` | `fastForwardOnly` | -| `-f` / `--force` | `add`, `branch`, `checkout`, `push`, `update-ref`, `remote add` | `force` | +| `-f` / `--force` | `add`, `branch`, `checkout`, `clean`, `push`, `update-ref`, `remote add` | `force` | | `--get-all` | `config` | `all` | +| `--hard` | `reset` | `hard` | | `--initial-branch` | `init` | `defaultBranch` | -| `-m ` | `commit`, `merge` | `message` | +| `-m ` | `commit`, `merge`, `stash push` | `message` | | `-n ` | `log` | `depth` | +| `-` (e.g. `-1`) | `log` | `depth` | +| `--name-only` | `diff` | (via `diffSummary`) | +| `--name-status` | `diff` | (via `diffSummary`) | | `--no-ff` | `pull`, `merge` | `fastForward: false` | | `--no-single-branch` | `clone` | `singleBranch: false` | | `--no-tags` | `clone`, `fetch` | `noTags` / `tags: false` | | `--oneline` | `log` | (formatter) | -| `--porcelain[=v2]` | `status` | (formatter) | +| `--porcelain[=v1\|v2]` | `status` | (formatter) | | `-p` | `cat-file` | (implicit) | | `--prune` | `fetch` | `prune` | | `-q` / `--quiet` | `symbolic-ref` | (formatter) | | `--ref ` | `ls-files` | `ref` | | `--short` | `symbolic-ref`, `status` | `fullname: false` / (formatter) | +| `--show-current` | `branch` | (via `currentBranch`) | +| `--show-toplevel` | `rev-parse` | (via `repoRoot`) | | `-s` | `status` | (formatter) | | `--single-branch` | `clone` | `singleBranch` | +| `--stat` | `diff` | (via `diffSummary`) | | `--stdin` | `hash-object` | (implicit) | | `--tags` | `clone`, `fetch` | `noTags: false` / `tags: true` | | `--unset` | `config` | `value: undefined` | From a5c7b1e72aa2e83b0d801d5e49d4a4de4116cceb Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:40:44 +0000 Subject: [PATCH 17/20] workspace: add an edit/commit smoke test Exercise the expanded shell git surface end to end through a real Workspace: configure identity in local config, stage with add -A, commit with -am, inspect with diff --stat / --name-only and a revision suffix, branch with switch -c, then reset --hard, stash, and clean -fd. The smoke test keeps the agent-oriented edit/commit workflow covered as the shell git surface grows. --- packages/workspace/src/git/cli.test.ts | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 3872c6d8..57bf0da9 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -2147,6 +2147,69 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { expect(res.stderr).toContain("author identity unknown"); }); + it("agent edit/commit loop: config identity, -A, commit -am, diff --stat, switch -c", async () => { + // Exercises the Phase 1-4 surface end to end through the + // shell-facing CLI against a real Workspace, the way an agent + // would drive it: configure identity, stage with -A, commit + // with -am, inspect with the new flags, branch with switch + // -c, then reset / stash / clean. + const ws = new Workspace({ storage: new SQLiteTestStorage() }); + await ws.ready(); + const cli = (argv: string[]) => ws.git.cli({ argv, cwd: "/" }); + + await cli(["init"]); + // Identity comes from local config, not a default identity. + expect((await cli(["config", "user.name", "Agent"])).exitCode).toBe(0); + expect((await cli(["config", "user.email", "agent@example.test"])).exitCode).toBe(0); + + // -A stages a brand new file; commit reads the config identity. + await ws.fs.writeFile("/a.txt", "one\n"); + expect((await cli(["add", "-A"])).exitCode).toBe(0); + const c1 = await cli(["commit", "-m", "init"]); + expect(c1.exitCode, c1.stderr).toBe(0); + + // branch --show-current works on the symbolic HEAD. + expect((await cli(["branch", "--show-current"])).stdout).toBe("main\n"); + // rev-parse --show-toplevel finds the root. + expect((await cli(["rev-parse", "--show-toplevel"])).stdout).toBe("/\n"); + + // Modify the tracked file and commit with -am in one step. + await ws.fs.writeFile("/a.txt", "one\ntwo\n"); + const c2 = await cli(["commit", "-am", "second"]); + expect(c2.exitCode, c2.stderr).toBe(0); + + // diff --stat / --name-only against the previous commit via a + // revision suffix. + const stat = await cli(["diff", "--stat", "HEAD~1", "HEAD"]); + expect(stat.exitCode).toBe(0); + expect(stat.stdout).toContain("a.txt"); + expect(stat.stdout).toContain("1 file changed"); + const names = await cli(["diff", "--name-only", "HEAD~1", "HEAD"]); + expect(names.stdout).toBe("a.txt\n"); + + // log -1 --oneline shorthand. + const log = await cli(["log", "-1", "--oneline"]); + expect(log.stdout.trim()).toMatch(/^[0-9a-f]{7} second$/); + + // switch -c creates and moves onto a new branch. + expect((await cli(["switch", "-c", "feature"])).exitCode).toBe(0); + expect((await cli(["branch", "--show-current"])).stdout).toBe("feature\n"); + + // Stage a change, then reset --hard restores it. + await ws.fs.writeFile("/a.txt", "dirty\n"); + await cli(["add", "-A"]); + expect((await cli(["reset", "--hard", "HEAD"])).exitCode).toBe(0); + expect(await ws.fs.readFile("/a.txt", "utf8")).toBe("one\ntwo\n"); + + // clean -fd removes untracked junk. + await ws.fs.writeFile("/junk.txt", "junk\n"); + const clean = await cli(["clean", "-fd"]); + expect(clean.exitCode).toBe(0); + expect(clean.stdout).toContain("Removing junk.txt"); + const statusAfter = await cli(["status", "--porcelain=v1"]); + expect(statusAfter.stdout).toBe(""); + }); + it("a clone failure surfaces as exit 1 on stderr", async () => { // Force the clone path to fail by pointing at an invalid host; // we want to pin that the dispatcher's catch arm produces a From 382e7f7973a404c00a7c87dcbcd96f11eda4e91d Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:23:35 +0000 Subject: [PATCH 18/20] workspace: fix hard reset and reset HEAD Fix reset --hard so it preserves a symbolic HEAD while moving the current branch to the target commit, then checking that branch out to restore tracked file content. Also make git reset HEAD unstage all staged paths instead of silently treating HEAD as a pathspec. Strengthen the real-Workspace smoke coverage for branch switching, ancestor hard reset, reset HEAD, and stash push/list/pop. Tighten diff --stat counting so content lines beginning with diff-header prefixes are counted inside hunks, and pin the stat formatter with an exact assertion. Consolidate status-matrix tuple typing across modules and replace a few inline import types with explicit type imports. --- docs/13_git_interface.md | 10 ++-- packages/workspace/src/git/cli.test.ts | 70 ++++++++++++++++++++++--- packages/workspace/src/git/cli.ts | 29 +++++----- packages/workspace/src/git/diff.test.ts | 8 +++ packages/workspace/src/git/diff.ts | 36 ++++++------- packages/workspace/src/git/staging.ts | 9 ++-- packages/workspace/src/git/status.ts | 7 ++- packages/workspace/src/git/worktree.ts | 56 ++++++++++++++++---- 8 files changed, 166 insertions(+), 59 deletions(-) diff --git a/docs/13_git_interface.md b/docs/13_git_interface.md index b68356a3..54f2f278 100644 --- a/docs/13_git_interface.md +++ b/docs/13_git_interface.md @@ -944,11 +944,11 @@ ws.git.reset({ ``` Path reset unstages the listed paths back to `ref` and leaves -the working tree alone (built on `resetIndex`). `--hard` -force-checks-out `ref`, rewriting both the index and working -tree. Without `--hard`, bare positionals are treated as -pathspecs; with `--hard`, a single positional is the ref. The -ref accepts revision suffixes (`HEAD~1`). *Not mapped:* +the working tree alone (built on `resetIndex`). Bare `git reset` +and `git reset HEAD` unstage all staged paths. `--hard` moves the +current branch (when HEAD is symbolic) to `ref` and rewrites both +the index and working tree. With `--hard`, a single positional is +the ref. Refs accept revision suffixes (`HEAD~1`). *Not mapped:* `--soft`, `--mixed` (both exit 129), `--merge`, `--keep`. ### `stash` diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 57bf0da9..6b72953a 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -693,12 +693,9 @@ describe("runGitCli — diff argv parsing", () => { ); const res = await runGitCli(client, { argv: ["diff", "--stat"] }); expect(res.exitCode).toBe(0); - expect(res.stdout).toContain("a.txt"); - expect(res.stdout).toContain("b.txt"); - // Summary footer: total files changed and line counts. - expect(res.stdout).toContain("2 files changed"); - expect(res.stdout).toContain("5 insertions(+)"); - expect(res.stdout).toContain("1 deletion(-)"); + expect(res.stdout).toBe( + " a.txt | 4 +++-\n b.txt | 2 ++\n 2 files changed, 5 insertions(+), 1 deletion(-)\n", + ); }); it("--stat emits nothing for an empty change set", async () => { @@ -2136,6 +2133,50 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { expect(branches2.stdout).toBe("* main\n"); }); + it("switch restores tracked file content from the target branch", async () => { + const ws = new Workspace({ + storage: new SQLiteTestStorage(), + defaultGitIdentity: { name: "Test", email: "test@example.test" }, + }); + await ws.ready(); + const cli = (argv: string[]) => ws.git.cli({ argv, cwd: "/" }); + await cli(["init"]); + await ws.fs.writeFile("/a.txt", "main\n"); + await cli(["add", "a.txt"]); + await cli(["commit", "-m", "main"]); + + await cli(["switch", "-c", "feature"]); + await ws.fs.writeFile("/a.txt", "feature\n"); + await cli(["commit", "-am", "feature"]); + + const switched = await cli(["switch", "main"]); + expect(switched.exitCode, switched.stderr).toBe(0); + expect(await ws.fs.readFile("/a.txt", "utf8")).toBe("main\n"); + expect((await cli(["branch", "--show-current"])).stdout).toBe("main\n"); + }); + + it("reset HEAD unstages all staged changes", async () => { + const ws = new Workspace({ + storage: new SQLiteTestStorage(), + defaultGitIdentity: { name: "Test", email: "test@example.test" }, + }); + await ws.ready(); + const cli = (argv: string[]) => ws.git.cli({ argv, cwd: "/" }); + await cli(["init"]); + await ws.fs.writeFile("/a.txt", "one\n"); + await cli(["add", "a.txt"]); + await cli(["commit", "-m", "init"]); + + await ws.fs.writeFile("/a.txt", "two\n"); + await ws.fs.writeFile("/b.txt", "new\n"); + await cli(["add", "-A"]); + expect((await cli(["status", "--porcelain=v1"])).stdout).toContain("A"); + + const reset = await cli(["reset", "HEAD"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect((await cli(["status", "--porcelain=v1"])).stdout).toContain("?? b.txt\n"); + }); + it("commit without identity surfaces as exit 128", async () => { const ws = new Workspace({ storage: new SQLiteTestStorage() }); await ws.ready(); @@ -2201,6 +2242,23 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { expect((await cli(["reset", "--hard", "HEAD"])).exitCode).toBe(0); expect(await ws.fs.readFile("/a.txt", "utf8")).toBe("one\ntwo\n"); + // reset --hard to an ancestor restores content and keeps HEAD attached. + const resetAncestor = await cli(["reset", "--hard", "HEAD~1"]); + expect(resetAncestor.exitCode, resetAncestor.stderr).toBe(0); + expect(await ws.fs.readFile("/a.txt", "utf8")).toBe("one\n"); + expect((await cli(["branch", "--show-current"])).stdout).toBe("feature\n"); + + // stash pushes a dirty tracked change, lists it, and pops it back. + await ws.fs.writeFile("/a.txt", "stashed\n"); + const stash = await cli(["stash", "push", "-m", "wip"]); + expect(stash.exitCode, stash.stderr).toBe(0); + expect(await ws.fs.readFile("/a.txt", "utf8")).toBe("one\n"); + expect((await cli(["stash", "list"])).stdout).toContain("wip"); + const pop = await cli(["stash", "pop"]); + expect(pop.exitCode, pop.stderr).toBe(0); + expect(await ws.fs.readFile("/a.txt", "utf8")).toBe("stashed\n"); + expect((await cli(["reset", "--hard", "HEAD"])).exitCode).toBe(0); + // clean -fd removes untracked junk. await ws.fs.writeFile("/junk.txt", "junk\n"); const clean = await cli(["clean", "-fd"]); diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 4639aa7e..44b3c1d1 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -20,7 +20,7 @@ import { NotARepositoryError, PathspecNotFoundError, } from "./errors.js"; -import type { GitClient, GitIdentity } from "./index.js"; +import type { CommitView, DiffSummaryEntry, GitClient, GitIdentity, StatusEntry } from "./index.js"; import { formatPorcelainV1, formatPorcelainV2, formatShort } from "./status.js"; export interface GitCliInput { @@ -385,7 +385,7 @@ async function runDiff( } } -type DiffSummary = import("./diff.js").DiffSummaryEntry; +type DiffSummary = DiffSummaryEntry; /** `--name-only`: one changed path per line. */ function formatDiffNameOnly(entries: DiffSummary[]): string { @@ -521,7 +521,7 @@ async function runStatus( }; } const dir = resolveDir(undefined, input.cwd); - let entries: import("./status.js").StatusEntry[]; + let entries: StatusEntry[]; try { entries = await client.status({ dir }); } catch (cause) { @@ -804,12 +804,12 @@ async function runLog( } } -function formatLogOneline(commits: import("./reads.js").CommitView[]): string { +function formatLogOneline(commits: CommitView[]): string { if (commits.length === 0) return ""; return `${commits.map((c) => `${c.oid.slice(0, 7)} ${firstLine(c.message)}`).join("\n")}\n`; } -function formatLogFull(commits: import("./reads.js").CommitView[]): string { +function formatLogFull(commits: CommitView[]): string { if (commits.length === 0) return ""; const blocks: string[] = []; for (const c of commits) { @@ -2012,16 +2012,15 @@ async function runReset( const dir = resolveDir(undefined, input.cwd); const hard = parsed.flags.hard === true; - // A leading positional that isn't after `--` is the ref; the - // rest (or everything after `--`) are paths. + // A leading positional before `--` can be a ref; everything + // after `--` is paths. Real git is more context-sensitive than + // this subset, but handle the ubiquitous `git reset HEAD` + // spelling explicitly so it resets all staged changes instead + // of silently treating HEAD as a pathspec. let ref: string | undefined; let paths = pathArgs; if (sep === -1) { - // No `--`: a single positional is the ref for `--hard`, or a - // pathspec otherwise. Real git is context-sensitive here; for - // the supported subset we treat positionals as the ref when - // `--hard`, else as paths. - if (hard) { + if (hard || isResetRefOnly(positional)) { ref = positional[0]; } else { paths = positional; @@ -2044,6 +2043,12 @@ async function runReset( } } +function isResetRefOnly(positional: string[]): boolean { + if (positional.length !== 1) return false; + const value = positional[0]; + return value === "HEAD" || hasRevisionSuffix(value); +} + // --------------------------------------------------------------- // clean // --------------------------------------------------------------- diff --git a/packages/workspace/src/git/diff.test.ts b/packages/workspace/src/git/diff.test.ts index 6f828d81..02cf365c 100644 --- a/packages/workspace/src/git/diff.test.ts +++ b/packages/workspace/src/git/diff.test.ts @@ -314,4 +314,12 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => { const entries = await summary({ ref: first, to: second }); expect(entries).toEqual([{ path: "new.txt", status: "A", insertions: 1, deletions: 0 }]); }); + + it("counts content lines that begin with diff header prefixes", async () => { + await init(); + await commitFile("patch.txt", "-- old old\n", "v1"); + await memfs.promises.writeFile(`${DIR}/patch.txt`, "++ new\n"); + const entries = await summary(); + expect(entries).toEqual([{ path: "patch.txt", status: "M", insertions: 1, deletions: 1 }]); + }); }); diff --git a/packages/workspace/src/git/diff.ts b/packages/workspace/src/git/diff.ts index 36a97d48..19d9c1da 100644 --- a/packages/workspace/src/git/diff.ts +++ b/packages/workspace/src/git/diff.ts @@ -9,6 +9,7 @@ // `diff` so both stay optional peer deps. import type { IsomorphicGitFSClient } from "./adapter.js"; +import type { StatusMatrixRow } from "./status.js"; /** * Status-matrix row as emitted by isomorphic-git's `statusMatrix`: @@ -18,7 +19,7 @@ import type { IsomorphicGitFSClient } from "./adapter.js"; * - 2 = differs from HEAD * - 3 = differs from HEAD and stage (rarely meaningful here) */ -export type StatusRow = [string, number, number, number]; +export type StatusRow = StatusMatrixRow; /** Subset of isomorphic-git's API used to compute a working-tree diff. */ export interface IsomorphicGitDiffClient { @@ -28,7 +29,7 @@ export interface IsomorphicGitDiffClient { dir: string; ref?: string; cache?: object; - }): Promise; + }): Promise; readBlob(args: { fs: object; dir: string; @@ -36,6 +37,7 @@ export interface IsomorphicGitDiffClient { filepath: string; cache?: object; }): Promise<{ blob: Uint8Array; oid: string }>; + listFiles?(args: { fs: object; dir: string; ref?: string }): Promise; } /** Signature compatible with the `diff` package's `createPatch`. */ @@ -205,12 +207,8 @@ async function collectRefToRef( } catch { return []; } - const fromFiles = new Set( - await listFilesAt(opts.git as unknown as IsomorphicGitDiffWithListFiles, opts.fs, dir, from), - ); - const toFiles = new Set( - await listFilesAt(opts.git as unknown as IsomorphicGitDiffWithListFiles, opts.fs, dir, to), - ); + const fromFiles = new Set(await listFilesAt(opts.git, opts.fs, dir, from)); + const toFiles = new Set(await listFilesAt(opts.git, opts.fs, dir, to)); const union = new Set([...fromFiles, ...toFiles]); const pathFilter = makePathFilter(opts.paths); @@ -231,28 +229,30 @@ async function collectRefToRef( } /** - * Count added / removed content lines in a unified patch. Skips - * the `+++` / `---` file headers; everything else prefixed `+` or - * `-` is a content line. Good enough for `--stat`'s numeric - * column, which is all the CLI needs. + * Count added / removed content lines in a unified patch. Only + * count lines inside hunks (after an `@@` header) so file headers + * are ignored while real content that begins with `+++` / `---` + * is still counted. Good enough for `--stat`'s numeric column, + * which is all the CLI needs. */ function countChanges(patch: string): { insertions: number; deletions: number } { let insertions = 0; let deletions = 0; + let inHunk = false; for (const line of patch.split("\n")) { - if (line.startsWith("+++") || line.startsWith("---")) continue; + if (line.startsWith("@@")) { + inHunk = true; + continue; + } + if (!inHunk) continue; if (line.startsWith("+")) insertions++; else if (line.startsWith("-")) deletions++; } return { insertions, deletions }; } -interface IsomorphicGitDiffWithListFiles extends IsomorphicGitDiffClient { - listFiles(args: { fs: object; dir: string; ref?: string }): Promise; -} - async function listFilesAt( - git: IsomorphicGitDiffWithListFiles, + git: IsomorphicGitDiffClient, fs: object, dir: string, ref: string, diff --git a/packages/workspace/src/git/staging.ts b/packages/workspace/src/git/staging.ts index c1515fc4..15b2e88e 100644 --- a/packages/workspace/src/git/staging.ts +++ b/packages/workspace/src/git/staging.ts @@ -11,6 +11,7 @@ import { NotARepositoryError, PathspecNotFoundError, } from "./errors.js"; +import type { StatusMatrixRow } from "./status.js"; /** Subset of `isomorphic-git`'s API used for `add`. */ export interface IsomorphicGitAddClient { @@ -22,11 +23,7 @@ export interface IsomorphicGitAddClient { force?: boolean; }): Promise; /** Used by `all` mode to enumerate changed paths. */ - statusMatrix(args: { - fs: object; - dir: string; - cache?: object; - }): Promise>; + statusMatrix(args: { fs: object; dir: string; cache?: object }): Promise; /** Used by `all` mode to stage deletions. */ remove(args: { fs: object; dir: string; filepath: string; cache?: object }): Promise; } @@ -109,7 +106,7 @@ export async function addWith(opts: AddWithDeps): Promise { * stage]`; `workdir === 0` means the file is gone from disk. */ async function addAll(opts: AddWithDeps, dir: string): Promise { - let matrix: Array<[string, number, number, number]>; + let matrix: StatusMatrixRow[]; try { matrix = await opts.git.statusMatrix({ fs: opts.fs, dir, cache: opts.cache }); } catch (cause) { diff --git a/packages/workspace/src/git/status.ts b/packages/workspace/src/git/status.ts index 82353434..96a7dad8 100644 --- a/packages/workspace/src/git/status.ts +++ b/packages/workspace/src/git/status.ts @@ -21,7 +21,12 @@ import { GitError, isNotARepositoryCause, NotARepositoryError } from "./errors.js"; -export type StatusMatrixRow = [string, number, number, number]; +export type StatusMatrixRow = [ + filepath: string, + headStatus: number, + workdirStatus: number, + stageStatus: number, +]; /** Subset of `isomorphic-git`'s API used for `status`. */ export interface IsomorphicGitStatusClient { diff --git a/packages/workspace/src/git/worktree.ts b/packages/workspace/src/git/worktree.ts index 038ba894..6119efbc 100644 --- a/packages/workspace/src/git/worktree.ts +++ b/packages/workspace/src/git/worktree.ts @@ -8,6 +8,7 @@ // missing. import { GitError, isNotARepositoryCause, NotARepositoryError } from "./errors.js"; +import type { StatusMatrixRow } from "./status.js"; // --------------------------------------------------------------- // stash @@ -106,6 +107,16 @@ export interface IsomorphicGitResetClient { force?: boolean; cache?: object; }): Promise; + resolveRef(args: { fs: object; dir: string; ref: string }): Promise; + writeRef(args: { + fs: object; + dir: string; + ref: string; + value: string; + force?: boolean; + }): Promise; + currentBranch(args: { fs: object; dir: string; fullname?: boolean }): Promise; + statusMatrix(args: { fs: object; dir: string; cache?: object }): Promise; } export interface ResetOptions extends BaseWorktreeOptions { @@ -135,14 +146,15 @@ export async function resetWith(opts: ResetWithDeps): Promise { const ref = opts.ref ?? "HEAD"; try { if (opts.hard) { - // Hard reset: force-checkout the ref, which rewrites both - // the index and the working tree to match. - await opts.git.checkout({ fs: opts.fs, dir, ref, force: true, cache: opts.cache }); + await hardReset(opts, dir, ref); return; } // Path reset: unstage each path back to `ref` without - // touching the working tree. - for (const filepath of opts.paths ?? []) { + // touching the working tree. When no paths are supplied, + // reset every staged entry — the common `git reset` / `git + // reset HEAD` behavior. + const paths = opts.paths ?? (await stagedPaths(opts, dir)); + for (const filepath of paths) { await opts.git.resetIndex({ fs: opts.fs, dir, filepath, ref, cache: opts.cache }); } } catch (cause) { @@ -151,17 +163,39 @@ export async function resetWith(opts: ResetWithDeps): Promise { } } +async function hardReset(opts: ResetWithDeps, dir: string, ref: string): Promise { + // Real `git reset --hard ` moves the current branch to the + // target commit when HEAD is symbolic, then rewrites the index + // and work tree. A plain checkout of a resolved oid would leave + // HEAD detached, so update the branch ref first and then + // checkout that branch name to materialize the tree. + const oid = await opts.git.resolveRef({ fs: opts.fs, dir, ref }); + const branch = await opts.git.currentBranch({ fs: opts.fs, dir, fullname: true }); + if (branch) { + await opts.git.writeRef({ fs: opts.fs, dir, ref: branch, value: oid, force: true }); + await opts.git.checkout({ fs: opts.fs, dir, ref: branch, force: true, cache: opts.cache }); + return; + } + await opts.git.writeRef({ fs: opts.fs, dir, ref: "HEAD", value: oid, force: true }); + await opts.git.checkout({ fs: opts.fs, dir, ref: "HEAD", force: true, cache: opts.cache }); +} + +async function stagedPaths(opts: ResetWithDeps, dir: string): Promise { + const matrix = await opts.git.statusMatrix({ fs: opts.fs, dir, cache: opts.cache }); + const paths: string[] = []; + for (const [filepath, head, _workdir, stage] of matrix) { + if (stage !== head) paths.push(filepath); + } + return paths; +} + // --------------------------------------------------------------- // clean // --------------------------------------------------------------- /** Subset of `isomorphic-git`'s API used for `clean`. */ export interface IsomorphicGitCleanClient { - statusMatrix(args: { - fs: object; - dir: string; - cache?: object; - }): Promise>; + statusMatrix(args: { fs: object; dir: string; cache?: object }): Promise; } /** `fs.promises` surface used to remove paths. */ @@ -198,7 +232,7 @@ export interface CleanWithDeps extends CleanOptions { */ export async function cleanWith(opts: CleanWithDeps): Promise { const dir = opts.dir ?? "/"; - let matrix: Array<[string, number, number, number]>; + let matrix: StatusMatrixRow[]; try { matrix = await opts.git.statusMatrix({ fs: opts.fs, dir, cache: opts.cache }); } catch (cause) { From 96bd0259f33c18a51d710d7a05439f71ef4722f1 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:22:33 +0000 Subject: [PATCH 19/20] workspace: reject mixed reset mode Return exit 129 for git reset --mixed so the CLI matches its documented unsupported-mode contract instead of silently treating the command like a bare reset. Cover hard reset from detached HEAD and reset that path by checking out the resolved oid directly, avoiding a write through the symbolic HEAD name. --- packages/workspace/src/git/cli.test.ts | 8 ++++++++ packages/workspace/src/git/cli.ts | 3 +++ packages/workspace/src/git/worktree.test.ts | 16 ++++++++++++++++ packages/workspace/src/git/worktree.ts | 3 +-- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/workspace/src/git/cli.test.ts b/packages/workspace/src/git/cli.test.ts index 6b72953a..82d49363 100644 --- a/packages/workspace/src/git/cli.test.ts +++ b/packages/workspace/src/git/cli.test.ts @@ -1891,6 +1891,14 @@ describe("runGitCli — reset argv parsing", () => { expect(res.exitCode).toBe(129); expect(res.stderr).toContain("--soft is not supported"); }); + + it("--mixed is rejected as unsupported", async () => { + const { client, calls } = fakeClient(); + const res = await runGitCli(client, { argv: ["reset", "--mixed", "HEAD"] }); + expect(res.exitCode).toBe(129); + expect(res.stderr).toContain("--mixed is not supported"); + expect(calls.reset).toEqual([]); + }); }); describe("runGitCli — clean argv parsing", () => { diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index 44b3c1d1..878666d7 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -2005,6 +2005,9 @@ async function runReset( if (parsed.flags.soft === true) { return { stdout: "", stderr: "git reset: --soft is not supported\n", exitCode: 129 }; } + if (parsed.flags.mixed === true) { + return { stdout: "", stderr: "git reset: --mixed is not supported\n", exitCode: 129 }; + } const sep = args.indexOf("--"); const positional = sep === -1 ? parsed.positional : args.slice(0, sep).filter((a) => !a.startsWith("-")); diff --git a/packages/workspace/src/git/worktree.test.ts b/packages/workspace/src/git/worktree.test.ts index eb099209..2b3aadcc 100644 --- a/packages/workspace/src/git/worktree.test.ts +++ b/packages/workspace/src/git/worktree.test.ts @@ -114,6 +114,22 @@ describe("resetWith", () => { expect(await statusOf("a.txt")).toEqual([1, 1, 1]); }); + it("hard reset works from detached HEAD", async () => { + await init(); + const first = await commit("a.txt", "v1\n", "first"); + const second = await commit("a.txt", "v2\n", "second"); + await git.checkout({ fs: memfs, dir: DIR, ref: second }); + expect(await git.currentBranch({ fs: memfs, dir: DIR })).toBeUndefined(); + await memfs.promises.writeFile(`${DIR}/a.txt`, "dirty\n"); + + await resetWith({ git: resetClient, fs: memfs, dir: DIR, hard: true, ref: first }); + + expect(await git.resolveRef({ fs: memfs, dir: DIR, ref: "HEAD" })).toBe(first); + expect(await git.currentBranch({ fs: memfs, dir: DIR })).toBeUndefined(); + expect(await memfs.promises.readFile(`${DIR}/a.txt`, "utf8")).toBe("v1\n"); + expect(await statusOf("a.txt")).toEqual([1, 1, 1]); + }); + it("hard reset throws NotARepositoryError outside a repo", async () => { await memfs.promises.mkdir("/loose", { recursive: true }); await expect( diff --git a/packages/workspace/src/git/worktree.ts b/packages/workspace/src/git/worktree.ts index 6119efbc..cd0854de 100644 --- a/packages/workspace/src/git/worktree.ts +++ b/packages/workspace/src/git/worktree.ts @@ -176,8 +176,7 @@ async function hardReset(opts: ResetWithDeps, dir: string, ref: string): Promise await opts.git.checkout({ fs: opts.fs, dir, ref: branch, force: true, cache: opts.cache }); return; } - await opts.git.writeRef({ fs: opts.fs, dir, ref: "HEAD", value: oid, force: true }); - await opts.git.checkout({ fs: opts.fs, dir, ref: "HEAD", force: true, cache: opts.cache }); + await opts.git.checkout({ fs: opts.fs, dir, ref: oid, force: true, cache: opts.cache }); } async function stagedPaths(opts: ResetWithDeps, dir: string): Promise { From 2e19823787c0a50fb38466c0a72cb8d1d116f9ab Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:05:49 +0000 Subject: [PATCH 20/20] workspace: stage deleted new files with add all When a newly staged file is removed from disk before git add -A, remove its index entry instead of leaving the deleted file staged. This keeps the index aligned with the working tree for staged files that never existed in HEAD. The trackedOnly guard still preserves commit -a behavior by leaving staged-but-untracked paths alone. --- packages/workspace/src/git/staging.test.ts | 19 +++++++++++++++++++ packages/workspace/src/git/staging.ts | 7 ++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/workspace/src/git/staging.test.ts b/packages/workspace/src/git/staging.test.ts index cdaf9e92..6aa4410a 100644 --- a/packages/workspace/src/git/staging.test.ts +++ b/packages/workspace/src/git/staging.test.ts @@ -101,6 +101,25 @@ describe("addWith", () => { expect(await statusOf("gone.txt")).toEqual([1, 0, 0]); }); + it("all: true unstages a new file that was deleted after staging", async () => { + await init(); + await memfs.promises.writeFile(`${DIR}/new.txt`, "n1\n"); + await git.add({ fs: memfs, dir: DIR, filepath: "new.txt" }); + expect(await statusOf("new.txt")).toEqual([0, 2, 2]); + await memfs.promises.unlink(`${DIR}/new.txt`); + expect(await statusOf("new.txt")).toEqual([0, 0, 3]); + + await addWith({ + git: git as unknown as IsomorphicGitAddClient, + fs: memfs, + dir: DIR, + paths: [], + all: true, + }); + + expect(await statusOf("new.txt")).toBeUndefined(); + }); + it("all + trackedOnly stages tracked changes but leaves untracked files alone", async () => { await init(); await memfs.promises.writeFile(`${DIR}/keep.txt`, "k1\n"); diff --git a/packages/workspace/src/git/staging.ts b/packages/workspace/src/git/staging.ts index 15b2e88e..dd21ab4e 100644 --- a/packages/workspace/src/git/staging.ts +++ b/packages/workspace/src/git/staging.ts @@ -121,9 +121,10 @@ async function addAll(opts: AddWithDeps, dir: string): Promise { // so untracked files (head === 0) are left alone. if (opts.trackedOnly && head !== 1) continue; if (workdir === 0) { - // Gone from the working tree. Only stage the deletion when - // it isn't already staged (head present, stage present). - if (head === 1 && stage !== 0) toRemove.push(filepath); + // Gone from the working tree. Remove any staged entry so + // the index matches the absence on disk. trackedOnly above + // keeps `commit -a` from touching staged-but-untracked paths. + if (stage !== 0) toRemove.push(filepath); continue; } // Present on disk and differs from the staged copy.