git-patch 0.1.3 → 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.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-patch",
3
- "version": "0.1.3",
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": {
@@ -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
 
@@ -138,4 +138,9 @@ describe("coverage edges", () => {
138
138
  let root = getRoot();
139
139
  assert.equal(root, process.cwd());
140
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
+ });
141
146
  });
@@ -200,6 +200,45 @@ describe("git-patch integration", () => {
200
200
  assert.equal(out.files[0].hunks[0].id, 1);
201
201
  });
202
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
+
203
242
  it("outputs hunk summaries with --json --summary", () => {
204
243
  writeFile(
205
244
  "src/app.js",
@@ -320,7 +359,7 @@ describe("git-patch integration", () => {
320
359
  fakeGit,
321
360
  [
322
361
  "#!/bin/sh",
323
- 'if [ \"$1\" = \"diff\" ]; then',
362
+ 'if [ "$1" = "diff" ]; then',
324
363
  "cat <<'EOF'",
325
364
  "diff --git a/demo.js b/demo.js",
326
365
  "index 1111111..2222222 100644",
@@ -332,7 +371,10 @@ describe("git-patch integration", () => {
332
371
  "EOF",
333
372
  "exit 0",
334
373
  "fi",
335
- 'echo \"unsupported command\" >&2',
374
+ 'if [ "$1" = "ls-files" ]; then',
375
+ "exit 0",
376
+ "fi",
377
+ 'echo "unsupported command" >&2',
336
378
  "exit 1",
337
379
  "",
338
380
  ].join("\n"),
@@ -347,6 +389,78 @@ describe("git-patch integration", () => {
347
389
  });
348
390
  assert.match(out, /function demo\(\)/);
349
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
+ });
350
464
  });
351
465
 
352
466
  describe("cli", () => {
@@ -725,6 +839,19 @@ describe("git-patch integration", () => {
725
839
  assert.equal(stagedNames, "D\tsrc/utils.js");
726
840
  assert.doesNotMatch(stagedNames, /dev\/null/);
727
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
+ });
728
855
  });
729
856
 
730
857
  describe("unstage", () => {