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:
2026-09-25 17:30:28 +07:00
parent 3f6e903c22
commit 952e8584f0
48 changed files with 3336 additions and 1579 deletions
+101
View File
@@ -661,6 +661,107 @@ describe("runGitAction — one hunk at a time", () => {
});
});
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";