@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81
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 +76 -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 +76 -38
- package/dist/node/index.js.map +1 -1
- package/package.json +34 -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.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +48 -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 +241 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +47 -0
- package/src/peers/peers-command.ts +142 -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,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BEHAVIOR-2437: `/git` — one command whose `execute` parses its verb (`status` | `diff` | `commit`),
|
|
3
|
+
* with the three verbs on `subcommands` for `/help` and autocomplete. Host-only (`modelInvocable:
|
|
4
|
+
* false`); the confirmation lives in the `commit` verb, because `requiresPermission` is not enforced
|
|
5
|
+
* on the user path (`SystemCommandExecutor.executeCommand` calls `execute` directly).
|
|
6
|
+
*/
|
|
7
|
+
import { executeGitCommit } from './git-commit.js';
|
|
8
|
+
import { executeGitDiff } from './git-diff.js';
|
|
9
|
+
import { createGitProcess } from './git-process.js';
|
|
10
|
+
import { executeGitStatus } from './git-status.js';
|
|
11
|
+
|
|
12
|
+
import type { IGitProcessPort } from './git-process.js';
|
|
13
|
+
import type {
|
|
14
|
+
ICommandHostUserInteraction,
|
|
15
|
+
ICommandHostWorkspace,
|
|
16
|
+
ICommandModule,
|
|
17
|
+
ISystemCommand,
|
|
18
|
+
} from '@robota-sdk/agent-framework';
|
|
19
|
+
import type { ICommand, ICommandResult, ICommandSource } from '@robota-sdk/agent-interface-command';
|
|
20
|
+
|
|
21
|
+
export const GIT_COMMAND_DESCRIPTION =
|
|
22
|
+
'Show the git status, view a diff, or commit the staged changes after confirming';
|
|
23
|
+
export const GIT_COMMAND_ARGUMENT_HINT =
|
|
24
|
+
'status | diff [--staged | <rev> | <a>..<b>] [-- <path> ...] | commit [<subject>]';
|
|
25
|
+
export const GIT_COMMAND_USAGE =
|
|
26
|
+
'Usage: /git status | /git diff [--staged | <rev> | <a>..<b>] [-- <path> ...] | /git commit [<subject>]';
|
|
27
|
+
|
|
28
|
+
const SOURCE = 'git';
|
|
29
|
+
|
|
30
|
+
function verbEntry(name: string, description: string, argumentHint?: string): ICommand {
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
description,
|
|
34
|
+
source: SOURCE,
|
|
35
|
+
modelInvocable: false,
|
|
36
|
+
...(argumentHint ? { argumentHint } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createGitCommandEntry(): ICommand {
|
|
41
|
+
return {
|
|
42
|
+
name: 'git',
|
|
43
|
+
displayName: 'Git',
|
|
44
|
+
description: GIT_COMMAND_DESCRIPTION,
|
|
45
|
+
source: SOURCE,
|
|
46
|
+
modelInvocable: false,
|
|
47
|
+
argumentHint: GIT_COMMAND_ARGUMENT_HINT,
|
|
48
|
+
subcommands: [
|
|
49
|
+
verbEntry('status', 'Show the branch and the staged, unstaged and untracked paths'),
|
|
50
|
+
verbEntry(
|
|
51
|
+
'diff',
|
|
52
|
+
'Show the unstaged diff, the staged diff, or a diff against one or two revisions',
|
|
53
|
+
'[--staged | <rev> | <a>..<b>] [-- <path> ...]',
|
|
54
|
+
),
|
|
55
|
+
verbEntry(
|
|
56
|
+
'commit',
|
|
57
|
+
'Commit the staged changes with a Conventional Commits subject, after confirming',
|
|
58
|
+
'[<subject>]',
|
|
59
|
+
),
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What the verbs need from the host: the working directory and the ask-the-user port. */
|
|
65
|
+
export type TGitCommandContext = Pick<ICommandHostWorkspace, 'getCwd'> &
|
|
66
|
+
ICommandHostUserInteraction;
|
|
67
|
+
|
|
68
|
+
export async function executeGitCommand(
|
|
69
|
+
context: TGitCommandContext,
|
|
70
|
+
args: string,
|
|
71
|
+
port: IGitProcessPort = createGitProcess(),
|
|
72
|
+
): Promise<ICommandResult> {
|
|
73
|
+
const trimmed = args.trim();
|
|
74
|
+
const split = trimmed.search(/\s/);
|
|
75
|
+
const verb = split === -1 ? trimmed : trimmed.slice(0, split);
|
|
76
|
+
const rest = split === -1 ? '' : trimmed.slice(split + 1).trim();
|
|
77
|
+
const cwd = context.getCwd();
|
|
78
|
+
|
|
79
|
+
switch (verb) {
|
|
80
|
+
case 'status':
|
|
81
|
+
if (rest.length > 0) {
|
|
82
|
+
return {
|
|
83
|
+
success: false,
|
|
84
|
+
message: `\`/git status\` takes no arguments.\n${GIT_COMMAND_USAGE}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return executeGitStatus(port, cwd);
|
|
88
|
+
case 'diff':
|
|
89
|
+
return executeGitDiff(port, cwd, rest);
|
|
90
|
+
case 'commit':
|
|
91
|
+
return executeGitCommit(port, cwd, rest, context.getUserInteraction());
|
|
92
|
+
default:
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
message:
|
|
96
|
+
verb.length === 0
|
|
97
|
+
? GIT_COMMAND_USAGE
|
|
98
|
+
: `Unknown /git verb "${verb}".\n${GIT_COMMAND_USAGE}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createGitSystemCommand(port: IGitProcessPort): ISystemCommand {
|
|
104
|
+
const entry = createGitCommandEntry();
|
|
105
|
+
return {
|
|
106
|
+
name: entry.name,
|
|
107
|
+
displayName: entry.displayName,
|
|
108
|
+
description: entry.description,
|
|
109
|
+
argumentHint: entry.argumentHint,
|
|
110
|
+
example: '/git status',
|
|
111
|
+
subcommands: entry.subcommands,
|
|
112
|
+
requiresPermission: true,
|
|
113
|
+
userInvocable: true,
|
|
114
|
+
modelInvocable: false,
|
|
115
|
+
lifecycle: 'blocking',
|
|
116
|
+
execute: (context, args) => executeGitCommand(context, args, port),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class GitCommandSource implements ICommandSource {
|
|
121
|
+
readonly name = SOURCE;
|
|
122
|
+
|
|
123
|
+
getCommands(): ICommand[] {
|
|
124
|
+
return [createGitCommandEntry()];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface IGitCommandModuleOptions {
|
|
129
|
+
/** The process seam; the production `createGitProcess()` by default, injectable for tests. */
|
|
130
|
+
port?: IGitProcessPort;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createGitCommandModule(options: IGitCommandModuleOptions = {}): ICommandModule {
|
|
134
|
+
return {
|
|
135
|
+
name: 'agent-command-git',
|
|
136
|
+
commandSources: [new GitCommandSource()],
|
|
137
|
+
systemCommands: [createGitSystemCommand(options.port ?? createGitProcess())],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BEHAVIOR-2437: `/git commit [<subject>]` — the staged set, and only ever the staged set.
|
|
3
|
+
*
|
|
4
|
+
* No `-a`, no `add`, no paths. Nothing staged is answered with guidance that says why; the subject
|
|
5
|
+
* is held to the Conventional Commits v1.0.0 MUST rules and nothing more (the commitlint conventions
|
|
6
|
+
* are warnings, never refusals); the confirmation lists the message and exactly the files
|
|
7
|
+
* `git diff --cached --name-status` reports; an absent `IUserInteraction` is a cancellation — the
|
|
8
|
+
* `/doctor repair` precedent — never a guess. Only after "Yes" does `git commit -m <subject>` run.
|
|
9
|
+
*/
|
|
10
|
+
import { confirmAction, isConfirmed, textAction } from '@robota-sdk/agent-core';
|
|
11
|
+
|
|
12
|
+
import { gitFailureMessage } from './git-process.js';
|
|
13
|
+
import { parseStatusPorcelainV2, GIT_STATUS_ARGS } from './git-status.js';
|
|
14
|
+
|
|
15
|
+
import type { IGitProcessPort } from './git-process.js';
|
|
16
|
+
import type { IUserInteraction } from '@robota-sdk/agent-core';
|
|
17
|
+
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
18
|
+
|
|
19
|
+
const GIT_COMMIT_CONVENTIONAL_TYPES: readonly string[] = [
|
|
20
|
+
'build',
|
|
21
|
+
'chore',
|
|
22
|
+
'ci',
|
|
23
|
+
'docs',
|
|
24
|
+
'feat',
|
|
25
|
+
'fix',
|
|
26
|
+
'perf',
|
|
27
|
+
'refactor',
|
|
28
|
+
'revert',
|
|
29
|
+
'style',
|
|
30
|
+
'test',
|
|
31
|
+
];
|
|
32
|
+
const GIT_COMMIT_SUBJECT_LIMIT = 72;
|
|
33
|
+
|
|
34
|
+
export type TConventionalSubjectCheck =
|
|
35
|
+
{ ok: true; warnings: readonly string[] } | { ok: false; reason: string };
|
|
36
|
+
|
|
37
|
+
/** `<type>[(scope)][!]: <description>` — the MUST rules, and only those. */
|
|
38
|
+
const CONVENTIONAL_SUBJECT = /^([^\s():!]+)(\(([^\s()]+)\))?(!)?: (.*)$/;
|
|
39
|
+
|
|
40
|
+
export function validateConventionalSubject(subject: string): TConventionalSubjectCheck {
|
|
41
|
+
if (subject.trim().length === 0) return { ok: false, reason: 'the subject is empty' };
|
|
42
|
+
const separator = subject.indexOf(': ');
|
|
43
|
+
if (separator === -1) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason:
|
|
47
|
+
'the subject has no `: ` type separator — the form is `<type>[(scope)][!]: <description>`',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const match = CONVENTIONAL_SUBJECT.exec(subject);
|
|
51
|
+
if (!match) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason:
|
|
55
|
+
'the part before `: ` must be a type word with an optional `(scope)` and `!` — the form is `<type>[(scope)][!]: <description>`',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const type = match[1] ?? '';
|
|
59
|
+
const description = match[5] ?? '';
|
|
60
|
+
if (description.trim().length === 0) {
|
|
61
|
+
return { ok: false, reason: 'the description after `: ` is empty' };
|
|
62
|
+
}
|
|
63
|
+
const warnings: string[] = [];
|
|
64
|
+
if (!GIT_COMMIT_CONVENTIONAL_TYPES.includes(type)) {
|
|
65
|
+
warnings.push(
|
|
66
|
+
`type "${type}" is not one of the conventional types (${GIT_COMMIT_CONVENTIONAL_TYPES.join(', ')})`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (subject.length > GIT_COMMIT_SUBJECT_LIMIT) {
|
|
70
|
+
warnings.push(
|
|
71
|
+
`the subject is ${subject.length} characters; ${GIT_COMMIT_SUBJECT_LIMIT} is the conventional limit`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (subject.endsWith('.')) warnings.push('the subject ends with a period');
|
|
75
|
+
return { ok: true, warnings };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The inline subject: an optional leading `-m`, then one matching pair of outer quotes stripped. */
|
|
79
|
+
export function normalizeCommitSubject(args: string): string {
|
|
80
|
+
let subject = args.trim();
|
|
81
|
+
if (subject === '-m') return '';
|
|
82
|
+
if (subject.startsWith('-m ')) subject = subject.slice('-m '.length).trim();
|
|
83
|
+
if (
|
|
84
|
+
subject.length >= 2 &&
|
|
85
|
+
(subject.startsWith('"') || subject.startsWith("'")) &&
|
|
86
|
+
subject.endsWith(subject[0] ?? '')
|
|
87
|
+
) {
|
|
88
|
+
subject = subject.slice(1, -1).trim();
|
|
89
|
+
}
|
|
90
|
+
return subject;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cancelled(message: string): ICommandResult {
|
|
94
|
+
return { success: false, message, data: { git: 'commit', committed: false } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function askSubject(ui: IUserInteraction): Promise<string | undefined> {
|
|
98
|
+
const response = await ui.ask(
|
|
99
|
+
textAction('git-commit-subject', 'Commit message', {
|
|
100
|
+
description: 'Conventional Commits form: <type>[(scope)][!]: <description>',
|
|
101
|
+
placeholder: 'feat: describe the change',
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
if (response.type !== 'answer') return undefined;
|
|
105
|
+
const text = response.text ?? response.values[0] ?? '';
|
|
106
|
+
return normalizeCommitSubject(text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type TStagedCheck = { staged: true } | { staged: false; result: ICommandResult };
|
|
110
|
+
|
|
111
|
+
/** Step 1 — the staged set decides everything; exit 1 means something is staged. */
|
|
112
|
+
async function checkStaged(port: IGitProcessPort, cwd: string): Promise<TStagedCheck> {
|
|
113
|
+
const staged = await port.run(['diff', '--cached', '--quiet'], { cwd });
|
|
114
|
+
if (staged.kind === 'failed' || (staged.exitCode !== 0 && staged.exitCode !== 1)) {
|
|
115
|
+
return {
|
|
116
|
+
staged: false,
|
|
117
|
+
result: {
|
|
118
|
+
success: false,
|
|
119
|
+
message: gitFailureMessage('diff --cached', staged) ?? 'git failed',
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (staged.exitCode === 1) return { staged: true };
|
|
124
|
+
const status = await port.run(GIT_STATUS_ARGS, { cwd });
|
|
125
|
+
const failure = gitFailureMessage('status', status);
|
|
126
|
+
if (failure !== undefined || status.kind !== 'exited') {
|
|
127
|
+
return { staged: false, result: { success: false, message: failure ?? 'git status failed' } };
|
|
128
|
+
}
|
|
129
|
+
const summary = parseStatusPorcelainV2(status.stdout);
|
|
130
|
+
return {
|
|
131
|
+
staged: false,
|
|
132
|
+
result: {
|
|
133
|
+
success: false,
|
|
134
|
+
message: `Nothing is staged (${summary.unstaged.length} unstaged, ${summary.untracked.length} untracked). Stage files with \`git add\` or \`/shell git add ...\` and run \`/git commit\` again.`,
|
|
135
|
+
data: {
|
|
136
|
+
git: 'commit',
|
|
137
|
+
committed: false,
|
|
138
|
+
unstaged: summary.unstaged.length,
|
|
139
|
+
untracked: summary.untracked.length,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type TSubjectResolution = { subject: string } | { result: ICommandResult };
|
|
146
|
+
|
|
147
|
+
/** Step 2 — the subject: inline, or asked for as free text; then the MUST rules. */
|
|
148
|
+
async function resolveSubject(
|
|
149
|
+
args: string,
|
|
150
|
+
ui: IUserInteraction | undefined,
|
|
151
|
+
): Promise<TSubjectResolution> {
|
|
152
|
+
let subject = normalizeCommitSubject(args);
|
|
153
|
+
if (subject.length === 0) {
|
|
154
|
+
if (ui === undefined) {
|
|
155
|
+
return {
|
|
156
|
+
result: cancelled(
|
|
157
|
+
'Commit cancelled: a commit message was needed and none could be asked for (no interactive session). Run `/git commit <subject>`.',
|
|
158
|
+
),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const asked = await askSubject(ui);
|
|
162
|
+
if (asked === undefined || asked.length === 0)
|
|
163
|
+
return { result: cancelled('Commit cancelled.') };
|
|
164
|
+
subject = asked;
|
|
165
|
+
}
|
|
166
|
+
return { subject };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Step 3 — what is confirmed is what is committed: the listing comes from git, not from a guess. */
|
|
170
|
+
async function confirmCommit(
|
|
171
|
+
port: IGitProcessPort,
|
|
172
|
+
cwd: string,
|
|
173
|
+
subject: string,
|
|
174
|
+
warnings: readonly string[],
|
|
175
|
+
ui: IUserInteraction | undefined,
|
|
176
|
+
): Promise<
|
|
177
|
+
{ confirmed: true; files: readonly string[] } | { confirmed: false; result: ICommandResult }
|
|
178
|
+
> {
|
|
179
|
+
if (ui === undefined) {
|
|
180
|
+
return {
|
|
181
|
+
confirmed: false,
|
|
182
|
+
result: cancelled(
|
|
183
|
+
'Commit cancelled: a confirmation was needed and none could be asked for (no interactive session).',
|
|
184
|
+
),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const listing = await port.run(['diff', '--cached', '--name-status'], { cwd });
|
|
188
|
+
const listingFailure = gitFailureMessage('diff --cached --name-status', listing);
|
|
189
|
+
if (listingFailure !== undefined || listing.kind !== 'exited') {
|
|
190
|
+
return {
|
|
191
|
+
confirmed: false,
|
|
192
|
+
result: { success: false, message: listingFailure ?? 'git failed' },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const files = listing.stdout
|
|
196
|
+
.split('\n')
|
|
197
|
+
.filter((line) => line.length > 0)
|
|
198
|
+
.map((line) => line.replace(/\t/g, ' '));
|
|
199
|
+
const description = [
|
|
200
|
+
`Message: ${subject}`,
|
|
201
|
+
...warnings.map((warning) => `Warning: ${warning}`),
|
|
202
|
+
`Staged (${files.length}):`,
|
|
203
|
+
...files.map((file) => ` ${file}`),
|
|
204
|
+
].join('\n');
|
|
205
|
+
const response = await ui.ask(
|
|
206
|
+
confirmAction('git-commit', `Commit ${files.length} staged file(s)?`, {
|
|
207
|
+
description,
|
|
208
|
+
defaultYes: false,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
if (!isConfirmed(response)) return { confirmed: false, result: cancelled('Commit cancelled.') };
|
|
212
|
+
return { confirmed: true, files };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function executeGitCommit(
|
|
216
|
+
port: IGitProcessPort,
|
|
217
|
+
cwd: string,
|
|
218
|
+
args: string,
|
|
219
|
+
ui: IUserInteraction | undefined,
|
|
220
|
+
): Promise<ICommandResult> {
|
|
221
|
+
const staged = await checkStaged(port, cwd);
|
|
222
|
+
if (!staged.staged) return staged.result;
|
|
223
|
+
|
|
224
|
+
const resolved = await resolveSubject(args, ui);
|
|
225
|
+
if (!('subject' in resolved)) return resolved.result;
|
|
226
|
+
const { subject } = resolved;
|
|
227
|
+
const check = validateConventionalSubject(subject);
|
|
228
|
+
if (!check.ok) {
|
|
229
|
+
return {
|
|
230
|
+
success: false,
|
|
231
|
+
message: `Commit refused: ${check.reason}.`,
|
|
232
|
+
data: { git: 'commit', committed: false, subject },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const confirmation = await confirmCommit(port, cwd, subject, check.warnings, ui);
|
|
237
|
+
if (!confirmation.confirmed) return confirmation.result;
|
|
238
|
+
|
|
239
|
+
// Step 4 — exactly `commit -m <subject>`: never `-a`, never `add`.
|
|
240
|
+
const commit = await port.run(['commit', '-m', subject], { cwd });
|
|
241
|
+
const failure = gitFailureMessage('commit', commit);
|
|
242
|
+
if (failure !== undefined || commit.kind !== 'exited') {
|
|
243
|
+
return { success: false, message: failure ?? 'git commit failed' };
|
|
244
|
+
}
|
|
245
|
+
const firstLine = commit.stdout.split('\n').find((line) => line.trim().length > 0) ?? '';
|
|
246
|
+
return {
|
|
247
|
+
success: true,
|
|
248
|
+
message: `Committed: ${firstLine.length > 0 ? firstLine : subject}`,
|
|
249
|
+
data: { git: 'commit', committed: true, subject, files: confirmation.files },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
}
|