Files
awesome-copilot/extensions/git-worktree-explorer/git-data.mjs
T
James Montemagno ca87445cb2 Add Git Worktree Explorer canvas (#2344)
* Add Git Worktree Explorer canvas

Add an interactive repository, worktree, branch, and commit graph with GitHub PR enrichment, safe inspection actions, and shared lane visualization.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 69dd5824-7094-4b82-8cfa-83c2fbe53307

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address review feedback for Git Worktree Explorer

Security
- Quote copied shell commands (git -C / git log) with shell-safe single quoting and --end-of-options via new public/shell-quote.mjs
- Move repository path into the untrusted data block of the commit Ask Copilot prompt

Performance
- Compute branch divergence with a single `git for-each-ref %(ahead-behind:...)` query (Git 2.41+), falling back to rev-list with bounded concurrency (8) instead of one process per branch
- Drop the unused `git show --stat` summary from commit details

Correctness
- Only attach same-repository pull requests to local branches (filter isCrossRepository / headRepositoryOwner)
- Resolve the default branch by full name or upstream instead of a suffix match (server + client)
- Preserve already-loaded commit pages when a load-more request fails; only reset requests show the error state
- Size the lane graph from the widest row (lanes + branch badges + text) and collapse >3 branch tips into a "+N more" badge

Accessibility / UX
- Add aria-pressed to view toggles and graph nodes; replace the unimplemented ARIA tree with group/button semantics
- preventDefault on Space/Enter for branch badges so keyboard activation does not scroll
- Use a lane palette that meets 3:1 contrast in both light and dark themes (tested)
- Mobile inspector no longer auto-opens on snapshot load and gains a close button

Tests: 19 -> 29

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b

* Migrate git-worktree-explorer to plugins/ manifest layout

Move the manifest from extensions/git-worktree-explorer/.github/plugin/plugin.json to plugins/git-worktree-explorer/plugin.json with README and copilot-extension.json, matching the extensions-container migration (#2334). Regenerated marketplace.json and docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b

* Address follow-up review: shell-specific quoting and sibling row controls

- quoteShellArg/formatShellCommand take a target shell; PowerShell doubles apostrophes while POSIX uses '\\''. app.js detects the platform and reports which syntax was copied.
- Commit rows no longer nest branch badge buttons inside the row button; the row button and badges are sibling controls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b

* Make the closed mobile inspector inert and restore focus to the graph

When the inspector overlay is dismissed on narrow viewports it is now marked inert/aria-hidden so its controls leave the tab order and accessibility tree, focus returns to the selected graph control, Escape closes it, and viewport changes re-sync the state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Aaron Powell <me@aaron-powell.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot-Session: 69dd5824-7094-4b82-8cfa-83c2fbe53307
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b
2026-08-26 10:25:00 +10:00

531 lines
20 KiB
JavaScript

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 parseAheadBehindRecords(output) {
const divergence = new Map();
for (const record of String(output || "").split(/\r?\n/)) {
if (!record) continue;
const [ref, counts] = record.split(FIELD_SEPARATOR);
const [aheadValue, behindValue] = String(counts || "").trim().split(/\s+/);
const ahead = Number(aheadValue);
const behind = Number(behindValue);
if (!ref || !Number.isFinite(ahead) || !Number.isFinite(behind)) continue;
divergence.set(ref, { ahead, behind });
}
return divergence;
}
export function describeDefaultBranch(defaultBranch) {
if (!defaultBranch) return { ref: null, short: null, name: null };
const short = defaultBranch.replace(/^refs\/remotes\//, "");
const separator = short.indexOf("/");
return {
ref: defaultBranch,
short,
name: separator === -1 ? short : short.slice(separator + 1),
};
}
export function isDefaultBranch(branch, defaultBranch) {
const resolved = typeof defaultBranch === "string" ? describeDefaultBranch(defaultBranch) : defaultBranch;
if (!resolved?.name || !branch || branch.detached) return false;
if (branch.upstream) return branch.upstream === resolved.short;
return branch.name === resolved.name;
}
export async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let nextIndex = 0;
const runners = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await worker(items[index], index);
}
});
await Promise.all(runners);
return results;
}
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}`;
}
export function isSameRepositoryPullRequest(pullRequest, remote) {
if (pullRequest.isCrossRepository === true) return false;
const headOwner = pullRequest.headRepositoryOwner?.login;
if (headOwner && remote?.owner && headOwner.toLowerCase() !== remote.owner.toLowerCase()) return false;
return true;
}
function enrichBranches(branches, worktrees, pullRequests, remote) {
const prsByBranch = new Map();
for (const pullRequest of pullRequests) {
// Fork PRs share headRefName with unrelated local branches, so only same-repository heads are attached.
if (!isSameRepositoryPullRequest(pullRequest, remote)) continue;
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) || [],
}));
}
const DIVERGENCE_CONCURRENCY = 8;
async function addDefaultDivergence(branches, defaultBranch, cwd, commandRunner) {
if (!defaultBranch || !branches.length) {
return branches.map((branch) => ({ ...branch, defaultTracking: null }));
}
// Git 2.41+ computes every branch's divergence in a single process.
const batched = await commandRunner("git", [
"for-each-ref",
`--format=%(refname)%1f%(ahead-behind:${defaultBranch})`,
"refs/heads",
], cwd, { allowFailure: true });
if (!batched.error) {
const divergence = parseAheadBehindRecords(batched.stdout);
return branches.map((branch) => ({
...branch,
defaultTracking: divergence.get(branch.ref) || null,
}));
}
// Older Git falls back to one rev-list per branch with bounded concurrency.
return mapWithConcurrency(branches, DIVERGENCE_CONCURRENCY, 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,isCrossRepository,headRepositoryOwner",
], 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 defaultBranchInfo = describeDefaultBranch(defaultBranch);
const localBranches = enrichBranches(allBranches, worktrees, github.pullRequests, remote);
const branches = (await addDefaultDivergence(
localBranches,
defaultBranch,
root,
commandRunner,
)).map((branch) => ({ ...branch, isDefault: isDefaultBranch(branch, defaultBranchInfo) }));
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,
isDefault: false,
}));
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,
defaultBranchName: defaultBranchInfo.name,
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] = 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),
]);
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(" -> ") };
}),
githubUrl: remote?.github ? `${remote.webUrl}/commit/${encodeURIComponent(fullSha)}` : null,
};
}