17d56d0e01
Clean break, no compatibility alias. Renames the product name, the CLI binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` -> `AGENTHUB_`), the Prometheus metric namespace (`ccam_*` -> `agenthub_*`), the tmux session prefix (`ccam-lane-<id>` -> `agenthub-lane-<id>`), the per-repo runtime profile directory (`.ccam/profile` -> `.agenthub/profile`), the lanes state root (`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` -> `/agenthub:*`). Because there is no alias, an existing install has to be updated by hand: rename its `.ccam/profile` directory and any `CCAM_*` variables, and reinstall the plugin under its new name. Two other name families are deliberately left alone here, since they are not just strings: the `Claude-Code-Monitor` repository/marketplace name, and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard` runtime directory, which holds the live database and needs a migration rather than a rename. Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
362 lines
15 KiB
JavaScript
362 lines
15 KiB
JavaScript
/**
|
|
* @file Tests for server/lib/lane-sync.js against a REAL git fixture: a bare
|
|
* "origin", a lane clone, and a second clone acting as another lane that
|
|
* pushes to origin/development independently. Mirrors the fixture shape of
|
|
* Shipyard's own lane-sync-dev.sh test (test_sync_dev.sh) — collision
|
|
* detection, clean merges, and conflicts are git's own behavior, so a mocked
|
|
* git would only test our idea of git.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after, 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-sync-"));
|
|
|
|
const laneSync = require("../lib/lane-sync");
|
|
|
|
const ORIGIN = path.join(ROOT, "origin.git");
|
|
const LANE_DIR = path.join(ROOT, "lane");
|
|
const PUSHER_DIR = path.join(ROOT, "pusher");
|
|
|
|
const g = (cwd, ...args) => {
|
|
const env = { ...process.env };
|
|
delete env.GIT_DIR;
|
|
delete env.GIT_WORK_TREE;
|
|
delete env.GIT_INDEX_FILE;
|
|
delete env.GIT_COMMON_DIR;
|
|
delete env.GIT_OBJECT_DIRECTORY;
|
|
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
|
|
delete env.GIT_PREFIX;
|
|
delete env.GIT_NAMESPACE;
|
|
delete env.GIT_CONFIG_PARAMETERS;
|
|
env.GIT_TERMINAL_PROMPT = "0";
|
|
return execFileSync("git", args, { cwd, encoding: "utf8", env });
|
|
};
|
|
const gc = (cwd, ...args) => g(cwd, "-c", "user.email=t@h", "-c", "user.name=t", ...args);
|
|
|
|
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);
|
|
fs.mkdirSync(path.join(seed, "db", "migrations"), { recursive: true });
|
|
fs.writeFileSync(path.join(seed, "db", "migrations", "001_init.sql"), "create table a;\n");
|
|
fs.writeFileSync(path.join(seed, "README.md"), "hello\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_DIR);
|
|
g(ROOT, "clone", "-q", ORIGIN, PUSHER_DIR);
|
|
|
|
gc(LANE_DIR, "checkout", "-qb", "feat/thing");
|
|
}
|
|
|
|
function lane() {
|
|
return { cwd: LANE_DIR };
|
|
}
|
|
|
|
function profile(over = {}) {
|
|
return {
|
|
env: { MIGRATIONS_DIR: "db/migrations", GENERATED_MERGE_PATHS: "" },
|
|
generatedMergePaths: [],
|
|
hooks: new Set(),
|
|
...over,
|
|
};
|
|
}
|
|
|
|
before(freshFixture);
|
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
|
|
|
describe("lane-sync guards", () => {
|
|
it("refuses to run on development or main", async () => {
|
|
await assert.rejects(
|
|
() => laneSync.checkSync(lane(), profile(), "development"),
|
|
(e) => e.code === "EBADBRANCH" && /feature branch/.test(e.message)
|
|
);
|
|
await assert.rejects(() => laneSync.mergeSync(lane(), profile(), "main"), {
|
|
code: "EBADBRANCH",
|
|
});
|
|
});
|
|
|
|
it("refuses a branch that doesn't exist", async () => {
|
|
await assert.rejects(() => laneSync.checkSync(lane(), profile(), "feat/nope"), {
|
|
code: "EBADBRANCH",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("lane-sync --check: migration collision", () => {
|
|
it("detects a collision and suggests the next free number", () => {
|
|
fs.writeFileSync(path.join(LANE_DIR, "db", "migrations", "002_a.sql"), "create table x;\n");
|
|
gc(LANE_DIR, "add", "-A");
|
|
gc(LANE_DIR, "commit", "-qm", "feat: add x");
|
|
|
|
fs.writeFileSync(path.join(PUSHER_DIR, "db", "migrations", "002_b.sql"), "create table y;\n");
|
|
gc(PUSHER_DIR, "add", "-A");
|
|
gc(PUSHER_DIR, "commit", "-qm", "other lane");
|
|
gc(PUSHER_DIR, "push", "-q", "origin", "development");
|
|
|
|
return laneSync.checkSync(lane(), profile(), "feat/thing").then((result) => {
|
|
assert.equal(result.code, 5);
|
|
assert.equal(result.collisions.length, 1);
|
|
assert.match(result.collisions[0].file, /002_a\.sql$/);
|
|
assert.match(result.collisions[0].suggestion, /^003_a\.sql$/);
|
|
});
|
|
});
|
|
|
|
it("passes clean after the renumber and reports the upstream delta", async () => {
|
|
gc(LANE_DIR, "mv", "db/migrations/002_a.sql", "db/migrations/003_a.sql");
|
|
gc(LANE_DIR, "commit", "-qm", "renumber migration");
|
|
|
|
const result = await laneSync.checkSync(lane(), profile(), "feat/thing");
|
|
assert.equal(result.code, 0);
|
|
assert.equal(result.devDelta.length, 1);
|
|
assert.match(result.devDelta[0], /002_b\.sql$/);
|
|
assert.deepEqual(result.overlap, []);
|
|
});
|
|
});
|
|
|
|
describe("lane-sync merge: clean merge lands upstream on the feature branch", () => {
|
|
it("merges origin/development into the feature branch as a merge commit", async () => {
|
|
const result = await laneSync.mergeSync(lane(), profile(), "feat/thing");
|
|
assert.equal(result.code, 0);
|
|
assert.equal(g(LANE_DIR, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/thing");
|
|
assert.doesNotThrow(() => g(LANE_DIR, "rev-parse", "-q", "--verify", "HEAD^2"));
|
|
assert.ok(fs.existsSync(path.join(LANE_DIR, "db", "migrations", "002_b.sql")));
|
|
});
|
|
});
|
|
|
|
describe("lane-sync merge: generated-file merge driver + regen fold-in", () => {
|
|
const GEN_DIR = path.join(ROOT, "gen-fixture");
|
|
const GEN_ORIGIN = path.join(ROOT, "gen-origin.git");
|
|
const GEN_LANE = path.join(ROOT, "gen-lane");
|
|
const GEN_PUSHER = path.join(ROOT, "gen-pusher");
|
|
|
|
before(() => {
|
|
fs.mkdirSync(GEN_DIR, { recursive: true });
|
|
g(GEN_DIR, "init", "-q", "--bare", GEN_ORIGIN);
|
|
const seed = path.join(GEN_DIR, "seed");
|
|
g(GEN_DIR, "init", "-q", "-b", "development", seed);
|
|
fs.mkdirSync(path.join(seed, "api"), { recursive: true });
|
|
fs.writeFileSync(path.join(seed, "api", "openapi.json"), '{"v":1}\n');
|
|
gc(seed, "add", "-A");
|
|
gc(seed, "commit", "-qm", "init");
|
|
gc(seed, "remote", "add", "origin", GEN_ORIGIN);
|
|
gc(seed, "push", "-q", "origin", "development");
|
|
g(GEN_ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
|
|
|
|
g(GEN_DIR, "clone", "-q", GEN_ORIGIN, GEN_LANE);
|
|
g(GEN_DIR, "clone", "-q", GEN_ORIGIN, GEN_PUSHER);
|
|
gc(GEN_LANE, "checkout", "-qb", "feat/gen");
|
|
|
|
// The lane's own change to the generated file (would conflict without
|
|
// the keep-ours driver).
|
|
fs.writeFileSync(path.join(GEN_LANE, "api", "openapi.json"), '{"v":2,"branch":"feat"}\n');
|
|
gc(GEN_LANE, "add", "-A");
|
|
gc(GEN_LANE, "commit", "-qm", "feat: touches the contract");
|
|
|
|
// Upstream's own change to the same generated file.
|
|
fs.writeFileSync(path.join(GEN_PUSHER, "api", "openapi.json"), '{"v":2,"branch":"dev"}\n');
|
|
gc(GEN_PUSHER, "add", "-A");
|
|
gc(GEN_PUSHER, "commit", "-qm", "dev: also touches the contract");
|
|
gc(GEN_PUSHER, "push", "-q", "origin", "development");
|
|
|
|
// A regen hook the fold-in step will run.
|
|
const profileDir = path.join(GEN_LANE, ".agenthub", "profile");
|
|
fs.mkdirSync(path.join(profileDir, "hooks"), { recursive: true });
|
|
fs.writeFileSync(path.join(profileDir, "profile.env"), "PORTS=api\n");
|
|
fs.writeFileSync(
|
|
path.join(profileDir, "hooks", "regen.sh"),
|
|
'#!/usr/bin/env bash\nset -euo pipefail\necho \'{"v":3,"regenerated":true}\' > "$LANE_DIR/api/openapi.json"\n'
|
|
);
|
|
fs.chmodSync(path.join(profileDir, "hooks", "regen.sh"), 0o755);
|
|
});
|
|
|
|
function genProfile() {
|
|
return {
|
|
env: { MIGRATIONS_DIR: "", GENERATED_MERGE_PATHS: "api/openapi.json" },
|
|
generatedMergePaths: ["api/openapi.json"],
|
|
hooks: new Set(["regen"]),
|
|
dir: path.join(GEN_LANE, ".agenthub", "profile"),
|
|
};
|
|
}
|
|
|
|
it("installs a keep-ours driver so the generated file never conflicts, then folds regen output into the merge commit", async () => {
|
|
const result = await laneSync.mergeSync(
|
|
{ cwd: GEN_LANE, slot: 999, id: 999 },
|
|
genProfile(),
|
|
"feat/gen"
|
|
);
|
|
assert.equal(result.code, 0);
|
|
const contents = fs.readFileSync(path.join(GEN_LANE, "api", "openapi.json"), "utf8");
|
|
assert.match(contents, /"regenerated":true/);
|
|
// The regen output landed IN the merge commit, not a separate one.
|
|
assert.doesNotThrow(() => g(GEN_LANE, "rev-parse", "-q", "--verify", "HEAD^2"));
|
|
const parents = g(GEN_LANE, "log", "-1", "--format=%P").trim().split(" ");
|
|
assert.equal(parents.length, 2);
|
|
});
|
|
});
|
|
|
|
describe("lane-sync merge: conflict is left in place, --continue finishes it", () => {
|
|
it("exits with code 4 and leaves MERGE_HEAD in place on a real conflict", async () => {
|
|
fs.writeFileSync(path.join(LANE_DIR, "README.md"), "feature words\n");
|
|
gc(LANE_DIR, "add", "-A");
|
|
gc(LANE_DIR, "commit", "-qm", "feat: readme");
|
|
|
|
fs.writeFileSync(path.join(PUSHER_DIR, "README.md"), "upstream words\n");
|
|
gc(PUSHER_DIR, "add", "-A");
|
|
gc(PUSHER_DIR, "commit", "-qm", "other readme");
|
|
gc(PUSHER_DIR, "push", "-q", "origin", "development");
|
|
|
|
const result = await laneSync.mergeSync(lane(), profile(), "feat/thing");
|
|
assert.equal(result.code, 4);
|
|
assert.deepEqual(result.conflictedFiles, ["README.md"]);
|
|
assert.ok(fs.existsSync(path.join(LANE_DIR, ".git", "MERGE_HEAD")));
|
|
});
|
|
|
|
it("--continue refuses while conflicts are unresolved", async () => {
|
|
await assert.rejects(() => laneSync.continueSync(lane(), profile(), "feat/thing"), {
|
|
code: "EUNRESOLVED",
|
|
});
|
|
});
|
|
|
|
it("--continue refuses while the merge is resolved but not committed", async () => {
|
|
fs.writeFileSync(path.join(LANE_DIR, "README.md"), "merged words\n");
|
|
gc(LANE_DIR, "add", "README.md");
|
|
await assert.rejects(() => laneSync.continueSync(lane(), profile(), "feat/thing"), {
|
|
code: "EMERGEUNCOMMITTED",
|
|
});
|
|
});
|
|
|
|
it("--continue finishes after the conflict is resolved and committed", async () => {
|
|
gc(LANE_DIR, "commit", "-q", "--no-edit");
|
|
const result = await laneSync.continueSync(lane(), profile(), "feat/thing");
|
|
assert.equal(result.code, 0);
|
|
assert.equal(g(LANE_DIR, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/thing");
|
|
});
|
|
});
|
|
|
|
describe("lane-sync merge: rerere auto-resolves a previously-seen conflict", () => {
|
|
// Bug this guards: mergeSync's catch block used to check
|
|
// `conflicted.length && MERGE_HEAD exists` before falling through to the
|
|
// rerere-commit path — but a fully rerere-auto-resolved merge has ZERO
|
|
// unmerged files (git already staged the resolution), so that condition
|
|
// was always false and the code rethrew the raw git error instead of
|
|
// committing. Fixed by checking MERGE_HEAD first, unconditionally.
|
|
const RR_ROOT = path.join(ROOT, "rerere-fixture");
|
|
const RR_ORIGIN = path.join(RR_ROOT, "origin.git");
|
|
const RR_LANE = path.join(RR_ROOT, "lane");
|
|
const RR_PUSHER = path.join(RR_ROOT, "pusher");
|
|
|
|
before(() => {
|
|
fs.mkdirSync(RR_ROOT, { recursive: true });
|
|
g(RR_ROOT, "init", "-q", "--bare", RR_ORIGIN);
|
|
|
|
const seed = path.join(RR_ROOT, "seed");
|
|
g(RR_ROOT, "init", "-q", "-b", "development", seed);
|
|
fs.writeFileSync(path.join(seed, "config.txt"), "base\n");
|
|
gc(seed, "add", "-A");
|
|
gc(seed, "commit", "-qm", "init");
|
|
gc(seed, "remote", "add", "origin", RR_ORIGIN);
|
|
gc(seed, "push", "-q", "origin", "development");
|
|
g(RR_ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
|
|
|
|
g(RR_ROOT, "clone", "-q", RR_ORIGIN, RR_LANE);
|
|
g(RR_ROOT, "clone", "-q", RR_ORIGIN, RR_PUSHER);
|
|
gc(RR_LANE, "config", "rerere.enabled", "true");
|
|
// autoUpdate is what stages a rerere-recognized resolution automatically —
|
|
// without it, git restores the resolved CONTENT but still leaves the file
|
|
// as "unmerged" (ls-files -u non-empty), so MERGE_HEAD + zero unmerged
|
|
// files (the exact condition the fixed code branches on) never occurs.
|
|
gc(RR_LANE, "config", "rerere.autoupdate", "true");
|
|
|
|
const baseSha = g(RR_LANE, "rev-parse", "development").trim();
|
|
|
|
// Upstream's side of the conflict — pushed once, applies to both rounds.
|
|
fs.writeFileSync(path.join(RR_PUSHER, "config.txt"), "dev version\n");
|
|
gc(RR_PUSHER, "add", "-A");
|
|
gc(RR_PUSHER, "commit", "-qm", "dev edits config");
|
|
gc(RR_PUSHER, "push", "-q", "origin", "development");
|
|
gc(RR_LANE, "fetch", "-q", "origin");
|
|
|
|
// Round 1 — teach rerere the resolution.
|
|
gc(RR_LANE, "checkout", "-qb", "feat/rerere-teach", baseSha);
|
|
fs.writeFileSync(path.join(RR_LANE, "config.txt"), "lane version\n");
|
|
gc(RR_LANE, "add", "-A");
|
|
gc(RR_LANE, "commit", "-qm", "lane edits config");
|
|
let conflicted = false;
|
|
try {
|
|
gc(RR_LANE, "merge", "--no-edit", "origin/development");
|
|
} catch {
|
|
conflicted = true;
|
|
}
|
|
if (!conflicted) throw new Error("fixture bug: expected the teach-round merge to conflict");
|
|
fs.writeFileSync(path.join(RR_LANE, "config.txt"), "resolved version\n");
|
|
gc(RR_LANE, "add", "config.txt");
|
|
gc(RR_LANE, "commit", "-q", "--no-edit");
|
|
|
|
// Round 2 — the actual test branch: an IDENTICAL edit to config.txt from
|
|
// the same base, so the conflict signature matches what rerere just
|
|
// learned and git auto-applies the recorded resolution during the merge
|
|
// this test's assertion drives.
|
|
gc(RR_LANE, "checkout", "-qb", "feat/rerere-actual", baseSha);
|
|
fs.writeFileSync(path.join(RR_LANE, "config.txt"), "lane version\n");
|
|
gc(RR_LANE, "add", "-A");
|
|
gc(RR_LANE, "commit", "-qm", "lane edits config (again)");
|
|
});
|
|
|
|
it("commits automatically instead of reporting a conflict", async () => {
|
|
const result = await laneSync.mergeSync({ cwd: RR_LANE }, profile(), "feat/rerere-actual");
|
|
assert.equal(result.code, 0);
|
|
assert.equal(fs.readFileSync(path.join(RR_LANE, "config.txt"), "utf8"), "resolved version\n");
|
|
assert.ok(!fs.existsSync(path.join(RR_LANE, ".git", "MERGE_HEAD")));
|
|
});
|
|
});
|
|
|
|
describe("lane-sync against a real git-worktree lane", () => {
|
|
it("resolves MERGE_HEAD and info/attributes correctly under git worktree add", async () => {
|
|
const wt = require("../lib/worktree");
|
|
const WT_ROOT = path.join(ROOT, "wt-fixture");
|
|
fs.mkdirSync(WT_ROOT, { recursive: true });
|
|
const src = path.join(WT_ROOT, "src");
|
|
g(WT_ROOT, "init", "-q", "-b", "development", src);
|
|
fs.writeFileSync(path.join(src, "README.md"), "hello\n");
|
|
gc(src, "add", "-A");
|
|
gc(src, "commit", "-qm", "init");
|
|
gc(src, "remote", "add", "origin", src); // self-origin: fetch is a same-repo no-op, good enough here
|
|
gc(src, "branch", "-f", "refs/remotes/origin/development", "development");
|
|
|
|
const wtDir = path.join(WT_ROOT, "wt-lane");
|
|
await wt.addWorktree({ sourceRepo: src, dir: wtDir, branch: "feat/wt", base: "development" });
|
|
|
|
// Simulate upstream moving, so devDeltaReport / collisionCheck have
|
|
// something to resolve against without a real remote. `src` is still on
|
|
// "development" here — addWorktree only checks out feat/wt in the NEW
|
|
// worktree dir; checking out feat/wt on src too would collide with the
|
|
// worktree (git refuses the same branch checked out twice).
|
|
fs.writeFileSync(path.join(src, "README.md"), "upstream change\n");
|
|
gc(src, "add", "-A");
|
|
gc(src, "commit", "-qm", "upstream");
|
|
gc(src, "branch", "-f", "refs/remotes/origin/development", "development");
|
|
|
|
const wtProfile = {
|
|
env: { MIGRATIONS_DIR: "", GENERATED_MERGE_PATHS: "" },
|
|
generatedMergePaths: [],
|
|
hooks: new Set(),
|
|
};
|
|
const result = await laneSync.checkSync({ cwd: wtDir }, wtProfile, "feat/wt");
|
|
assert.equal(result.code, 0);
|
|
assert.equal(result.devDelta.length, 1);
|
|
});
|
|
});
|