317 lines
15 KiB
JavaScript
317 lines
15 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
|
|
const projectRoot = path.resolve(__dirname, "..", "..", "..");
|
|
const helper = path.join(projectRoot, "skills", "git-pushing", "scripts", "smart_commit.sh");
|
|
const skillInstructions = fs.readFileSync(
|
|
path.join(projectRoot, "skills", "git-pushing", "SKILL.md"),
|
|
"utf8",
|
|
);
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "git-pushing-helper-"));
|
|
|
|
assert.doesNotMatch(skillInstructions, /bash skills\/git-pushing\//);
|
|
assert.match(skillInstructions, /<skill-directory>\/scripts\/smart_commit\.sh/);
|
|
|
|
function run(command, args, cwd, expectedStatus = 0, extraEnv = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: { ...process.env, ...extraEnv },
|
|
});
|
|
assert.strictEqual(
|
|
result.status,
|
|
expectedStatus,
|
|
`${command} ${args.join(" ")} exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
function git(cwd, ...args) {
|
|
return run("git", args, cwd).stdout.trim();
|
|
}
|
|
|
|
try {
|
|
const remote = path.join(tempRoot, "remote.git");
|
|
const repo = path.join(tempRoot, "repo");
|
|
fs.mkdirSync(repo);
|
|
run("git", ["init", "--bare", remote], tempRoot);
|
|
run("git", ["init", "-b", "main"], repo);
|
|
git(repo, "config", "user.name", "Test User");
|
|
git(repo, "config", "user.email", "test@example.com");
|
|
git(repo, "remote", "add", "origin", remote);
|
|
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "base\n");
|
|
fs.writeFileSync(path.join(repo, "unrelated.txt"), "base\n");
|
|
fs.writeFileSync(path.join(repo, "staged.txt"), "base\n");
|
|
git(repo, "add", ".");
|
|
git(repo, "commit", "-m", "chore: seed repository");
|
|
git(repo, "push", "-u", "origin", "main");
|
|
git(repo, "branch", "--unset-upstream");
|
|
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "selected change\n");
|
|
fs.writeFileSync(path.join(repo, "unrelated.txt"), "keep local\n");
|
|
run("bash", [helper, "fix: commit selected path", "--", "intended.txt"], repo);
|
|
assert.deepStrictEqual(git(repo, "show", "--pretty=", "--name-only", "HEAD").split("\n"), ["intended.txt"]);
|
|
assert.match(git(repo, "status", "--short"), /^\s*M unrelated\.txt$/m);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), git(remote, "rev-parse", "refs/heads/main"));
|
|
assert.strictEqual(git(repo, "rev-parse", "@{upstream}"), git(remote, "rev-parse", "refs/heads/main"));
|
|
git(repo, "restore", "unrelated.txt");
|
|
|
|
fs.writeFileSync(path.join(repo, "hidden.txt"), "base\n");
|
|
git(repo, "add", "hidden.txt");
|
|
git(repo, "commit", "-m", "chore: add hidden fixture");
|
|
git(repo, "push", "origin", "main");
|
|
git(repo, "update-index", "--assume-unchanged", "hidden.txt");
|
|
fs.writeFileSync(path.join(repo, "hidden.txt"), "must remain hidden\n");
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "visible all-path change\n");
|
|
run("bash", [helper, "fix: preserve live index flags"], repo);
|
|
assert.deepStrictEqual(git(repo, "show", "--pretty=", "--name-only", "HEAD").split("\n"), ["intended.txt"]);
|
|
assert.strictEqual(git(repo, "show", "HEAD:hidden.txt"), "base");
|
|
git(repo, "update-index", "--no-assume-unchanged", "hidden.txt");
|
|
git(repo, "restore", "hidden.txt", "unrelated.txt");
|
|
fs.writeFileSync(path.join(repo, "unrelated.txt"), "keep local\n");
|
|
|
|
const headBeforeEmptySelector = git(repo, "rev-parse", "HEAD");
|
|
const emptySelector = run("bash", [helper, "fix: reject empty selector", "--"], repo, 1);
|
|
assert.match(emptySelector.stderr, /requires at least one path/i);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), headBeforeEmptySelector);
|
|
assert.match(git(repo, "status", "--short"), /^\s*M unrelated\.txt$/m);
|
|
|
|
const invalidMessage = run("bash", [helper, "not conventional", "--", "intended.txt"], repo, 1);
|
|
assert.match(invalidMessage.stderr, /conventional/i);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), headBeforeEmptySelector);
|
|
|
|
fs.writeFileSync(path.join(repo, "staged.txt"), "pre-staged change\n");
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "second selected change\n");
|
|
git(repo, "add", "staged.txt");
|
|
const headBeforeRejection = git(repo, "rev-parse", "HEAD");
|
|
const rejected = run("bash", [helper, "fix: must reject mixed index", "--", "intended.txt"], repo, 1);
|
|
assert.match(rejected.stderr, /index already contains staged changes/i);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), headBeforeRejection);
|
|
const rejectedStatus = git(repo, "status", "--short");
|
|
assert.match(rejectedStatus, /^\s*M intended\.txt$/m);
|
|
assert.match(rejectedStatus, /^M staged\.txt$/m);
|
|
|
|
const alternateIndex = path.join(tempRoot, "alternate-index");
|
|
run("git", ["read-tree", "HEAD"], repo, 0, { GIT_INDEX_FILE: alternateIndex });
|
|
const alternateIndexRejected = run(
|
|
"bash",
|
|
[helper, "fix: ignore inherited alternate index", "--", "intended.txt"],
|
|
repo,
|
|
1,
|
|
{ GIT_INDEX_FILE: alternateIndex },
|
|
);
|
|
assert.match(alternateIndexRejected.stderr, /index already contains staged changes/i);
|
|
assert.match(git(repo, "status", "--short"), /^M staged\.txt$/m);
|
|
|
|
git(repo, "restore", "--staged", "staged.txt");
|
|
git(repo, "restore", "intended.txt", "staged.txt", "unrelated.txt");
|
|
const empty = run("bash", [helper, "chore: no changes"], repo, 1);
|
|
assert.match(empty.stderr, /no changes staged for commit/i);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), headBeforeRejection);
|
|
|
|
const upstream = path.join(tempRoot, "upstream.git");
|
|
run("git", ["init", "--bare", upstream], tempRoot);
|
|
git(repo, "remote", "add", "upstream", upstream);
|
|
git(repo, "push", "upstream", "main");
|
|
git(repo, "config", "branch.main.remote", "upstream");
|
|
git(repo, "config", "branch.main.merge", "refs/heads/main");
|
|
git(repo, "config", "branch.main.pushRemote", "origin");
|
|
const upstreamBeforeTriangularPush = git(upstream, "rev-parse", "refs/heads/main");
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "configured upstream change\n");
|
|
run("bash", [helper, "refactor: honor configured push remote", "--", "intended.txt"], repo);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), git(remote, "rev-parse", "refs/heads/main"));
|
|
assert.strictEqual(git(upstream, "rev-parse", "refs/heads/main"), upstreamBeforeTriangularPush);
|
|
|
|
const hookPath = path.join(repo, ".git", "hooks", "pre-commit");
|
|
fs.writeFileSync(hookPath, "#!/bin/sh\ngit add unrelated.txt\n");
|
|
fs.chmodSync(hookPath, 0o755);
|
|
const headBeforeHook = git(repo, "rev-parse", "HEAD");
|
|
const upstreamBeforeHook = git(upstream, "rev-parse", "refs/heads/main");
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "hook-protected intended change\n");
|
|
fs.writeFileSync(path.join(repo, "unrelated.txt"), "hook must not add this\n");
|
|
const hookRejected = run("bash", [helper, "fix: reject hook-expanded tree", "--", "intended.txt"], repo, 1);
|
|
assert.match(hookRejected.stderr, /pre-commit hooks changed the isolated index/i);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), headBeforeHook);
|
|
assert.strictEqual(git(remote, "rev-parse", "refs/heads/main"), headBeforeHook);
|
|
assert.strictEqual(git(upstream, "rev-parse", "refs/heads/main"), upstreamBeforeHook);
|
|
assert.match(git(repo, "status", "--short"), /^\s*M intended\.txt$/m);
|
|
assert.match(git(repo, "status", "--short"), /^\s*M unrelated\.txt$/m);
|
|
fs.unlinkSync(hookPath);
|
|
git(repo, "restore", "intended.txt", "unrelated.txt");
|
|
|
|
const postCommitHook = path.join(repo, ".git", "hooks", "post-commit");
|
|
const headBeforePostCommitRace = git(repo, "rev-parse", "HEAD");
|
|
const originBeforePostCommitRace = git(remote, "rev-parse", "refs/heads/main");
|
|
fs.writeFileSync(
|
|
postCommitHook,
|
|
`#!/bin/sh\ngit update-ref refs/heads/main ${headBeforePostCommitRace}\n`,
|
|
);
|
|
fs.chmodSync(postCommitHook, 0o755);
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "post-commit race change\n");
|
|
const postCommitRace = run("bash", [helper, "fix: lock branch during post-commit", "--", "intended.txt"], repo);
|
|
assert.match(postCommitRace.stderr, /cannot lock ref/i);
|
|
assert.match(postCommitRace.stderr, /post-commit hook failed/i);
|
|
assert.notStrictEqual(git(repo, "rev-parse", "HEAD"), headBeforePostCommitRace);
|
|
assert.notStrictEqual(git(remote, "rev-parse", "refs/heads/main"), originBeforePostCommitRace);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), git(remote, "rev-parse", "refs/heads/main"));
|
|
assert.strictEqual(git(repo, "status", "--short"), "");
|
|
fs.unlinkSync(postCommitHook);
|
|
git(repo, "restore", "intended.txt");
|
|
|
|
const headBeforeFailingPostCommit = git(repo, "rev-parse", "HEAD");
|
|
fs.writeFileSync(postCommitHook, "#!/bin/sh\nexit 23\n");
|
|
fs.chmodSync(postCommitHook, 0o755);
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "failing post-commit change\n");
|
|
const failingPostCommit = run(
|
|
"bash",
|
|
[helper, "fix: keep index consistent after hook failure", "--", "intended.txt"],
|
|
repo,
|
|
);
|
|
assert.match(failingPostCommit.stderr, /post-commit hook failed/i);
|
|
assert.notStrictEqual(git(repo, "rev-parse", "HEAD"), headBeforeFailingPostCommit);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), git(remote, "rev-parse", "refs/heads/main"));
|
|
assert.strictEqual(git(repo, "status", "--short"), "");
|
|
fs.unlinkSync(postCommitHook);
|
|
|
|
const headBeforePushRace = git(repo, "rev-parse", "HEAD");
|
|
const prePushHook = path.join(repo, ".git", "hooks", "pre-push");
|
|
fs.writeFileSync(
|
|
prePushHook,
|
|
`#!/bin/sh\ngit update-ref refs/heads/main ${headBeforeFailingPostCommit}\n`,
|
|
);
|
|
fs.chmodSync(prePushHook, 0o755);
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "late ref race change\n");
|
|
const pushRace = run(
|
|
"bash",
|
|
[helper, "fix: lock the branch during push hooks", "--", "intended.txt"],
|
|
repo,
|
|
1,
|
|
);
|
|
assert.match(pushRace.stderr, /cannot lock ref|pre-push hook declined/i);
|
|
const remoteAfterPushRace = git(remote, "rev-parse", "refs/heads/main");
|
|
assert.strictEqual(remoteAfterPushRace, headBeforePushRace);
|
|
assert.strictEqual(git(repo, "show", "-s", "--format=%s", "HEAD"), "fix: lock the branch during push hooks");
|
|
assert.strictEqual(git(repo, "status", "--short"), "");
|
|
fs.unlinkSync(prePushHook);
|
|
git(repo, "push", "origin", "HEAD:main");
|
|
|
|
git(repo, "config", "branch.main.pushRemote", "upstream");
|
|
git(repo, "config", "branch.main.remote", "upstream");
|
|
git(repo, "config", "branch.main.merge", "refs/pull/123/head");
|
|
const headBeforeReadOnlyUpstream = git(repo, "rev-parse", "HEAD");
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "read-only upstream change\n");
|
|
const readOnlyUpstream = run(
|
|
"bash",
|
|
[helper, "fix: reject non-branch upstream", "--", "intended.txt"],
|
|
repo,
|
|
1,
|
|
);
|
|
assert.match(readOnlyUpstream.stderr, /not a pushable branch ref/i);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), headBeforeReadOnlyUpstream);
|
|
assert.strictEqual(git(upstream, "show-ref", "--verify", "--quiet", "refs/heads/main"), "");
|
|
git(repo, "restore", "intended.txt");
|
|
git(repo, "config", "branch.main.merge", "refs/heads/main");
|
|
git(repo, "config", "branch.main.pushRemote", "origin");
|
|
|
|
const linkedWorktree = path.join(tempRoot, "linked-worktree");
|
|
run("git", ["worktree", "add", "-b", "linked-test", linkedWorktree, "main"], repo);
|
|
const linkedParent = git(linkedWorktree, "rev-parse", "HEAD");
|
|
fs.writeFileSync(
|
|
postCommitHook,
|
|
`#!/bin/sh\ngit update-ref refs/heads/linked-test ${linkedParent}\n`,
|
|
);
|
|
fs.chmodSync(postCommitHook, 0o755);
|
|
fs.appendFileSync(path.join(linkedWorktree, "intended.txt"), `linked worktree race from ${linkedParent}\n`);
|
|
const linkedRace = run(
|
|
"bash",
|
|
[helper, "fix: lock common worktree refs", "--", "intended.txt"],
|
|
linkedWorktree,
|
|
);
|
|
assert.match(linkedRace.stderr, /cannot lock ref/i);
|
|
assert.match(linkedRace.stderr, /post-commit hook failed/i);
|
|
assert.notStrictEqual(git(linkedWorktree, "rev-parse", "HEAD"), linkedParent);
|
|
assert.strictEqual(git(linkedWorktree, "status", "--short"), "");
|
|
fs.unlinkSync(postCommitHook);
|
|
run("git", ["worktree", "remove", linkedWorktree], repo);
|
|
|
|
git(repo, "switch", "--detach");
|
|
const detachedHead = git(repo, "rev-parse", "HEAD");
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "detached change\n");
|
|
const detached = run("bash", [helper, "fix: reject detached head", "--", "intended.txt"], repo, 1);
|
|
assert.match(detached.stderr, /detached HEAD/i);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), detachedHead);
|
|
git(repo, "restore", "intended.txt");
|
|
git(repo, "switch", "main");
|
|
|
|
const legacyRepo = path.join(tempRoot, "legacy-ref-format-repo");
|
|
const legacyRemote = path.join(tempRoot, "legacy-ref-format-remote.git");
|
|
const shimDir = path.join(tempRoot, "legacy-git-shim");
|
|
fs.mkdirSync(legacyRepo);
|
|
fs.mkdirSync(shimDir);
|
|
run("git", ["init", "--bare", legacyRemote], tempRoot);
|
|
run("git", ["init", "-b", "main"], legacyRepo);
|
|
git(legacyRepo, "config", "user.name", "Test User");
|
|
git(legacyRepo, "config", "user.email", "test@example.com");
|
|
git(legacyRepo, "remote", "add", "origin", legacyRemote);
|
|
fs.writeFileSync(path.join(legacyRepo, "file.txt"), "base\n");
|
|
git(legacyRepo, "add", "file.txt");
|
|
git(legacyRepo, "commit", "-m", "chore: seed legacy Git repository");
|
|
git(legacyRepo, "push", "-u", "origin", "main");
|
|
fs.writeFileSync(
|
|
path.join(shimDir, "git"),
|
|
`#!/bin/sh
|
|
if [ "$1" = "rev-parse" ] && [ "$2" = "--show-ref-format" ]; then
|
|
printf '%s\\n' --show-ref-format
|
|
exit 0
|
|
fi
|
|
exec "$REAL_GIT" "$@"
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
fs.writeFileSync(path.join(legacyRepo, "file.txt"), "legacy Git change\n");
|
|
run(
|
|
"bash",
|
|
[helper, "fix: support legacy ref format detection", "--", "file.txt"],
|
|
legacyRepo,
|
|
0,
|
|
{ PATH: `${shimDir}${path.delimiter}${process.env.PATH}`, REAL_GIT: run("which", ["git"], tempRoot).stdout.trim() },
|
|
);
|
|
assert.strictEqual(git(legacyRepo, "show", "-s", "--format=%s", "HEAD"), "fix: support legacy ref format detection");
|
|
assert.strictEqual(git(legacyRepo, "rev-parse", "HEAD"), git(legacyRemote, "rev-parse", "refs/heads/main"));
|
|
|
|
const reftableRepo = path.join(tempRoot, "reftable-repo");
|
|
run("git", ["init", "--ref-format=reftable", "-b", "main", reftableRepo], tempRoot);
|
|
git(reftableRepo, "config", "user.name", "Test User");
|
|
git(reftableRepo, "config", "user.email", "test@example.com");
|
|
fs.writeFileSync(path.join(reftableRepo, "file.txt"), "base\n");
|
|
git(reftableRepo, "add", "file.txt");
|
|
git(reftableRepo, "commit", "-m", "chore: seed reftable repository");
|
|
const reftableHead = git(reftableRepo, "rev-parse", "HEAD");
|
|
fs.writeFileSync(path.join(reftableRepo, "file.txt"), "change\n");
|
|
const reftableRejected = run(
|
|
"bash",
|
|
[helper, "fix: reject unsupported ref backend", "--", "file.txt"],
|
|
reftableRepo,
|
|
1,
|
|
);
|
|
assert.match(reftableRejected.stderr, /ref backend 'reftable'/i);
|
|
assert.strictEqual(git(reftableRepo, "rev-parse", "HEAD"), reftableHead);
|
|
assert.strictEqual(git(reftableRepo, "status", "--short"), "M file.txt");
|
|
|
|
git(repo, "remote", "remove", "upstream");
|
|
git(repo, "remote", "remove", "origin");
|
|
const headBeforeMissingRemote = git(repo, "rev-parse", "HEAD");
|
|
fs.writeFileSync(path.join(repo, "intended.txt"), "missing remote change\n");
|
|
const missingRemote = run("bash", [helper, "fix: reject missing remote", "--", "intended.txt"], repo, 1);
|
|
assert.match(missingRemote.stderr, /does not exist/i);
|
|
assert.strictEqual(git(repo, "rev-parse", "HEAD"), headBeforeMissingRemote);
|
|
|
|
console.log("git-pushing helper tests passed");
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|