diff --git a/docs/13_git_interface.md b/docs/13_git_interface.md index 5cf394d4..54f2f278 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`). 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` + +``` +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` | 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..82d49363 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, @@ -61,6 +62,7 @@ import type { GitLogOptions, GitLsFilesOptions, GitLsTreeOptions, + GitRepoRootOptions, GitRevParseOptions, GitShowOptions, TreeEntryView, @@ -80,6 +82,7 @@ import type { GitStatusOptions, StatusEntry } from "./status.js"; interface FakeCalls { clone: GitCloneOptions[]; diff: GitDiffOptions[]; + diffSummary: GitDiffOptions[]; init: GitInitOptions[]; status: GitStatusOptions[]; add: GitAddOptions[]; @@ -88,6 +91,7 @@ interface FakeCalls { log: GitLogOptions[]; show: GitShowOptions[]; revParse: GitRevParseOptions[]; + repoRoot: GitRepoRootOptions[]; currentBranch: GitCurrentBranchOptions[]; lsFiles: GitLsFilesOptions[]; lsTree: GitLsTreeOptions[]; @@ -110,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( @@ -119,6 +128,8 @@ function fakeClient( log?: () => CommitView[]; show?: () => CommitView; revParse?: () => string; + repoRoot?: () => string; + diffSummary?: () => import("./diff.js").DiffSummaryEntry[]; currentBranch?: () => string | undefined; lsFiles?: () => string[]; lsTree?: () => TreeEntryView[]; @@ -131,6 +142,8 @@ function fakeClient( hashObject?: () => string; catFile?: () => CatFileResult; configGet?: () => string | string[] | undefined; + stashList?: () => string[]; + clean?: () => string[]; } = {}, ): { client: GitClient; @@ -139,6 +152,7 @@ function fakeClient( const calls: FakeCalls = { clone: [], diff: [], + diffSummary: [], init: [], status: [], add: [], @@ -147,6 +161,7 @@ function fakeClient( log: [], show: [], revParse: [], + repoRoot: [], currentBranch: [], lsFiles: [], lsTree: [], @@ -169,6 +184,11 @@ function fakeClient( updateRef: [], configGet: [], configSet: [], + stashPush: [], + stashList: [], + stashPop: [], + reset: [], + clean: [], }; const client: GitClient = { async clone(options) { @@ -178,6 +198,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); }, @@ -216,6 +240,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?.(); @@ -299,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"); }, @@ -340,19 +385,65 @@ 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 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 +452,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, { @@ -486,6 +614,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"] }); @@ -512,6 +648,76 @@ 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).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 () => { + 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", () => { @@ -600,6 +806,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"] }); @@ -630,7 +858,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 () => { @@ -720,6 +948,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() { @@ -791,6 +1052,32 @@ describe("runGitCli — log argv parsing", () => { expect(res.exitCode).toBe(129); 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"] }); + 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", () => { @@ -806,6 +1093,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"] }); @@ -820,6 +1114,50 @@ 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("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"] }); @@ -952,6 +1290,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", () => { @@ -1012,6 +1366,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", () => { @@ -1397,6 +1816,117 @@ 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"); + }); + + 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", () => { + 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. @@ -1611,6 +2141,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(); @@ -1622,6 +2196,86 @@ 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"); + + // 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"]); + 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 diff --git a/packages/workspace/src/git/cli.ts b/packages/workspace/src/git/cli.ts index c0a94be9..878666d7 100644 --- a/packages/workspace/src/git/cli.ts +++ b/packages/workspace/src/git/cli.ts @@ -20,8 +20,8 @@ import { NotARepositoryError, PathspecNotFoundError, } from "./errors.js"; -import type { GitClient, GitIdentity } from "./index.js"; -import { formatPorcelainV2, formatShort } from "./status.js"; +import type { CommitView, DiffSummaryEntry, GitClient, GitIdentity, StatusEntry } from "./index.js"; +import { formatPorcelainV1, formatPorcelainV2, formatShort } from "./status.js"; export interface GitCliInput { /** Argv as seen by the shell command. `argv[0]` is the subcommand. */ @@ -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": @@ -106,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": @@ -124,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: "", @@ -146,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.", @@ -160,10 +179,13 @@ 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.", " tag Create, delete, or list tags.", " update-ref Write a ref directly.", @@ -226,7 +248,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) { @@ -281,13 +320,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 @@ -307,11 +354,30 @@ 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 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: from, - to, - paths: pathArgs.length > 0 ? pathArgs : undefined, + ref: fromResolved, + to: toResolved, + paths, }); return { stdout: output, stderr: "", exitCode: 0 }; } catch (cause) { @@ -319,6 +385,60 @@ async function runDiff( } } +type DiffSummary = 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 // --------------------------------------------------------------- @@ -384,13 +504,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`, @@ -398,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) { @@ -406,9 +529,9 @@ async function runStatus( } const stdout = useShort ? formatShort(entries) - : v2 - ? formatPorcelainV2(entries) - : formatShort(entries); + : isV1 + ? formatPorcelainV1(entries) + : formatPorcelainV2(entries); return { stdout, stderr: "", exitCode: 0 }; } @@ -421,19 +544,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); } @@ -481,11 +613,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 }; @@ -520,6 +658,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, @@ -539,6 +683,45 @@ 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. + */ +/** + * 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) { + 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 @@ -562,16 +745,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: "", @@ -593,7 +795,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) { @@ -601,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) { @@ -662,7 +865,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); @@ -678,10 +882,26 @@ async function runRevParse( args: string[], input: GitCliInput, ): Promise { - const parsed = parseFlags(args, {}); + 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 }; } @@ -693,8 +913,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); @@ -837,6 +1077,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 }; @@ -845,6 +1086,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 { @@ -982,11 +1234,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 }; @@ -995,6 +1247,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 }; } @@ -1005,7 +1267,6 @@ async function runCheckout( exitCode: 129, }; } - const dir = resolveDir(undefined, input.cwd); try { await client.checkout({ dir, @@ -1019,6 +1280,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 // --------------------------------------------------------------- @@ -1594,6 +1926,179 @@ 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 }; + } + 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("-")); + const pathArgs = sep === -1 ? [] : args.slice(sep + 1); + const dir = resolveDir(undefined, input.cwd); + const hard = parsed.flags.hard === true; + + // 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) { + if (hard || isResetRefOnly(positional)) { + 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); + } +} + +function isResetRefOnly(positional: string[]): boolean { + if (positional.length !== 1) return false; + const value = positional[0]; + return value === "HEAD" || hasRevisionSuffix(value); +} + +// --------------------------------------------------------------- +// 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 // --------------------------------------------------------------- @@ -1663,6 +2168,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[] = []; @@ -1757,6 +2302,54 @@ 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; +} + +/** 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://"); } 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, }); } 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); diff --git a/packages/workspace/src/git/diff.test.ts b/packages/workspace/src/git/diff.test.ts index 3eeab9d6..02cf365c 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,67 @@ 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 }]); + }); + + 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 1a553176..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`. */ @@ -90,25 +92,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,78 +168,91 @@ 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), - ); - 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); - 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; } -interface IsomorphicGitDiffWithListFiles extends IsomorphicGitDiffClient { - listFiles(args: { fs: object; dir: string; ref?: string }): Promise; +/** + * 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("@@")) { + inHunk = true; + continue; + } + if (!inHunk) continue; + if (line.startsWith("+")) insertions++; + else if (line.startsWith("-")) deletions++; + } + return { insertions, deletions }; } async function listFilesAt( - git: IsomorphicGitDiffWithListFiles, + git: IsomorphicGitDiffClient, fs: object, dir: string, ref: string, diff --git a/packages/workspace/src/git/index.ts b/packages/workspace/src/git/index.ts index 6c5c87dc..8647a504 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, @@ -81,12 +83,14 @@ import { type GitLogOptions, type GitLsFilesOptions, type GitLsTreeOptions, + type GitRepoRootOptions, type GitRevParseOptions, type GitShowOptions, type IsomorphicGitReadsClient, logWith, lsFilesWith, lsTreeWith, + repoRootWith, revParseWith, showWith, type TreeEntryView, @@ -122,11 +126,26 @@ 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"; 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, @@ -166,6 +185,7 @@ export type { GitLogOptions, GitLsFilesOptions, GitLsTreeOptions, + GitRepoRootOptions, GitRevParseOptions, GitShowOptions, TreeEntryView, @@ -181,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 { @@ -206,6 +233,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. */ @@ -222,6 +251,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). */ @@ -266,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 @@ -364,6 +405,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, @@ -427,6 +479,9 @@ export function createGitClient({ git: await loadGit(), }); }, + async repoRoot(options = {}) { + return repoRootWith({ ...options, fs: await fs() }); + }, async currentBranch(options = {}) { return currentBranchWith({ ...options, @@ -594,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/reads.test.ts b/packages/workspace/src/git/reads.test.ts index 41671e40..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"; @@ -98,6 +99,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", () => { @@ -118,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 3bde8513..740bb934 100644 --- a/packages/workspace/src/git/reads.ts +++ b/packages/workspace/src/git/reads.ts @@ -179,13 +179,139 @@ 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; +} + +// --------------------------------------------------------------- +// 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 // --------------------------------------------------------------- diff --git a/packages/workspace/src/git/staging.test.ts b/packages/workspace/src/git/staging.test.ts index 1b0446d9..6aa4410a 100644 --- a/packages/workspace/src/git/staging.test.ts +++ b/packages/workspace/src/git/staging.test.ts @@ -70,6 +70,84 @@ 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]); + }); + + 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"); + 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 17d4f9f9..dd21ab4e 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 { @@ -21,6 +22,10 @@ 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 +50,20 @@ 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; + /** + * 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 { @@ -55,6 +74,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 +98,58 @@ 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: StatusMatrixRow[]; + 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) { + // `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. 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. + 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; 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..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 { @@ -138,6 +143,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 ""; diff --git a/packages/workspace/src/git/worktree.test.ts b/packages/workspace/src/git/worktree.test.ts new file mode 100644 index 00000000..2b3aadcc --- /dev/null +++ b/packages/workspace/src/git/worktree.test.ts @@ -0,0 +1,195 @@ +// 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 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( + 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..cd0854de --- /dev/null +++ b/packages/workspace/src/git/worktree.ts @@ -0,0 +1,328 @@ +// 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"; +import type { StatusMatrixRow } from "./status.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; + 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 { + /** + * 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) { + await hardReset(opts, dir, ref); + return; + } + // Path reset: unstage each path back to `ref` without + // 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) { + if (isNotARepositoryCause(cause)) throw new NotARepositoryError(dir, { cause }); + throw new GitError("ERESETFAIL", `git reset failed: ${errorMessage(cause)}`, { cause }); + } +} + +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.checkout({ fs: opts.fs, dir, ref: oid, 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; +} + +/** `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: StatusMatrixRow[]; + 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); +}