diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 37acb1e95..03f2ac5e4 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1335,7 +1335,7 @@ "name": "where-was-i", "source": "extensions/where-was-i", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", - "version": "1.0.1" + "version": "1.1.0" }, { "name": "winappcli", diff --git a/extensions/where-was-i/.github/plugin/plugin.json b/extensions/where-was-i/.github/plugin/plugin.json index e43c6f26d..be976e412 100644 --- a/extensions/where-was-i/.github/plugin/plugin.json +++ b/extensions/where-was-i/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "where-was-i", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", - "version": "1.0.1", + "version": "1.1.0", "author": { "name": "Aaron Powell", "url": "https://github.com/aaronpowell" diff --git a/extensions/where-was-i/extension.mjs b/extensions/where-was-i/extension.mjs index c376fa9b4..40dc50f60 100644 --- a/extensions/where-was-i/extension.mjs +++ b/extensions/where-was-i/extension.mjs @@ -3,28 +3,15 @@ import { createServer } from "node:http"; import { execFile } from "node:child_process"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { gatherGitContext, getFileDiff } from "./git-context.mjs"; const servers = new Map(); const sseClients = new Map(); // instanceId → Set const contextCache = new Map(); // instanceId → contextData -const isWindows = process.platform === "win32"; - -// Fallback repo root derived from extension location. Only used when the -// session's real working directory is unavailable (see captureCwd below). -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const REPO_ROOT = join(__dirname, "..", "..", ".."); - -// The canvas request context reports the active session's working directory — -// the actual repo checkout or worktree the user opened the canvas in. This is -// what git commands must run against; REPO_ROOT (the extension's install dir) -// and session.workspacePath (the session-state folder) are NOT the repo, which -// is why the board previously showed an empty branch as "detached HEAD". let workspaceCwd = null; function captureCwd(ctx) { @@ -32,52 +19,61 @@ function captureCwd(ctx) { if (typeof dir === "string" && dir.trim()) workspaceCwd = dir; } -function repoCwd() { - return workspaceCwd || REPO_ROOT; +async function activeCwd(ctx) { + captureCwd(ctx); + if (!workspaceCwd && sessionRef) { + const snapshot = await sessionRef.rpc.metadata.snapshot(); + const dir = snapshot?.workingDirectory; + if (typeof dir === "string" && dir.trim()) workspaceCwd = dir; + } + if (!workspaceCwd) { + throw new CanvasError( + "workspace_unavailable", + "No repository working directory is attached to this session.", + ); + } + return workspaceCwd; } -// --- Shell helpers --- - -function run(cmd, cwd) { - const shell = isWindows ? "powershell" : "bash"; - const args = isWindows - ? ["-NoProfile", "-NoLogo", "-Command", cmd] - : ["-c", cmd]; - return new Promise((resolve) => { - execFile(shell, args, { cwd, timeout: 15000, maxBuffer: 1024 * 256 }, (err, stdout) => { - resolve(err ? "" : (stdout || "").trim()); +function runGhJson(cwd, args) { + return new Promise((resolve, reject) => { + execFile("gh", args, { cwd, timeout: 15000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + reject(new Error((stderr || error.message || "GitHub CLI command failed").trim())); + return; + } + try { + resolve({ + data: JSON.parse(stdout || "[]"), + warning: (stderr || "").trim(), + }); + } catch (parseError) { + reject(new Error(`GitHub CLI returned invalid JSON: ${parseError.message}`)); + } }); }); } async function gatherContext(cwd) { - cwd = cwd || repoCwd(); - const authorCmd = isWindows - ? 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"' - : 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"'; - const suppressErr = isWindows ? "2>$null" : "2>/dev/null"; - - const [branch, log, status, diff, prs, issues] = await Promise.all([ - run("git branch --show-current", cwd), - run(authorCmd, cwd), - run("git status --short", cwd), - run("git diff --stat", cwd), - run(`gh pr list --author=@me --state=open --limit=10 --json number,title,url,updatedAt,comments ${suppressErr}`, cwd), - run(`gh issue list --assignee=@me --state=open --limit=10 --json number,title,url,updatedAt ${suppressErr}`, cwd), + const gitContext = await gatherGitContext(cwd); + const [prs, issues] = await Promise.allSettled([ + runGhJson(cwd, [ + "pr", "list", "--author=@me", "--state=open", "--limit=10", + "--json", "number,title,url,updatedAt,comments", + ]), + runGhJson(cwd, [ + "issue", "list", "--assignee=@me", "--state=open", "--limit=10", + "--json", "number,title,url,updatedAt", + ]), ]); - let parsedPrs = []; - let parsedIssues = []; - try { parsedPrs = JSON.parse(prs || "[]"); } catch {} - try { parsedIssues = JSON.parse(issues || "[]"); } catch {} - return { - branch, - recentCommits: log.split("\n").filter(Boolean), - uncommitted: status.split("\n").filter(Boolean), - diffStat: diff, - openPrs: parsedPrs, - assignedIssues: parsedIssues, + ...gitContext, + openPrs: prs.status === "fulfilled" ? prs.value.data : [], + assignedIssues: issues.status === "fulfilled" ? issues.value.data : [], + warnings: [prs, issues] + .map((result) => result.status === "fulfilled" ? result.value.warning : result.reason.message) + .filter(Boolean), gatheredAt: new Date().toISOString(), }; } @@ -87,18 +83,10 @@ async function gatherContext(cwd) { async function saveContext(workspacePath, data) { if (!workspacePath) return; const dir = join(workspacePath, "files"); - try { await mkdir(dir, { recursive: true }); } catch {} + await mkdir(dir, { recursive: true }); await writeFile(join(dir, "where-was-i-context.json"), JSON.stringify(data, null, 2)); } -async function loadContext(workspacePath) { - if (!workspacePath) return null; - try { - const raw = await readFile(join(workspacePath, "files", "where-was-i-context.json"), "utf-8"); - return JSON.parse(raw); - } catch { return null; } -} - // --- SSE --- function broadcast(instanceId, data) { @@ -106,7 +94,11 @@ function broadcast(instanceId, data) { if (!clients) return; const payload = `data: ${JSON.stringify(data)}\n\n`; for (const res of clients) { - try { res.write(payload); } catch {} + try { + res.write(payload); + } catch { + clients.delete(res); + } } } @@ -200,6 +192,25 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } font-weight: 500; color: var(--azure); } +.branch-bar .worktree-name { + font-family: var(--mono); + font-size: 0.8rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.branch-bar .divider { + width: 1px; + height: 20px; + background: var(--border); +} +.branch-bar .divergence { + margin-left: auto; + font-family: var(--mono); + font-size: 0.75rem; + color: var(--muted); +} .branch-bar .label { font-size: 0.75rem; font-weight: 600; @@ -255,24 +266,124 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } color: var(--text); } +.git-graph { + overflow-x: auto; +} +.graph-row { + display: grid; + grid-template-columns: 72px 72px minmax(220px, 1fr) auto; + align-items: center; + min-height: 34px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent); +} +.graph-row:last-child { border-bottom: none; } +.graph-row.worktree-commit { + background: color-mix(in srgb, var(--azure) 5%, transparent); +} +.graph-lines { + color: var(--azure); + font-family: var(--mono); + font-size: 0.9rem; + font-weight: 600; + white-space: pre; +} +.graph-hash { + color: var(--meta); + font-family: var(--mono); + font-size: 0.72rem; +} +.graph-subject { + color: var(--text); + font-size: 0.84rem; + overflow: hidden; + padding-right: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.graph-refs { + display: flex; + gap: 4px; + justify-content: flex-end; + white-space: nowrap; +} +.graph-ref { + background: var(--azure-tint); + border: 1px solid color-mix(in srgb, var(--azure) 20%, transparent); + border-radius: var(--radius-pill); + color: var(--azure); + font-family: var(--mono); + font-size: 0.65rem; + padding: 2px 7px; +} +.graph-history { + border-top: 1px solid var(--border); + margin-top: 4px; +} +.graph-history summary { + color: var(--muted); + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + list-style-position: inside; + padding: 12px 0 6px; + user-select: none; +} +.graph-history summary:hover { color: var(--azure); } +.graph-history[open] summary { margin-bottom: 4px; } + .file-list { list-style: none; } .file-list li { + padding: 0; +} +.file-list .file-change { + align-items: center; + background: transparent; + border: 0; + border-radius: 6px; + color: var(--muted); + display: flex; + gap: 8px; font-family: var(--mono); font-size: 0.8rem; - padding: 4px 0; - color: var(--muted); + text-align: left; + width: 100%; + padding: 7px 8px; +} +.file-list .file-change { + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +.file-list .file-change:hover, +.file-list .file-change:focus-visible { + background: color-mix(in srgb, var(--azure) 7%, transparent); + color: var(--text); + outline: none; } -.file-list .status-badge { +.file-list .view-diff { + color: var(--meta); + font-family: var(--sans); + font-size: 0.7rem; + margin-left: auto; +} +.file-list .status-badge, +.diff-header .status-badge { display: inline-block; - width: 18px; - text-align: center; - margin-right: 6px; + border-radius: var(--radius-pill); font-weight: 600; + min-width: 76px; + padding: 2px 8px; + text-align: center; +} +.status-badge.modified { background: #fff7ed; color: #b45309; } +.status-badge.added { background: var(--sage-tint); color: #4d7c0f; } +.status-badge.deleted { background: #fef2f2; color: #dc2626; } +.status-badge.untracked { background: var(--coral-tint); color: #c2410c; } +.status-badge.renamed { background: var(--azure-tint); color: var(--azure); } +.file-list .file-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.file-list .status-badge.M { color: #d97706; } -.file-list .status-badge.A { color: var(--sage); } -.file-list .status-badge.D { color: #ef4444; } -.file-list .status-badge.U { color: var(--coral); } .thread-cards { display: grid; grid-template-columns: 1fr; gap: 0.6rem; } .thread-card { @@ -357,6 +468,16 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } padding: 8px 0; } +.warnings { + color: #b45309; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: var(--radius-compact); + padding: 10px 14px; + font-size: 0.78rem; + margin-bottom: 1.5rem; +} + .refresh-btn { display: inline-flex; align-items: center; @@ -388,6 +509,68 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } line-height: 1.5; } +.diff-overlay { + align-items: stretch; + background: rgba(15, 23, 42, 0.35); + display: none; + inset: 0; + justify-content: flex-end; + position: fixed; + z-index: 20; +} +.diff-overlay.open { display: flex; } +.diff-panel { + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: -12px 0 32px rgba(15, 23, 42, 0.15); + display: flex; + flex-direction: column; + max-width: 760px; + width: min(78vw, 760px); +} +.diff-header { + align-items: center; + border-bottom: 1px solid var(--border); + display: flex; + gap: 12px; + padding: 14px 18px; +} +.diff-title { + flex: 1; + font-family: var(--mono); + font-size: 0.82rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.diff-close { + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted); + cursor: pointer; + font-size: 1rem; + height: 30px; + width: 30px; +} +.diff-content { + background: #0d1117; + color: #c9d1d9; + flex: 1; + font-family: var(--mono); + font-size: 0.75rem; + line-height: 1.55; + margin: 0; + overflow: auto; + padding: 18px; + tab-size: 4; + white-space: pre; +} +.diff-loading { + color: var(--muted); + padding: 24px; +} + .loading { display: flex; align-items: center; @@ -415,6 +598,16 @@ body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; } Reconstructing your context… +
+
+
+ Changed + + +
+

+  
+
`; @@ -596,8 +901,9 @@ fetch("/context").then(r => r.json()).then(render).catch(() => {}); // --- Server --- -async function startServer(instanceId, sessionRef, cwd, workspacePath) { - const server = createServer(async (req, res) => { +async function startServer(instanceId, cwd, workspacePath) { + const entry = { server: null, url: "", cwd }; + entry.server = createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); if (url.pathname === "/events") { @@ -621,13 +927,36 @@ async function startServer(instanceId, sessionRef, cwd, workspacePath) { return; } + if (url.pathname === "/file-diff" && req.method === "GET") { + const path = url.searchParams.get("path"); + if (!path) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "A file path is required." })); + return; + } + try { + const data = await getFileDiff(entry.cwd, path); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); + } catch (error) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: error.message || "Unable to load this diff." })); + } + return; + } + if (url.pathname === "/refresh" && req.method === "POST") { - const data = await gatherContext(cwd); - contextCache.set(instanceId, data); - await saveContext(workspacePath, data); - broadcast(instanceId, data); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(data)); + try { + const data = await gatherContext(entry.cwd); + contextCache.set(instanceId, data); + await saveContext(workspacePath, data); + broadcast(instanceId, data); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: error.message || "Unable to refresh context." })); + } return; } @@ -635,21 +964,30 @@ async function startServer(instanceId, sessionRef, cwd, workspacePath) { let body = ""; for await (const chunk of req) body += chunk; let thread = null; - try { thread = JSON.parse(body).thread; } catch {} + try { + thread = JSON.parse(body).thread; + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "The resume request body must be valid JSON." })); + return; + } const ctx = contextCache.get(instanceId) || {}; + const commits = ctx.branchCommits?.length ? ctx.branchCommits : (ctx.recentCommits || []); let prompt; if (thread) { prompt = `I was working on ${thread} and got interrupted. Here's my current context:\n\n` + + `**Worktree:** ${ctx.worktreeRoot || "unknown"}\n` + `**Branch:** ${ctx.branch || "unknown"}\n` + - `**Recent commits:** ${(ctx.recentCommits || []).join(", ")}\n` + + `**Worktree commits:** ${commits.join(", ")}\n` + `**Uncommitted changes:** ${(ctx.uncommitted || []).join(", ")}\n` + `**Open PRs:** ${(ctx.openPrs || []).map(p => "#" + p.number + " " + p.title).join(", ")}\n\n` + `Help me pick up where I left off on this specific thread.`; } else { prompt = `I got interrupted and need to resume my work. Here's my full context:\n\n` + + `**Worktree:** ${ctx.worktreeRoot || "unknown"}\n` + `**Branch:** ${ctx.branch || "unknown"}\n` + - `**Recent commits:**\n${(ctx.recentCommits || []).map(c => "- " + c).join("\n")}\n\n` + + `**Worktree commits:**\n${commits.map(c => "- " + c).join("\n")}\n\n` + `**Uncommitted changes:**\n${(ctx.uncommitted || []).map(f => "- " + f).join("\n")}\n\n` + `**Diff stat:**\n${ctx.diffStat || "none"}\n\n` + `**Open PRs:** ${(ctx.openPrs || []).map(p => "#" + p.number + " " + p.title).join(", ") || "none"}\n` + @@ -657,9 +995,15 @@ async function startServer(instanceId, sessionRef, cwd, workspacePath) { `Help me pick up where I left off. What should I focus on first?`; } - try { await sessionRef.send(prompt); } catch {} - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); + try { + if (!sessionRef) throw new Error("The Copilot session is unavailable."); + await sessionRef.send(prompt); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: error.message || "Unable to send the resume prompt." })); + } return; } @@ -668,10 +1012,11 @@ async function startServer(instanceId, sessionRef, cwd, workspacePath) { res.end(renderHtml(instanceId)); }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); + await new Promise((resolve) => entry.server.listen(0, "127.0.0.1", resolve)); + const address = entry.server.address(); const port = typeof address === "object" && address ? address.port : 0; - return { server, url: `http://127.0.0.1:${port}/` }; + entry.url = `http://127.0.0.1:${port}/`; + return entry; } // --- Extension --- @@ -689,8 +1034,10 @@ const session = await joinSession({ name: "refresh", description: "Re-gather all git/project context and push updates to the canvas", handler: async (ctx) => { - captureCwd(ctx); - const data = await gatherContext(repoCwd()); + const cwd = await activeCwd(ctx); + const entry = servers.get(ctx.instanceId); + if (entry) entry.cwd = cwd; + const data = await gatherContext(cwd); contextCache.set(ctx.instanceId, data); if (sessionRef) await saveContext(sessionRef.workspacePath, data); broadcast(ctx.instanceId, data); @@ -704,6 +1051,26 @@ const session = await joinSession({ return contextCache.get(ctx.instanceId) || {}; }, }, + { + name: "get_file_diff", + description: "Return the staged, unstaged, or untracked patch for a changed file", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + minLength: 1, + description: "Repository-relative path from the current context", + }, + }, + required: ["path"], + additionalProperties: false, + }, + handler: async (ctx) => { + const cwd = await activeCwd(ctx); + return await getFileDiff(cwd, ctx.input.path); + }, + }, { name: "resume", description: "Send a contextual 'resume' message to the agent with the developer's assembled state", @@ -719,11 +1086,14 @@ const session = await joinSession({ handler: async (ctx) => { const thread = ctx.input?.thread || null; const data = contextCache.get(ctx.instanceId) || {}; + const commits = data.branchCommits?.length + ? data.branchCommits + : (data.recentCommits || []); let prompt; if (thread) { - prompt = `I was working on ${thread} and got interrupted. Context: branch=${data.branch}, recent commits: ${(data.recentCommits || []).join("; ")}. Help me resume.`; + prompt = `I was working on ${thread} and got interrupted. Context: worktree=${data.worktreeRoot}, branch=${data.branch}, worktree commits: ${commits.join("; ")}. Help me resume.`; } else { - prompt = `Help me resume. Branch: ${data.branch}. Commits: ${(data.recentCommits || []).join("; ")}. Uncommitted: ${(data.uncommitted || []).join("; ")}.`; + prompt = `Help me resume. Worktree: ${data.worktreeRoot}. Branch: ${data.branch}. Commits: ${commits.join("; ")}. Uncommitted: ${(data.uncommitted || []).join("; ")}.`; } if (sessionRef) await sessionRef.send(prompt); return { sent: true }; @@ -731,22 +1101,17 @@ const session = await joinSession({ }, ], open: async (ctx) => { - captureCwd(ctx); + const cwd = await activeCwd(ctx); let entry = servers.get(ctx.instanceId); if (!entry) { - entry = await startServer(ctx.instanceId, sessionRef, repoCwd(), sessionRef?.workspacePath); + entry = await startServer(ctx.instanceId, cwd, sessionRef?.workspacePath); servers.set(ctx.instanceId, entry); + } else { + entry.cwd = cwd; } - // Load persisted context or gather fresh. Re-gather when the - // saved context is missing or has no branch (e.g. it was saved - // before the working directory was known), so the board never - // opens stuck on a stale "detached HEAD". - let data = await loadContext(sessionRef?.workspacePath); - if (!data || !data.branch) { - data = await gatherContext(repoCwd()); - await saveContext(sessionRef?.workspacePath, data); - } + const data = await gatherContext(cwd); + await saveContext(sessionRef?.workspacePath, data); contextCache.set(ctx.instanceId, data); // Push to any waiting SSE clients setTimeout(() => broadcast(ctx.instanceId, data), 100); diff --git a/extensions/where-was-i/git-context.mjs b/extensions/where-was-i/git-context.mjs new file mode 100644 index 000000000..63262c786 --- /dev/null +++ b/extensions/where-was-i/git-context.mjs @@ -0,0 +1,198 @@ +import { execFile } from "node:child_process"; +import { readFile, stat } from "node:fs/promises"; +import { basename, isAbsolute, relative, resolve } from "node:path"; + +function runGit(cwd, args, { optional = false } = {}) { + return new Promise((resolve, reject) => { + execFile( + "git", + args, + { cwd, timeout: 15000, maxBuffer: 1024 * 1024, encoding: "utf8" }, + (error, stdout, stderr) => { + if (error) { + if (optional) { + resolve(""); + return; + } + reject(new Error((stderr || error.message || "Git command failed").trim())); + return; + } + resolve((stdout || "").trimEnd()); + }, + ); + }); +} + +async function resolveBaseRef(cwd, branch) { + const remoteDefault = await runGit( + cwd, + ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], + { optional: true }, + ); + const candidates = [remoteDefault, "origin/main", "origin/master", "main", "master"] + .filter(Boolean) + .filter((ref, index, refs) => refs.indexOf(ref) === index && ref !== branch); + + for (const ref of candidates) { + const commit = await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { + optional: true, + }); + if (commit) return ref; + } + return null; +} + +function lines(value) { + return value.split("\n").map((line) => line.trimEnd()).filter(Boolean); +} + +export function parseStatusEntry(line) { + const code = line.slice(0, 2); + const rawPath = line.slice(3); + const renameMarker = rawPath.lastIndexOf(" -> "); + return { + code, + path: renameMarker >= 0 ? rawPath.slice(renameMarker + 4) : rawPath, + originalPath: renameMarker >= 0 ? rawPath.slice(0, renameMarker) : null, + }; +} + +function parseGraphLine(line) { + const [graphAndHash, subject = "", refs = ""] = line.split("\t"); + const hashMatch = graphAndHash.match(/([0-9a-f]{7,})$/); + return { + graph: hashMatch ? graphAndHash.slice(0, hashMatch.index) : graphAndHash, + hash: hashMatch?.[1] || "", + subject, + refs, + }; +} + +function assertRepositoryPath(root, path) { + const absolutePath = resolve(root, path); + const relativePath = relative(root, absolutePath); + if (!relativePath || relativePath.startsWith("..") || isAbsolute(relativePath)) { + throw new Error("The requested file must be inside the current worktree."); + } + return { absolutePath, relativePath: relativePath.replaceAll("\\", "/") }; +} + +async function renderUntrackedFile(root, path) { + const { absolutePath, relativePath } = assertRepositoryPath(root, path); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) throw new Error("Only untracked files can be previewed."); + if (fileStat.size > 512 * 1024) throw new Error("This untracked file is too large to preview."); + + const content = await readFile(absolutePath); + if (content.includes(0)) return `Binary file ${relativePath} is untracked.`; + + const text = content.toString("utf8"); + const addedLines = text.split(/\r?\n/); + if (addedLines.at(-1) === "") addedLines.pop(); + return [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${relativePath}`, + `@@ -0,0 +1,${addedLines.length} @@`, + ...addedLines.map((line) => `+${line}`), + ].join("\n"); +} + +export async function getFileDiff(cwd, requestedPath) { + const root = await runGit(cwd, ["rev-parse", "--show-toplevel"]); + const { relativePath } = assertRepositoryPath(root, requestedPath); + const status = await runGit(root, [ + "status", "--short", "--untracked-files=all", "--", relativePath, + ]); + const entry = lines(status).map(parseStatusEntry).find((item) => item.path === relativePath); + if (!entry) throw new Error("This file no longer has uncommitted changes."); + + if (entry.code === "??") { + return { + ...entry, + diff: await renderUntrackedFile(root, relativePath), + }; + } + + const patches = []; + if (entry.code[0] && entry.code[0] !== " ") { + const staged = await runGit(root, ["diff", "--cached", "--no-ext-diff", "--", relativePath]); + if (staged) patches.push({ kind: "Staged", content: staged }); + } + if (entry.code[1] && entry.code[1] !== " ") { + const unstaged = await runGit(root, ["diff", "--no-ext-diff", "--", relativePath]); + if (unstaged) patches.push({ kind: "Unstaged", content: unstaged }); + } + + return { + ...entry, + diff: patches + .map((patch) => patches.length > 1 ? `# ${patch.kind}\n${patch.content}` : patch.content) + .join("\n\n"), + }; +} + +export async function gatherGitContext(cwd) { + const worktreeRoot = await runGit(cwd, ["rev-parse", "--show-toplevel"]); + const [branch, head] = await Promise.all([ + runGit(worktreeRoot, ["branch", "--show-current"]), + runGit(worktreeRoot, ["rev-parse", "--short", "HEAD"]), + ]); + const baseRef = await resolveBaseRef(worktreeRoot, branch); + const mergeBase = baseRef + ? await runGit(worktreeRoot, ["merge-base", "HEAD", baseRef], { optional: true }) + : ""; + + const branchRange = mergeBase ? `${mergeBase}..HEAD` : null; + const graphRefs = ["HEAD"]; + if (baseRef) graphRefs.push(baseRef); + const [branchLog, recentLog, graphLog, status, diffStat, stagedDiffStat, unstagedDiffStat, divergence] = + await Promise.all([ + branchRange + ? runGit(worktreeRoot, ["log", "--format=%h %s", branchRange]) + : Promise.resolve(""), + runGit(worktreeRoot, ["log", "-10", "--format=%h %s", "HEAD"]), + runGit(worktreeRoot, [ + "log", + "--graph", + "--decorate=short", + "--topo-order", + "--format=%h%x09%s%x09%D", + "--max-count=40", + ...graphRefs, + ]), + runGit(worktreeRoot, ["status", "--short", "--untracked-files=all"]), + runGit(worktreeRoot, ["diff", "--stat", "HEAD"]), + runGit(worktreeRoot, ["diff", "--cached", "--stat"]), + runGit(worktreeRoot, ["diff", "--stat"]), + baseRef + ? runGit(worktreeRoot, ["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], { + optional: true, + }) + : Promise.resolve(""), + ]); + + const [behind = 0, ahead = 0] = divergence + .split(/\s+/) + .filter(Boolean) + .map((value) => Number.parseInt(value, 10) || 0); + + return { + worktreeRoot, + worktreeName: basename(worktreeRoot), + branch, + head, + baseRef, + ahead, + behind, + branchCommits: lines(branchLog), + recentCommits: lines(recentLog), + commitGraph: lines(graphLog).map(parseGraphLine), + uncommitted: lines(status), + changes: lines(status).map(parseStatusEntry), + diffStat, + stagedDiffStat, + unstagedDiffStat, + }; +} diff --git a/extensions/where-was-i/git-context.test.mjs b/extensions/where-was-i/git-context.test.mjs new file mode 100644 index 000000000..f0aa9c13a --- /dev/null +++ b/extensions/where-was-i/git-context.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { gatherGitContext, getFileDiff } from "./git-context.mjs"; + +function git(cwd, ...args) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +function write(cwd, path, content) { + writeFileSync(join(cwd, path), content, "utf8"); +} + +test("gathers branch commits and every worktree change", async (t) => { + const cwd = mkdtempSync(join(tmpdir(), "where-was-i-")); + t.after(() => rmSync(cwd, { recursive: true, force: true })); + + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Canvas Tester"); + git(cwd, "config", "user.email", "canvas@example.com"); + write(cwd, "staged.txt", "initial\n"); + write(cwd, "unstaged.txt", "initial\n"); + git(cwd, "add", "."); + git(cwd, "commit", "-m", "Seed repository"); + + git(cwd, "switch", "-c", "feature/context"); + write(cwd, "first.txt", "first\n"); + git(cwd, "add", "first.txt"); + git(cwd, "commit", "-m", "Add first feature commit"); + git(cwd, "config", "user.name", "Another Contributor"); + git(cwd, "config", "user.email", "another@example.com"); + write(cwd, "second.txt", "second\n"); + git(cwd, "add", "second.txt"); + git(cwd, "commit", "-m", "Add second feature commit"); + + write(cwd, "staged.txt", "staged change\n"); + git(cwd, "add", "staged.txt"); + write(cwd, "unstaged.txt", "unstaged change\n"); + write(cwd, "untracked.txt", "untracked change\n"); + + const context = await gatherGitContext(cwd); + + assert.equal(context.worktreeRoot.replaceAll("\\", "/"), cwd.replaceAll("\\", "/")); + assert.equal(context.worktreeName, cwd.split(/[\\/]/).at(-1)); + assert.equal(context.branch, "feature/context"); + assert.equal(context.baseRef, "main"); + assert.equal(context.ahead, 2); + assert.equal(context.behind, 0); + assert.deepEqual( + context.branchCommits.map((commit) => commit.replace(/^[0-9a-f]+ /, "")), + ["Add second feature commit", "Add first feature commit"], + ); + assert.equal(context.commitGraph[0].subject, "Add second feature commit"); + assert.match(context.commitGraph[0].refs, /HEAD -> feature\/context/); + assert.deepEqual( + context.changes.map((change) => [change.code, change.path]), + [ + ["M ", "staged.txt"], + [" M", "unstaged.txt"], + ["??", "untracked.txt"], + ], + ); + assert.match(context.uncommitted.join("\n"), /M staged\.txt/); + assert.match(context.uncommitted.join("\n"), / M unstaged\.txt/); + assert.match(context.uncommitted.join("\n"), /\?\? untracked\.txt/); + assert.match(context.diffStat, /staged\.txt/); + assert.match(context.diffStat, /unstaged\.txt/); + assert.match(context.stagedDiffStat, /staged\.txt/); + assert.match(context.unstagedDiffStat, /unstaged\.txt/); + + const stagedDiff = await getFileDiff(cwd, "staged.txt"); + assert.equal(stagedDiff.code, "M "); + assert.match(stagedDiff.diff, /\+staged change/); + + const unstagedDiff = await getFileDiff(cwd, "unstaged.txt"); + assert.equal(unstagedDiff.code, " M"); + assert.match(unstagedDiff.diff, /\+unstaged change/); + + const untrackedDiff = await getFileDiff(cwd, "untracked.txt"); + assert.equal(untrackedDiff.code, "??"); + assert.match(untrackedDiff.diff, /new file mode 100644/); + assert.match(untrackedDiff.diff, /\+untracked change/); +}); diff --git a/extensions/where-was-i/package.json b/extensions/where-was-i/package.json index 5534b4dbb..3971e3683 100644 --- a/extensions/where-was-i/package.json +++ b/extensions/where-was-i/package.json @@ -1,6 +1,6 @@ { "name": "where-was-i", - "version": "1.0.0", + "version": "1.1.0", "type": "module", "main": "extension.mjs", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",