@robota-sdk/agent-command 3.0.0-beta.83 → 3.0.0-beta.85
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/CHANGELOG.md +69 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +75 -72
- package/dist/node/index.d.cts +37 -45
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +37 -45
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +78 -75
- package/dist/node/index.js.map +1 -1
- package/package.json +9 -8
- package/src/agent/__tests__/agent-command.test.ts +62 -0
- package/src/agent/agent-command-parser.ts +2 -2
- package/src/agent/agent-command.ts +20 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +5 -4
- package/src/default/__tests__/model-exposure.test.ts +2 -0
- package/src/default/default-command-modules.ts +8 -0
- package/src/doctor/__tests__/doctor-fixture.ts +1 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +26 -6
- package/src/doctor/doctor-extensions-probe.ts +36 -12
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- package/src/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/git-command-module.ts +3 -4
- package/src/git/git-commit.ts +6 -3
- package/src/git/index.ts +4 -1
- package/src/goal/goal-command.ts +3 -1
- package/src/index.ts +6 -0
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +225 -1
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +77 -2
- package/src/memory/__tests__/memory-command-module.test.ts +40 -2
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/memory-command-module.ts +4 -2
- package/src/memory/memory-command.ts +42 -2
- package/src/mode/__tests__/mode-command-module.test.ts +40 -0
- package/src/mode/mode-command.ts +1 -1
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +240 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +223 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +84 -3
- package/src/plugin/plugin-command.ts +50 -8
- package/src/provider/__tests__/org-policy.test.ts +4 -0
- package/src/provider/__tests__/provider-command-module.test.ts +276 -14
- package/src/provider/__tests__/scripted-interaction.ts +12 -2
- package/src/provider/provider-command-execution.ts +54 -8
- package/src/provider/provider-command-profile-lifecycle.ts +48 -2
- package/src/provider/provider-command-profile-operations.ts +14 -0
- package/src/provider/provider-command-profile.ts +7 -4
- package/src/provider/provider-command-setup.ts +14 -1
- package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/loop-command.ts +95 -3
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/git/__tests__/git-diff.test.ts +0 -125
- package/src/git/__tests__/git-process.test.ts +0 -221
- package/src/git/__tests__/git-status.test.ts +0 -117
- package/src/git/git-diff.ts +0 -129
- package/src/git/git-process.ts +0 -169
- package/src/git/git-status.ts +0 -134
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
|
|
5
|
+
import { selectAction } from '@robota-sdk/agent-core';
|
|
6
|
+
import { isLoopStopVerb } from '@robota-sdk/agent-framework';
|
|
7
|
+
|
|
5
8
|
import { jitterFixedLoop } from './loop-jitter.js';
|
|
6
9
|
|
|
10
|
+
import type { IUserInteraction } from '@robota-sdk/agent-core';
|
|
7
11
|
import type { IAgentJobHostContext } from '@robota-sdk/agent-framework';
|
|
8
|
-
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
12
|
+
import type { ICommandResult, TCommandInvocationSource } from '@robota-sdk/agent-interface-command';
|
|
9
13
|
import type { IBackgroundTaskState } from '@robota-sdk/agent-interface-execution';
|
|
10
14
|
|
|
11
15
|
const LOOP_LABEL = 'Loop: ';
|
|
@@ -14,6 +18,7 @@ const HOURS_PER_DAY = 24;
|
|
|
14
18
|
const MAX_LABEL_LENGTH = 48;
|
|
15
19
|
const MAX_ACTIVE_LOOPS = 3;
|
|
16
20
|
const LOOP_LIFETIME_MS = 7 * 24 * 60 * 60_000;
|
|
21
|
+
const LOOP_LIFETIME_DAYS = LOOP_LIFETIME_MS / (HOURS_PER_DAY * 60 * SECONDS_PER_MINUTE * 1_000);
|
|
17
22
|
const MAX_DEFAULT_PROMPT_LENGTH = 4_096;
|
|
18
23
|
const pendingCreates = new WeakMap<object, number>();
|
|
19
24
|
const TRAILING_UNITS = new Set([
|
|
@@ -31,6 +36,20 @@ export interface ILoopCommandOptions {
|
|
|
31
36
|
disabled?: boolean;
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
/**
|
|
40
|
+
* #3288 §1: who invoked `/loop`, and how to ask them. `ui` is the injected "ask the user" port
|
|
41
|
+
* (absent for a headless/automation host — see `ICommandHostUserInteraction`); `source` is the
|
|
42
|
+
* command's own invocation source. Defaults to `{ ui: undefined, source: 'user' }` when the caller
|
|
43
|
+
* omits it, matching every pre-existing (headless) call site: proceed unconfirmed, the same as
|
|
44
|
+
* before this gate existed.
|
|
45
|
+
*/
|
|
46
|
+
export interface ILoopInvocationContext {
|
|
47
|
+
readonly ui: IUserInteraction | undefined;
|
|
48
|
+
readonly source: TCommandInvocationSource;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_INVOCATION: ILoopInvocationContext = { ui: undefined, source: 'user' };
|
|
52
|
+
|
|
34
53
|
const UNIT_MS: Record<string, number> = {
|
|
35
54
|
s: 1_000,
|
|
36
55
|
m: 60_000,
|
|
@@ -164,20 +183,80 @@ function loopIdOf(task: IBackgroundTaskState): string {
|
|
|
164
183
|
return typeof id === 'string' && id.length > 0 ? id : task.id;
|
|
165
184
|
}
|
|
166
185
|
|
|
186
|
+
function describeActiveLoops(
|
|
187
|
+
host: Pick<IAgentJobHostContext, 'listSchedules' | 'listSelfPacedLoops'>,
|
|
188
|
+
): string {
|
|
189
|
+
const loops = activeLoops(host);
|
|
190
|
+
const selfPaced = activeSelfPacedLoops(host);
|
|
191
|
+
if (loops.length + selfPaced.length === 0) return 'No other active loops.';
|
|
192
|
+
return [
|
|
193
|
+
...loops.map((task) => `- ${loopIdOf(task)} [${task.status}] ${task.label}`),
|
|
194
|
+
...selfPaced.map((loop) => `- ${loop.loopId} [${loop.phase}] Loop: ${loop.instruction}`),
|
|
195
|
+
].join('\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The plain refusal for a default-prompt loop with no one to ask — never a silent, unconfirmed start. */
|
|
199
|
+
const HEADLESS_REFUSAL =
|
|
200
|
+
'A loop with the default maintenance prompt needs someone to confirm it. Start it from an ' +
|
|
201
|
+
'interactive session, or give the loop its own prompt: /loop <prompt>';
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* #3288 §1: every form that falls back to the host's default maintenance prompt — bare `/loop` or an
|
|
205
|
+
* interval-only `/loop <N><s|m|h|d>` — keeps working on its own for up to 7 days: a standing
|
|
206
|
+
* commitment the operator never typed, gated the same way regardless of which form reached it.
|
|
207
|
+
* Returns the refusal/decline result to return in place of creating the loop, or `undefined` to
|
|
208
|
+
* proceed. A `source: 'model'` call is refused outright and never asks — the model must give its own
|
|
209
|
+
* prompt, or name `/loop` to the user (AGENTS.md: trust and permission-widening actions stay
|
|
210
|
+
* user-only, and a refusal names the command to suggest). A headless/automation host (no ask port —
|
|
211
|
+
* print mode, a scheduled run) is refused too, the same reason: a loop that can run for a week must
|
|
212
|
+
* not start without a person saying yes, and there is no one here to ask.
|
|
213
|
+
*/
|
|
214
|
+
async function confirmDefaultLoopStart(
|
|
215
|
+
host: Pick<IAgentJobHostContext, 'listSchedules' | 'listSelfPacedLoops'>,
|
|
216
|
+
invocation: ILoopInvocationContext,
|
|
217
|
+
instruction: string,
|
|
218
|
+
): Promise<ICommandResult | undefined> {
|
|
219
|
+
if (invocation.source === 'model') {
|
|
220
|
+
return {
|
|
221
|
+
success: false,
|
|
222
|
+
message:
|
|
223
|
+
'Starting a loop with its built-in prompt needs a person to confirm it — ' +
|
|
224
|
+
'ask the user to run /loop, or give your own prompt with /loop <prompt>.',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const { ui } = invocation;
|
|
228
|
+
if (!ui) return { success: false, message: HEADLESS_REFUSAL };
|
|
229
|
+
const firstLine = instruction.split('\n')[0]!.trim() || instruction;
|
|
230
|
+
const response = await ui.ask(
|
|
231
|
+
selectAction(
|
|
232
|
+
'loop-start-default',
|
|
233
|
+
`Start a loop that keeps working on its own for up to ${LOOP_LIFETIME_DAYS} days? It will: ${firstLine}`,
|
|
234
|
+
[
|
|
235
|
+
{ value: 'start', label: 'Start' },
|
|
236
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
237
|
+
],
|
|
238
|
+
{ description: describeActiveLoops(host) },
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
if (response.type === 'answer' && response.values.includes('start')) return undefined;
|
|
242
|
+
return { success: true, message: 'Loop not started.' };
|
|
243
|
+
}
|
|
244
|
+
|
|
167
245
|
export async function executeLoopCommand(
|
|
168
246
|
host: Pick<IAgentJobHostContext, 'spawnScheduledWake' | 'listSchedules' | 'createSelfPacedLoop' | 'listSelfPacedLoops' | 'stopSelfPacedLoop'>,
|
|
169
247
|
cancelBackgroundTask: (taskId: string, reason: string) => Promise<void>,
|
|
170
248
|
args: string,
|
|
171
249
|
options: ILoopCommandOptions = {},
|
|
250
|
+
invocation: ILoopInvocationContext = DEFAULT_INVOCATION,
|
|
172
251
|
): Promise<ICommandResult> {
|
|
173
252
|
const trimmed = args.trim();
|
|
174
253
|
if (trimmed === 'list') return listLoops(host, options);
|
|
175
|
-
if (
|
|
254
|
+
if (isLoopStopVerb(trimmed)) return stopLoop(host, cancelBackgroundTask, trimmed);
|
|
176
255
|
|
|
177
256
|
if (options.disabled) {
|
|
178
257
|
return { success: false, message: 'Session loops are disabled by the host.' };
|
|
179
258
|
}
|
|
180
|
-
return createLoop(host, trimmed, options);
|
|
259
|
+
return createLoop(host, trimmed, options, invocation);
|
|
181
260
|
}
|
|
182
261
|
|
|
183
262
|
function listLoops(
|
|
@@ -237,6 +316,7 @@ async function createLoop(
|
|
|
237
316
|
host: Pick<IAgentJobHostContext, 'spawnScheduledWake' | 'listSchedules' | 'createSelfPacedLoop' | 'listSelfPacedLoops'>,
|
|
238
317
|
args: string,
|
|
239
318
|
options: ILoopCommandOptions,
|
|
319
|
+
invocation: ILoopInvocationContext = DEFAULT_INVOCATION,
|
|
240
320
|
): Promise<ICommandResult> {
|
|
241
321
|
const useDefaultPrompt = args === '' || /^\d+(s|m|h|d)$/i.test(args);
|
|
242
322
|
let defaultPrompt = options.defaultPrompt;
|
|
@@ -259,6 +339,10 @@ async function createLoop(
|
|
|
259
339
|
if (!host.createSelfPacedLoop) {
|
|
260
340
|
return { success: false, message: 'Self-paced loops require a persistent interactive session.' };
|
|
261
341
|
}
|
|
342
|
+
if (useDefaultPrompt) {
|
|
343
|
+
const declined = await confirmDefaultLoopStart(host, invocation, instruction);
|
|
344
|
+
if (declined) return declined;
|
|
345
|
+
}
|
|
262
346
|
const pending = pendingCreates.get(host) ?? 0;
|
|
263
347
|
if (activeLoops(host).length + activeSelfPacedLoops(host).length + pending >= MAX_ACTIVE_LOOPS) {
|
|
264
348
|
return { success: false, message: `At most ${MAX_ACTIVE_LOOPS} active loops are allowed. Stop one before creating another.` };
|
|
@@ -279,6 +363,14 @@ async function createLoop(
|
|
|
279
363
|
else pendingCreates.set(host, remaining);
|
|
280
364
|
}
|
|
281
365
|
}
|
|
366
|
+
// #3288 §1: an interval-only form (`/loop 5m`) that fell back to the host's default prompt is
|
|
367
|
+
// gated exactly like the bare form — it is the SAME standing commitment (up to 7 days, unattended),
|
|
368
|
+
// only on a fixed cadence instead of self-paced. An explicit prompt (`/loop 5m check the build`)
|
|
369
|
+
// never reaches here with `useDefaultPrompt` true, so it is never gated.
|
|
370
|
+
if (useDefaultPrompt) {
|
|
371
|
+
const declined = await confirmDefaultLoopStart(host, invocation, parsed.instruction);
|
|
372
|
+
if (declined) return declined;
|
|
373
|
+
}
|
|
282
374
|
const pending = pendingCreates.get(host) ?? 0;
|
|
283
375
|
if (activeLoops(host).length + activeSelfPacedLoops(host).length + pending >= MAX_ACTIVE_LOOPS) {
|
|
284
376
|
return {
|
|
@@ -43,10 +43,13 @@ const MONITOR_MODEL_DESCRIPTION =
|
|
|
43
43
|
'The command is allowed, refused or asked about by the same permission rules as a shell tool ' +
|
|
44
44
|
'call. Returns the monitor task id, or a refusal naming what the user must allow.';
|
|
45
45
|
const LOOP_MODEL_DESCRIPTION =
|
|
46
|
-
'Repeat a prompt on a fixed cadence (`<N><s|m|h|d>
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
46
|
+
'Repeat a prompt on a fixed cadence (`<N><s|m|h|d> <prompt>`) or self-paced (`<prompt>`: each ' +
|
|
47
|
+
'iteration chooses its next delay or stops). Use it when the user asks for recurring work in ' +
|
|
48
|
+
'this session, and always give your own prompt: any form that would fall back to the host\'s ' +
|
|
49
|
+
'default maintenance prompt — bare `/loop` or an interval alone (`<N><s|m|h|d>`) — needs a ' +
|
|
50
|
+
'person to confirm it and is always refused for you, whether or not one is attached. `list` ' +
|
|
51
|
+
'shows active loops; `stop <id>` ends one, even mid-turn. Returns the loop id and its next fire ' +
|
|
52
|
+
'time, the loop list, or a refusal naming the command to suggest.';
|
|
50
53
|
|
|
51
54
|
export function createScheduleCommandEntry(): ICommand {
|
|
52
55
|
return {
|
|
@@ -154,6 +157,7 @@ function createLoopSystemCommand(options: ILoopCommandOptions): ISystemCommand {
|
|
|
154
157
|
(taskId, reason) => context.cancelBackgroundTask(taskId, reason),
|
|
155
158
|
args,
|
|
156
159
|
options,
|
|
160
|
+
{ ui: context.getUserInteraction(), source: context.getCommandInvocationSource() },
|
|
157
161
|
),
|
|
158
162
|
};
|
|
159
163
|
}
|
|
@@ -5,9 +5,9 @@ export function createSettingsCommandEntry(): ICommand {
|
|
|
5
5
|
return {
|
|
6
6
|
name: 'settings',
|
|
7
7
|
displayName: 'Settings',
|
|
8
|
-
description: 'Open
|
|
8
|
+
description: 'Open settings — language, output style, preset, permissions and more',
|
|
9
9
|
source: 'settings',
|
|
10
|
-
// User-only:
|
|
10
|
+
// User-only: opens a screen; nothing here is a model action.
|
|
11
11
|
modelInvocable: false,
|
|
12
12
|
};
|
|
13
13
|
}
|
|
@@ -1,125 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,221 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
});
|