dsh-git-tools 0.0.0-stage → 1.2.4
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 +519 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +8 -0
- package/index.js +1193 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
package/index.js
ADDED
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-git-tools — Git capabilities for the DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* The tools run `git` through the Host subprocess service, so they execute in the
|
|
5
|
+
* host process rather than the agent's file sandbox. That matters on Windows:
|
|
6
|
+
* the agent shell cannot negotiate TLS from inside the sandbox, and git does not
|
|
7
|
+
* read the WinINET system proxy, so `fetch`/`pull`/`push` would fail there. This
|
|
8
|
+
* plugin makes those operations work while keeping the agent's own shell fenced.
|
|
9
|
+
*
|
|
10
|
+
* All output is produced with `-z` / explicit formats where possible so it does
|
|
11
|
+
* not depend on the ambient locale.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-git-tools
|
|
14
|
+
*/
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
17
|
+
|
|
18
|
+
/** Services this plugin consumes. Every service read from `ctx` must be declared here. */
|
|
19
|
+
export const inject = ['commands', 'tools', 'subprocess'];
|
|
20
|
+
|
|
21
|
+
/** Stable Loader identity. */
|
|
22
|
+
export const name = 'git-tools';
|
|
23
|
+
|
|
24
|
+
/** Milliseconds any single git invocation may run. */
|
|
25
|
+
const COMMAND_TIMEOUT_MS = 120_000;
|
|
26
|
+
/** Milliseconds the network-facing commands may run. */
|
|
27
|
+
const NETWORK_TIMEOUT_MS = 300_000;
|
|
28
|
+
/** Maximum stdout bytes retained per invocation. */
|
|
29
|
+
const OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
/** Milliseconds a git child gets to exit after termination starts. */
|
|
31
|
+
const TERMINATE_GRACE_MS = 2_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run `git <args>` through the Host subprocess service.
|
|
35
|
+
* @param ctx - context carrying `subprocess`.
|
|
36
|
+
* @param executable - resolved git executable path.
|
|
37
|
+
* @param args - git arguments; never shell-interpreted.
|
|
38
|
+
* @param cwd - repository working directory.
|
|
39
|
+
* @param signal - cancellation.
|
|
40
|
+
* @param maxBytes - stdout cap.
|
|
41
|
+
* @param timeoutMs - time limit for this invocation.
|
|
42
|
+
* @returns exit code, stdout, and stderr.
|
|
43
|
+
*/
|
|
44
|
+
async function runGit(ctx, executable, args, cwd, signal, maxBytes = OUTPUT_MAX_BYTES, timeoutMs = COMMAND_TIMEOUT_MS) {
|
|
45
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
46
|
+
const combined = AbortSignal.any([signal, timeout]);
|
|
47
|
+
const handle = ctx.subprocess.spawn({
|
|
48
|
+
argv: [executable, ...args],
|
|
49
|
+
cwd,
|
|
50
|
+
stdio: {
|
|
51
|
+
stdin: 'ignore',
|
|
52
|
+
stdout: { maxBytes },
|
|
53
|
+
stderr: { maxBytes: 64 * 1024 },
|
|
54
|
+
},
|
|
55
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
56
|
+
signal: combined,
|
|
57
|
+
// GIT_TERMINAL_PROMPT=0 keeps a missing credential from hanging the tool;
|
|
58
|
+
// the bounded output protects the Host from a runaway diff.
|
|
59
|
+
env: { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' },
|
|
60
|
+
});
|
|
61
|
+
const outcome = await handle.done;
|
|
62
|
+
const stdout = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false };
|
|
63
|
+
const stderr = handle.collected.stderr?.readFrom(0)?.text ?? '';
|
|
64
|
+
if (combined.aborted) {
|
|
65
|
+
throw new Error(`git ${args[0]}${timeout.aborted ? ` timed out after ${timeoutMs}ms` : ' was aborted'}`);
|
|
66
|
+
}
|
|
67
|
+
return { exitCode: outcome.exitCode, stdout: stdout.text, stderr, truncated: stdout.lossy };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reject a failed invocation with git's own diagnostic.
|
|
72
|
+
* @param result - settled invocation facts.
|
|
73
|
+
* @param what - human description for the error.
|
|
74
|
+
* @returns the same result when it exited zero.
|
|
75
|
+
*/
|
|
76
|
+
function expectOk(result, what) {
|
|
77
|
+
if (result.exitCode !== 0) {
|
|
78
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`;
|
|
79
|
+
throw new Error(`${what} failed: ${detail}`);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the working directory for a call: an explicit `cwd` when given,
|
|
86
|
+
* otherwise the Session's own working directory.
|
|
87
|
+
* @param exec - tool execution context.
|
|
88
|
+
* @param args - validated tool arguments.
|
|
89
|
+
* @returns an absolute directory path.
|
|
90
|
+
*/
|
|
91
|
+
function workingDirectory(exec, args) {
|
|
92
|
+
if (typeof args.cwd === 'string' && args.cwd.trim() !== '') return args.cwd.trim();
|
|
93
|
+
const sessionCwd = exec.agent?.session?.header?.cwd;
|
|
94
|
+
if (typeof sessionCwd === 'string' && sessionCwd !== '') return sessionCwd;
|
|
95
|
+
throw new Error('no working directory: pass `cwd` explicitly, or run this tool in a Session that has one');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the repository enclosing a directory.
|
|
100
|
+
* @param ctx - context carrying `subprocess`.
|
|
101
|
+
* @param executable - resolved git executable path.
|
|
102
|
+
* @param cwd - directory to inspect.
|
|
103
|
+
* @param signal - cancellation.
|
|
104
|
+
* @returns the top-level working tree path.
|
|
105
|
+
* @throws when the directory is not inside a git repository.
|
|
106
|
+
*/
|
|
107
|
+
async function repositoryRoot(ctx, executable, cwd, signal) {
|
|
108
|
+
const result = await runGit(ctx, executable, ['rev-parse', '--show-toplevel'], cwd, signal);
|
|
109
|
+
if (result.exitCode !== 0) {
|
|
110
|
+
throw new Error(`not a git repository: ${cwd}`);
|
|
111
|
+
}
|
|
112
|
+
return result.stdout.trim() || cwd;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Tool output shape shared by every git tool: one rendered text blob. */
|
|
116
|
+
const textOutput = {
|
|
117
|
+
schema: { type: 'string' },
|
|
118
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Register the git tools.
|
|
123
|
+
* @param ctx - Host context carrying `subprocess`.
|
|
124
|
+
*/
|
|
125
|
+
export function apply(ctx) {
|
|
126
|
+
const lifetime = new AbortController();
|
|
127
|
+
ctx.effect(() => () => {
|
|
128
|
+
lifetime.abort();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let runner;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve git once for this plugin generation.
|
|
134
|
+
* @returns the executable path, or null when git is unavailable.
|
|
135
|
+
*/
|
|
136
|
+
const git = () => {
|
|
137
|
+
runner ??= ctx.subprocess
|
|
138
|
+
.resolveExecutable('git', undefined, lifetime.signal)
|
|
139
|
+
.then(
|
|
140
|
+
(executable) => executable ?? null,
|
|
141
|
+
() => null,
|
|
142
|
+
);
|
|
143
|
+
return runner;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve git and the repository for one call.
|
|
148
|
+
* @param exec - tool execution context.
|
|
149
|
+
* @param args - validated tool arguments.
|
|
150
|
+
* @returns executable, work tree root, and the original directory.
|
|
151
|
+
*/
|
|
152
|
+
const target = async (exec, args) => {
|
|
153
|
+
const executable = await git();
|
|
154
|
+
if (executable === null) throw new Error('git was not found on this Host');
|
|
155
|
+
const cwd = workingDirectory(exec, args);
|
|
156
|
+
const root = await repositoryRoot(ctx, executable, cwd, exec.signal);
|
|
157
|
+
return { executable, root, cwd };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
ctx.tools.register(
|
|
161
|
+
defineTool({
|
|
162
|
+
name: 'git_status',
|
|
163
|
+
description:
|
|
164
|
+
'Report git repository state: current branch, upstream and ahead/behind counts, and every changed path with its staged and unstaged status. Use this before committing. Runs git in the Host process, so it also works when the session shell cannot reach the network.',
|
|
165
|
+
parameters: { cwd: { type: 'string', description: 'Repository directory; defaults to the Session working directory.' } },
|
|
166
|
+
output: textOutput,
|
|
167
|
+
async execute(args, exec) {
|
|
168
|
+
const { executable, root } = await target(exec, args);
|
|
169
|
+
const signal = exec.signal;
|
|
170
|
+
|
|
171
|
+
const branch = await runGit(ctx, executable, ['branch', '--show-current'], root, signal);
|
|
172
|
+
const name = branch.stdout.trim();
|
|
173
|
+
|
|
174
|
+
const upstream = await runGit(
|
|
175
|
+
ctx,
|
|
176
|
+
executable,
|
|
177
|
+
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
|
|
178
|
+
root,
|
|
179
|
+
signal,
|
|
180
|
+
);
|
|
181
|
+
const upstreamName = upstream.exitCode === 0 ? upstream.stdout.trim() : '';
|
|
182
|
+
|
|
183
|
+
let ahead = '';
|
|
184
|
+
if (upstreamName !== '') {
|
|
185
|
+
const counts = await runGit(
|
|
186
|
+
ctx,
|
|
187
|
+
executable,
|
|
188
|
+
['rev-list', '--left-right', '--count', `${upstreamName}...HEAD`],
|
|
189
|
+
root,
|
|
190
|
+
signal,
|
|
191
|
+
);
|
|
192
|
+
if (counts.exitCode === 0) {
|
|
193
|
+
const [behind, forward] = counts.stdout.trim().split(/\s+/);
|
|
194
|
+
ahead = `ahead ${forward ?? '?'}, behind ${behind ?? '?'} of ${upstreamName}`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const status = expectOk(
|
|
199
|
+
await runGit(ctx, executable, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], root, signal),
|
|
200
|
+
'git status',
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const lines = [`branch: ${name === '' ? '(detached HEAD)' : name}`];
|
|
204
|
+
if (ahead !== '') lines.push(ahead);
|
|
205
|
+
if (upstreamName === '') lines.push('no upstream configured');
|
|
206
|
+
|
|
207
|
+
const records = status.stdout.split('\0');
|
|
208
|
+
const files = [];
|
|
209
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
210
|
+
const record = records[index];
|
|
211
|
+
if (record === '') continue;
|
|
212
|
+
const code = record.slice(0, 2);
|
|
213
|
+
let path = record.slice(3);
|
|
214
|
+
// A rename or copy record is followed by its source path.
|
|
215
|
+
if (code[0] === 'R' || code[0] === 'C') {
|
|
216
|
+
const source = records[index + 1];
|
|
217
|
+
index += 1;
|
|
218
|
+
if (source !== undefined && source !== '') path = `${source} -> ${path}`;
|
|
219
|
+
}
|
|
220
|
+
files.push(`${code} ${path}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lines.push(files.length === 0 ? 'working tree clean' : `${files.length} changed path(s):`);
|
|
224
|
+
lines.push(...files);
|
|
225
|
+
if (status.truncated) lines.push('(output was truncated)');
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
},
|
|
228
|
+
presentCall: (args) => ({ card: 'generic', title: 'Git status', kind: 'read', rawInput: args }),
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
ctx.tools.register(
|
|
233
|
+
defineTool({
|
|
234
|
+
name: 'git_diff',
|
|
235
|
+
description:
|
|
236
|
+
'Show a git diff. Defaults to unstaged changes in the working tree; set `staged` for the index, or pass `rev` for a range such as "HEAD~1" or "main...HEAD". Use before committing to review exactly what changed.',
|
|
237
|
+
parameters: {
|
|
238
|
+
cwd: { type: 'string', description: 'Repository directory; defaults to the Session working directory.' },
|
|
239
|
+
staged: { type: 'boolean', description: 'Diff the index instead of the working tree.' },
|
|
240
|
+
rev: { type: 'string', description: 'Revision or range to diff, for example "HEAD~1" or "origin/main...HEAD".' },
|
|
241
|
+
path: { type: 'string', description: 'Limit the diff to this path or pathspec.' },
|
|
242
|
+
},
|
|
243
|
+
output: textOutput,
|
|
244
|
+
async execute(args, exec) {
|
|
245
|
+
const { executable, root } = await target(exec, args);
|
|
246
|
+
const argv = ['diff', '--no-color', '--no-ext-diff'];
|
|
247
|
+
if (args.staged === true) argv.push('--cached');
|
|
248
|
+
if (typeof args.rev === 'string' && args.rev.trim() !== '') argv.push(args.rev.trim());
|
|
249
|
+
if (typeof args.path === 'string' && args.path.trim() !== '') argv.push('--', args.path.trim());
|
|
250
|
+
const result = expectOk(await runGit(ctx, executable, argv, root, exec.signal), 'git diff');
|
|
251
|
+
const text = result.stdout.trim();
|
|
252
|
+
if (text === '') return 'no changes';
|
|
253
|
+
return result.truncated ? `${text}\n\n(diff was truncated)` : text;
|
|
254
|
+
},
|
|
255
|
+
presentCall: (args) => ({ card: 'generic', title: 'Git diff', kind: 'read', rawInput: args }),
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
ctx.tools.register(
|
|
260
|
+
defineTool({
|
|
261
|
+
name: 'git_log',
|
|
262
|
+
description: 'Show recent commits as one line each with the author date, hash, and subject. Use to understand recent history or find a revision to diff against.',
|
|
263
|
+
parameters: {
|
|
264
|
+
cwd: { type: 'string', description: 'Repository directory; defaults to the Session working directory.' },
|
|
265
|
+
limit: { type: 'number', description: 'How many commits to list; defaults to 20, maximum 200.' },
|
|
266
|
+
},
|
|
267
|
+
output: textOutput,
|
|
268
|
+
async execute(args, exec) {
|
|
269
|
+
const { executable, root } = await target(exec, args);
|
|
270
|
+
const requested = typeof args.limit === 'number' && Number.isFinite(args.limit) ? Math.trunc(args.limit) : 20;
|
|
271
|
+
const limit = Math.min(Math.max(requested, 1), 200);
|
|
272
|
+
const result = expectOk(
|
|
273
|
+
await runGit(
|
|
274
|
+
ctx,
|
|
275
|
+
executable,
|
|
276
|
+
['log', `--max-count=${limit}`, '--date=short', '--pretty=format:%h %ad %an: %s'],
|
|
277
|
+
root,
|
|
278
|
+
exec.signal,
|
|
279
|
+
),
|
|
280
|
+
'git log',
|
|
281
|
+
);
|
|
282
|
+
const text = result.stdout.trim();
|
|
283
|
+
return text === '' ? 'no commits yet' : text;
|
|
284
|
+
},
|
|
285
|
+
presentCall: (args) => ({ card: 'generic', title: 'Git log', kind: 'read', rawInput: args }),
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
ctx.tools.register(
|
|
290
|
+
defineTool({
|
|
291
|
+
name: 'git_stage',
|
|
292
|
+
description:
|
|
293
|
+
'Stage or unstage paths in the git index. Pass `all: true` to stage everything including untracked files, or name explicit paths. Set `unstage: true` to remove paths from the index instead. This never creates a commit.',
|
|
294
|
+
parameters: {
|
|
295
|
+
cwd: { type: 'string', description: 'Repository directory; defaults to the Session working directory.' },
|
|
296
|
+
all: { type: 'boolean', description: 'Stage every change in the repository, including untracked files.' },
|
|
297
|
+
paths: { type: 'array', items: { type: 'string' }, description: 'Repository-relative paths to stage or unstage.' },
|
|
298
|
+
unstage: { type: 'boolean', description: 'Remove the given paths from the index instead of adding them.' },
|
|
299
|
+
},
|
|
300
|
+
output: textOutput,
|
|
301
|
+
async execute(args, exec) {
|
|
302
|
+
const { executable, root } = await target(exec, args);
|
|
303
|
+
const paths = Array.isArray(args.paths) ? args.paths.filter((entry) => typeof entry === 'string' && entry !== '') : [];
|
|
304
|
+
if (args.all !== true && paths.length === 0) {
|
|
305
|
+
throw new Error('pass `all: true` or at least one path in `paths`');
|
|
306
|
+
}
|
|
307
|
+
const argv = args.unstage === true ? ['reset', '--'] : ['add', '--'];
|
|
308
|
+
if (args.all === true && args.unstage !== true) argv.push('--all');
|
|
309
|
+
argv.push(...paths);
|
|
310
|
+
expectOk(await runGit(ctx, executable, argv, root, exec.signal), `git ${argv[0]}`);
|
|
311
|
+
|
|
312
|
+
const status = await runGit(ctx, executable, ['status', '--short'], root, exec.signal);
|
|
313
|
+
return `staged.\n${status.stdout.trim() || '(working tree clean)'}`;
|
|
314
|
+
},
|
|
315
|
+
presentCall: (args) => ({ card: 'generic', title: 'Git stage', kind: 'other', rawInput: args }),
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
ctx.tools.register(
|
|
320
|
+
defineTool({
|
|
321
|
+
name: 'git_commit',
|
|
322
|
+
description:
|
|
323
|
+
'Create a git commit. The commit message is required. By default only already-staged changes are committed; set `all: true` to stage tracked modifications first. Set `amend: true` to replace the previous commit. Creating a commit is a local operation and does not contact a remote.',
|
|
324
|
+
parameters: {
|
|
325
|
+
cwd: { type: 'string', description: 'Repository directory; defaults to the Session working directory.' },
|
|
326
|
+
message: { type: 'string', required: true, description: 'Commit message.' },
|
|
327
|
+
all: { type: 'boolean', description: 'Stage tracked changes before committing.' },
|
|
328
|
+
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one.' },
|
|
329
|
+
},
|
|
330
|
+
output: textOutput,
|
|
331
|
+
async execute(args, exec) {
|
|
332
|
+
const { executable, root } = await target(exec, args);
|
|
333
|
+
const message = typeof args.message === 'string' ? args.message.trim() : '';
|
|
334
|
+
if (message === '') throw new Error('commit message must not be empty');
|
|
335
|
+
|
|
336
|
+
if (args.all === true && args.amend !== true) {
|
|
337
|
+
expectOk(await runGit(ctx, executable, ['add', '--all'], root, exec.signal), 'git add');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const argv = ['commit', '--message', message];
|
|
341
|
+
if (args.amend === true) argv.push('--amend');
|
|
342
|
+
const result = await runGit(ctx, executable, argv, root, exec.signal);
|
|
343
|
+
if (result.exitCode !== 0) {
|
|
344
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
345
|
+
throw new Error(`git commit failed: ${detail}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const head = await runGit(ctx, executable, ['log', '--max-count=1', '--pretty=format:%h %s'], root, exec.signal);
|
|
349
|
+
return `${result.stdout.trim()}\n${head.stdout.trim()}`;
|
|
350
|
+
},
|
|
351
|
+
presentCall: (args) => ({ card: 'generic', title: 'Git commit', kind: 'other', rawInput: args }),
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
ctx.tools.register(
|
|
356
|
+
defineTool({
|
|
357
|
+
name: 'git_push',
|
|
358
|
+
description:
|
|
359
|
+
'Push commits to a remote. This runs in the Host process and therefore works when the sandboxed session shell cannot reach the network. It always uses git\'s default non-forced push; it cannot force-push. Set `setUpstream: true` when the current branch has no upstream yet.',
|
|
360
|
+
parameters: {
|
|
361
|
+
cwd: { type: 'string', description: 'Repository directory; defaults to the Session working directory.' },
|
|
362
|
+
remote: { type: 'string', description: 'Remote name; defaults to "origin".' },
|
|
363
|
+
branch: { type: 'string', description: 'Branch to push; defaults to the current branch.' },
|
|
364
|
+
setUpstream: { type: 'boolean', description: 'Add --set-upstream so a new branch starts tracking the remote.' },
|
|
365
|
+
},
|
|
366
|
+
output: textOutput,
|
|
367
|
+
async execute(args, exec) {
|
|
368
|
+
const { executable, root } = await target(exec, args);
|
|
369
|
+
const remote = typeof args.remote === 'string' && args.remote.trim() !== '' ? args.remote.trim() : 'origin';
|
|
370
|
+
let branch = typeof args.branch === 'string' ? args.branch.trim() : '';
|
|
371
|
+
if (branch === '') {
|
|
372
|
+
const current = expectOk(await runGit(ctx, executable, ['branch', '--show-current'], root, exec.signal), 'git branch');
|
|
373
|
+
branch = current.stdout.trim();
|
|
374
|
+
}
|
|
375
|
+
if (branch === '') throw new Error('no branch to push: the repository is in detached HEAD state; pass `branch` explicitly');
|
|
376
|
+
|
|
377
|
+
const argv = ['push', '--porcelain'];
|
|
378
|
+
if (args.setUpstream === true) argv.push('--set-upstream');
|
|
379
|
+
argv.push(remote, branch);
|
|
380
|
+
const result = await runGit(ctx, executable, argv, root, exec.signal, OUTPUT_MAX_BYTES, NETWORK_TIMEOUT_MS);
|
|
381
|
+
if (result.exitCode !== 0) {
|
|
382
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
383
|
+
throw new Error(`git push failed: ${detail}`);
|
|
384
|
+
}
|
|
385
|
+
return `pushed ${branch} to ${remote}\n${result.stdout.trim()}`;
|
|
386
|
+
},
|
|
387
|
+
presentCall: (args) => ({ card: 'generic', title: 'Git push', kind: 'other', rawInput: args }),
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
ctx.tools.register(
|
|
392
|
+
defineTool({
|
|
393
|
+
name: 'git_fetch',
|
|
394
|
+
description:
|
|
395
|
+
'Fetch refs and objects from a remote without touching the working tree, then report ahead/behind against the upstream. Runs in the Host process, so it works when the sandboxed session shell cannot reach the network. Use it to check whether the remote moved before pulling or pushing.',
|
|
396
|
+
parameters: {
|
|
397
|
+
cwd: { type: 'string', description: 'Repository directory; defaults to the Session working directory.' },
|
|
398
|
+
remote: { type: 'string', description: 'Remote name; defaults to "origin".' },
|
|
399
|
+
prune: { type: 'boolean', description: 'Also delete remote-tracking refs that no longer exist on the remote.' },
|
|
400
|
+
},
|
|
401
|
+
output: textOutput,
|
|
402
|
+
async execute(args, exec) {
|
|
403
|
+
const { executable, root } = await target(exec, args);
|
|
404
|
+
const remote = typeof args.remote === 'string' && args.remote.trim() !== '' ? args.remote.trim() : 'origin';
|
|
405
|
+
const argv = ['fetch', '--porcelain'];
|
|
406
|
+
if (args.prune === true) argv.push('--prune');
|
|
407
|
+
argv.push(remote);
|
|
408
|
+
const result = await runGit(ctx, executable, argv, root, exec.signal, OUTPUT_MAX_BYTES, NETWORK_TIMEOUT_MS);
|
|
409
|
+
if (result.exitCode !== 0) {
|
|
410
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
411
|
+
throw new Error(`git fetch failed: ${detail}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const upstream = await runGit(
|
|
415
|
+
ctx,
|
|
416
|
+
executable,
|
|
417
|
+
['rev-list', '--left-right', '--count', '@{upstream}...HEAD'],
|
|
418
|
+
root,
|
|
419
|
+
exec.signal,
|
|
420
|
+
);
|
|
421
|
+
const lines = [`fetched ${remote}`];
|
|
422
|
+
if (upstream.exitCode === 0) {
|
|
423
|
+
const [behind, forward] = upstream.stdout.trim().split(/\s+/);
|
|
424
|
+
lines.push(`ahead ${forward ?? '?'}, behind ${behind ?? '?'} of upstream`);
|
|
425
|
+
}
|
|
426
|
+
const summary = result.stdout.trim();
|
|
427
|
+
if (summary !== '') lines.push(summary);
|
|
428
|
+
return lines.join('\n');
|
|
429
|
+
},
|
|
430
|
+
presentCall: (args) => ({ card: 'generic', title: 'Git fetch', kind: 'other', rawInput: args }),
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
ctx.tools.register(
|
|
435
|
+
defineTool({
|
|
436
|
+
name: 'git_pull',
|
|
437
|
+
description:
|
|
438
|
+
'Fetch from a remote and integrate the upstream branch into the current branch. Runs in the Host process, so it works when the sandboxed session shell cannot reach the network. Use `rebase: true` to rebase instead of merging; a conflicted result is reported and left for you to resolve.',
|
|
439
|
+
parameters: {
|
|
440
|
+
cwd: { type: 'string', description: 'Repository directory; defaults to the Session working directory.' },
|
|
441
|
+
remote: { type: 'string', description: 'Remote name; defaults to "origin".' },
|
|
442
|
+
branch: { type: 'string', description: 'Remote branch to integrate; defaults to the configured upstream.' },
|
|
443
|
+
rebase: { type: 'boolean', description: 'Rebase local commits instead of merging.' },
|
|
444
|
+
},
|
|
445
|
+
output: textOutput,
|
|
446
|
+
async execute(args, exec) {
|
|
447
|
+
const { executable, root } = await target(exec, args);
|
|
448
|
+
const remote = typeof args.remote === 'string' && args.remote.trim() !== '' ? args.remote.trim() : 'origin';
|
|
449
|
+
const argv = ['pull'];
|
|
450
|
+
if (args.rebase === true) argv.push('--rebase');
|
|
451
|
+
argv.push(remote);
|
|
452
|
+
if (typeof args.branch === 'string' && args.branch.trim() !== '') argv.push(args.branch.trim());
|
|
453
|
+
const result = await runGit(ctx, executable, argv, root, exec.signal, OUTPUT_MAX_BYTES, NETWORK_TIMEOUT_MS);
|
|
454
|
+
if (result.exitCode !== 0) {
|
|
455
|
+
const detail = [result.stdout.trim(), result.stderr.trim()].filter((part) => part !== '').join('\n');
|
|
456
|
+
throw new Error(`git pull failed: ${detail}`);
|
|
457
|
+
}
|
|
458
|
+
return result.stdout.trim() || 'already up to date';
|
|
459
|
+
},
|
|
460
|
+
presentCall: (args) => ({ card: 'generic', title: 'Git pull', kind: 'other', rawInput: args }),
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
//#region human slash commands
|
|
465
|
+
// These are the `/` commands a person types in the composer. They run the same
|
|
466
|
+
// host-side git path as the tools above, so network operations work here too.
|
|
467
|
+
// A command handler resolves the Session cwd itself because it receives an
|
|
468
|
+
// invocation rather than a tool execution context.
|
|
469
|
+
|
|
470
|
+
/** One-line usage help for every command this plugin registers. */
|
|
471
|
+
const USAGE_LINES = [
|
|
472
|
+
'Git commands (git runs on the Host, so network operations work):',
|
|
473
|
+
'',
|
|
474
|
+
' /git-status [cwd=<dir>] branch, upstream, ahead/behind, changed files',
|
|
475
|
+
' /git-diff [--staged] [rev=<rev>] [cwd=<dir>]',
|
|
476
|
+
' /git-log [n] [cwd=<dir>] recent commits, default 20',
|
|
477
|
+
' /git-commit [--all] [--amend] <message> [cwd=<dir>]',
|
|
478
|
+
' /git-push [--set-upstream] [remote=<name>] [branch=<name>] [cwd=<dir>]',
|
|
479
|
+
' /git-pull [--rebase] [remote=<name>] [branch=<name>] [cwd=<dir>]',
|
|
480
|
+
' /git-fetch [--prune] [remote=<name>] [cwd=<dir>]',
|
|
481
|
+
' /git-name-set name=<name> email=<email> [remote=<url>] [cwd=<dir>]',
|
|
482
|
+
' /git-tag-show [version] [cwd=<dir>] list versions (tags), or inspect one',
|
|
483
|
+
' /git-tag <version> [<remote>] [message=<text>] [rev=<rev>] [cwd=<dir>]',
|
|
484
|
+
' /git-tag --push [version] [<remote>] [cwd=<dir>] (no version = all tags)',
|
|
485
|
+
'',
|
|
486
|
+
'Tips:',
|
|
487
|
+
' - The repository is the Session working directory. If it is not a repo,',
|
|
488
|
+
' pass cwd=<path> pointing at one, or move the workspace to the repo root.',
|
|
489
|
+
' - git-push never force-pushes.',
|
|
490
|
+
' - git-name-set writes the identity GLOBALLY (git config --global), so it',
|
|
491
|
+
' applies to every repository on this machine; the remote= target is written',
|
|
492
|
+
' only into the chosen repository.',
|
|
493
|
+
' - git-tag creates a version AND pushes it to the remote (default origin);',
|
|
494
|
+
' git-tag --push sends tags an earlier attempt left local. GitHub shows a',
|
|
495
|
+
' version on the Tags/Releases pages only after the tag reaches the remote.',
|
|
496
|
+
].join('\n');
|
|
497
|
+
|
|
498
|
+
/** A success result with no side channel. */
|
|
499
|
+
const ok = (text) => ({ kind: 'success', text });
|
|
500
|
+
/** A refusal shown to the person who typed the command. */
|
|
501
|
+
const fail = (text) => ({ kind: 'error', text });
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Split one raw command input into flags and a free-text remainder.
|
|
505
|
+
* @param rawInput - text following the command name.
|
|
506
|
+
* @param flags - flag names taking no value.
|
|
507
|
+
* @param valued - option names taking `name=value`.
|
|
508
|
+
* @returns parsed flags, options, and the remaining words.
|
|
509
|
+
*/
|
|
510
|
+
const parseArgs = (rawInput, flags, valued) => {
|
|
511
|
+
const present = new Set();
|
|
512
|
+
const options = new Map();
|
|
513
|
+
const words = [];
|
|
514
|
+
for (const word of rawInput.trim().split(/\s+/)) {
|
|
515
|
+
if (word === '') continue;
|
|
516
|
+
if (flags.includes(word)) {
|
|
517
|
+
present.add(word);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const separator = word.indexOf('=');
|
|
521
|
+
const key = separator === -1 ? '' : word.slice(0, separator);
|
|
522
|
+
if (valued.includes(key)) {
|
|
523
|
+
options.set(key, word.slice(separator + 1));
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
words.push(word);
|
|
527
|
+
}
|
|
528
|
+
return { flags: present, options, words };
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/** The Session working directory this invocation belongs to, or an empty string. */
|
|
532
|
+
const invocationCwd = (invocation) => {
|
|
533
|
+
const cwd = invocation.agent?.session?.header?.cwd;
|
|
534
|
+
return typeof cwd === 'string' ? cwd : '';
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Resolve the directory a command should operate in.
|
|
539
|
+
* @param invocation - the command invocation.
|
|
540
|
+
* @param options - parsed `name=value` options.
|
|
541
|
+
* @returns the directory, or undefined when neither source provides one.
|
|
542
|
+
*/
|
|
543
|
+
const commandDirectory = (invocation, options) => {
|
|
544
|
+
const explicit = options.get('cwd');
|
|
545
|
+
if (typeof explicit === 'string' && explicit !== '') return explicit;
|
|
546
|
+
const sessionCwd = invocationCwd(invocation);
|
|
547
|
+
return sessionCwd === '' ? undefined : sessionCwd;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Resolve git, the work tree root, and the signal for one command invocation.
|
|
552
|
+
* @param invocation - the command invocation.
|
|
553
|
+
* @param options - parsed `name=value` options.
|
|
554
|
+
* @returns resolved host facts, or a refusal to return to the caller.
|
|
555
|
+
*/
|
|
556
|
+
const commandTarget = async (invocation, options) => {
|
|
557
|
+
const executable = await git();
|
|
558
|
+
if (executable === null) return { refusal: fail('git was not found on this Host.') };
|
|
559
|
+
const directory = commandDirectory(invocation, options);
|
|
560
|
+
if (directory === undefined) return { refusal: fail('This Session has no working directory. Pass cwd=<path>.') };
|
|
561
|
+
try {
|
|
562
|
+
const root = await repositoryRoot(ctx, executable, directory, invocation.signal);
|
|
563
|
+
return { executable, root, signal: invocation.signal };
|
|
564
|
+
} catch (error) {
|
|
565
|
+
return {
|
|
566
|
+
refusal: fail(
|
|
567
|
+
`Not a git repository: ${directory}\n` +
|
|
568
|
+
'Pass cwd=<path> pointing at a repository, or move this workspace to the repository root.\n' +
|
|
569
|
+
`Underlying error: ${error instanceof Error ? error.message : String(error)}`,
|
|
570
|
+
),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Render `branch` plus upstream divergence for a repository root.
|
|
577
|
+
* @param executable - resolved git executable.
|
|
578
|
+
* @param root - work tree root.
|
|
579
|
+
* @param signal - cancellation.
|
|
580
|
+
* @returns two report lines.
|
|
581
|
+
*/
|
|
582
|
+
const branchReport = async (executable, root, signal) => {
|
|
583
|
+
const branch = await runGit(ctx, executable, ['branch', '--show-current'], root, signal);
|
|
584
|
+
const name = branch.stdout.trim();
|
|
585
|
+
const upstream = await runGit(
|
|
586
|
+
ctx,
|
|
587
|
+
executable,
|
|
588
|
+
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
|
|
589
|
+
root,
|
|
590
|
+
signal,
|
|
591
|
+
);
|
|
592
|
+
const upstreamName = upstream.exitCode === 0 ? upstream.stdout.trim() : '';
|
|
593
|
+
if (upstreamName === '') {
|
|
594
|
+
return [`branch: ${name === '' ? '(detached HEAD)' : name}`, 'no upstream configured'];
|
|
595
|
+
}
|
|
596
|
+
const counts = await runGit(ctx, executable, ['rev-list', '--left-right', '--count', `${upstreamName}...HEAD`], root, signal);
|
|
597
|
+
if (counts.exitCode !== 0) return [`branch: ${name}`, `upstream: ${upstreamName}`];
|
|
598
|
+
const [behind, ahead] = counts.stdout.trim().split(/\s+/);
|
|
599
|
+
return [`branch: ${name}`, `ahead ${ahead ?? '?'}, behind ${behind ?? '?'} of ${upstreamName}`];
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Report the changed paths of a repository root.
|
|
604
|
+
* @param executable - resolved git executable.
|
|
605
|
+
* @param root - work tree root.
|
|
606
|
+
* @param signal - cancellation.
|
|
607
|
+
* @returns the status lines, or a refusal-shaped error string.
|
|
608
|
+
*/
|
|
609
|
+
const statusLines = async (executable, root, signal) => {
|
|
610
|
+
const status = await runGit(ctx, executable, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], root, signal);
|
|
611
|
+
if (status.exitCode !== 0) return [`git status failed: ${status.stderr.trim() || `exit ${status.exitCode}`}`];
|
|
612
|
+
const records = status.stdout.split('\0');
|
|
613
|
+
const files = [];
|
|
614
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
615
|
+
const record = records[index];
|
|
616
|
+
if (record === '') continue;
|
|
617
|
+
const code = record.slice(0, 2);
|
|
618
|
+
let path = record.slice(3);
|
|
619
|
+
if (code[0] === 'R' || code[0] === 'C') {
|
|
620
|
+
const source = records[index + 1];
|
|
621
|
+
index += 1;
|
|
622
|
+
if (source !== undefined && source !== '') path = `${source} -> ${path}`;
|
|
623
|
+
}
|
|
624
|
+
files.push(`${code} ${path}`);
|
|
625
|
+
}
|
|
626
|
+
if (files.length === 0) return ['working tree clean'];
|
|
627
|
+
return [`${files.length} changed path(s):`, ...files];
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
ctx.effect(function* () {
|
|
631
|
+
yield ctx.commands.register({
|
|
632
|
+
name: 'git-status',
|
|
633
|
+
description: 'Git: branch, upstream divergence, and changed files',
|
|
634
|
+
async handler(invocation) {
|
|
635
|
+
const parsed = parseArgs(invocation.rawInput, [], ['cwd']);
|
|
636
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
637
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
638
|
+
const lines = await branchReport(resolved.executable, resolved.root, resolved.signal);
|
|
639
|
+
lines.push(...(await statusLines(resolved.executable, resolved.root, resolved.signal)));
|
|
640
|
+
return ok(lines.join('\n'));
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
yield ctx.commands.register({
|
|
645
|
+
name: 'git-diff',
|
|
646
|
+
description: 'Git: show a diff (working tree by default, --staged for the index)',
|
|
647
|
+
input: { hint: '[--staged] [rev=<rev>] [cwd=<dir>]' },
|
|
648
|
+
async handler(invocation) {
|
|
649
|
+
const parsed = parseArgs(invocation.rawInput, ['--staged'], ['rev', 'cwd']);
|
|
650
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
651
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
652
|
+
const argv = ['diff', '--no-color', '--no-ext-diff'];
|
|
653
|
+
if (parsed.flags.has('--staged')) argv.push('--cached');
|
|
654
|
+
const rev = parsed.options.get('rev');
|
|
655
|
+
if (typeof rev === 'string' && rev !== '') argv.push(rev);
|
|
656
|
+
const result = await runGit(ctx, resolved.executable, argv, resolved.root, resolved.signal);
|
|
657
|
+
if (result.exitCode !== 0) return fail(`git diff failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
658
|
+
const text = result.stdout.trim();
|
|
659
|
+
if (text === '') return ok('no changes');
|
|
660
|
+
return ok(result.truncated ? `${text}\n\n(diff was truncated)` : text);
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
yield ctx.commands.register({
|
|
665
|
+
name: 'git-log',
|
|
666
|
+
description: 'Git: list recent commits (one line each)',
|
|
667
|
+
input: { hint: '[n] [cwd=<dir>]' },
|
|
668
|
+
async handler(invocation) {
|
|
669
|
+
const parsed = parseArgs(invocation.rawInput, [], ['cwd']);
|
|
670
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
671
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
672
|
+
const requested = Number.parseInt(parsed.words[0] ?? '', 10);
|
|
673
|
+
const limit = Number.isSafeInteger(requested) && requested > 0 ? Math.min(requested, 200) : 20;
|
|
674
|
+
const result = await runGit(
|
|
675
|
+
ctx,
|
|
676
|
+
resolved.executable,
|
|
677
|
+
['log', `--max-count=${limit}`, '--date=short', '--pretty=format:%h %ad %an: %s'],
|
|
678
|
+
resolved.root,
|
|
679
|
+
resolved.signal,
|
|
680
|
+
);
|
|
681
|
+
if (result.exitCode !== 0) return fail(`git log failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
682
|
+
const text = result.stdout.trim();
|
|
683
|
+
return ok(text === '' ? 'no commits yet' : text);
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
yield ctx.commands.register({
|
|
688
|
+
name: 'git-commit',
|
|
689
|
+
description: 'Git: create a commit from the index (or --all for tracked changes)',
|
|
690
|
+
input: { hint: '[--all] [--amend] <message> [cwd=<dir>]' },
|
|
691
|
+
async handler(invocation) {
|
|
692
|
+
const parsed = parseArgs(invocation.rawInput, ['--all', '--amend', '-a'], ['cwd']);
|
|
693
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
694
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
695
|
+
const message = parsed.words.join(' ').trim();
|
|
696
|
+
if (message === '') return fail('A commit message is required.\n\n /git-commit My commit message\n /git-commit --all My commit message');
|
|
697
|
+
|
|
698
|
+
const amend = parsed.flags.has('--amend');
|
|
699
|
+
if (!amend && (parsed.flags.has('--all') || parsed.flags.has('-a'))) {
|
|
700
|
+
const added = await runGit(ctx, resolved.executable, ['add', '--all'], resolved.root, resolved.signal);
|
|
701
|
+
if (added.exitCode !== 0) return fail(`git add failed: ${added.stderr.trim() || `exit ${added.exitCode}`}`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const argv = ['commit', '--message', message];
|
|
705
|
+
if (amend) argv.push('--amend');
|
|
706
|
+
const result = await runGit(ctx, resolved.executable, argv, resolved.root, resolved.signal);
|
|
707
|
+
if (result.exitCode !== 0) {
|
|
708
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
709
|
+
return fail(`git commit failed: ${detail}`);
|
|
710
|
+
}
|
|
711
|
+
const head = await runGit(
|
|
712
|
+
ctx,
|
|
713
|
+
resolved.executable,
|
|
714
|
+
['log', '--max-count=1', '--pretty=format:%h %s'],
|
|
715
|
+
resolved.root,
|
|
716
|
+
resolved.signal,
|
|
717
|
+
);
|
|
718
|
+
return ok(`${result.stdout.trim()}\n${head.stdout.trim()}`);
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
yield ctx.commands.register({
|
|
723
|
+
name: 'git-push',
|
|
724
|
+
description: 'Git: push the current branch to a remote (never forced)',
|
|
725
|
+
input: { hint: '[--set-upstream] [remote=<name>] [branch=<name>] [cwd=<dir>]' },
|
|
726
|
+
async handler(invocation) {
|
|
727
|
+
const parsed = parseArgs(invocation.rawInput, ['--set-upstream', '-u'], ['remote', 'branch', 'cwd']);
|
|
728
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
729
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
730
|
+
const remote = parsed.options.get('remote') ?? 'origin';
|
|
731
|
+
let branch = parsed.options.get('branch') ?? '';
|
|
732
|
+
if (branch === '') {
|
|
733
|
+
const current = await runGit(ctx, resolved.executable, ['branch', '--show-current'], resolved.root, resolved.signal);
|
|
734
|
+
branch = current.stdout.trim();
|
|
735
|
+
}
|
|
736
|
+
if (branch === '') return fail('Detached HEAD: pass branch=<name> to say what to push.');
|
|
737
|
+
const argv = ['push', '--porcelain'];
|
|
738
|
+
if (parsed.flags.has('--set-upstream') || parsed.flags.has('-u')) argv.push('--set-upstream');
|
|
739
|
+
argv.push(remote, branch);
|
|
740
|
+
const result = await runGit(ctx, resolved.executable, argv, resolved.root, resolved.signal, OUTPUT_MAX_BYTES, NETWORK_TIMEOUT_MS);
|
|
741
|
+
if (result.exitCode !== 0) {
|
|
742
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
743
|
+
return fail(`git push failed: ${detail}`);
|
|
744
|
+
}
|
|
745
|
+
return ok(`pushed ${branch} to ${remote}\n${result.stdout.trim()}`);
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
yield ctx.commands.register({
|
|
750
|
+
name: 'git-pull',
|
|
751
|
+
description: 'Git: fetch and integrate the upstream branch',
|
|
752
|
+
input: { hint: '[--rebase] [remote=<name>] [branch=<name>] [cwd=<dir>]' },
|
|
753
|
+
async handler(invocation) {
|
|
754
|
+
const parsed = parseArgs(invocation.rawInput, ['--rebase'], ['remote', 'branch', 'cwd']);
|
|
755
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
756
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
757
|
+
const remote = parsed.options.get('remote') ?? 'origin';
|
|
758
|
+
const argv = ['pull'];
|
|
759
|
+
if (parsed.flags.has('--rebase')) argv.push('--rebase');
|
|
760
|
+
argv.push(remote);
|
|
761
|
+
const branch = parsed.options.get('branch');
|
|
762
|
+
if (typeof branch === 'string' && branch !== '') argv.push(branch);
|
|
763
|
+
const result = await runGit(ctx, resolved.executable, argv, resolved.root, resolved.signal, OUTPUT_MAX_BYTES, NETWORK_TIMEOUT_MS);
|
|
764
|
+
if (result.exitCode !== 0) {
|
|
765
|
+
const detail = [result.stdout.trim(), result.stderr.trim()].filter((part) => part !== '').join('\n');
|
|
766
|
+
return fail(`git pull failed: ${detail}`);
|
|
767
|
+
}
|
|
768
|
+
return ok(result.stdout.trim() || 'already up to date');
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
yield ctx.commands.register({
|
|
773
|
+
name: 'git-fetch',
|
|
774
|
+
description: 'Git: fetch from a remote and report ahead/behind',
|
|
775
|
+
input: { hint: '[--prune] [remote=<name>] [cwd=<dir>]' },
|
|
776
|
+
async handler(invocation) {
|
|
777
|
+
const parsed = parseArgs(invocation.rawInput, ['--prune'], ['remote', 'cwd']);
|
|
778
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
779
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
780
|
+
const remote = parsed.options.get('remote') ?? 'origin';
|
|
781
|
+
const argv = ['fetch', '--porcelain'];
|
|
782
|
+
if (parsed.flags.has('--prune')) argv.push('--prune');
|
|
783
|
+
argv.push(remote);
|
|
784
|
+
const result = await runGit(ctx, resolved.executable, argv, resolved.root, resolved.signal, OUTPUT_MAX_BYTES, NETWORK_TIMEOUT_MS);
|
|
785
|
+
if (result.exitCode !== 0) {
|
|
786
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
787
|
+
return fail(`git fetch failed: ${detail}`);
|
|
788
|
+
}
|
|
789
|
+
const lines = [`fetched ${remote}`];
|
|
790
|
+
const counts = await runGit(
|
|
791
|
+
ctx,
|
|
792
|
+
resolved.executable,
|
|
793
|
+
['rev-list', '--left-right', '--count', '@{upstream}...HEAD'],
|
|
794
|
+
resolved.root,
|
|
795
|
+
resolved.signal,
|
|
796
|
+
);
|
|
797
|
+
if (counts.exitCode === 0) {
|
|
798
|
+
const [behind, ahead] = counts.stdout.trim().split(/\s+/);
|
|
799
|
+
lines.push(`ahead ${ahead ?? '?'}, behind ${behind ?? '?'} of upstream`);
|
|
800
|
+
}
|
|
801
|
+
const summary = result.stdout.trim();
|
|
802
|
+
if (summary !== '') lines.push(summary);
|
|
803
|
+
return ok(lines.join('\n'));
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
yield ctx.commands.register({
|
|
808
|
+
name: 'git-name-set',
|
|
809
|
+
description: 'Git: set the global commit identity and optionally repoint this repository at another URL',
|
|
810
|
+
input: { hint: 'name=<name> email=<email> [remote=<url>] [cwd=<dir>]' },
|
|
811
|
+
async handler(invocation) {
|
|
812
|
+
const parsed = parseArgs(invocation.rawInput, [], ['name', 'email', 'remote', 'cwd']);
|
|
813
|
+
const desiredName = (parsed.options.get('name') ?? '').trim();
|
|
814
|
+
const desiredEmail = (parsed.options.get('email') ?? '').trim();
|
|
815
|
+
const remoteUrl = (parsed.options.get('remote') ?? '').trim();
|
|
816
|
+
|
|
817
|
+
// ---- validate everything BEFORE writing anything ----
|
|
818
|
+
// A refusal must leave the machine untouched, so no git call happens above this line.
|
|
819
|
+
if (desiredName === '' || desiredEmail === '') {
|
|
820
|
+
return fail(
|
|
821
|
+
[
|
|
822
|
+
'Both name=<name> and email=<email> are required.',
|
|
823
|
+
'',
|
|
824
|
+
' /git-name-set name=ZhangSan email=zhangsan@example.com',
|
|
825
|
+
' /git-name-set name=ZhangSan email=zhangsan@example.com remote=https://github.com/zhangsan/repo.git',
|
|
826
|
+
'',
|
|
827
|
+
'Note: the identity is written GLOBALLY (git config --global), so it',
|
|
828
|
+
'applies to every repository on this machine. The remote= target is',
|
|
829
|
+
'written only into the chosen repository.',
|
|
830
|
+
].join('\n'),
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(desiredEmail)) {
|
|
834
|
+
return fail(`That does not look like an email address: ${desiredEmail}`);
|
|
835
|
+
}
|
|
836
|
+
if (remoteUrl !== '' && !/^(https?:\/\/|git@|ssh:\/\/|file:\/\/|\/|[A-Za-z]:[\\/])/.test(remoteUrl)) {
|
|
837
|
+
return fail(
|
|
838
|
+
`remote= expects a URL, not a remote name: ${remoteUrl}\n` +
|
|
839
|
+
` example: remote=https://github.com/you/repo.git\n` +
|
|
840
|
+
`Nothing was written.`,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const executable = await git();
|
|
845
|
+
if (executable === null) return fail('git was not found on this Host.');
|
|
846
|
+
const signal = invocation.signal;
|
|
847
|
+
const directory = commandDirectory(invocation, parsed.options);
|
|
848
|
+
|
|
849
|
+
// A remote target needs a repository, so resolve it before writing the identity.
|
|
850
|
+
let root;
|
|
851
|
+
if (remoteUrl !== '') {
|
|
852
|
+
if (directory === undefined) {
|
|
853
|
+
return fail('Pass cwd=<dir> (or run this in a Session with a working directory) so the remote target can be applied.\nNothing was written.');
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
root = await repositoryRoot(ctx, executable, directory, signal);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
return fail(
|
|
859
|
+
`Not a git repository: ${directory}\n` +
|
|
860
|
+
`Pass cwd=<path> pointing at a repository, or move this workspace to the repository root.\n` +
|
|
861
|
+
`Underlying error: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
862
|
+
`Nothing was written.`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ---- write, now that every refusal path is behind us ----
|
|
868
|
+
// `git config --global` does not read a repository, so any existing
|
|
869
|
+
// directory works as its working directory.
|
|
870
|
+
const runIn = root ?? directory ?? homedir();
|
|
871
|
+
const failures = [];
|
|
872
|
+
for (const [field, value] of [
|
|
873
|
+
['user.name', desiredName],
|
|
874
|
+
['user.email', desiredEmail],
|
|
875
|
+
]) {
|
|
876
|
+
const result = await runGit(ctx, executable, ['config', '--global', field, value], runIn, signal);
|
|
877
|
+
if (result.exitCode !== 0) failures.push(`git config --global ${field} failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
|
878
|
+
}
|
|
879
|
+
if (failures.length > 0) return fail(failures.join('\n'));
|
|
880
|
+
|
|
881
|
+
const report = [`global identity: ${desiredName} <${desiredEmail}>`];
|
|
882
|
+
|
|
883
|
+
if (remoteUrl === '') {
|
|
884
|
+
report.push('', 'remote= was not given, so no repository target was changed.');
|
|
885
|
+
report.push('Add remote=<url> to also point this repository at another remote.');
|
|
886
|
+
} else {
|
|
887
|
+
const existing = await runGit(ctx, executable, ['remote'], root, signal);
|
|
888
|
+
const names = existing.exitCode === 0 ? existing.stdout.trim().split('\n').filter((line) => line !== '') : [];
|
|
889
|
+
const remoteName = names.includes('origin') ? 'origin' : names[0] ?? 'origin';
|
|
890
|
+
const argv = names.length === 0
|
|
891
|
+
? ['remote', 'add', remoteName, remoteUrl]
|
|
892
|
+
: ['remote', 'set-url', remoteName, remoteUrl];
|
|
893
|
+
const applied = await runGit(ctx, executable, argv, root, signal);
|
|
894
|
+
if (applied.exitCode !== 0) {
|
|
895
|
+
return fail(`global identity written, but git ${argv[1]} failed: ${applied.stderr.trim() || `exit ${applied.exitCode}`}`);
|
|
896
|
+
}
|
|
897
|
+
report.push(`remote '${remoteName}' -> ${remoteUrl}`);
|
|
898
|
+
report.push(`repository: ${root}`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
report.push('', 'Identity applies to new commits; existing commits keep their recorded author.');
|
|
902
|
+
return ok(report.join('\n'));
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
//#region version tags
|
|
907
|
+
// A tag names one commit, which is what makes "version v1.0.0" retrievable later.
|
|
908
|
+
// Tags are local until pushed; only then does GitHub show them on its Tags/Releases pages.
|
|
909
|
+
|
|
910
|
+
/** Maximum tags listed at once. */
|
|
911
|
+
const TAG_LIST_LIMIT = 50;
|
|
912
|
+
/**
|
|
913
|
+
* Characters a tag name may not contain, mirroring git's own ref rules.
|
|
914
|
+
* Inside a character class `[` must be escaped; leaving it raw made the whole
|
|
915
|
+
* pattern invalid, which silently let whitespace and other illegal names through.
|
|
916
|
+
*/
|
|
917
|
+
const BAD_TAG_CHARS = /[\s~^:?*[\]\\|]|\.\./;
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Reject a tag name git would refuse, before running anything.
|
|
921
|
+
* @param value - candidate tag name.
|
|
922
|
+
* @returns a complaint, or undefined when the name looks usable.
|
|
923
|
+
*/
|
|
924
|
+
const tagNameProblem = (value) => {
|
|
925
|
+
if (value === '') return 'a version name is required';
|
|
926
|
+
if (value.length > 200) return 'a version name must be at most 200 characters';
|
|
927
|
+
if (BAD_TAG_CHARS.test(value)) return `a version name may not contain spaces, or any of ~ ^ : ? * [ \\ | or two consecutive dots (got: ${value})`;
|
|
928
|
+
if (value.startsWith('.') || value.startsWith('-')) return `a version name may not start with . or - (got: ${value})`;
|
|
929
|
+
if (value.endsWith('.') || value.endsWith('.lock')) return `a version name may not end with . or .lock (got: ${value})`;
|
|
930
|
+
return undefined;
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Ask git itself whether a tag name is a legal ref, as the authoritative check.
|
|
935
|
+
* @param executable - resolved git executable.
|
|
936
|
+
* @param root - work tree root.
|
|
937
|
+
* @param value - candidate tag name.
|
|
938
|
+
* @param signal - cancellation.
|
|
939
|
+
* @returns a complaint, or undefined when git accepts the ref.
|
|
940
|
+
*/
|
|
941
|
+
const gitRefProblem = async (executable, root, value, signal) => {
|
|
942
|
+
const checked = await runGit(ctx, executable, ['check-ref-format', `refs/tags/${value}`], root, signal);
|
|
943
|
+
if (checked.exitCode === 0) return undefined;
|
|
944
|
+
return `git rejects this as a ref name: ${value}`;
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
yield ctx.commands.register({
|
|
948
|
+
name: 'git-tag-show',
|
|
949
|
+
description: 'Git: list versions (tags) newest first, or show what one version changed (read-only)',
|
|
950
|
+
input: { hint: '[version] [cwd=<dir>]' },
|
|
951
|
+
async handler(invocation) {
|
|
952
|
+
const parsed = parseArgs(invocation.rawInput, [], ['cwd']);
|
|
953
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
954
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
955
|
+
const { executable, root, signal } = resolved;
|
|
956
|
+
const version = (parsed.words[0] ?? '').trim();
|
|
957
|
+
|
|
958
|
+
// With a version this is the read-only detail view; without one it lists
|
|
959
|
+
// every tag. Both paths only read, so nothing here can alter the repository.
|
|
960
|
+
if (version !== '') {
|
|
961
|
+
// Resolve the version first, so an unknown name is reported as such.
|
|
962
|
+
const target = await runGit(ctx, executable, ['show', '--no-patch', '--date=iso', '--pretty=format:%h %an <%ae>%n%ad%n%n%s', version], root, signal);
|
|
963
|
+
if (target.exitCode !== 0) {
|
|
964
|
+
const detail = target.stderr.trim() || `exit ${target.exitCode}`;
|
|
965
|
+
return fail(`Unknown version or commit: ${version}\n${detail}\n\nList available versions with /git-tag-show`);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// --name-status prints only the changed paths, so the result does not
|
|
969
|
+
// depend on whether the version is an annotated tag, a lightweight tag,
|
|
970
|
+
// or a bare commit (their `show --stat` headers differ in shape).
|
|
971
|
+
// Resolve the commit first: diff-tree does not peel a tag by itself.
|
|
972
|
+
const commit = await runGit(ctx, executable, ['rev-parse', `${version}^{commit}`], root, signal);
|
|
973
|
+
if (commit.exitCode !== 0) {
|
|
974
|
+
return fail(`Unknown version or commit: ${version}\n\nList available versions with /git-tag-show`);
|
|
975
|
+
}
|
|
976
|
+
const changed = await runGit(ctx, executable, ['diff-tree', '--no-commit-id', '--name-status', '-r', commit.stdout.trim()], root, signal);
|
|
977
|
+
if (changed.exitCode !== 0) {
|
|
978
|
+
return fail(`git diff-tree failed: ${changed.stderr.trim() || `exit ${changed.exitCode}`}`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const files = changed.stdout.trim() === '' ? [] : changed.stdout.trim().split('\n');
|
|
982
|
+
return ok(
|
|
983
|
+
[
|
|
984
|
+
`version: ${version}`,
|
|
985
|
+
'',
|
|
986
|
+
target.stdout.trim(),
|
|
987
|
+
'',
|
|
988
|
+
files.length === 0 ? '(this version changed no files)' : `changed files (${files.length}):`,
|
|
989
|
+
...files,
|
|
990
|
+
].join('\n'),
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const listed = await runGit(ctx, executable, ['tag', '--list', '--sort=-v:refname'], root, signal);
|
|
995
|
+
if (listed.exitCode !== 0) return fail(`git tag failed: ${listed.stderr.trim() || `exit ${listed.exitCode}`}`);
|
|
996
|
+
const names = listed.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '');
|
|
997
|
+
if (names.length === 0) {
|
|
998
|
+
return ok(
|
|
999
|
+
[
|
|
1000
|
+
'No versions yet. Create one with:',
|
|
1001
|
+
'',
|
|
1002
|
+
' /git-tag v1.0.0 message=first-release',
|
|
1003
|
+
'',
|
|
1004
|
+
'A version is a tag: a name pointing at one commit, so it can be',
|
|
1005
|
+
'retrieved later. /git-tag creates the tag and pushes it to the',
|
|
1006
|
+
'remote in one step.',
|
|
1007
|
+
].join('\n'),
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const shown = names.slice(0, TAG_LIST_LIMIT);
|
|
1012
|
+
const lines = [`${names.length} version(s), newest first:`, ''];
|
|
1013
|
+
for (const tag of shown) {
|
|
1014
|
+
// \t is not allowed in a ref name, so it cannot collide with a tag.
|
|
1015
|
+
const facts = await runGit(ctx, executable, ['for-each-ref', '--format=%(objectname:short)\t%(creatordate:short)', `refs/tags/${tag}`], root, signal);
|
|
1016
|
+
const fields = facts.exitCode === 0 ? facts.stdout.trim().split('\t') : [];
|
|
1017
|
+
const subject = await runGit(ctx, executable, ['log', '--max-count=1', '--pretty=format:%s', tag], root, signal);
|
|
1018
|
+
lines.push(` ${tag.padEnd(16)} ${(fields[0] ?? '?').padEnd(9)} ${(fields[1] ?? '?').padEnd(11)} ${subject.stdout.trim()}`);
|
|
1019
|
+
}
|
|
1020
|
+
if (names.length > shown.length) lines.push(` ... and ${names.length - shown.length} more`);
|
|
1021
|
+
lines.push('', 'Inspect one: /git-tag-show <version> Create and push: /git-tag <version>');
|
|
1022
|
+
return ok(lines.join('\n'));
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
yield ctx.commands.register({
|
|
1027
|
+
name: 'git-tag',
|
|
1028
|
+
description: 'Git: name a commit as a version and push it; --push sends tags that already exist',
|
|
1029
|
+
input: { hint: '<version> [<remote>] [message=<text>] [rev=<rev>] [cwd=<dir>] | --push [version] [<remote>] [cwd=<dir>]' },
|
|
1030
|
+
async handler(invocation) {
|
|
1031
|
+
const parsed = parseArgs(invocation.rawInput, ['--push'], ['message', 'rev', 'remote', 'cwd']);
|
|
1032
|
+
const pushOnly = parsed.flags.has('--push');
|
|
1033
|
+
const words = parsed.words.map((word) => word.trim()).filter((word) => word !== '');
|
|
1034
|
+
const version = words[0] ?? '';
|
|
1035
|
+
const positionalRemote = words[1];
|
|
1036
|
+
|
|
1037
|
+
// Argument shape is checked BEFORE the repository is resolved. A malformed
|
|
1038
|
+
// invocation must be reported as such even when the Session directory is not
|
|
1039
|
+
// a git repository, otherwise this command blames the wrong problem.
|
|
1040
|
+
if (words.length > 2) {
|
|
1041
|
+
return fail(
|
|
1042
|
+
[
|
|
1043
|
+
`Too many arguments: ${words.map((word) => `"${word}"`).join(' ')}`,
|
|
1044
|
+
'',
|
|
1045
|
+
'Option values may not contain spaces, so `message=first release`',
|
|
1046
|
+
'arrives here as the two words `message=first` and `release`.',
|
|
1047
|
+
'',
|
|
1048
|
+
' /git-tag v1.0.0 [<remote>] [message=<text>] [rev=<rev>]',
|
|
1049
|
+
' /git-tag v1.0.0 message=first-release',
|
|
1050
|
+
].join('\n'),
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Without --push a version is mandatory: this command does not list tags.
|
|
1055
|
+
// /git-tag-show is the read-only way to see what already exists.
|
|
1056
|
+
if (!pushOnly && version === '') {
|
|
1057
|
+
return fail(
|
|
1058
|
+
[
|
|
1059
|
+
'A version name is required.',
|
|
1060
|
+
'',
|
|
1061
|
+
' /git-tag v1.0.0 create v1.0.0 and push it to origin',
|
|
1062
|
+
' /git-tag v1.0.0 origin message=first-release',
|
|
1063
|
+
' /git-tag --push v1.0.0 push a tag that is still local',
|
|
1064
|
+
' /git-tag --push push every local tag',
|
|
1065
|
+
'',
|
|
1066
|
+
'List existing versions with /git-tag-show',
|
|
1067
|
+
].join('\n'),
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const resolved = await commandTarget(invocation, parsed.options);
|
|
1072
|
+
if (resolved.refusal !== undefined) return resolved.refusal;
|
|
1073
|
+
const { executable, root, signal } = resolved;
|
|
1074
|
+
|
|
1075
|
+
// The push target is the second word, as in `/git-tag v1.0.0 origin`;
|
|
1076
|
+
// remote=<name> is accepted as an equivalent spelling. It defaults to the
|
|
1077
|
+
// same remote the other network commands use (origin). A positional target
|
|
1078
|
+
// is checked against the configured remotes, so a stray word cannot invent
|
|
1079
|
+
// a destination that git would then fail (or succeed) against.
|
|
1080
|
+
const remotes = await runGit(ctx, executable, ['remote'], root, signal);
|
|
1081
|
+
const knownRemotes = remotes.exitCode === 0 ? remotes.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '') : [];
|
|
1082
|
+
const remote = (positionalRemote ?? parsed.options.get('remote') ?? 'origin').trim();
|
|
1083
|
+
const message = (parsed.options.get('message') ?? '').trim();
|
|
1084
|
+
const rev = (parsed.options.get('rev') ?? '').trim();
|
|
1085
|
+
|
|
1086
|
+
if (remote === '') {
|
|
1087
|
+
return fail(`The push target must not be empty.\n\n /git-tag ${version === '' ? '--push' : version} origin`);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (positionalRemote !== undefined && !knownRemotes.includes(remote)) {
|
|
1091
|
+
return fail(
|
|
1092
|
+
[
|
|
1093
|
+
`Unknown remote: ${remote}`,
|
|
1094
|
+
'',
|
|
1095
|
+
knownRemotes.length === 0 ? 'This repository has no remotes configured.' : `Configured remotes: ${knownRemotes.join(', ')}`,
|
|
1096
|
+
'',
|
|
1097
|
+
'If that word was meant to be part of an option value, note that values',
|
|
1098
|
+
'may not contain spaces: `message=first release` is two separate words.',
|
|
1099
|
+
'',
|
|
1100
|
+
` /git-tag ${version === '' ? '--push' : version} ${knownRemotes[0] ?? 'origin'}`,
|
|
1101
|
+
].join('\n'),
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Validate the name before running anything: a bad name must not reach git,
|
|
1106
|
+
// and in create mode it must certainly not reach the network.
|
|
1107
|
+
if (version !== '') {
|
|
1108
|
+
const problem = tagNameProblem(version);
|
|
1109
|
+
if (problem !== undefined) {
|
|
1110
|
+
return fail(`Invalid version name: ${problem}\n\n /git-tag v1.0.0\n /git-tag v1.0.0 origin message=first-release`);
|
|
1111
|
+
}
|
|
1112
|
+
const refProblem = await gitRefProblem(executable, root, version, signal);
|
|
1113
|
+
if (refProblem !== undefined) {
|
|
1114
|
+
return fail(`Invalid version name: ${refProblem}\n\n /git-tag v1.0.0\n /git-tag v1.0.0 origin message=first-release`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// In push mode the first word is the version, so `/git-tag --push origin`
|
|
1119
|
+
// would look for a tag named origin. Name that confusion explicitly.
|
|
1120
|
+
if (pushOnly && knownRemotes.includes(version)) {
|
|
1121
|
+
const sameName = await runGit(ctx, executable, ['tag', '--list', version], root, signal);
|
|
1122
|
+
if (sameName.stdout.trim() === '') {
|
|
1123
|
+
return fail(
|
|
1124
|
+
[
|
|
1125
|
+
`${version} is a configured remote, not a tag.`,
|
|
1126
|
+
'',
|
|
1127
|
+
` /git-tag --push remote=${version} push every tag to it`,
|
|
1128
|
+
` /git-tag --push <version> ${version} push one tag to it`,
|
|
1129
|
+
].join('\n'),
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const report = [];
|
|
1135
|
+
|
|
1136
|
+
// Create first: it is local and fast, and it is what the network step sends.
|
|
1137
|
+
if (!pushOnly) {
|
|
1138
|
+
const argv = ['tag'];
|
|
1139
|
+
if (message !== '') argv.push('--annotate', '--message', message);
|
|
1140
|
+
argv.push(version);
|
|
1141
|
+
if (rev !== '') argv.push(rev);
|
|
1142
|
+
const created = await runGit(ctx, executable, argv, root, signal);
|
|
1143
|
+
if (created.exitCode !== 0) {
|
|
1144
|
+
return fail(`git tag failed: ${created.stderr.trim() || created.stdout.trim() || `exit ${created.exitCode}`}`);
|
|
1145
|
+
}
|
|
1146
|
+
const pointer = await runGit(ctx, executable, ['rev-parse', '--short', version], root, signal);
|
|
1147
|
+
report.push(`created version ${version} -> ${pointer.stdout.trim()}`);
|
|
1148
|
+
report.push(message === '' ? '(lightweight tag: names the commit, no extra message)' : `message: ${message}`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Pushing a bare tag name resolves refs/tags/<name>, so no refspec is needed.
|
|
1152
|
+
// `--push` without a version sends every tag, matching `git push --tags`.
|
|
1153
|
+
const argv = ['push', '--porcelain'];
|
|
1154
|
+
if (version === '') argv.push('--tags', remote);
|
|
1155
|
+
else argv.push(remote, version);
|
|
1156
|
+
const pushed = await runGit(ctx, executable, argv, root, signal, OUTPUT_MAX_BYTES, NETWORK_TIMEOUT_MS);
|
|
1157
|
+
if (pushed.exitCode !== 0) {
|
|
1158
|
+
const detail = pushed.stderr.trim() || pushed.stdout.trim();
|
|
1159
|
+
if (pushOnly) return fail(`git push failed: ${detail}`);
|
|
1160
|
+
// The tag already exists locally, so report that and how to retry the push.
|
|
1161
|
+
return fail(
|
|
1162
|
+
[
|
|
1163
|
+
...report,
|
|
1164
|
+
'',
|
|
1165
|
+
`git push failed: ${detail}`,
|
|
1166
|
+
'',
|
|
1167
|
+
'The tag exists locally but is NOT on the remote yet. Retry just the push:',
|
|
1168
|
+
` /git-tag --push ${version}${remote === 'origin' ? '' : ` ${remote}`}`,
|
|
1169
|
+
].join('\n'),
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
report.push(
|
|
1174
|
+
version === '' ? `pushed all tags to ${remote}` : `pushed ${version} to ${remote}`,
|
|
1175
|
+
pushed.stdout.trim(),
|
|
1176
|
+
'',
|
|
1177
|
+
'GitHub now lists this version on the repository Tags and Releases pages.',
|
|
1178
|
+
);
|
|
1179
|
+
return ok(report.join('\n'));
|
|
1180
|
+
},
|
|
1181
|
+
});
|
|
1182
|
+
//#endregion
|
|
1183
|
+
|
|
1184
|
+
yield ctx.commands.register({
|
|
1185
|
+
name: 'git',
|
|
1186
|
+
description: 'Git: list the git commands this plugin provides',
|
|
1187
|
+
async handler() {
|
|
1188
|
+
return ok(USAGE_LINES);
|
|
1189
|
+
},
|
|
1190
|
+
});
|
|
1191
|
+
}, 'git-tools: human commands');
|
|
1192
|
+
//#endregion
|
|
1193
|
+
}
|