Skip to content

feat(vcs): add adapter abstraction#313

Merged
benvinegar merged 1 commit into
mainfrom
feat/vcs-abstract
May 14, 2026
Merged

feat(vcs): add adapter abstraction#313
benvinegar merged 1 commit into
mainfrom
feat/vcs-abstract

Conversation

@benvinegar
Copy link
Copy Markdown
Member

@benvinegar benvinegar commented May 14, 2026

Summary

  • Add a VCS adapter abstraction and registry for the currently supported Git and Jujutsu backends.
  • Route VCS-backed loading, watching, and config detection/validation through adapters.
  • Add registry and per-adapter tests covering Git and Jujutsu behavior.

Related PRs

Difference from #291

  • This PR is intentionally architecture-only: it creates the adapter layer using existing supported backends and does not expose partial Sapling support.
  • feat: add Sapling (sl) VCS backend support #291 is the Sapling product PR: docs, CI Sapling installation, .sl/.hg detection, Sapling command/error handling, pathspec behavior, and untracked-file support.
  • If both move forward, feat: add Sapling (sl) VCS backend support #291's Sapling backend can be ported into a src/core/vcs/sl.ts adapter and registered through this abstraction.

Tests

  • bun run typecheck
  • bun test
  • bun test src/core/vcs/git.test.ts src/core/vcs/jj.test.ts src/core/vcs/index.test.ts src/core/config.test.ts src/core/loaders.test.ts src/core/watch.test.ts

This PR description was generated by Pi using GPT-5

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 14, 2026

Greptile Summary

Introduces a VCS adapter abstraction with a priority-ordered registry ([jj, sl, git]) that replaces per-VCS switch statements scattered across loaders.ts, watch.ts, and config.ts. The Sapling (sl) adapter is added as a first-class backend alongside the existing Git and Jujutsu adapters.

  • vcs/index.ts provides the registry API (getVcsAdapter, detectVcs, findVcsRepoRootCandidate, operationFromInput, createUnsupportedVcsOperationError) consumed by loaders.ts, config.ts, and watch.ts.
  • loaders.ts collapses five separate VCS-specific changeset functions into a single loadVcsChangeset with a capability check before dispatching to the adapter.
  • Each adapter (git.ts, jj.ts, sl.ts) implements detect, loadReview, and watchSignature; the Sapling adapter accepts an injected runner for lightweight unit testing without a real sl binary.

Confidence Score: 4/5

Safe to merge; the core adapter dispatch and capability-checking logic in the load path is solid, and all three adapters are well-tested.

The load path correctly guards unsupported operations before ever reaching an adapter. The two noted gaps — findVcsRepoRootCandidate coupling adapter IDs to directory names, and watch.ts skipping the capability check before calling watchSignature — are not reachable through normal app flow but represent inconsistencies that could affect future adapters or tests. No data-loss or security concerns were found.

src/core/vcs/index.ts (findVcsRepoRootCandidate detection logic) and src/core/watch.ts (missing capability guard in vcsPatchSignature)

Important Files Changed

Filename Overview
src/core/vcs/index.ts New registry module; findVcsRepoRootCandidate uses .${adapter.id} pattern that implicitly couples IDs to directory names rather than delegating to each adapter's detect() method.
src/core/watch.ts Replaces per-VCS signature functions with a single vcsPatchSignature; missing capability guard before calling watchSignature, inconsistent with the check in loadVcsChangeset.
src/core/vcs/git.ts New Git adapter implementing detect, loadReview (working-tree-diff, revision-show, stash-show), and watchSignature; large-diff skipping logic migrated from loaders.ts cleanly.
src/core/vcs/jj.ts New Jujutsu adapter; stash-show arm throws raw Error instead of HunkUserError, inconsistent with the user-friendly error path in the loader layer.
src/core/vcs/sl.ts New Sapling adapter with injected-runner design enabling pure unit tests; same raw Error inconsistency for stash-show as the Jujutsu adapter.
src/core/loaders.ts Collapses five VCS-specific changeset functions into a single loadVcsChangeset with capability check guarding all unsupported operations.
src/core/config.ts Replaces inline findRepoRoot/detectRepoVcsMode with registry helpers; VCS mode validation now delegates to isVcsId so sl is accepted without a hardcoded string.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/core/vcs/index.ts:33-47
`findVcsRepoRootCandidate` hard-codes the assumption that each adapter's filesystem marker is `.${adapter.id}` — this implicitly couples the adapter's `id` string to its directory name. Every adapter already exposes its own `detect()` method which knows the correct path to look for. If a future adapter uses an ID that doesn't map 1:1 to its directory, the function would silently fail to detect it as a repo root, causing config files in that root to be missed even though `detectVcs` correctly identifies the VCS.

