|
1 | 1 | import assert from "node:assert/strict"; |
2 | 2 | import { execFileSync } from "node:child_process"; |
3 | | -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; |
| 3 | +import { mkdtempSync, writeFileSync, rmSync, renameSync } from "node:fs"; |
4 | 4 | import { tmpdir } from "node:os"; |
5 | 5 | import path from "node:path"; |
6 | 6 | import { test, before, after } from "node:test"; |
@@ -138,3 +138,145 @@ test("repo mode: staged diff ignores untracked files", async () => { |
138 | 138 | assert.equal(src, null); // nothing staged, and untracked must not leak into staged mode |
139 | 139 | rmSync(path.join(root, "new.ts")); |
140 | 140 | }); |
| 141 | + |
| 142 | +// ── git -M rename handling (issue 01) ──────────────────────────────────────── |
| 143 | +// Self-contained repos (own git init) so ordering can't collide with the shared-root tests above, |
| 144 | +// and so we can force `diff.renames=false` — proving detection rides our explicit -M, not config. |
| 145 | + |
| 146 | +function freshRepo(renamesOff: boolean): { dir: string; main: string } { |
| 147 | + const dir = mkdtempSync(path.join(tmpdir(), "galley-rn-")); |
| 148 | + const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString(); |
| 149 | + g(["init", "-q"]); |
| 150 | + g(["config", "user.email", "t@t.co"]); |
| 151 | + g(["config", "user.name", "tester"]); |
| 152 | + if (renamesOff) g(["config", "diff.renames", "false"]); |
| 153 | + writeFileSync(path.join(dir, "old.txt"), "a\nb\nc\n"); |
| 154 | + g(["add", "."]); |
| 155 | + g(["commit", "-qm", "init"]); |
| 156 | + const main = g(["rev-parse", "--abbrev-ref", "HEAD"]).trim(); |
| 157 | + return { dir, main }; |
| 158 | +} |
| 159 | + |
| 160 | +test("pr mode: rename+edit with diff.renames=false → one file at the new path, edited lines only", async () => { |
| 161 | + const { dir, main } = freshRepo(true); |
| 162 | + const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString(); |
| 163 | + g(["checkout", "-q", "-b", "feature"]); |
| 164 | + g(["mv", "old.txt", "new.txt"]); |
| 165 | + writeFileSync(path.join(dir, "new.txt"), "a\nB\nc\n"); // one edited line |
| 166 | + g(["commit", "-qam", "rename + edit"]); |
| 167 | + const src = await buildDiffSource({ mode: "pr", root: dir, base: main }); |
| 168 | + assert.ok(src); |
| 169 | + assert.equal(src!.files.length, 1); |
| 170 | + const f = src!.files[0]!; |
| 171 | + assert.equal(f.path, "new.txt"); |
| 172 | + assert.equal(f.oldFile.name, "old.txt"); // distinct names → @pierre infers rename-changed |
| 173 | + assert.equal(f.newFile.name, "new.txt"); |
| 174 | + assert.equal(f.oldFile.contents, "a\nb\nc\n"); // full old content (moved, not re-added) |
| 175 | + assert.equal(f.newFile.contents, "a\nB\nc\n"); |
| 176 | + assert.equal(src!.changes.length, 1); // only the edited line is up for review, not the whole file |
| 177 | + rmSync(dir, { recursive: true, force: true }); |
| 178 | +}); |
| 179 | + |
| 180 | +test("pr mode: a pure committed rename → a zero-hunk entry at the new path, distinct names", async () => { |
| 181 | + const { dir, main } = freshRepo(true); |
| 182 | + const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString(); |
| 183 | + g(["checkout", "-q", "-b", "feature"]); |
| 184 | + g(["mv", "old.txt", "new.txt"]); |
| 185 | + g(["commit", "-qam", "pure rename"]); |
| 186 | + const src = await buildDiffSource({ mode: "pr", root: dir, base: main }); |
| 187 | + assert.ok(src, "a pure rename must not vanish from the review"); |
| 188 | + assert.equal(src!.files.length, 1); |
| 189 | + const f = src!.files[0]!; |
| 190 | + assert.equal(f.path, "new.txt"); |
| 191 | + assert.equal(f.oldFile.name, "old.txt"); |
| 192 | + assert.equal(f.newFile.name, "new.txt"); |
| 193 | + assert.equal(f.hunks.length, 0); // no content change |
| 194 | + assert.equal(f.oldFile.contents, f.newFile.contents); // identical → the muted moved row in the UI |
| 195 | + assert.equal(src!.changes.length, 0); |
| 196 | + rmSync(dir, { recursive: true, force: true }); |
| 197 | +}); |
| 198 | + |
| 199 | +test("staged mode: a staged git mv + edit renders as a merged rename", async () => { |
| 200 | + const { dir } = freshRepo(false); |
| 201 | + const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString(); |
| 202 | + g(["mv", "old.txt", "new.txt"]); |
| 203 | + writeFileSync(path.join(dir, "new.txt"), "a\nB\nc\n"); |
| 204 | + g(["add", "."]); |
| 205 | + const src = await buildDiffSource({ mode: "repo", root: dir, staged: true }); |
| 206 | + assert.ok(src); |
| 207 | + assert.equal(src!.files.length, 1); |
| 208 | + const f = src!.files[0]!; |
| 209 | + assert.equal(f.path, "new.txt"); |
| 210 | + assert.equal(f.oldFile.name, "old.txt"); |
| 211 | + assert.equal(f.newFile.name, "new.txt"); |
| 212 | + assert.equal(src!.changes.length, 1); |
| 213 | + rmSync(dir, { recursive: true, force: true }); |
| 214 | +}); |
| 215 | + |
| 216 | +// ── working-mode move pairing (issue 02) ───────────────────────────────────── |
| 217 | +// A plain `mv` (no git mv) shows as a full deletion + a full untracked addition; git can't see |
| 218 | +// the move. buildDiffSource pairs byte-identical halves into one rename-pure entry. |
| 219 | + |
| 220 | +test("repo mode: a plain mv (no edit) pairs into one rename-pure entry", async () => { |
| 221 | + const { dir } = freshRepo(false); |
| 222 | + renameSync(path.join(dir, "old.txt"), path.join(dir, "new.txt")); // plain mv, not git mv |
| 223 | + const src = await buildDiffSource({ mode: "repo", root: dir }); |
| 224 | + assert.ok(src); |
| 225 | + assert.equal(src!.files.length, 1); // merged, not delete + add |
| 226 | + const f = src!.files[0]!; |
| 227 | + assert.equal(f.path, "new.txt"); |
| 228 | + assert.equal(f.oldPath, "old.txt"); |
| 229 | + assert.equal(f.newPath, "new.txt"); |
| 230 | + assert.equal(f.oldFile.name, "old.txt"); |
| 231 | + assert.equal(f.newFile.name, "new.txt"); |
| 232 | + assert.equal(f.oldFile.contents, f.newFile.contents); // identical → the muted moved row |
| 233 | + assert.equal(src!.changes.length, 0); |
| 234 | + rmSync(dir, { recursive: true, force: true }); |
| 235 | +}); |
| 236 | + |
| 237 | +test("repo mode: two identical untracked copies of a deleted file → nothing paired (ambiguity)", async () => { |
| 238 | + const { dir } = freshRepo(false); |
| 239 | + rmSync(path.join(dir, "old.txt")); // delete the tracked file… |
| 240 | + writeFileSync(path.join(dir, "copy1.txt"), "a\nb\nc\n"); // …and two byte-identical untracked copies |
| 241 | + writeFileSync(path.join(dir, "copy2.txt"), "a\nb\nc\n"); |
| 242 | + const src = await buildDiffSource({ mode: "repo", root: dir }); |
| 243 | + assert.ok(src); |
| 244 | + assert.deepEqual( |
| 245 | + src!.files.map((f) => f.path).sort(), |
| 246 | + ["copy1.txt", "copy2.txt", "old.txt"], // deletion stays + two additions, none merged |
| 247 | + ); |
| 248 | + assert.equal( |
| 249 | + src!.files.find((f) => f.path === "old.txt")!.newPath, |
| 250 | + undefined, // still a plain deletion, not a rename |
| 251 | + ); |
| 252 | + rmSync(dir, { recursive: true, force: true }); |
| 253 | +}); |
| 254 | + |
| 255 | +test("repo mode: mv + edit is NOT paired — renders as delete + add (exact-content only)", async () => { |
| 256 | + const { dir } = freshRepo(false); |
| 257 | + rmSync(path.join(dir, "old.txt")); |
| 258 | + writeFileSync(path.join(dir, "new.txt"), "a\nB\nc\n"); // moved AND edited → not byte-identical |
| 259 | + const src = await buildDiffSource({ mode: "repo", root: dir }); |
| 260 | + assert.ok(src); |
| 261 | + assert.deepEqual(src!.files.map((f) => f.path).sort(), ["new.txt", "old.txt"]); |
| 262 | + assert.equal(src!.files.find((f) => f.path === "old.txt")!.newPath, undefined); // deletion |
| 263 | + assert.equal(src!.files.find((f) => f.path === "new.txt")!.oldFile.contents, ""); // untracked add |
| 264 | + rmSync(dir, { recursive: true, force: true }); |
| 265 | +}); |
| 266 | + |
| 267 | +test("mode-only and same-path binary changes still produce no review entry", async () => { |
| 268 | + const { dir } = freshRepo(false); |
| 269 | + const g = (args: string[]) => execFileSync("git", args, { cwd: dir }).toString(); |
| 270 | + const shPath = path.join(dir, "s.sh"); |
| 271 | + const binPath = path.join(dir, "img.bin"); |
| 272 | + writeFileSync(shPath, "#!/bin/sh\necho hi\n"); |
| 273 | + writeFileSync(binPath, Buffer.from([0, 1, 2, 0, 255])); |
| 274 | + g(["add", "."]); |
| 275 | + g(["commit", "-qm", "add binary + script"]); |
| 276 | + // A mode-only change on the script, and a byte change to the binary — both zero-hunk, same-path. |
| 277 | + execFileSync("chmod", ["755", shPath], { cwd: dir }); |
| 278 | + writeFileSync(binPath, Buffer.from([0, 9, 9, 9, 255])); |
| 279 | + const src = await buildDiffSource({ mode: "repo", root: dir }); |
| 280 | + assert.equal(src, null); // no text hunks and same-path → both drop; nothing else → null |
| 281 | + rmSync(dir, { recursive: true, force: true }); |
| 282 | +}); |
0 commit comments