git-patch 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,3 +28,12 @@ jobs:
28
28
  with:
29
29
  node-version: ${{ matrix.node-version }}
30
30
  - run: npm test
31
+
32
+ coverage:
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: actions/setup-node@v4
37
+ with:
38
+ node-version: 22
39
+ - run: npm run test:coverage
package/README.md CHANGED
@@ -29,6 +29,8 @@ git-patch list --staged # Show staged hunks
29
29
  git-patch list -- src/main.rs # Filter to specific files
30
30
  ```
31
31
 
32
+ Untracked files are included in unstaged output, so brand new files get hunk IDs without needing `git add -N`.
33
+
32
34
  Each hunk gets a sequential ID. Change lines within each hunk are numbered too — these are what you use for line-level selection.
33
35
 
34
36
  ### Stage hunks
@@ -43,6 +45,8 @@ git-patch stage --all # Stage everything
43
45
  git-patch stage --matching "TODO" # Stage hunks matching a regex
44
46
  ```
45
47
 
48
+ This also works for untracked files directly; no intent-to-add prep step required.
49
+
46
50
  ### Unstage hunks
47
51
 
48
52
  Same selectors, operates on staged diff:
@@ -41,7 +41,7 @@ function summarizeHunks(fileDiffs) {
41
41
  }
42
42
 
