@infinisys/is-devops 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,1364 @@
1
+ /**
2
+ * Audited local Git operations exposed through `infinity_git`.
3
+ *
4
+ * The service never trusts a model-supplied repository path: every command runs
5
+ * at the workspace's Git root, and every staged file must stay inside the
6
+ * session workspace (and, in Team Collaboration Mode, inside the role's write
7
+ * boundary). Push credentials come from `infinitySettings` and are injected
8
+ * through a URL-scoped credential helper in the child environment, so the
9
+ * access token never enters argv.
10
+ *
11
+ * Staging is recorded per seat (see `commit-scope.js`), so a commit contains
12
+ * exactly the paths that seat staged: another seat's staged work, or an edit
13
+ * made after the seat staged, cannot ride along.
14
+ */
15
+ import { access, stat as statFile, unlink } from 'node:fs/promises';
16
+ import { isAbsolute, relative, resolve } from 'node:path';
17
+
18
+ import { commitIdentityFor } from './bot-identity.js';
19
+ import {
20
+ commitStagedEntries,
21
+ forgetStagedEntries,
22
+ readStagedEntries,
23
+ recordStagedEntries,
24
+ withCommitLock,
25
+ } from './commit-scope.js';
26
+ import { DEFAULT_GIT_TIMEOUT_MS, PUSH_TIMEOUT_MS, runGit } from './git-exec.js';
27
+ import {
28
+ LOG_FORMAT,
29
+ normalizeRepoPaths,
30
+ parseLogRecords,
31
+ parsePorcelainZ,
32
+ projectRemoteUrl,
33
+ validateBranchName,
34
+ } from './git-parse.js';
35
+ import { GitFoxError, parseProject } from './gitfox-api.js';
36
+
37
+ /** Branches that reject force-with-lease from automation. */
38
+ const PROTECTED_BRANCHES = new Set(['main', 'master']);
39
+
40
+ /** Inline Git credential helper; reads the token from the child environment. */
41
+ const CREDENTIAL_HELPER =
42
+ '!f() { printf \'username=%s\\npassword=%s\\n\' "$INFINITY_GIT_USERNAME" "$INFINITY_GIT_TOKEN"; }; f';
43
+
44
+ /**
45
+ * @returns {GitFoxError} the error raised when GitFox credentials are absent.
46
+ */
47
+ function notConnectedError() {
48
+ return new GitFoxError(
49
+ 'GitFox is not connected. Open Settings → Infinity System and add an access token.',
50
+ { status: 503, code: 'not-configured' },
51
+ );
52
+ }
53
+
54
+ /**
55
+ * @param {{ canWriteGit?: boolean, actor?: string }} actor - resolved actor.
56
+ * @returns {void}
57
+ * @throws {GitFoxError} when the actor may not mutate Git state.
58
+ */
59
+ function assertGitWrite(actor) {
60
+ if (actor?.canWriteGit === false) {
61
+ throw new GitFoxError(
62
+ `actor "${actor.actor ?? 'unknown'}" may not stage, commit or push in Team Collaboration Mode`,
63
+ { code: 'git-write-denied' },
64
+ );
65
+ }
66
+ }
67
+
68
+ /**
69
+ * @param {object} actor - resolved actor.
70
+ * @param {{ namespace: string, name: string }} project - target project.
71
+ * @returns {void}
72
+ * @throws {GitFoxError} when the actor may not write the pinned project.
73
+ */
74
+ function assertProjectAllowed(actor, project) {
75
+ if (typeof actor?.isProjectAllowed !== 'function') return;
76
+ if (actor.isProjectAllowed(`${project.namespace}/${project.name}`) === false) {
77
+ throw new GitFoxError(
78
+ `actor "${actor.actor ?? 'unknown'}" may only push the team's pinned GitFox project`,
79
+ { code: 'git-project-denied' },
80
+ );
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @param {object} actor - resolved actor.
86
+ * @param {string} path - workspace-relative path.
87
+ * @returns {void}
88
+ * @throws {GitFoxError} when the role's write boundary excludes the path.
89
+ */
90
+ function assertRolePath(actor, path) {
91
+ if (typeof actor?.isPathAllowed !== 'function') return;
92
+ if (actor.isPathAllowed(path) === false) {
93
+ throw new GitFoxError(`actor "${actor.actor ?? 'unknown'}" may not write "${path}"`, {
94
+ code: 'git-path-denied',
95
+ });
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Convert a workspace-relative path into a repo-relative path and prove it did
101
+ * not escape the workspace.
102
+ *
103
+ * @param {{ workspaceRoot: string, root: string }} context - repository context.
104
+ * @param {string} path - normalized workspace-relative path.
105
+ * @returns {string} repo-relative path.
106
+ * @throws {GitFoxError} when the path escapes the workspace.
107
+ */
108
+ function toRepoPath(context, path) {
109
+ const absolute = resolve(context.workspaceRoot, path);
110
+ const workspaceRelative = relative(context.workspaceRoot, absolute);
111
+ if (workspaceRelative.startsWith('..') || isAbsolute(workspaceRelative)) {
112
+ throw new GitFoxError(`path "${path}" escapes the session workspace`, {
113
+ code: 'git-path-denied',
114
+ });
115
+ }
116
+ const repoPath = relative(context.root, absolute).replaceAll('\\', '/');
117
+ if (repoPath === '' || repoPath.startsWith('..') || isAbsolute(repoPath)) {
118
+ throw new GitFoxError(`path "${path}" is outside the Git repository`, {
119
+ code: 'git-path-denied',
120
+ });
121
+ }
122
+ return repoPath;
123
+ }
124
+
125
+ /**
126
+ * Validate every staged path against the workspace and role boundary.
127
+ *
128
+ * @param {object} actor - resolved actor.
129
+ * @param {{ workspaceRoot: string, root: string }} context - repository context.
130
+ * @param {string[]} paths - repo-relative paths from the index.
131
+ * @returns {void}
132
+ */
133
+ function assertStagedPaths(actor, context, paths) {
134
+ for (const path of paths) {
135
+ const absolute = resolve(context.root, path);
136
+ const workspaceRelative = relative(context.workspaceRoot, absolute);
137
+ if (workspaceRelative.startsWith('..') || isAbsolute(workspaceRelative)) {
138
+ throw new GitFoxError(`staged path "${path}" is outside the session workspace`, {
139
+ code: 'git-path-denied',
140
+ });
141
+ }
142
+ assertRolePath(actor, workspaceRelative.replaceAll('\\', '/'));
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Build Git config arguments that scope our helper to the GitFox origin and
148
+ * remove any ambient helper that could answer for a different host.
149
+ *
150
+ * @param {string} origin - URL origin of the push target.
151
+ * @returns {string[]} `-c key=value` pairs.
152
+ */
153
+ function credentialConfigArgs(origin) {
154
+ const section = `credential.${origin}`;
155
+ return [
156
+ '-c',
157
+ 'credential.helper=',
158
+ '-c',
159
+ `${section}.helper=`,
160
+ '-c',
161
+ `${section}.helper=${CREDENTIAL_HELPER}`,
162
+ ];
163
+ }
164
+
165
+ /**
166
+ * Make a Git HTTP authentication failure actionable without echoing the token.
167
+ *
168
+ * The built-in `infinity-system` OAuth client historically requested only
169
+ * `read_repository`; GitFox correctly rejects a push from such a token. The
170
+ * message points at the two working configurations instead of leaving the raw
171
+ * "403" from the workhorse.
172
+ *
173
+ * @param {unknown} error - thrown push error.
174
+ * @returns {unknown} mapped error.
175
+ */
176
+ function pushFailure(error) {
177
+ if (!(error instanceof GitFoxError)) return error;
178
+ const message = error.message.toLowerCase();
179
+ const scopeDenied =
180
+ message.includes('write_repository') || message.includes('repository:write');
181
+ if (scopeDenied) {
182
+ return new GitFoxError(
183
+ 'GitFox rejected the push: the OAuth2 grant lacks write_repository. Re-authorize the GitFox connection in Settings → Infinity System (the connection only holds the scopes it was signed in with), or use a PAT with write_repository.',
184
+ { status: 403, code: 'push-scope-denied' },
185
+ );
186
+ }
187
+ const unauthenticated =
188
+ message.includes('authentication failed') || message.includes('401 unauthorized');
189
+ if (unauthenticated) {
190
+ return new GitFoxError(
191
+ 'GitFox rejected the push credentials. Reconnect GitFox in Settings → Infinity System or use a valid PAT.',
192
+ { status: 401, code: 'push-unauthenticated' },
193
+ );
194
+ }
195
+ return error;
196
+ }
197
+
198
+ /**
199
+ * Format a bounded push summary without leaking credentials.
200
+ *
201
+ * @param {string} stdout - `git push --porcelain` output.
202
+ * @param {string} stderr - Git stderr.
203
+ * @returns {string} bounded summary.
204
+ */
205
+ function pushSummary(stdout, stderr) {
206
+ const text = `${stdout.trim()}\n${stderr.trim()}`.trim();
207
+ return text.length <= 8_000 ? text : `${text.slice(0, 8_000)}\n… (truncated)`;
208
+ }
209
+
210
+ /**
211
+ * @param {number | undefined} value - requested log size.
212
+ * @returns {number} bounded positive count.
213
+ */
214
+ function logLimit(value) {
215
+ const parsed = Number(value ?? 20);
216
+ if (!Number.isInteger(parsed) || parsed < 1) {
217
+ throw new GitFoxError('limit must be a positive integer', { code: 'invalid-arguments' });
218
+ }
219
+ return Math.min(parsed, 100);
220
+ }
221
+
222
+ /**
223
+ * Resolve the workspace's Git root.
224
+ *
225
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
226
+ * @param {string} workspaceRoot - session working directory.
227
+ * @param {AbortSignal | undefined} signal - cancellation.
228
+ * @returns {Promise<{ workspaceRoot: string, root: string }>} repository context.
229
+ */
230
+ async function contextOf(runtime, workspaceRoot, signal) {
231
+ const cwd = isAbsolute(workspaceRoot) ? workspaceRoot : resolve(workspaceRoot);
232
+ const { stdout } = await runtime.runGitImpl({
233
+ args: ['rev-parse', '--show-toplevel'],
234
+ cwd,
235
+ signal,
236
+ });
237
+ const root = stdout.trim();
238
+ if (root === '') {
239
+ throw new GitFoxError('workspace is not a Git repository', { code: 'not-a-repository' });
240
+ }
241
+ return { workspaceRoot: cwd, root };
242
+ }
243
+
244
+ /**
245
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
246
+ * @param {{ root: string }} context - repository context.
247
+ * @param {AbortSignal | undefined} signal - cancellation.
248
+ * @returns {Promise<string | null>} current branch, or null when detached.
249
+ */
250
+ async function branchOf(runtime, context, signal) {
251
+ try {
252
+ const { stdout } = await runtime.runGitImpl({
253
+ args: ['symbolic-ref', '--quiet', '--short', 'HEAD'],
254
+ cwd: context.root,
255
+ signal,
256
+ });
257
+ return stdout.trim() || null;
258
+ } catch (error) {
259
+ if (error instanceof GitFoxError && error.code === 'git-failed') return null;
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
266
+ * @param {{ root: string }} context - repository context.
267
+ * @param {AbortSignal | undefined} signal - cancellation.
268
+ * @returns {Promise<string>} HEAD commit.
269
+ */
270
+ async function headSha(runtime, context, signal) {
271
+ try {
272
+ const { stdout } = await runtime.runGitImpl({
273
+ args: ['rev-parse', '--verify', 'HEAD'],
274
+ cwd: context.root,
275
+ signal,
276
+ });
277
+ return stdout.trim();
278
+ } catch (error) {
279
+ if (error instanceof GitFoxError && error.code === 'git-failed') {
280
+ throw new GitFoxError('the repository has no commits yet', { code: 'no-commits' });
281
+ }
282
+ throw error;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
288
+ * @param {{ root: string }} context - repository context.
289
+ * @param {AbortSignal | undefined} signal - cancellation.
290
+ * @returns {Promise<string[]>} repo-relative staged paths.
291
+ */
292
+ async function stagedPaths(runtime, context, signal) {
293
+ const { stdout } = await runtime.runGitImpl({
294
+ args: ['diff', '--cached', '--name-only', '-z'],
295
+ cwd: context.root,
296
+ signal,
297
+ });
298
+ return stdout.split('\0').filter((path) => path !== '');
299
+ }
300
+
301
+ /**
302
+ * Parse NUL-separated `--name-status` records.
303
+ *
304
+ * @param {string} raw - Git output.
305
+ * @returns {{ path: string, status: string }[]} entries.
306
+ */
307
+ function parseNameStatusZ(raw) {
308
+ const tokens = raw.split('\0').filter((token) => token !== '');
309
+ const entries = [];
310
+ for (let index = 0; index + 1 < tokens.length; index += 2) {
311
+ const status = tokens[index];
312
+ const path = tokens[index + 1];
313
+ if (typeof status === 'string' && status !== '' && typeof path === 'string') {
314
+ entries.push({ path, status: status.slice(0, 1) });
315
+ }
316
+ }
317
+ return entries;
318
+ }
319
+
320
+ /**
321
+ * The index as paths with their status letter.
322
+ *
323
+ * `status` is what makes "this change deletes a work record" distinguishable
324
+ * from "this change adds one": a deletion is the correct fix for a record that
325
+ * already sits in the tree, and callers that only see paths cannot tell.
326
+ *
327
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
328
+ * @param {{ root: string }} context - repository context.
329
+ * @param {AbortSignal | undefined} signal - cancellation.
330
+ * @param {string[]} [paths] - optional repo-relative pathspec.
331
+ * @returns {Promise<{ path: string, status: string }[]>} staged entries.
332
+ */
333
+ async function stagedEntries(runtime, context, signal, paths) {
334
+ const args = ['diff', '--cached', '--name-status', '-z', '--no-renames'];
335
+ if (Array.isArray(paths) && paths.length > 0) args.push('--', ...paths);
336
+ const { stdout } = await runtime.runGitImpl({ args, cwd: context.root, signal });
337
+ return parseNameStatusZ(stdout);
338
+ }
339
+
340
+ /**
341
+ * Resolve the repository's Git directory.
342
+ *
343
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
344
+ * @param {{ root: string }} context - repository context.
345
+ * @param {AbortSignal | undefined} signal - cancellation.
346
+ * @returns {Promise<string>} absolute Git directory.
347
+ */
348
+ async function gitDirOf(runtime, context, signal) {
349
+ const { stdout } = await runtime.runGitImpl({
350
+ args: ['rev-parse', '--absolute-git-dir'],
351
+ cwd: context.root,
352
+ signal,
353
+ });
354
+ return stdout.trim();
355
+ }
356
+
357
+ /**
358
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
359
+ * @param {{ root: string }} context - repository context.
360
+ * @param {AbortSignal | undefined} signal - cancellation.
361
+ * @returns {Promise<string | null>} HEAD commit, or null before the first commit.
362
+ */
363
+ async function headOrNull(runtime, context, signal) {
364
+ try {
365
+ return await headSha(runtime, context, signal);
366
+ } catch (error) {
367
+ if (error instanceof GitFoxError && error.code === 'no-commits') return null;
368
+ throw error;
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Read index records (mode and blob) for the given paths.
374
+ *
375
+ * The blob is the content that was staged, so replaying it later commits what
376
+ * the seat staged even if the worktree file has changed since.
377
+ *
378
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
379
+ * @param {{ root: string }} context - repository context.
380
+ * @param {string[]} paths - repo-relative paths.
381
+ * @param {AbortSignal | undefined} signal - cancellation.
382
+ * @returns {Promise<{ path: string, mode: string, sha: string }[]>} records.
383
+ */
384
+ async function indexRecordsOf(runtime, context, paths, signal) {
385
+ const { stdout } = await runtime.runGitImpl({
386
+ args: ['ls-files', '-s', '-z', '--', ...paths],
387
+ cwd: context.root,
388
+ signal,
389
+ });
390
+ const records = [];
391
+ for (const record of stdout.split('\0')) {
392
+ if (record === '') continue;
393
+ const tab = record.indexOf('\t');
394
+ if (tab === -1) continue;
395
+ const [mode, sha, stage] = record.slice(0, tab).split(' ');
396
+ if (stage !== '0' || typeof mode !== 'string' || typeof sha !== 'string') continue;
397
+ records.push({ path: record.slice(tab + 1), mode, sha });
398
+ }
399
+ return records;
400
+ }
401
+
402
+ /**
403
+ * Paths tracked at HEAD, for telling a staged modification from an addition.
404
+ *
405
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
406
+ * @param {{ root: string }} context - repository context.
407
+ * @param {AbortSignal | undefined} signal - cancellation.
408
+ * @returns {Promise<string[]>} repo-relative paths.
409
+ */
410
+ async function trackedPaths(runtime, context, signal) {
411
+ const { stdout } = await runtime.runGitImpl({
412
+ args: ['ls-tree', '-r', '--name-only', '-z', 'HEAD'],
413
+ cwd: context.root,
414
+ signal,
415
+ });
416
+ return stdout.split('\0').filter((path) => path !== '');
417
+ }
418
+
419
+ /**
420
+ * The entries a `stage` added, keyed for the seat's ledger.
421
+ *
422
+ * Deletions carry no blob: the entry records that the path must leave the tree.
423
+ *
424
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
425
+ * @param {{ root: string }} context - repository context.
426
+ * @param {string[]} paths - repo-relative pathspec.
427
+ * @param {AbortSignal | undefined} signal - cancellation.
428
+ * @returns {Promise<{ path: string, mode?: string, sha?: string, deleted?: boolean }[]>} entries.
429
+ */
430
+ async function stagedRecordsOf(runtime, context, paths, signal) {
431
+ const changes = await stagedEntries(runtime, context, signal, paths);
432
+ const modified = changes.filter((change) => change.status !== 'D').map((change) => change.path);
433
+ const records = modified.length > 0
434
+ ? await indexRecordsOf(runtime, context, modified, signal)
435
+ : [];
436
+ const byPath = new Map(records.map((record) => [record.path, record]));
437
+ return changes
438
+ .map((change) => (change.status === 'D'
439
+ ? { path: change.path, deleted: true }
440
+ : byPath.get(change.path)))
441
+ .filter((entry) => entry !== undefined);
442
+ }
443
+
444
+ /**
445
+ * Explain an empty commit target in the terms the caller can act on.
446
+ *
447
+ * When the working tree holds staged work this seat did not stage, say so: the
448
+ * old behaviour put that work into the commit without naming it.
449
+ *
450
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
451
+ * @param {{ root: string }} context - repository context.
452
+ * @param {AbortSignal | undefined} signal - cancellation.
453
+ * @param {string[]} requested - paths the caller named, if any.
454
+ * @returns {Promise<string>} message.
455
+ */
456
+ async function nothingStagedText(runtime, context, signal, requested) {
457
+ if (requested.length > 0) {
458
+ return `nothing to commit for ${requested.join(', ')}: those paths already match the newest commit or hold no staged change`;
459
+ }
460
+ const shared = await stagedPaths(runtime, context, signal);
461
+ if (shared.length === 0) {
462
+ return 'there is nothing staged to commit: stage the paths first — a commit contains exactly what this seat staged';
463
+ }
464
+ const shown = shared.slice(0, 5).join(', ');
465
+ const more = shared.length > 5 ? ` and ${shared.length - 5} more` : '';
466
+ return `nothing is staged for this seat, but ${shared.length} path(s) were staged elsewhere in this working tree (${shown}${more}); those belong to another seat and cannot enter your commit — stage the paths you mean to commit`;
467
+ }
468
+
469
+ /**
470
+ * @param {{ path: string }[]} entries - ledger entries.
471
+ * @param {string[]} repoPaths - repo-relative pathspec.
472
+ * @returns {{ path: string }[]} entries the pathspec names.
473
+ */
474
+ function selectEntries(entries, repoPaths) {
475
+ return entries.filter((entry) => repoPaths.some(
476
+ (path) => entry.path === path || entry.path.startsWith(`${path}/`),
477
+ ));
478
+ }
479
+
480
+ /**
481
+ * Build the local ref and worktree maintenance operations.
482
+ *
483
+ * A seat can cut branches and linked worktrees while gathering evidence; with
484
+ * no way to remove them the checkout keeps refs and metadata no tool can see or
485
+ * clean (issue #56, note on the R-0016 leftovers).
486
+ *
487
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
488
+ * @returns {object} delete_branch/prune_worktrees methods.
489
+ */
490
+ function createRefOperations(runtime) {
491
+ return {
492
+ /**
493
+ * Delete a local branch.
494
+ *
495
+ * @param {object} actor - resolved actor.
496
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, branch?: unknown, force?: boolean }} options - invocation.
497
+ * @returns {Promise<object>} deleted branch.
498
+ */
499
+ async delete_branch(actor, { workspaceRoot, signal, branch, force = false }) {
500
+ assertGitWrite(actor);
501
+ const name = validateBranchName(branch);
502
+ const context = await contextOf(runtime, workspaceRoot, signal);
503
+ const current = await branchOf(runtime, context, signal);
504
+ if (current === name) {
505
+ throw new GitFoxError(`"${name}" is checked out; switch to another branch before deleting it`, {
506
+ code: 'branch-in-use',
507
+ });
508
+ }
509
+ if (PROTECTED_BRANCHES.has(name)) {
510
+ throw new GitFoxError(
511
+ `deleting "${name}" is denied; remove it from a terminal outside the team if that is really intended`,
512
+ { code: 'protected-branch' },
513
+ );
514
+ }
515
+ const ref = `refs/heads/${name}`;
516
+ if (!(await refExists(runtime, context, ref, signal))) {
517
+ throw new GitFoxError(`there is no local branch named "${name}"`, { code: 'branch-not-found' });
518
+ }
519
+ try {
520
+ await runtime.runGitImpl({
521
+ args: ['branch', force === true ? '-D' : '-d', '--', name],
522
+ cwd: context.root,
523
+ signal,
524
+ });
525
+ } catch (error) {
526
+ const stillThere = await refExists(runtime, context, ref, signal);
527
+ if (error instanceof GitFoxError && error.code === 'git-failed' && stillThere
528
+ && !(await isMergedIntoHead(runtime, context, ref, signal))) {
529
+ throw new GitFoxError(
530
+ `"${name}" is not fully merged; delete it with force: true if its commits are meant to be discarded`,
531
+ { code: 'branch-not-merged' },
532
+ );
533
+ }
534
+ throw error;
535
+ }
536
+ return { deleted: name, repository: context.root };
537
+ },
538
+
539
+ /**
540
+ * Remove worktree metadata whose directory no longer exists.
541
+ *
542
+ * @param {object} actor - resolved actor.
543
+ * @param {{ workspaceRoot: string, signal?: AbortSignal }} options - invocation.
544
+ * @returns {Promise<object>} pruned worktrees.
545
+ */
546
+ async prune_worktrees(actor, { workspaceRoot, signal }) {
547
+ assertGitWrite(actor);
548
+ const context = await contextOf(runtime, workspaceRoot, signal);
549
+ const { stdout, stderr } = await runtime.runGitImpl({
550
+ args: ['worktree', 'prune', '--verbose'],
551
+ cwd: context.root,
552
+ signal,
553
+ });
554
+ const pruned = `${stdout}\n${stderr}`
555
+ .split('\n')
556
+ .map((line) => line.trim())
557
+ .filter((line) => line !== '');
558
+ return { pruned, repository: context.root };
559
+ },
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Build the read-only operations.
565
+ *
566
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
567
+ * @returns {object} status/diff/log methods.
568
+ */
569
+ function createReadOperations(runtime) {
570
+ return {
571
+ /**
572
+ * @param {object} actor - resolved actor.
573
+ * @param {{ workspaceRoot: string, signal?: AbortSignal }} options - invocation.
574
+ * @returns {Promise<object>} branch and porcelain entries.
575
+ */
576
+ async status(actor, { workspaceRoot, signal }) {
577
+ const context = await contextOf(runtime, workspaceRoot, signal);
578
+ const branch = await branchOf(runtime, context, signal);
579
+ const { stdout } = await runtime.runGitImpl({
580
+ args: ['status', '--porcelain=v1', '-z', '--untracked-files=all'],
581
+ cwd: context.root,
582
+ signal,
583
+ });
584
+ const entries = parsePorcelainZ(stdout);
585
+ const gitDir = await gitDirOf(runtime, context, signal);
586
+ const staged = await readStagedEntries(gitDir, actor?.actor);
587
+ const mine = new Set(staged.map((entry) => entry.path));
588
+ const shared = await stagedPaths(runtime, context, signal);
589
+ return {
590
+ repository: context.root,
591
+ branch,
592
+ detached: branch === null,
593
+ clean: entries.length === 0,
594
+ entries,
595
+ staged: staged.map((entry) => (entry.deleted === true ? `${entry.path} (deleted)` : entry.path)),
596
+ stagedByOthers: shared.filter((path) => !mine.has(path)),
597
+ actor: actor?.actor ?? 'agent',
598
+ };
599
+ },
600
+
601
+ /**
602
+ * The entries this seat's next commit would contain.
603
+ *
604
+ * The seat's own ledger is the answer; without an identity the shared index
605
+ * is still readable for callers that only need "what is staged somewhere".
606
+ *
607
+ * @param {object} [actor] - resolved actor.
608
+ * @param {{ workspaceRoot: string, signal?: AbortSignal }} options - invocation.
609
+ * @returns {Promise<{ path: string, status: string }[]>} staged entries.
610
+ */
611
+ async stagedFiles(actor, { workspaceRoot, signal }) {
612
+ const context = await contextOf(runtime, workspaceRoot, signal);
613
+ if (actor === undefined || actor === null) {
614
+ return stagedEntries(runtime, context, signal);
615
+ }
616
+ const gitDir = await gitDirOf(runtime, context, signal);
617
+ const entries = await readStagedEntries(gitDir, actor?.actor);
618
+ if (entries.length === 0) return [];
619
+ const head = await headOrNull(runtime, context, signal);
620
+ const tracked = new Set(head === null ? [] : await trackedPaths(runtime, context, signal));
621
+ return entries.map((entry) => {
622
+ if (entry.deleted === true) return { path: entry.path, status: 'D' };
623
+ return { path: entry.path, status: tracked.has(entry.path) ? 'M' : 'A' };
624
+ });
625
+ },
626
+
627
+ /**
628
+ * @param {object} actor - resolved actor.
629
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, staged?: boolean, paths?: unknown, stat?: boolean }} options - invocation.
630
+ * @returns {Promise<object>} diff text.
631
+ */
632
+ async diff(actor, { workspaceRoot, signal, staged = false, paths, stat = false }) {
633
+ const context = await contextOf(runtime, workspaceRoot, signal);
634
+ const normalized = normalizeRepoPaths(paths).map((path) => toRepoPath(context, path));
635
+ const args = ['diff', '--no-ext-diff', '--no-color'];
636
+ if (staged === true) args.push('--cached');
637
+ if (stat === true) args.push('--stat');
638
+ args.push('--', ...normalized);
639
+ const { stdout } = await runtime.runGitImpl({ args, cwd: context.root, signal });
640
+ return {
641
+ repository: context.root,
642
+ staged: staged === true,
643
+ text: stdout,
644
+ empty: stdout.trim() === '',
645
+ };
646
+ },
647
+
648
+ /**
649
+ * @param {object} actor - resolved actor.
650
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, limit?: number, paths?: unknown }} options - invocation.
651
+ * @returns {Promise<object>} recent commits.
652
+ */
653
+ async log(actor, { workspaceRoot, signal, limit, paths }) {
654
+ const context = await contextOf(runtime, workspaceRoot, signal);
655
+ const normalized = normalizeRepoPaths(paths).map((path) => toRepoPath(context, path));
656
+ const args = ['log', `--max-count=${logLimit(limit)}`, `--pretty=format:${LOG_FORMAT}`];
657
+ args.push('--', ...normalized);
658
+ try {
659
+ const { stdout } = await runtime.runGitImpl({ args, cwd: context.root, signal });
660
+ return { repository: context.root, commits: parseLogRecords(stdout) };
661
+ } catch (error) {
662
+ if (error instanceof GitFoxError && error.code === 'git-failed') {
663
+ return { repository: context.root, commits: [] };
664
+ }
665
+ throw error;
666
+ }
667
+ },
668
+
669
+ /**
670
+ * Read the workspace's `origin` remote without mutating anything. Project
671
+ * discovery needs the URL even when the workspace is not yet a checkout.
672
+ *
673
+ * @param {object} actor - resolved actor.
674
+ * @param {{ workspaceRoot: string, signal?: AbortSignal }} options - invocation.
675
+ * @returns {Promise<{ repository: string | null, remote: string | null }>} origin URL.
676
+ */
677
+ async remoteUrl(actor, { workspaceRoot, signal }) {
678
+ let context;
679
+ try {
680
+ context = await contextOf(runtime, workspaceRoot, signal);
681
+ } catch (error) {
682
+ if (error instanceof GitFoxError && ['not-a-repository', 'git-failed'].includes(error.code)) {
683
+ return { repository: null, remote: null };
684
+ }
685
+ throw error;
686
+ }
687
+ try {
688
+ const { stdout } = await runtime.runGitImpl({
689
+ args: ['remote', 'get-url', 'origin'],
690
+ cwd: context.root,
691
+ signal,
692
+ });
693
+ const remote = stdout.trim();
694
+ return { repository: context.root, remote: remote === '' ? null : remote };
695
+ } catch {
696
+ return { repository: context.root, remote: null };
697
+ }
698
+ },
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Build the index operations.
704
+ *
705
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
706
+ * @returns {object} stage/unstage methods.
707
+ */
708
+ function createIndexOperations(runtime) {
709
+ return {
710
+ /**
711
+ * @param {object} actor - resolved actor.
712
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, paths?: unknown }} options - invocation.
713
+ * @returns {Promise<object>} staged paths.
714
+ */
715
+ async stage(actor, { workspaceRoot, signal, paths }) {
716
+ assertGitWrite(actor);
717
+ const context = await contextOf(runtime, workspaceRoot, signal);
718
+ const normalized = normalizeRepoPaths(paths, { required: true });
719
+ const repoPaths = normalized.map((path) => toRepoPath(context, path));
720
+ assertStagedPaths(actor, context, repoPaths);
721
+ await runtime.runGitImpl({ args: ['add', '--', ...repoPaths], cwd: context.root, signal });
722
+ const gitDir = await gitDirOf(runtime, context, signal);
723
+ const entries = await stagedRecordsOf(runtime, context, repoPaths, signal);
724
+ await recordStagedEntries(gitDir, actor?.actor, entries);
725
+ return { staged: normalized, repository: context.root };
726
+ },
727
+
728
+ /**
729
+ * @param {object} actor - resolved actor.
730
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, paths?: unknown }} options - invocation.
731
+ * @returns {Promise<object>} unstaged paths.
732
+ */
733
+ async unstage(actor, { workspaceRoot, signal, paths }) {
734
+ assertGitWrite(actor);
735
+ const context = await contextOf(runtime, workspaceRoot, signal);
736
+ const normalized = normalizeRepoPaths(paths, { required: true });
737
+ const repoPaths = normalized.map((path) => toRepoPath(context, path));
738
+ assertStagedPaths(actor, context, repoPaths);
739
+ const gitDir = await gitDirOf(runtime, context, signal);
740
+ const targets = selectEntries(await readStagedEntries(gitDir, actor?.actor), repoPaths);
741
+ if (targets.length === 0) {
742
+ return {
743
+ unstaged: [],
744
+ repository: context.root,
745
+ note: 'nothing was staged by this seat under those paths; another seat’s staging is untouched',
746
+ };
747
+ }
748
+ const dropped = targets.map((entry) => entry.path);
749
+ try {
750
+ await runtime.runGitImpl({
751
+ args: ['restore', '--staged', '--', ...dropped],
752
+ cwd: context.root,
753
+ signal,
754
+ });
755
+ } catch (error) {
756
+ if (!(error instanceof GitFoxError) || !/HEAD|unborn|ambiguous/i.test(error.message)) {
757
+ throw error;
758
+ }
759
+ await runtime.runGitImpl({
760
+ args: ['rm', '--cached', '--quiet', '--', ...dropped],
761
+ cwd: context.root,
762
+ signal,
763
+ });
764
+ }
765
+ await forgetStagedEntries(gitDir, actor?.actor, dropped);
766
+ return { unstaged: dropped, repository: context.root };
767
+ },
768
+ };
769
+ }
770
+
771
+ /** Age after which a leftover `index.lock` is debris from a dead Git process. */
772
+ const STALE_LOCK_MS = 30_000;
773
+
774
+ /**
775
+ * @param {unknown} value - model-supplied start point.
776
+ * @returns {void}
777
+ * @throws {GitFoxError} when the value could become an option or a bad ref.
778
+ */
779
+ function assertStartPoint(value) {
780
+ const text = typeof value === 'string' ? value.trim() : '';
781
+ const hasControl = [...text].some((character) => character.charCodeAt(0) <= 0x20 || character === '\0');
782
+ if (text === '') return;
783
+ if (hasControl || text.length > 200 || text.startsWith('-')) {
784
+ throw new GitFoxError(`invalid start point "${String(value)}"`, { code: 'invalid-arguments' });
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Remove a leftover `index.lock` that no live Git process can still own.
790
+ *
791
+ * A crashed or killed command leaves the lock behind, and every later mutation
792
+ * then fails with "Unable to create .../index.lock: File exists" although
793
+ * nothing holds it. The tool repairs that state itself; the user is never asked
794
+ * to delete a file by hand.
795
+ *
796
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
797
+ * @param {{ root: string }} context - repository context.
798
+ * @param {AbortSignal | undefined} signal - cancellation.
799
+ * @returns {Promise<boolean>} whether a stale lock was removed.
800
+ */
801
+ async function clearStaleLock(runtime, context, signal) {
802
+ try {
803
+ const { stdout } = await runtime.runGitImpl({
804
+ args: ['rev-parse', '--git-path', 'index.lock'],
805
+ cwd: context.root,
806
+ signal,
807
+ });
808
+ const lock = resolve(context.root, stdout.trim());
809
+ const info = await statFile(lock);
810
+ if (Date.now() - info.mtimeMs < STALE_LOCK_MS) return false;
811
+ await unlink(lock);
812
+ return true;
813
+ } catch {
814
+ return false;
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Whether one revision resolves to a commit in this repository.
820
+ *
821
+ * Git is run under the user's locale, so failure messages are translated. Every
822
+ * branch decision here is taken from an exit code instead of a message, so the
823
+ * tool behaves the same in Chinese and English environments.
824
+ *
825
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
826
+ * @param {{ root: string }} context - repository context.
827
+ * @param {string} revision - branch, tag or commit-ish.
828
+ * @param {AbortSignal | undefined} signal - cancellation.
829
+ * @returns {Promise<boolean>} whether the revision exists.
830
+ */
831
+ async function refExists(runtime, context, revision, signal) {
832
+ try {
833
+ await runtime.runGitImpl({
834
+ args: ['rev-parse', '--verify', '--quiet', revision],
835
+ cwd: context.root,
836
+ signal,
837
+ });
838
+ return true;
839
+ } catch {
840
+ return false;
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Report whether a ref is an ancestor of HEAD.
846
+ *
847
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
848
+ * @param {{ root: string }} context - repository context.
849
+ * @param {string} ref - ref to test.
850
+ * @param {AbortSignal | undefined} signal - caller cancellation.
851
+ * @returns {Promise<boolean>} whether HEAD already contains the ref.
852
+ */
853
+ async function isMergedIntoHead(runtime, context, ref, signal) {
854
+ try {
855
+ await runtime.runGitImpl({
856
+ args: ['merge-base', '--is-ancestor', ref, 'HEAD'],
857
+ cwd: context.root,
858
+ signal,
859
+ });
860
+ return true;
861
+ } catch {
862
+ return false;
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Park uncommitted work so a switch can proceed.
868
+ *
869
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
870
+ * @param {{ root: string }} context - repository context.
871
+ * @param {string} target - requested branch, used in the stash message.
872
+ * @param {AbortSignal | undefined} signal - cancellation.
873
+ * @returns {Promise<boolean>} whether anything was stashed.
874
+ */
875
+ async function stashWorktree(runtime, context, target, signal) {
876
+ const { stdout } = await runtime.runGitImpl({
877
+ args: ['status', '--porcelain=v1', '-z', '--untracked-files=all'],
878
+ cwd: context.root,
879
+ signal,
880
+ });
881
+ if (parsePorcelainZ(stdout).length === 0) return false;
882
+ try {
883
+ await runtime.runGitImpl({
884
+ args: ['stash', 'push', '--quiet', '--include-untracked', '-m', `infinity_git: carry onto ${target}`],
885
+ cwd: context.root,
886
+ signal,
887
+ });
888
+ return true;
889
+ } catch {
890
+ return false;
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Put the parked work back, reporting conflicts instead of throwing.
896
+ *
897
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
898
+ * @param {{ root: string }} context - repository context.
899
+ * @param {AbortSignal | undefined} signal - cancellation.
900
+ * @returns {Promise<{ restored: boolean, files: string[] }>} restore outcome.
901
+ */
902
+ async function restoreWorktree(runtime, context, signal) {
903
+ try {
904
+ await runtime.runGitImpl({ args: ['stash', 'pop', '--quiet'], cwd: context.root, signal });
905
+ return { restored: true, files: [] };
906
+ } catch {
907
+ return { restored: false, files: await conflictedPaths(runtime, context, signal) };
908
+ }
909
+ }
910
+
911
+ /**
912
+ * Build the branch checkout operation.
913
+ *
914
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
915
+ * @returns {object} switch method.
916
+ */
917
+ function createCheckoutOperations(runtime) {
918
+ return {
919
+ /**
920
+ * Check out a branch, creating it from `from` when asked. Uncommitted work
921
+ * is carried across the switch (stash, switch, pop) so a dirty worktree
922
+ * left by an earlier run never turns into a request for the user to run Git
923
+ * by hand.
924
+ *
925
+ * @param {object} actor - resolved actor.
926
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, branch?: unknown, create?: boolean, from?: unknown }} options - invocation.
927
+ * @returns {Promise<object>} checkout record.
928
+ */
929
+ async switch(actor, { workspaceRoot, signal, branch, create = false, from }) {
930
+ assertGitWrite(actor);
931
+ const context = await contextOf(runtime, workspaceRoot, signal);
932
+ const target = validateBranchName(branch);
933
+ const start = typeof from === 'string' ? from.trim() : '';
934
+ assertStartPoint(start);
935
+ if (create === true && (await refExists(runtime, context, `refs/heads/${target}`, signal))) {
936
+ throw new GitFoxError(
937
+ `branch "${target}" already exists; call switch without "create" to check it out`,
938
+ { code: 'branch-exists' },
939
+ );
940
+ }
941
+ if (start !== '' && !(await refExists(runtime, context, `${start}^{commit}`, signal))) {
942
+ throw new GitFoxError(`"${start}" does not resolve to a commit in this repository`, {
943
+ code: 'unknown-start-point',
944
+ });
945
+ }
946
+ const previous = await branchOf(runtime, context, signal);
947
+ await clearStaleLock(runtime, context, signal);
948
+ const args = create === true
949
+ ? ['switch', '-c', target, ...(start === '' ? [] : [start])]
950
+ : ['switch', target];
951
+ const carried = await stashWorktree(runtime, context, target, signal);
952
+ try {
953
+ await runtime.runGitImpl({ args, cwd: context.root, signal });
954
+ } catch (error) {
955
+ if (carried) await restoreWorktree(runtime, context, signal);
956
+ throw error;
957
+ }
958
+ const branchNow = await branchOf(runtime, context, signal);
959
+ const commit = await headSha(runtime, context, signal);
960
+ if (carried) {
961
+ const restore = await restoreWorktree(runtime, context, signal);
962
+ if (!restore.restored) {
963
+ return {
964
+ status: 'conflicted',
965
+ branch: branchNow,
966
+ previous,
967
+ created: create === true,
968
+ carried: true,
969
+ commit,
970
+ files: restore.files,
971
+ note: 'the switch happened and the uncommitted work is parked in a stash; resolve the listed files, stage them, then drop the stash entry',
972
+ };
973
+ }
974
+ }
975
+ const { stdout } = await runtime.runGitImpl({
976
+ args: ['status', '--porcelain=v1', '-z', '--untracked-files=all'],
977
+ cwd: context.root,
978
+ signal,
979
+ });
980
+ const entries = parsePorcelainZ(stdout);
981
+ return {
982
+ branch: branchNow,
983
+ previous,
984
+ created: create === true,
985
+ carried,
986
+ commit,
987
+ detached: branchNow === null,
988
+ clean: entries.length === 0,
989
+ entries,
990
+ };
991
+ },
992
+ };
993
+ }
994
+
995
+ /**
996
+ * Resolve the exact lease value for a force push.
997
+ *
998
+ * `--force-with-lease` without an explicit expected value consults a
999
+ * remote-tracking ref, which a direct-URL push does not maintain. Reading the
1000
+ * current remote head first makes the lease meaningful: if anyone pushes in
1001
+ * between, Git rejects the force push instead of overwriting their work.
1002
+ *
1003
+ * @param {{ runGitImpl: typeof runGit }} runtime - service wiring.
1004
+ * @param {{ credentialArgs: string[], env: Record<string, string>, context: { root: string }, remote: string, branch: string, signal?: AbortSignal }} input - push wiring.
1005
+ * @returns {Promise<string[]>} lease argument, or an empty list.
1006
+ */
1007
+ async function forceLease(runtime, input) {
1008
+ const { stdout } = await runtime.runGitImpl({
1009
+ args: [...input.credentialArgs, 'ls-remote', '--heads', input.remote, `refs/heads/${input.branch}`],
1010
+ cwd: input.context.root,
1011
+ env: input.env,
1012
+ timeoutMs: PUSH_TIMEOUT_MS,
1013
+ signal: input.signal,
1014
+ });
1015
+ const remoteSha = stdout.trim().split(/\s+/)[0] ?? '';
1016
+ return [`--force-with-lease=refs/heads/${input.branch}:${remoteSha}`];
1017
+ }
1018
+
1019
+ /**
1020
+ * Build the commit and push operations.
1021
+ *
1022
+ * @param {{ runGitImpl: typeof runGit, resolveConfig: () => Promise<{ baseUrl: string, token: string }> }} runtime - service wiring.
1023
+ * @returns {object} commit/push methods.
1024
+ */
1025
+ function createPublishOperations(runtime) {
1026
+ return {
1027
+ /**
1028
+ * @param {object} actor - resolved actor.
1029
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, message?: unknown, paths?: unknown }} options - invocation.
1030
+ * @returns {Promise<object>} commit record.
1031
+ */
1032
+ async commit(actor, { workspaceRoot, signal, message, paths }) {
1033
+ assertGitWrite(actor);
1034
+ const text = typeof message === 'string' ? message.trim() : '';
1035
+ if (text === '') {
1036
+ throw new GitFoxError('commit message is required', { code: 'invalid-arguments' });
1037
+ }
1038
+ if (text.length > 20_000 || text.includes('\0')) {
1039
+ throw new GitFoxError('commit message is too long or contains NUL', {
1040
+ code: 'invalid-arguments',
1041
+ });
1042
+ }
1043
+ const context = await contextOf(runtime, workspaceRoot, signal);
1044
+ const gitDir = await gitDirOf(runtime, context, signal);
1045
+ const normalized = normalizeRepoPaths(paths);
1046
+ let targets = await readStagedEntries(gitDir, actor?.actor);
1047
+ if (normalized.length > 0) {
1048
+ const repoPaths = normalized.map((path) => toRepoPath(context, path));
1049
+ assertStagedPaths(actor, context, repoPaths);
1050
+ await runtime.runGitImpl({ args: ['add', '--', ...repoPaths], cwd: context.root, signal });
1051
+ const records = await stagedRecordsOf(runtime, context, repoPaths, signal);
1052
+ await recordStagedEntries(gitDir, actor?.actor, records);
1053
+ targets = selectEntries(await readStagedEntries(gitDir, actor?.actor), repoPaths);
1054
+ }
1055
+ if (targets.length === 0) {
1056
+ throw new GitFoxError(await nothingStagedText(runtime, context, signal, normalized), {
1057
+ code: 'nothing-staged',
1058
+ });
1059
+ }
1060
+ assertStagedPaths(actor, context, targets.map((entry) => entry.path));
1061
+ const identity = commitIdentityFor(actor?.actor);
1062
+ const committed = await withCommitLock(`${gitDir}#commit`, async () => {
1063
+ const head = await headOrNull(runtime, context, signal);
1064
+ return commitStagedEntries({
1065
+ runGit: (args, env) => runtime.runGitImpl({ args, cwd: context.root, env, signal }),
1066
+ gitDir,
1067
+ head,
1068
+ entries: targets,
1069
+ message: text,
1070
+ identity,
1071
+ });
1072
+ });
1073
+ await forgetStagedEntries(gitDir, actor?.actor, committed.files);
1074
+ return {
1075
+ commit: committed.commit,
1076
+ branch: await branchOf(runtime, context, signal),
1077
+ author: identity.name,
1078
+ email: identity.email,
1079
+ files: committed.files,
1080
+ };
1081
+ },
1082
+
1083
+ /**
1084
+ * @param {object} actor - resolved actor.
1085
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, project?: unknown, branch?: unknown, force?: boolean }} options - invocation.
1086
+ * @returns {Promise<object>} push result.
1087
+ */
1088
+ async push(actor, { workspaceRoot, signal, project, branch, force = false }) {
1089
+ assertGitWrite(actor);
1090
+ const parsed = parseProject(project);
1091
+ assertProjectAllowed(actor, parsed);
1092
+ const context = await contextOf(runtime, workspaceRoot, signal);
1093
+ const current = await branchOf(runtime, context, signal);
1094
+ if (branch === undefined || branch === null || branch === '') {
1095
+ if (current === null) {
1096
+ throw new GitFoxError('HEAD is detached; specify the remote branch explicitly', {
1097
+ code: 'detached-head',
1098
+ });
1099
+ }
1100
+ }
1101
+ const targetBranch = validateBranchName(branch ?? current);
1102
+ if (force === true && PROTECTED_BRANCHES.has(targetBranch)) {
1103
+ throw new GitFoxError(`force-push to protected branch "${targetBranch}" is denied`, {
1104
+ code: 'protected-branch',
1105
+ });
1106
+ }
1107
+ const commit = await headSha(runtime, context, signal);
1108
+ const config = await runtime.resolveConfig();
1109
+ if (config.token === '') throw notConnectedError();
1110
+ const remote = projectRemoteUrl(config.baseUrl, parsed);
1111
+ const origin = new URL(remote).origin;
1112
+ const credentialArgs = credentialConfigArgs(origin);
1113
+ const env = {
1114
+ GIT_TERMINAL_PROMPT: '0',
1115
+ GCM_INTERACTIVE: 'never',
1116
+ INFINITY_GIT_USERNAME: actor?.botName ?? 'infinsys-agent',
1117
+ INFINITY_GIT_TOKEN: config.token,
1118
+ };
1119
+ const lease = force === true ? await forceLease(runtime, { credentialArgs, env, context, remote, branch: targetBranch, signal }) : [];
1120
+ const args = [
1121
+ ...credentialArgs,
1122
+ 'push',
1123
+ '--porcelain',
1124
+ '--no-verify',
1125
+ ...lease,
1126
+ remote,
1127
+ `HEAD:refs/heads/${targetBranch}`,
1128
+ ];
1129
+ let pushed;
1130
+ try {
1131
+ pushed = await runtime.runGitImpl({
1132
+ args,
1133
+ cwd: context.root,
1134
+ env,
1135
+ timeoutMs: PUSH_TIMEOUT_MS,
1136
+ signal,
1137
+ });
1138
+ } catch (error) {
1139
+ throw pushFailure(error);
1140
+ }
1141
+ const { stdout, stderr } = pushed;
1142
+ return {
1143
+ project: `${parsed.namespace}/${parsed.name}`,
1144
+ branch: targetBranch,
1145
+ commit,
1146
+ remote,
1147
+ forced: force === true,
1148
+ output: pushSummary(stdout, stderr),
1149
+ };
1150
+ },
1151
+ };
1152
+ }
1153
+
1154
+ /**
1155
+ * Whether a rebase is stopped mid-flight (conflicts or a pending edit).
1156
+ *
1157
+ * @param {{ runGitImpl: object }} runtime - service wiring.
1158
+ * @param {{ root: string }} context - repository context.
1159
+ * @param {AbortSignal | undefined} signal - caller cancellation.
1160
+ * @returns {Promise<string | null>} state directory, or null when no rebase runs.
1161
+ */
1162
+ async function rebaseStopped(runtime, context, signal) {
1163
+ for (const name of ['rebase-merge', 'rebase-apply']) {
1164
+ try {
1165
+ const { stdout } = await runtime.runGitImpl({
1166
+ args: ['rev-parse', '--git-path', name],
1167
+ cwd: context.root,
1168
+ signal,
1169
+ });
1170
+ const directory = resolve(context.root, stdout.trim());
1171
+ await access(directory);
1172
+ return directory;
1173
+ } catch {
1174
+ continue;
1175
+ }
1176
+ }
1177
+ return null;
1178
+ }
1179
+
1180
+ /**
1181
+ * Unmerged paths left by a stopped rebase.
1182
+ *
1183
+ * @param {{ runGitImpl: object }} runtime - service wiring.
1184
+ * @param {{ root: string }} context - repository context.
1185
+ * @param {AbortSignal | undefined} signal - caller cancellation.
1186
+ * @returns {Promise<string[]>} conflicted repo-relative paths.
1187
+ */
1188
+ async function conflictedPaths(runtime, context, signal) {
1189
+ const { stdout } = await runtime.runGitImpl({
1190
+ args: ['diff', '--name-only', '--diff-filter=U', '-z'],
1191
+ cwd: context.root,
1192
+ signal,
1193
+ });
1194
+ return stdout.split('\0').filter((path) => path !== '');
1195
+ }
1196
+
1197
+ /** Editors that keep a rebase from blocking on a commit message. */
1198
+ const REBASE_ENV = Object.freeze({ GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' });
1199
+
1200
+ /**
1201
+ * Run one rebase step and translate a conflict stop into a structured result.
1202
+ *
1203
+ * @param {{ runGitImpl: object }} runtime - service wiring.
1204
+ * @param {{ root: string }} context - repository context.
1205
+ * @param {{ args: string[], branch: string | null, signal?: AbortSignal }} input - step.
1206
+ * @returns {Promise<object>} completed or conflicted outcome.
1207
+ */
1208
+ async function runRebaseStep(runtime, context, input) {
1209
+ try {
1210
+ const { stdout, stderr } = await runtime.runGitImpl({
1211
+ args: input.args,
1212
+ cwd: context.root,
1213
+ env: REBASE_ENV,
1214
+ signal: input.signal,
1215
+ });
1216
+ return {
1217
+ status: 'completed',
1218
+ branch: input.branch,
1219
+ commit: await headSha(runtime, context, input.signal),
1220
+ output: `${stdout.trim()}\n${stderr.trim()}`.trim(),
1221
+ };
1222
+ } catch (error) {
1223
+ const stopped = error instanceof GitFoxError && error.code === 'git-failed'
1224
+ ? await rebaseStopped(runtime, context, input.signal)
1225
+ : null;
1226
+ if (stopped === null || stopped === undefined) throw error;
1227
+ return {
1228
+ status: 'conflicted',
1229
+ branch: input.branch,
1230
+ files: await conflictedPaths(runtime, context, input.signal),
1231
+ note: 'resolve the listed files, stage them with action "stage", then call action "rebase_continue" (or "rebase_abort")',
1232
+ };
1233
+ }
1234
+ }
1235
+
1236
+ /**
1237
+ * Build the rebase operations: fetch the target, then rebase the checked-out
1238
+ * branch locally, resolving (or aborting) conflict stops.
1239
+ *
1240
+ * @param {{ runGitImpl: typeof runGit, resolveConfig: () => Promise<object> }} runtime - service wiring.
1241
+ * @returns {object} fetch/rebase methods.
1242
+ */
1243
+ function createRebaseOperations(runtime) {
1244
+ return {
1245
+ /**
1246
+ * @param {object} actor - resolved actor.
1247
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, project?: unknown, branch?: unknown }} options - invocation.
1248
+ * @returns {Promise<object>} fetched ref.
1249
+ */
1250
+ async fetch(actor, { workspaceRoot, signal, project, branch }) {
1251
+ const parsed = parseProject(project);
1252
+ assertProjectAllowed(actor, parsed);
1253
+ if (typeof branch !== 'string' || branch.trim() === '') {
1254
+ throw new GitFoxError('action "fetch" requires "branch"', { code: 'invalid-arguments' });
1255
+ }
1256
+ const target = validateBranchName(branch.trim());
1257
+ const context = await contextOf(runtime, workspaceRoot, signal);
1258
+ const config = await runtime.resolveConfig();
1259
+ if (config.token === '') throw notConnectedError();
1260
+ const remote = projectRemoteUrl(config.baseUrl, parsed);
1261
+ const origin = new URL(remote).origin;
1262
+ const env = {
1263
+ GIT_TERMINAL_PROMPT: '0',
1264
+ GCM_INTERACTIVE: 'never',
1265
+ INFINITY_GIT_USERNAME: actor?.botName ?? 'infinsys-agent',
1266
+ INFINITY_GIT_TOKEN: config.token,
1267
+ };
1268
+ await runtime.runGitImpl({
1269
+ args: [
1270
+ ...credentialConfigArgs(origin),
1271
+ 'fetch',
1272
+ '--no-tags',
1273
+ remote,
1274
+ `+refs/heads/${target}:refs/remotes/origin/${target}`,
1275
+ ],
1276
+ cwd: context.root,
1277
+ env,
1278
+ timeoutMs: PUSH_TIMEOUT_MS,
1279
+ signal,
1280
+ });
1281
+ return { project: `${parsed.namespace}/${parsed.name}`, branch: target, ref: `origin/${target}` };
1282
+ },
1283
+
1284
+ /**
1285
+ * @param {object} actor - resolved actor.
1286
+ * @param {{ workspaceRoot: string, signal?: AbortSignal, onto?: unknown, branch?: unknown }} options - invocation.
1287
+ * @returns {Promise<object>} completed or conflicted outcome.
1288
+ */
1289
+ async rebase(actor, { workspaceRoot, signal, onto, branch }) {
1290
+ assertGitWrite(actor);
1291
+ const context = await contextOf(runtime, workspaceRoot, signal);
1292
+ const current = await branchOf(runtime, context, signal);
1293
+ if (current === null) {
1294
+ throw new GitFoxError('HEAD is detached; check out the branch before rebasing', {
1295
+ code: 'detached-head',
1296
+ });
1297
+ }
1298
+ const requested = typeof branch === 'string' ? branch.trim() : '';
1299
+ if (requested !== '' && requested !== current) {
1300
+ throw new GitFoxError(
1301
+ `rebase applies to the checked-out branch "${current}", not "${requested}"`,
1302
+ { code: 'invalid-arguments' },
1303
+ );
1304
+ }
1305
+ const target = typeof onto === 'string' ? onto.trim() : '';
1306
+ const args = target === '' ? ['rebase'] : ['rebase', target];
1307
+ return runRebaseStep(runtime, context, { args, branch: current, signal });
1308
+ },
1309
+
1310
+ /**
1311
+ * @param {object} actor - resolved actor.
1312
+ * @param {{ workspaceRoot: string, signal?: AbortSignal }} options - invocation.
1313
+ * @returns {Promise<object>} completed or conflicted outcome.
1314
+ */
1315
+ async rebase_continue(actor, { workspaceRoot, signal }) {
1316
+ assertGitWrite(actor);
1317
+ const context = await contextOf(runtime, workspaceRoot, signal);
1318
+ const branch = await branchOf(runtime, context, signal);
1319
+ const stopped = await rebaseStopped(runtime, context, signal);
1320
+ if (stopped === null) {
1321
+ throw new GitFoxError('no rebase is in progress', { code: 'no-rebase-in-progress' });
1322
+ }
1323
+ return runRebaseStep(runtime, context, { args: ['rebase', '--continue'], branch, signal });
1324
+ },
1325
+
1326
+ /**
1327
+ * @param {object} actor - resolved actor.
1328
+ * @param {{ workspaceRoot: string, signal?: AbortSignal }} options - invocation.
1329
+ * @returns {Promise<object>} abort outcome.
1330
+ */
1331
+ async rebase_abort(actor, { workspaceRoot, signal }) {
1332
+ assertGitWrite(actor);
1333
+ const context = await contextOf(runtime, workspaceRoot, signal);
1334
+ const branch = await branchOf(runtime, context, signal);
1335
+ const stopped = await rebaseStopped(runtime, context, signal);
1336
+ if (stopped === null) {
1337
+ throw new GitFoxError('no rebase is in progress', { code: 'no-rebase-in-progress' });
1338
+ }
1339
+ await runtime.runGitImpl({ args: ['rebase', '--abort'], cwd: context.root, env: REBASE_ENV, signal });
1340
+ return { status: 'aborted', branch, commit: await headSha(runtime, context, signal) };
1341
+ },
1342
+ };
1343
+ }
1344
+
1345
+ /**
1346
+ * Create the local Git service.
1347
+ *
1348
+ * @param {{ resolveConfig: () => Promise<{ baseUrl: string, token: string }>, runGitImpl?: typeof runGit }} options - wiring.
1349
+ * @returns {object} service with one method per `infinity_git` action.
1350
+ */
1351
+ export function createGitRepository({ resolveConfig, runGitImpl = runGit }) {
1352
+ const runtime = { resolveConfig, runGitImpl };
1353
+ return {
1354
+ ...createReadOperations(runtime),
1355
+ ...createIndexOperations(runtime),
1356
+ ...createCheckoutOperations(runtime),
1357
+ ...createRebaseOperations(runtime),
1358
+ ...createPublishOperations(runtime),
1359
+ ...createRefOperations(runtime),
1360
+ };
1361
+ }
1362
+
1363
+ /** Re-exported for tests that exercise the default timeout. */
1364
+ export { DEFAULT_GIT_TIMEOUT_MS };