```suggestion
export function findVcsRepoRootCandidate(cwd = process.cwd()) {
  let current = resolve(cwd);

  for (;;) {
    if (vcsAdapters.some((adapter) => adapter.detect(current) !== null)) {
      return current;
    }

    const parent = dirname(current);
    if (parent === current) {
      return undefined;
    }
    current = parent;
  }
}
```

### Issue 2 of 3
src/core/watch.ts:24-31
`vcsPatchSignature` doesn't validate adapter capabilities before calling `watchSignature`, unlike `loadVcsChangeset` which explicitly checks `adapter.capabilities.reviewOperations` first. If `stash-show` with a jj or sl adapter somehow reaches `computeWatchSignature`, the adapter's `watchSignature` switch will hit its `stash-show` arm and throw a raw internal `Error` rather than a `HunkUserError`, bypassing user-friendly error handling upstream.

```suggestion
/** Build one exact patch signature for adapter-backed review inputs. */
function vcsPatchSignature(input: Extract<CliInput, { kind: "vcs" | "show" | "stash-show" }>) {
  const adapter = getVcsAdapter(input.options.vcs ?? "git");
  if (!adapter.watchSignature) {
    throw new Error(`${adapter.name} does not support watch signatures.`);
  }
  const operation = operationFromInput(input);
  if (!adapter.capabilities.reviewOperations.has(operation.kind)) {
    throw createUnsupportedVcsOperationError(adapter, operation);
  }
  return adapter.watchSignature(operation, { cwd: process.cwd() });
}
```

### Issue 3 of 3
src/core/vcs/sl.ts:111-112
Both `sl.ts` (here and at `watchSignature` line 127) and `jj.ts` throw a raw `Error` for the `stash-show` arm, but the production load path throws a `HunkUserError` via `createUnsupportedVcsOperationError`. Any caller that directly invokes the adapter gets an internal error without the user-friendly hints. Consider using `HunkUserError` to keep error handling uniform across all adapter entry points.

Reviews (1): Last reviewed commit: "feat(vcs): add adapter abstraction" | Re-trigger Greptile

