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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"basilisk.enabled": true,
"basilisk.uv.enabled": true,
"basilisk.aiTyping.enabled": false
}
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@

⚠️ DO NOT KILL VSCODE PROCESSES ⚠️

⚠️ ASKING THE USER QUESTIONS IS ⛔️ ILLEGAL. USE YOUR JUDGEMENT ⚠️

⚠️ **TOKEN DISCIPLINE.** Check file size first. `Grep` over `Read`. Use `offset`/`limit`.
Smallest diff that solves the problem. Delete dead code, unused imports, stale comments.
Call out irrelevant context before proceeding. Bloat degrades reasoning. ⚠️

⚠️ **CRITICAL: THIS CODEBASE RECEIVES A GRADE OF A+.** WE DON'T ALLOW BAD CODE. NOT EVEN FOR ONE LINE. CODE MUST PASS REVIEW AT Google / Meta / Microsoft. ANYTHING LESS IS ⛔️ ILLEGAL AND MUST BE FIXED IMMEDIATELY.⚠️

⚠️ **NEW VIEWS, ACTIVITY-BAR ICONS, SIDEBARS, TREE PROVIDERS, OR WEBVIEWS ARE ⛔️ ILLEGAL.**
Diffr is **context-menu only**. Every feature hangs off VSCode's existing SCM history, SCM resource state, editor title, and explorer menus — plus a small set of palette commands. If a feature needs a new panel to exist, the feature is wrong.⚠️
⚠️ **KEEP THE UI MINIMAL.** Default to VSCode's existing surfaces — SCM history, SCM resource state, editor title, and explorer context menus, plus a small set of palette commands. Avoid building new views, sidebars, activity-bar icons, tree providers, or webviews **unless the UI is an integral part of the flow** and no built-in surface can carry it well. When custom UI does earn its place, it must be first-class — not a half-measure bolted onto a picker.⚠️

Full design + execution plan: [spec.md](spec.md).

## Project Overview

**Diffr** is a VSCode extension that does exactly one thing: **pick two things and diff them** against a git repository. Side A is a commit; Side B is another commit, the working copy, the index, or a branch/tag (resolved to a commit). It shells out to `git`, hands two URIs to VSCode's built-in `vscode.diff`, and uses a multi-step QuickPick for browsing many changed files. No custom renderer, no custom view.
**Diffr** is a VSCode extension that does exactly one thing: **pick two things and diff them** against a git repository. Side A is a commit; Side B is another commit, the working copy, the index, or a branch/tag (resolved to a commit). It shells out to `git`, hands two URIs to VSCode's built-in `vscode.diff`, and browses many changed files through a focused selection UI. The UI stays minimal — built-in surfaces first, purpose-built UI only when it's integral to the flow.

**Primary language:** TypeScript (pure — Rust LSP was considered and rejected; LSP is for _language_ semantics, not diffing)
**Build command:** `make ci`
Expand Down Expand Up @@ -46,7 +47,7 @@ context-menu / palette command
## Hard Rules (no exceptions, NON-NEGOTIABLE)

- **NO git commands from the agent.** No `git add`, `commit`, `push`, `checkout`, `merge`, `rebase`. CI and GitHub Actions handle git. (Diffr itself shells out to `git` at runtime — that's the product. The _agent_ doesn't drive git in the dev loop.)
- **NO new views, sidebars, activity-bar icons, tree providers, or webviews.** Context menus + palette commands only. Browsing many files is a QuickPick, not a panel.
- **Keep UI minimal.** Prefer context menus, palette commands, and other built-in surfaces. Add new views, sidebars, tree providers, or webviews only when the UI is integral to the flow and no built-in surface fits — and when you do, build it properly.
- **NO THROWING EXCEPTIONS for control flow.** Return `Result<T,E>` via a discriminated union. Panics are bugs.
- **NO REGEX on structured data.** Git porcelain output is parsed via NUL-delimited splits (`-z` flag everywhere). Never regex over JSON, YAML, source code, or git output.
- **NO PLACEHOLDERS.** If something isn't implemented, leave a loud compilation error with TODO. Silent no-ops = ⛔️ ILLEGAL.
Expand Down
2 changes: 1 addition & 1 deletion coverage-thresholds.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"_agent_pmo": "74cf183",
"_doc": "Single source of truth for code coverage thresholds. See REPO-STANDARDS-SPEC [COVERAGE-THRESHOLDS-JSON]. NO GitHub repo variables. NO env vars. NO public `make coverage-check` target. This file is read by the internal `_coverage_check` recipe inside `make test`. `make test` exits non-zero if measured coverage < threshold. Thresholds are monotonically increasing — only ratchet UP, never down.",
"default_threshold": 95
"default_threshold": 95.3
}
13 changes: 8 additions & 5 deletions src/commands/compareWith.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import * as vscode from "vscode";
import { TITLE_PREFIX } from "../constants";
import type { MementoStore } from "../state";
import { extractHistoryItemSha } from "./historyItem";
import { historyItemShaFromArgs } from "./historyItem";
import { type CommandDeps, buildRepo, pickRepoFrom } from "./shared";
import { drillIntoFiles, pickSideBAndResolve, sideAFromSha } from "./flow";

const NOT_FROM_HISTORY = `${TITLE_PREFIX} this command must be invoked from the SCM history view.`;

const handler = async (deps: CommandDeps & { readonly state: MementoStore }, arg: unknown): Promise<void> => {
const sha = extractHistoryItemSha(arg);
const handler = async (
deps: CommandDeps & { readonly state: MementoStore },
args: readonly unknown[]
): Promise<void> => {
const sha = historyItemShaFromArgs(args);
if (sha === undefined) {
void vscode.window.showWarningMessage(NOT_FROM_HISTORY);
return;
Expand All @@ -34,6 +37,6 @@ const handler = async (deps: CommandDeps & { readonly state: MementoStore }, arg

export const makeCompareWith =
(deps: CommandDeps & { readonly state: MementoStore }) =>
async (arg: unknown): Promise<void> => {
await handler(deps, arg);
async (...args: unknown[]): Promise<void> => {
await handler(deps, args);
};
13 changes: 8 additions & 5 deletions src/commands/compareWithPrevious.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import * as vscode from "vscode";
import { REV_KINDS, TITLE_PREFIX } from "../constants";
import type { MementoStore } from "../state";
import { extractHistoryItemSha } from "./historyItem";
import { historyItemShaFromArgs } from "./historyItem";
import { type CommandDeps, buildRepo, pickRepoFrom } from "./shared";
import { drillIntoFiles, reportGitError, sideAFromSha } from "./flow";

const REV_PARSE_PARENT_OP = "rev-parse parent";
const HISTORY_VIEW_WARNING = `${TITLE_PREFIX} this command must be invoked from the SCM history view.`;

const handler = async (deps: CommandDeps & { readonly state: MementoStore }, arg: unknown): Promise<void> => {
const sha = extractHistoryItemSha(arg);
const handler = async (
deps: CommandDeps & { readonly state: MementoStore },
args: readonly unknown[]
): Promise<void> => {
const sha = historyItemShaFromArgs(args);
if (sha === undefined) {
void vscode.window.showWarningMessage(HISTORY_VIEW_WARNING);
return;
Expand Down Expand Up @@ -40,6 +43,6 @@ const handler = async (deps: CommandDeps & { readonly state: MementoStore }, arg

export const makeCompareWithPrevious =
(deps: CommandDeps & { readonly state: MementoStore }) =>
async (arg: unknown): Promise<void> => {
await handler(deps, arg);
async (...args: unknown[]): Promise<void> => {
await handler(deps, args);
};
12 changes: 6 additions & 6 deletions src/commands/compareWithRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@ import * as vscode from "vscode";
import { REV_KINDS, TITLE_PREFIX } from "../constants";
import type { RefType } from "../git/types";
import type { MementoStore } from "../state";
import { extractHistoryItemSha } from "./historyItem";
import { historyItemShaFromArgs } from "./historyItem";
import { type CommandDeps, buildRepo, pickRepoFrom } from "./shared";
import { drillIntoFiles, pickRefAsSha, sideAFromSha } from "./flow";

const NOT_FROM_HISTORY = `${TITLE_PREFIX} this command must be invoked from the SCM history view.`;

const handler = async ({
deps,
arg,
args,
filter,
}: {
deps: CommandDeps & { readonly state: MementoStore };
arg: unknown;
args: readonly unknown[];
filter: RefType;
}): Promise<void> => {
const sha = extractHistoryItemSha(arg);
const sha = historyItemShaFromArgs(args);
if (sha === undefined) {
void vscode.window.showWarningMessage(NOT_FROM_HISTORY);
return;
Expand All @@ -43,6 +43,6 @@ const handler = async ({

export const makeCompareWithRef =
({ deps, filter }: { deps: CommandDeps & { readonly state: MementoStore }; filter: RefType }) =>
async (arg: unknown): Promise<void> => {
await handler({ deps, arg, filter });
async (...args: unknown[]): Promise<void> => {
await handler({ deps, args, filter });
};
13 changes: 8 additions & 5 deletions src/commands/compareWithWorkingCopy.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import * as vscode from "vscode";
import { REV_KINDS, TITLE_PREFIX } from "../constants";
import type { MementoStore } from "../state";
import { extractHistoryItemSha } from "./historyItem";
import { historyItemShaFromArgs } from "./historyItem";
import { type CommandDeps, buildRepo, pickRepoFrom } from "./shared";
import { drillIntoFiles, sideAFromSha } from "./flow";

const NOT_FROM_HISTORY = `${TITLE_PREFIX} this command must be invoked from the SCM history view.`;

const handler = async (deps: CommandDeps & { readonly state: MementoStore }, arg: unknown): Promise<void> => {
const sha = extractHistoryItemSha(arg);
const handler = async (
deps: CommandDeps & { readonly state: MementoStore },
args: readonly unknown[]
): Promise<void> => {
const sha = historyItemShaFromArgs(args);
if (sha === undefined) {
void vscode.window.showWarningMessage(NOT_FROM_HISTORY);
return;
Expand All @@ -30,6 +33,6 @@ const handler = async (deps: CommandDeps & { readonly state: MementoStore }, arg

export const makeCompareWithWorkingCopy =
(deps: CommandDeps & { readonly state: MementoStore }) =>
async (arg: unknown): Promise<void> => {
await handler(deps, arg);
async (...args: unknown[]): Promise<void> => {
await handler(deps, args);
};
42 changes: 6 additions & 36 deletions src/commands/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,14 @@ import { CANCELLED, type Cancelled } from "../ui/cancelled";
import { pickCommit } from "../ui/CommitPicker";
import { pickRef } from "../ui/RefPicker";
import { pickSideBChoice, type SideBChoice } from "../ui/SideBPicker";
import { mergeChangedFilesWithStats, pickFiles } from "../ui/FilePicker";
import type { MementoStore } from "../state";
import { buildRepo, openDiff, pickRepoFrom } from "./shared";
import { buildRepo, openMultiFileDiff, pickRepoFrom } from "./shared";

const GIT_OPS = {
listRefs: "list refs",
revParse: "rev-parse",
log: "log",
diffNameStatus: "diff --name-status",
diffNumstat: "diff --numstat",
currentBranch: "current branch",
} as const;

Expand Down Expand Up @@ -182,45 +180,17 @@ export const drillIntoFiles = async ({
state: MementoStore;
output: vscode.OutputChannel;
}): Promise<void> => {
const entries = await collectChangedFiles({ repo, revA, revB, output });
if (entries === undefined) {
const ns = await repo.nameStatus({ from: revA, to: revB });
if (!ns.ok) {
reportGitError({ output, op: GIT_OPS.diffNameStatus, e: ns.error });
return;
}
if (entries.length === 0) {
if (ns.value.length === 0) {
void vscode.window.showInformationMessage(UI_TEXT.noChanges);
return;
}
await state.setLastComparison({ revA, revB, repoRoot });
await pickFiles({
entries,
onPick: async (entry) => {
await openDiff({ revA, revB, repoRoot, relPath: entry.file.path });
},
});
};

const collectChangedFiles = async ({
repo,
revA,
revB,
output,
}: {
repo: GitRepo;
revA: CommitRev;
revB: RevSpec;
output: vscode.OutputChannel;
}) => {
const ns = await repo.nameStatus({ from: revA, to: revB });
if (!ns.ok) {
reportGitError({ output, op: GIT_OPS.diffNameStatus, e: ns.error });
return undefined;
}
const num = await repo.numstat({ from: revA, to: revB });
if (!num.ok) {
reportGitError({ output, op: GIT_OPS.diffNumstat, e: num.error });
return undefined;
}
return mergeChangedFilesWithStats(ns.value, num.value);
await openMultiFileDiff({ revA, revB, repoRoot, relPaths: ns.value.map((f) => f.path) });
};

export const sideAFromSha = (sha: Sha): CommitRev => ({
Expand Down
37 changes: 37 additions & 0 deletions src/commands/historyItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,40 @@ export const extractHistoryItemSha = (arg: unknown): string | undefined => {
}
return stringIdOf(inner);
};

// A real SCM history item carries a `parentIds` array alongside its SHA `id`.
// The git SourceControl provider also has an `id` ("git") but NO `parentIds`,
// so `parentIds` is what tells the commit apart from the provider VSCode passes.
const historyItemShaOf = (v: unknown): string | undefined => {
if (typeof v !== "object" || v === null) {
return undefined;
}
if ("parentIds" in v && Array.isArray(v.parentIds)) {
return stringIdOf(v);
}
if ("historyItem" in v) {
return historyItemShaOf(v.historyItem);
}
return undefined;
};

// VSCode invokes `scm/historyItem/context` commands with MULTIPLE arguments —
// the SourceControl provider (id "git") FIRST, then the history item. Scan for
// the real history item before anything else so the provider's "git" id is never
// fed to git as a revision; fall back to the single-argument shapes used by the
// command palette and older callers.
export const historyItemShaFromArgs = (args: readonly unknown[]): string | undefined => {
for (const a of args) {
const sha = historyItemShaOf(a);
if (sha !== undefined) {
return sha;
}
}
for (const a of args) {
const sha = extractHistoryItemSha(a);
if (sha !== undefined) {
return sha;
}
}
return undefined;
};
33 changes: 32 additions & 1 deletion src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const labelForRev = (rev: RevSpec): string => {
return UI_TEXT.indexLabel;
};

export const formatComparisonTitle = ({ revA, revB }: { revA: CommitRev; revB: RevSpec }): string =>
`${labelForRev(revA)} ${UI_TEXT.pathArrow} ${labelForRev(revB)}`;

export const formatDiffTitle = ({
revA,
revB,
Expand All @@ -37,7 +40,7 @@ export const formatDiffTitle = ({
revA: CommitRev;
revB: RevSpec;
basename: string;
}): string => `${labelForRev(revA)} ${UI_TEXT.pathArrow} ${labelForRev(revB)} ${UI_TEXT.pathDash} ${basename}`;
}): string => `${formatComparisonTitle({ revA, revB })} ${UI_TEXT.pathDash} ${basename}`;

export const uriForRev = ({
rev,
Expand Down Expand Up @@ -78,6 +81,34 @@ export const openDiff = async ({
await vscode.commands.executeCommand(BUILT_IN_COMMANDS.diff, left, right, title);
};

// One persistent multi-diff editor listing every changed file with its full
// path and inline diff — click a row to jump to that file's diff. This is
// VSCode's built-in `vscode.changes` editor, NOT a custom view: it takes a
// title and a list of `[resource, original, modified]` URI triples. We key each
// row by the on-disk file URI so the list shows clean workspace-relative paths,
// while the actual diff sides come from `original` (revA, left) and `modified`
// (revB, right).
export const openMultiFileDiff = async ({
revA,
revB,
repoRoot,
relPaths,
}: {
revA: CommitRev;
revB: RevSpec;
repoRoot: string;
relPaths: readonly string[];
}): Promise<void> => {
const resourceList = relPaths.map((relPath): [vscode.Uri, vscode.Uri, vscode.Uri] => [
vscode.Uri.file(path.join(repoRoot, relPath)),
uriForRev({ rev: revA, repoRoot, relPath }),
uriForRev({ rev: revB, repoRoot, relPath }),
]);
const title = formatComparisonTitle({ revA, revB });
logger.info({ shaA: shortSha(revA.sha), revBKind: revB.kind, files: relPaths.length }, LOG_EVENTS.diffOpen);
await vscode.commands.executeCommand(BUILT_IN_COMMANDS.changes, title, resourceList);
};

export const repoForUri = (api: GitApi, uri: vscode.Uri): GitVsRepository | undefined => findRepoForUri(api, uri);

export const pickRepoFrom = async (api: GitApi): Promise<Result<GitVsRepository, Cancelled>> => {
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const COMMAND_IDS = {

export const BUILT_IN_COMMANDS = {
diff: "vscode.diff",
changes: "vscode.changes",
setContext: "setContext",
} as const;

Expand All @@ -56,7 +57,6 @@ export const SHORT_SHA_LEN = 7;
export const GIT_BINARY = "git";

export const NUL = "\x00";
export const TAB = "\t";
export const LF = "\n";

export const GIT_LOG_FORMAT = "%H%x00%h%x00%an%x00%at%x00%s";
Expand Down
Loading
Loading