43
43
  export function run({ json = false, staged = false, files = [], summary = false } = {}) {
44
- let raw = getDiff({ staged, files });
44
+ let raw = getDiff({ staged, files, includeUntracked: !staged });
45
45
  let fileDiffs = parseDiff(raw);
46
46
 
47
47
  if (json) {
@@ -4,7 +4,7 @@ import { buildPatchFromHunks, buildPatchFromLines } from "../patch-builder.js";
4
4
  import { parseSelector } from "../selector.js";
5
5
 
6
6
  export function run({ selector, all = false, matching = null, files = [] } = {}) {
7
- let raw = getDiff({ staged: false, files });
7
+ let raw = getDiff({ staged: false, files, includeUntracked: true });
8
8
  let fileDiffs = parseDiff(raw);
9
9
 
10
10
  if (fileDiffs.length === 0) {
package/lib/git.js CHANGED
@@ -1,41 +1,84 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
 
3
- function exec(cmd, opts = {}) {
4
- return execSync(cmd, {
3
+ function execGit(args, opts = {}) {
4
+ return execFileSync("git", args, {
5
5
  encoding: "utf-8",
6
6
  maxBuffer: 50 * 1024 * 1024,
7
7
  ...opts,
8
8
  });
9
9
  }
10
10
 
11
- export function getDiff({ staged = false, files = [] } = {}) {
12
- let args = ["git", "diff"];
11
+ function execGitAllowStatus(args, allowedStatuses = [0], opts = {}) {
12
+ try {
13
+ return execGit(args, opts);
14
+ } catch (error) {
15
+ if (allowedStatuses.includes(error.status)) {
16
+ return String(error.stdout || "");
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ function getUntrackedFiles(files) {
23
+ let args = ["ls-files", "--others", "--exclude-standard"];
24
+ if (files.length > 0) {
25
+ args.push("--");
26
+ args.push(...files);
27
+ }
28
+
29
+ return execGit(args)
30
+ .split("\n")
31
+ .map((line) => line.trim())
32
+ .filter(Boolean);
33
+ }
34
+
35
+ function getUntrackedDiff(files) {
36
+ let patches = [];
37
+
38
+ for (let file of getUntrackedFiles(files)) {
39
+ let patch = execGitAllowStatus(["diff", "--no-index", "--", "/dev/null", file], [0, 1]).trim();
40
+ if (patch) patches.push(patch);
41
+ }
42
+
43
+ if (patches.length === 0) return "";
44
+ return `${patches.join("\n")}\n`;
45
+ }
46
+
47
+ export function getDiff({ staged = false, files = [], includeUntracked = false } = {}) {
48
+ let args = ["diff"];
13
49
  if (staged) args.push("--cached");
14
- // Full diff context isn't needed — default 3-line context is fine
15
50
  if (files.length > 0) {
16
51
  args.push("--");
17
52
  args.push(...files);
18
53
  }
19
- return exec(args.join(" "));
54
+ let trackedDiff = execGit(args);
55
+
56
+ if (!includeUntracked || staged) {
57
+ return trackedDiff;
58
+ }
59
+
60
+ let untrackedDiff = getUntrackedDiff(files);
61
+ if (!untrackedDiff) return trackedDiff;
62
+ if (!trackedDiff.trim()) return untrackedDiff;
63
+
64
+ return `${trackedDiff.trim()}\n${untrackedDiff}`;
20
65
  }
21
66
 
22
67
  export function applyPatch(patch, { cached = false, reverse = false } = {}) {
23
- let args = ["git", "apply"];
68
+ let args = ["apply"];
24
69
  if (cached) args.push("--cached");
25
70
  if (reverse) args.push("--reverse");
26
71
  args.push("-");
27
72
 
28
- return execSync(args.join(" "), {
73
+ return execGit(args, {
29
74
  input: patch,
30
- encoding: "utf-8",
31
- stdio: ["pipe", "pipe", "pipe"],
32
75
  });
33
76
  }
34
77
 
35
78
  export function getStatus() {
36
- return exec("git status --porcelain");
79
+ return execGit(["status", "--porcelain"]);
37
80
  }
38
81
 
39
82
  export function getRoot() {
40
- return exec("git rev-parse --show-toplevel").trim();
83
+ return execGit(["rev-parse", "--show-toplevel"]).trim();
41
84
  }
package/lib/selector.js CHANGED
@@ -2,12 +2,12 @@
2
2
  * Parse hunk/line selector strings.
3
3
  *
4
4
  * Formats:
5
- * "1" { type: "hunks", ids: [1] }
6
- * "1,3,5" { type: "hunks", ids: [1,3,5] }
7
- * "1-5" { type: "hunks", ids: [1,2,3,4,5] }
8
- * "1-3,7,9" { type: "hunks", ids: [1,2,3,7,9] }
9
- * "1:2-4" { type: "lines", hunkId: 1, lines: [2,3,4] }
10
- * "1:3,5,8" { type: "lines", hunkId: 1, lines: [3,5,8] }
5
+ * "1" -> { type: "hunks", ids: [1] }
6
+ * "1,3,5" -> { type: "hunks", ids: [1,3,5] }
7
+ * "1-5" -> { type: "hunks", ids: [1,2,3,4,5] }
8
+ * "1-3,7,9" -> { type: "hunks", ids: [1,2,3,7,9] }
9
+ * "1:2-4" -> { type: "lines", hunkId: 1, lines: [2,3,4] }
10
+ * "1:3,5,8" -> { type: "lines", hunkId: 1, lines: [3,5,8] }
11
11
  */
12
12
  export function parseSelector(str) {
13
13
  if (!str) throw new Error("No selector provided");
@@ -15,8 +15,12 @@ export function parseSelector(str) {
15
15
  // Line-level: "hunkId:lineSpec"
16
16
  if (str.includes(":")) {
17
17
  let [hunkPart, linePart] = str.split(":", 2);
18
- let hunkId = parseInt(hunkPart, 10);
19
- if (Number.isNaN(hunkId)) throw new Error(`Invalid hunk ID: ${hunkPart}`);
18
+ let trimmedHunkPart = hunkPart.trim();
19
+ if (!/^\d+$/.test(trimmedHunkPart)) {
20
+ throw new Error(`Invalid hunk ID: ${hunkPart}`);
21
+ }
22
+
23
+ let hunkId = Number(trimmedHunkPart);
20
24
  let lines = expandNumberSpec(linePart);
21
25
  return { type: "lines", hunkId, lines };
22
26
  }
@@ -29,17 +33,26 @@ export function parseSelector(str) {
29
33
  function expandNumberSpec(spec) {
30
34
  let parts = spec.split(",");
31
35
  let result = [];
36
+
32
37
  for (let part of parts) {
33
38
  part = part.trim();
39
+ if (part.length === 0) throw new Error("Invalid number: ");
40
+
34
41
  if (part.includes("-")) {
35
- let [start, end] = part.split("-", 2).map((s) => parseInt(s.trim(), 10));
36
- if (Number.isNaN(start) || Number.isNaN(end)) throw new Error(`Invalid range: ${part}`);
42
+ let match = part.match(/^(\d+)\s*-\s*(\d+)$/);
43
+ if (!match) throw new Error(`Invalid range: ${part}`);
44
+
45
+ let start = Number(match[1]);
46
+ let end = Number(match[2]);
47
+ if (start > end) throw new Error(`Invalid range: ${part}`);
48
+
37
49
  for (let i = start; i <= end; i++) result.push(i);
38
- } else {
39
- let n = parseInt(part, 10);
40
- if (Number.isNaN(n)) throw new Error(`Invalid number: ${part}`);
41
- result.push(n);
50
+ continue;
42
51
  }
52
+
53
+ if (!/^\d+$/.test(part)) throw new Error(`Invalid number: ${part}`);
54
+ result.push(Number(part));
43
55
  }
56
+
44
57
  return result;
45
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-patch",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Non-interactive hunk staging for LLMs — stage, unstage, and discard git changes by hunk or line",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test test/*.test.js",
11
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100 '--test-coverage-include=bin/*.js' '--test-coverage-include=lib/**/*.js' test/*.test.js",
11
12
  "lint": "biome check .",
12
13
  "format": "biome format --write .",
13
14
  "lint:fix": "biome check --write ."
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { parseDiff } from "../lib/diff-parser.js";
4
- import { getRoot } from "../lib/git.js";
4
+ import { getDiff, getRoot } from "../lib/git.js";
5
5
  import { buildPatchFromHunks, buildPatchFromLines } from "../lib/patch-builder.js";
6
6
  import { parseSelector } from "../lib/selector.js";
7
7
 
@@ -56,6 +56,21 @@ describe("coverage edges", () => {
56
56
  assert.throws(() => parseSelector("1:1-a"), /Invalid range: 1-a/);
57
57
  });
58
58
 
59
+ it("throws for mixed hunk and line selectors", () => {
60
+ assert.throws(() => parseSelector("1,2:3"), /Invalid hunk ID: 1,2/);
61
+ });
62
+
63
+ it("throws for partially numeric selector tokens", () => {
64
+ assert.throws(() => parseSelector("1x"), /Invalid number: 1x/);
65
+ assert.throws(() => parseSelector("1-2x"), /Invalid range: 1-2x/);
66
+ assert.throws(() => parseSelector("1:2,3x"), /Invalid number: 3x/);
67
+ });
68
+
69
+ it("throws for empty selector segments and descending ranges", () => {
70
+ assert.throws(() => parseSelector("1,,2"), /Invalid number: /);
71
+ assert.throws(() => parseSelector("3-1"), /Invalid range: 3-1/);
72
+ });
73
+
59
74
  it("finds line-level hunks across multiple files", () => {
60
75
  let patch = buildPatchFromLines(
61
76
  [
@@ -123,4 +138,9 @@ describe("coverage edges", () => {
123
138
  let root = getRoot();
124
139
  assert.equal(root, process.cwd());
125
140
  });
141
+
142
+ it("allows includeUntracked option with staged diffs", () => {
143
+ let out = getDiff({ staged: true, includeUntracked: true });
144
+ assert.equal(typeof out, "string");
145
+ });
126
146
  });
@@ -7,6 +7,12 @@ import { after, before, beforeEach, describe, it } from "node:test";
7
7
 
8
8
  let tmp;
9
9
  let bin = join(import.meta.dirname, "..", "bin", "git-patch.js");
10
+ let gitIdentityEnv = {
11
+ GIT_AUTHOR_NAME: "Test",
12
+ GIT_AUTHOR_EMAIL: "test@test.com",
13
+ GIT_COMMITTER_NAME: "Test",
14
+ GIT_COMMITTER_EMAIL: "test@test.com",
15
+ };
10
16
 
11
17
  function gp(args, opts = {}) {
12
18
  return execSync(`node ${bin} ${args}`, {
@@ -14,10 +20,7 @@ function gp(args, opts = {}) {
14
20
  encoding: "utf-8",
15
21
  env: {
16
22
  ...process.env,
17
- GIT_AUTHOR_NAME: "Test",
18
- GIT_AUTHOR_EMAIL: "test@test.com",
19
- GIT_COMMITTER_NAME: "Test",
20
- GIT_COMMITTER_EMAIL: "test@test.com",
23
+ ...gitIdentityEnv,
21
24
  },
22
25
  ...opts,
23
26
  }).trim();
@@ -30,10 +33,7 @@ function gpResult(args, opts = {}) {
30
33
  encoding: "utf-8",
31
34
  env: {
32
35
  ...process.env,
33
- GIT_AUTHOR_NAME: "Test",
34
- GIT_AUTHOR_EMAIL: "test@test.com",
35
- GIT_COMMITTER_NAME: "Test",
36
- GIT_COMMITTER_EMAIL: "test@test.com",
36
+ ...gitIdentityEnv,
37
37
  },
38
38
  stdio: ["pipe", "pipe", "pipe"],
39
39
  ...opts,
@@ -49,7 +49,14 @@ function gpResult(args, opts = {}) {
49
49
  }
50
50
 
51
51
  function git(args) {
52
- return execSync(`git ${args}`, { cwd: tmp, encoding: "utf-8" }).trim();
52
+ return execSync(`git ${args}`, {
53
+ cwd: tmp,
54
+ encoding: "utf-8",
55
+ env: {
56
+ ...process.env,
57
+ ...gitIdentityEnv,
58
+ },
59
+ }).trim();
53
60
  }
54
61
 
55
62
  function writeFile(name, content) {
@@ -193,6 +200,45 @@ describe("git-patch integration", () => {
193
200
  assert.equal(out.files[0].hunks[0].id, 1);
194
201
  });
195
202
 
203
+ it("lists untracked files as unstaged hunks", () => {
204
+ writeFile(
205
+ "src/new-untracked.js",
206
+ ['module.exports = "new";', "module.exports += '!';", ""].join("\n"),
207
+ );
208
+
209
+ let out = JSON.parse(gp("list --json"));
210
+ assert.equal(out.type, "unstaged");
211
+ assert.equal(out.files.length, 1);
212
+ assert.equal(out.files[0].file, "src/new-untracked.js");
213
+ assert.equal(out.files[0].hunks.length, 1);
214
+ assert.equal(out.files[0].hunks[0].oldStart, 0);
215
+ assert.equal(out.files[0].hunks[0].oldCount, 0);
216
+ assert.equal(out.files[0].hunks[0].addedCount, 2);
217
+ });
218
+
219
+ it("lists tracked and untracked hunks together", () => {
220
+ writeFile(
221
+ "src/app.js",
222
+ [
223
+ "function greet(name) {",
224
+ ' return "Hi, " + name;',
225
+ "}",
226
+ "",
227
+ "function farewell(name) {",
228
+ ' return "Goodbye, " + name;',
229
+ "}",
230
+ "",
231
+ "module.exports = { greet, farewell };",
232
+ "",
233
+ ].join("\n"),
234
+ );
235
+ writeFile("src/added.js", "module.exports = 1;\n");
236
+
237
+ let out = JSON.parse(gp("list --json"));
238
+ let files = out.files.map((file) => file.file).sort();
239
+ assert.deepEqual(files, ["src/added.js", "src/app.js"]);
240
+ });
241
+
196
242
  it("outputs hunk summaries with --json --summary", () => {
197
243
  writeFile(
198
244
  "src/app.js",
@@ -313,7 +359,7 @@ describe("git-patch integration", () => {
313
359
  fakeGit,
314
360
  [
315
361
  "#!/bin/sh",
316
- 'if [ \"$1\" = \"diff\" ]; then',
362
+ 'if [ "$1" = "diff" ]; then',
317
363
  "cat <<'EOF'",
318
364
  "diff --git a/demo.js b/demo.js",
319
365
  "index 1111111..2222222 100644",
@@ -325,7 +371,10 @@ describe("git-patch integration", () => {
325
371
  "EOF",
326
372
  "exit 0",
327
373
  "fi",
328
- 'echo \"unsupported command\" >&2',
374
+ 'if [ "$1" = "ls-files" ]; then',
375
+ "exit 0",
376
+ "fi",
377
+ 'echo "unsupported command" >&2',
329
378
  "exit 1",
330
379
  "",
331
380
  ].join("\n"),
@@ -340,6 +389,78 @@ describe("git-patch integration", () => {
340
389
  });
341
390
  assert.match(out, /function demo\(\)/);
342
391
  });
392
+
393
+ it("surfaces unexpected untracked-diff failures", () => {
394
+ let fakeBin = join(tmp, "fake-bin-fail");
395
+ mkdirSync(fakeBin, { recursive: true });
396
+
397
+ let fakeGit = join(fakeBin, "git");
398
+ writeFileSync(
399
+ fakeGit,
400
+ [
401
+ "#!/bin/sh",
402
+ 'if [ "$1" = "diff" ] && [ "$2" = "--no-index" ]; then',
403
+ 'echo "no-index failure" >&2',
404
+ "exit 2",
405
+ "fi",
406
+ 'if [ "$1" = "diff" ]; then',
407
+ "exit 0",
408
+ "fi",
409
+ 'if [ "$1" = "ls-files" ]; then',
410
+ 'echo "src/new-file.js"',
411
+ "exit 0",
412
+ "fi",
413
+ 'echo "unsupported command" >&2',
414
+ "exit 1",
415
+ "",
416
+ ].join("\n"),
417
+ );
418
+ chmodSync(fakeGit, 0o755);
419
+
420
+ let result = gpResult("list", {
421
+ env: {
422
+ ...process.env,
423
+ PATH: `${fakeBin}:${process.env.PATH}`,
424
+ },
425
+ });
426
+ assert.equal(result.status, 1);
427
+ assert.match(result.stderr, /no-index failure/);
428
+ });
429
+
430
+ it("ignores empty untracked diff output", () => {
431
+ let fakeBin = join(tmp, "fake-bin-empty");
432
+ mkdirSync(fakeBin, { recursive: true });
433
+
434
+ let fakeGit = join(fakeBin, "git");
435
+ writeFileSync(
436
+ fakeGit,
437
+ [
438
+ "#!/bin/sh",
439
+ 'if [ "$1" = "diff" ] && [ "$2" = "--no-index" ]; then',
440
+ "exit 1",
441
+ "fi",
442
+ 'if [ "$1" = "diff" ]; then',
443
+ "exit 0",
444
+ "fi",
445
+ 'if [ "$1" = "ls-files" ]; then',
446
+ 'echo "src/new-file.js"',
447
+ "exit 0",
448
+ "fi",
449
+ 'echo "unsupported command" >&2',
450
+ "exit 1",
451
+ "",
452
+ ].join("\n"),
453
+ );
454
+ chmodSync(fakeGit, 0o755);
455
+
456
+ let out = gp("list", {
457
+ env: {
458
+ ...process.env,
459
+ PATH: `${fakeBin}:${process.env.PATH}`,
460
+ },
461
+ });
462
+ assert.equal(out, "No unstaged changes.");
463
+ });
343
464
  });
344
465
 
345
466
  describe("cli", () => {
@@ -428,6 +549,28 @@ describe("git-patch integration", () => {
428
549
  assert.match(result.stderr, /Invalid hunk ID: x/);
429
550
  });
430
551
 
552
+ it("fails on mixed hunk and line selectors", () => {
553
+ writeFile(
554
+ "src/app.js",
555
+ [
556
+ "function greet(name) {",
557
+ ' return "Hi, " + name;',
558
+ "}",
559
+ "",
560
+ "function farewell(name) {",
561
+ ' return "Goodbye, " + name;',
562
+ "}",
563
+ "",
564
+ "module.exports = { greet, farewell };",
565
+ "",
566
+ ].join("\\n"),
567
+ );
568
+
569
+ let result = gpResult("stage 1,2:3");
570
+ assert.equal(result.status, 1);
571
+ assert.match(result.stderr, /Invalid hunk ID: 1,2/);
572
+ });
573
+
431
574
  it("fails on invalid selector range", () => {
432
575
  writeFile(
433
576
  "src/app.js",
@@ -696,6 +839,19 @@ describe("git-patch integration", () => {
696
839
  assert.equal(stagedNames, "D\tsrc/utils.js");
697
840
  assert.doesNotMatch(stagedNames, /dev\/null/);
698
841
  });
842
+
843
+ it("stages untracked files without intent-to-add", () => {
844
+ writeFile(
845
+ "src/new-stage.js",
846
+ ['module.exports = "new";', "module.exports += '!';", ""].join("\n"),
847
+ );
848
+
849
+ gp("stage 1");
850
+
851
+ let stagedNames = git("diff --cached --name-status");
852
+ assert.equal(stagedNames, "A\tsrc/new-stage.js");
853
+ assert.equal(gp("list"), "No unstaged changes.");
854
+ });
699
855
  });
700
856
 
701
857
  describe("unstage", () => {