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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user