c2ced00ce4c7
show a change to a file's final newline as a changed last line with the no-newline marker, and record the phase 3 review fixes in continuity (v3.0.18)
- time
- 2026-09-26 18:38 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- 56d982f71079
↓ kepr-c2ced00c.tar.gzatlas at this save
files changed3 files
package.json+1 −1src/pages/save.ts+16 −2tests/pages.test.ts+28 −2package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.17",3+ "version": "3.0.18",44 "private": true,55 "type": "module",66 "scripts": {src/pages/save.ts+16 −2
@@ -135,7 +135,7 @@135135 }136136 const oldText = new TextDecoder().decode(oldBytes);137137 const newText = new TextDecoder().decode(newBytes);138- const edits = diffLines(lines(oldText), lines(newText));138+ const edits = diffLines(compared(oldText), compared(newText));139139 const added = edits.filter((e) => e.kind === "+").length;140140 const removed = edits.filter((e) => e.kind === "-").length;141141 return { ...change, kind: "text", oldText, newText, edits, added, removed };@@ -145,6 +145,14 @@145145 return text === "" ? [] : text.replace(/\n$/, "").split("\n");146146 }147147 148+// A last line without its newline differs from the same line with one, as in GNU diff: a newline149+// can never occur inside a split line, so it marks the ending for the comparison alone.150+function compared(text: string): string[] {151+ const split = lines(text);152+ if (split.length > 0 && !text.endsWith("\n")) split[split.length - 1] += "\n";153+ return split;154+}155+148156 function stat(d: FileDiff): Html {149157 if (d.kind !== "text") return html`<span class="diff-note">${d.kind}</span>`;150158 return html`<span class="diff-add">+${d.added}</span> <span class="diff-del">−${d.removed}</span>`;@@ -194,10 +202,16 @@194202 highlighted(d.oldText, d.path),195203 highlighted(d.newText, d.path),196204 ]);205+ // The last line of a side that doesn't end in a newline, or -1 when it does.206+ const unterminated = (text: string, count: number) =>207+ text === "" || text.endsWith("\n") ? -1 : count - 1;208+ const oldEnd = unterminated(d.oldText, oldLines.length);209+ const newEnd = unterminated(d.newText, newLines.length);197210 const row = (e: Edit) => {198211 const code = e.kind === "-" ? oldLines[e.a] : newLines[e.b];199212 const kind = e.kind === "+" ? "add" : e.kind === "-" ? "del" : "ctx";200- return html`<span class="dl dl-${kind}"><span class="ln">${e.kind !== "+" && e.a + 1}</span><span class="ln">${e.kind !== "-" && e.b + 1}</span><span class="ds">${e.kind}</span><span class="lc">${code}</span></span>`;213+ const bare = (e.kind !== "+" && e.a === oldEnd) || (e.kind !== "-" && e.b === newEnd);214+ return html`<span class="dl dl-${kind}"><span class="ln">${e.kind !== "+" && e.a + 1}</span><span class="ln">${e.kind !== "-" && e.b + 1}</span><span class="ds">${e.kind}</span><span class="lc">${code}</span></span>${bare && html`<span class="dh">\\ No newline at end of file</span>`}`;201215 };202216 return html`<div class="file-view"><pre class="code diff"><code>${hunkList.map(203217 (h) => html`<span class="dh">${h.header}</span>${h.edits.map(row)}`,tests/pages.test.ts+28 −2
@@ -14,6 +14,7 @@1414 let fx: Fixture;1515 let app: Hono;1616 let mirrorDir: string;17+let tailSaves: string[];1718 const get = (path: string) => app.request(path);1819 const body = async (path: string) => (await get(path)).text();1920 @@ -31,17 +32,31 @@3132 cwd: large.repoDir,3233 env: { PATH: process.env.PATH, HOME: join(large.repoDir, "../../home"), NO_COLOR: "1" },3334 });35+ // A file that gains its final newline, then loses it again.36+ const tail = await makeKohBucket("tail");37+ const tailEnv = { PATH: process.env.PATH, HOME: join(tail.repoDir, "../../home"), NO_COLOR: "1" };38+ for (const [text, message] of [39+ ["a\nb", "add tail"],40+ ["a\nb\n", "end tail with a newline"],41+ ["a\nb", "drop the newline"],42+ ] as const) {43+ await writeFile(join(tail.repoDir, "tail.txt"), text);44+ Bun.spawnSync(["koh3", "save", message], { cwd: tail.repoDir, env: tailEnv });45+ }46+ tailSaves = JSON.parse(47+ Bun.spawnSync(["koh3", "log", "--json"], { cwd: tail.repoDir, env: tailEnv }).stdout.toString(),48+ ).saves.map((save: { id: string }) => save.id);3449 const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));3550 mirrorDir = join(dataDir, "mirror");3651 const kepr = openKeprDb(dataDir);37- for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir]) {52+ for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {3853 const bucket = openBucket({ kind: "folder", path: bucketDir });3954 await syncAll({ bucket, kepr, mirrorDir, tarballDir: join(dataDir, "tarballs") });4055 }4156 kepr.run(4257 "UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",4358 );44- kepr.run("UPDATE repos SET public = 1 WHERE name = 'large'");59+ kepr.run("UPDATE repos SET public = 1 WHERE name IN ('large', 'tail')");4560 app = createApp(4661 {4762 domain: "kepr.example",@@ -311,6 +326,17 @@311326 const second_ = await body(`/demo/saves/${second()}`);312327 expect(second_).toContain("<dt>agent</dt><dd>test-agent</dd><dt>session</dt><dd>fixture</dd>");313328 expect(second_).not.toContain("save-flag");329+ });330+331+ test("a change to the final newline alone is a changed last line, marked as in GNU diff", async () => {332+ const marker = '<span class="dh">\\ No newline at end of file</span>';333+ const row = (kind: string, a: string, b: string, sign: string) =>334+ `<span class="dl dl-${kind}"><span class="ln">${a}</span><span class="ln">${b}</span><span class="ds">${sign}</span><span class="lc">b</span></span>`;335+ const gained = await body(`/tail/saves/${tailSaves[1]}`);336+ expect(gained).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');337+ expect(gained).toContain(`${row("del", "2", "", "-")}${marker}${row("add", "", "2", "+")}<`);338+ const lost = await body(`/tail/saves/${tailSaves[0]}`);339+ expect(lost).toContain(`${row("del", "2", "", "-")}${row("add", "", "2", "+")}${marker}`);314340 });315341 316342 test("a prefix redirects, and a checkpoint, an unknown or a private save is a 404", async () => {