diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 37acb1e95..0dcfa4b04 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -649,6 +649,12 @@ "repo": "Azure/git-ape" } }, + { + "name": "git-worktree-explorer", + "source": "extensions/git-worktree-explorer", + "description": "Visualize the active Git repository through worktrees, branches, commits, and optional GitHub pull request context.", + "version": "1.0.0" + }, { "name": "github-copilot-modernization", "description": "Autonomous application modernization using multi-agent orchestration for GitHub Copilot CLI. Supports Java upgrades (8→21, Spring Boot 2.x→3.x), .NET modernization, Azure migration, CVE/vulnerability fixing, and application rearchitecture (monolith-to-microservices). Features a 3-level agent hierarchy (orchestrator → coordinators → executors) with enterprise rulebook support for embedding organizational policies into the workflow.", diff --git a/extensions/git-worktree-explorer/.github/plugin/plugin.json b/extensions/git-worktree-explorer/.github/plugin/plugin.json new file mode 100644 index 000000000..97b2f1e4c --- /dev/null +++ b/extensions/git-worktree-explorer/.github/plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "git-worktree-explorer", + "description": "Visualize the active Git repository through worktrees, branches, commits, and optional GitHub pull request context.", + "version": "1.0.0", + "author": { + "name": "GitHub Copilot" + }, + "keywords": [ + "branch-visualization", + "canvas", + "commit-history", + "git", + "repository-topology", + "worktrees" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/git-worktree-explorer/assets/branch-graph.png b/extensions/git-worktree-explorer/assets/branch-graph.png new file mode 100644 index 000000000..ebecf46b3 Binary files /dev/null and b/extensions/git-worktree-explorer/assets/branch-graph.png differ diff --git a/extensions/git-worktree-explorer/assets/preview.png b/extensions/git-worktree-explorer/assets/preview.png new file mode 100644 index 000000000..5ad7aaa7c Binary files /dev/null and b/extensions/git-worktree-explorer/assets/preview.png differ diff --git a/extensions/git-worktree-explorer/assets/worktree-topology.png b/extensions/git-worktree-explorer/assets/worktree-topology.png new file mode 100644 index 000000000..79964238f Binary files /dev/null and b/extensions/git-worktree-explorer/assets/worktree-topology.png differ diff --git a/extensions/git-worktree-explorer/extension.mjs b/extensions/git-worktree-explorer/extension.mjs new file mode 100644 index 000000000..e74192491 --- /dev/null +++ b/extensions/git-worktree-explorer/extension.mjs @@ -0,0 +1,94 @@ +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; +import { getServerEntry, refreshServer, startServer, stopServer } from "./server.mjs"; + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "git-worktree-explorer", + displayName: "Git Worktree Explorer", + description: "Explore the active Git repository through worktrees, branches, commits, and related GitHub pull requests.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + startAt: { + type: "string", + enum: ["repository"], + description: "Initial topology level.", + }, + }, + }, + actions: [ + { + name: "refresh", + description: "Refresh Git and GitHub information shown by an open explorer.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + handler: async (ctx) => { + try { + const snapshot = await refreshServer(ctx.instanceId); + return { + gatheredAt: snapshot.gatheredAt, + worktrees: snapshot.worktrees.length, + branches: snapshot.branches.length, + }; + } catch (error) { + throw new CanvasError("git_refresh_failed", error.message); + } + }, + }, + { + name: "focus_node", + description: "Ask an open explorer to focus a repository, worktree, or branch node by its canvas node ID.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + nodeId: { type: "string", minLength: 1 }, + }, + required: ["nodeId"], + }, + handler: async (ctx) => { + const entry = getServerEntry(ctx.instanceId); + if (!entry) throw new CanvasError("canvas_not_open", "Canvas instance is not open."); + const nodeId = ctx.input?.nodeId; + const snapshot = entry.snapshot; + const exists = nodeId === "repository" + || snapshot.worktrees.some((item) => item.id === nodeId) + || snapshot.branches.some((item) => item.id === nodeId); + if (!exists) throw new CanvasError("git_node_not_found", `Git node not found: ${nodeId}`); + for (const client of entry.clients) { + client.write(`event: focus\ndata: ${JSON.stringify({ nodeId })}\n\n`); + } + return { nodeId }; + }, + }, + ], + open: async (ctx) => { + const cwd = ctx.session?.workingDirectory; + if (!cwd) { + throw new CanvasError("workspace_unavailable", "The active session working directory is unavailable."); + } + try { + const entry = await startServer(ctx.instanceId, { + cwd, + sendPrompt: async (prompt) => session.send({ prompt }), + }); + return { + title: "Git Worktree Explorer", + status: `${entry.snapshot.worktrees.length} worktrees · ${entry.snapshot.branches.length} branches`, + url: entry.url, + }; + } catch (error) { + throw new CanvasError("git_repository_unavailable", error.message); + } + }, + onClose: async (ctx) => { + await stopServer(ctx.instanceId); + }, + }), + ], +}); diff --git a/extensions/git-worktree-explorer/git-data.mjs b/extensions/git-worktree-explorer/git-data.mjs new file mode 100644 index 000000000..ea9d84c49 --- /dev/null +++ b/extensions/git-worktree-explorer/git-data.mjs @@ -0,0 +1,457 @@ +import { execFile } from "node:child_process"; +import { basename, resolve } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const FIELD_SEPARATOR = "\x1f"; +const RECORD_SEPARATOR = "\x1e"; + +export class CommandError extends Error { + constructor(command, args, cause) { + const detail = String(cause?.stderr || cause?.message || "command failed").trim(); + super(`${command} ${args.join(" ")}: ${detail}`); + this.name = "CommandError"; + this.command = command; + this.args = args; + this.code = cause?.code; + this.stderr = String(cause?.stderr || "").trim(); + } +} + +export async function runCommand(command, args, cwd, options = {}) { + try { + const { stdout, stderr } = await execFileAsync(command, args, { + cwd, + encoding: "utf8", + timeout: options.timeout ?? 15_000, + maxBuffer: options.maxBuffer ?? 2 * 1024 * 1024, + windowsHide: true, + }); + return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd() }; + } catch (error) { + if (options.allowFailure) { + return { + stdout: String(error?.stdout || "").trimEnd(), + stderr: String(error?.stderr || error?.message || "").trimEnd(), + error, + }; + } + throw new CommandError(command, args, error); + } +} + +export function parseWorktreePorcelain(output) { + if (!output.trim()) return []; + return output.trim().split(/\r?\n\r?\n/).map((block) => { + const worktree = { + path: "", + head: null, + branch: null, + detached: false, + bare: false, + locked: false, + prunable: false, + }; + + for (const line of block.split(/\r?\n/)) { + const separator = line.indexOf(" "); + const key = separator === -1 ? line : line.slice(0, separator); + const value = separator === -1 ? "" : line.slice(separator + 1); + if (key === "worktree") worktree.path = value; + else if (key === "HEAD") worktree.head = value; + else if (key === "branch") worktree.branch = value.replace(/^refs\/heads\//, ""); + else if (key === "detached") worktree.detached = true; + else if (key === "bare") worktree.bare = true; + else if (key === "locked") worktree.locked = value || true; + else if (key === "prunable") worktree.prunable = value || true; + } + return worktree; + }).filter((worktree) => worktree.path); +} + +export function parseTracking(value) { + const ahead = Number(value.match(/ahead (\d+)/)?.[1] || 0); + const behind = Number(value.match(/behind (\d+)/)?.[1] || 0); + return { ahead, behind, gone: value.includes("[gone]") }; +} + +export function parseBranchRecords(output) { + if (!output.trim()) return []; + return output.split(/\r?\n/).filter(Boolean).map((record) => { + const [ref, name, sha, upstream, tracking, updatedAt, subject] = record.split(FIELD_SEPARATOR); + const remote = ref.startsWith("refs/remotes/"); + return { + ref, + name, + sha, + upstream: upstream || null, + tracking: parseTracking(tracking || ""), + updatedAt: updatedAt || null, + subject: subject || "", + remote, + }; + }).filter((branch) => branch.ref && branch.name && !branch.name.endsWith("/HEAD")); +} + +export function parseCommitRecords(output) { + if (!output.trim()) return []; + return output.split(RECORD_SEPARATOR).map((record) => record.replace(/^[\r\n]+|[\r\n]+$/g, "")).filter(Boolean) + .map((record) => { + const [sha, shortSha, parents, authorName, authorEmail, authoredAt, committedAt, subject] = + record.split(FIELD_SEPARATOR); + return { + sha, + shortSha, + parents: parents ? parents.split(" ") : [], + author: { name: authorName, email: authorEmail }, + authoredAt, + committedAt, + subject: subject || "(no subject)", + }; + }); +} + +export function parseDivergence(output) { + const [behindValue, aheadValue] = String(output || "").trim().split(/\s+/); + const behind = Number(behindValue); + const ahead = Number(aheadValue); + if (!Number.isFinite(behind) || !Number.isFinite(ahead)) return null; + return { ahead, behind }; +} + +export function resolveDefaultBranch(symbolicRef, branches) { + if (symbolicRef) return symbolicRef; + const refs = new Set(branches.filter((branch) => branch.remote).map((branch) => branch.ref)); + const preferred = [ + "refs/remotes/origin/main", + "refs/remotes/origin/master", + ]; + for (const ref of preferred) { + if (refs.has(ref)) return ref; + } + return branches.find((branch) => + branch.remote && /\/(?:main|master)$/.test(branch.ref) + )?.ref || null; +} + +export function normalizeRemoteUrl(rawUrl) { + const raw = String(rawUrl || "").trim(); + if (!raw) return null; + + let host; + let repoPath; + const scpMatch = raw.match(/^[^@]+@([^:]+):(.+)$/); + if (scpMatch) { + [, host, repoPath] = scpMatch; + } else { + try { + const parsed = new URL(raw); + host = parsed.hostname; + repoPath = parsed.pathname.replace(/^\/+/, ""); + } catch { + return null; + } + } + + repoPath = repoPath.replace(/\.git$/, "").replace(/\/+$/, ""); + const parts = repoPath.split("/").filter(Boolean); + if (!host || parts.length !== 2) return null; + const [owner, repo] = parts; + const github = host.toLowerCase() === "github.com"; + return { + raw, + host: host.toLowerCase(), + owner, + repo, + github, + webUrl: `https://${host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + }; +} + +export function parseStatus(output) { + const lines = output.split(/\r?\n/).filter(Boolean); + const branchLine = lines.find((line) => line.startsWith("## ")); + const files = lines.filter((line) => !line.startsWith("## ")).map((line) => ({ + status: line.slice(0, 2), + path: line.slice(3), + })); + return { branchSummary: branchLine?.slice(3) || "", files }; +} + +function branchId(name) { + return `branch:${name}`; +} + +function worktreeId(path) { + return `worktree:${path}`; +} + +function enrichBranches(branches, worktrees, pullRequests) { + const prsByBranch = new Map(); + for (const pullRequest of pullRequests) { + const existing = prsByBranch.get(pullRequest.headRefName) || []; + existing.push(pullRequest); + prsByBranch.set(pullRequest.headRefName, existing); + } + + return branches.filter((branch) => !branch.remote).map((branch) => ({ + ...branch, + id: branchId(branch.name), + worktrees: worktrees.filter((worktree) => worktree.branch === branch.name).map((worktree) => worktree.path), + pullRequests: prsByBranch.get(branch.name) || [], + })); +} + +async function addDefaultDivergence(branches, defaultBranch, cwd, commandRunner) { + if (!defaultBranch) { + return branches.map((branch) => ({ ...branch, defaultTracking: null })); + } + return Promise.all(branches.map(async (branch) => { + const result = await commandRunner("git", [ + "rev-list", + "--left-right", + "--count", + `${defaultBranch}...${branch.ref}`, + "--", + ], cwd, { allowFailure: true }); + return { + ...branch, + defaultTracking: result.error ? null : parseDivergence(result.stdout), + }; + })); +} + +async function gatherGitHub(remote, cwd, commandRunner) { + if (!remote?.github) { + return { status: "not-github", message: "The origin remote is not hosted on github.com.", pullRequests: [] }; + } + + const result = await commandRunner("gh", [ + "pr", "list", + "--repo", `${remote.owner}/${remote.repo}`, + "--state", "all", + "--limit", "100", + "--json", "number,title,url,state,isDraft,headRefName,baseRefName,updatedAt", + ], cwd, { allowFailure: true, timeout: 20_000 }); + + if (result.error) { + const unavailable = result.error.code === "ENOENT"; + return { + status: unavailable ? "unavailable" : "unauthenticated", + message: unavailable + ? "GitHub CLI is not installed; showing local Git data." + : "GitHub CLI could not load pull requests; showing local Git data.", + pullRequests: [], + }; + } + + try { + return { + status: "ready", + message: "GitHub pull request context is available.", + pullRequests: JSON.parse(result.stdout || "[]"), + }; + } catch { + return { + status: "error", + message: "GitHub CLI returned an unreadable response; showing local Git data.", + pullRequests: [], + }; + } +} + +export async function gatherRepository(startCwd, options = {}) { + const commandRunner = options.commandRunner || runCommand; + const rootResult = await commandRunner("git", ["rev-parse", "--show-toplevel"], startCwd); + const root = resolve(rootResult.stdout); + + const [commonDirResult, headResult, originResult, defaultBranchResult, statusResult, worktreeResult, branchResult] = await Promise.all([ + commandRunner("git", ["rev-parse", "--git-common-dir"], root), + commandRunner("git", ["rev-parse", "--verify", "HEAD"], root, { allowFailure: true }), + commandRunner("git", ["remote", "get-url", "origin"], root, { allowFailure: true }), + commandRunner("git", ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], root, { allowFailure: true }), + commandRunner("git", ["status", "--porcelain=v1", "--branch", "--untracked-files=normal"], root), + commandRunner("git", ["worktree", "list", "--porcelain"], root), + commandRunner("git", [ + "for-each-ref", + `--format=%(refname)%1f%(refname:short)%1f%(objectname)%1f%(upstream:short)%1f%(upstream:track)%1f%(committerdate:iso-strict)%1f%(subject)`, + "refs/heads", + "refs/remotes", + ], root), + ]); + + const remote = normalizeRemoteUrl(originResult.stdout); + const github = await gatherGitHub(remote, root, commandRunner); + const status = parseStatus(statusResult.stdout); + const worktrees = parseWorktreePorcelain(worktreeResult.stdout); + const allBranches = parseBranchRecords(branchResult.stdout); + const defaultBranch = resolveDefaultBranch(defaultBranchResult.stdout || null, allBranches); + const localBranches = enrichBranches(allBranches, worktrees, github.pullRequests); + const branches = await addDefaultDivergence( + localBranches, + defaultBranch, + root, + commandRunner, + ); + const assignedBranches = new Set(worktrees.map((worktree) => worktree.branch).filter(Boolean)); + const unassignedBranchIds = branches.filter((branch) => !assignedBranches.has(branch.name)).map((branch) => branch.id); + + const normalizedWorktrees = worktrees.map((worktree) => ({ + ...worktree, + id: worktreeId(worktree.path), + name: basename(worktree.path) || worktree.path, + current: resolve(worktree.path) === root, + branchIds: worktree.branch + ? [branchId(worktree.branch)] + : worktree.detached + ? [`detached:${worktree.path}`] + : [], + })); + if (unassignedBranchIds.length) { + normalizedWorktrees.push({ + id: "worktree:unassigned", + path: null, + name: "Unassigned branches", + head: null, + branch: null, + current: false, + virtual: true, + detached: false, + bare: false, + locked: false, + prunable: false, + branchIds: unassignedBranchIds, + }); + } + + const detachedBranches = worktrees.filter((worktree) => worktree.detached).map((worktree) => ({ + id: `detached:${worktree.path}`, + ref: worktree.head, + name: `Detached at ${worktree.head?.slice(0, 8) || "unknown"}`, + sha: worktree.head, + upstream: null, + tracking: { ahead: 0, behind: 0 }, + updatedAt: null, + subject: "Detached worktree", + remote: false, + detached: true, + worktrees: [worktree.path], + pullRequests: [], + defaultTracking: null, + })); + + return { + repository: { + id: "repository", + name: basename(root) || root, + root, + commonDir: resolve(root, commonDirResult.stdout), + head: headResult.stdout || null, + empty: Boolean(headResult.error), + dirty: status.files.length > 0, + changedFiles: status.files, + branchSummary: status.branchSummary, + defaultBranch, + remote, + }, + worktrees: normalizedWorktrees, + branches: [...branches, ...detachedBranches], + remoteBranches: allBranches.filter((branch) => branch.remote), + github: { + status: github.status, + message: github.message, + pullRequestCount: github.pullRequests.length, + }, + gatheredAt: new Date().toISOString(), + }; +} + +export async function gatherCommits(cwd, ref, baseRef, offset = 0, limit = 50, options = {}) { + const commandRunner = options.commandRunner || runCommand; + const boundedLimit = Math.min(Math.max(Number(limit) || 50, 1), 100); + const boundedOffset = Math.max(Number(offset) || 0, 0); + const format = [ + "%H", "%h", "%P", "%an", "%ae", "%aI", "%cI", "%s", + ].join("%x1f") + "%x1e"; + const revisions = [ref]; + if (baseRef && baseRef !== ref) revisions.push("--not", baseRef); + const result = await commandRunner("git", [ + "log", + `--skip=${boundedOffset}`, + `--max-count=${boundedLimit + 1}`, + `--format=${format}`, + ...revisions, + "--", + ], cwd); + const records = parseCommitRecords(result.stdout); + return { + commits: records.slice(0, boundedLimit), + offset: boundedOffset, + nextOffset: records.length > boundedLimit ? boundedOffset + boundedLimit : null, + comparisonBase: baseRef || null, + comparisonUnavailable: !baseRef, + }; +} + +export async function gatherGraphCommits(cwd, refs, offset = 0, limit = 100, options = {}) { + const commandRunner = options.commandRunner || runCommand; + const boundedLimit = Math.min(Math.max(Number(limit) || 100, 1), 250); + const boundedOffset = Math.max(Number(offset) || 0, 0); + const revisions = [...new Set(refs)].filter((ref) => + typeof ref === "string" + && (ref.startsWith("refs/heads/") || /^[0-9a-f]{40}$/i.test(ref)) + ); + if (!revisions.length) { + return { commits: [], offset: boundedOffset, nextOffset: null }; + } + const format = [ + "%H", "%h", "%P", "%an", "%ae", "%aI", "%cI", "%s", + ].join("%x1f") + "%x1e"; + const result = await commandRunner("git", [ + "log", + "--topo-order", + "--date-order", + `--skip=${boundedOffset}`, + `--max-count=${boundedLimit + 1}`, + `--format=${format}`, + ...revisions, + "--", + ], cwd); + const records = parseCommitRecords(result.stdout); + return { + commits: records.slice(0, boundedLimit), + offset: boundedOffset, + nextOffset: records.length > boundedLimit ? boundedOffset + boundedLimit : null, + }; +} + +export async function gatherCommitDetails(cwd, sha, remote, options = {}) { + if (!/^[0-9a-f]{7,40}$/i.test(sha)) { + throw new Error("Invalid commit SHA."); + } + const commandRunner = options.commandRunner || runCommand; + const format = ["%H", "%h", "%P", "%an", "%ae", "%aI", "%cI", "%s", "%b"].join("%x1f"); + const [metadata, files, summary] = await Promise.all([ + commandRunner("git", ["show", "--no-patch", `--format=${format}`, sha], cwd), + commandRunner("git", ["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-M", sha], cwd), + commandRunner("git", ["show", "--stat", "--oneline", "--format=", sha], cwd), + ]); + const [fullSha, shortSha, parents, authorName, authorEmail, authoredAt, committedAt, subject, ...bodyParts] = + metadata.stdout.split(FIELD_SEPARATOR); + return { + sha: fullSha, + shortSha, + parents: parents ? parents.split(" ") : [], + author: { name: authorName, email: authorEmail }, + authoredAt, + committedAt, + subject, + body: bodyParts.join(FIELD_SEPARATOR).trim(), + files: files.stdout.split(/\r?\n/).filter(Boolean).map((line) => { + const [status, ...paths] = line.split("\t"); + return { status, path: paths.join(" -> ") }; + }), + summary: summary.stdout, + githubUrl: remote?.github ? `${remote.webUrl}/commit/${encodeURIComponent(fullSha)}` : null, + }; +} diff --git a/extensions/git-worktree-explorer/git-data.test.mjs b/extensions/git-worktree-explorer/git-data.test.mjs new file mode 100644 index 000000000..8f245ab91 --- /dev/null +++ b/extensions/git-worktree-explorer/git-data.test.mjs @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + gatherCommitDetails, + gatherCommits, + gatherGraphCommits, + gatherRepository, + normalizeRemoteUrl, + parseBranchRecords, + parseCommitRecords, + parseDivergence, + parseTracking, + parseWorktreePorcelain, + resolveDefaultBranch, +} from "./git-data.mjs"; + +test("parses linked, detached, and locked worktrees", () => { + const worktrees = parseWorktreePorcelain([ + "worktree C:/repos/main", + "HEAD 1111111111111111111111111111111111111111", + "branch refs/heads/main", + "", + "worktree C:/repos/feature", + "HEAD 2222222222222222222222222222222222222222", + "detached", + "locked in use", + "", + ].join("\n")); + + assert.deepEqual(worktrees, [ + { + path: "C:/repos/main", + head: "1111111111111111111111111111111111111111", + branch: "main", + detached: false, + bare: false, + locked: false, + prunable: false, + }, + { + path: "C:/repos/feature", + head: "2222222222222222222222222222222222222222", + branch: null, + detached: true, + bare: false, + locked: "in use", + prunable: false, + }, + ]); +}); + +test("parses branch tracking and excludes symbolic remote HEAD", () => { + const separator = "\x1f"; + const branches = parseBranchRecords([ + ["refs/heads/main", "main", "a".repeat(40), "origin/main", "[ahead 2, behind 3]", "2026-01-02T03:04:05Z", "Main"].join(separator), + ["refs/remotes/origin/HEAD", "origin/HEAD", "a".repeat(40), "", "", "", ""].join(separator), + ["refs/remotes/origin/main", "origin/main", "a".repeat(40), "", "", "2026-01-02T03:04:05Z", "Main"].join(separator), + ].join("\n")); + + assert.equal(branches.length, 2); + assert.deepEqual(branches[0].tracking, { ahead: 2, behind: 3, gone: false }); + assert.equal(branches[1].remote, true); + assert.deepEqual(parseTracking("[gone]"), { ahead: 0, behind: 0, gone: true }); +}); + +test("parses commit records with parents and timestamps", () => { + const separator = "\x1f"; + const recordSeparator = "\x1e"; + const output = [ + "a".repeat(40), + "aaaaaaaa", + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + "2026-01-01T01:00:00Z", + "Merge topic", + ].join(separator) + recordSeparator; + const [commit] = parseCommitRecords(output); + assert.equal(commit.shortSha, "aaaaaaaa"); + assert.equal(commit.parents.length, 2); + assert.equal(commit.subject, "Merge topic"); +}); + +test("parses branch divergence from git rev-list output", () => { + assert.deepEqual(parseDivergence("3\t7"), { ahead: 7, behind: 3 }); + assert.equal(parseDivergence("invalid"), null); +}); + +test("resolves a remote default branch when origin HEAD is unavailable", () => { + const branches = [ + { ref: "refs/heads/main", remote: false }, + { ref: "refs/remotes/origin/main", remote: true }, + ]; + assert.equal(resolveDefaultBranch(null, branches), "refs/remotes/origin/main"); + assert.equal(resolveDefaultBranch("refs/remotes/upstream/trunk", branches), "refs/remotes/upstream/trunk"); + assert.equal(resolveDefaultBranch(null, [{ ref: "refs/heads/main", remote: false }]), null); +}); + +test("normalizes supported GitHub remote URL forms", () => { + assert.deepEqual(normalizeRemoteUrl("git@github.com:octo/repo.git"), { + raw: "git@github.com:octo/repo.git", + host: "github.com", + owner: "octo", + repo: "repo", + github: true, + webUrl: "https://github.com/octo/repo", + }); + assert.equal(normalizeRemoteUrl("https://github.com/octo/repo.git").repo, "repo"); + assert.equal(normalizeRemoteUrl("not a remote"), null); + assert.equal(normalizeRemoteUrl("https://github.com/too/many/parts"), null); +}); + +test("repository snapshot creates a virtual group for branches without worktrees", async () => { + const root = process.cwd(); + const sha = "a".repeat(40); + const separator = "\x1f"; + const runner = async (command, args) => { + const key = `${command} ${args.join(" ")}`; + if (key === "git rev-parse --show-toplevel") return { stdout: root, stderr: "" }; + if (key === "git rev-parse --git-common-dir") return { stdout: ".git", stderr: "" }; + if (key === "git rev-parse --verify HEAD") return { stdout: sha, stderr: "" }; + if (key === "git remote get-url origin") return { stdout: "git@github.com:octo/repo.git", stderr: "" }; + if (key === "git symbolic-ref --quiet refs/remotes/origin/HEAD") { + return { stdout: "refs/remotes/origin/main", stderr: "" }; + } + if (key.startsWith("git status ")) return { stdout: "## main...origin/main\n M file.txt", stderr: "" }; + if (key === "git worktree list --porcelain") { + return { stdout: `worktree ${root}\nHEAD ${sha}\nbranch refs/heads/main\n`, stderr: "" }; + } + if (key.startsWith("git for-each-ref ")) { + return { + stdout: [ + ["refs/heads/main", "main", sha, "origin/main", "", "2026-01-01T00:00:00Z", "Main"].join(separator), + ["refs/heads/topic", "topic", sha, "", "", "2026-01-01T00:00:00Z", "Topic"].join(separator), + ].join("\n"), + stderr: "", + }; + } + if (key.startsWith("git rev-list --left-right --count ")) { + return { stdout: key.includes("refs/heads/topic") ? "4\t2" : "0\t0", stderr: "" }; + } + if (key.startsWith("gh pr list ")) { + const error = new Error("not found"); + error.code = "ENOENT"; + return { stdout: "", stderr: "not found", error }; + } + throw new Error(`Unexpected command: ${key}`); + }; + + const snapshot = await gatherRepository(root, { commandRunner: runner }); + assert.equal(snapshot.repository.dirty, true); + assert.equal(snapshot.repository.defaultBranch, "refs/remotes/origin/main"); + assert.equal(snapshot.github.status, "unavailable"); + assert.equal(snapshot.worktrees.length, 2); + assert.deepEqual(snapshot.worktrees[1].branchIds, ["branch:topic"]); + assert.equal(snapshot.branches[0].worktrees[0], root); + assert.deepEqual(snapshot.branches[1].defaultTracking, { ahead: 2, behind: 4 }); +}); + +test("commit pagination returns a cursor only when more records exist", async () => { + const separator = "\x1f"; + const recordSeparator = "\x1e"; + const output = Array.from({ length: 51 }, (_, index) => [ + String(index).padStart(40, "a"), + String(index).padStart(8, "a"), + "", + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + `Commit ${index}`, + ].join(separator) + recordSeparator).join(""); + let receivedArgs; + const runner = async (_command, args) => { + receivedArgs = args; + return { stdout: output, stderr: "" }; + }; + const page = await gatherCommits( + process.cwd(), + "refs/heads/topic", + "refs/remotes/origin/main", + 0, + 50, + { commandRunner: runner }, + ); + assert.equal(page.commits.length, 50); + assert.equal(page.nextOffset, 50); + assert.equal(page.comparisonBase, "refs/remotes/origin/main"); + assert.equal(page.comparisonUnavailable, false); + assert.deepEqual(receivedArgs.slice(-4), [ + "refs/heads/topic", + "--not", + "refs/remotes/origin/main", + "--", + ]); +}); + +test("combined graph uses all local branch refs in topological order", async () => { + let receivedArgs; + const runner = async (_command, args) => { + receivedArgs = args; + return { stdout: "", stderr: "" }; + }; + const page = await gatherGraphCommits( + process.cwd(), + ["refs/heads/main", "refs/heads/topic", "refs/remotes/origin/main"], + 0, + 100, + { commandRunner: runner }, + ); + assert.equal(page.commits.length, 0); + assert.ok(receivedArgs.includes("--topo-order")); + assert.ok(receivedArgs.includes("--date-order")); + assert.ok(receivedArgs.includes("refs/heads/main")); + assert.ok(receivedArgs.includes("refs/heads/topic")); + assert.ok(!receivedArgs.includes("refs/remotes/origin/main")); +}); + +test("combined graph accepts pinned commit tips for stable pagination", async () => { + const tip = "a".repeat(40); + let receivedArgs; + const runner = async (_command, args) => { + receivedArgs = args; + return { stdout: "", stderr: "" }; + }; + await gatherGraphCommits(process.cwd(), [tip, "--all"], 100, 100, { commandRunner: runner }); + assert.ok(receivedArgs.includes(tip)); + assert.ok(!receivedArgs.includes("--all")); + assert.ok(receivedArgs.includes("--skip=100")); +}); + +test("commit details reject non-SHA revisions before executing Git", async () => { + await assert.rejects( + gatherCommitDetails(process.cwd(), "--all", null, { + commandRunner: async () => { + throw new Error("should not run"); + }, + }), + /Invalid commit SHA/, + ); +}); diff --git a/extensions/git-worktree-explorer/public/app.js b/extensions/git-worktree-explorer/public/app.js new file mode 100644 index 000000000..3565105d6 --- /dev/null +++ b/extensions/git-worktree-explorer/public/app.js @@ -0,0 +1,986 @@ +import { layoutCommitGraph } from "./graph-layout.mjs"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const token = new URLSearchParams(location.search).get("token"); + +const elements = { + breadcrumbs: document.querySelector("#breadcrumbs"), + empty: document.querySelector("#empty-state"), + githubStatus: document.querySelector("#github-status"), + graph: document.querySelector("#graph"), + graphScroll: document.querySelector("#graph-scroll"), + inspector: document.querySelector("#inspector"), + loading: document.querySelector("#loading"), + refresh: document.querySelector("#refresh-button"), + repoPath: document.querySelector("#repo-path"), + toast: document.querySelector("#toast"), + viewBranches: document.querySelector("#view-branches"), + viewWorktrees: document.querySelector("#view-worktrees"), + zoomIn: document.querySelector("#zoom-in"), + zoomOut: document.querySelector("#zoom-out"), + zoomReset: document.querySelector("#zoom-reset"), +}; + +const state = { + snapshot: null, + current: { type: "repository", id: "repository", label: "Repository" }, + breadcrumbs: [], + selected: null, + commits: new Map(), + branchReloadTimer: null, + branchRequestId: 0, + branchGraph: null, + branchGraphRequestId: 0, + historyGeneration: 0, + eventsStopped: false, + repositoryView: "worktrees", + zoom: 1, +}; + +async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { + "Content-Type": "application/json", + "x-git-worktree-token": token, + ...(options.headers || {}), + }, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`); + return data; +} + +function post(path, data) { + return api(path, { method: "POST", body: JSON.stringify(data) }); +} + +function showToast(message) { + elements.toast.textContent = message; + elements.toast.classList.add("visible"); + clearTimeout(showToast.timer); + showToast.timer = setTimeout(() => elements.toast.classList.remove("visible"), 2200); +} + +function formatDate(value) { + if (!value) return "Unknown"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function shortPath(value) { + if (!value) return "Unassigned"; + const parts = value.replace(/\\/g, "/").split("/"); + return parts.length > 3 ? `…/${parts.slice(-3).join("/")}` : value; +} + +function svgElement(name, attributes = {}) { + const element = document.createElementNS(SVG_NS, name); + for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value); + return element; +} + +function nodeForId(id) { + if (id === "repository") return { type: "repository", value: state.snapshot.repository }; + const worktree = state.snapshot.worktrees.find((item) => item.id === id); + if (worktree) return { type: "worktree", value: worktree }; + const branch = state.snapshot.branches.find((item) => item.id === id); + if (branch) return { type: "branch", value: branch }; + for (const page of state.commits.values()) { + const commit = page.commits.find((item) => `commit:${item.sha}` === id); + if (commit) return { type: "commit", value: commit }; + } + return null; +} + +function labelFor(node) { + if (node.type === "repository") return node.value.name; + if (node.type === "worktree") return node.value.name; + if (node.type === "branch") return node.value.name; + return node.value.shortSha; +} + +function metaFor(node) { + if (node.type === "repository") { + return `${state.snapshot.worktrees.length} worktrees · ${state.snapshot.branches.length} branches`; + } + if (node.type === "worktree") { + if (node.value.virtual) return `${node.value.branchIds.length} branches`; + return node.value.current ? "Current worktree" : shortPath(node.value.path); + } + if (node.type === "branch") { + if (node.value.detached) return node.value.sha?.slice(0, 8); + if (state.repositoryView === "branches" && node.value.defaultTracking) { + const { ahead, behind } = node.value.defaultTracking; + const badges = [ + node.value.worktrees.length ? `${node.value.worktrees.length} WT` : null, + node.value.pullRequests.length ? `${node.value.pullRequests.length} PR` : null, + ].filter(Boolean); + return `+${ahead} / -${behind} vs default${badges.length ? ` · ${badges.join(" · ")}` : ""}`; + } + const { ahead, behind } = node.value.tracking; + return `${ahead} ahead · ${behind} behind`; + } + return `${node.value.author.name} · ${formatDate(node.value.committedAt)}`; +} + +function graphChildren() { + if (state.current.type === "repository") { + return state.repositoryView === "branches" + ? state.snapshot.branches.filter((branch) => !branch.detached).map((value) => ({ type: "branch", value })) + : state.snapshot.worktrees.map((value) => ({ type: "worktree", value })); + } + if (state.current.type === "worktree") { + const worktree = state.snapshot.worktrees.find((item) => item.id === state.current.id); + return (worktree?.branchIds || []).map((id) => { + const value = state.snapshot.branches.find((branch) => branch.id === id); + return value ? { type: "branch", value } : null; + }).filter(Boolean); + } + if (state.current.type === "branch") { + return (state.commits.get(state.current.id)?.commits || []).map((value) => ({ type: "commit", value })); + } + return []; +} + +function currentNode() { + return nodeForId(state.current.id); +} + +function nodeId(node) { + return node.type === "commit" ? `commit:${node.value.sha}` : node.value.id; +} + +function repositoryCrumb() { + return { type: "repository", id: "repository", label: state.snapshot.repository.name }; +} + +function pathForNode(node) { + const root = repositoryCrumb(); + if (node.type === "repository") return [root]; + if (node.type === "worktree") { + return [root, { type: "worktree", id: node.value.id, label: labelFor(node) }]; + } + if (node.type === "branch") { + if (state.repositoryView === "branches") { + return [root, { type: "branch", id: node.value.id, label: labelFor(node) }]; + } + const worktree = state.snapshot.worktrees.find((item) => item.branchIds.includes(node.value.id)); + const branch = { type: "branch", id: node.value.id, label: labelFor(node) }; + return worktree + ? [root, { type: "worktree", id: worktree.id, label: worktree.name }, branch] + : [root, branch]; + } + return state.breadcrumbs; +} + +function addText(group, className, x, y, text, maxLength = 34) { + const label = String(text || ""); + const clipped = label.length > maxLength ? `${label.slice(0, maxLength - 1)}…` : label; + const element = svgElement("text", { class: className, x, y }); + element.textContent = clipped; + group.append(element); +} + +function renderNode(node, x, y, width, isParent = false) { + const id = nodeId(node); + const group = svgElement("g", { + class: `node ${node.type}${node.value.dirty ? " dirty" : ""}${state.selected?.id === id ? " selected" : ""}`, + role: "treeitem", + tabindex: "0", + "aria-label": `${node.type}: ${labelFor(node)}. ${metaFor(node)}`, + transform: `translate(${x} ${y})`, + }); + const height = isParent ? 98 : 88; + group.append(svgElement("rect", { class: "node-card", width, height, rx: 11 })); + group.append(svgElement("rect", { class: "node-accent", width: 5, height, rx: 3 })); + addText(group, "node-type", 18, 23, node.type.toUpperCase()); + addText(group, "node-label", 18, 48, labelFor(node), isParent ? 42 : 30); + addText(group, "node-meta", 18, 69, metaFor(node), isParent ? 52 : 34); + if (node.type === "commit") addText(group, "node-meta", 18, 80, node.value.subject, 34); + + const activate = () => selectAndDrill(node); + group.addEventListener("click", activate); + group.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(); + } + }); + return { group, height }; +} + +function renderGraph() { + if (state.current.type === "repository" && state.repositoryView === "branches") { + renderCombinedBranchGraph(); + return; + } + if (state.current.type === "branch") { + renderBranchCommitGraph(); + return; + } + const parent = currentNode(); + const children = graphChildren(); + elements.graph.replaceChildren(); + elements.empty.hidden = true; + if (!parent) return; + + const childWidth = 250; + const gapX = 28; + const columns = Math.min(Math.max(children.length, 1), 4); + const contentWidth = Math.max(900, columns * childWidth + (columns - 1) * gapX + 120); + const rows = Math.max(1, Math.ceil(children.length / columns)); + const contentHeight = Math.max(560, 230 + rows * 130); + elements.graph.setAttribute("viewBox", `0 0 ${contentWidth} ${contentHeight}`); + elements.graph.style.width = `${contentWidth * state.zoom}px`; + elements.graph.style.height = `${contentHeight * state.zoom}px`; + + const parentWidth = 300; + const parentX = (contentWidth - parentWidth) / 2; + const parentY = 55; + const edgeLayer = svgElement("g", { class: "edge-layer", "aria-hidden": "true" }); + const nodeLayer = svgElement("g", { class: "node-layer" }); + elements.graph.append(edgeLayer, nodeLayer); + const renderedParent = renderNode(parent, parentX, parentY, parentWidth, true); + nodeLayer.append(renderedParent.group); + + if (!children.length) { + elements.empty.textContent = state.snapshot.repository.empty + ? "This repository has no commits yet." + : "No child nodes are available at this level."; + elements.empty.hidden = false; + return; + } + + function graphPath(from, to, top, middle, bottom, kind) { + const startX = 28 + from * 22; + const endX = 28 + to * 22; + if (kind === "merge-parent") { + return `M ${startX} ${middle} C ${startX} ${middle + 10}, ${endX} ${bottom - 10}, ${endX} ${bottom}`; + } + if (startX === endX) return `M ${startX} ${top} L ${endX} ${bottom}`; + return `M ${startX} ${top} C ${startX} ${middle}, ${endX} ${middle}, ${endX} ${bottom}`; + } + + function relativeTime(value) { + const elapsed = Date.now() - new Date(value).getTime(); + const minutes = Math.max(0, Math.floor(elapsed / 60000)); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + return formatDate(value); + } + + function renderCombinedBranchGraph() { + renderCommitLaneGraph(state.branchGraph, { + loadingMessage: "Loading combined branch history…", + emptyMessage: "No commits are reachable from local branches.", + loadLabel: "Load 100 more", + onLoadMore: loadMoreBranchGraph, + onRetry: () => loadBranchGraph(true), + }); + } + + function renderBranchCommitGraph() { + const branch = state.snapshot.branches.find((candidate) => candidate.id === state.current.id); + const page = state.commits.get(state.current.id); + const decoratedPage = page && branch + ? { + ...page, + commits: page.commits.map((commit) => commit.sha === branch.sha + ? { + ...commit, + refs: [{ + id: branch.id, + name: branch.name, + worktreeCount: branch.worktrees.length, + pullRequestCount: branch.pullRequests.length, + default: state.snapshot.repository.defaultBranch?.endsWith(`/${branch.name}`) || false, + }], + } + : commit), + } + : page; + renderCommitLaneGraph(decoratedPage, { + loadingMessage: `Loading commits unique to ${branch?.name || "branch"}…`, + emptyMessage: page?.comparisonUnavailable + ? "Unique commits are unavailable because no remote default branch could be resolved." + : "No commits are unique to this branch.", + loadLabel: "Load 50 more", + onLoadMore: loadMoreCommits, + onRetry: () => loadBranchCommits(state.current.id), + }); + } + + function renderCommitLaneGraph(page, options) { + elements.graph.replaceChildren(); + elements.empty.replaceChildren(); + elements.empty.classList.remove("has-action"); + elements.empty.hidden = true; + if (!page) { + elements.empty.textContent = options.loadingMessage; + elements.empty.hidden = false; + return; + } + if (page.error) { + const message = document.createElement("span"); + message.textContent = `Could not load commit history: ${page.error}`; + const retry = document.createElement("button"); + retry.type = "button"; + retry.className = "action-button"; + retry.textContent = "Retry"; + retry.addEventListener("click", options.onRetry); + elements.empty.append(message, retry); + elements.empty.classList.add("has-action"); + elements.empty.hidden = false; + return; + } + if (!page.commits.length) { + elements.empty.textContent = options.emptyMessage; + elements.empty.hidden = false; + return; + } + + const { rows, maxLanes } = layoutCommitGraph(page.commits); + const rowHeight = 44; + const topPadding = 18; + const laneAreaWidth = Math.max(92, 36 + maxLanes * 22); + const contentWidth = Math.max(980, elements.graphScroll.clientWidth || 980); + const contentHeight = topPadding * 2 + rows.length * rowHeight + (page.nextOffset !== null ? 58 : 0); + elements.graph.setAttribute("viewBox", `0 0 ${contentWidth} ${contentHeight}`); + elements.graph.style.width = `${contentWidth * state.zoom}px`; + elements.graph.style.height = `${contentHeight * state.zoom}px`; + + const lineLayer = svgElement("g", { class: "commit-line-layer", "aria-hidden": "true" }); + const rowLayer = svgElement("g", { class: "commit-row-layer" }); + elements.graph.append(lineLayer, rowLayer); + + rows.forEach((row, index) => { + const top = topPadding + index * rowHeight; + const middle = top + rowHeight / 2; + const bottom = top + rowHeight; + row.transitions.forEach((transition) => { + const path = svgElement("path", { + class: `commit-lane ${transition.kind}`, + d: graphPath(transition.from, transition.to, top, middle, bottom, transition.kind), + stroke: transition.color, + }); + lineLayer.append(path); + }); + + const group = svgElement("g", { + class: `commit-row${state.selected?.id === `commit:${row.commit.sha}` ? " selected" : ""}`, + role: "treeitem", + tabindex: "0", + "aria-label": `${row.commit.subject}, ${row.commit.author.name}, ${formatDate(row.commit.committedAt)}`, + }); + group.append(svgElement("rect", { + class: "commit-row-hit", + x: 0, + y: top, + width: contentWidth, + height: rowHeight, + })); + group.append(svgElement("circle", { + class: "commit-dot", + cx: 28 + row.laneIndex * 22, + cy: middle, + r: row.commit.parents.length > 1 ? 6 : 5, + fill: row.color, + })); + + let textX = laneAreaWidth; + for (const ref of row.commit.refs || []) { + const badgeWidth = Math.min(190, 22 + ref.name.length * 7); + const badge = svgElement("g", { + class: `ref-badge${ref.default ? " default" : ""}`, + role: "button", + tabindex: "0", + "aria-label": `Open branch ${ref.name}`, + }); + badge.append(svgElement("rect", { + x: textX, + y: middle - 11, + width: badgeWidth, + height: 22, + rx: 11, + })); + addText(badge, "ref-badge-text", textX + 10, middle + 4, ref.name, 24); + const openBranch = (event) => { + event.stopPropagation(); + const branch = state.snapshot.branches.find((candidate) => candidate.id === ref.id); + if (branch) selectAndDrill({ type: "branch", value: branch }); + }; + badge.addEventListener("click", openBranch); + badge.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") openBranch(event); + }); + group.append(badge); + textX += badgeWidth + 8; + } + + addText(group, "commit-subject", textX, middle - 2, row.commit.subject, 72); + addText(group, "commit-author", textX, middle + 15, row.commit.author.name, 32); + addText(group, "commit-time", contentWidth - 72, middle + 4, relativeTime(row.commit.committedAt), 18); + + const activate = () => selectAndDrill({ type: "commit", value: row.commit }); + group.addEventListener("click", activate); + group.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(); + } + }); + rowLayer.append(group); + }); + + if (page.nextOffset !== null) { + const foreignObject = svgElement("foreignObject", { + x: contentWidth / 2 - 70, + y: contentHeight - 50, + width: 140, + height: 44, + }); + const button = document.createElement("button"); + button.className = "load-more"; + button.type = "button"; + button.textContent = options.loadLabel; + button.addEventListener("click", options.onLoadMore); + foreignObject.append(button); + rowLayer.append(foreignObject); + } + } + + const firstRowCount = Math.min(children.length, columns); + const firstRowWidth = firstRowCount * childWidth + (firstRowCount - 1) * gapX; + const firstRowStart = (contentWidth - firstRowWidth) / 2; + children.forEach((node, index) => { + const row = Math.floor(index / columns); + const itemsInRow = Math.min(columns, children.length - row * columns); + const rowWidth = itemsInRow * childWidth + (itemsInRow - 1) * gapX; + const rowStart = row === 0 ? firstRowStart : (contentWidth - rowWidth) / 2; + const column = index % columns; + const x = rowStart + column * (childWidth + gapX); + const y = 225 + row * 130; + const parentCenterX = contentWidth / 2; + const childCenterX = x + childWidth / 2; + const edge = svgElement("path", { + class: "edge", + d: `M ${parentCenterX} ${parentY + renderedParent.height} C ${parentCenterX} ${y - 50}, ${childCenterX} ${y - 50}, ${childCenterX} ${y}`, + }); + edgeLayer.append(edge); + nodeLayer.append(renderNode(node, x, y, childWidth).group); + }); + +} + +function renderBreadcrumbs() { + elements.breadcrumbs.replaceChildren(); + state.breadcrumbs.forEach((crumb, index) => { + if (index) { + const separator = document.createElement("span"); + separator.className = "crumb-separator"; + separator.textContent = "›"; + elements.breadcrumbs.append(separator); + } + const button = document.createElement("button"); + button.type = "button"; + button.className = "crumb"; + button.textContent = crumb.label; + button.title = crumb.label; + button.addEventListener("click", () => navigateTo(index)); + elements.breadcrumbs.append(button); + }); +} + +function detailRow(term, value, mono = false) { + const wrapper = document.createElement("div"); + wrapper.className = "detail-row"; + const dt = document.createElement("dt"); + dt.textContent = term; + const dd = document.createElement("dd"); + if (mono) dd.className = "mono"; + dd.textContent = value ?? "—"; + wrapper.append(dt, dd); + return wrapper; +} + +function actionButton(label, handler, primary = false) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `action-button${primary ? " primary" : ""}`; + button.textContent = label; + button.addEventListener("click", handler); + return button; +} + +function safeGitHubUrl(value) { + try { + const url = new URL(value); + return url.protocol === "https:" && url.hostname === "github.com" ? url.href : null; + } catch { + return null; + } +} + +async function copyText(value) { + await navigator.clipboard.writeText(value); + showToast("Copied to clipboard"); +} + +function renderInspector(node, details = null) { + if (!node) return; + const content = document.createElement("div"); + content.className = "inspector-content"; + const eyebrow = document.createElement("div"); + eyebrow.className = "eyebrow"; + eyebrow.textContent = node.type; + const title = document.createElement("h2"); + title.textContent = node.type === "commit" && details ? details.subject : labelFor(node); + const summary = document.createElement("p"); + summary.className = "summary"; + summary.textContent = metaFor(node); + const list = document.createElement("dl"); + list.className = "detail-list"; + const actions = document.createElement("div"); + actions.className = "actions"; + const askStatus = document.createElement("p"); + askStatus.className = "action-status"; + askStatus.dataset.role = "ask-status"; + + if (node.type === "repository") { + list.append( + detailRow("Root", node.value.root, true), + detailRow("HEAD", node.value.head?.slice(0, 12) || "No commits", true), + detailRow("Working tree", node.value.dirty ? `${node.value.changedFiles.length} changed files` : "Clean"), + detailRow("Remote", node.value.remote?.raw || "No origin remote", true), + detailRow("GitHub", state.snapshot.github.message), + ); + actions.append( + actionButton("Copy path", () => copyText(node.value.root)), + actionButton("Copy status command", () => copyText(`git -C "${node.value.root}" status`)), + actionButton("Ask Copilot", (event) => askCopilot({ id: "repository" }, event.currentTarget), true), + ); + if (node.value.changedFiles.length) appendFiles(content, node.value.changedFiles, "Changed files"); + } else if (node.type === "worktree") { + list.append( + detailRow("Path", node.value.path || "Virtual branch group", true), + detailRow("Branch", node.value.branch || (node.value.detached ? "Detached HEAD" : "Multiple")), + detailRow("HEAD", node.value.head?.slice(0, 12) || "—", true), + detailRow("State", [ + node.value.current ? "current" : null, + node.value.locked ? "locked" : null, + node.value.prunable ? "prunable" : null, + ].filter(Boolean).join(", ") || "available"), + ); + if (node.value.path) { + actions.append( + actionButton("Copy path", () => copyText(node.value.path)), + actionButton("Copy status command", () => copyText(`git -C "${node.value.path}" status`)), + ); + } + actions.append(actionButton( + "Ask Copilot", + (event) => askCopilot({ id: node.value.id }, event.currentTarget), + true, + )); + } else if (node.type === "branch") { + list.append( + detailRow("Reference", node.value.ref, true), + detailRow("HEAD", node.value.sha?.slice(0, 12), true), + detailRow("Upstream", node.value.upstream || "Not configured", true), + detailRow("Tracking", `${node.value.tracking.ahead} ahead · ${node.value.tracking.behind} behind`), + detailRow( + "vs default branch", + node.value.defaultTracking + ? `${node.value.defaultTracking.ahead} unique · ${node.value.defaultTracking.behind} behind` + : "Unavailable", + ), + detailRow("Updated", formatDate(node.value.updatedAt)), + detailRow("Worktrees", node.value.worktrees.join(", ") || "Not checked out", true), + ); + actions.append( + actionButton("Copy branch", () => copyText(node.value.name)), + actionButton("Copy log command", () => copyText(`git log "${node.value.name}" --oneline -50`)), + actionButton("Ask Copilot", (event) => askCopilot({ id: node.value.id }, event.currentTarget), true), + ); + appendPullRequests(content, node.value.pullRequests); + } else { + const commit = details || node.value; + list.append( + detailRow("Commit", commit.sha, true), + detailRow("Author", `${commit.author.name} <${commit.author.email}>`), + detailRow("Authored", formatDate(commit.authoredAt)), + detailRow("Committed", formatDate(commit.committedAt)), + detailRow("Parents", commit.parents.join(", ") || "Root commit", true), + ); + actions.append( + actionButton("Copy SHA", () => copyText(commit.sha)), + actionButton("Copy show command", () => copyText(`git show ${commit.sha}`)), + actionButton("Ask Copilot", (event) => askCopilot({ sha: commit.sha }, event.currentTarget), true), + ); + const githubUrl = safeGitHubUrl(commit.githubUrl); + if (githubUrl) actions.append(actionButton("Open on GitHub", () => window.open(githubUrl, "_blank", "noopener"))); + if (commit.body) { + const heading = document.createElement("h3"); + heading.className = "section-title"; + heading.textContent = "Message"; + const body = document.createElement("p"); + body.className = "summary"; + body.textContent = commit.body; + content.append(heading, body); + } + if (commit.files) appendFiles(content, commit.files, "Changed files"); + } + + content.prepend(eyebrow, title, summary, list, actions, askStatus); + elements.inspector.replaceChildren(content); + elements.inspector.classList.add("has-selection"); +} + +function appendFiles(content, files, title) { + const heading = document.createElement("h3"); + heading.className = "section-title"; + heading.textContent = title; + const list = document.createElement("ul"); + list.className = "file-list"; + files.forEach((file) => { + const item = document.createElement("li"); + const status = document.createElement("span"); + status.className = "file-status"; + status.textContent = file.status.trim() || "?"; + const path = document.createElement("span"); + path.className = "mono"; + path.textContent = file.path; + item.append(status, path); + list.append(item); + }); + content.append(heading, list); +} + +function appendPullRequests(content, pullRequests) { + if (!pullRequests?.length) return; + const heading = document.createElement("h3"); + heading.className = "section-title"; + heading.textContent = "Pull requests"; + const list = document.createElement("ul"); + list.className = "pr-list"; + pullRequests.forEach((pullRequest) => { + const item = document.createElement("li"); + const url = safeGitHubUrl(pullRequest.url); + if (url) { + const link = document.createElement("a"); + link.className = "pr-link"; + link.href = url; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = `#${pullRequest.number} ${pullRequest.title}`; + item.append(link); + } else { + item.textContent = `#${pullRequest.number} ${pullRequest.title}`; + } + const stateLabel = document.createElement("span"); + stateLabel.className = "badge"; + stateLabel.textContent = pullRequest.isDraft ? "Draft" : pullRequest.state; + item.append(" ", stateLabel); + list.append(item); + }); + content.append(heading, list); +} + +async function selectAndDrill(node) { + const id = nodeId(node); + state.selected = { type: node.type, id }; + renderGraph(); + if (node.type === "commit") { + renderInspector(node); + try { + const details = await post("/api/commit", { sha: node.value.sha }); + if (state.selected?.id === id) renderInspector(node, details); + } catch (error) { + showToast(error.message); + } + return; + } + + renderInspector(node); + const path = pathForNode(node); + state.breadcrumbs = path; + state.current = path.at(-1); + renderBreadcrumbs(); + renderGraph(); + + if (node.type === "branch" && !state.commits.has(id)) await loadBranchCommits(id); +} + +function navigateTo(index) { + state.breadcrumbs = state.breadcrumbs.slice(0, index + 1); + state.current = state.breadcrumbs.at(-1); + const node = currentNode(); + state.selected = node ? { type: node.type, id: nodeId(node) } : null; + renderBreadcrumbs(); + renderGraph(); + renderInspector(node); +} + +async function loadMoreCommits() { + const branchId = state.current.id; + const page = state.commits.get(branchId); + if (!page || page.nextOffset === null) return; + const requestId = ++state.branchRequestId; + const generation = state.historyGeneration; + try { + const next = await post("/api/commits", { branchId, offset: page.nextOffset }); + if ( + state.current.id !== branchId + || state.branchRequestId !== requestId + || state.historyGeneration !== generation + ) return; + state.commits.set(branchId, { + ...next, + commits: [...page.commits, ...next.commits], + offset: 0, + }); + renderGraph(); + } catch (error) { + if (state.branchRequestId === requestId && state.historyGeneration === generation) { + state.commits.set(branchId, { ...page, error: error.message }); + renderGraph(); + showToast(error.message); + } + } +} + +async function loadBranchGraph(reset = false) { + const requestId = ++state.branchGraphRequestId; + const generation = state.historyGeneration; + const offset = reset ? 0 : state.branchGraph?.nextOffset; + if (offset === null) return; + elements.loading.hidden = false; + try { + const page = await post("/api/graph", { offset: offset || 0 }); + if ( + state.branchGraphRequestId !== requestId + || state.historyGeneration !== generation + || state.repositoryView !== "branches" + ) return; + state.branchGraph = reset || !state.branchGraph + ? page + : { ...page, commits: [...state.branchGraph.commits, ...page.commits] }; + renderGraph(); + } catch (error) { + if (state.branchGraphRequestId === requestId && state.historyGeneration === generation) { + state.branchGraph = { commits: [], nextOffset: null, error: error.message }; + renderGraph(); + showToast(error.message); + } + } finally { + if (state.branchGraphRequestId === requestId) elements.loading.hidden = true; + } +} + +function loadMoreBranchGraph() { + return loadBranchGraph(false); +} + +async function askCopilot(payload, button) { + const status = elements.inspector.querySelector('[data-role="ask-status"]'); + const originalLabel = button?.textContent || "Ask Copilot"; + if (button) { + button.disabled = true; + button.textContent = "Sending…"; + } + if (status) { + status.className = "action-status pending"; + status.textContent = "Sending this selection to the current Copilot chat…"; + } + try { + await post("/api/ask", payload); + if (button) button.textContent = "Sent ✓"; + if (status) { + status.className = "action-status success"; + status.textContent = "Sent to chat. Copilot will respond in the conversation."; + } + showToast("Sent to the current Copilot chat"); + } catch (error) { + if (button) button.textContent = "Try again"; + if (status) { + status.className = "action-status error"; + status.textContent = `Could not send: ${error.message}`; + } + showToast(error.message); + } finally { + if (button) button.disabled = false; + if (button?.textContent === "Sending…") button.textContent = originalLabel; + } +} + +function updateHeader() { + elements.repoPath.textContent = state.snapshot.repository.root; + elements.repoPath.title = state.snapshot.repository.root; + elements.githubStatus.textContent = state.snapshot.github.status === "ready" + ? `${state.snapshot.github.pullRequestCount} GitHub PRs` + : "Local Git only"; + elements.githubStatus.classList.toggle("ready", state.snapshot.github.status === "ready"); + elements.githubStatus.title = state.snapshot.github.message; +} + +function applySnapshot(snapshot, preserveNavigation = false) { + state.historyGeneration++; + state.branchRequestId++; + state.branchGraphRequestId++; + clearTimeout(state.branchReloadTimer); + state.snapshot = snapshot; + state.commits.clear(); + state.branchGraph = null; + if (!preserveNavigation || !nodeForId(state.current.id)) { + state.current = repositoryCrumb(); + state.breadcrumbs = [state.current]; + } else { + state.breadcrumbs = pathForNode(nodeForId(state.current.id)); + state.current = state.breadcrumbs.at(-1); + } + state.selected = { type: state.current.type, id: state.current.id }; + updateHeader(); + renderBreadcrumbs(); + renderGraph(); + renderInspector(currentNode()); + elements.loading.hidden = true; + if (state.current.type === "branch") scheduleVisibleBranchReload(); + if (state.current.type === "repository" && state.repositoryView === "branches") loadBranchGraph(true); +} + +function scheduleVisibleBranchReload() { + clearTimeout(state.branchReloadTimer); + const branchId = state.current.id; + state.branchReloadTimer = setTimeout(() => loadBranchCommits(branchId), 75); +} + +async function loadBranchCommits(branchId) { + const requestId = ++state.branchRequestId; + const generation = state.historyGeneration; + elements.loading.hidden = false; + try { + const page = await post("/api/commits", { branchId, offset: 0 }); + if ( + state.current.id !== branchId + || state.branchRequestId !== requestId + || state.historyGeneration !== generation + ) return; + state.commits.set(branchId, page); + renderGraph(); + } catch (error) { + if ( + state.current.id === branchId + && state.branchRequestId === requestId + && state.historyGeneration === generation + ) { + state.commits.set(branchId, { commits: [], nextOffset: null, error: error.message }); + renderGraph(); + showToast(error.message); + } + } finally { + if (state.branchRequestId === requestId) elements.loading.hidden = true; + } +} + +async function refresh() { + elements.refresh.classList.add("busy"); + elements.refresh.disabled = true; + try { + applySnapshot(await post("/api/refresh", {}), true); + showToast("Repository refreshed"); + } catch (error) { + showToast(error.message); + } finally { + elements.refresh.classList.remove("busy"); + elements.refresh.disabled = false; + } +} + +async function connectEvents() { + let retryDelay = 500; + while (!state.eventsStopped) { + try { + const response = await fetch("/api/events", { + headers: { "x-git-worktree-token": token }, + }); + if (!response.ok || !response.body) throw new Error("Event stream unavailable."); + retryDelay = 500; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (!state.eventsStopped) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let boundary; + while ((boundary = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const event = block.match(/^event: (.+)$/m)?.[1]; + const data = block.match(/^data: (.+)$/m)?.[1]; + if (!event || !data) continue; + const payload = JSON.parse(data); + if (event === "snapshot") applySnapshot(payload, true); + if (event === "focus") { + const node = nodeForId(payload.nodeId); + if (node) selectAndDrill(node); + } + } + } + } catch { + // Retry because agent-triggered refresh and focus actions depend on SSE. + } + if (!state.eventsStopped) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + retryDelay = Math.min(retryDelay * 2, 5000); + } + } +} + +function setZoom(value) { + state.zoom = Math.min(1.5, Math.max(0.6, value)); + elements.zoomReset.textContent = `${Math.round(state.zoom * 100)}%`; + renderGraph(); +} + +function setRepositoryView(view) { + state.repositoryView = view; + state.current = repositoryCrumb(); + state.breadcrumbs = [state.current]; + state.selected = { type: "repository", id: "repository" }; + elements.viewWorktrees.classList.toggle("active", view === "worktrees"); + elements.viewBranches.classList.toggle("active", view === "branches"); + renderBreadcrumbs(); + renderGraph(); + renderInspector(currentNode()); + if (view === "branches") loadBranchGraph(true); +} + +elements.refresh.addEventListener("click", refresh); +elements.viewWorktrees.addEventListener("click", () => setRepositoryView("worktrees")); +elements.viewBranches.addEventListener("click", () => setRepositoryView("branches")); +elements.zoomIn.addEventListener("click", () => setZoom(state.zoom + 0.1)); +elements.zoomOut.addEventListener("click", () => setZoom(state.zoom - 0.1)); +elements.zoomReset.addEventListener("click", () => setZoom(1)); +window.addEventListener("pagehide", () => { + state.eventsStopped = true; + clearTimeout(state.branchReloadTimer); +}); + +try { + if (!token) throw new Error("Canvas capability token is missing."); + applySnapshot(await api("/api/snapshot")); + connectEvents(); +} catch (error) { + elements.loading.hidden = true; + elements.empty.hidden = false; + elements.empty.textContent = error.message; +} diff --git a/extensions/git-worktree-explorer/public/graph-layout.mjs b/extensions/git-worktree-explorer/public/graph-layout.mjs new file mode 100644 index 000000000..a5696f54e --- /dev/null +++ b/extensions/git-worktree-explorer/public/graph-layout.mjs @@ -0,0 +1,86 @@ +const COLORS = [ + "#2f81f7", + "#f778ba", + "#d29922", + "#3fb950", + "#a371f7", + "#db6d28", + "#39c5cf", + "#f85149", +]; + +function nextColor(index) { + return COLORS[index % COLORS.length]; +} + +export function layoutCommitGraph(commits) { + let lanes = []; + let colorIndex = 0; + let maxLanes = 1; + + const rows = commits.map((commit) => { + let laneIndex = lanes.findIndex((lane) => lane.sha === commit.sha); + if (laneIndex === -1) { + lanes.push({ sha: commit.sha, color: nextColor(colorIndex++) }); + laneIndex = lanes.length - 1; + } + + const before = lanes.map((lane) => ({ ...lane })); + const current = before[laneIndex]; + const after = lanes.map((lane) => ({ ...lane })); + const firstParent = commit.parents[0] || null; + + if (!firstParent) { + after.splice(laneIndex, 1); + } else { + const existingFirstParent = after.findIndex((lane, index) => + index !== laneIndex && lane.sha === firstParent + ); + if (existingFirstParent >= 0) { + after.splice(laneIndex, 1); + } else { + after[laneIndex] = { sha: firstParent, color: current.color }; + } + } + + for (const parent of commit.parents.slice(1)) { + if (after.some((lane) => lane.sha === parent)) continue; + const insertAt = Math.min(laneIndex + 1, after.length); + after.splice(insertAt, 0, { sha: parent, color: nextColor(colorIndex++) }); + } + + const transitions = []; + before.forEach((lane, index) => { + if (index === laneIndex) return; + const target = after.findIndex((candidate) => candidate.sha === lane.sha); + if (target >= 0) { + transitions.push({ from: index, to: target, color: lane.color, kind: "pass" }); + } + }); + + commit.parents.forEach((parent, parentIndex) => { + const target = after.findIndex((lane) => lane.sha === parent); + if (target >= 0) { + transitions.push({ + from: laneIndex, + to: target, + color: parentIndex === 0 ? current.color : after[target].color, + kind: parentIndex === 0 ? "first-parent" : "merge-parent", + }); + } + }); + + maxLanes = Math.max(maxLanes, before.length, after.length); + lanes = after; + return { + commit, + laneIndex, + color: current.color, + transitions, + lanesBefore: before.length, + lanesAfter: after.length, + }; + }); + + return { rows, maxLanes }; +} diff --git a/extensions/git-worktree-explorer/public/graph-layout.test.mjs b/extensions/git-worktree-explorer/public/graph-layout.test.mjs new file mode 100644 index 000000000..954d988a4 --- /dev/null +++ b/extensions/git-worktree-explorer/public/graph-layout.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { layoutCommitGraph } from "./graph-layout.mjs"; + +function commit(sha, parents = []) { + return { sha, parents }; +} + +test("lays out a linear history in one lane", () => { + const graph = layoutCommitGraph([ + commit("c", ["b"]), + commit("b", ["a"]), + commit("a"), + ]); + assert.equal(graph.maxLanes, 1); + assert.deepEqual(graph.rows.map((row) => row.laneIndex), [0, 0, 0]); +}); + +test("creates and rejoins a lane for merge parents", () => { + const graph = layoutCommitGraph([ + commit("merge", ["main", "topic"]), + commit("topic", ["base"]), + commit("main", ["base"]), + commit("base"), + ]); + assert.ok(graph.maxLanes >= 2); + assert.equal(graph.rows[0].transitions.filter((line) => line.kind === "merge-parent").length, 1); + assert.equal(graph.rows.at(-1).commit.sha, "base"); +}); + +test("keeps independent branch tips in separate lanes", () => { + const graph = layoutCommitGraph([ + commit("tip-a", ["base"]), + commit("tip-b", ["base"]), + commit("base"), + ]); + assert.ok(graph.maxLanes >= 2); + assert.notEqual(graph.rows[0].color, graph.rows[1].color); +}); diff --git a/extensions/git-worktree-explorer/public/index.html b/extensions/git-worktree-explorer/public/index.html new file mode 100644 index 000000000..9e239ca46 --- /dev/null +++ b/extensions/git-worktree-explorer/public/index.html @@ -0,0 +1,58 @@ + + +
+ + +Loading repository…
+