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
85 changes: 76 additions & 9 deletions src/commands/cd.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,25 +13,33 @@ 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 <id>");
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) {
err(`Not found: ${query}`);
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;
Expand All @@ -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 &&
Expand All @@ -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<string | null> {
if (!process.stdout.isTTY) {
err("Usage: exp cd <id>");
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;
}
52 changes: 26 additions & 26 deletions src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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,
Expand All @@ -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)}`)}`);
Expand All @@ -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();
}
Expand Down
63 changes: 24 additions & 39 deletions tests/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading