git-patch 0.1.0 → 0.1.2
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 +2 -0
- package/bin/git-patch.js +3 -2
- package/lib/commands/list.js +54 -6
- package/lib/diff-parser.js +9 -3
- package/lib/patch-builder.js +10 -3
- package/package.json +1 -1
- package/test/coverage-edges.test.js +126 -0
- package/test/integration.test.js +508 -3
package/README.md
CHANGED
|
@@ -22,7 +22,9 @@ npx git-patch list
|
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
git-patch list # Human-readable hunk list
|
|
25
|
+
git-patch list --summary # One line per hunk (id + file/range + counts)
|
|
25
26
|
git-patch list --json # Structured JSON output
|
|
27
|
+
git-patch list --json --summary # Flat hunk summaries for scripts/LLMs
|
|
26
28
|
git-patch list --staged # Show staged hunks
|
|
27
29
|
git-patch list -- src/main.rs # Filter to specific files
|
|
28
30
|
```
|
package/bin/git-patch.js
CHANGED
|
@@ -10,7 +10,7 @@ function usage() {
|
|
|
10
10
|
console.log(`git-patch — Non-interactive hunk staging for LLMs
|
|
11
11
|
|
|
12
12
|
Usage:
|
|
13
|
-
git-patch list [--json] [--staged] [-- files...]
|
|
13
|
+
git-patch list [--json] [--summary] [--staged] [-- files...]
|
|
14
14
|
git-patch stage <selector> [--all] [--matching <regex>] [-- files...]
|
|
15
15
|
git-patch unstage <selector> [--all] [--matching <regex>]
|
|
16
16
|
git-patch discard <selector> [--all] [--matching <regex>] [--yes] [--dry-run] [-- files...]
|
|
@@ -39,12 +39,13 @@ try {
|
|
|
39
39
|
args,
|
|
40
40
|
options: {
|
|
41
41
|
json: { type: "boolean", default: false },
|
|
42
|
+
summary: { type: "boolean", default: false },
|
|
42
43
|
staged: { type: "boolean", default: false },
|
|
43
44
|
},
|
|
44
45
|
strict: true,
|
|
45
46
|
});
|
|
46
47
|
let { run } = await import("../lib/commands/list.js");
|
|
47
|
-
run({ json: values.json, staged: values.staged, files });
|
|
48
|
+
run({ json: values.json, summary: values.summary, staged: values.staged, files });
|
|
48
49
|
break;
|
|
49
50
|
}
|
|
50
51
|
|
package/lib/commands/list.js
CHANGED
|
@@ -1,15 +1,59 @@
|
|
|
1
1
|
import { parseDiff } from "../diff-parser.js";
|
|
2
2
|
import { getDiff } from "../git.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
function formatDisplayRange(file, hunk) {
|
|
5
|
+
let span = Math.max(hunk.oldCount, hunk.newCount, 1);
|
|
6
|
+
let start = hunk.oldStart > 0 ? hunk.oldStart : hunk.newStart;
|
|
7
|
+
let end = start + span - 1;
|
|
8
|
+
return `${file.file}:${start}-${end}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function summarizeHunks(fileDiffs) {
|
|
12
|
+
let hunks = [];
|
|
13
|
+
|
|
14
|
+
for (let file of fileDiffs) {
|
|
15
|
+
for (let hunk of file.hunks) {
|
|
16
|
+
let oldEnd = hunk.oldCount === 0 ? hunk.oldStart : hunk.oldStart + hunk.oldCount - 1;
|
|
17
|
+
let newEnd = hunk.newCount === 0 ? hunk.newStart : hunk.newStart + hunk.newCount - 1;
|
|
18
|
+
|
|
19
|
+
hunks.push({
|
|
20
|
+
id: hunk.id,
|
|
21
|
+
file: file.file,
|
|
22
|
+
range: formatDisplayRange(file, hunk),
|
|
23
|
+
oldRange: {
|
|
24
|
+
start: hunk.oldStart,
|
|
25
|
+
end: oldEnd,
|
|
26
|
+
count: hunk.oldCount,
|
|
27
|
+
},
|
|
28
|
+
newRange: {
|
|
29
|
+
start: hunk.newStart,
|
|
30
|
+
end: newEnd,
|
|
31
|
+
count: hunk.newCount,
|
|
32
|
+
},
|
|
33
|
+
addedCount: hunk.addedCount,
|
|
34
|
+
removedCount: hunk.removedCount,
|
|
35
|
+
context: hunk.context,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return hunks;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function run({ json = false, staged = false, files = [], summary = false } = {}) {
|
|
5
44
|
let raw = getDiff({ staged, files });
|
|
6
45
|
let fileDiffs = parseDiff(raw);
|
|
7
46
|
|
|
8
47
|
if (json) {
|
|
9
|
-
let output =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
48
|
+
let output = summary
|
|
49
|
+
? {
|
|
50
|
+
type: staged ? "staged" : "unstaged",
|
|
51
|
+
hunks: summarizeHunks(fileDiffs),
|
|
52
|
+
}
|
|
53
|
+
: {
|
|
54
|
+
type: staged ? "staged" : "unstaged",
|
|
55
|
+
files: fileDiffs,
|
|
56
|
+
};
|
|
13
57
|
console.log(JSON.stringify(output, null, 2));
|
|
14
58
|
return;
|
|
15
59
|
}
|
|
@@ -23,11 +67,15 @@ export function run({ json = false, staged = false, files = [] } = {}) {
|
|
|
23
67
|
|
|
24
68
|
for (let file of fileDiffs) {
|
|
25
69
|
for (let hunk of file.hunks) {
|
|
26
|
-
let range =
|
|
70
|
+
let range = formatDisplayRange(file, hunk);
|
|
27
71
|
let counts = `(+${hunk.addedCount} -${hunk.removedCount})`;
|
|
28
72
|
let ctx = hunk.context ? ` ${hunk.context}` : "";
|
|
29
73
|
console.log(` ${hunk.id} ${range} ${counts}${ctx}`);
|
|
30
74
|
|
|
75
|
+
if (summary) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
31
79
|
// Show only change lines with their line indices
|
|
32
80
|
let changeIndex = 0;
|
|
33
81
|
for (let line of hunk.lines) {
|
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/package.json
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
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("finds line-level hunks across multiple files", () => {
|
|
60
|
+
let patch = buildPatchFromLines(
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
file: "first.txt",
|
|
64
|
+
hunks: [
|
|
65
|
+
{
|
|
66
|
+
id: 1,
|
|
67
|
+
oldStart: 1,
|
|
68
|
+
newStart: 1,
|
|
69
|
+
context: null,
|
|
70
|
+
lines: [{ type: "context", content: " keep", oldLine: 1, newLine: 1 }],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
file: "second.txt",
|
|
76
|
+
hunks: [
|
|
77
|
+
{
|
|
78
|
+
id: 2,
|
|
79
|
+
oldStart: 1,
|
|
80
|
+
newStart: 1,
|
|
81
|
+
context: null,
|
|
82
|
+
lines: [
|
|
83
|
+
{ type: "removed", content: "-old", oldLine: 1, newLine: null },
|
|
84
|
+
{ type: "added", content: "+new", oldLine: null, newLine: 1 },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
2,
|
|
91
|
+
[1],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
assert.match(patch, /diff --git a\/second\.txt b\/second\.txt/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws when line-level hunk ID is missing", () => {
|
|
98
|
+
assert.throws(
|
|
99
|
+
() =>
|
|
100
|
+
buildPatchFromLines(
|
|
101
|
+
[
|
|
102
|
+
{
|
|
103
|
+
file: "only.txt",
|
|
104
|
+
hunks: [
|
|
105
|
+
{
|
|
106
|
+
id: 1,
|
|
107
|
+
oldStart: 1,
|
|
108
|
+
newStart: 1,
|
|
109
|
+
context: null,
|
|
110
|
+
lines: [{ type: "added", content: "+x", oldLine: null, newLine: 1 }],
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
999,
|
|
116
|
+
[1],
|
|
117
|
+
),
|
|
118
|
+
/Hunk 999 not found/,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns the current git root", () => {
|
|
123
|
+
let root = getRoot();
|
|
124
|
+
assert.equal(root, process.cwd());
|
|
125
|
+
});
|
|
126
|
+
});
|
package/test/integration.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
@@ -23,6 +23,31 @@ function gp(args, opts = {}) {
|
|
|
23
23
|
}).trim();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function gpResult(args, opts = {}) {
|
|
27
|
+
try {
|
|
28
|
+
let stdout = execSync(`node ${bin} ${args}`, {
|
|
29
|
+
cwd: tmp,
|
|
30
|
+
encoding: "utf-8",
|
|
31
|
+
env: {
|
|
32
|
+
...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",
|
|
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
52
|
return execSync(`git ${args}`, { cwd: tmp, encoding: "utf-8" }).trim();
|
|
28
53
|
}
|
|
@@ -86,8 +111,8 @@ describe("git-patch integration", () => {
|
|
|
86
111
|
|
|
87
112
|
// Reset to clean state before each test
|
|
88
113
|
beforeEach(() => {
|
|
89
|
-
git("
|
|
90
|
-
git("
|
|
114
|
+
git("reset --hard HEAD");
|
|
115
|
+
git("clean -fd");
|
|
91
116
|
});
|
|
92
117
|
|
|
93
118
|
describe("list", () => {
|
|
@@ -119,6 +144,30 @@ describe("git-patch integration", () => {
|
|
|
119
144
|
assert.match(out, /-.*Hello/);
|
|
120
145
|
});
|
|
121
146
|
|
|
147
|
+
it("lists only hunk headers with --summary", () => {
|
|
148
|
+
writeFile(
|
|
149
|
+
"src/app.js",
|
|
150
|
+
[
|
|
151
|
+
"function greet(name) {",
|
|
152
|
+
' return "Hi, " + name;',
|
|
153
|
+
"}",
|
|
154
|
+
"",
|
|
155
|
+
"function farewell(name) {",
|
|
156
|
+
' return "Goodbye, " + name;',
|
|
157
|
+
"}",
|
|
158
|
+
"",
|
|
159
|
+
"module.exports = { greet, farewell };",
|
|
160
|
+
"",
|
|
161
|
+
].join("\n"),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
let out = gp("list --summary");
|
|
165
|
+
assert.match(out, /Unstaged changes/);
|
|
166
|
+
assert.match(out, /src\/app\.js/);
|
|
167
|
+
assert.doesNotMatch(out, /return "Hi, "/);
|
|
168
|
+
assert.doesNotMatch(out, /return "Hello, "/);
|
|
169
|
+
});
|
|
170
|
+
|
|
122
171
|
it("outputs valid JSON with --json", () => {
|
|
123
172
|
writeFile(
|
|
124
173
|
"src/utils.js",
|
|
@@ -144,6 +193,35 @@ describe("git-patch integration", () => {
|
|
|
144
193
|
assert.equal(out.files[0].hunks[0].id, 1);
|
|
145
194
|
});
|
|
146
195
|
|
|
196
|
+
it("outputs hunk summaries with --json --summary", () => {
|
|
197
|
+
writeFile(
|
|
198
|
+
"src/app.js",
|
|
199
|
+
[
|
|
200
|
+
"function greet(name) {",
|
|
201
|
+
' return "Hi, " + name;',
|
|
202
|
+
"}",
|
|
203
|
+
"",
|
|
204
|
+
"function farewell(name) {",
|
|
205
|
+
' return "Goodbye, " + name;',
|
|
206
|
+
"}",
|
|
207
|
+
"",
|
|
208
|
+
"module.exports = { greet, farewell };",
|
|
209
|
+
"",
|
|
210
|
+
].join("\n"),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
let out = JSON.parse(gp("list --json --summary"));
|
|
214
|
+
assert.equal(out.type, "unstaged");
|
|
215
|
+
assert.equal(out.hunks.length, 1);
|
|
216
|
+
assert.equal(out.hunks[0].id, 1);
|
|
217
|
+
assert.equal(out.hunks[0].file, "src/app.js");
|
|
218
|
+
assert.equal(out.hunks[0].addedCount, 1);
|
|
219
|
+
assert.equal(out.hunks[0].removedCount, 1);
|
|
220
|
+
assert.ok(out.hunks[0].range.startsWith("src/app.js:"));
|
|
221
|
+
assert.equal(out.hunks[0].oldRange.start, 1);
|
|
222
|
+
assert.equal(out.hunks[0].newRange.start, 1);
|
|
223
|
+
});
|
|
224
|
+
|
|
147
225
|
it("lists staged hunks with --staged", () => {
|
|
148
226
|
writeFile(
|
|
149
227
|
"src/app.js",
|
|
@@ -204,9 +282,181 @@ describe("git-patch integration", () => {
|
|
|
204
282
|
assert.equal(out.files.length, 1);
|
|
205
283
|
assert.equal(out.files[0].file, "src/utils.js");
|
|
206
284
|
});
|
|
285
|
+
|
|
286
|
+
it("summarizes a newly added file with zero old range", () => {
|
|
287
|
+
writeFile("src/new-summary.js", "module.exports = 1;\n");
|
|
288
|
+
git("add src/new-summary.js");
|
|
289
|
+
|
|
290
|
+
let out = JSON.parse(gp("list --staged --json --summary"));
|
|
291
|
+
let hunk = out.hunks.find((h) => h.file === "src/new-summary.js");
|
|
292
|
+
assert.ok(hunk);
|
|
293
|
+
assert.equal(hunk.oldRange.count, 0);
|
|
294
|
+
assert.equal(hunk.oldRange.end, 0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("summarizes a deleted file with zero new range", () => {
|
|
298
|
+
rmSync(join(tmp, "src/utils.js"));
|
|
299
|
+
|
|
300
|
+
let out = JSON.parse(gp("list --json --summary"));
|
|
301
|
+
let hunk = out.hunks.find((h) => h.file === "src/utils.js");
|
|
302
|
+
assert.ok(hunk);
|
|
303
|
+
assert.equal(hunk.newRange.count, 0);
|
|
304
|
+
assert.equal(hunk.newRange.end, 0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("prints hunk context when git provides it", () => {
|
|
308
|
+
let fakeBin = join(tmp, "fake-bin");
|
|
309
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
310
|
+
|
|
311
|
+
let fakeGit = join(fakeBin, "git");
|
|
312
|
+
writeFileSync(
|
|
313
|
+
fakeGit,
|
|
314
|
+
[
|
|
315
|
+
"#!/bin/sh",
|
|
316
|
+
'if [ \"$1\" = \"diff\" ]; then',
|
|
317
|
+
"cat <<'EOF'",
|
|
318
|
+
"diff --git a/demo.js b/demo.js",
|
|
319
|
+
"index 1111111..2222222 100644",
|
|
320
|
+
"--- a/demo.js",
|
|
321
|
+
"+++ b/demo.js",
|
|
322
|
+
"@@ -1 +1 @@ function demo()",
|
|
323
|
+
"-old",
|
|
324
|
+
"+new",
|
|
325
|
+
"EOF",
|
|
326
|
+
"exit 0",
|
|
327
|
+
"fi",
|
|
328
|
+
'echo \"unsupported command\" >&2',
|
|
329
|
+
"exit 1",
|
|
330
|
+
"",
|
|
331
|
+
].join("\n"),
|
|
332
|
+
);
|
|
333
|
+
chmodSync(fakeGit, 0o755);
|
|
334
|
+
|
|
335
|
+
let out = gp("list", {
|
|
336
|
+
env: {
|
|
337
|
+
...process.env,
|
|
338
|
+
PATH: `${fakeBin}:${process.env.PATH}`,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
assert.match(out, /function demo\(\)/);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe("cli", () => {
|
|
346
|
+
it("shows usage for --help", () => {
|
|
347
|
+
let out = gp("--help");
|
|
348
|
+
assert.match(out, /Usage:/);
|
|
349
|
+
assert.match(out, /Selectors:/);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("fails with usage for unknown command", () => {
|
|
353
|
+
let result = gpResult("nope");
|
|
354
|
+
assert.equal(result.status, 1);
|
|
355
|
+
assert.match(result.stderr, /Unknown command: nope/);
|
|
356
|
+
assert.match(result.stdout, /Usage:/);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("fails with parse error for unknown flags", () => {
|
|
360
|
+
let result = gpResult("list --wat");
|
|
361
|
+
assert.equal(result.status, 1);
|
|
362
|
+
assert.match(result.stderr, /Unknown option '--wat'/);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("fails when selector is missing", () => {
|
|
366
|
+
writeFile(
|
|
367
|
+
"src/app.js",
|
|
368
|
+
[
|
|
369
|
+
"function greet(name) {",
|
|
370
|
+
' return "Hi, " + name;',
|
|
371
|
+
"}",
|
|
372
|
+
"",
|
|
373
|
+
"function farewell(name) {",
|
|
374
|
+
' return "Goodbye, " + name;',
|
|
375
|
+
"}",
|
|
376
|
+
"",
|
|
377
|
+
"module.exports = { greet, farewell };",
|
|
378
|
+
"",
|
|
379
|
+
].join("\n"),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
let result = gpResult("stage");
|
|
383
|
+
assert.equal(result.status, 1);
|
|
384
|
+
assert.match(result.stderr, /No selector provided/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("fails on invalid selector", () => {
|
|
388
|
+
writeFile(
|
|
389
|
+
"src/app.js",
|
|
390
|
+
[
|
|
391
|
+
"function greet(name) {",
|
|
392
|
+
' return "Hi, " + name;',
|
|
393
|
+
"}",
|
|
394
|
+
"",
|
|
395
|
+
"function farewell(name) {",
|
|
396
|
+
' return "Goodbye, " + name;',
|
|
397
|
+
"}",
|
|
398
|
+
"",
|
|
399
|
+
"module.exports = { greet, farewell };",
|
|
400
|
+
"",
|
|
401
|
+
].join("\n"),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
let result = gpResult("stage abc");
|
|
405
|
+
assert.equal(result.status, 1);
|
|
406
|
+
assert.match(result.stderr, /Invalid number: abc/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("fails on invalid line-level hunk ID", () => {
|
|
410
|
+
writeFile(
|
|
411
|
+
"src/app.js",
|
|
412
|
+
[
|
|
413
|
+
"function greet(name) {",
|
|
414
|
+
' return "Hi, " + name;',
|
|
415
|
+
"}",
|
|
416
|
+
"",
|
|
417
|
+
"function farewell(name) {",
|
|
418
|
+
' return "Goodbye, " + name;',
|
|
419
|
+
"}",
|
|
420
|
+
"",
|
|
421
|
+
"module.exports = { greet, farewell };",
|
|
422
|
+
"",
|
|
423
|
+
].join("\n"),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
let result = gpResult("stage x:1");
|
|
427
|
+
assert.equal(result.status, 1);
|
|
428
|
+
assert.match(result.stderr, /Invalid hunk ID: x/);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("fails on invalid selector range", () => {
|
|
432
|
+
writeFile(
|
|
433
|
+
"src/app.js",
|
|
434
|
+
[
|
|
435
|
+
"function greet(name) {",
|
|
436
|
+
' return "Hi, " + name;',
|
|
437
|
+
"}",
|
|
438
|
+
"",
|
|
439
|
+
"function farewell(name) {",
|
|
440
|
+
' return "Goodbye, " + name;',
|
|
441
|
+
"}",
|
|
442
|
+
"",
|
|
443
|
+
"module.exports = { greet, farewell };",
|
|
444
|
+
"",
|
|
445
|
+
].join("\n"),
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
let result = gpResult("stage 1-a");
|
|
449
|
+
assert.equal(result.status, 1);
|
|
450
|
+
assert.match(result.stderr, /Invalid range: 1-a/);
|
|
451
|
+
});
|
|
207
452
|
});
|
|
208
453
|
|
|
209
454
|
describe("stage", () => {
|
|
455
|
+
it("reports when there are no unstaged changes", () => {
|
|
456
|
+
let out = gp("stage --all");
|
|
457
|
+
assert.equal(out, "No unstaged changes to stage.");
|
|
458
|
+
});
|
|
459
|
+
|
|
210
460
|
it("stages a single hunk by ID", () => {
|
|
211
461
|
// Use two separate files so each gets its own hunk
|
|
212
462
|
writeFile(
|
|
@@ -379,9 +629,81 @@ describe("git-patch integration", () => {
|
|
|
379
629
|
let content = staged.files[0].hunks[0].lines.map((l) => l.content).join("\n");
|
|
380
630
|
assert.match(content, /See ya/);
|
|
381
631
|
});
|
|
632
|
+
|
|
633
|
+
it("reports when --matching finds nothing", () => {
|
|
634
|
+
writeFile(
|
|
635
|
+
"src/app.js",
|
|
636
|
+
[
|
|
637
|
+
"function greet(name) {",
|
|
638
|
+
' return "Hi, " + name;',
|
|
639
|
+
"}",
|
|
640
|
+
"",
|
|
641
|
+
"function farewell(name) {",
|
|
642
|
+
' return "See ya, " + name;',
|
|
643
|
+
"}",
|
|
644
|
+
"",
|
|
645
|
+
"module.exports = { greet, farewell };",
|
|
646
|
+
"",
|
|
647
|
+
].join("\n"),
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
let out = gp('stage --matching "definitely-no-match"');
|
|
651
|
+
assert.equal(out, "No hunks matching /definitely-no-match/.");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("parses no-newline markers without crashing", () => {
|
|
655
|
+
writeFile(
|
|
656
|
+
"src/app.js",
|
|
657
|
+
[
|
|
658
|
+
"function greet(name) {",
|
|
659
|
+
' return "Hello, " + name;',
|
|
660
|
+
"}",
|
|
661
|
+
"",
|
|
662
|
+
"function farewell(name) {",
|
|
663
|
+
' return "Goodbye, " + name;',
|
|
664
|
+
"}",
|
|
665
|
+
"",
|
|
666
|
+
"module.exports = { greet, farewell };",
|
|
667
|
+
].join("\n"),
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
writeFile(
|
|
671
|
+
"src/app.js",
|
|
672
|
+
[
|
|
673
|
+
"function greet(name) {",
|
|
674
|
+
' return "Hi, " + name;',
|
|
675
|
+
"}",
|
|
676
|
+
"",
|
|
677
|
+
"function farewell(name) {",
|
|
678
|
+
' return "Goodbye, " + name;',
|
|
679
|
+
"}",
|
|
680
|
+
"",
|
|
681
|
+
"module.exports = { greet, farewell };",
|
|
682
|
+
].join("\n"),
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
gp("stage 1");
|
|
686
|
+
let staged = gp("list --staged");
|
|
687
|
+
assert.match(staged, /src\/app\.js/);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("stages file deletions without creating dev/null in index", () => {
|
|
691
|
+
rmSync(join(tmp, "src/utils.js"));
|
|
692
|
+
|
|
693
|
+
gp("stage --all");
|
|
694
|
+
|
|
695
|
+
let stagedNames = git("diff --cached --name-status");
|
|
696
|
+
assert.equal(stagedNames, "D\tsrc/utils.js");
|
|
697
|
+
assert.doesNotMatch(stagedNames, /dev\/null/);
|
|
698
|
+
});
|
|
382
699
|
});
|
|
383
700
|
|
|
384
701
|
describe("unstage", () => {
|
|
702
|
+
it("reports when there are no staged changes", () => {
|
|
703
|
+
let out = gp("unstage --all");
|
|
704
|
+
assert.equal(out, "No staged changes to unstage.");
|
|
705
|
+
});
|
|
706
|
+
|
|
385
707
|
it("unstages a single hunk", () => {
|
|
386
708
|
writeFile(
|
|
387
709
|
"src/app.js",
|
|
@@ -432,6 +754,90 @@ describe("git-patch integration", () => {
|
|
|
432
754
|
let staged = gp("list --staged");
|
|
433
755
|
assert.equal(staged, "No staged changes.");
|
|
434
756
|
});
|
|
757
|
+
|
|
758
|
+
it("unstages hunks matching a regex", () => {
|
|
759
|
+
writeFile(
|
|
760
|
+
"src/app.js",
|
|
761
|
+
[
|
|
762
|
+
"function greet(name) {",
|
|
763
|
+
' return "Hi, " + name;',
|
|
764
|
+
"}",
|
|
765
|
+
"",
|
|
766
|
+
"function farewell(name) {",
|
|
767
|
+
' return "See ya, " + name;',
|
|
768
|
+
"}",
|
|
769
|
+
"",
|
|
770
|
+
"module.exports = { greet, farewell };",
|
|
771
|
+
"",
|
|
772
|
+
].join("\n"),
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
gp("stage --all");
|
|
776
|
+
let out = gp('unstage --matching "See ya"');
|
|
777
|
+
assert.equal(out, "Unstaging 1 hunk(s) matching /See ya/");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("reports when unstage --matching finds nothing", () => {
|
|
781
|
+
writeFile(
|
|
782
|
+
"src/app.js",
|
|
783
|
+
[
|
|
784
|
+
"function greet(name) {",
|
|
785
|
+
' return "Hi, " + name;',
|
|
786
|
+
"}",
|
|
787
|
+
"",
|
|
788
|
+
"function farewell(name) {",
|
|
789
|
+
' return "See ya, " + name;',
|
|
790
|
+
"}",
|
|
791
|
+
"",
|
|
792
|
+
"module.exports = { greet, farewell };",
|
|
793
|
+
"",
|
|
794
|
+
].join("\n"),
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
gp("stage --all");
|
|
798
|
+
let out = gp('unstage --matching "definitely-no-match"');
|
|
799
|
+
assert.equal(out, "No staged hunks matching /definitely-no-match/.");
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it("unstages specific lines within a hunk", () => {
|
|
803
|
+
writeFile(
|
|
804
|
+
"src/app.js",
|
|
805
|
+
[
|
|
806
|
+
"function greet(name) {",
|
|
807
|
+
' return "Hi, " + name;',
|
|
808
|
+
"}",
|
|
809
|
+
"",
|
|
810
|
+
"function farewell(name) {",
|
|
811
|
+
' return "See ya, " + name;',
|
|
812
|
+
"}",
|
|
813
|
+
"",
|
|
814
|
+
"module.exports = { greet, farewell };",
|
|
815
|
+
"",
|
|
816
|
+
].join("\n"),
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
gp("stage 1:2");
|
|
820
|
+
let out = gp("unstage 1:1");
|
|
821
|
+
assert.equal(out, "Unstaged successfully.");
|
|
822
|
+
|
|
823
|
+
let staged = gp("list --staged");
|
|
824
|
+
assert.equal(staged, "No staged changes.");
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("unstages newly-added files without creating dev/null in index", () => {
|
|
828
|
+
writeFile("src/new-file.js", 'module.exports = "new";\n');
|
|
829
|
+
git("add src/new-file.js");
|
|
830
|
+
|
|
831
|
+
gp("unstage --all");
|
|
832
|
+
|
|
833
|
+
let stagedNames = git("diff --cached --name-status");
|
|
834
|
+
let porcelain = git("status --porcelain");
|
|
835
|
+
assert.equal(stagedNames, "");
|
|
836
|
+
assert.match(porcelain, /\?\? src\/new-file\.js/);
|
|
837
|
+
assert.doesNotMatch(porcelain, /dev\/null/);
|
|
838
|
+
|
|
839
|
+
rmSync(join(tmp, "src/new-file.js"), { force: true });
|
|
840
|
+
});
|
|
435
841
|
});
|
|
436
842
|
|
|
437
843
|
describe("line-level selection", () => {
|
|
@@ -567,6 +973,99 @@ describe("git-patch integration", () => {
|
|
|
567
973
|
assert.match(content, /Hello/);
|
|
568
974
|
assert.doesNotMatch(content, /Hi/);
|
|
569
975
|
});
|
|
976
|
+
|
|
977
|
+
it("reports when there is nothing to discard", () => {
|
|
978
|
+
let out = gp("discard --all --yes");
|
|
979
|
+
assert.equal(out, "No unstaged changes to discard.");
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it("discards all changes with --all --yes", () => {
|
|
983
|
+
writeFile(
|
|
984
|
+
"src/app.js",
|
|
985
|
+
[
|
|
986
|
+
"function greet(name) {",
|
|
987
|
+
' return "Hi, " + name;',
|
|
988
|
+
"}",
|
|
989
|
+
"",
|
|
990
|
+
"function farewell(name) {",
|
|
991
|
+
' return "See ya, " + name;',
|
|
992
|
+
"}",
|
|
993
|
+
"",
|
|
994
|
+
"module.exports = { greet, farewell };",
|
|
995
|
+
"",
|
|
996
|
+
].join("\n"),
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
gp("discard --all --yes");
|
|
1000
|
+
let out = gp("list");
|
|
1001
|
+
assert.equal(out, "No unstaged changes.");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("discards hunks matching a regex", () => {
|
|
1005
|
+
writeFile(
|
|
1006
|
+
"src/app.js",
|
|
1007
|
+
[
|
|
1008
|
+
"function greet(name) {",
|
|
1009
|
+
' return "Hello, " + name;',
|
|
1010
|
+
"}",
|
|
1011
|
+
"",
|
|
1012
|
+
"function farewell(name) {",
|
|
1013
|
+
' return "See ya, " + name;',
|
|
1014
|
+
"}",
|
|
1015
|
+
"",
|
|
1016
|
+
"module.exports = { greet, farewell };",
|
|
1017
|
+
"",
|
|
1018
|
+
].join("\n"),
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
gp('discard --matching "See ya" --yes');
|
|
1022
|
+
let content = readFile("src/app.js");
|
|
1023
|
+
assert.match(content, /Goodbye/);
|
|
1024
|
+
assert.match(content, /Hello/);
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("reports when discard --matching finds nothing", () => {
|
|
1028
|
+
writeFile(
|
|
1029
|
+
"src/app.js",
|
|
1030
|
+
[
|
|
1031
|
+
"function greet(name) {",
|
|
1032
|
+
' return "Hi, " + name;',
|
|
1033
|
+
"}",
|
|
1034
|
+
"",
|
|
1035
|
+
"function farewell(name) {",
|
|
1036
|
+
' return "See ya, " + name;',
|
|
1037
|
+
"}",
|
|
1038
|
+
"",
|
|
1039
|
+
"module.exports = { greet, farewell };",
|
|
1040
|
+
"",
|
|
1041
|
+
].join("\n"),
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
let out = gp('discard --matching "definitely-no-match" --yes');
|
|
1045
|
+
assert.equal(out, "No hunks matching /definitely-no-match/.");
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it("discards specific lines within a hunk", () => {
|
|
1049
|
+
writeFile(
|
|
1050
|
+
"src/app.js",
|
|
1051
|
+
[
|
|
1052
|
+
"function greet(name) {",
|
|
1053
|
+
' return "Hi, " + name;',
|
|
1054
|
+
"}",
|
|
1055
|
+
"",
|
|
1056
|
+
"function farewell(name) {",
|
|
1057
|
+
' return "See ya, " + name;',
|
|
1058
|
+
"}",
|
|
1059
|
+
"",
|
|
1060
|
+
"module.exports = { greet, farewell };",
|
|
1061
|
+
"",
|
|
1062
|
+
].join("\n"),
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
let out = gp("discard 1:2 --dry-run");
|
|
1066
|
+
assert.match(out, /Dry run/);
|
|
1067
|
+
assert.match(out, /@@/);
|
|
1068
|
+
});
|
|
570
1069
|
});
|
|
571
1070
|
|
|
572
1071
|
describe("status", () => {
|
|
@@ -634,6 +1133,12 @@ describe("git-patch integration", () => {
|
|
|
634
1133
|
assert.equal(out.unstaged.files, 1);
|
|
635
1134
|
assert.equal(out.staged.hunks, 0);
|
|
636
1135
|
});
|
|
1136
|
+
|
|
1137
|
+
it("lists untracked files in per-file output", () => {
|
|
1138
|
+
writeFile("src/untracked.js", "module.exports = 1;\n");
|
|
1139
|
+
let out = gp("status");
|
|
1140
|
+
assert.match(out, /src\/untracked\.js\s+untracked/);
|
|
1141
|
+
});
|
|
637
1142
|
});
|
|
638
1143
|
|
|
639
1144
|
describe("full workflow", () => {
|