Files
AgentHub/server/__tests__/git-diff-ops.test.js
T
nntrivi2001 46e3899945 fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again
  (a staged change since undone on disk) is no longer listed — JetBrains'
  GitChangesCollector drops it the same way. Mode-only changes stay listed.
  statusCounts (the dirty-tree safety checks) is unchanged.
- Chat (#73): Claude Code runs no hook before a folder is trusted, so a new
  pane's session has no row; the chat now says the session has not started,
  offers Open terminal, and asks before typing a message or /model //effort
  into the pane (an Enter would pick the trust prompt's "No, exit").
- Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on
  each key (insertReplacementText), which xterm.js ignores — only each word's
  first letter reached the pane. The edit is now sent as DELs + the new tail.
- Also: Ctrl+V pastes a screenshot into the terminal as a file path; the
  folder browser fallback lists files for a file field (`files=1`); the macOS
  native picker opens in front of the browser; a hidden tab is marked while a
  chat waits or once a turn ended; chat live line shows running calls, turn
  time and tokens, and "Done in …".
2026-10-12 15:40:51 +07:00

957 lines
36 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("lists each file inside a wholly untracked folder, not the folder (#64)", async () => {
write(LANE, "docs/report.md", "# report\n");
write(LANE, "docs/deep/notes.txt", "n\n");
const out = await collectChanges(LANE, { range: "work" });
// `--untracked-files=normal` folded these into one "docs/" row that
// opened nothing; the panel needs the files, like JetBrains and VS Code.
assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]);
assert.ok(out.files.every((f) => f.untracked && f.added === 1));
assert.equal((await gitFacts(LANE)).untracked, 2);
});
// #72: a change staged and then undone in the working tree is `MM` in
// `git status` while the file on disk equals HEAD — JetBrains' Changes view
// (GitChangesCollector) drops it after a `git diff HEAD`, and so does this.
it("leaves out a staged change the working tree has reverted (#72)", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
g(LANE, "add", "a.txt");
write(LANE, "a.txt", "one\ntwo\nthree\n"); // back to HEAD's content
write(LANE, "keep.txt", "staged\n");
g(LANE, "add", "keep.txt");
write(LANE, "keep.txt", "staged, then edited\n"); // MM and still a change
assert.deepEqual(porcelain(LANE).sort(), ["MM a.txt", "MM keep.txt"]);
const out = await collectChanges(LANE, { range: "work" });
assert.deepEqual(
out.files.map((f) => f.path),
["keep.txt"]
);
});
it("still lists a file whose only change is its mode, as JetBrains does", async () => {
fs.chmodSync(path.join(LANE, "a.txt"), 0o755);
const out = await collectChanges(LANE, { range: "work" });
assert.deepEqual(
out.files.map((f) => [f.path, f.added, f.deleted]),
[["a.txt", 0, 0]]
);
});
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("falls back to the default branch without a base, and refuses a base that is gone", async () => {
// No recorded base (an adopted folder): what origin/HEAD names.
assert.equal(
(await collectChanges(LANE, { range: "branch", base: null })).base,
"development"
);
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\nvendor/\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");
write(LANE, "vendor/lib/x.js", "ignored folder\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"].ignored, true, "an ignored file is listed, marked");
assert.equal(by.vendor.ignored, true);
assert.equal(by.vendor.dir, true);
assert.equal(by[".git"], undefined, "git's own dir is never listed");
assert.equal(by["a.txt"].ignored, undefined);
assert.equal(by.src.ignored, undefined);
// Inside an ignored folder, which `ls-files --ignored` cannot list.
const lib = await listTree(LANE, { subdir: "vendor/lib" });
assert.deepEqual(
lib.entries.map((e) => [e.name, e.ignored]),
[["x.js", true]]
);
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(), "");
});
});