git-patch 0.1.1 → 0.1.3
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/.github/workflows/ci.yml +9 -0
- package/lib/diff-parser.js +9 -3
- package/lib/patch-builder.js +10 -3
- package/lib/selector.js +27 -14
- package/package.json +2 -1
- package/test/coverage-edges.test.js +141 -0
- package/test/integration.test.js +489 -8
package/.github/workflows/ci.yml
CHANGED
|
@@ -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/lib/diff-parser.js
CHANGED
|
@@ -40,8 +40,11 @@ function classifyLine(raw) {
|
|
|
40
40
|
function parseFileDiff(chunk) {
|
|
41
41
|
let lines = chunk.split("\n");
|
|
42
42
|
let file = null;
|
|
43
|
+
let diffOldFile = null;
|
|
44
|
+
let diffNewFile = null;
|
|
43
45
|
let oldFile = null;
|
|
44
46
|
let newFile = null;
|
|
47
|
+
let metadataLines = [];
|
|
45
48
|
let hunks = [];
|
|
46
49
|
let i = 0;
|
|
47
50
|
|
|
@@ -51,8 +54,10 @@ function parseFileDiff(chunk) {
|
|
|
51
54
|
if (line.startsWith("diff --git")) {
|
|
52
55
|
let match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
53
56
|
if (match) {
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
diffOldFile = `a/${match[1]}`;
|
|
58
|
+
diffNewFile = `b/${match[2]}`;
|
|
59
|
+
oldFile = diffOldFile;
|
|
60
|
+
newFile = diffNewFile;
|
|
56
61
|
file = match[2];
|
|
57
62
|
}
|
|
58
63
|
i++;
|
|
@@ -71,6 +76,7 @@ function parseFileDiff(chunk) {
|
|
|
71
76
|
line.startsWith("copy to") ||
|
|
72
77
|
line.startsWith("Binary files")
|
|
73
78
|
) {
|
|
79
|
+
metadataLines.push(line);
|
|
74
80
|
i++;
|
|
75
81
|
continue;
|
|
76
82
|
}
|
|
@@ -165,7 +171,7 @@ function parseFileDiff(chunk) {
|
|
|
165
171
|
});
|
|
166
172
|
}
|
|
167
173
|
|
|
168
|
-
return { file, oldFile, newFile, hunks };
|
|
174
|
+
return { file, diffOldFile, diffNewFile, oldFile, newFile, metadataLines, hunks };
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
export function parseDiff(raw) {
|
package/lib/patch-builder.js
CHANGED
|
@@ -100,9 +100,16 @@ export function buildPatchFromLines(fileDiffs, hunkId, changeLineIndices, _mode
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
function buildFileHeader(file) {
|
|
103
|
-
let
|
|
104
|
-
let
|
|
105
|
-
|
|
103
|
+
let diffOldFile = file.diffOldFile || `a/${file.file}`;
|
|
104
|
+
let diffNewFile = file.diffNewFile || `b/${file.file}`;
|
|
105
|
+
let oldFile = file.oldFile || diffOldFile;
|
|
106
|
+
let newFile = file.newFile || diffNewFile;
|
|
107
|
+
let headerLines = [`diff --git ${diffOldFile} ${diffNewFile}`];
|
|
108
|
+
if (Array.isArray(file.metadataLines) && file.metadataLines.length > 0) {
|
|
109
|
+
headerLines.push(...file.metadataLines);
|
|
110
|
+
}
|
|
111
|
+
headerLines.push(`--- ${oldFile}`, `+++ ${newFile}`);
|
|
112
|
+
return headerLines.join("\n");
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
function formatHunk(hunk) {
|
package/lib/selector.js
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Parse hunk/line selector strings.
|
|
3
3
|
*
|
|
4
4
|
* Formats:
|
|
5
|
-
* "1"
|
|
6
|
-
* "1,3,5"
|
|
7
|
-
* "1-5"
|
|
8
|
-
* "1-3,7,9"
|
|
9
|
-
* "1:2-4"
|
|
10
|
-
* "1: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
|
|
19
|
-
if (
|
|
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
|
|
36
|
-
if (
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.3",
|
|
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 ."
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseDiff } from "../lib/diff-parser.js";
|
|
4
|
+
import { getRoot } from "../lib/git.js";
|
|
5
|
+
import { buildPatchFromHunks, buildPatchFromLines } from "../lib/patch-builder.js";
|
|
6
|
+
import { parseSelector } from "../lib/selector.js";
|
|
7
|
+
|
|
8
|
+
describe("coverage edges", () => {
|
|
9
|
+
it("parses diffs with ignorable and malformed hunk markers", () => {
|
|
10
|
+
let raw = [
|
|
11
|
+
"diff --git a/src/a.js b/src/a.js",
|
|
12
|
+
"index 1111111..2222222 100644",
|
|
13
|
+
"--- a/src/a.js",
|
|
14
|
+
"+++ b/src/a.js",
|
|
15
|
+
"garbage before hunk",
|
|
16
|
+
"@@ not-a-real-hunk @@",
|
|
17
|
+
"interstitial non-hunk line",
|
|
18
|
+
"@@ -1 +1 @@",
|
|
19
|
+
"-old",
|
|
20
|
+
"+new",
|
|
21
|
+
"garbage after hunk",
|
|
22
|
+
"",
|
|
23
|
+
].join("\n");
|
|
24
|
+
|
|
25
|
+
let files = parseDiff(raw);
|
|
26
|
+
assert.equal(files.length, 1);
|
|
27
|
+
assert.equal(files[0].hunks.length, 1);
|
|
28
|
+
assert.equal(files[0].hunks[0].addedCount, 1);
|
|
29
|
+
assert.equal(files[0].hunks[0].removedCount, 1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("builds headers from fallback file paths when metadata is absent", () => {
|
|
33
|
+
let patch = buildPatchFromHunks(
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
file: "demo.txt",
|
|
37
|
+
metadataLines: [],
|
|
38
|
+
hunks: [
|
|
39
|
+
{
|
|
40
|
+
id: 7,
|
|
41
|
+
header: "@@ -1 +1 @@",
|
|
42
|
+
lines: [{ content: "-a" }, { content: "+b" }],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
[7],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
assert.match(patch, /diff --git a\/demo\.txt b\/demo\.txt/);
|
|
51
|
+
assert.match(patch, /--- a\/demo\.txt/);
|
|
52
|
+
assert.match(patch, /\+\+\+ b\/demo\.txt/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("throws for invalid line-range selectors", () => {
|
|
56
|
+
assert.throws(() => parseSelector("1:1-a"), /Invalid range: 1-a/);
|
|
57
|
+
});
|
|
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
|
+
|
|
74
|
+
it("finds line-level hunks across multiple files", () => {
|
|
75
|
+
let patch = buildPatchFromLines(
|
|
76
|
+
[
|
|
77
|
+
{
|
|
78
|
+
file: "first.txt",
|
|
79
|
+
hunks: [
|
|
80
|
+
{
|
|
81
|
+
id: 1,
|
|
82
|
+
oldStart: 1,
|
|
83
|
+
newStart: 1,
|
|
84
|
+
context: null,
|
|
85
|
+
lines: [{ type: "context", content: " keep", oldLine: 1, newLine: 1 }],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
file: "second.txt",
|
|
91
|
+
hunks: [
|
|
92
|
+
{
|
|
93
|
+
id: 2,
|
|
94
|
+
oldStart: 1,
|
|
95
|
+
newStart: 1,
|
|
96
|
+
context: null,
|
|
97
|
+
lines: [
|
|
98
|
+
{ type: "removed", content: "-old", oldLine: 1, newLine: null },
|
|
99
|
+
{ type: "added", content: "+new", oldLine: null, newLine: 1 },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
2,
|
|
106
|
+
[1],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
assert.match(patch, /diff --git a\/second\.txt b\/second\.txt/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("throws when line-level hunk ID is missing", () => {
|
|
113
|
+
assert.throws(
|
|
114
|
+
() =>
|
|
115
|
+
buildPatchFromLines(
|
|
116
|
+
[
|
|
117
|
+
{
|
|
118
|
+
file: "only.txt",
|
|
119
|
+
hunks: [
|
|
120
|
+
{
|
|
121
|
+
id: 1,
|
|
122
|
+
oldStart: 1,
|
|
123
|
+
newStart: 1,
|
|
124
|
+
context: null,
|
|
125
|
+
lines: [{ type: "added", content: "+x", oldLine: null, newLine: 1 }],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
999,
|
|
131
|
+
[1],
|
|
132
|
+
),
|
|
133
|
+
/Hunk 999 not found/,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns the current git root", () => {
|
|
138
|
+
let root = getRoot();
|
|
139
|
+
assert.equal(root, process.cwd());
|
|
140
|
+
});
|
|
141
|
+
});
|
package/test/integration.test.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
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,17 +20,43 @@ function gp(args, opts = {}) {
|
|
|
14
20
|
encoding: "utf-8",
|
|
15
21
|
env: {
|
|
16
22
|
...process.env,
|
|
17
|
-
|
|
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();
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
function gpResult(args, opts = {}) {
|
|
30
|
+
try {
|
|
31
|
+
let stdout = execSync(`node ${bin} ${args}`, {
|
|
32
|
+
cwd: tmp,
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
env: {
|
|
35
|
+
...process.env,
|
|
36
|
+
...gitIdentityEnv,
|
|
37
|
+
},
|
|
38
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
39
|
+
...opts,
|
|
40
|
+
}).trim();
|
|
41
|
+
return { status: 0, stdout, stderr: "" };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return {
|
|
44
|
+
status: error.status ?? 1,
|
|
45
|
+
stdout: String(error.stdout || "").trim(),
|
|
46
|
+
stderr: String(error.stderr || "").trim(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
function git(args) {
|
|
27
|
-
return execSync(`git ${args}`, {
|
|
52
|
+
return execSync(`git ${args}`, {
|
|
53
|
+
cwd: tmp,
|
|
54
|
+
encoding: "utf-8",
|
|
55
|
+
env: {
|
|
56
|
+
...process.env,
|
|
57
|
+
...gitIdentityEnv,
|
|
58
|
+
},
|
|
59
|
+
}).trim();
|
|
28
60
|
}
|
|
29
61
|
|
|
30
62
|
function writeFile(name, content) {
|
|
@@ -86,8 +118,8 @@ describe("git-patch integration", () => {
|
|
|
86
118
|
|
|
87
119
|
// Reset to clean state before each test
|
|
88
120
|
beforeEach(() => {
|
|
89
|
-
git("
|
|
90
|
-
git("
|
|
121
|
+
git("reset --hard HEAD");
|
|
122
|
+
git("clean -fd");
|
|
91
123
|
});
|
|
92
124
|
|
|
93
125
|
describe("list", () => {
|
|
@@ -257,9 +289,203 @@ describe("git-patch integration", () => {
|
|
|
257
289
|
assert.equal(out.files.length, 1);
|
|
258
290
|
assert.equal(out.files[0].file, "src/utils.js");
|
|
259
291
|
});
|
|
292
|
+
|
|
293
|
+
it("summarizes a newly added file with zero old range", () => {
|
|
294
|
+
writeFile("src/new-summary.js", "module.exports = 1;\n");
|
|
295
|
+
git("add src/new-summary.js");
|
|
296
|
+
|
|
297
|
+
let out = JSON.parse(gp("list --staged --json --summary"));
|
|
298
|
+
let hunk = out.hunks.find((h) => h.file === "src/new-summary.js");
|
|
299
|
+
assert.ok(hunk);
|
|
300
|
+
assert.equal(hunk.oldRange.count, 0);
|
|
301
|
+
assert.equal(hunk.oldRange.end, 0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("summarizes a deleted file with zero new range", () => {
|
|
305
|
+
rmSync(join(tmp, "src/utils.js"));
|
|
306
|
+
|
|
307
|
+
let out = JSON.parse(gp("list --json --summary"));
|
|
308
|
+
let hunk = out.hunks.find((h) => h.file === "src/utils.js");
|
|
309
|
+
assert.ok(hunk);
|
|
310
|
+
assert.equal(hunk.newRange.count, 0);
|
|
311
|
+
assert.equal(hunk.newRange.end, 0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("prints hunk context when git provides it", () => {
|
|
315
|
+
let fakeBin = join(tmp, "fake-bin");
|
|
316
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
317
|
+
|
|
318
|
+
let fakeGit = join(fakeBin, "git");
|
|
319
|
+
writeFileSync(
|
|
320
|
+
fakeGit,
|
|
321
|
+
[
|
|
322
|
+
"#!/bin/sh",
|
|
323
|
+
'if [ \"$1\" = \"diff\" ]; then',
|
|
324
|
+
"cat <<'EOF'",
|
|
325
|
+
"diff --git a/demo.js b/demo.js",
|
|
326
|
+
"index 1111111..2222222 100644",
|
|
327
|
+
"--- a/demo.js",
|
|
328
|
+
"+++ b/demo.js",
|
|
329
|
+
"@@ -1 +1 @@ function demo()",
|
|
330
|
+
"-old",
|
|
331
|
+
"+new",
|
|
332
|
+
"EOF",
|
|
333
|
+
"exit 0",
|
|
334
|
+
"fi",
|
|
335
|
+
'echo \"unsupported command\" >&2',
|
|
336
|
+
"exit 1",
|
|
337
|
+
"",
|
|
338
|
+
].join("\n"),
|
|
339
|
+
);
|
|
340
|
+
chmodSync(fakeGit, 0o755);
|
|
341
|
+
|
|
342
|
+
let out = gp("list", {
|
|
343
|
+
env: {
|
|
344
|
+
...process.env,
|
|
345
|
+
PATH: `${fakeBin}:${process.env.PATH}`,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
assert.match(out, /function demo\(\)/);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("cli", () => {
|
|
353
|
+
it("shows usage for --help", () => {
|
|
354
|
+
let out = gp("--help");
|
|
355
|
+
assert.match(out, /Usage:/);
|
|
356
|
+
assert.match(out, /Selectors:/);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("fails with usage for unknown command", () => {
|
|
360
|
+
let result = gpResult("nope");
|
|
361
|
+
assert.equal(result.status, 1);
|
|
362
|
+
assert.match(result.stderr, /Unknown command: nope/);
|
|
363
|
+
assert.match(result.stdout, /Usage:/);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("fails with parse error for unknown flags", () => {
|
|
367
|
+
let result = gpResult("list --wat");
|
|
368
|
+
assert.equal(result.status, 1);
|
|
369
|
+
assert.match(result.stderr, /Unknown option '--wat'/);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("fails when selector is missing", () => {
|
|
373
|
+
writeFile(
|
|
374
|
+
"src/app.js",
|
|
375
|
+
[
|
|
376
|
+
"function greet(name) {",
|
|
377
|
+
' return "Hi, " + name;',
|
|
378
|
+
"}",
|
|
379
|
+
"",
|
|
380
|
+
"function farewell(name) {",
|
|
381
|
+
' return "Goodbye, " + name;',
|
|
382
|
+
"}",
|
|
383
|
+
"",
|
|
384
|
+
"module.exports = { greet, farewell };",
|
|
385
|
+
"",
|
|
386
|
+
].join("\n"),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
let result = gpResult("stage");
|
|
390
|
+
assert.equal(result.status, 1);
|
|
391
|
+
assert.match(result.stderr, /No selector provided/);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("fails on invalid selector", () => {
|
|
395
|
+
writeFile(
|
|
396
|
+
"src/app.js",
|
|
397
|
+
[
|
|
398
|
+
"function greet(name) {",
|
|
399
|
+
' return "Hi, " + name;',
|
|
400
|
+
"}",
|
|
401
|
+
"",
|
|
402
|
+
"function farewell(name) {",
|
|
403
|
+
' return "Goodbye, " + name;',
|
|
404
|
+
"}",
|
|
405
|
+
"",
|
|
406
|
+
"module.exports = { greet, farewell };",
|
|
407
|
+
"",
|
|
408
|
+
].join("\n"),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
let result = gpResult("stage abc");
|
|
412
|
+
assert.equal(result.status, 1);
|
|
413
|
+
assert.match(result.stderr, /Invalid number: abc/);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("fails on invalid line-level hunk ID", () => {
|
|
417
|
+
writeFile(
|
|
418
|
+
"src/app.js",
|
|
419
|
+
[
|
|
420
|
+
"function greet(name) {",
|
|
421
|
+
' return "Hi, " + name;',
|
|
422
|
+
"}",
|
|
423
|
+
"",
|
|
424
|
+
"function farewell(name) {",
|
|
425
|
+
' return "Goodbye, " + name;',
|
|
426
|
+
"}",
|
|
427
|
+
"",
|
|
428
|
+
"module.exports = { greet, farewell };",
|
|
429
|
+
"",
|
|
430
|
+
].join("\n"),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
let result = gpResult("stage x:1");
|
|
434
|
+
assert.equal(result.status, 1);
|
|
435
|
+
assert.match(result.stderr, /Invalid hunk ID: x/);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("fails on mixed hunk and line selectors", () => {
|
|
439
|
+
writeFile(
|
|
440
|
+
"src/app.js",
|
|
441
|
+
[
|
|
442
|
+
"function greet(name) {",
|
|
443
|
+
' return "Hi, " + name;',
|
|
444
|
+
"}",
|
|
445
|
+
"",
|
|
446
|
+
"function farewell(name) {",
|
|
447
|
+
' return "Goodbye, " + name;',
|
|
448
|
+
"}",
|
|
449
|
+
"",
|
|
450
|
+
"module.exports = { greet, farewell };",
|
|
451
|
+
"",
|
|
452
|
+
].join("\\n"),
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
let result = gpResult("stage 1,2:3");
|
|
456
|
+
assert.equal(result.status, 1);
|
|
457
|
+
assert.match(result.stderr, /Invalid hunk ID: 1,2/);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("fails on invalid selector range", () => {
|
|
461
|
+
writeFile(
|
|
462
|
+
"src/app.js",
|
|
463
|
+
[
|
|
464
|
+
"function greet(name) {",
|
|
465
|
+
' return "Hi, " + name;',
|
|
466
|
+
"}",
|
|
467
|
+
"",
|
|
468
|
+
"function farewell(name) {",
|
|
469
|
+
' return "Goodbye, " + name;',
|
|
470
|
+
"}",
|
|
471
|
+
"",
|
|
472
|
+
"module.exports = { greet, farewell };",
|
|
473
|
+
"",
|
|
474
|
+
].join("\n"),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
let result = gpResult("stage 1-a");
|
|
478
|
+
assert.equal(result.status, 1);
|
|
479
|
+
assert.match(result.stderr, /Invalid range: 1-a/);
|
|
480
|
+
});
|
|
260
481
|
});
|
|
261
482
|
|
|
262
483
|
describe("stage", () => {
|
|
484
|
+
it("reports when there are no unstaged changes", () => {
|
|
485
|
+
let out = gp("stage --all");
|
|
486
|
+
assert.equal(out, "No unstaged changes to stage.");
|
|
487
|
+
});
|
|
488
|
+
|
|
263
489
|
it("stages a single hunk by ID", () => {
|
|
264
490
|
// Use two separate files so each gets its own hunk
|
|
265
491
|
writeFile(
|
|
@@ -432,9 +658,81 @@ describe("git-patch integration", () => {
|
|
|
432
658
|
let content = staged.files[0].hunks[0].lines.map((l) => l.content).join("\n");
|
|
433
659
|
assert.match(content, /See ya/);
|
|
434
660
|
});
|
|
661
|
+
|
|
662
|
+
it("reports when --matching finds nothing", () => {
|
|
663
|
+
writeFile(
|
|
664
|
+
"src/app.js",
|
|
665
|
+
[
|
|
666
|
+
"function greet(name) {",
|
|
667
|
+
' return "Hi, " + name;',
|
|
668
|
+
"}",
|
|
669
|
+
"",
|
|
670
|
+
"function farewell(name) {",
|
|
671
|
+
' return "See ya, " + name;',
|
|
672
|
+
"}",
|
|
673
|
+
"",
|
|
674
|
+
"module.exports = { greet, farewell };",
|
|
675
|
+
"",
|
|
676
|
+
].join("\n"),
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
let out = gp('stage --matching "definitely-no-match"');
|
|
680
|
+
assert.equal(out, "No hunks matching /definitely-no-match/.");
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("parses no-newline markers without crashing", () => {
|
|
684
|
+
writeFile(
|
|
685
|
+
"src/app.js",
|
|
686
|
+
[
|
|
687
|
+
"function greet(name) {",
|
|
688
|
+
' return "Hello, " + name;',
|
|
689
|
+
"}",
|
|
690
|
+
"",
|
|
691
|
+
"function farewell(name) {",
|
|
692
|
+
' return "Goodbye, " + name;',
|
|
693
|
+
"}",
|
|
694
|
+
"",
|
|
695
|
+
"module.exports = { greet, farewell };",
|
|
696
|
+
].join("\n"),
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
writeFile(
|
|
700
|
+
"src/app.js",
|
|
701
|
+
[
|
|
702
|
+
"function greet(name) {",
|
|
703
|
+
' return "Hi, " + name;',
|
|
704
|
+
"}",
|
|
705
|
+
"",
|
|
706
|
+
"function farewell(name) {",
|
|
707
|
+
' return "Goodbye, " + name;',
|
|
708
|
+
"}",
|
|
709
|
+
"",
|
|
710
|
+
"module.exports = { greet, farewell };",
|
|
711
|
+
].join("\n"),
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
gp("stage 1");
|
|
715
|
+
let staged = gp("list --staged");
|
|
716
|
+
assert.match(staged, /src\/app\.js/);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("stages file deletions without creating dev/null in index", () => {
|
|
720
|
+
rmSync(join(tmp, "src/utils.js"));
|
|
721
|
+
|
|
722
|
+
gp("stage --all");
|
|
723
|
+
|
|
724
|
+
let stagedNames = git("diff --cached --name-status");
|
|
725
|
+
assert.equal(stagedNames, "D\tsrc/utils.js");
|
|
726
|
+
assert.doesNotMatch(stagedNames, /dev\/null/);
|
|
727
|
+
});
|
|
435
728
|
});
|
|
436
729
|
|
|
437
730
|
describe("unstage", () => {
|
|
731
|
+
it("reports when there are no staged changes", () => {
|
|
732
|
+
let out = gp("unstage --all");
|
|
733
|
+
assert.equal(out, "No staged changes to unstage.");
|
|
734
|
+
});
|
|
735
|
+
|
|
438
736
|
it("unstages a single hunk", () => {
|
|
439
737
|
writeFile(
|
|
440
738
|
"src/app.js",
|
|
@@ -485,6 +783,90 @@ describe("git-patch integration", () => {
|
|
|
485
783
|
let staged = gp("list --staged");
|
|
486
784
|
assert.equal(staged, "No staged changes.");
|
|
487
785
|
});
|
|
786
|
+
|
|
787
|
+
it("unstages hunks matching a regex", () => {
|
|
788
|
+
writeFile(
|
|
789
|
+
"src/app.js",
|
|
790
|
+
[
|
|
791
|
+
"function greet(name) {",
|
|
792
|
+
' return "Hi, " + name;',
|
|
793
|
+
"}",
|
|
794
|
+
"",
|
|
795
|
+
"function farewell(name) {",
|
|
796
|
+
' return "See ya, " + name;',
|
|
797
|
+
"}",
|
|
798
|
+
"",
|
|
799
|
+
"module.exports = { greet, farewell };",
|
|
800
|
+
"",
|
|
801
|
+
].join("\n"),
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
gp("stage --all");
|
|
805
|
+
let out = gp('unstage --matching "See ya"');
|
|
806
|
+
assert.equal(out, "Unstaging 1 hunk(s) matching /See ya/");
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("reports when unstage --matching finds nothing", () => {
|
|
810
|
+
writeFile(
|
|
811
|
+
"src/app.js",
|
|
812
|
+
[
|
|
813
|
+
"function greet(name) {",
|
|
814
|
+
' return "Hi, " + name;',
|
|
815
|
+
"}",
|
|
816
|
+
"",
|
|
817
|
+
"function farewell(name) {",
|
|
818
|
+
' return "See ya, " + name;',
|
|
819
|
+
"}",
|
|
820
|
+
"",
|
|
821
|
+
"module.exports = { greet, farewell };",
|
|
822
|
+
"",
|
|
823
|
+
].join("\n"),
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
gp("stage --all");
|
|
827
|
+
let out = gp('unstage --matching "definitely-no-match"');
|
|
828
|
+
assert.equal(out, "No staged hunks matching /definitely-no-match/.");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("unstages specific lines within a hunk", () => {
|
|
832
|
+
writeFile(
|
|
833
|
+
"src/app.js",
|
|
834
|
+
[
|
|
835
|
+
"function greet(name) {",
|
|
836
|
+
' return "Hi, " + name;',
|
|
837
|
+
"}",
|
|
838
|
+
"",
|
|
839
|
+
"function farewell(name) {",
|
|
840
|
+
' return "See ya, " + name;',
|
|
841
|
+
"}",
|
|
842
|
+
"",
|
|
843
|
+
"module.exports = { greet, farewell };",
|
|
844
|
+
"",
|
|
845
|
+
].join("\n"),
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
gp("stage 1:2");
|
|
849
|
+
let out = gp("unstage 1:1");
|
|
850
|
+
assert.equal(out, "Unstaged successfully.");
|
|
851
|
+
|
|
852
|
+
let staged = gp("list --staged");
|
|
853
|
+
assert.equal(staged, "No staged changes.");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("unstages newly-added files without creating dev/null in index", () => {
|
|
857
|
+
writeFile("src/new-file.js", 'module.exports = "new";\n');
|
|
858
|
+
git("add src/new-file.js");
|
|
859
|
+
|
|
860
|
+
gp("unstage --all");
|
|
861
|
+
|
|
862
|
+
let stagedNames = git("diff --cached --name-status");
|
|
863
|
+
let porcelain = git("status --porcelain");
|
|
864
|
+
assert.equal(stagedNames, "");
|
|
865
|
+
assert.match(porcelain, /\?\? src\/new-file\.js/);
|
|
866
|
+
assert.doesNotMatch(porcelain, /dev\/null/);
|
|
867
|
+
|
|
868
|
+
rmSync(join(tmp, "src/new-file.js"), { force: true });
|
|
869
|
+
});
|
|
488
870
|
});
|
|
489
871
|
|
|
490
872
|
describe("line-level selection", () => {
|
|
@@ -620,6 +1002,99 @@ describe("git-patch integration", () => {
|
|
|
620
1002
|
assert.match(content, /Hello/);
|
|
621
1003
|
assert.doesNotMatch(content, /Hi/);
|
|
622
1004
|
});
|
|
1005
|
+
|
|
1006
|
+
it("reports when there is nothing to discard", () => {
|
|
1007
|
+
let out = gp("discard --all --yes");
|
|
1008
|
+
assert.equal(out, "No unstaged changes to discard.");
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("discards all changes with --all --yes", () => {
|
|
1012
|
+
writeFile(
|
|
1013
|
+
"src/app.js",
|
|
1014
|
+
[
|
|
1015
|
+
"function greet(name) {",
|
|
1016
|
+
' return "Hi, " + name;',
|
|
1017
|
+
"}",
|
|
1018
|
+
"",
|
|
1019
|
+
"function farewell(name) {",
|
|
1020
|
+
' return "See ya, " + name;',
|
|
1021
|
+
"}",
|
|
1022
|
+
"",
|
|
1023
|
+
"module.exports = { greet, farewell };",
|
|
1024
|
+
"",
|
|
1025
|
+
].join("\n"),
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
gp("discard --all --yes");
|
|
1029
|
+
let out = gp("list");
|
|
1030
|
+
assert.equal(out, "No unstaged changes.");
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("discards hunks matching a regex", () => {
|
|
1034
|
+
writeFile(
|
|
1035
|
+
"src/app.js",
|
|
1036
|
+
[
|
|
1037
|
+
"function greet(name) {",
|
|
1038
|
+
' return "Hello, " + name;',
|
|
1039
|
+
"}",
|
|
1040
|
+
"",
|
|
1041
|
+
"function farewell(name) {",
|
|
1042
|
+
' return "See ya, " + name;',
|
|
1043
|
+
"}",
|
|
1044
|
+
"",
|
|
1045
|
+
"module.exports = { greet, farewell };",
|
|
1046
|
+
"",
|
|
1047
|
+
].join("\n"),
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
gp('discard --matching "See ya" --yes');
|
|
1051
|
+
let content = readFile("src/app.js");
|
|
1052
|
+
assert.match(content, /Goodbye/);
|
|
1053
|
+
assert.match(content, /Hello/);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it("reports when discard --matching finds nothing", () => {
|
|
1057
|
+
writeFile(
|
|
1058
|
+
"src/app.js",
|
|
1059
|
+
[
|
|
1060
|
+
"function greet(name) {",
|
|
1061
|
+
' return "Hi, " + name;',
|
|
1062
|
+
"}",
|
|
1063
|
+
"",
|
|
1064
|
+
"function farewell(name) {",
|
|
1065
|
+
' return "See ya, " + name;',
|
|
1066
|
+
"}",
|
|
1067
|
+
"",
|
|
1068
|
+
"module.exports = { greet, farewell };",
|
|
1069
|
+
"",
|
|
1070
|
+
].join("\n"),
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
let out = gp('discard --matching "definitely-no-match" --yes');
|
|
1074
|
+
assert.equal(out, "No hunks matching /definitely-no-match/.");
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("discards specific lines within a hunk", () => {
|
|
1078
|
+
writeFile(
|
|
1079
|
+
"src/app.js",
|
|
1080
|
+
[
|
|
1081
|
+
"function greet(name) {",
|
|
1082
|
+
' return "Hi, " + name;',
|
|
1083
|
+
"}",
|
|
1084
|
+
"",
|
|
1085
|
+
"function farewell(name) {",
|
|
1086
|
+
' return "See ya, " + name;',
|
|
1087
|
+
"}",
|
|
1088
|
+
"",
|
|
1089
|
+
"module.exports = { greet, farewell };",
|
|
1090
|
+
"",
|
|
1091
|
+
].join("\n"),
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
let out = gp("discard 1:2 --dry-run");
|
|
1095
|
+
assert.match(out, /Dry run/);
|
|
1096
|
+
assert.match(out, /@@/);
|
|
1097
|
+
});
|
|
623
1098
|
});
|
|
624
1099
|
|
|
625
1100
|
describe("status", () => {
|
|
@@ -687,6 +1162,12 @@ describe("git-patch integration", () => {
|
|
|
687
1162
|
assert.equal(out.unstaged.files, 1);
|
|
688
1163
|
assert.equal(out.staged.hunks, 0);
|
|
689
1164
|
});
|
|
1165
|
+
|
|
1166
|
+
it("lists untracked files in per-file output", () => {
|
|
1167
|
+
writeFile("src/untracked.js", "module.exports = 1;\n");
|
|
1168
|
+
let out = gp("status");
|
|
1169
|
+
assert.match(out, /src\/untracked\.js\s+untracked/);
|
|
1170
|
+
});
|
|
690
1171
|
});
|
|
691
1172
|
|
|
692
1173
|
describe("full workflow", () => {
|