Comment thread src/core/vcs/index.ts
Comment on lines +33 to +47
export function findVcsRepoRootCandidate(cwd = process.cwd()) {
let current = resolve(cwd);

for (;;) {
if (vcsAdapters.some((adapter) => fs.existsSync(join(current, `.${adapter.id}`)))) {
return current;
}

const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 findVcsRepoRootCandidate hard-codes the assumption that each adapter's filesystem marker is .${adapter.id} — this implicitly couples the adapter's id string to its directory name. Every adapter already exposes its own detect() method which knows the correct path to look for. If a future adapter uses an ID that doesn't map 1:1 to its directory, the function would silently fail to detect it as a repo root, causing config files in that root to be missed even though detectVcs correctly identifies the VCS.

Suggested change
export function findVcsRepoRootCandidate(cwd = process.cwd()) {
let current = resolve(cwd);
for (;;) {
if (vcsAdapters.some((adapter) => fs.existsSync(join(current, `.${adapter.id}`)))) {
return current;
}
const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
export function findVcsRepoRootCandidate(cwd = process.cwd()) {
let current = resolve(cwd);
for (;;) {
if (vcsAdapters.some((adapter) => adapter.detect(current) !== null)) {
return current;
}
const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/vcs/index.ts
Line: 33-47

Comment:
`findVcsRepoRootCandidate` hard-codes the assumption that each adapter's filesystem marker is `.${adapter.id}` — this implicitly couples the adapter's `id` string to its directory name. Every adapter already exposes its own `detect()` method which knows the correct path to look for. If a future adapter uses an ID that doesn't map 1:1 to its directory, the function would silently fail to detect it as a repo root, causing config files in that root to be missed even though `detectVcs` correctly identifies the VCS.

```suggestion
export function findVcsRepoRootCandidate(cwd = process.cwd()) {
  let current = resolve(cwd);

  for (;;) {
    if (vcsAdapters.some((adapter) => adapter.detect(current) !== null)) {
      return current;
    }

    const parent = dirname(current);
    if (parent === current) {
      return undefined;
    }
    current = parent;
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I changed findVcsRepoRootCandidate to use each adapter's detect() result and only return the current directory when the adapter reports that exact directory as the repo root, so repo-root config lookup no longer assumes .${adapter.id}. Added a registry test with a custom marker to cover this.

This comment was generated by Pi using GPT-5

Comment thread src/core/watch.ts
Comment on lines +24 to 31
/** Build one exact patch signature for adapter-backed review inputs. */
function vcsPatchSignature(input: Extract<CliInput, { kind: "vcs" | "show" | "stash-show" }>) {
const adapter = getVcsAdapter(input.options.vcs ?? "git");
if (!adapter.watchSignature) {
throw new Error(`${adapter.name} does not support watch signatures.`);
}
return adapter.watchSignature(operationFromInput(input), { cwd: process.cwd() });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 vcsPatchSignature doesn't validate adapter capabilities before calling watchSignature, unlike loadVcsChangeset which explicitly checks adapter.capabilities.reviewOperations first. If stash-show with a jj or sl adapter somehow reaches computeWatchSignature, the adapter's watchSignature switch will hit its stash-show arm and throw a raw internal Error rather than a HunkUserError, bypassing user-friendly error handling upstream.

Suggested change
/** Build one exact patch signature for adapter-backed review inputs. */
function vcsPatchSignature(input: Extract<CliInput, { kind: "vcs" | "show" | "stash-show" }>) {
const adapter = getVcsAdapter(input.options.vcs ?? "git");
if (!adapter.watchSignature) {
throw new Error(`${adapter.name} does not support watch signatures.`);
}
return adapter.watchSignature(operationFromInput(input), { cwd: process.cwd() });
}
/** Build one exact patch signature for adapter-backed review inputs. */
function vcsPatchSignature(input: Extract<CliInput, { kind: "vcs" | "show" | "stash-show" }>) {
const adapter = getVcsAdapter(input.options.vcs ?? "git");
if (!adapter.watchSignature) {
throw new Error(`${adapter.name} does not support watch signatures.`);
}
const operation = operationFromInput(input);
if (!adapter.capabilities.reviewOperations.has(operation.kind)) {
throw createUnsupportedVcsOperationError(adapter, operation);
}
return adapter.watchSignature(operation, { cwd: process.cwd() });
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/watch.ts
Line: 24-31

Comment:
`vcsPatchSignature` doesn't validate adapter capabilities before calling `watchSignature`, unlike `loadVcsChangeset` which explicitly checks `adapter.capabilities.reviewOperations` first. If `stash-show` with a jj or sl adapter somehow reaches `computeWatchSignature`, the adapter's `watchSignature` switch will hit its `stash-show` arm and throw a raw internal `Error` rather than a `HunkUserError`, bypassing user-friendly error handling upstream.

```suggestion
/** Build one exact patch signature for adapter-backed review inputs. */
function vcsPatchSignature(input: Extract<CliInput, { kind: "vcs" | "show" | "stash-show" }>) {
  const adapter = getVcsAdapter(input.options.vcs ?? "git");
  if (!adapter.watchSignature) {
    throw new Error(`${adapter.name} does not support watch signatures.`);
  }
  const operation = operationFromInput(input);
  if (!adapter.capabilities.reviewOperations.has(operation.kind)) {
    throw createUnsupportedVcsOperationError(adapter, operation);
  }
  return adapter.watchSignature(operation, { cwd: process.cwd() });
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. vcsPatchSignature now builds the neutral operation once, checks adapter.capabilities.reviewOperations, and throws the same user-facing unsupported-operation error before calling watchSignature. Added watch coverage for stash-show under vcs = "jj".

This comment was generated by Pi using GPT-5

Comment thread src/core/vcs/sl.ts Outdated
Comment on lines +111 to +112
case "stash-show":
throw new Error("Sapling adapter does not support stash-show.");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Both sl.ts (here and at watchSignature line 127) and jj.ts throw a raw Error for the stash-show arm, but the production load path throws a HunkUserError via createUnsupportedVcsOperationError. Any caller that directly invokes the adapter gets an internal error without the user-friendly hints. Consider using HunkUserError to keep error handling uniform across all adapter entry points.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/vcs/sl.ts
Line: 111-112

Comment:
Both `sl.ts` (here and at `watchSignature` line 127) and `jj.ts` throw a raw `Error` for the `stash-show` arm, but the production load path throws a `HunkUserError` via `createUnsupportedVcsOperationError`. Any caller that directly invokes the adapter gets an internal error without the user-friendly hints. Consider using `HunkUserError` to keep error handling uniform across all adapter entry points.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Sapling adapter was removed from this PR so we do not expose partial Sapling support here. I also updated the Jujutsu adapter's direct stash-show arms to throw the same user-facing Git-required error instead of a raw internal Error.

This comment was generated by Pi using GPT-5

@benvinegar benvinegar force-pushed the feat/vcs-abstract branch 2 times, most recently from 42d58c5 to 43557c6 Compare May 14, 2026 22:14
@benvinegar benvinegar force-pushed the feat/vcs-abstract branch from 43557c6 to 7f10606 Compare May 14, 2026 22:18
@benvinegar benvinegar merged commit 8a6f733 into main May 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant