From 600ab3fbb3632fd4847070aa5c87eb3ab142401f Mon Sep 17 00:00:00 2001 From: Joel Brubaker Date: Sun, 15 Mar 2026 09:41:16 -0700 Subject: [PATCH 1/2] fix: exp diff uses git merge-base instead of filesystem walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was running `git diff --no-index` between full directory trees, which walked every file including .vite/deps, build artifacts, and untracked noise (8k+ files, 1.4M deletions in output). Now uses `git diff main...HEAD` which operates on git objects only — instant and shows only meaningful committed changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/diff.ts | 52 ++++++++++++++++++------------------ tests/diff.test.ts | 63 +++++++++++++++++--------------------------- 2 files changed, 50 insertions(+), 65 deletions(-) diff --git a/src/commands/diff.ts b/src/commands/diff.ts index 7ad0a6c..bc44ad3 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -80,8 +80,16 @@ export function rewritePaths(line: string, root: string, expDir: string): string return line.replace(root, "[source]").replace(expDir, "[exp]"); } +async function findDefaultBranch(dir: string): Promise { + for (const candidate of ["main", "master"]) { + const result = await exec(["git", "-C", dir, "rev-parse", "--verify", candidate]); + if (result.success) return candidate; + } + throw new Error("Could not find default branch (tried main, master)"); +} + async function gitDiff( - root: string, + _root: string, expDir: string, name: string, expName: string, @@ -97,6 +105,9 @@ async function gitDiff( ? statusResult.stdout.trim().split("\n").length : 0; + // Find default branch for merge-base + const defaultBranch = await findDefaultBranch(expDir); + // Header console.log(); console.log(` ${c.bold(`Diff: ${c.cyan(name)} ${c.dim("↔")} ${c.magenta(expName)}`)}`); @@ -110,47 +121,36 @@ async function gitDiff( console.log(branchInfo + uncommittedInfo); console.log(); - // Stat summary via git diff --no-index - const statResult = await exec([ - "git", - "diff", - "--no-index", - "--stat", - "--color=always", - root, - expDir, - ]); - - // git diff --no-index exits 1 when there are differences — that's normal + // Git diff against merge-base (three-dot = changes on branch only) + const diffRef = `${defaultBranch}...HEAD`; + const statResult = await exec(["git", "-C", expDir, "diff", diffRef, "--stat", "--color=always"]); + const statOutput = statResult.stdout || statResult.stderr; - if (!statOutput.trim()) { + if (!statOutput.trim() && uncommitted === 0) { dim(" No differences found."); console.log(); return; } - // Filter and display stat lines - const statLines = statOutput.trim().split("\n"); - const filtered = filterExcludedLines(statLines, DIFF_EXCLUDES); - - for (const line of filtered) { - const display = rewritePaths(line, root, expDir); - console.log(` ${display}`); + if (statOutput.trim()) { + for (const line of statOutput.trim().split("\n")) { + console.log(` ${line}`); + } + } else { + dim(" No committed changes."); } console.log(); // Full diff if verbose if (config.verbose) { - const fullResult = await exec(["git", "diff", "--no-index", "--color=always", root, expDir]); + const fullResult = await exec(["git", "-C", expDir, "diff", diffRef, "--color=always"]); const fullOutput = fullResult.stdout || fullResult.stderr; if (fullOutput.trim()) { - const fullLines = fullOutput.trim().split("\n"); - const filteredFull = filterExcludedLines(fullLines, DIFF_EXCLUDES); - for (const line of filteredFull) { - console.log(rewritePaths(line, root, expDir)); + for (const line of fullOutput.trim().split("\n")) { + console.log(line); } console.log(); } diff --git a/tests/diff.test.ts b/tests/diff.test.ts index 307d39d..a2e9fe0 100644 --- a/tests/diff.test.ts +++ b/tests/diff.test.ts @@ -80,78 +80,63 @@ describe("rewritePaths", () => { }); describe("git diff integration", () => { - const sourceDir = join(TMP, "source"); const expDir = join(TMP, "001-test-exp"); beforeEach(async () => { - // Set up source as a git repo - mkdirSync(join(sourceDir, "src"), { recursive: true }); - writeFileSync(join(sourceDir, "src", "index.ts"), 'console.log("hello");\n'); - writeFileSync(join(sourceDir, "README.md"), "# Project\n"); - await exec(["git", "init"], { cwd: sourceDir }); - await exec(["git", "add", "."], { cwd: sourceDir }); + // Set up a git repo with an initial commit on main, then branch and make changes + mkdirSync(join(expDir, "src"), { recursive: true }); + writeFileSync(join(expDir, "src", "index.ts"), 'console.log("hello");\n'); + writeFileSync(join(expDir, "README.md"), "# Project\n"); + await exec(["git", "init", "-b", "main"], { cwd: expDir }); + await exec(["git", "add", "."], { cwd: expDir }); await exec( ["git", "-c", "user.name=Test", "-c", "user.email=t@t.com", "commit", "-m", "init"], - { - cwd: sourceDir, - }, + { cwd: expDir }, ); - // Set up experiment as a git repo with changes - mkdirSync(join(expDir, "src"), { recursive: true }); + // Branch and make changes (simulates what exp new does) + await exec(["git", "checkout", "-b", "exp/test-feature"], { cwd: expDir }); writeFileSync(join(expDir, "src", "index.ts"), 'console.log("hello world");\n'); - writeFileSync(join(expDir, "README.md"), "# Project\n"); writeFileSync(join(expDir, "src", "config.ts"), "export const PORT = 3000;\n"); - await exec(["git", "init"], { cwd: expDir }); - await exec(["git", "checkout", "-b", "exp/test-feature"], { cwd: expDir }); await exec(["git", "add", "."], { cwd: expDir }); await exec( ["git", "-c", "user.name=Test", "-c", "user.email=t@t.com", "commit", "-m", "changes"], - { - cwd: expDir, - }, + { cwd: expDir }, ); }); - test("git diff --no-index detects file differences", async () => { - const result = await exec(["git", "diff", "--no-index", "--stat", sourceDir, expDir]); + test("git diff main...HEAD detects branch changes", async () => { + const result = await exec(["git", "-C", expDir, "diff", "main...HEAD", "--stat"]); - // Should have output (exit code 1 means differences found) const output = result.stdout || result.stderr; expect(output).toContain("index.ts"); expect(output).toContain("config.ts"); }); + test("git diff main...HEAD ignores untracked files naturally", async () => { + // Add untracked noise (like .vite/deps) — git diff won't see it + mkdirSync(join(expDir, ".vite", "deps"), { recursive: true }); + writeFileSync(join(expDir, ".vite", "deps", "chunk.js"), "// noise\n"); + + const result = await exec(["git", "-C", expDir, "diff", "main...HEAD", "--stat"]); + + const output = result.stdout || result.stderr; + expect(output).not.toContain(".vite"); + expect(output).not.toContain("chunk.js"); + }); + test("git branch --show-current reports experiment branch", async () => { const result = await exec(["git", "-C", expDir, "branch", "--show-current"]); expect(result.stdout.trim()).toBe("exp/test-feature"); }); test("git status --porcelain counts uncommitted changes", async () => { - // Add an uncommitted change writeFileSync(join(expDir, "src", "new-file.ts"), "// new\n"); const result = await exec(["git", "-C", expDir, "status", "--porcelain"]); const lines = result.stdout.trim().split("\n").filter(Boolean); expect(lines.length).toBe(1); }); - - test("git diff --no-index with filtering removes noise", async () => { - // Add node_modules noise to experiment - mkdirSync(join(expDir, "node_modules", "pkg"), { recursive: true }); - writeFileSync(join(expDir, "node_modules", "pkg", "index.js"), "module.exports = {};\n"); - - const result = await exec(["git", "diff", "--no-index", "--stat", sourceDir, expDir]); - - const output = result.stdout || result.stderr; - const lines = output.trim().split("\n"); - const filtered = filterExcludedLines(lines, ["node_modules", ".git", ".next"]); - - // Filtered output should not contain node_modules - for (const line of filtered) { - expect(line).not.toContain("node_modules"); - } - }); }); describe("fs diff fallback", () => { From 97c7381daebbfb2f5edca93498ac8847d2f3de33 Mon Sep 17 00:00:00 2001 From: Joel Brubaker Date: Mon, 16 Mar 2026 08:40:27 -0700 Subject: [PATCH 2/2] feat: exp cd shows interactive branch picker when called with no args Replaces the "Usage: exp cd " error with an @inquirer/select prompt listing all branches with name, description, and time ago. Non-TTY environments keep the error fallback for agent compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/cd.ts | 85 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/src/commands/cd.ts b/src/commands/cd.ts index d596b57..3a6a320 100644 --- a/src/commands/cd.ts +++ b/src/commands/cd.ts @@ -1,9 +1,10 @@ -import { confirm } from "@inquirer/prompts"; +import { existsSync, readdirSync } from "node:fs"; +import { confirm, select } from "@inquirer/prompts"; import type { ExpConfig } from "../core/config.ts"; import { readRawConfig, writeConfig } from "../core/config.ts"; import { detectContext } from "../core/context.ts"; -import { getExpBase, resolveExp } from "../core/experiment.ts"; -import { getProjectRoot } from "../core/project.ts"; +import { getExpBase, readMetadata, resolveExp } from "../core/experiment.ts"; +import { getProjectName, getProjectRoot } from "../core/project.ts"; import { writeCdTarget } from "../utils/cd-file.ts"; import { c, dim, err, ok } from "../utils/colors.ts"; import { @@ -12,18 +13,21 @@ import { installShellIntegration, isShellIntegrationInstalled, } from "../utils/shell-integration.ts"; +import { timeAgo } from "../utils/time.ts"; export async function cmdCd(query: string | undefined, config: ExpConfig) { - if (!query) { - err("Usage: exp cd "); - dim(" Run exp ls to see available branches."); - process.exit(1); - } - // If inside a branch, use the original project root so we can cd to siblings const ctx = detectContext(); const root = ctx.isClone ? ctx.originalRoot : getProjectRoot(); const base = getExpBase(root, config); + + if (!query) { + const expDir = await selectBranch(base, root); + if (!expDir) return; + cdTo(expDir); + return; + } + const expDir = resolveExp(query, base); if (!expDir) { @@ -31,6 +35,11 @@ export async function cmdCd(query: string | undefined, config: ExpConfig) { process.exit(1); } + cdTo(expDir); +} + +/** Write cd target or print path, offer shell integration if needed */ +function cdTo(expDir: string) { // If the shell wrapper is active, write to cd-file and stay quiet if (writeCdTarget(expDir)) { return; @@ -40,6 +49,10 @@ export async function cmdCd(query: string | undefined, config: ExpConfig) { console.log(`cd ${expDir}`); // If TTY, shell integration not installed, and we haven't asked before — offer to set it up + offerShellIntegration(); +} + +async function offerShellIntegration() { const existing = readRawConfig(); if ( process.stdout.isTTY && @@ -63,3 +76,57 @@ export async function cmdCd(query: string | undefined, config: ExpConfig) { } } } + +/** Show interactive branch picker, return selected path or null */ +async function selectBranch(base: string, root: string): Promise { + if (!process.stdout.isTTY) { + err("Usage: exp cd "); + dim(" Run exp ls to see available branches."); + process.exit(1); + } + + if (!existsSync(base)) { + const name = getProjectName(root); + dim(`No branches for ${name}. Run: exp new "my idea"`); + return null; + } + + const entries = readdirSync(base, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (entries.length === 0) { + const name = getProjectName(root); + dim(`No branches for ${name}. Run: exp new "my idea"`); + return null; + } + + const choices = entries.map((entry) => { + const expDir = `${base}/${entry.name}`; + const meta = readMetadata(expDir); + const desc = meta?.description ?? ""; + const time = meta?.created ? timeAgo(meta.created) : ""; + const isDone = meta?.status === "done"; + + // Build display label: name + description (if different from slug) + time + let label = entry.name; + if (desc && desc !== entry.name.replace(/^\d+-/, "")) { + label += c.dim(` — ${desc}`); + } + if (time) { + label += c.dim(` ${time}`); + } + if (isDone) { + label = c.dim(`${entry.name} ✓`); + } + + return { name: label, value: expDir }; + }); + + const selected = await select({ + message: "Branch:", + choices, + }); + + return selected; +}