mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 10:45:03 +00:00
chore: publish from main
This commit is contained in:
@@ -1724,7 +1724,7 @@
|
||||
"name": "where-was-i",
|
||||
"source": "plugins/where-was-i",
|
||||
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
|
||||
"version": "1.0.2"
|
||||
"version": "1.1.0"
|
||||
},
|
||||
{
|
||||
"name": "winappcli",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { lstat, readFile, readlink } from "node:fs/promises";
|
||||
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
const STATUS_ARGS = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
|
||||
|
||||
function runGit(cwd, args, { optional = false, input } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
"git",
|
||||
args,
|
||||
{ cwd, timeout: 15000, maxBuffer: 1024 * 1024, encoding: "utf8" },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
if (optional) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
reject(new Error((stderr || error.message || "Git command failed").trim()));
|
||||
return;
|
||||
}
|
||||
resolve((stdout || "").trimEnd());
|
||||
},
|
||||
);
|
||||
if (input !== undefined) child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveBaseRef(cwd, branch) {
|
||||
const remoteDefault = await runGit(
|
||||
cwd,
|
||||
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
||||
{ optional: true },
|
||||
);
|
||||
const candidates = [remoteDefault, "origin/main", "origin/master", "main", "master"]
|
||||
.filter(Boolean)
|
||||
.filter((ref, index, refs) => refs.indexOf(ref) === index && ref !== branch);
|
||||
|
||||
for (const ref of candidates) {
|
||||
const commit = await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
||||
optional: true,
|
||||
});
|
||||
if (commit) return ref;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lines(value) {
|
||||
return value.split("\n").map((line) => line.trimEnd()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Parses `git status --porcelain=v1 -z` output. Each entry is `XY PATH\0`; renames and
|
||||
// copies are followed by a second `ORIG_PATH\0` field. Paths are never quoted in -z mode.
|
||||
export function parseStatusOutput(output) {
|
||||
const fields = output.split("\0");
|
||||
const entries = [];
|
||||
for (let index = 0; index < fields.length; index += 1) {
|
||||
const field = fields[index];
|
||||
if (!field) continue;
|
||||
const code = field.slice(0, 2);
|
||||
const path = field.slice(3);
|
||||
const isRenameOrCopy = /[RC]/.test(code);
|
||||
const originalPath = isRenameOrCopy ? fields[index + 1] || null : null;
|
||||
if (isRenameOrCopy) index += 1;
|
||||
entries.push({ code, path, originalPath });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function formatStatusEntry(entry) {
|
||||
const rename = entry.originalPath ? `${entry.originalPath} -> ` : "";
|
||||
return `${entry.code} ${rename}${entry.path}`;
|
||||
}
|
||||
|
||||
function parseGraphLine(line) {
|
||||
const [graphAndHash, subject = "", refs = ""] = line.split("\t");
|
||||
const hashMatch = graphAndHash.match(/([0-9a-f]{7,})$/);
|
||||
return {
|
||||
graph: hashMatch ? graphAndHash.slice(0, hashMatch.index) : graphAndHash,
|
||||
hash: hashMatch?.[1] || "",
|
||||
subject,
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
// Returns the index of the first graph row that can be collapsed as base-branch history.
|
||||
// `--topo-order` may interleave newer base commits above branch commits, so only the
|
||||
// suffix after the final branch commit is collapsible. Returns -1 when nothing can be split.
|
||||
export function splitCommitGraph(graph, branchHashes, baseRef) {
|
||||
if (!baseRef || !branchHashes?.size || !graph?.length) return -1;
|
||||
let lastBranchRow = -1;
|
||||
graph.forEach((row, index) => {
|
||||
if (row.hash && branchHashes.has(row.hash)) lastBranchRow = index;
|
||||
});
|
||||
if (lastBranchRow === -1) return -1;
|
||||
const firstBaseRow = graph.findIndex((row, index) => index > lastBranchRow && row.hash);
|
||||
return firstBaseRow;
|
||||
}
|
||||
|
||||
function assertRepositoryPath(root, path) {
|
||||
const absolutePath = resolve(root, path);
|
||||
const relativePath = relative(root, absolutePath);
|
||||
if (
|
||||
!relativePath
|
||||
|| relativePath === ".."
|
||||
|| relativePath.startsWith(`..${sep}`)
|
||||
|| isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error("The requested file must be inside the current worktree.");
|
||||
}
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: process.platform === "win32"
|
||||
? relativePath.replaceAll("\\", "/")
|
||||
: relativePath,
|
||||
};
|
||||
}
|
||||
|
||||
function renderNewFilePatch(relativePath, mode, addedLines) {
|
||||
return [
|
||||
`diff --git a/${relativePath} b/${relativePath}`,
|
||||
`new file mode ${mode}`,
|
||||
"--- /dev/null",
|
||||
`+++ b/${relativePath}`,
|
||||
`@@ -0,0 +1,${addedLines.length} @@`,
|
||||
...addedLines.map((line) => `+${line}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function renderUntrackedFile(root, path) {
|
||||
const { absolutePath, relativePath } = assertRepositoryPath(root, path);
|
||||
// lstat never follows symlinks, so a link pointing outside the worktree cannot be
|
||||
// dereferenced into reading an arbitrary file on disk.
|
||||
const fileStat = await lstat(absolutePath);
|
||||
if (fileStat.isSymbolicLink()) {
|
||||
// Mirror Git: a symlink's "content" is its link target, not the file it points to.
|
||||
const target = await readlink(absolutePath);
|
||||
return renderNewFilePatch(relativePath, "120000", [target]);
|
||||
}
|
||||
if (!fileStat.isFile()) throw new Error("Only untracked files can be previewed.");
|
||||
if (fileStat.size > 512 * 1024) throw new Error("This untracked file is too large to preview.");
|
||||
|
||||
const content = await readFile(absolutePath);
|
||||
if (content.includes(0)) return `Binary file ${relativePath} is untracked.`;
|
||||
|
||||
const text = content.toString("utf8");
|
||||
const addedLines = text.split(/\r?\n/);
|
||||
if (addedLines.at(-1) === "") addedLines.pop();
|
||||
const mode = (fileStat.mode & 0o111) !== 0 ? "100755" : "100644";
|
||||
return renderNewFilePatch(relativePath, mode, addedLines);
|
||||
}
|
||||
|
||||
export async function getFileDiff(cwd, requestedPath, requestedCode = null) {
|
||||
const root = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
||||
const { relativePath } = assertRepositoryPath(root, requestedPath);
|
||||
// Filtering status by only the destination path makes Git report a rename as an add.
|
||||
// Read the full status first so the original path remains available for the patch.
|
||||
const status = await runGit(root, STATUS_ARGS);
|
||||
// The same path can appear twice (e.g. a staged deletion plus an untracked re-creation),
|
||||
// so prefer the record whose status code the caller selected.
|
||||
const matches = parseStatusOutput(status).filter((item) => item.path === relativePath);
|
||||
const entry = (requestedCode && matches.find((item) => item.code === requestedCode)) || matches[0];
|
||||
if (!entry) throw new Error("This file no longer has uncommitted changes.");
|
||||
|
||||
if (entry.code === "??") {
|
||||
return {
|
||||
...entry,
|
||||
diff: await renderUntrackedFile(root, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
const patches = [];
|
||||
const diffPaths = entry.originalPath
|
||||
? [entry.originalPath, relativePath]
|
||||
: [relativePath];
|
||||
if (entry.code[0] && entry.code[0] !== " ") {
|
||||
const staged = await runGit(root, [
|
||||
"--literal-pathspecs", "diff", "--cached", "--no-ext-diff", "--", ...diffPaths,
|
||||
]);
|
||||
if (staged) patches.push({ kind: "Staged", content: staged });
|
||||
}
|
||||
if (entry.code[1] && entry.code[1] !== " ") {
|
||||
const unstaged = await runGit(root, [
|
||||
"--literal-pathspecs", "diff", "--no-ext-diff", "--", ...diffPaths,
|
||||
]);
|
||||
if (unstaged) patches.push({ kind: "Unstaged", content: unstaged });
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
diff: patches
|
||||
.map((patch) => patches.length > 1 ? `# ${patch.kind}\n${patch.content}` : patch.content)
|
||||
.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function gatherGitContext(cwd) {
|
||||
const worktreeRoot = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
||||
const [branch, head] = await Promise.all([
|
||||
runGit(worktreeRoot, ["branch", "--show-current"]),
|
||||
// Empty on an unborn branch (fresh `git init`), where no commit exists yet.
|
||||
runGit(worktreeRoot, ["rev-parse", "--short", "--verify", "--quiet", "HEAD"], { optional: true }),
|
||||
]);
|
||||
const hasHead = Boolean(head);
|
||||
const emptyTree = hasHead
|
||||
? ""
|
||||
: await runGit(worktreeRoot, ["hash-object", "-t", "tree", "--stdin"], { input: "" });
|
||||
const baseRef = hasHead ? await resolveBaseRef(worktreeRoot, branch) : null;
|
||||
const mergeBase = baseRef
|
||||
? await runGit(worktreeRoot, ["merge-base", "HEAD", baseRef], { optional: true })
|
||||
: "";
|
||||
|
||||
const branchRange = mergeBase ? `${mergeBase}..HEAD` : null;
|
||||
const graphRefs = ["HEAD"];
|
||||
if (baseRef) graphRefs.push(baseRef);
|
||||
const none = Promise.resolve("");
|
||||
const [branchLog, recentLog, graphLog, status, diffStat, stagedDiffStat, unstagedDiffStat, divergence] =
|
||||
await Promise.all([
|
||||
branchRange
|
||||
? runGit(worktreeRoot, ["log", "--format=%h %s", branchRange])
|
||||
: none,
|
||||
hasHead ? runGit(worktreeRoot, ["log", "-10", "--format=%h %s", "HEAD"]) : none,
|
||||
hasHead
|
||||
? runGit(worktreeRoot, [
|
||||
"log",
|
||||
"--graph",
|
||||
"--decorate=short",
|
||||
"--topo-order",
|
||||
"--format=%h%x09%s%x09%D",
|
||||
"--max-count=40",
|
||||
...graphRefs,
|
||||
])
|
||||
: none,
|
||||
runGit(worktreeRoot, STATUS_ARGS),
|
||||
hasHead
|
||||
? runGit(worktreeRoot, ["diff", "--stat", "HEAD"])
|
||||
: runGit(worktreeRoot, ["diff", "--stat", emptyTree]),
|
||||
runGit(worktreeRoot, ["diff", "--cached", "--stat"]),
|
||||
runGit(worktreeRoot, ["diff", "--stat"]),
|
||||
baseRef
|
||||
? runGit(worktreeRoot, ["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], {
|
||||
optional: true,
|
||||
})
|
||||
: none,
|
||||
]);
|
||||
|
||||
const [behind = 0, ahead = 0] = divergence
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((value) => Number.parseInt(value, 10) || 0);
|
||||
const changes = parseStatusOutput(status);
|
||||
const branchCommits = lines(branchLog);
|
||||
const commitGraph = lines(graphLog).map(parseGraphLine);
|
||||
const branchHashes = new Set(branchCommits.map((commit) => commit.split(" ")[0]));
|
||||
|
||||
return {
|
||||
worktreeRoot,
|
||||
worktreeName: basename(worktreeRoot),
|
||||
branch,
|
||||
head,
|
||||
baseRef,
|
||||
ahead,
|
||||
behind,
|
||||
branchCommits,
|
||||
recentCommits: lines(recentLog),
|
||||
commitGraph,
|
||||
baseGraphStart: splitCommitGraph(commitGraph, branchHashes, baseRef),
|
||||
uncommitted: changes.map(formatStatusEntry),
|
||||
changes,
|
||||
diffStat,
|
||||
stagedDiffStat,
|
||||
unstagedDiffStat,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { gatherGitContext, getFileDiff, splitCommitGraph } from "./git-context.mjs";
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function write(cwd, path, content) {
|
||||
writeFileSync(join(cwd, path), content, "utf8");
|
||||
}
|
||||
|
||||
test("gathers branch commits and every worktree change", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "staged.txt", "initial\n");
|
||||
write(cwd, "unstaged.txt", "initial\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "Seed repository");
|
||||
|
||||
git(cwd, "switch", "-c", "feature/context");
|
||||
write(cwd, "first.txt", "first\n");
|
||||
git(cwd, "add", "first.txt");
|
||||
git(cwd, "commit", "-m", "Add first feature commit");
|
||||
git(cwd, "config", "user.name", "Another Contributor");
|
||||
git(cwd, "config", "user.email", "another@example.com");
|
||||
write(cwd, "second.txt", "second\n");
|
||||
git(cwd, "add", "second.txt");
|
||||
git(cwd, "commit", "-m", "Add second feature commit");
|
||||
|
||||
write(cwd, "staged.txt", "staged change\n");
|
||||
git(cwd, "add", "staged.txt");
|
||||
write(cwd, "unstaged.txt", "unstaged change\n");
|
||||
write(cwd, "untracked.txt", "untracked change\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
|
||||
assert.equal(context.worktreeRoot.replaceAll("\\", "/"), cwd.replaceAll("\\", "/"));
|
||||
assert.equal(context.worktreeName, cwd.split(/[\\/]/).at(-1));
|
||||
assert.equal(context.branch, "feature/context");
|
||||
assert.equal(context.baseRef, "main");
|
||||
assert.equal(context.ahead, 2);
|
||||
assert.equal(context.behind, 0);
|
||||
assert.deepEqual(
|
||||
context.branchCommits.map((commit) => commit.replace(/^[0-9a-f]+ /, "")),
|
||||
["Add second feature commit", "Add first feature commit"],
|
||||
);
|
||||
assert.equal(context.commitGraph[0].subject, "Add second feature commit");
|
||||
assert.match(context.commitGraph[0].refs, /HEAD -> feature\/context/);
|
||||
assert.deepEqual(
|
||||
context.changes.map((change) => [change.code, change.path]),
|
||||
[
|
||||
["M ", "staged.txt"],
|
||||
[" M", "unstaged.txt"],
|
||||
["??", "untracked.txt"],
|
||||
],
|
||||
);
|
||||
assert.match(context.uncommitted.join("\n"), /M staged\.txt/);
|
||||
assert.match(context.uncommitted.join("\n"), / M unstaged\.txt/);
|
||||
assert.match(context.uncommitted.join("\n"), /\?\? untracked\.txt/);
|
||||
assert.match(context.diffStat, /staged\.txt/);
|
||||
assert.match(context.diffStat, /unstaged\.txt/);
|
||||
assert.match(context.stagedDiffStat, /staged\.txt/);
|
||||
assert.match(context.unstagedDiffStat, /unstaged\.txt/);
|
||||
|
||||
const stagedDiff = await getFileDiff(cwd, "staged.txt");
|
||||
assert.equal(stagedDiff.code, "M ");
|
||||
assert.match(stagedDiff.diff, /\+staged change/);
|
||||
|
||||
const unstagedDiff = await getFileDiff(cwd, "unstaged.txt");
|
||||
assert.equal(unstagedDiff.code, " M");
|
||||
assert.match(unstagedDiff.diff, /\+unstaged change/);
|
||||
|
||||
const untrackedDiff = await getFileDiff(cwd, "untracked.txt");
|
||||
assert.equal(untrackedDiff.code, "??");
|
||||
assert.match(untrackedDiff.diff, /new file mode 100644/);
|
||||
assert.match(untrackedDiff.diff, /\+untracked change/);
|
||||
});
|
||||
|
||||
test("preserves spaces and rename paths from porcelain status", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-paths-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "before name.txt", "tracked\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "Seed repository");
|
||||
|
||||
git(cwd, "mv", "before name.txt", "after name.txt");
|
||||
write(cwd, "notes draft.md", "draft\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.deepEqual(
|
||||
context.changes.map(({ code, path, originalPath }) => ({ code, path, originalPath })),
|
||||
[
|
||||
{ code: "R ", path: "after name.txt", originalPath: "before name.txt" },
|
||||
{ code: "??", path: "notes draft.md", originalPath: null },
|
||||
],
|
||||
);
|
||||
|
||||
const diff = await getFileDiff(cwd, "notes draft.md");
|
||||
assert.match(diff.diff, /\+draft/);
|
||||
|
||||
const renameDiff = await getFileDiff(cwd, "after name.txt");
|
||||
assert.equal(renameDiff.code, "R ");
|
||||
assert.equal(renameDiff.originalPath, "before name.txt");
|
||||
assert.match(renameDiff.diff, /rename from before name\.txt/);
|
||||
assert.match(renameDiff.diff, /rename to after name\.txt/);
|
||||
});
|
||||
|
||||
test("treats status-derived filenames as literal Git pathspecs", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-literal-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "[ab].txt", "initial bracket\n");
|
||||
write(cwd, "a.txt", "initial a\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "Seed repository");
|
||||
|
||||
write(cwd, "[ab].txt", "changed bracket\n");
|
||||
write(cwd, "a.txt", "changed a\n");
|
||||
|
||||
const diff = await getFileDiff(cwd, "[ab].txt");
|
||||
assert.match(diff.diff, /changed bracket/);
|
||||
assert.doesNotMatch(diff.diff, /changed a/);
|
||||
});
|
||||
|
||||
test("allows repository filenames beginning with two dots", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-dots-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
write(cwd, "..notes", "valid repository file\n");
|
||||
|
||||
const diff = await getFileDiff(cwd, "..notes");
|
||||
assert.match(diff.diff, /valid repository file/);
|
||||
});
|
||||
|
||||
test("gathers staged and untracked work from an unborn branch", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-unborn-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
write(cwd, "staged.txt", "staged\n");
|
||||
git(cwd, "add", "staged.txt");
|
||||
write(cwd, "untracked.txt", "untracked\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.equal(context.head, "");
|
||||
assert.equal(context.baseRef, null);
|
||||
assert.deepEqual(context.branchCommits, []);
|
||||
assert.deepEqual(context.recentCommits, []);
|
||||
assert.deepEqual(context.commitGraph, []);
|
||||
assert.deepEqual(
|
||||
context.changes.map((change) => [change.code, change.path]),
|
||||
[
|
||||
["A ", "staged.txt"],
|
||||
["??", "untracked.txt"],
|
||||
],
|
||||
);
|
||||
assert.match(context.diffStat, /staged\.txt/);
|
||||
});
|
||||
|
||||
test("gathers an unborn SHA-256 repository without a SHA-1 empty tree", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-sha256-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "--object-format=sha256", "-b", "main");
|
||||
write(cwd, "staged.txt", "staged\n");
|
||||
git(cwd, "add", "staged.txt");
|
||||
write(cwd, "staged.txt", "staged\nthen modified\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.equal(context.head, "");
|
||||
assert.match(context.diffStat, /staged\.txt/);
|
||||
assert.match(context.diffStat, /2 insertions/);
|
||||
});
|
||||
|
||||
test("does not dereference untracked symlinks", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-link-"));
|
||||
const outside = mkdtempSync(join(tmpdir(), "where-was-i-secret-"));
|
||||
t.after(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
write(outside, "secret.txt", "must not be exposed\n");
|
||||
const target = join(outside, "secret.txt");
|
||||
try {
|
||||
symlinkSync(target, join(cwd, "external-link.txt"), "file");
|
||||
} catch (error) {
|
||||
if (error.code === "EPERM") {
|
||||
t.skip("Creating symlinks requires Windows Developer Mode or elevated privileges.");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const diff = await getFileDiff(cwd, "external-link.txt");
|
||||
assert.match(diff.diff, /new file mode 120000/);
|
||||
assert.match(diff.diff, new RegExp(target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.doesNotMatch(diff.diff, /must not be exposed/);
|
||||
});
|
||||
|
||||
test("preserves executable mode for untracked files", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Windows does not expose POSIX execute bits.");
|
||||
return;
|
||||
}
|
||||
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-mode-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
write(cwd, "run.sh", "#!/bin/sh\necho hello\n");
|
||||
chmodSync(join(cwd, "run.sh"), 0o755);
|
||||
|
||||
const diff = await getFileDiff(cwd, "run.sh");
|
||||
assert.match(diff.diff, /new file mode 100755/);
|
||||
});
|
||||
|
||||
test("collapses only base history after the final branch commit in a diverged graph", () => {
|
||||
const row = (hash, extra = {}) => ({ graph: "* ", hash, subject: hash, refs: "", ...extra });
|
||||
const branchHashes = new Set(["b2", "b1"]);
|
||||
// --topo-order can place a newer base commit above the branch's own commits.
|
||||
const graph = [
|
||||
row("base3"),
|
||||
row("b2"),
|
||||
row("base2"),
|
||||
row("b1"),
|
||||
{ graph: "|/", hash: "", subject: "", refs: "" },
|
||||
row("base1"),
|
||||
row("root"),
|
||||
];
|
||||
const start = splitCommitGraph(graph, branchHashes, "main");
|
||||
assert.equal(start, 5, "the split must begin after the last branch commit");
|
||||
assert.deepEqual(graph.slice(start).map((item) => item.hash), ["base1", "root"]);
|
||||
|
||||
assert.equal(splitCommitGraph(graph, branchHashes, null), -1, "no base ref means no split");
|
||||
assert.equal(splitCommitGraph(graph, new Set(), "main"), -1, "no branch commits means no split");
|
||||
assert.equal(splitCommitGraph([row("b1")], branchHashes, "main"), -1, "nothing after the last branch commit");
|
||||
});
|
||||
|
||||
test("gathered context reports a graph split that keeps diverged branch commits visible", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-diverged-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "a.txt", "a\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "root");
|
||||
git(cwd, "switch", "-c", "feature");
|
||||
write(cwd, "b.txt", "b\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "feature 1");
|
||||
git(cwd, "switch", "main");
|
||||
write(cwd, "c.txt", "c\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "main moved on");
|
||||
git(cwd, "switch", "feature");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.equal(context.behind, 1);
|
||||
assert.equal(context.ahead, 1);
|
||||
const branchHashes = new Set(context.branchCommits.map((commit) => commit.split(" ")[0]));
|
||||
const lastBranchRow = context.commitGraph.reduce(
|
||||
(last, item, index) => (item.hash && branchHashes.has(item.hash) ? index : last),
|
||||
-1,
|
||||
);
|
||||
assert.ok(lastBranchRow >= 0);
|
||||
assert.ok(context.baseGraphStart > lastBranchRow, "branch commits must never land in the collapsed base section");
|
||||
// Same-second commits make the relative order of "main moved on" and "feature 1" under
|
||||
// --topo-order nondeterministic, so only assert what must always hold.
|
||||
const subjects = (rows) => rows.filter((item) => item.hash).map((item) => item.subject);
|
||||
const collapsed = subjects(context.commitGraph.slice(context.baseGraphStart));
|
||||
const focused = subjects(context.commitGraph.slice(0, context.baseGraphStart));
|
||||
assert.ok(collapsed.includes("root"));
|
||||
assert.ok(!collapsed.includes("feature 1"));
|
||||
assert.ok(focused.includes("feature 1"));
|
||||
});
|
||||
|
||||
test("selects the requested status record when a path appears twice", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-dupe-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "file.txt", "tracked\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "seed");
|
||||
// Leaves a staged deletion and an untracked file with the same path.
|
||||
git(cwd, "rm", "--cached", "file.txt");
|
||||
write(cwd, "file.txt", "recreated\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.deepEqual(
|
||||
context.changes.filter((change) => change.path === "file.txt").map((change) => change.code).sort(),
|
||||
["??", "D "],
|
||||
);
|
||||
|
||||
const staged = await getFileDiff(cwd, "file.txt", "D ");
|
||||
assert.equal(staged.code, "D ");
|
||||
assert.match(staged.diff, /-tracked/);
|
||||
|
||||
const untracked = await getFileDiff(cwd, "file.txt", "??");
|
||||
assert.equal(untracked.code, "??");
|
||||
assert.match(untracked.diff, /\+recreated/);
|
||||
|
||||
const fallback = await getFileDiff(cwd, "file.txt", "ZZ");
|
||||
assert.ok(["D ", "??"].includes(fallback.code), "an unknown code falls back to the first record");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "where-was-i",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { lstat, readFile, readlink } from "node:fs/promises";
|
||||
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
const STATUS_ARGS = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
|
||||
|
||||
function runGit(cwd, args, { optional = false, input } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
"git",
|
||||
args,
|
||||
{ cwd, timeout: 15000, maxBuffer: 1024 * 1024, encoding: "utf8" },
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
if (optional) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
reject(new Error((stderr || error.message || "Git command failed").trim()));
|
||||
return;
|
||||
}
|
||||
resolve((stdout || "").trimEnd());
|
||||
},
|
||||
);
|
||||
if (input !== undefined) child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveBaseRef(cwd, branch) {
|
||||
const remoteDefault = await runGit(
|
||||
cwd,
|
||||
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
||||
{ optional: true },
|
||||
);
|
||||
const candidates = [remoteDefault, "origin/main", "origin/master", "main", "master"]
|
||||
.filter(Boolean)
|
||||
.filter((ref, index, refs) => refs.indexOf(ref) === index && ref !== branch);
|
||||
|
||||
for (const ref of candidates) {
|
||||
const commit = await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
||||
optional: true,
|
||||
});
|
||||
if (commit) return ref;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lines(value) {
|
||||
return value.split("\n").map((line) => line.trimEnd()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Parses `git status --porcelain=v1 -z` output. Each entry is `XY PATH\0`; renames and
|
||||
// copies are followed by a second `ORIG_PATH\0` field. Paths are never quoted in -z mode.
|
||||
export function parseStatusOutput(output) {
|
||||
const fields = output.split("\0");
|
||||
const entries = [];
|
||||
for (let index = 0; index < fields.length; index += 1) {
|
||||
const field = fields[index];
|
||||
if (!field) continue;
|
||||
const code = field.slice(0, 2);
|
||||
const path = field.slice(3);
|
||||
const isRenameOrCopy = /[RC]/.test(code);
|
||||
const originalPath = isRenameOrCopy ? fields[index + 1] || null : null;
|
||||
if (isRenameOrCopy) index += 1;
|
||||
entries.push({ code, path, originalPath });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function formatStatusEntry(entry) {
|
||||
const rename = entry.originalPath ? `${entry.originalPath} -> ` : "";
|
||||
return `${entry.code} ${rename}${entry.path}`;
|
||||
}
|
||||
|
||||
function parseGraphLine(line) {
|
||||
const [graphAndHash, subject = "", refs = ""] = line.split("\t");
|
||||
const hashMatch = graphAndHash.match(/([0-9a-f]{7,})$/);
|
||||
return {
|
||||
graph: hashMatch ? graphAndHash.slice(0, hashMatch.index) : graphAndHash,
|
||||
hash: hashMatch?.[1] || "",
|
||||
subject,
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
// Returns the index of the first graph row that can be collapsed as base-branch history.
|
||||
// `--topo-order` may interleave newer base commits above branch commits, so only the
|
||||
// suffix after the final branch commit is collapsible. Returns -1 when nothing can be split.
|
||||
export function splitCommitGraph(graph, branchHashes, baseRef) {
|
||||
if (!baseRef || !branchHashes?.size || !graph?.length) return -1;
|
||||
let lastBranchRow = -1;
|
||||
graph.forEach((row, index) => {
|
||||
if (row.hash && branchHashes.has(row.hash)) lastBranchRow = index;
|
||||
});
|
||||
if (lastBranchRow === -1) return -1;
|
||||
const firstBaseRow = graph.findIndex((row, index) => index > lastBranchRow && row.hash);
|
||||
return firstBaseRow;
|
||||
}
|
||||
|
||||
function assertRepositoryPath(root, path) {
|
||||
const absolutePath = resolve(root, path);
|
||||
const relativePath = relative(root, absolutePath);
|
||||
if (
|
||||
!relativePath
|
||||
|| relativePath === ".."
|
||||
|| relativePath.startsWith(`..${sep}`)
|
||||
|| isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error("The requested file must be inside the current worktree.");
|
||||
}
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: process.platform === "win32"
|
||||
? relativePath.replaceAll("\\", "/")
|
||||
: relativePath,
|
||||
};
|
||||
}
|
||||
|
||||
function renderNewFilePatch(relativePath, mode, addedLines) {
|
||||
return [
|
||||
`diff --git a/${relativePath} b/${relativePath}`,
|
||||
`new file mode ${mode}`,
|
||||
"--- /dev/null",
|
||||
`+++ b/${relativePath}`,
|
||||
`@@ -0,0 +1,${addedLines.length} @@`,
|
||||
...addedLines.map((line) => `+${line}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function renderUntrackedFile(root, path) {
|
||||
const { absolutePath, relativePath } = assertRepositoryPath(root, path);
|
||||
// lstat never follows symlinks, so a link pointing outside the worktree cannot be
|
||||
// dereferenced into reading an arbitrary file on disk.
|
||||
const fileStat = await lstat(absolutePath);
|
||||
if (fileStat.isSymbolicLink()) {
|
||||
// Mirror Git: a symlink's "content" is its link target, not the file it points to.
|
||||
const target = await readlink(absolutePath);
|
||||
return renderNewFilePatch(relativePath, "120000", [target]);
|
||||
}
|
||||
if (!fileStat.isFile()) throw new Error("Only untracked files can be previewed.");
|
||||
if (fileStat.size > 512 * 1024) throw new Error("This untracked file is too large to preview.");
|
||||
|
||||
const content = await readFile(absolutePath);
|
||||
if (content.includes(0)) return `Binary file ${relativePath} is untracked.`;
|
||||
|
||||
const text = content.toString("utf8");
|
||||
const addedLines = text.split(/\r?\n/);
|
||||
if (addedLines.at(-1) === "") addedLines.pop();
|
||||
const mode = (fileStat.mode & 0o111) !== 0 ? "100755" : "100644";
|
||||
return renderNewFilePatch(relativePath, mode, addedLines);
|
||||
}
|
||||
|
||||
export async function getFileDiff(cwd, requestedPath, requestedCode = null) {
|
||||
const root = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
||||
const { relativePath } = assertRepositoryPath(root, requestedPath);
|
||||
// Filtering status by only the destination path makes Git report a rename as an add.
|
||||
// Read the full status first so the original path remains available for the patch.
|
||||
const status = await runGit(root, STATUS_ARGS);
|
||||
// The same path can appear twice (e.g. a staged deletion plus an untracked re-creation),
|
||||
// so prefer the record whose status code the caller selected.
|
||||
const matches = parseStatusOutput(status).filter((item) => item.path === relativePath);
|
||||
const entry = (requestedCode && matches.find((item) => item.code === requestedCode)) || matches[0];
|
||||
if (!entry) throw new Error("This file no longer has uncommitted changes.");
|
||||
|
||||
if (entry.code === "??") {
|
||||
return {
|
||||
...entry,
|
||||
diff: await renderUntrackedFile(root, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
const patches = [];
|
||||
const diffPaths = entry.originalPath
|
||||
? [entry.originalPath, relativePath]
|
||||
: [relativePath];
|
||||
if (entry.code[0] && entry.code[0] !== " ") {
|
||||
const staged = await runGit(root, [
|
||||
"--literal-pathspecs", "diff", "--cached", "--no-ext-diff", "--", ...diffPaths,
|
||||
]);
|
||||
if (staged) patches.push({ kind: "Staged", content: staged });
|
||||
}
|
||||
if (entry.code[1] && entry.code[1] !== " ") {
|
||||
const unstaged = await runGit(root, [
|
||||
"--literal-pathspecs", "diff", "--no-ext-diff", "--", ...diffPaths,
|
||||
]);
|
||||
if (unstaged) patches.push({ kind: "Unstaged", content: unstaged });
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
diff: patches
|
||||
.map((patch) => patches.length > 1 ? `# ${patch.kind}\n${patch.content}` : patch.content)
|
||||
.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function gatherGitContext(cwd) {
|
||||
const worktreeRoot = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
||||
const [branch, head] = await Promise.all([
|
||||
runGit(worktreeRoot, ["branch", "--show-current"]),
|
||||
// Empty on an unborn branch (fresh `git init`), where no commit exists yet.
|
||||
runGit(worktreeRoot, ["rev-parse", "--short", "--verify", "--quiet", "HEAD"], { optional: true }),
|
||||
]);
|
||||
const hasHead = Boolean(head);
|
||||
const emptyTree = hasHead
|
||||
? ""
|
||||
: await runGit(worktreeRoot, ["hash-object", "-t", "tree", "--stdin"], { input: "" });
|
||||
const baseRef = hasHead ? await resolveBaseRef(worktreeRoot, branch) : null;
|
||||
const mergeBase = baseRef
|
||||
? await runGit(worktreeRoot, ["merge-base", "HEAD", baseRef], { optional: true })
|
||||
: "";
|
||||
|
||||
const branchRange = mergeBase ? `${mergeBase}..HEAD` : null;
|
||||
const graphRefs = ["HEAD"];
|
||||
if (baseRef) graphRefs.push(baseRef);
|
||||
const none = Promise.resolve("");
|
||||
const [branchLog, recentLog, graphLog, status, diffStat, stagedDiffStat, unstagedDiffStat, divergence] =
|
||||
await Promise.all([
|
||||
branchRange
|
||||
? runGit(worktreeRoot, ["log", "--format=%h %s", branchRange])
|
||||
: none,
|
||||
hasHead ? runGit(worktreeRoot, ["log", "-10", "--format=%h %s", "HEAD"]) : none,
|
||||
hasHead
|
||||
? runGit(worktreeRoot, [
|
||||
"log",
|
||||
"--graph",
|
||||
"--decorate=short",
|
||||
"--topo-order",
|
||||
"--format=%h%x09%s%x09%D",
|
||||
"--max-count=40",
|
||||
...graphRefs,
|
||||
])
|
||||
: none,
|
||||
runGit(worktreeRoot, STATUS_ARGS),
|
||||
hasHead
|
||||
? runGit(worktreeRoot, ["diff", "--stat", "HEAD"])
|
||||
: runGit(worktreeRoot, ["diff", "--stat", emptyTree]),
|
||||
runGit(worktreeRoot, ["diff", "--cached", "--stat"]),
|
||||
runGit(worktreeRoot, ["diff", "--stat"]),
|
||||
baseRef
|
||||
? runGit(worktreeRoot, ["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], {
|
||||
optional: true,
|
||||
})
|
||||
: none,
|
||||
]);
|
||||
|
||||
const [behind = 0, ahead = 0] = divergence
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((value) => Number.parseInt(value, 10) || 0);
|
||||
const changes = parseStatusOutput(status);
|
||||
const branchCommits = lines(branchLog);
|
||||
const commitGraph = lines(graphLog).map(parseGraphLine);
|
||||
const branchHashes = new Set(branchCommits.map((commit) => commit.split(" ")[0]));
|
||||
|
||||
return {
|
||||
worktreeRoot,
|
||||
worktreeName: basename(worktreeRoot),
|
||||
branch,
|
||||
head,
|
||||
baseRef,
|
||||
ahead,
|
||||
behind,
|
||||
branchCommits,
|
||||
recentCommits: lines(recentLog),
|
||||
commitGraph,
|
||||
baseGraphStart: splitCommitGraph(commitGraph, branchHashes, baseRef),
|
||||
uncommitted: changes.map(formatStatusEntry),
|
||||
changes,
|
||||
diffStat,
|
||||
stagedDiffStat,
|
||||
unstagedDiffStat,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { gatherGitContext, getFileDiff, splitCommitGraph } from "./git-context.mjs";
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function write(cwd, path, content) {
|
||||
writeFileSync(join(cwd, path), content, "utf8");
|
||||
}
|
||||
|
||||
test("gathers branch commits and every worktree change", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "staged.txt", "initial\n");
|
||||
write(cwd, "unstaged.txt", "initial\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "Seed repository");
|
||||
|
||||
git(cwd, "switch", "-c", "feature/context");
|
||||
write(cwd, "first.txt", "first\n");
|
||||
git(cwd, "add", "first.txt");
|
||||
git(cwd, "commit", "-m", "Add first feature commit");
|
||||
git(cwd, "config", "user.name", "Another Contributor");
|
||||
git(cwd, "config", "user.email", "another@example.com");
|
||||
write(cwd, "second.txt", "second\n");
|
||||
git(cwd, "add", "second.txt");
|
||||
git(cwd, "commit", "-m", "Add second feature commit");
|
||||
|
||||
write(cwd, "staged.txt", "staged change\n");
|
||||
git(cwd, "add", "staged.txt");
|
||||
write(cwd, "unstaged.txt", "unstaged change\n");
|
||||
write(cwd, "untracked.txt", "untracked change\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
|
||||
assert.equal(context.worktreeRoot.replaceAll("\\", "/"), cwd.replaceAll("\\", "/"));
|
||||
assert.equal(context.worktreeName, cwd.split(/[\\/]/).at(-1));
|
||||
assert.equal(context.branch, "feature/context");
|
||||
assert.equal(context.baseRef, "main");
|
||||
assert.equal(context.ahead, 2);
|
||||
assert.equal(context.behind, 0);
|
||||
assert.deepEqual(
|
||||
context.branchCommits.map((commit) => commit.replace(/^[0-9a-f]+ /, "")),
|
||||
["Add second feature commit", "Add first feature commit"],
|
||||
);
|
||||
assert.equal(context.commitGraph[0].subject, "Add second feature commit");
|
||||
assert.match(context.commitGraph[0].refs, /HEAD -> feature\/context/);
|
||||
assert.deepEqual(
|
||||
context.changes.map((change) => [change.code, change.path]),
|
||||
[
|
||||
["M ", "staged.txt"],
|
||||
[" M", "unstaged.txt"],
|
||||
["??", "untracked.txt"],
|
||||
],
|
||||
);
|
||||
assert.match(context.uncommitted.join("\n"), /M staged\.txt/);
|
||||
assert.match(context.uncommitted.join("\n"), / M unstaged\.txt/);
|
||||
assert.match(context.uncommitted.join("\n"), /\?\? untracked\.txt/);
|
||||
assert.match(context.diffStat, /staged\.txt/);
|
||||
assert.match(context.diffStat, /unstaged\.txt/);
|
||||
assert.match(context.stagedDiffStat, /staged\.txt/);
|
||||
assert.match(context.unstagedDiffStat, /unstaged\.txt/);
|
||||
|
||||
const stagedDiff = await getFileDiff(cwd, "staged.txt");
|
||||
assert.equal(stagedDiff.code, "M ");
|
||||
assert.match(stagedDiff.diff, /\+staged change/);
|
||||
|
||||
const unstagedDiff = await getFileDiff(cwd, "unstaged.txt");
|
||||
assert.equal(unstagedDiff.code, " M");
|
||||
assert.match(unstagedDiff.diff, /\+unstaged change/);
|
||||
|
||||
const untrackedDiff = await getFileDiff(cwd, "untracked.txt");
|
||||
assert.equal(untrackedDiff.code, "??");
|
||||
assert.match(untrackedDiff.diff, /new file mode 100644/);
|
||||
assert.match(untrackedDiff.diff, /\+untracked change/);
|
||||
});
|
||||
|
||||
test("preserves spaces and rename paths from porcelain status", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-paths-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "before name.txt", "tracked\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "Seed repository");
|
||||
|
||||
git(cwd, "mv", "before name.txt", "after name.txt");
|
||||
write(cwd, "notes draft.md", "draft\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.deepEqual(
|
||||
context.changes.map(({ code, path, originalPath }) => ({ code, path, originalPath })),
|
||||
[
|
||||
{ code: "R ", path: "after name.txt", originalPath: "before name.txt" },
|
||||
{ code: "??", path: "notes draft.md", originalPath: null },
|
||||
],
|
||||
);
|
||||
|
||||
const diff = await getFileDiff(cwd, "notes draft.md");
|
||||
assert.match(diff.diff, /\+draft/);
|
||||
|
||||
const renameDiff = await getFileDiff(cwd, "after name.txt");
|
||||
assert.equal(renameDiff.code, "R ");
|
||||
assert.equal(renameDiff.originalPath, "before name.txt");
|
||||
assert.match(renameDiff.diff, /rename from before name\.txt/);
|
||||
assert.match(renameDiff.diff, /rename to after name\.txt/);
|
||||
});
|
||||
|
||||
test("treats status-derived filenames as literal Git pathspecs", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-literal-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "[ab].txt", "initial bracket\n");
|
||||
write(cwd, "a.txt", "initial a\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "Seed repository");
|
||||
|
||||
write(cwd, "[ab].txt", "changed bracket\n");
|
||||
write(cwd, "a.txt", "changed a\n");
|
||||
|
||||
const diff = await getFileDiff(cwd, "[ab].txt");
|
||||
assert.match(diff.diff, /changed bracket/);
|
||||
assert.doesNotMatch(diff.diff, /changed a/);
|
||||
});
|
||||
|
||||
test("allows repository filenames beginning with two dots", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-dots-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
write(cwd, "..notes", "valid repository file\n");
|
||||
|
||||
const diff = await getFileDiff(cwd, "..notes");
|
||||
assert.match(diff.diff, /valid repository file/);
|
||||
});
|
||||
|
||||
test("gathers staged and untracked work from an unborn branch", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-unborn-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
write(cwd, "staged.txt", "staged\n");
|
||||
git(cwd, "add", "staged.txt");
|
||||
write(cwd, "untracked.txt", "untracked\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.equal(context.head, "");
|
||||
assert.equal(context.baseRef, null);
|
||||
assert.deepEqual(context.branchCommits, []);
|
||||
assert.deepEqual(context.recentCommits, []);
|
||||
assert.deepEqual(context.commitGraph, []);
|
||||
assert.deepEqual(
|
||||
context.changes.map((change) => [change.code, change.path]),
|
||||
[
|
||||
["A ", "staged.txt"],
|
||||
["??", "untracked.txt"],
|
||||
],
|
||||
);
|
||||
assert.match(context.diffStat, /staged\.txt/);
|
||||
});
|
||||
|
||||
test("gathers an unborn SHA-256 repository without a SHA-1 empty tree", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-sha256-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "--object-format=sha256", "-b", "main");
|
||||
write(cwd, "staged.txt", "staged\n");
|
||||
git(cwd, "add", "staged.txt");
|
||||
write(cwd, "staged.txt", "staged\nthen modified\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.equal(context.head, "");
|
||||
assert.match(context.diffStat, /staged\.txt/);
|
||||
assert.match(context.diffStat, /2 insertions/);
|
||||
});
|
||||
|
||||
test("does not dereference untracked symlinks", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-link-"));
|
||||
const outside = mkdtempSync(join(tmpdir(), "where-was-i-secret-"));
|
||||
t.after(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
write(outside, "secret.txt", "must not be exposed\n");
|
||||
const target = join(outside, "secret.txt");
|
||||
try {
|
||||
symlinkSync(target, join(cwd, "external-link.txt"), "file");
|
||||
} catch (error) {
|
||||
if (error.code === "EPERM") {
|
||||
t.skip("Creating symlinks requires Windows Developer Mode or elevated privileges.");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const diff = await getFileDiff(cwd, "external-link.txt");
|
||||
assert.match(diff.diff, /new file mode 120000/);
|
||||
assert.match(diff.diff, new RegExp(target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.doesNotMatch(diff.diff, /must not be exposed/);
|
||||
});
|
||||
|
||||
test("preserves executable mode for untracked files", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Windows does not expose POSIX execute bits.");
|
||||
return;
|
||||
}
|
||||
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-mode-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
write(cwd, "run.sh", "#!/bin/sh\necho hello\n");
|
||||
chmodSync(join(cwd, "run.sh"), 0o755);
|
||||
|
||||
const diff = await getFileDiff(cwd, "run.sh");
|
||||
assert.match(diff.diff, /new file mode 100755/);
|
||||
});
|
||||
|
||||
test("collapses only base history after the final branch commit in a diverged graph", () => {
|
||||
const row = (hash, extra = {}) => ({ graph: "* ", hash, subject: hash, refs: "", ...extra });
|
||||
const branchHashes = new Set(["b2", "b1"]);
|
||||
// --topo-order can place a newer base commit above the branch's own commits.
|
||||
const graph = [
|
||||
row("base3"),
|
||||
row("b2"),
|
||||
row("base2"),
|
||||
row("b1"),
|
||||
{ graph: "|/", hash: "", subject: "", refs: "" },
|
||||
row("base1"),
|
||||
row("root"),
|
||||
];
|
||||
const start = splitCommitGraph(graph, branchHashes, "main");
|
||||
assert.equal(start, 5, "the split must begin after the last branch commit");
|
||||
assert.deepEqual(graph.slice(start).map((item) => item.hash), ["base1", "root"]);
|
||||
|
||||
assert.equal(splitCommitGraph(graph, branchHashes, null), -1, "no base ref means no split");
|
||||
assert.equal(splitCommitGraph(graph, new Set(), "main"), -1, "no branch commits means no split");
|
||||
assert.equal(splitCommitGraph([row("b1")], branchHashes, "main"), -1, "nothing after the last branch commit");
|
||||
});
|
||||
|
||||
test("gathered context reports a graph split that keeps diverged branch commits visible", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-diverged-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "a.txt", "a\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "root");
|
||||
git(cwd, "switch", "-c", "feature");
|
||||
write(cwd, "b.txt", "b\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "feature 1");
|
||||
git(cwd, "switch", "main");
|
||||
write(cwd, "c.txt", "c\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "main moved on");
|
||||
git(cwd, "switch", "feature");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.equal(context.behind, 1);
|
||||
assert.equal(context.ahead, 1);
|
||||
const branchHashes = new Set(context.branchCommits.map((commit) => commit.split(" ")[0]));
|
||||
const lastBranchRow = context.commitGraph.reduce(
|
||||
(last, item, index) => (item.hash && branchHashes.has(item.hash) ? index : last),
|
||||
-1,
|
||||
);
|
||||
assert.ok(lastBranchRow >= 0);
|
||||
assert.ok(context.baseGraphStart > lastBranchRow, "branch commits must never land in the collapsed base section");
|
||||
// Same-second commits make the relative order of "main moved on" and "feature 1" under
|
||||
// --topo-order nondeterministic, so only assert what must always hold.
|
||||
const subjects = (rows) => rows.filter((item) => item.hash).map((item) => item.subject);
|
||||
const collapsed = subjects(context.commitGraph.slice(context.baseGraphStart));
|
||||
const focused = subjects(context.commitGraph.slice(0, context.baseGraphStart));
|
||||
assert.ok(collapsed.includes("root"));
|
||||
assert.ok(!collapsed.includes("feature 1"));
|
||||
assert.ok(focused.includes("feature 1"));
|
||||
});
|
||||
|
||||
test("selects the requested status record when a path appears twice", async (t) => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "where-was-i-dupe-"));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Canvas Tester");
|
||||
git(cwd, "config", "user.email", "canvas@example.com");
|
||||
write(cwd, "file.txt", "tracked\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "commit", "-m", "seed");
|
||||
// Leaves a staged deletion and an untracked file with the same path.
|
||||
git(cwd, "rm", "--cached", "file.txt");
|
||||
write(cwd, "file.txt", "recreated\n");
|
||||
|
||||
const context = await gatherGitContext(cwd);
|
||||
assert.deepEqual(
|
||||
context.changes.filter((change) => change.path === "file.txt").map((change) => change.code).sort(),
|
||||
["??", "D "],
|
||||
);
|
||||
|
||||
const staged = await getFileDiff(cwd, "file.txt", "D ");
|
||||
assert.equal(staged.code, "D ");
|
||||
assert.match(staged.diff, /-tracked/);
|
||||
|
||||
const untracked = await getFileDiff(cwd, "file.txt", "??");
|
||||
assert.equal(untracked.code, "??");
|
||||
assert.match(untracked.diff, /\+recreated/);
|
||||
|
||||
const fallback = await getFileDiff(cwd, "file.txt", "ZZ");
|
||||
assert.ok(["D ", "??"].includes(fallback.code), "an unknown code falls back to the first record");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "where-was-i",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"name": "where-was-i",
|
||||
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"author": {
|
||||
"name": "Aaron Powell",
|
||||
"url": "https://github.com/aaronpowell"
|
||||
|
||||
Reference in New Issue
Block a user