daf5b7be92
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
904 lines
33 KiB
JavaScript
904 lines
33 KiB
JavaScript
/**
|
|
* @file Tests for server/lib/git-diff.js and server/lib/git-ops.js against a
|
|
* REAL git fixture (a bare origin, a clone, a second clone that pushes behind
|
|
* the first one's back). Diff parsing, --ff-only's refusal, the dirty-tree
|
|
* guard on `switch` and the one-worktree-per-branch rule are all git's own
|
|
* behavior — a mocked git would only test our idea of it.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, beforeEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { execFileSync } = require("node:child_process");
|
|
|
|
const {
|
|
collectChanges,
|
|
listCommits,
|
|
listPaths,
|
|
listTree,
|
|
listTreeAll,
|
|
parseUnifiedDiff,
|
|
readFileAt,
|
|
} = require("../lib/git-diff");
|
|
const { runGitAction, validateRemoteUrl } = require("../lib/git-ops");
|
|
const { gitFacts } = require("../lib/worktree");
|
|
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-"));
|
|
const ORIGIN = path.join(ROOT, "origin.git");
|
|
const LANE = path.join(ROOT, "lane");
|
|
const OTHER = path.join(ROOT, "other");
|
|
|
|
const g = (cwd, ...args) => {
|
|
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
delete env.GIT_DIR;
|
|
delete env.GIT_WORK_TREE;
|
|
delete env.GIT_INDEX_FILE;
|
|
return execFileSync("git", args, { cwd, encoding: "utf8", env });
|
|
};
|
|
const gc = (cwd, ...args) => g(cwd, "-c", "user.email=t@h", "-c", "user.name=t", ...args);
|
|
|
|
/** `git status --porcelain` as lines — the XY columns matter, so never trim. */
|
|
function porcelain(dir) {
|
|
return g(dir, "status", "--porcelain").split("\n").filter(Boolean);
|
|
}
|
|
|
|
function write(dir, file, body) {
|
|
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, file), body);
|
|
}
|
|
|
|
function freshFixture() {
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
fs.mkdirSync(ROOT, { recursive: true });
|
|
g(ROOT, "init", "-q", "--bare", ORIGIN);
|
|
|
|
const seed = path.join(ROOT, "seed");
|
|
g(ROOT, "init", "-q", "-b", "development", seed);
|
|
write(seed, "a.txt", "one\ntwo\nthree\n");
|
|
write(seed, "keep.txt", "unchanged\n");
|
|
gc(seed, "add", "-A");
|
|
gc(seed, "commit", "-qm", "init");
|
|
gc(seed, "remote", "add", "origin", ORIGIN);
|
|
gc(seed, "push", "-q", "origin", "development");
|
|
g(ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
|
|
|
|
g(ROOT, "clone", "-q", ORIGIN, LANE);
|
|
g(ROOT, "clone", "-q", ORIGIN, OTHER);
|
|
// runGitAction's `commit` uses the repo's own identity (it never injects
|
|
// one), so the fixture sets a local one — a CI box may have no global user.
|
|
for (const dir of [LANE, OTHER]) {
|
|
g(dir, "config", "user.email", "t@h");
|
|
g(dir, "config", "user.name", "t");
|
|
}
|
|
gc(LANE, "checkout", "-qb", "feat/thing");
|
|
}
|
|
|
|
before(freshFixture);
|
|
|
|
describe("parseUnifiedDiff", () => {
|
|
it("resolves both gutters across a hunk", () => {
|
|
const hunks = parseUnifiedDiff(
|
|
"@@ -10,3 +10,4 @@ function f()\n ctx\n-gone\n+new1\n+new2\n ctx2\n"
|
|
);
|
|
assert.equal(hunks.length, 1);
|
|
assert.equal(hunks[0].context, "function f()");
|
|
assert.deepEqual(
|
|
hunks[0].lines.map((l) => [l.type, l.oldLine, l.newLine, l.text]),
|
|
[
|
|
["ctx", 10, 10, "ctx"],
|
|
["del", 11, null, "gone"],
|
|
["add", null, 11, "new1"],
|
|
["add", null, 12, "new2"],
|
|
["ctx", 12, 13, "ctx2"],
|
|
]
|
|
);
|
|
});
|
|
|
|
it("ignores the preamble and the no-newline marker", () => {
|
|
const hunks = parseUnifiedDiff(
|
|
"diff --git a/x b/x\nindex 1..2 100644\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n"
|
|
);
|
|
assert.equal(hunks.length, 1);
|
|
assert.equal(hunks[0].lines.length, 2);
|
|
});
|
|
|
|
it("returns no hunks for an empty patch", () => {
|
|
assert.deepEqual(parseUnifiedDiff(""), []);
|
|
});
|
|
});
|
|
|
|
describe("collectChanges — work range", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
it("summarizes tracked edits, deletions and untracked files without hunks", async () => {
|
|
write(LANE, "a.txt", "one\nTWO\nthree\nfour\n");
|
|
fs.rmSync(path.join(LANE, "keep.txt"));
|
|
write(LANE, "new.txt", "fresh\nlines\n");
|
|
|
|
const out = await collectChanges(LANE, { range: "work" });
|
|
assert.equal(out.range, "work");
|
|
assert.equal(out.truncated, false);
|
|
const byPath = Object.fromEntries(out.files.map((f) => [f.path, f]));
|
|
assert.deepEqual(Object.keys(byPath).sort(), ["a.txt", "keep.txt", "new.txt"]);
|
|
assert.equal(byPath["a.txt"].added, 2);
|
|
assert.equal(byPath["a.txt"].deleted, 1);
|
|
assert.equal(byPath["keep.txt"].deleted, 1);
|
|
assert.equal(byPath["new.txt"].untracked, true);
|
|
assert.equal(byPath["new.txt"].added, 2);
|
|
// Overview mode is a summary: no file carries hunks.
|
|
assert.ok(out.files.every((f) => f.hunks === undefined));
|
|
});
|
|
|
|
it("returns hunks for every file in full mode", async () => {
|
|
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
|
const out = await collectChanges(LANE, { range: "work", full: true });
|
|
assert.equal(out.files.length, 1);
|
|
assert.equal(out.files[0].hunks.length, 1);
|
|
const kinds = out.files[0].hunks[0].lines.map((l) => l.type);
|
|
assert.ok(kinds.includes("del") && kinds.includes("add"));
|
|
});
|
|
|
|
it("synthesizes an all-added patch for an untracked file", async () => {
|
|
write(LANE, "new.txt", "alpha\nbeta\n");
|
|
const out = await collectChanges(LANE, { range: "work", file: "new.txt" });
|
|
assert.equal(out.files.length, 1);
|
|
const lines = out.files[0].hunks[0].lines;
|
|
assert.deepEqual(
|
|
lines.map((l) => [l.type, l.newLine, l.text]),
|
|
[
|
|
["add", 1, "alpha"],
|
|
["add", 2, "beta"],
|
|
]
|
|
);
|
|
});
|
|
|
|
it("rejects a path with no changes in the range", async () => {
|
|
await assert.rejects(() => collectChanges(LANE, { range: "work", file: "keep.txt" }), {
|
|
code: "ENOFILECHANGE",
|
|
});
|
|
});
|
|
|
|
it("still returns content when a huge file has no newline to cut on", async () => {
|
|
// A minified bundle is ONE line. Cutting the patch at the last newline
|
|
// inside the cap would land at index -1 here and hand back an empty
|
|
// patch — a file changed by a megabyte rendering as "no textual change".
|
|
const { MAX_FILE_BYTES } = require("../lib/git-diff");
|
|
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
|
|
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
|
|
const entry = out.files[0];
|
|
assert.equal(entry.truncated, true);
|
|
assert.equal(out.truncated, true);
|
|
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
|
|
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
|
|
});
|
|
|
|
it("reports a binary file without trying to diff it", async () => {
|
|
fs.writeFileSync(path.join(LANE, "blob.bin"), Buffer.from([0, 1, 2, 0, 3]));
|
|
const out = await collectChanges(LANE, { range: "work", full: true });
|
|
const blob = out.files.find((f) => f.path === "blob.bin");
|
|
assert.equal(blob.binary, true);
|
|
assert.deepEqual(blob.hunks, []);
|
|
});
|
|
});
|
|
|
|
describe("collectChanges — branch range", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
it("diffs the branch against its base, ignoring uncommitted work", async () => {
|
|
write(LANE, "a.txt", "one\ntwo\nthree\nfour\n");
|
|
gc(LANE, "add", "-A");
|
|
gc(LANE, "commit", "-qm", "add four");
|
|
write(LANE, "scratch.txt", "not committed\n");
|
|
|
|
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
|
|
assert.equal(out.baseRef, "origin/development");
|
|
assert.equal(out.mergeBase, g(LANE, "merge-base", "origin/development", "HEAD").trim());
|
|
assert.deepEqual(
|
|
out.files.map((f) => f.path),
|
|
["a.txt"]
|
|
);
|
|
assert.equal(out.files[0].added, 1);
|
|
});
|
|
|
|
it("refuses when the lane has no base to measure against", async () => {
|
|
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: null }), {
|
|
code: "ENOBASE",
|
|
});
|
|
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
|
|
code: "ENOBASE",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("gitFacts tracking counts", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
it("reports null ahead/behind for a branch with no upstream", async () => {
|
|
const facts = await gitFacts(LANE);
|
|
assert.equal(facts.branch, "feat/thing");
|
|
assert.equal(facts.hasRemote, true);
|
|
assert.equal(facts.upstream, null);
|
|
assert.equal(facts.ahead, null);
|
|
});
|
|
|
|
it("counts both sides once an upstream exists", async () => {
|
|
gc(LANE, "checkout", "-q", "development");
|
|
write(LANE, "a.txt", "one\ntwo\nthree\nlocal\n");
|
|
gc(LANE, "add", "-A");
|
|
gc(LANE, "commit", "-qm", "local");
|
|
|
|
write(OTHER, "b.txt", "remote\n");
|
|
gc(OTHER, "add", "-A");
|
|
gc(OTHER, "commit", "-qm", "remote");
|
|
gc(OTHER, "push", "-q", "origin", "development");
|
|
g(LANE, "fetch", "-q", "origin");
|
|
|
|
const facts = await gitFacts(LANE);
|
|
assert.equal(facts.upstream, "origin/development");
|
|
assert.equal(facts.ahead, 1);
|
|
assert.equal(facts.behind, 1);
|
|
});
|
|
});
|
|
|
|
describe("runGitAction", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
it("rejects an unknown action", async () => {
|
|
await assert.rejects(() => runGitAction(LANE, "rebase"), { code: "EBADACTION" });
|
|
});
|
|
|
|
it("commits everything with the given message", async () => {
|
|
write(LANE, "a.txt", "changed\n");
|
|
write(LANE, "added.txt", "new\n");
|
|
const result = await runGitAction(LANE, "commit", { message: "feat: both files" });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(g(LANE, "log", "-1", "--format=%s").trim(), "feat: both files");
|
|
assert.equal(g(LANE, "status", "--porcelain").trim(), "");
|
|
});
|
|
|
|
it("refuses a commit with no message and one with nothing to commit", async () => {
|
|
await assert.rejects(() => runGitAction(LANE, "commit", { message: " " }), {
|
|
code: "EBADMESSAGE",
|
|
});
|
|
await assert.rejects(() => runGitAction(LANE, "commit", { message: "empty" }), {
|
|
code: "ENOCHANGES",
|
|
});
|
|
});
|
|
|
|
it("pulls fast-forward only and refuses a diverged branch", async () => {
|
|
gc(LANE, "checkout", "-q", "development");
|
|
write(OTHER, "b.txt", "remote\n");
|
|
gc(OTHER, "add", "-A");
|
|
gc(OTHER, "commit", "-qm", "remote");
|
|
gc(OTHER, "push", "-q", "origin", "development");
|
|
|
|
const ff = await runGitAction(LANE, "pull");
|
|
assert.equal(ff.ok, true);
|
|
assert.ok(fs.existsSync(path.join(LANE, "b.txt")));
|
|
|
|
// Diverge: a local commit here, a different one pushed there.
|
|
write(LANE, "c.txt", "local\n");
|
|
gc(LANE, "add", "-A");
|
|
gc(LANE, "commit", "-qm", "local");
|
|
write(OTHER, "d.txt", "remote2\n");
|
|
gc(OTHER, "add", "-A");
|
|
gc(OTHER, "commit", "-qm", "remote2");
|
|
gc(OTHER, "push", "-q", "origin", "development");
|
|
|
|
await assert.rejects(() => runGitAction(LANE, "pull"));
|
|
// The refusal left no merge in progress.
|
|
assert.ok(!fs.existsSync(path.join(LANE, ".git", "MERGE_HEAD")));
|
|
});
|
|
|
|
it("pushes the current branch and sets its upstream", async () => {
|
|
write(LANE, "a.txt", "changed\n");
|
|
await runGitAction(LANE, "commit", { message: "wip" });
|
|
const result = await runGitAction(LANE, "push");
|
|
assert.equal(result.ok, true);
|
|
assert.equal(
|
|
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
|
|
"origin/feat/thing"
|
|
);
|
|
});
|
|
|
|
it("switches branches only with a clean tree", async () => {
|
|
write(LANE, "a.txt", "dirty\n");
|
|
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), {
|
|
code: "EDIRTY",
|
|
});
|
|
gc(LANE, "checkout", "-q", "--", "a.txt");
|
|
|
|
const result = await runGitAction(LANE, "switch", { branch: "development" });
|
|
assert.equal(result.branch, "development");
|
|
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "development");
|
|
});
|
|
|
|
it("creates a branch only when it does not already exist", async () => {
|
|
await runGitAction(LANE, "switch", { branch: "feat/second", create: true });
|
|
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/second");
|
|
await assert.rejects(
|
|
() => runGitAction(LANE, "switch", { branch: "feat/thing", create: true }),
|
|
{
|
|
code: "EBRANCHEXISTS",
|
|
}
|
|
);
|
|
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "nope/missing" }), {
|
|
code: "ENOBRANCH",
|
|
});
|
|
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "..bad" }), {
|
|
code: "EBADBRANCH",
|
|
});
|
|
});
|
|
|
|
it("refuses a branch another worktree holds", async () => {
|
|
const wt = path.join(ROOT, "sibling");
|
|
g(LANE, "worktree", "add", "-q", wt, "development");
|
|
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), {
|
|
code: "EBRANCHBUSY",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("collectChanges — commit range", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
it("reads one commit's files and hunks", async () => {
|
|
write(LANE, "a.txt", "one\nTWO\nthree\n");
|
|
gc(LANE, "commit", "-qam", "tweak a");
|
|
const sha = g(LANE, "rev-parse", "HEAD").trim();
|
|
|
|
const out = await collectChanges(LANE, { range: "commit", sha, full: true });
|
|
assert.equal(out.sha, sha);
|
|
assert.deepEqual(
|
|
out.files.map((f) => f.path),
|
|
["a.txt"]
|
|
);
|
|
assert.ok(out.files[0].hunks.some((h) => h.lines.some((l) => l.type === "add")));
|
|
});
|
|
|
|
it("rejects a sha that is not a commit here", async () => {
|
|
await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "nope" }), {
|
|
code: "EBADREV",
|
|
});
|
|
await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "a b;rm" }), {
|
|
code: "EBADREV",
|
|
});
|
|
});
|
|
|
|
it("widens a hunk when context is raised", async () => {
|
|
write(LANE, "long.txt", Array.from({ length: 40 }, (_, i) => `line${i}`).join("\n") + "\n");
|
|
gc(LANE, "add", "-A");
|
|
gc(LANE, "commit", "-qm", "long");
|
|
write(
|
|
LANE,
|
|
"long.txt",
|
|
Array.from({ length: 40 }, (_, i) => (i === 20 ? "CHANGED" : `line${i}`)).join("\n") + "\n"
|
|
);
|
|
|
|
const narrow = await collectChanges(LANE, { range: "work", file: "long.txt" });
|
|
const wide = await collectChanges(LANE, { range: "work", file: "long.txt", context: 20 });
|
|
const count = (o) => o.files[0].hunks.reduce((n, h) => n + h.lines.length, 0);
|
|
assert.equal(narrow.context, 3);
|
|
assert.equal(wide.context, 20);
|
|
assert.ok(count(wide) > count(narrow));
|
|
});
|
|
});
|
|
|
|
describe("listCommits / readFileAt / listPaths / listTree", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
it("lists commits, newest first, and narrows to one file", async () => {
|
|
write(LANE, "a.txt", "two\n");
|
|
gc(LANE, "commit", "-qam", "touch a");
|
|
write(LANE, "keep.txt", "edited\n");
|
|
gc(LANE, "commit", "-qam", "touch keep");
|
|
|
|
const all = await listCommits(LANE, { limit: 10 });
|
|
assert.deepEqual(
|
|
all.map((c) => c.subject),
|
|
["touch keep", "touch a", "init"]
|
|
);
|
|
assert.ok(all[0].refs.includes("HEAD -> feat/thing"));
|
|
assert.deepEqual(all[0].parents, [all[1].sha]);
|
|
assert.deepEqual(all[2].parents, []);
|
|
|
|
const forA = await listCommits(LANE, { file: "a.txt" });
|
|
assert.deepEqual(
|
|
forA.map((c) => c.subject),
|
|
["touch a", "init"]
|
|
);
|
|
});
|
|
|
|
it("reads a file at work and at a revision, and refuses to escape the lane", async () => {
|
|
write(LANE, "a.txt", "one\nEDITED\n");
|
|
const work = await readFileAt(LANE, "a.txt", "work");
|
|
assert.deepEqual(work.lines, ["one", "EDITED"]);
|
|
const head = await readFileAt(LANE, "a.txt", "HEAD");
|
|
assert.deepEqual(head.lines, ["one", "two", "three"]);
|
|
|
|
await assert.rejects(() => readFileAt(LANE, "../../etc/passwd", "work"), { code: "EBADPATH" });
|
|
await assert.rejects(() => readFileAt(LANE, "/etc/passwd", "work"), { code: "EBADPATH" });
|
|
await assert.rejects(() => readFileAt(LANE, "a.txt", "no such rev"), { code: "EBADREV" });
|
|
await assert.rejects(() => readFileAt(LANE, "ghost.txt", "work"), { code: "ENOFILE" });
|
|
});
|
|
|
|
it("quick-opens by substring, basename hits first, ignoring untracked files", async () => {
|
|
write(LANE, "src/api.ts", "x\n");
|
|
write(LANE, "src/api/helper.ts", "x\n");
|
|
gc(LANE, "add", "-A");
|
|
gc(LANE, "commit", "-qm", "src");
|
|
write(LANE, "untracked-api.ts", "x\n");
|
|
|
|
const out = await listPaths(LANE, { q: "api" });
|
|
assert.equal(out.paths[0], "src/api.ts");
|
|
assert.ok(out.paths.includes("src/api/helper.ts"));
|
|
assert.ok(!out.paths.includes("untracked-api.ts"));
|
|
});
|
|
|
|
it("folds the tree one directory level at a time", async () => {
|
|
write(LANE, "src/api/helper.ts", "x\n");
|
|
write(LANE, "src/main.ts", "x\n");
|
|
gc(LANE, "add", "-A");
|
|
gc(LANE, "commit", "-qm", "src");
|
|
|
|
const root = await listTree(LANE, {});
|
|
assert.deepEqual(
|
|
root.entries.map((e) => [e.name, e.dir]),
|
|
[
|
|
["src", true],
|
|
["a.txt", false],
|
|
["keep.txt", false],
|
|
]
|
|
);
|
|
const src = await listTree(LANE, { subdir: "src" });
|
|
assert.deepEqual(
|
|
src.entries.map((e) => e.path),
|
|
["src/api", "src/main.ts"]
|
|
);
|
|
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
|
|
});
|
|
|
|
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
|
|
write(LANE, "src/main.ts", "m\n");
|
|
write(LANE, ".gitignore", "*.log\n");
|
|
gc(LANE, "add", "-A");
|
|
gc(LANE, "commit", "-qm", "src");
|
|
write(LANE, "a.txt", "modified\n");
|
|
write(LANE, "new.txt", "fresh\n");
|
|
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
|
|
write(LANE, "noise.log", "ignored\n");
|
|
|
|
const root = await listTree(LANE, {});
|
|
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
|
|
assert.equal(by["a.txt"].status, " M");
|
|
assert.equal(by["new.txt"].status, "??");
|
|
assert.equal(by["keep.txt"].status, undefined);
|
|
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
|
|
assert.equal(by.gen.changed, true);
|
|
assert.equal(by.src.changed, undefined);
|
|
|
|
// Inside a folder git reports only as "gen/": still untracked, still marked.
|
|
const deep = await listTree(LANE, { subdir: "gen/deep" });
|
|
assert.equal(deep.entries[0].status, "??");
|
|
const all = await listTreeAll(LANE);
|
|
assert.equal(all.levels["gen"][0].changed, true);
|
|
assert.deepEqual(all.levels[""], root.entries);
|
|
});
|
|
|
|
it("reads every level of the tree in one pass, grouped like listTree", async () => {
|
|
write(LANE, "src/api/x.ts", "x\n");
|
|
write(LANE, "src/main.ts", "m\n");
|
|
gc(LANE, "add", "-A");
|
|
gc(LANE, "commit", "-qm", "src");
|
|
|
|
const all = await listTreeAll(LANE);
|
|
assert.equal(all.truncated, false);
|
|
// Each level equals what the one-level read returns for it.
|
|
for (const level of ["", "src", "src/api"]) {
|
|
const one = await listTree(LANE, { subdir: level });
|
|
assert.deepEqual(all.levels[level], one.entries, level);
|
|
}
|
|
assert.deepEqual(Object.keys(all.levels).sort(), ["", "src", "src/api"]);
|
|
});
|
|
});
|
|
|
|
describe("runGitAction — per-file staging, discard and branches", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
it("stages and unstages one file, leaving the other alone", async () => {
|
|
write(LANE, "a.txt", "edited\n");
|
|
write(LANE, "keep.txt", "also edited\n");
|
|
|
|
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
|
|
assert.deepEqual(porcelain(LANE), ["M a.txt", " M keep.txt"]);
|
|
|
|
await runGitAction(LANE, "unstage", { path: "a.txt" });
|
|
assert.deepEqual(porcelain(LANE), [" M a.txt", " M keep.txt"]);
|
|
});
|
|
|
|
it("refuses a path git is not reporting as changed", async () => {
|
|
await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["keep.txt"] }), {
|
|
code: "EBADPATH",
|
|
});
|
|
await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["../../etc/passwd"] }), {
|
|
code: "EBADPATH",
|
|
});
|
|
await assert.rejects(() => runGitAction(LANE, "stage", {}), { code: "EBADPATH" });
|
|
});
|
|
|
|
it("commits only the index when asked", async () => {
|
|
write(LANE, "a.txt", "staged\n");
|
|
write(LANE, "keep.txt", "left behind\n");
|
|
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
|
|
|
|
const out = await runGitAction(LANE, "commit", { message: "only a", only: "staged" });
|
|
assert.equal(out.ok, true);
|
|
assert.deepEqual(porcelain(LANE), [" M keep.txt"]);
|
|
});
|
|
|
|
it("refuses a staged-only commit with an empty index", async () => {
|
|
write(LANE, "a.txt", "unstaged\n");
|
|
await assert.rejects(() => runGitAction(LANE, "commit", { message: "nope", only: "staged" }), {
|
|
code: "ENOCHANGES",
|
|
});
|
|
});
|
|
|
|
it("discards a tracked file and deletes an untracked one, but only on confirm", async () => {
|
|
write(LANE, "a.txt", "edited\n");
|
|
write(LANE, "junk.txt", "throwaway\n");
|
|
|
|
await assert.rejects(() => runGitAction(LANE, "discard", { paths: ["a.txt"] }), {
|
|
code: "ECONFIRM",
|
|
});
|
|
assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "edited\n");
|
|
|
|
await runGitAction(LANE, "discard", { paths: ["a.txt", "junk.txt"], confirm: true });
|
|
assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "one\ntwo\nthree\n");
|
|
assert.ok(!fs.existsSync(path.join(LANE, "junk.txt")));
|
|
});
|
|
|
|
it("discards a staged edit in both gutters", async () => {
|
|
write(LANE, "a.txt", "edited\n");
|
|
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
|
|
await runGitAction(LANE, "discard", { paths: ["a.txt"], confirm: true });
|
|
assert.equal(g(LANE, "status", "--porcelain").trim(), "");
|
|
});
|
|
|
|
it("creates a branch without moving HEAD, and deletes it again", async () => {
|
|
const before = g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim();
|
|
await runGitAction(LANE, "branch-create", { branch: "feat/sibling" });
|
|
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), before);
|
|
assert.ok(g(LANE, "branch", "--list", "feat/sibling").trim());
|
|
|
|
await assert.rejects(() => runGitAction(LANE, "branch-create", { branch: "feat/sibling" }), {
|
|
code: "EBRANCHEXISTS",
|
|
});
|
|
|
|
await runGitAction(LANE, "branch-delete", { branch: "feat/sibling" });
|
|
assert.equal(g(LANE, "branch", "--list", "feat/sibling").trim(), "");
|
|
});
|
|
|
|
it("refuses to delete a protected, current, missing or busy branch", async () => {
|
|
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "development" }), {
|
|
code: "EPROTECTED",
|
|
});
|
|
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/thing" }), {
|
|
code: "EBRANCHBUSY",
|
|
});
|
|
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "nope" }), {
|
|
code: "ENOBRANCH",
|
|
});
|
|
|
|
await runGitAction(LANE, "branch-create", { branch: "feat/held" });
|
|
g(LANE, "worktree", "add", "-q", path.join(ROOT, "held"), "feat/held");
|
|
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/held" }), {
|
|
code: "EBRANCHBUSY",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("runGitAction — one hunk at a time", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
/** A 20-line file edited at both ends: two hunks at the default context. */
|
|
function twoHunks() {
|
|
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
|
write(LANE, "big.txt", lines.join("\n") + "\n");
|
|
gc(LANE, "add", "big.txt");
|
|
gc(LANE, "commit", "-qm", "big");
|
|
lines[0] = "FIRST";
|
|
lines[19] = "LAST";
|
|
write(LANE, "big.txt", lines.join("\n") + "\n");
|
|
}
|
|
const headers = async () =>
|
|
(await collectChanges(LANE, { range: "work", file: "big.txt" })).files[0].hunks.map(
|
|
(h) => h.header
|
|
);
|
|
|
|
it("stages ONE hunk, leaving the other in the working tree", async () => {
|
|
twoHunks();
|
|
const [first] = await headers();
|
|
await runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: first });
|
|
const staged = g(LANE, "diff", "--cached", "--", "big.txt");
|
|
const unstaged = g(LANE, "diff", "--", "big.txt");
|
|
assert.match(staged, /\+FIRST/);
|
|
assert.doesNotMatch(staged, /LAST/);
|
|
assert.match(unstaged, /\+LAST/);
|
|
assert.doesNotMatch(unstaged, /FIRST/);
|
|
});
|
|
|
|
it("undoes ONE hunk in the working tree, only with confirm", async () => {
|
|
twoHunks();
|
|
const [, second] = await headers();
|
|
await assert.rejects(
|
|
() => runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second }),
|
|
{ code: "ECONFIRM" }
|
|
);
|
|
await runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second, confirm: true });
|
|
const text = fs.readFileSync(path.join(LANE, "big.txt"), "utf8");
|
|
assert.match(text, /^FIRST\n/);
|
|
assert.match(text, /line 20\n$/);
|
|
});
|
|
|
|
it("refuses a hunk that is no longer in the diff, and a path that is not changed", async () => {
|
|
twoHunks();
|
|
await assert.rejects(
|
|
() => runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: "@@ -1,9 +1,9 @@" }),
|
|
{ code: "EHUNKGONE" }
|
|
);
|
|
await assert.rejects(
|
|
() => runGitAction(LANE, "stage-hunk", { path: "keep.txt", hunk: "@@ -1 +1 @@" }),
|
|
{ code: "EBADPATH" }
|
|
);
|
|
});
|
|
|
|
it("sends an untracked file to the per-file actions", async () => {
|
|
write(LANE, "new.txt", "x\n");
|
|
await assert.rejects(
|
|
() => runGitAction(LANE, "stage-hunk", { path: "new.txt", hunk: "@@ -0,0 +1,1 @@" }),
|
|
{ code: "EUNTRACKED" }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("runGitAction — one change block inside a hunk (issue #50)", () => {
|
|
beforeEach(freshFixture);
|
|
|
|
const base = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
|
/** Three change blocks close enough for git to merge into ONE hunk: lines
|
|
* 4-5 replaced, a line inserted after 8, line 11 deleted. */
|
|
function threeBlocks() {
|
|
write(LANE, "near.txt", base.join("\n") + "\n");
|
|
gc(LANE, "add", "near.txt");
|
|
gc(LANE, "commit", "-qm", "near");
|
|
const next = [...base];
|
|
next.splice(3, 2, "FOUR", "FIVE");
|
|
next.splice(8, 0, "INSERTED");
|
|
next.splice(11, 1); // "line 11"
|
|
write(LANE, "near.txt", next.join("\n") + "\n");
|
|
return next;
|
|
}
|
|
const hunkOf = async () => {
|
|
const [file] = (await collectChanges(LANE, { range: "work", file: "near.txt" })).files;
|
|
return file.hunks;
|
|
};
|
|
const read = () => fs.readFileSync(path.join(LANE, "near.txt"), "utf8").split("\n");
|
|
|
|
it("sees the three blocks as one git hunk (the shape the issue reports)", async () => {
|
|
threeBlocks();
|
|
assert.equal((await hunkOf()).length, 1);
|
|
});
|
|
|
|
it("stages ONE block, leaving the other two in the working tree", async () => {
|
|
threeBlocks();
|
|
const [hunk] = await hunkOf();
|
|
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 1 });
|
|
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
|
|
assert.match(staged, /^\+INSERTED$/m);
|
|
assert.doesNotMatch(staged, /FOUR|FIVE|^-line 11$/m);
|
|
const unstaged = g(LANE, "diff", "--", "near.txt");
|
|
assert.match(unstaged, /^\+FOUR$/m);
|
|
assert.match(unstaged, /^-line 11$/m);
|
|
assert.doesNotMatch(unstaged, /^\+INSERTED$/m); // staged: only context now
|
|
});
|
|
|
|
it("stages the LAST block after earlier ones shifted the line count", async () => {
|
|
threeBlocks();
|
|
const [hunk] = await hunkOf();
|
|
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 2 });
|
|
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
|
|
assert.match(staged, /^-line 11$/m);
|
|
assert.doesNotMatch(staged, /FOUR|INSERTED/);
|
|
});
|
|
|
|
it("undoes ONE block in the working tree, keeping the blocks around it", async () => {
|
|
const edited = threeBlocks();
|
|
const [hunk] = await hunkOf();
|
|
await runGitAction(LANE, "discard-hunk", {
|
|
path: "near.txt",
|
|
hunk: hunk.header,
|
|
block: 0,
|
|
confirm: true,
|
|
});
|
|
const want = [...edited];
|
|
want.splice(3, 2, "line 4", "line 5");
|
|
assert.deepEqual(read(), [...want, ""]);
|
|
});
|
|
|
|
it("splits a hunk whose last block only drops the final newline", async () => {
|
|
// One hunk: line 2 replaced, and line 6 losing its final newline — git
|
|
// writes that second block with a "\\ No newline" marker after its "+".
|
|
write(LANE, "tail.txt", "l1\nl2\nl3\nl4\nl5\nl6\n");
|
|
gc(LANE, "add", "tail.txt");
|
|
gc(LANE, "commit", "-qm", "tail");
|
|
write(LANE, "tail.txt", "l1\nTWO\nl3\nl4\nl5\nl6");
|
|
const [file] = (await collectChanges(LANE, { range: "work", file: "tail.txt" })).files;
|
|
assert.equal(file.hunks.length, 1);
|
|
const header = file.hunks[0].header;
|
|
|
|
await runGitAction(LANE, "stage-hunk", { path: "tail.txt", hunk: header, block: 1 });
|
|
const staged = g(LANE, "diff", "--cached", "--", "tail.txt");
|
|
assert.match(staged, /No newline at end of file/);
|
|
assert.doesNotMatch(staged, /TWO/);
|
|
|
|
await runGitAction(LANE, "discard-hunk", {
|
|
path: "tail.txt",
|
|
hunk: header,
|
|
block: 0,
|
|
confirm: true,
|
|
});
|
|
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
|
|
});
|
|
|
|
it("refuses a block the hunk does not have", async () => {
|
|
threeBlocks();
|
|
const [hunk] = await hunkOf();
|
|
for (const block of [3, -1, 1.5, "0"]) {
|
|
await assert.rejects(
|
|
() => runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block }),
|
|
{ code: "EHUNKGONE" }
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("an unborn repo and `publish`", () => {
|
|
const EMPTY = path.join(ROOT, "empty");
|
|
const URL_OK = "https://example.test/owner/r.git";
|
|
|
|
/** A `git init`ed repo with no commit and no remote. */
|
|
function unborn() {
|
|
fs.rmSync(EMPTY, { recursive: true, force: true });
|
|
g(ROOT, "init", "-q", "-b", "main", EMPTY);
|
|
g(EMPTY, "config", "user.email", "t@h");
|
|
g(EMPTY, "config", "user.name", "t");
|
|
}
|
|
|
|
function firstCommit() {
|
|
write(EMPTY, "x.txt", "x\n");
|
|
gc(EMPTY, "add", "-A");
|
|
gc(EMPTY, "commit", "-qm", "first");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
freshFixture();
|
|
unborn();
|
|
});
|
|
|
|
it("reports an unborn repo instead of failing to read it", async () => {
|
|
write(EMPTY, "x.txt", "x\n");
|
|
const facts = await gitFacts(EMPTY);
|
|
assert.equal(facts.unborn, true);
|
|
assert.equal(facts.head, null);
|
|
assert.equal(facts.branch, "main");
|
|
assert.equal(facts.subject, "");
|
|
assert.equal(facts.untracked, 1);
|
|
assert.equal(facts.hasRemote, false);
|
|
assert.equal((await gitFacts(LANE)).unborn, false);
|
|
});
|
|
|
|
it("makes the first commit of an unborn repo", async () => {
|
|
write(EMPTY, "x.txt", "x\n");
|
|
const out = await runGitAction(EMPTY, "commit", { message: "first" });
|
|
assert.equal(out.ok, true);
|
|
assert.equal((await gitFacts(EMPTY)).unborn, false);
|
|
});
|
|
|
|
it("refuses to publish before the first commit", async () => {
|
|
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }), {
|
|
code: "ENOCOMMITS",
|
|
});
|
|
});
|
|
|
|
it("refuses to publish a repo that already has a remote", async () => {
|
|
await assert.rejects(() => runGitAction(LANE, "publish", { url: URL_OK }), {
|
|
code: "EREMOTEEXISTS",
|
|
});
|
|
assert.equal(g(LANE, "remote", "get-url", "origin").trim(), ORIGIN);
|
|
});
|
|
|
|
it("refuses every URL outside https-without-userinfo, ssh:// and scp-like", async () => {
|
|
firstCommit();
|
|
for (const url of [
|
|
ORIGIN, // a local path
|
|
"/tmp/x",
|
|
"../sibling.git",
|
|
"file:///tmp/x.git",
|
|
"ext::sh -c id",
|
|
"ext::sh",
|
|
"fd::3",
|
|
"git://host/r.git",
|
|
"http://host/r.git",
|
|
"-oProxyCommand=x",
|
|
"--upload-pack=x",
|
|
"https://u:p@host/r.git",
|
|
"https://token@host/r.git",
|
|
"https://host/r .git",
|
|
"https://host/r.git\n",
|
|
"ssh://-oProxyCommand=x/r.git",
|
|
// WHATWG parses these as https://host/x, but git sees no "://" and reads
|
|
// them as scp-like ssh to a host named "https" — the checked string and
|
|
// the used string must be the same string.
|
|
"https:/host/r.git",
|
|
"https:host/r.git",
|
|
"https:\\\\host\\r.git",
|
|
// Percent-encoded userinfo/host that git decodes into a leading "-".
|
|
"ssh://%2DoProxyCommand=x@host/r.git",
|
|
"ssh://git@%2Dhost/r.git",
|
|
"-git@host:r.git",
|
|
"",
|
|
42,
|
|
undefined,
|
|
]) {
|
|
await assert.rejects(
|
|
() => runGitAction(EMPTY, "publish", { url }),
|
|
{ code: "EBADURL" },
|
|
`accepted ${JSON.stringify(url)}`
|
|
);
|
|
}
|
|
assert.equal(g(EMPTY, "remote").trim(), "");
|
|
});
|
|
|
|
it("accepts https, ssh:// and scp-like URLs", () => {
|
|
for (const url of [
|
|
URL_OK,
|
|
"https://host:8443/owner/r",
|
|
"ssh://git@host:2222/owner/r.git",
|
|
"ssh://host/r.git",
|
|
"git@github.com:owner/r.git",
|
|
]) {
|
|
assert.equal(validateRemoteUrl(url), url);
|
|
}
|
|
});
|
|
|
|
it("adds origin and pushes HEAD with an upstream", async () => {
|
|
firstCommit();
|
|
const bare = path.join(ROOT, "published.git");
|
|
g(ROOT, "init", "-q", "--bare", bare);
|
|
// Repo-local insteadOf maps the https URL onto the bare fixture, so the
|
|
// real https branch of the allowlist is exercised without a network.
|
|
g(EMPTY, "config", `url.${bare}.insteadOf`, URL_OK);
|
|
|
|
const out = await runGitAction(EMPTY, "publish", { url: URL_OK });
|
|
assert.equal(out.ok, true);
|
|
assert.equal(out.remote, "origin");
|
|
// `remote add origin -- <url>` stores the URL itself, not "--".
|
|
assert.equal(g(EMPTY, "config", "remote.origin.url").trim(), URL_OK);
|
|
assert.equal(g(bare, "rev-parse", "main").trim(), g(EMPTY, "rev-parse", "HEAD").trim());
|
|
const facts = await gitFacts(EMPTY);
|
|
assert.equal(facts.upstream, "origin/main");
|
|
assert.equal(facts.ahead, 0);
|
|
});
|
|
|
|
it("removes the remote again when the push fails", async () => {
|
|
firstCommit();
|
|
g(EMPTY, "config", `url.${path.join(ROOT, "nowhere.git")}.insteadOf`, URL_OK);
|
|
|
|
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }));
|
|
assert.equal(g(EMPTY, "remote").trim(), "");
|
|
});
|
|
});
|