1b561c22d9
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
555 lines
20 KiB
JavaScript
555 lines
20 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,
|
|
parseUnifiedDiff,
|
|
readFileAt,
|
|
} = require("../lib/git-diff");
|
|
const { runGitAction } = 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.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"));
|
|
|
|
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" });
|
|
});
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
});
|