@robota-sdk/agent-command 3.0.0-beta.82 → 3.0.0-beta.84
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 +1071 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +81 -72
- package/dist/node/index.d.cts +136 -52
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +136 -52
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +79 -70
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -10
- 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/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +2 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +54 -14
- package/src/default/__tests__/model-exposure.test.ts +18 -3
- package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
- package/src/default/default-command-modules.ts +15 -8
- package/src/devices/__tests__/devices-command-module.test.ts +99 -12
- package/src/devices/devices-command-module.ts +108 -24
- package/src/devices/devices-command-port.ts +62 -1
- package/src/devices/index.ts +3 -0
- package/src/doctor/doctor-node-deps.ts +6 -7
- package/src/editor/editor-command-module.ts +5 -0
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/__tests__/git-command-module.test.ts +10 -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/handoff/__tests__/handoff-command-module.test.ts +2 -1
- package/src/handoff/handoff-command-module.ts +1 -1
- package/src/handoff/handoff-command.ts +2 -2
- package/src/help/__tests__/help-command-module.test.ts +3 -0
- package/src/help/__tests__/help-command.test.ts +3 -0
- package/src/index.ts +14 -0
- package/src/keybindings/keybindings-command-module.ts +17 -3
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +172 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +62 -4
- 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/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +78 -0
- package/src/peers/peers-command-module.ts +1 -1
- package/src/peers/peers-command.ts +33 -4
- package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
- package/src/plugin/plugin-command.ts +47 -4
- 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 +32 -1
- package/src/rewind/rewind-command.ts +23 -2
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
- package/src/schedule/loop-command.ts +98 -4
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/shell/shell-command-module.ts +5 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +1 -1
- package/src/theme/theme-command-module.ts +12 -2
- package/src/user-local/__tests__/user-local-command.test.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
|
@@ -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
|
-
});
|
package/src/git/git-diff.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BEHAVIOR-2437: `/git diff` — a closed grammar, not a pass-through for git flags.
|
|
3
|
-
*
|
|
4
|
-
* `/git diff` (unstaged) · `/git diff --staged` (index vs HEAD) · `/git diff <rev>` (worktree vs rev)
|
|
5
|
-
* · `/git diff <a>..<b>` (two revisions), each optionally followed by `-- <path>…`. Every revision is
|
|
6
|
-
* verified with `rev-parse --verify --quiet --end-of-options <rev>^{commit}` before any diff runs;
|
|
7
|
-
* any other `-` token is refused, because a flag is exactly how an argv element becomes an option.
|
|
8
|
-
* In the argv git receives, `--end-of-options` precedes every user revision and `--` every path.
|
|
9
|
-
*/
|
|
10
|
-
import { gitFailureMessage } from './git-process.js';
|
|
11
|
-
|
|
12
|
-
import type { IGitProcessPort } from './git-process.js';
|
|
13
|
-
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
14
|
-
|
|
15
|
-
export const GIT_DIFF_USAGE =
|
|
16
|
-
'Usage: /git diff [--staged | <rev> | <a>..<b>] [-- <path> ...] — other git flags are not accepted here.';
|
|
17
|
-
|
|
18
|
-
export type TGitDiffTarget =
|
|
19
|
-
| { kind: 'worktree' }
|
|
20
|
-
| { kind: 'staged' }
|
|
21
|
-
| { kind: 'revision'; revision: string }
|
|
22
|
-
| { kind: 'range'; from: string; to: string };
|
|
23
|
-
|
|
24
|
-
export interface IGitDiffArgs {
|
|
25
|
-
target: TGitDiffTarget;
|
|
26
|
-
paths: readonly string[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type TParseGitDiffArgs = { ok: true; args: IGitDiffArgs } | { ok: false; message: string };
|
|
30
|
-
|
|
31
|
-
function refuse(reason: string): TParseGitDiffArgs {
|
|
32
|
-
return { ok: false, message: `${reason}\n${GIT_DIFF_USAGE}` };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function parseGitDiffArgs(tokens: readonly string[]): TParseGitDiffArgs {
|
|
36
|
-
const separator = tokens.indexOf('--');
|
|
37
|
-
const head = separator === -1 ? tokens : tokens.slice(0, separator);
|
|
38
|
-
const paths = separator === -1 ? [] : tokens.slice(separator + 1);
|
|
39
|
-
if (separator !== -1 && paths.length === 0) return refuse('`--` must be followed by a path.');
|
|
40
|
-
|
|
41
|
-
const flag = head.find((token) => token.startsWith('-') && token !== '--staged');
|
|
42
|
-
if (flag !== undefined) return refuse(`\`${flag}\` is not accepted.`);
|
|
43
|
-
if (head.length > 1) return refuse(`Too many arguments: ${head.join(' ')}.`);
|
|
44
|
-
|
|
45
|
-
const token = head[0];
|
|
46
|
-
if (token === undefined) return { ok: true, args: { target: { kind: 'worktree' }, paths } };
|
|
47
|
-
if (token === '--staged') return { ok: true, args: { target: { kind: 'staged' }, paths } };
|
|
48
|
-
if (token.includes('..')) {
|
|
49
|
-
if (token.includes('...')) return refuse(`\`${token}\` is not a \`<a>..<b>\` range.`);
|
|
50
|
-
const parts = token.split('..');
|
|
51
|
-
const [from, to] = parts;
|
|
52
|
-
if (parts.length !== 2 || !from || !to) {
|
|
53
|
-
return refuse(`\`${token}\` is not a \`<a>..<b>\` range.`);
|
|
54
|
-
}
|
|
55
|
-
return { ok: true, args: { target: { kind: 'range', from, to }, paths } };
|
|
56
|
-
}
|
|
57
|
-
return { ok: true, args: { target: { kind: 'revision', revision: token }, paths } };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** The revisions a target names, in the order they are verified. */
|
|
61
|
-
function revisionsOf(target: TGitDiffTarget): readonly string[] {
|
|
62
|
-
switch (target.kind) {
|
|
63
|
-
case 'revision':
|
|
64
|
-
return [target.revision];
|
|
65
|
-
case 'range':
|
|
66
|
-
return [target.from, target.to];
|
|
67
|
-
default:
|
|
68
|
-
return [];
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** The `diff` argv for a parsed target, `--end-of-options` before revisions and `--` before paths. */
|
|
73
|
-
export function gitDiffArgv(args: IGitDiffArgs): readonly string[] {
|
|
74
|
-
const argv = ['diff'];
|
|
75
|
-
switch (args.target.kind) {
|
|
76
|
-
case 'staged':
|
|
77
|
-
argv.push('--staged');
|
|
78
|
-
break;
|
|
79
|
-
case 'revision':
|
|
80
|
-
argv.push('--end-of-options', args.target.revision);
|
|
81
|
-
break;
|
|
82
|
-
case 'range':
|
|
83
|
-
argv.push('--end-of-options', `${args.target.from}..${args.target.to}`);
|
|
84
|
-
break;
|
|
85
|
-
default:
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
|
-
if (args.paths.length > 0) argv.push('--', ...args.paths);
|
|
89
|
-
return argv;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function gitRevParseArgv(revision: string): readonly string[] {
|
|
93
|
-
return ['rev-parse', '--verify', '--quiet', '--end-of-options', `${revision}^{commit}`];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function tokenizeGitArgs(args: string): string[] {
|
|
97
|
-
return args.split(/\s+/).filter((token) => token.length > 0);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export async function executeGitDiff(
|
|
101
|
-
port: IGitProcessPort,
|
|
102
|
-
cwd: string,
|
|
103
|
-
args: string,
|
|
104
|
-
): Promise<ICommandResult> {
|
|
105
|
-
const parsed = parseGitDiffArgs(tokenizeGitArgs(args));
|
|
106
|
-
if (!parsed.ok) return { success: false, message: parsed.message };
|
|
107
|
-
|
|
108
|
-
for (const revision of revisionsOf(parsed.args.target)) {
|
|
109
|
-
const outcome = await port.run(gitRevParseArgv(revision), { cwd });
|
|
110
|
-
if (outcome.kind === 'failed') {
|
|
111
|
-
return { success: false, message: gitFailureMessage('rev-parse', outcome) ?? 'git failed' };
|
|
112
|
-
}
|
|
113
|
-
if (outcome.exitCode !== 0) {
|
|
114
|
-
return { success: false, message: `Unknown revision: ${revision}` };
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const outcome = await port.run(gitDiffArgv(parsed.args), { cwd });
|
|
119
|
-
const failure = gitFailureMessage('diff', outcome);
|
|
120
|
-
if (failure !== undefined || outcome.kind !== 'exited') {
|
|
121
|
-
return { success: false, message: failure ?? 'git diff failed' };
|
|
122
|
-
}
|
|
123
|
-
const diff = outcome.stdout.replace(/\n$/, '');
|
|
124
|
-
return {
|
|
125
|
-
success: true,
|
|
126
|
-
message: diff.length > 0 ? diff : 'No differences.',
|
|
127
|
-
data: { git: 'diff', target: parsed.args.target, paths: parsed.args.paths },
|
|
128
|
-
};
|
|
129
|
-
}
|
package/src/git/git-process.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BEHAVIOR-2437: the one seam every `/git` verb runs git through.
|
|
3
|
-
*
|
|
4
|
-
* argv only — `execFile` with `shell: false`, so no user token is ever parsed as shell syntax; stdin
|
|
5
|
-
* is closed at once, so a hook that reads it sees EOF instead of hanging; output is capped, the child
|
|
6
|
-
* is bounded by a timeout, and every way a run can end is a typed outcome rather than a throw. Exit
|
|
7
|
-
* codes are DATA: `diff --cached --quiet` answers with 1, and that is an answer, not an error.
|
|
8
|
-
*/
|
|
9
|
-
import { execFile } from 'node:child_process';
|
|
10
|
-
import { constants } from 'node:os';
|
|
11
|
-
|
|
12
|
-
import type { ExecFileException } from 'node:child_process';
|
|
13
|
-
|
|
14
|
-
export const GIT_DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
-
const BYTES_PER_KIB = 1024;
|
|
16
|
-
const OUTPUT_CAP_MIB = 16;
|
|
17
|
-
export const GIT_MAX_OUTPUT_BYTES = OUTPUT_CAP_MIB * BYTES_PER_KIB * BYTES_PER_KIB;
|
|
18
|
-
/** The shell convention for a signal-terminated child: 128 + the signal number. */
|
|
19
|
-
const SIGNAL_EXIT_BASE = 128;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Variables that redirect a git child to ANOTHER repository. Git exports them into hooks, so a
|
|
23
|
-
* `/git` run started from inside a hook (or from any process that inherited one) would otherwise
|
|
24
|
-
* operate on the wrong tree. Identity and configuration variables are deliberately NOT here — the
|
|
25
|
-
* user's `GIT_CONFIG_GLOBAL`, `GIT_AUTHOR_*`, `GPG_TTY`, `SSH_AUTH_SOCK` … are what make the commit
|
|
26
|
-
* theirs.
|
|
27
|
-
*/
|
|
28
|
-
export const GIT_ENV_DENYLIST: readonly string[] = [
|
|
29
|
-
'GIT_DIR',
|
|
30
|
-
'GIT_WORK_TREE',
|
|
31
|
-
'GIT_INDEX_FILE',
|
|
32
|
-
'GIT_OBJECT_DIRECTORY',
|
|
33
|
-
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
34
|
-
'GIT_COMMON_DIR',
|
|
35
|
-
'GIT_PREFIX',
|
|
36
|
-
'GIT_NAMESPACE',
|
|
37
|
-
];
|
|
38
|
-
|
|
39
|
-
export interface IGitProcessRunOptions {
|
|
40
|
-
cwd: string;
|
|
41
|
-
timeoutMs?: number;
|
|
42
|
-
signal?: AbortSignal;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* `not-found` covers every spawn failure — ENOENT, EACCES, ENOTDIR, … — with the code in `detail`;
|
|
47
|
-
* the other three are the run's own bounds.
|
|
48
|
-
*/
|
|
49
|
-
export type TGitProcessFailureReason = 'not-found' | 'timeout' | 'aborted' | 'output-too-large';
|
|
50
|
-
|
|
51
|
-
export type TGitProcessOutcome =
|
|
52
|
-
| { kind: 'exited'; stdout: string; stderr: string; exitCode: number }
|
|
53
|
-
| { kind: 'failed'; reason: TGitProcessFailureReason; detail: string };
|
|
54
|
-
|
|
55
|
-
export interface IGitProcessPort {
|
|
56
|
-
run(args: readonly string[], options: IGitProcessRunOptions): Promise<TGitProcessOutcome>;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface ICreateGitProcessOptions {
|
|
60
|
-
/** The executable to run; `git` on PATH by default. */
|
|
61
|
-
executable?: string;
|
|
62
|
-
/** The environment to derive the child's from; the live process environment by default. */
|
|
63
|
-
env?: NodeJS.ProcessEnv;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** The child's environment: `source` minus the repository-redirecting variables. */
|
|
67
|
-
export function gitEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
68
|
-
const env: NodeJS.ProcessEnv = {};
|
|
69
|
-
for (const [key, value] of Object.entries(source)) {
|
|
70
|
-
if (!GIT_ENV_DENYLIST.includes(key)) env[key] = value;
|
|
71
|
-
}
|
|
72
|
-
return env;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function failed(reason: TGitProcessFailureReason, detail: string): TGitProcessOutcome {
|
|
76
|
-
return { kind: 'failed', reason, detail };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function signalNumber(signal: NodeJS.Signals): number {
|
|
80
|
-
return constants.signals[signal] ?? 0;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function toOutcome(
|
|
84
|
-
error: ExecFileException | null,
|
|
85
|
-
stdout: string,
|
|
86
|
-
stderr: string,
|
|
87
|
-
context: { executable: string; timeoutMs: number; signal: AbortSignal | undefined },
|
|
88
|
-
): TGitProcessOutcome {
|
|
89
|
-
if (error === null) return { kind: 'exited', stdout, stderr, exitCode: 0 };
|
|
90
|
-
if (context.signal?.aborted === true || error.name === 'AbortError') {
|
|
91
|
-
return failed('aborted', 'the run was aborted before git exited');
|
|
92
|
-
}
|
|
93
|
-
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
|
94
|
-
return failed(
|
|
95
|
-
'output-too-large',
|
|
96
|
-
`git produced more than ${OUTPUT_CAP_MIB} MiB of output and was stopped`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
// Our own kill wins over whatever exit the child managed on the way down (a shell trap exiting
|
|
100
|
-
// 143 is still a timeout).
|
|
101
|
-
if (error.killed === true) {
|
|
102
|
-
return failed('timeout', `git did not exit within ${context.timeoutMs} ms and was stopped`);
|
|
103
|
-
}
|
|
104
|
-
if (typeof error.code === 'number')
|
|
105
|
-
return { kind: 'exited', stdout, stderr, exitCode: error.code };
|
|
106
|
-
if (error.signal) {
|
|
107
|
-
// RUNTIME-53 convention: a signal-terminated child reports 128 + the signal number.
|
|
108
|
-
return {
|
|
109
|
-
kind: 'exited',
|
|
110
|
-
stdout,
|
|
111
|
-
stderr,
|
|
112
|
-
exitCode: SIGNAL_EXIT_BASE + signalNumber(error.signal),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
return failed(
|
|
116
|
-
'not-found',
|
|
117
|
-
`${context.executable} could not be run (${String(error.code ?? error.message)})`,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** The production port: argv-only `execFile`, stdin closed, output capped, timeout bounded. */
|
|
122
|
-
export function createGitProcess(options: ICreateGitProcessOptions = {}): IGitProcessPort {
|
|
123
|
-
const executable = options.executable ?? 'git';
|
|
124
|
-
return {
|
|
125
|
-
run(args, runOptions) {
|
|
126
|
-
const timeoutMs = runOptions.timeoutMs ?? GIT_DEFAULT_TIMEOUT_MS;
|
|
127
|
-
const env = gitEnvironment(options.env ?? process.env);
|
|
128
|
-
return new Promise<TGitProcessOutcome>((resolve) => {
|
|
129
|
-
const child = execFile(
|
|
130
|
-
executable,
|
|
131
|
-
[...args],
|
|
132
|
-
{
|
|
133
|
-
cwd: runOptions.cwd,
|
|
134
|
-
env,
|
|
135
|
-
shell: false,
|
|
136
|
-
encoding: 'utf8',
|
|
137
|
-
timeout: timeoutMs,
|
|
138
|
-
maxBuffer: GIT_MAX_OUTPUT_BYTES,
|
|
139
|
-
windowsHide: true,
|
|
140
|
-
...(runOptions.signal ? { signal: runOptions.signal } : {}),
|
|
141
|
-
},
|
|
142
|
-
(error, stdout, stderr) =>
|
|
143
|
-
resolve(
|
|
144
|
-
toOutcome(error, stdout, stderr, {
|
|
145
|
-
executable,
|
|
146
|
-
timeoutMs,
|
|
147
|
-
signal: runOptions.signal,
|
|
148
|
-
}),
|
|
149
|
-
),
|
|
150
|
-
);
|
|
151
|
-
// stdin is a pipe by default; closing it at once turns a hook's read into EOF.
|
|
152
|
-
child.stdin?.end();
|
|
153
|
-
});
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* The reason a run did not produce a usable answer, for the verbs where a non-zero exit IS a failure.
|
|
160
|
-
* `undefined` when the child exited 0.
|
|
161
|
-
*/
|
|
162
|
-
export function gitFailureMessage(verb: string, outcome: TGitProcessOutcome): string | undefined {
|
|
163
|
-
if (outcome.kind === 'failed') return `git ${verb} failed: ${outcome.detail}`;
|
|
164
|
-
if (outcome.exitCode === 0) return undefined;
|
|
165
|
-
const stderr = outcome.stderr.trim();
|
|
166
|
-
return stderr.length > 0
|
|
167
|
-
? `git ${verb} failed (exit ${outcome.exitCode}): ${stderr}`
|
|
168
|
-
: `git ${verb} failed (exit ${outcome.exitCode})`;
|
|
169
|
-
}
|