@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.82
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 +11 -2
- package/dist/node/index.cjs +77 -38
- package/dist/node/index.d.cts +946 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +564 -51
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +77 -38
- package/dist/node/index.js.map +1 -1
- package/package.json +39 -16
- package/src/advisor/advisor-command-module.ts +47 -0
- package/src/advisor/advisor-command.test.ts +102 -0
- package/src/advisor/advisor-command.ts +55 -0
- package/src/advisor/advisor-cost-functional.test.ts +92 -0
- package/src/advisor/index.ts +6 -0
- package/src/agent/__tests__/agent-command.test.ts +39 -8
- package/src/agent/agent-command-module.ts +14 -7
- package/src/agent/agent-command-parser.ts +1 -1
- package/src/agent/agent-command.ts +3 -3
- package/src/background/__tests__/background-command-module.test.ts +59 -52
- package/src/background/background-command-module.ts +2 -1
- package/src/background/background-command.ts +3 -3
- package/src/command-module-utils.ts +33 -0
- package/src/compact/__tests__/compact-command-module.test.ts +31 -23
- package/src/compact/compact-command-module.ts +10 -1
- package/src/compact/compact-command.ts +6 -3
- package/src/context/__tests__/context-command-module.test.ts +214 -63
- package/src/context/auto-compact-format.ts +25 -0
- package/src/context/context-breakdown.ts +295 -0
- package/src/context/context-command-module.ts +45 -14
- package/src/context/context-command.ts +34 -257
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
- package/src/default/__tests__/default-command-modules.test.ts +59 -7
- package/src/default/__tests__/model-exposure.test.ts +241 -0
- package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +130 -18
- package/src/default/index.ts +4 -1
- package/src/devices/__tests__/devices-command-module.test.ts +212 -0
- package/src/devices/devices-command-module.ts +224 -0
- package/src/devices/devices-command-port.ts +82 -0
- package/src/devices/index.ts +11 -0
- package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
- package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
- package/src/doctor/__tests__/doctor-fixture.ts +145 -0
- package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
- package/src/doctor/doctor-command-module.ts +132 -0
- package/src/doctor/doctor-extensions-probe.ts +299 -0
- package/src/doctor/doctor-node-deps.ts +89 -0
- package/src/doctor/doctor-redaction.ts +99 -0
- package/src/doctor/doctor-render.ts +70 -0
- package/src/doctor/doctor-repair.ts +136 -0
- package/src/doctor/doctor-runner.ts +122 -0
- package/src/doctor/doctor-settings-probe.ts +239 -0
- package/src/doctor/doctor-storage-probe.ts +187 -0
- package/src/doctor/doctor-types.ts +109 -0
- package/src/doctor/index.ts +32 -0
- package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
- package/src/editor/editor-command-module.ts +6 -5
- package/src/editor/editor-command.ts +10 -5
- package/src/effort/effort-command-module.ts +51 -0
- package/src/effort/effort-command.test.ts +140 -0
- package/src/effort/effort-command.ts +122 -0
- package/src/effort/index.ts +6 -0
- package/src/exit/__tests__/exit-command-module.test.ts +16 -9
- package/src/exit/exit-command-module.ts +2 -1
- package/src/exit/exit-command.ts +6 -6
- package/src/fork/__tests__/fork-command.test.ts +184 -0
- package/src/fork/fork-command-module.ts +61 -0
- package/src/fork/fork-command.ts +162 -0
- package/src/fork/index.ts +8 -0
- package/src/git/__tests__/fake-git-port.ts +28 -0
- package/src/git/__tests__/git-command-module.test.ts +103 -0
- package/src/git/__tests__/git-commit.test.ts +185 -0
- package/src/git/__tests__/git-diff.test.ts +125 -0
- package/src/git/__tests__/git-process.test.ts +221 -0
- package/src/git/__tests__/git-status.test.ts +117 -0
- package/src/git/git-command-module.ts +139 -0
- package/src/git/git-commit.ts +251 -0
- package/src/git/git-diff.ts +129 -0
- package/src/git/git-process.ts +169 -0
- package/src/git/git-status.ts +134 -0
- package/src/git/index.ts +17 -0
- package/src/goal/__tests__/goal-command.test.ts +9 -4
- package/src/goal/goal-command-module.ts +2 -1
- package/src/goal/goal-command.ts +6 -9
- package/src/handoff/__tests__/handoff-command-module.test.ts +24 -0
- package/src/handoff/__tests__/handoff-command.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +49 -0
- package/src/handoff/handoff-command.ts +187 -0
- package/src/handoff/index.ts +6 -0
- package/src/help/__tests__/help-command-module.test.ts +63 -40
- package/src/help/__tests__/help-command.test.ts +46 -37
- package/src/help/help-command-module.ts +2 -1
- package/src/help/help-command.ts +3 -3
- package/src/index.ts +124 -2
- package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
- package/src/keybindings/index.ts +6 -0
- package/src/keybindings/keybindings-command-module.ts +74 -0
- package/src/language/__tests__/language-command-module.test.ts +33 -28
- package/src/language/language-command-module.ts +2 -1
- package/src/language/language-command.ts +8 -6
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
- package/src/mcp-activation/index.ts +12 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
- package/src/mcp-activation/mcp-activation-command.ts +448 -0
- package/src/mcp-activation/mcp-model-notice.ts +85 -0
- package/src/mcp-activation/mcp-model-status.ts +99 -0
- package/src/memory/__tests__/memory-command-module.test.ts +167 -74
- package/src/memory/memory-command-module.ts +18 -1
- package/src/memory/memory-command.ts +38 -38
- package/src/mode/__tests__/mode-command-module.test.ts +51 -38
- package/src/mode/mode-command-module.ts +2 -1
- package/src/mode/mode-command.ts +15 -7
- package/src/output-style/__tests__/output-style-command.test.ts +96 -0
- package/src/output-style/index.ts +6 -0
- package/src/output-style/output-style-command-module.ts +143 -0
- package/src/peers/__tests__/peers-command.test.ts +321 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +49 -0
- package/src/peers/peers-command.ts +191 -0
- package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
- package/src/permissions/permissions-command-module.ts +2 -1
- package/src/permissions/permissions-command.ts +46 -4
- package/src/plan/__tests__/plan-command.test.ts +129 -0
- package/src/plan/index.ts +9 -0
- package/src/plan/plan-command-module.ts +49 -0
- package/src/plan/plan-command.ts +92 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
- package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
- package/src/plugin/plugin-command-module.ts +3 -1
- package/src/plugin/plugin-command.ts +17 -16
- package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
- package/src/preset/__tests__/preset-command-module.test.ts +102 -56
- package/src/preset/preset-command-module.ts +26 -8
- package/src/preset/preset-command.ts +85 -27
- package/src/provider/__tests__/org-policy.test.ts +4 -5
- package/src/provider/__tests__/provider-command-module.test.ts +11 -14
- package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
- package/src/provider/__tests__/scripted-interaction.ts +7 -5
- package/src/provider/provider-command-execution.ts +4 -4
- package/src/provider/provider-command-module.ts +2 -1
- package/src/provider/provider-command-profile-lifecycle.ts +4 -4
- package/src/provider/provider-command-profile-operations.ts +3 -3
- package/src/provider/provider-command-profile.ts +1 -1
- package/src/provider/provider-command-setup.ts +4 -4
- package/src/provider/provider-startup.ts +62 -35
- package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
- package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
- package/src/remote-control/index.ts +8 -0
- package/src/remote-control/remote-control-command-module.ts +61 -0
- package/src/remote-control/remote-control-command.ts +138 -0
- package/src/reset/__tests__/reset-command-module.test.ts +5 -5
- package/src/reset/reset-command-module.ts +2 -1
- package/src/reset/reset-command.ts +3 -4
- package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
- package/src/rewind/rewind-command-module.ts +2 -1
- package/src/rewind/rewind-command.ts +73 -6
- package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/sandbox-command-module.ts +56 -0
- package/src/sandbox/sandbox-command.ts +94 -0
- package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
- package/src/schedule/__tests__/loop-command.test.ts +360 -0
- package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
- package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
- package/src/schedule/__tests__/schedule-command.test.ts +143 -3
- package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
- package/src/schedule/index.ts +3 -0
- package/src/schedule/loop-command.ts +333 -0
- package/src/schedule/loop-jitter.ts +41 -0
- package/src/schedule/schedule-command-module.ts +92 -14
- package/src/schedule/schedule-command.ts +98 -9
- package/src/schedule/schedule-spec-parser.ts +5 -1
- package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
- package/src/session/__tests__/session-command-module.test.ts +210 -45
- package/src/session/session-command-module.ts +68 -37
- package/src/session/session-command.ts +147 -80
- package/src/settings/settings-command-module.ts +3 -2
- package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
- package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
- package/src/shell/resolve-shell.ts +2 -2
- package/src/shell/shell-command-module.ts +6 -5
- package/src/shell/shell-command.ts +9 -5
- package/src/skills/__tests__/skills-command-module.test.ts +69 -28
- package/src/skills/index.ts +1 -0
- package/src/skills/skills-command-module.ts +22 -5
- package/src/skills/skills-command.ts +14 -13
- package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
- package/src/statusline/statusline-command-module.ts +2 -1
- package/src/statusline/statusline-command.ts +4 -4
- package/src/theme/__tests__/theme-command.test.ts +204 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/theme-command-module.ts +211 -0
- package/src/user-local/__tests__/user-local-command.test.ts +145 -110
- package/src/user-local/user-local-command-constants.ts +1 -1
- package/src/user-local/user-local-command-module.ts +6 -5
- package/src/user-local/user-local-command.ts +12 -6
- package/src/user-local/user-local-memory-command.ts +17 -7
- package/src/plugins/default-plugin-command-adapter.ts +0 -164
- package/src/plugins/default-plugin-command-source-loader.ts +0 -31
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** BEHAVIOR-2437 TC-03 — `/git commit`: every branch of the staged-check → subject → confirm flow. */
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
executeGitCommit,
|
|
6
|
+
normalizeCommitSubject,
|
|
7
|
+
validateConventionalSubject,
|
|
8
|
+
} from '../git-commit.js';
|
|
9
|
+
|
|
10
|
+
import { exited, fakeGitPort } from './fake-git-port.js';
|
|
11
|
+
|
|
12
|
+
import type { IActionRequest, IUserInteraction, TActionResponse } from '@robota-sdk/agent-core';
|
|
13
|
+
|
|
14
|
+
const STAGED = 'diff --cached --quiet';
|
|
15
|
+
const LISTING = 'diff --cached --name-status';
|
|
16
|
+
const STATUS = 'status --porcelain=v2 -z --branch';
|
|
17
|
+
const H = '0000000000000000000000000000000000000000';
|
|
18
|
+
const NOTHING_STAGED_STATUS =
|
|
19
|
+
`# branch.head main\0` +
|
|
20
|
+
`1 .M N... 100644 100644 100644 ${H} ${H} notes.txt\0` +
|
|
21
|
+
`? scratch.log\0`;
|
|
22
|
+
|
|
23
|
+
function scriptedUi(
|
|
24
|
+
answer: (request: IActionRequest) => TActionResponse,
|
|
25
|
+
): IUserInteraction & { readonly asked: IActionRequest[] } {
|
|
26
|
+
const asked: IActionRequest[] = [];
|
|
27
|
+
return {
|
|
28
|
+
asked,
|
|
29
|
+
async ask(request) {
|
|
30
|
+
asked.push(request);
|
|
31
|
+
return answer(request);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const yes = (): TActionResponse => ({ type: 'answer', values: ['yes'] });
|
|
36
|
+
const no = (): TActionResponse => ({ type: 'answer', values: ['no'] });
|
|
37
|
+
|
|
38
|
+
describe('validateConventionalSubject', () => {
|
|
39
|
+
it('accepts the MUST forms and only warns on the conventions', () => {
|
|
40
|
+
expect(validateConventionalSubject('feat!: x')).toEqual({ ok: true, warnings: [] });
|
|
41
|
+
expect(validateConventionalSubject('fix(scope): y')).toEqual({ ok: true, warnings: [] });
|
|
42
|
+
expect(validateConventionalSubject('feat(a-b)!: z')).toEqual({ ok: true, warnings: [] });
|
|
43
|
+
const long = validateConventionalSubject(`feat: ${'x'.repeat(80)}`);
|
|
44
|
+
expect(long.ok).toBe(true);
|
|
45
|
+
if (long.ok) expect(long.warnings[0]).toContain('72');
|
|
46
|
+
const odd = validateConventionalSubject('wip: thing.');
|
|
47
|
+
expect(odd.ok).toBe(true);
|
|
48
|
+
if (odd.ok) {
|
|
49
|
+
expect(odd.warnings.some((w) => w.includes('"wip"'))).toBe(true);
|
|
50
|
+
expect(odd.warnings.some((w) => w.includes('period'))).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('refuses a MUST-rule violation naming the rule', () => {
|
|
55
|
+
const noSeparator = validateConventionalSubject('add greeting');
|
|
56
|
+
expect(noSeparator).toMatchObject({ ok: false });
|
|
57
|
+
if (!noSeparator.ok) expect(noSeparator.reason).toContain('`: `');
|
|
58
|
+
expect(validateConventionalSubject('feat: ')).toMatchObject({ ok: false });
|
|
59
|
+
expect(validateConventionalSubject('feat:')).toMatchObject({ ok: false });
|
|
60
|
+
expect(validateConventionalSubject('feat scope: x')).toMatchObject({ ok: false });
|
|
61
|
+
expect(validateConventionalSubject('(scope): x')).toMatchObject({ ok: false });
|
|
62
|
+
expect(validateConventionalSubject('')).toMatchObject({ ok: false });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('normalizeCommitSubject', () => {
|
|
67
|
+
it('strips a leading -m and ONE matching pair of outer quotes', () => {
|
|
68
|
+
expect(normalizeCommitSubject('feat: x')).toBe('feat: x');
|
|
69
|
+
expect(normalizeCommitSubject('-m feat: x')).toBe('feat: x');
|
|
70
|
+
expect(normalizeCommitSubject('"feat: x"')).toBe('feat: x');
|
|
71
|
+
expect(normalizeCommitSubject("'feat: x'")).toBe('feat: x');
|
|
72
|
+
expect(normalizeCommitSubject('""feat: x""')).toBe('"feat: x"');
|
|
73
|
+
expect(normalizeCommitSubject('"feat: x')).toBe('"feat: x');
|
|
74
|
+
expect(normalizeCommitSubject('-m')).toBe('');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('executeGitCommit', () => {
|
|
79
|
+
it('nothing staged → guidance with the counts, no commit call, no dialog', async () => {
|
|
80
|
+
const port = fakeGitPort({ [STAGED]: exited('', 0), [STATUS]: exited(NOTHING_STAGED_STATUS) });
|
|
81
|
+
const ui = scriptedUi(yes);
|
|
82
|
+
const result = await executeGitCommit(port, '/r', 'feat: nothing to commit', ui);
|
|
83
|
+
expect(result.success).toBe(false);
|
|
84
|
+
expect(result.message).toContain('Nothing is staged (1 unstaged, 1 untracked)');
|
|
85
|
+
expect(result.message).toContain('git add');
|
|
86
|
+
expect(result.message).toContain('/shell git add');
|
|
87
|
+
expect(ui.asked).toEqual([]);
|
|
88
|
+
expect(port.calls.map((c) => c[0])).not.toContain('commit');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('a MUST-rule violation is refused before any listing or dialog', async () => {
|
|
92
|
+
const port = fakeGitPort({ [STAGED]: exited('', 1) });
|
|
93
|
+
const ui = scriptedUi(yes);
|
|
94
|
+
const result = await executeGitCommit(port, '/r', 'add greeting', ui);
|
|
95
|
+
expect(result.success).toBe(false);
|
|
96
|
+
expect(result.message).toContain('`: `');
|
|
97
|
+
expect(ui.asked).toEqual([]);
|
|
98
|
+
expect(port.calls).toEqual([['diff', '--cached', '--quiet']]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('a refused confirmation → "Commit cancelled." and no commit call', async () => {
|
|
102
|
+
const port = fakeGitPort({ [STAGED]: exited('', 1), [LISTING]: exited('M\tgreeting.txt\n') });
|
|
103
|
+
const ui = scriptedUi(no);
|
|
104
|
+
const result = await executeGitCommit(port, '/r', 'feat: add greeting', ui);
|
|
105
|
+
expect(result.success).toBe(false);
|
|
106
|
+
expect(result.message).toBe('Commit cancelled.');
|
|
107
|
+
expect(ui.asked).toHaveLength(1);
|
|
108
|
+
expect(ui.asked[0]?.description).toContain('feat: add greeting');
|
|
109
|
+
expect(ui.asked[0]?.description).toContain('M greeting.txt');
|
|
110
|
+
expect(port.calls.map((c) => c[0])).not.toContain('commit');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('an absent IUserInteraction is a cancellation, never a commit', async () => {
|
|
114
|
+
const port = fakeGitPort({ [STAGED]: exited('', 1), [LISTING]: exited('M\tgreeting.txt\n') });
|
|
115
|
+
const result = await executeGitCommit(port, '/r', 'feat: add greeting', undefined);
|
|
116
|
+
expect(result.success).toBe(false);
|
|
117
|
+
expect(result.message).toContain('cancelled');
|
|
118
|
+
expect(result.message).toContain('confirmation');
|
|
119
|
+
// Nothing beyond the staged check runs: no listing, no commit.
|
|
120
|
+
expect(port.calls).toEqual([['diff', '--cached', '--quiet']]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('a confirmed commit runs exactly commit -m <subject> after the listing, quotes stripped', async () => {
|
|
124
|
+
const port = fakeGitPort({
|
|
125
|
+
[STAGED]: exited('', 1),
|
|
126
|
+
[LISTING]: exited('M\tgreeting.txt\n'),
|
|
127
|
+
'commit -m feat: add greeting': exited(
|
|
128
|
+
'[main abc1234] feat: add greeting\n 1 file changed\n',
|
|
129
|
+
),
|
|
130
|
+
});
|
|
131
|
+
const ui = scriptedUi(yes);
|
|
132
|
+
const result = await executeGitCommit(port, '/r', '"feat: add greeting"', ui);
|
|
133
|
+
expect(result.success).toBe(true);
|
|
134
|
+
expect(result.message).toContain('abc1234');
|
|
135
|
+
expect(result.message).toContain('feat: add greeting');
|
|
136
|
+
expect(port.calls).toEqual([
|
|
137
|
+
['diff', '--cached', '--quiet'],
|
|
138
|
+
['diff', '--cached', '--name-status'],
|
|
139
|
+
['commit', '-m', 'feat: add greeting'],
|
|
140
|
+
]);
|
|
141
|
+
expect(port.calls.flat()).not.toContain('-a');
|
|
142
|
+
expect(port.calls.map((c) => c[0])).not.toContain('add');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('a long subject only warns in the confirmation and still commits', async () => {
|
|
146
|
+
const subject = `feat: ${'x'.repeat(80)}`;
|
|
147
|
+
const port = fakeGitPort({
|
|
148
|
+
[STAGED]: exited('', 1),
|
|
149
|
+
[LISTING]: exited('A\tnew.txt\n'),
|
|
150
|
+
[`commit -m ${subject}`]: exited(`[main 1234567] ${subject}\n`),
|
|
151
|
+
});
|
|
152
|
+
const ui = scriptedUi(yes);
|
|
153
|
+
const result = await executeGitCommit(port, '/r', subject, ui);
|
|
154
|
+
expect(result.success).toBe(true);
|
|
155
|
+
expect(ui.asked[0]?.description).toContain('Warning:');
|
|
156
|
+
expect(ui.asked[0]?.description).toContain('72');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('with no inline subject, asks for one as free text and validates the answer', async () => {
|
|
160
|
+
const port = fakeGitPort({
|
|
161
|
+
[STAGED]: exited('', 1),
|
|
162
|
+
[LISTING]: exited('A\tnew.txt\n'),
|
|
163
|
+
'commit -m fix: typo': exited('[main 1234567] fix: typo\n'),
|
|
164
|
+
});
|
|
165
|
+
const ui = scriptedUi((request) =>
|
|
166
|
+
request.id === 'git-commit-subject'
|
|
167
|
+
? { type: 'answer', values: [], text: 'fix: typo' }
|
|
168
|
+
: yes(),
|
|
169
|
+
);
|
|
170
|
+
const result = await executeGitCommit(port, '/r', '', ui);
|
|
171
|
+
expect(result.success).toBe(true);
|
|
172
|
+
expect(ui.asked.map((r) => r.id)).toEqual(['git-commit-subject', 'git-commit']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('a failing commit (hook) reports git’s stderr', async () => {
|
|
176
|
+
const port = fakeGitPort({
|
|
177
|
+
[STAGED]: exited('', 1),
|
|
178
|
+
[LISTING]: exited('A\tnew.txt\n'),
|
|
179
|
+
'commit -m feat: x': exited('', 1, 'pre-commit hook refused\n'),
|
|
180
|
+
});
|
|
181
|
+
const result = await executeGitCommit(port, '/r', 'feat: x', scriptedUi(yes));
|
|
182
|
+
expect(result.success).toBe(false);
|
|
183
|
+
expect(result.message).toContain('pre-commit hook refused');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** BEHAVIOR-2437 TC-02 — `/git diff`: the closed grammar and the exact argv sequence per branch. */
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { executeGitDiff, gitDiffArgv, parseGitDiffArgs, GIT_DIFF_USAGE } from '../git-diff.js';
|
|
5
|
+
|
|
6
|
+
import { exited, fakeGitPort } from './fake-git-port.js';
|
|
7
|
+
|
|
8
|
+
const revParse = (rev: string): string =>
|
|
9
|
+
`rev-parse --verify --quiet --end-of-options ${rev}^{commit}`;
|
|
10
|
+
|
|
11
|
+
describe('parseGitDiffArgs', () => {
|
|
12
|
+
it('accepts the four forms and the path list', () => {
|
|
13
|
+
expect(parseGitDiffArgs([])).toEqual({
|
|
14
|
+
ok: true,
|
|
15
|
+
args: { target: { kind: 'worktree' }, paths: [] },
|
|
16
|
+
});
|
|
17
|
+
expect(parseGitDiffArgs(['--staged'])).toMatchObject({
|
|
18
|
+
ok: true,
|
|
19
|
+
args: { target: { kind: 'staged' } },
|
|
20
|
+
});
|
|
21
|
+
expect(parseGitDiffArgs(['HEAD~1'])).toMatchObject({
|
|
22
|
+
ok: true,
|
|
23
|
+
args: { target: { kind: 'revision', revision: 'HEAD~1' } },
|
|
24
|
+
});
|
|
25
|
+
expect(parseGitDiffArgs(['main..feature', '--', 'p', 'q'])).toEqual({
|
|
26
|
+
ok: true,
|
|
27
|
+
args: { target: { kind: 'range', from: 'main', to: 'feature' }, paths: ['p', 'q'] },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('refuses every other dash token with the usage line', () => {
|
|
32
|
+
for (const token of ['--output=/tmp/x', '-p', '--cached', '-U3']) {
|
|
33
|
+
const parsed = parseGitDiffArgs([token]);
|
|
34
|
+
expect(parsed.ok).toBe(false);
|
|
35
|
+
if (!parsed.ok) {
|
|
36
|
+
expect(parsed.message).toContain(token);
|
|
37
|
+
expect(parsed.message).toContain(GIT_DIFF_USAGE);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('refuses a malformed range, a bare `--`, and extra positionals', () => {
|
|
43
|
+
expect(parseGitDiffArgs(['a..'])).toMatchObject({ ok: false });
|
|
44
|
+
expect(parseGitDiffArgs(['a...b'])).toMatchObject({ ok: false });
|
|
45
|
+
expect(parseGitDiffArgs(['--'])).toMatchObject({ ok: false });
|
|
46
|
+
expect(parseGitDiffArgs(['a', 'b'])).toMatchObject({ ok: false });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('gitDiffArgv', () => {
|
|
51
|
+
it('places --end-of-options before revisions and -- before paths', () => {
|
|
52
|
+
expect(gitDiffArgv({ target: { kind: 'worktree' }, paths: [] })).toEqual(['diff']);
|
|
53
|
+
expect(gitDiffArgv({ target: { kind: 'staged' }, paths: [] })).toEqual(['diff', '--staged']);
|
|
54
|
+
expect(gitDiffArgv({ target: { kind: 'revision', revision: '-p' }, paths: ['-p'] })).toEqual([
|
|
55
|
+
'diff',
|
|
56
|
+
'--end-of-options',
|
|
57
|
+
'-p',
|
|
58
|
+
'--',
|
|
59
|
+
'-p',
|
|
60
|
+
]);
|
|
61
|
+
expect(
|
|
62
|
+
gitDiffArgv({ target: { kind: 'range', from: 'a', to: 'b' }, paths: ['p', 'q'] }),
|
|
63
|
+
).toEqual(['diff', '--end-of-options', 'a..b', '--', 'p', 'q']);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('executeGitDiff', () => {
|
|
68
|
+
it('bare and --staged run one diff call each', async () => {
|
|
69
|
+
const port = fakeGitPort({ diff: exited('+x\n'), 'diff --staged': exited('') });
|
|
70
|
+
expect((await executeGitDiff(port, '/r', '')).message).toBe('+x');
|
|
71
|
+
expect((await executeGitDiff(port, '/r', '--staged')).message).toBe('No differences.');
|
|
72
|
+
expect(port.calls).toEqual([['diff'], ['diff', '--staged']]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('verifies one revision, then diffs, with a trailing path list', async () => {
|
|
76
|
+
const port = fakeGitPort({
|
|
77
|
+
[revParse('HEAD~1')]: exited('abc\n'),
|
|
78
|
+
'diff --end-of-options HEAD~1 -- p q': exited('+y\n'),
|
|
79
|
+
});
|
|
80
|
+
const result = await executeGitDiff(port, '/r', 'HEAD~1 -- p q');
|
|
81
|
+
expect(result.success).toBe(true);
|
|
82
|
+
expect(port.calls).toEqual([
|
|
83
|
+
['rev-parse', '--verify', '--quiet', '--end-of-options', 'HEAD~1^{commit}'],
|
|
84
|
+
['diff', '--end-of-options', 'HEAD~1', '--', 'p', 'q'],
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('verifies both ends of a range before one diff call', async () => {
|
|
89
|
+
const port = fakeGitPort({
|
|
90
|
+
[revParse('a')]: exited('1\n'),
|
|
91
|
+
[revParse('b')]: exited('2\n'),
|
|
92
|
+
'diff --end-of-options a..b': exited(''),
|
|
93
|
+
});
|
|
94
|
+
await executeGitDiff(port, '/r', 'a..b');
|
|
95
|
+
expect(port.calls.map((c) => c.join(' '))).toEqual([
|
|
96
|
+
revParse('a'),
|
|
97
|
+
revParse('b'),
|
|
98
|
+
'diff --end-of-options a..b',
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('refuses an unknown revision by name with no diff call', async () => {
|
|
103
|
+
const port = fakeGitPort({ [revParse('nosuchrev')]: exited('', 1) });
|
|
104
|
+
const result = await executeGitDiff(port, '/r', 'nosuchrev');
|
|
105
|
+
expect(result.success).toBe(false);
|
|
106
|
+
expect(result.message).toContain('nosuchrev');
|
|
107
|
+
expect(port.calls).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('refuses a flag with the usage line and no call at all', async () => {
|
|
111
|
+
const port = fakeGitPort({});
|
|
112
|
+
const result = await executeGitDiff(port, '/r', '--output=/tmp/x');
|
|
113
|
+
expect(result.success).toBe(false);
|
|
114
|
+
expect(result.message).toContain('--output=/tmp/x');
|
|
115
|
+
expect(result.message).toContain('--staged');
|
|
116
|
+
expect(port.calls).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('reports a diff failure with git’s stderr', async () => {
|
|
120
|
+
const port = fakeGitPort({ diff: exited('', 129, 'fatal: bad\n') });
|
|
121
|
+
const result = await executeGitDiff(port, '/r', '');
|
|
122
|
+
expect(result.success).toBe(false);
|
|
123
|
+
expect(result.message).toContain('fatal: bad');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BEHAVIOR-2437 TC-04 — the seam against a REAL temporary repository: argv-only (metacharacters are
|
|
3
|
+
* data), exit codes as data, stdin closed (a hook that reads it gets EOF), timeout / abort / missing
|
|
4
|
+
* executable / oversized output as typed failures, and the hook-nesting environment policy.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { chmodSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { createGitProcess, gitEnvironment, GIT_ENV_DENYLIST } from '../git-process.js';
|
|
14
|
+
|
|
15
|
+
const dirs: string[] = [];
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function tempDir(prefix: string): string {
|
|
21
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
22
|
+
dirs.push(dir);
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A hermetic environment: no user/system git config, no inherited hook-nesting variables. */
|
|
27
|
+
function hermeticEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
28
|
+
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: tempDir('git-home-') };
|
|
29
|
+
env.GIT_CONFIG_NOSYSTEM = '1';
|
|
30
|
+
env.GIT_CONFIG_GLOBAL = join(env.HOME ?? '', 'gitconfig');
|
|
31
|
+
writeFileSync(env.GIT_CONFIG_GLOBAL, '[commit]\n\tgpgsign = false\n');
|
|
32
|
+
return { ...env, ...extra };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function git(cwd: string, env: NodeJS.ProcessEnv, ...args: string[]): string {
|
|
36
|
+
return execFileSync('git', args, {
|
|
37
|
+
cwd,
|
|
38
|
+
env,
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function initRepo(env: NodeJS.ProcessEnv, identity = true): string {
|
|
45
|
+
const repo = tempDir('git-seam-');
|
|
46
|
+
git(repo, env, 'init', '-q', '-b', 'main');
|
|
47
|
+
if (identity) {
|
|
48
|
+
git(repo, env, 'config', 'user.name', 'Seam Test');
|
|
49
|
+
git(repo, env, 'config', 'user.email', 'seam@example.com');
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(join(repo, 'base.txt'), 'base\n');
|
|
52
|
+
git(repo, env, 'add', '--', 'base.txt');
|
|
53
|
+
if (identity) git(repo, env, 'commit', '-q', '-m', 'chore: base');
|
|
54
|
+
return repo;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function installHook(repo: string, name: string, body: string): void {
|
|
58
|
+
const hooks = join(repo, '.git', 'hooks');
|
|
59
|
+
mkdirSync(hooks, { recursive: true });
|
|
60
|
+
const path = join(hooks, name);
|
|
61
|
+
writeFileSync(path, `#!/bin/sh\n${body}\n`);
|
|
62
|
+
chmodSync(path, 0o755);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('gitEnvironment', () => {
|
|
66
|
+
it('strips the repository-redirecting variables and preserves identity and config by name', () => {
|
|
67
|
+
const source: NodeJS.ProcessEnv = {
|
|
68
|
+
PATH: '/usr/bin',
|
|
69
|
+
XDG_CONFIG_HOME: '/x',
|
|
70
|
+
GIT_CONFIG_GLOBAL: '/g',
|
|
71
|
+
GIT_AUTHOR_NAME: 'a',
|
|
72
|
+
GIT_AUTHOR_EMAIL: 'a@x',
|
|
73
|
+
GIT_COMMITTER_NAME: 'c',
|
|
74
|
+
GIT_COMMITTER_EMAIL: 'c@x',
|
|
75
|
+
EMAIL: 'e@x',
|
|
76
|
+
GPG_TTY: '/dev/tty',
|
|
77
|
+
GNUPGHOME: '/gnupg',
|
|
78
|
+
SSH_AUTH_SOCK: '/sock',
|
|
79
|
+
};
|
|
80
|
+
for (const key of GIT_ENV_DENYLIST) source[key] = `/elsewhere/${key}`;
|
|
81
|
+
const env = gitEnvironment(source);
|
|
82
|
+
for (const key of GIT_ENV_DENYLIST) expect(env).not.toHaveProperty(key);
|
|
83
|
+
for (const key of [
|
|
84
|
+
'PATH',
|
|
85
|
+
'XDG_CONFIG_HOME',
|
|
86
|
+
'GIT_CONFIG_GLOBAL',
|
|
87
|
+
'GIT_AUTHOR_NAME',
|
|
88
|
+
'GIT_AUTHOR_EMAIL',
|
|
89
|
+
'GIT_COMMITTER_NAME',
|
|
90
|
+
'GIT_COMMITTER_EMAIL',
|
|
91
|
+
'EMAIL',
|
|
92
|
+
'GPG_TTY',
|
|
93
|
+
'GNUPGHOME',
|
|
94
|
+
'SSH_AUTH_SOCK',
|
|
95
|
+
]) {
|
|
96
|
+
expect(env[key]).toBe(source[key]);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('createGitProcess against a real repository', () => {
|
|
102
|
+
it('returns exit codes as data: diff --cached --quiet is 1 when staged, 0 when not', async () => {
|
|
103
|
+
const env = hermeticEnv();
|
|
104
|
+
const repo = initRepo(env);
|
|
105
|
+
const port = createGitProcess({ env });
|
|
106
|
+
expect(await port.run(['diff', '--cached', '--quiet'], { cwd: repo })).toMatchObject({
|
|
107
|
+
kind: 'exited',
|
|
108
|
+
exitCode: 0,
|
|
109
|
+
});
|
|
110
|
+
writeFileSync(join(repo, 'new.txt'), 'new\n');
|
|
111
|
+
git(repo, env, 'add', '--', 'new.txt');
|
|
112
|
+
expect(await port.run(['diff', '--cached', '--quiet'], { cwd: repo })).toMatchObject({
|
|
113
|
+
kind: 'exited',
|
|
114
|
+
exitCode: 1,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('passes a subject and a path with shell metacharacters as ONE element each (shell: false)', async () => {
|
|
119
|
+
const env = hermeticEnv();
|
|
120
|
+
const repo = initRepo(env);
|
|
121
|
+
const port = createGitProcess({ env });
|
|
122
|
+
const path = 'a;b c$(touch pwned-path).txt';
|
|
123
|
+
const subject = 'feat: $(touch pwned) ; echo "quoted" `id` | cat > /dev/null';
|
|
124
|
+
writeFileSync(join(repo, path), 'x\n');
|
|
125
|
+
expect(await port.run(['add', '--', path], { cwd: repo })).toMatchObject({ exitCode: 0 });
|
|
126
|
+
expect(await port.run(['commit', '-m', subject], { cwd: repo })).toMatchObject({ exitCode: 0 });
|
|
127
|
+
const log = await port.run(['log', '-1', '--format=%s'], { cwd: repo });
|
|
128
|
+
expect(log).toMatchObject({ kind: 'exited', exitCode: 0, stdout: `${subject}\n` });
|
|
129
|
+
const status = await port.run(['status', '--porcelain=v2', '-z'], { cwd: repo });
|
|
130
|
+
expect(status).toMatchObject({ kind: 'exited', exitCode: 0 });
|
|
131
|
+
// Neither substitution ran: the tree holds only what git was told, and nothing named pwned.
|
|
132
|
+
if (status.kind === 'exited') expect(status.stdout).not.toContain('pwned');
|
|
133
|
+
const shown = await port.run(['show', '--name-only', '--format=', 'HEAD'], { cwd: repo });
|
|
134
|
+
if (shown.kind === 'exited') expect(shown.stdout.trim()).toBe(path);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('closes stdin: a pre-commit hook that reads it sees EOF instead of hanging', async () => {
|
|
138
|
+
const env = hermeticEnv();
|
|
139
|
+
const repo = initRepo(env);
|
|
140
|
+
installHook(repo, 'pre-commit', 'cat > /dev/null\nexit 0');
|
|
141
|
+
writeFileSync(join(repo, 'new.txt'), 'new\n');
|
|
142
|
+
git(repo, env, 'add', '--', 'new.txt');
|
|
143
|
+
const port = createGitProcess({ env });
|
|
144
|
+
const outcome = await port.run(['commit', '-m', 'feat: hooked'], {
|
|
145
|
+
cwd: repo,
|
|
146
|
+
timeoutMs: 5000,
|
|
147
|
+
});
|
|
148
|
+
expect(outcome).toMatchObject({ kind: 'exited', exitCode: 0 });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('kills a child that exceeds the timeout and reports failed: timeout', async () => {
|
|
152
|
+
const env = hermeticEnv();
|
|
153
|
+
const repo = initRepo(env);
|
|
154
|
+
installHook(repo, 'pre-commit', 'exec >/dev/null 2>&1\nsleep 5');
|
|
155
|
+
writeFileSync(join(repo, 'new.txt'), 'new\n');
|
|
156
|
+
git(repo, env, 'add', '--', 'new.txt');
|
|
157
|
+
const port = createGitProcess({ env });
|
|
158
|
+
const outcome = await port.run(['commit', '-m', 'feat: slow'], { cwd: repo, timeoutMs: 300 });
|
|
159
|
+
expect(outcome).toMatchObject({ kind: 'failed', reason: 'timeout' });
|
|
160
|
+
expect(git(repo, env, 'log', '--oneline').trim().split('\n')).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('reports an aborted signal as failed: aborted', async () => {
|
|
164
|
+
const env = hermeticEnv();
|
|
165
|
+
const repo = initRepo(env);
|
|
166
|
+
installHook(repo, 'pre-commit', 'exec >/dev/null 2>&1\nsleep 5');
|
|
167
|
+
writeFileSync(join(repo, 'new.txt'), 'new\n');
|
|
168
|
+
git(repo, env, 'add', '--', 'new.txt');
|
|
169
|
+
const port = createGitProcess({ env });
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const pending = port.run(['commit', '-m', 'feat: aborted'], {
|
|
172
|
+
cwd: repo,
|
|
173
|
+
timeoutMs: 10_000,
|
|
174
|
+
signal: controller.signal,
|
|
175
|
+
});
|
|
176
|
+
setTimeout(() => controller.abort(), 100);
|
|
177
|
+
expect(await pending).toMatchObject({ kind: 'failed', reason: 'aborted' });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('reports a missing executable as failed: not-found', async () => {
|
|
181
|
+
const env = hermeticEnv();
|
|
182
|
+
const repo = initRepo(env);
|
|
183
|
+
const port = createGitProcess({ env, executable: join(repo, 'no-such-git') });
|
|
184
|
+
const outcome = await port.run(['status'], { cwd: repo });
|
|
185
|
+
expect(outcome).toMatchObject({ kind: 'failed', reason: 'not-found' });
|
|
186
|
+
if (outcome.kind === 'failed') expect(outcome.detail).toContain('ENOENT');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('reports output past 16 MiB as failed: output-too-large', async () => {
|
|
190
|
+
const env = hermeticEnv();
|
|
191
|
+
const dir = tempDir('git-big-');
|
|
192
|
+
const fake = join(dir, 'big-git');
|
|
193
|
+
writeFileSync(fake, '#!/bin/sh\nhead -c 17000000 /dev/zero\n');
|
|
194
|
+
chmodSync(fake, 0o755);
|
|
195
|
+
const port = createGitProcess({ env, executable: fake });
|
|
196
|
+
const outcome = await port.run(['status'], { cwd: dir, timeoutMs: 20_000 });
|
|
197
|
+
expect(outcome).toMatchObject({ kind: 'failed', reason: 'output-too-large' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('a hook-exported GIT_DIR does not redirect the child; GIT_CONFIG_GLOBAL still carries identity', async () => {
|
|
201
|
+
const env = hermeticEnv();
|
|
202
|
+
const other = initRepo(env);
|
|
203
|
+
const repo = initRepo(env, false);
|
|
204
|
+
writeFileSync(
|
|
205
|
+
env.GIT_CONFIG_GLOBAL ?? '',
|
|
206
|
+
'[commit]\n\tgpgsign = false\n[user]\n\tname = Global Identity\n\temail = global@example.com\n',
|
|
207
|
+
);
|
|
208
|
+
// A hook-style environment: GIT_DIR points at ANOTHER repository.
|
|
209
|
+
const port = createGitProcess({
|
|
210
|
+
env: { ...env, GIT_DIR: join(other, '.git'), GIT_WORK_TREE: other },
|
|
211
|
+
});
|
|
212
|
+
const top = await port.run(['rev-parse', '--show-toplevel'], { cwd: repo });
|
|
213
|
+
expect(top).toMatchObject({ kind: 'exited', exitCode: 0, stdout: `${repo}\n` });
|
|
214
|
+
const commit = await port.run(['commit', '-m', 'feat: identity'], { cwd: repo });
|
|
215
|
+
expect(commit).toMatchObject({ kind: 'exited', exitCode: 0 });
|
|
216
|
+
const author = await port.run(['log', '-1', '--format=%an <%ae>'], { cwd: repo });
|
|
217
|
+
expect(author).toMatchObject({ stdout: 'Global Identity <global@example.com>\n' });
|
|
218
|
+
// The other repository is untouched: still exactly its base commit.
|
|
219
|
+
expect(git(other, env, 'log', '--oneline').trim().split('\n')).toHaveLength(1);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** BEHAVIOR-2437 TC-01 — `/git status`: one porcelain-v2 call, parsed into the four groups. */
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { executeGitStatus, formatGitStatus, parseStatusPorcelainV2 } from '../git-status.js';
|
|
5
|
+
|
|
6
|
+
import { exited, fakeGitPort } from './fake-git-port.js';
|
|
7
|
+
|
|
8
|
+
const H = '0000000000000000000000000000000000000000';
|
|
9
|
+
const rec = (parts: string[]): string => `${parts.join(' ')}\0`;
|
|
10
|
+
/** Captured from a real `git status --porcelain=v2 -z --branch` run, plus the edge paths. */
|
|
11
|
+
const FIXTURE =
|
|
12
|
+
rec(['#', 'branch.oid', H]) +
|
|
13
|
+
rec(['#', 'branch.head', 'main']) +
|
|
14
|
+
rec(['1', 'MM', 'N...', '100644', '100644', '100644', H, H, 'a.txt']) +
|
|
15
|
+
rec(['2', 'R.', 'N...', '100644', '100644', '100644', H, H, 'R100', 'new.txt']) +
|
|
16
|
+
'old.txt\0' +
|
|
17
|
+
rec(['1', '.M', 'N...', '100644', '100644', '100644', H, H, 'sp ace.txt']) +
|
|
18
|
+
rec(['1', 'A.', 'N...', '000000', '100644', '100644', H, H, 'quo"te.txt']) +
|
|
19
|
+
rec(['1', 'A.', 'N...', '000000', '100644', '100644', H, H, '한글 파일.txt']) +
|
|
20
|
+
rec(['?', 'untracked.txt']);
|
|
21
|
+
|
|
22
|
+
describe('parseStatusPorcelainV2', () => {
|
|
23
|
+
it('yields the branch and the staged, unstaged and untracked sets, one element per path', () => {
|
|
24
|
+
const summary = parseStatusPorcelainV2(FIXTURE);
|
|
25
|
+
expect(summary.branch).toBe('main');
|
|
26
|
+
expect(summary.unborn).toBe(false);
|
|
27
|
+
// The MM entry is in BOTH sets; the rename pair stays a pair; each edge path is one element.
|
|
28
|
+
expect(summary.staged).toEqual([
|
|
29
|
+
'a.txt',
|
|
30
|
+
'new.txt (from old.txt)',
|
|
31
|
+
'quo"te.txt',
|
|
32
|
+
'한글 파일.txt',
|
|
33
|
+
]);
|
|
34
|
+
expect(summary.unstaged).toEqual(['a.txt', 'sp ace.txt']);
|
|
35
|
+
expect(summary.untracked).toEqual(['untracked.txt']);
|
|
36
|
+
expect(summary.conflicted).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reads an unborn branch and a detached HEAD', () => {
|
|
40
|
+
expect(
|
|
41
|
+
parseStatusPorcelainV2(
|
|
42
|
+
rec(['#', 'branch.oid', '(initial)']) + rec(['#', 'branch.head', 'main']),
|
|
43
|
+
),
|
|
44
|
+
).toMatchObject({ branch: 'main', unborn: true });
|
|
45
|
+
expect(parseStatusPorcelainV2(rec(['#', 'branch.head', '(detached)'])).branch).toBe(
|
|
46
|
+
'(detached)',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('collects unmerged entries separately', () => {
|
|
51
|
+
const fixture = rec([
|
|
52
|
+
'u',
|
|
53
|
+
'UU',
|
|
54
|
+
'N...',
|
|
55
|
+
'100644',
|
|
56
|
+
'100644',
|
|
57
|
+
'100644',
|
|
58
|
+
'100644',
|
|
59
|
+
H,
|
|
60
|
+
H,
|
|
61
|
+
H,
|
|
62
|
+
'c.txt',
|
|
63
|
+
]);
|
|
64
|
+
expect(parseStatusPorcelainV2(fixture).conflicted).toEqual(['c.txt']);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('formatGitStatus', () => {
|
|
69
|
+
it('prints the branch and each group with its count', () => {
|
|
70
|
+
expect(formatGitStatus(parseStatusPorcelainV2(FIXTURE))).toBe(
|
|
71
|
+
[
|
|
72
|
+
'On branch main',
|
|
73
|
+
'staged (4): a.txt, new.txt (from old.txt), quo"te.txt, 한글 파일.txt',
|
|
74
|
+
'unstaged (2): a.txt, sp ace.txt',
|
|
75
|
+
'untracked (1): untracked.txt',
|
|
76
|
+
].join('\n'),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('executeGitStatus', () => {
|
|
82
|
+
it('runs exactly one porcelain-v2 call and reports the summary', async () => {
|
|
83
|
+
const port = fakeGitPort({ 'status --porcelain=v2 -z --branch': exited(FIXTURE) });
|
|
84
|
+
const result = await executeGitStatus(port, '/repo');
|
|
85
|
+
expect(port.calls).toEqual([['status', '--porcelain=v2', '-z', '--branch']]);
|
|
86
|
+
expect(result.success).toBe(true);
|
|
87
|
+
expect(result.message).toContain('On branch main');
|
|
88
|
+
expect(result.data).toMatchObject({ git: 'status', untracked: ['untracked.txt'] });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('reports git’s own reason on exit 128 and makes no second call', async () => {
|
|
92
|
+
const port = fakeGitPort({
|
|
93
|
+
'status --porcelain=v2 -z --branch': exited(
|
|
94
|
+
'',
|
|
95
|
+
128,
|
|
96
|
+
'fatal: not a git repository (or any of the parent directories): .git\n',
|
|
97
|
+
),
|
|
98
|
+
});
|
|
99
|
+
const result = await executeGitStatus(port, '/not-a-repo');
|
|
100
|
+
expect(result.success).toBe(false);
|
|
101
|
+
expect(result.message).toContain('fatal: not a git repository');
|
|
102
|
+
expect(port.calls).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('reports a spawn failure by its reason', async () => {
|
|
106
|
+
const port = fakeGitPort({
|
|
107
|
+
'status --porcelain=v2 -z --branch': {
|
|
108
|
+
kind: 'failed',
|
|
109
|
+
reason: 'not-found',
|
|
110
|
+
detail: 'git could not be run (ENOENT)',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const result = await executeGitStatus(port, '/repo');
|
|
114
|
+
expect(result.success).toBe(false);
|
|
115
|
+
expect(result.message).toContain('ENOENT');
|
|
116
|
+
});
|
|
117
|
+
});
|