feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js) cuts git's fresh hunk down to it, folding the other blocks into the side being checked; LaneDiffView hangs one review bar per block (unified + split) and its arrows walk blocks. - git exclude: entries AgentHub writes for a lane go to the COMMON git dir with the lane's path prefix (server/lib/git-exclude.js, used by router-apply and lane-mcp) — a linked worktree's own info/exclude is never read by git, and a subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree lane or creates a stray .git in a non-repo lane. - codex lanes carry no pipeline: codex-ingest mirrors only idle/running, codex-nudge is removed, agents install writes nothing for codex, the Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and every server start clears leftover codex stage data and the old AGENTS.md stage block. - Changes panel: a whole file opens for reading and enters the editor on a click on its text (caret at the clicked line), not automatically. - macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by scripts/service-manager.js). - Adds the AgentHub introduction slides and their image-embedding script.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
@@ -144,50 +145,84 @@ describe("router-apply — Claude lane", () => {
|
||||
});
|
||||
|
||||
describe("router-apply — the written file must survive a lane reset", () => {
|
||||
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
|
||||
// Real repos, asserted through `git status`: a fake `.git` dir let a test pass
|
||||
// while git itself never read the exclude file it checked.
|
||||
const REPO = path.join(SUITE_ROOT, "repo");
|
||||
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
|
||||
// would point these fixture calls at the outer repo.
|
||||
const git = (cwd, ...args) => {
|
||||
const env = { ...process.env };
|
||||
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
|
||||
delete env[name];
|
||||
}
|
||||
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
|
||||
};
|
||||
const untracked = (cwd) =>
|
||||
git(cwd, "status", "--porcelain", "--untracked-files=all", "--ignored=no")
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
// A machine-wide ignore (Claude Code adds one for settings.local.json) would
|
||||
// hide the very file these tests look for.
|
||||
beforeEach(() => {
|
||||
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
git(REPO, "init", "-q");
|
||||
git(
|
||||
REPO,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init"
|
||||
);
|
||||
});
|
||||
|
||||
const laneAt = (cwd) => ({ id: 7, cwd, tool: "claude" });
|
||||
|
||||
it("hides settings.local.json and its backups, so `git clean -fd` cannot delete them", () => {
|
||||
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
|
||||
// Without -x that spares IGNORED files only — an untracked settings file
|
||||
// would be deleted, silently detaching the lane from its router. The same
|
||||
// entry also stops an API key from being committable.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
applyRouterToLane(laneAt(REPO), name);
|
||||
applyRouterToLane(laneAt(REPO), "native"); // a second write leaves a backup
|
||||
assert.deepEqual(untracked(REPO), []);
|
||||
});
|
||||
|
||||
it("excludes it for a native lane too, not only a lane on a router", () => {
|
||||
// The blanked connection pair is load-bearing in its own right: losing it
|
||||
// to `git clean -fd` lets a USER-level router take over a lane that
|
||||
// explicitly asked for native.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
it("hides them in a worktree lane, whose exclude file is the COMMON dir's", () => {
|
||||
// git reads info/exclude from the common git dir only; a copy under
|
||||
// .git/worktrees/<name>/info/ is never consulted.
|
||||
const wt = path.join(SUITE_ROOT, "wt");
|
||||
fs.rmSync(wt, { recursive: true, force: true });
|
||||
git(REPO, "worktree", "add", "-q", "-b", "lane1", wt);
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(laneAt(wt), name);
|
||||
applyRouterToLane(laneAt(wt), "native");
|
||||
assert.deepEqual(untracked(wt), []);
|
||||
});
|
||||
|
||||
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
|
||||
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
|
||||
// would be a path that cannot exist.
|
||||
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
|
||||
it("hides them in a lane that is a subdirectory of its repo", () => {
|
||||
const sub = path.join(REPO, "packages", "app");
|
||||
fs.mkdirSync(sub, { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
applyRouterToLane(laneAt(sub), name);
|
||||
applyRouterToLane(laneAt(sub), "native");
|
||||
assert.deepEqual(untracked(REPO), []);
|
||||
});
|
||||
|
||||
it("adds the entry once, however many times a router is applied", () => {
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
applyRouterToLane(laneAt(REPO), name);
|
||||
applyRouterToLane(laneAt(REPO), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8");
|
||||
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user