mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 18:45:12 +00:00
chore: publish from main
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user