@fluado/enmetu 0.0.0-stage → 0.2.0

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.
Files changed (209) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -2
  3. package/dist/adapters/clock/systemClockAdapter.d.ts +6 -0
  4. package/dist/adapters/clock/systemClockAdapter.js +12 -0
  5. package/dist/adapters/fs/nodeFileSystemAdapter.d.ts +18 -0
  6. package/dist/adapters/fs/nodeFileSystemAdapter.js +230 -0
  7. package/dist/adapters/git/gitFacade.d.ts +148 -0
  8. package/dist/adapters/git/gitFacade.js +822 -0
  9. package/dist/adapters/git/gitLocatorFacade.d.ts +8 -0
  10. package/dist/adapters/git/gitLocatorFacade.js +75 -0
  11. package/dist/adapters/git/gitProcessAdapter.d.ts +46 -0
  12. package/dist/adapters/git/gitProcessAdapter.js +261 -0
  13. package/dist/adapters/log/nullLogger.d.ts +9 -0
  14. package/dist/adapters/log/nullLogger.js +10 -0
  15. package/dist/adapters/log/stderrLoggerAdapter.d.ts +10 -0
  16. package/dist/adapters/log/stderrLoggerAdapter.js +13 -0
  17. package/dist/adapters/process/nodeProcessAdapter.d.ts +9 -0
  18. package/dist/adapters/process/nodeProcessAdapter.js +56 -0
  19. package/dist/adapters/watch/fsWatchAdapter.d.ts +24 -0
  20. package/dist/adapters/watch/fsWatchAdapter.js +47 -0
  21. package/dist/cli/commands/hookCommand.d.ts +29 -0
  22. package/dist/cli/commands/hookCommand.js +92 -0
  23. package/dist/cli/commands/resumeCommand.d.ts +48 -0
  24. package/dist/cli/commands/resumeCommand.js +124 -0
  25. package/dist/cli/commands/runCommand.d.ts +34 -0
  26. package/dist/cli/commands/runCommand.js +79 -0
  27. package/dist/cli/commands/statusCommand.d.ts +71 -0
  28. package/dist/cli/commands/statusCommand.js +249 -0
  29. package/dist/cli/exitCodes.d.ts +7 -0
  30. package/dist/cli/exitCodes.js +38 -0
  31. package/dist/cli/helpText.d.ts +1 -0
  32. package/dist/cli/helpText.js +16 -0
  33. package/dist/cli/main.d.ts +8 -0
  34. package/dist/cli/main.js +49 -0
  35. package/dist/cli/parseArgs.d.ts +19 -0
  36. package/dist/cli/parseArgs.js +106 -0
  37. package/dist/cli.d.ts +2 -0
  38. package/dist/cli.js +32 -0
  39. package/dist/commit/commitLocalChangesController.d.ts +132 -0
  40. package/dist/commit/commitLocalChangesController.js +357 -0
  41. package/dist/commit/gitIndexLock.d.ts +36 -0
  42. package/dist/commit/gitIndexLock.js +90 -0
  43. package/dist/commit/repairStaleIndexController.d.ts +90 -0
  44. package/dist/commit/repairStaleIndexController.js +150 -0
  45. package/dist/commit/stageGuardedChanges.d.ts +30 -0
  46. package/dist/commit/stageGuardedChanges.js +52 -0
  47. package/dist/contracts/IClock.d.ts +7 -0
  48. package/dist/contracts/IClock.js +1 -0
  49. package/dist/contracts/IDaemon.d.ts +26 -0
  50. package/dist/contracts/IDaemon.js +1 -0
  51. package/dist/contracts/IFileSystem.d.ts +50 -0
  52. package/dist/contracts/IFileSystem.js +1 -0
  53. package/dist/contracts/IGitCommitter.d.ts +60 -0
  54. package/dist/contracts/IGitCommitter.js +1 -0
  55. package/dist/contracts/IGitConfigReader.d.ts +5 -0
  56. package/dist/contracts/IGitConfigReader.js +1 -0
  57. package/dist/contracts/IGitIndexLock.d.ts +44 -0
  58. package/dist/contracts/IGitIndexLock.js +1 -0
  59. package/dist/contracts/IGitReader.d.ts +81 -0
  60. package/dist/contracts/IGitReader.js +1 -0
  61. package/dist/contracts/IGitRunner.d.ts +49 -0
  62. package/dist/contracts/IGitRunner.js +1 -0
  63. package/dist/contracts/IGitWriter.d.ts +39 -0
  64. package/dist/contracts/IGitWriter.js +1 -0
  65. package/dist/contracts/IHaltStore.d.ts +14 -0
  66. package/dist/contracts/IHaltStore.js +1 -0
  67. package/dist/contracts/IHookInstaller.d.ts +14 -0
  68. package/dist/contracts/IHookInstaller.js +1 -0
  69. package/dist/contracts/IInstanceLock.d.ts +26 -0
  70. package/dist/contracts/IInstanceLock.js +1 -0
  71. package/dist/contracts/ILogger.d.ts +11 -0
  72. package/dist/contracts/ILogger.js +1 -0
  73. package/dist/contracts/IOperationJournal.d.ts +12 -0
  74. package/dist/contracts/IOperationJournal.js +1 -0
  75. package/dist/contracts/IPathQuarantine.d.ts +67 -0
  76. package/dist/contracts/IPathQuarantine.js +1 -0
  77. package/dist/contracts/IProcessHost.d.ts +11 -0
  78. package/dist/contracts/IProcessHost.js +1 -0
  79. package/dist/contracts/IRepositoryLocator.d.ts +32 -0
  80. package/dist/contracts/IRepositoryLocator.js +1 -0
  81. package/dist/contracts/IStateRecorder.d.ts +68 -0
  82. package/dist/contracts/IStateRecorder.js +1 -0
  83. package/dist/contracts/IStateStore.d.ts +88 -0
  84. package/dist/contracts/IStateStore.js +1 -0
  85. package/dist/contracts/ISyncCycle.d.ts +23 -0
  86. package/dist/contracts/ISyncCycle.js +1 -0
  87. package/dist/contracts/ISyncCycleRunner.d.ts +4 -0
  88. package/dist/contracts/ISyncCycleRunner.js +1 -0
  89. package/dist/contracts/ISyncScheduler.d.ts +6 -0
  90. package/dist/contracts/ISyncScheduler.js +1 -0
  91. package/dist/contracts/ITextSink.d.ts +3 -0
  92. package/dist/contracts/ITextSink.js +1 -0
  93. package/dist/contracts/IWatcher.d.ts +9 -0
  94. package/dist/contracts/IWatcher.js +1 -0
  95. package/dist/contracts/config.d.ts +26 -0
  96. package/dist/contracts/config.js +1 -0
  97. package/dist/contracts/errors.d.ts +39 -0
  98. package/dist/contracts/errors.js +64 -0
  99. package/dist/contracts/exitCode.d.ts +10 -0
  100. package/dist/contracts/exitCode.js +17 -0
  101. package/dist/contracts/gitTypes.d.ts +113 -0
  102. package/dist/contracts/gitTypes.js +1 -0
  103. package/dist/contracts/library.d.ts +35 -0
  104. package/dist/contracts/library.js +1 -0
  105. package/dist/contracts/logCodes.d.ts +1 -0
  106. package/dist/contracts/logCodes.js +1 -0
  107. package/dist/contracts/runtime.d.ts +16 -0
  108. package/dist/contracts/runtime.js +1 -0
  109. package/dist/contracts/syncTypes.d.ts +75 -0
  110. package/dist/contracts/syncTypes.js +6 -0
  111. package/dist/core/commit/commitMemory.d.ts +3 -0
  112. package/dist/core/commit/commitMemory.js +9 -0
  113. package/dist/core/commit/commitMessage.d.ts +6 -0
  114. package/dist/core/commit/commitMessage.js +12 -0
  115. package/dist/core/config/parseConfig.d.ts +8 -0
  116. package/dist/core/config/parseConfig.js +176 -0
  117. package/dist/core/content/contentRules.d.ts +11 -0
  118. package/dist/core/content/contentRules.js +50 -0
  119. package/dist/core/errors/errorMessage.d.ts +6 -0
  120. package/dist/core/errors/errorMessage.js +14 -0
  121. package/dist/core/errors/withCycleId.d.ts +7 -0
  122. package/dist/core/errors/withCycleId.js +38 -0
  123. package/dist/core/git/enmetuRefs.d.ts +9 -0
  124. package/dist/core/git/enmetuRefs.js +22 -0
  125. package/dist/core/git/porcelain.d.ts +52 -0
  126. package/dist/core/git/porcelain.js +219 -0
  127. package/dist/core/git/rebaseOwnership.d.ts +28 -0
  128. package/dist/core/git/rebaseOwnership.js +29 -0
  129. package/dist/core/git/stoppedRebaseWrites.d.ts +10 -0
  130. package/dist/core/git/stoppedRebaseWrites.js +22 -0
  131. package/dist/core/halt/resumeHint.d.ts +6 -0
  132. package/dist/core/halt/resumeHint.js +9 -0
  133. package/dist/core/log/formatLogLine.d.ts +3 -0
  134. package/dist/core/log/formatLogLine.js +11 -0
  135. package/dist/core/quarantine/keptBlobMessage.d.ts +5 -0
  136. package/dist/core/quarantine/keptBlobMessage.js +8 -0
  137. package/dist/core/quarantine/restoreWarnings.d.ts +16 -0
  138. package/dist/core/quarantine/restoreWarnings.js +36 -0
  139. package/dist/core/schedule/schedulerStep.d.ts +63 -0
  140. package/dist/core/schedule/schedulerStep.js +144 -0
  141. package/dist/core/state/stateGuards.d.ts +21 -0
  142. package/dist/core/state/stateGuards.js +295 -0
  143. package/dist/core/state/stateLiveness.d.ts +10 -0
  144. package/dist/core/state/stateLiveness.js +21 -0
  145. package/dist/core/state/stateRecordStep.d.ts +28 -0
  146. package/dist/core/state/stateRecordStep.js +203 -0
  147. package/dist/core/status/conflictGuide.d.ts +30 -0
  148. package/dist/core/status/conflictGuide.js +86 -0
  149. package/dist/core/status/orphanedCommits.d.ts +15 -0
  150. package/dist/core/status/orphanedCommits.js +24 -0
  151. package/dist/core/status/statusHeadline.d.ts +46 -0
  152. package/dist/core/status/statusHeadline.js +130 -0
  153. package/dist/core/watch/watchFilter.d.ts +2 -0
  154. package/dist/core/watch/watchFilter.js +34 -0
  155. package/dist/daemon/daemon.d.ts +55 -0
  156. package/dist/daemon/daemon.js +277 -0
  157. package/dist/enmetuInstances.d.ts +65 -0
  158. package/dist/enmetuInstances.js +332 -0
  159. package/dist/halt/haltGuardGitWriter.d.ts +23 -0
  160. package/dist/halt/haltGuardGitWriter.js +61 -0
  161. package/dist/halt/haltStore.d.ts +13 -0
  162. package/dist/halt/haltStore.js +49 -0
  163. package/dist/halt/recordHalt.d.ts +19 -0
  164. package/dist/halt/recordHalt.js +20 -0
  165. package/dist/hooks/hookScript.d.ts +22 -0
  166. package/dist/hooks/hookScript.js +52 -0
  167. package/dist/hooks/nullHookInstaller.d.ts +5 -0
  168. package/dist/hooks/nullHookInstaller.js +6 -0
  169. package/dist/hooks/preCommitCheckController.d.ts +23 -0
  170. package/dist/hooks/preCommitCheckController.js +22 -0
  171. package/dist/hooks/preCommitHookInstaller.d.ts +32 -0
  172. package/dist/hooks/preCommitHookInstaller.js +123 -0
  173. package/dist/index.d.ts +18 -0
  174. package/dist/index.js +38 -0
  175. package/dist/library/enmetuLibrary.d.ts +16 -0
  176. package/dist/library/enmetuLibrary.js +62 -0
  177. package/dist/library/readStateController.d.ts +28 -0
  178. package/dist/library/readStateController.js +32 -0
  179. package/dist/library/requestedStopProcessHost.d.ts +28 -0
  180. package/dist/library/requestedStopProcessHost.js +58 -0
  181. package/dist/lock/instanceLock.d.ts +26 -0
  182. package/dist/lock/instanceLock.js +125 -0
  183. package/dist/preflight/repositoryPreflight.d.ts +20 -0
  184. package/dist/preflight/repositoryPreflight.js +42 -0
  185. package/dist/quarantine/pathQuarantine.d.ts +113 -0
  186. package/dist/quarantine/pathQuarantine.js +504 -0
  187. package/dist/recovery/enmetuRebaseAbort.d.ts +108 -0
  188. package/dist/recovery/enmetuRebaseAbort.js +302 -0
  189. package/dist/recovery/operationJournal.d.ts +13 -0
  190. package/dist/recovery/operationJournal.js +37 -0
  191. package/dist/recovery/recoveryController.d.ts +48 -0
  192. package/dist/recovery/recoveryController.js +112 -0
  193. package/dist/state/stateFileStore.d.ts +18 -0
  194. package/dist/state/stateFileStore.js +73 -0
  195. package/dist/state/stateRecorder.d.ts +26 -0
  196. package/dist/state/stateRecorder.js +69 -0
  197. package/dist/state/stateRecordingSyncCycle.d.ts +24 -0
  198. package/dist/state/stateRecordingSyncCycle.js +71 -0
  199. package/dist/sync/hookAbortingSyncScheduler.d.ts +16 -0
  200. package/dist/sync/hookAbortingSyncScheduler.js +27 -0
  201. package/dist/sync/integrateUpstreamController.d.ts +95 -0
  202. package/dist/sync/integrateUpstreamController.js +353 -0
  203. package/dist/sync/syncCycleController.d.ts +71 -0
  204. package/dist/sync/syncCycleController.js +345 -0
  205. package/dist/sync/syncCycleRunner.d.ts +24 -0
  206. package/dist/sync/syncCycleRunner.js +169 -0
  207. package/dist/sync/syncScheduler.d.ts +32 -0
  208. package/dist/sync/syncScheduler.js +97 -0
  209. package/package.json +42 -4
@@ -0,0 +1,822 @@
1
+ import { join } from 'node:path';
2
+ import { GitCommandError, GitOutputError } from '../../contracts/errors.js';
3
+ import { hasRejectedRef, parseBatch, parseBatchCheck, parseDivergence, parseLsRemote, parseRawDiff, parseStatus, parseUpstream, } from '../../core/git/porcelain.js';
4
+ /** At most this much blob content is requested from one `git cat-file --batch` call. */
5
+ export const GIT_BATCH_CHUNK_BYTES = 67108864;
6
+ /** A hook's stdout and stderr beyond this are dropped, never a failure (design 12.7, Decision 83). */
7
+ export const HOOK_OUTPUT_CAP_BYTES = 65536;
8
+ /** One-invocation override: no hook runs and no config file is written (D10). */
9
+ const HOOKLESS = ['-c', 'core.hooksPath=/dev/null'];
10
+ const INDEX_LOCKED = "index.lock': File exists";
11
+ const REBASE_IN_PROGRESS = /already a rebase-(?:merge|apply) directory/;
12
+ const BRANCH_PREFIX = 'refs/heads/';
13
+ /** What git writes into head-name for a rebase that started on a detached HEAD. */
14
+ const DETACHED_HEAD_NAME = 'detached HEAD';
15
+ /** What separates the action from the rest of a rebase's start entry in HEAD's reflog. */
16
+ const START_ENTRY = ' (start): ';
17
+ /**
18
+ * How many of HEAD's newest reflog entries headMovedSince reads. The commands
19
+ * that land while enmetu's rebase is stopped add a handful; the bound keeps a
20
+ * long reflog from being walked for nothing.
21
+ */
22
+ const HEAD_MOVE_SCAN = 64;
23
+ const REGULAR_FILE_MODES = ['100644', '100755'];
24
+ const OBJECT_ID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
25
+ const STATUS_ARGS = [
26
+ '--literal-pathspecs',
27
+ 'status',
28
+ '--porcelain=v1',
29
+ '-z',
30
+ '--untracked-files=all',
31
+ '--no-renames',
32
+ '--ignore-submodules=dirty',
33
+ ];
34
+ const STAGED_DIFF_ARGS = [
35
+ 'diff',
36
+ '--cached',
37
+ '--raw',
38
+ '-z',
39
+ '--no-renames',
40
+ '--no-abbrev',
41
+ '--diff-filter=ACMT',
42
+ ];
43
+ /** Files under gitDir whose presence means a person's git operation is in progress, checked in this order. */
44
+ const OPERATION_MARKERS = [
45
+ { file: 'rebase-merge', operation: 'rebase' },
46
+ { file: 'rebase-apply', operation: 'rebase' },
47
+ { file: 'MERGE_HEAD', operation: 'merge' },
48
+ { file: 'CHERRY_PICK_HEAD', operation: 'cherry-pick' },
49
+ { file: 'REVERT_HEAD', operation: 'revert' },
50
+ { file: 'BISECT_LOG', operation: 'bisect' },
51
+ ];
52
+ /**
53
+ * Facade: typed questions and commands over the git CLI, with exactly the
54
+ * argv and interpretations of design section 1.3. gitDir locates the state
55
+ * files repositoryState and the rebase readers check.
56
+ */
57
+ export class GitFacade {
58
+ runner;
59
+ fs;
60
+ gitDir;
61
+ hookTimeoutMs;
62
+ hookAbort;
63
+ constructor(runner, fs, gitDir,
64
+ /** A repository hook's own budget (hookTimeoutMs); only runHook uses it (design 12.7, Decision 83). */
65
+ hookTimeoutMs,
66
+ /** Aborted by a stop request: a running hook is ended, which is a rejection (Decision 92); only runHook uses it. */
67
+ hookAbort) {
68
+ this.runner = runner;
69
+ this.fs = fs;
70
+ this.gitDir = gitDir;
71
+ this.hookTimeoutMs = hookTimeoutMs;
72
+ this.hookAbort = hookAbort;
73
+ }
74
+ async repositoryState() {
75
+ const args = ['symbolic-ref', '-q', 'HEAD'];
76
+ const head = await this.git(args);
77
+ let branchRef;
78
+ if (head.exitCode === 0) {
79
+ branchRef = parseRefName(head.stdout);
80
+ }
81
+ else if (head.exitCode === 1) {
82
+ branchRef = null;
83
+ }
84
+ else {
85
+ throw gitFailed(args, head);
86
+ }
87
+ const [indexLock, ...markers] = await Promise.all([
88
+ this.exists('index.lock'),
89
+ ...OPERATION_MARKERS.map((marker) => this.exists(marker.file)),
90
+ ]);
91
+ const found = OPERATION_MARKERS.find((_, index) => markers[index] === true);
92
+ return {
93
+ indexLocked: indexLock === true,
94
+ operation: found === undefined ? null : found.operation,
95
+ branchRef,
96
+ };
97
+ }
98
+ async headCommit() {
99
+ return this.verifyCommit('HEAD');
100
+ }
101
+ async hasCommitterIdentity() {
102
+ const result = await this.git(['var', 'GIT_COMMITTER_IDENT']);
103
+ return result.exitCode === 0;
104
+ }
105
+ async upstream(branchRef) {
106
+ const result = await this.gitOk([
107
+ 'for-each-ref',
108
+ '--format=%(upstream:remotename)%00%(upstream:remoteref)%00%(upstream)',
109
+ branchRef,
110
+ ]);
111
+ return parseUpstream(result.stdout.toString('utf8'), branchRef);
112
+ }
113
+ async status(paths) {
114
+ if (paths === undefined) {
115
+ return parseStatus((await this.gitOk(STATUS_ARGS)).stdout);
116
+ }
117
+ // Without paths after `--`, git would report the whole tree.
118
+ if (paths.length === 0) {
119
+ return [];
120
+ }
121
+ const result = await this.gitOk([...STATUS_ARGS, '--', ...paths]);
122
+ return parseStatus(result.stdout);
123
+ }
124
+ async *stagedFiles(maxBytes) {
125
+ const blobs = await this.stagedBlobs(maxBytes);
126
+ let chunk = new Map();
127
+ for (const [index, blob] of blobs.entries()) {
128
+ if (blob.size === null) {
129
+ yield { path: blob.path, content: null };
130
+ continue;
131
+ }
132
+ let content = chunk.get(index);
133
+ if (content === undefined) {
134
+ // The previous chunk is released here, before the next one is read.
135
+ chunk = new Map();
136
+ chunk = await this.readChunk(blobs, index);
137
+ content = chunk.get(index);
138
+ }
139
+ if (content === undefined) {
140
+ throw new GitOutputError(`git cat-file --batch did not return blob ${blob.blobId}`);
141
+ }
142
+ yield { path: blob.path, content };
143
+ }
144
+ }
145
+ async hasStagedChanges() {
146
+ const args = ['diff', '--cached', '--quiet', '--ignore-submodules=dirty'];
147
+ const result = await this.git(args);
148
+ if (result.exitCode === 0 || result.exitCode === 1) {
149
+ return result.exitCode === 1;
150
+ }
151
+ throw gitFailed(args, result);
152
+ }
153
+ /** git diff --cached --name-only -z --no-renames --ignore-submodules=dirty (Decision 98). */
154
+ async stagedPaths() {
155
+ const args = [
156
+ 'diff',
157
+ '--cached',
158
+ '--name-only',
159
+ '-z',
160
+ '--no-renames',
161
+ '--ignore-submodules=dirty',
162
+ ];
163
+ return parseNulPaths((await this.gitOk(args)).stdout, 'git diff --cached --name-only');
164
+ }
165
+ async remoteHead(remote, remoteRef) {
166
+ const run = await this.remoteGit(['ls-remote', remote, remoteRef]);
167
+ if (run.kind === 'timed-out') {
168
+ return { kind: 'unreachable', detail: run.detail };
169
+ }
170
+ const { result } = run;
171
+ if (result.exitCode !== 0) {
172
+ return { kind: 'unreachable', detail: result.stderr };
173
+ }
174
+ const sha = parseLsRemote(result.stdout.toString('utf8'), remoteRef);
175
+ return sha === null ? { kind: 'absent' } : { kind: 'found', sha };
176
+ }
177
+ async resolveCommit(ref) {
178
+ return this.verifyCommit(ref);
179
+ }
180
+ async isAncestor(ancestor, descendant) {
181
+ const args = ['merge-base', '--is-ancestor', ancestor, descendant];
182
+ const result = await this.git(args);
183
+ if (result.exitCode === 0 || result.exitCode === 1) {
184
+ return result.exitCode === 0;
185
+ }
186
+ throw gitFailed(args, result);
187
+ }
188
+ async divergence(upstreamSha) {
189
+ const result = await this.gitOk([
190
+ 'rev-list',
191
+ '--left-right',
192
+ '--count',
193
+ `HEAD...${upstreamSha}`,
194
+ ]);
195
+ return parseDivergence(result.stdout.toString('utf8'));
196
+ }
197
+ async hasUnmergedPaths() {
198
+ const result = await this.gitOk(['ls-files', '--unmerged', '-z']);
199
+ return result.stdout.length > 0;
200
+ }
201
+ async rebaseOrigHead() {
202
+ return this.rebaseStateFile('orig-head');
203
+ }
204
+ async rebaseOnto() {
205
+ return this.rebaseStateFile('onto');
206
+ }
207
+ /**
208
+ * The branch a rebase in progress returns to: rebase-merge/head-name, else
209
+ * rebase-apply/head-name (design 14.4). git writes `detached HEAD` there for
210
+ * a rebase that started without a branch.
211
+ */
212
+ async rebaseHeadName() {
213
+ const text = (await this.fs.readText(join(this.gitDir, 'rebase-merge', 'head-name'))) ??
214
+ (await this.fs.readText(join(this.gitDir, 'rebase-apply', 'head-name')));
215
+ if (text === null) {
216
+ return null;
217
+ }
218
+ return parseHeadName(text);
219
+ }
220
+ /**
221
+ * git reflog exists HEAD, then git log -g -1 --format=%gs
222
+ * --grep-reflog=" (start): " HEAD (design 13.2, Decision 104). Without a
223
+ * reflog `git log -g` prints nothing with exit 0, exactly like a reflog
224
+ * without a start entry, so the first call keeps the two apart.
225
+ */
226
+ async rebaseStart() {
227
+ const existsArgs = ['reflog', 'exists', 'HEAD'];
228
+ const exists = await this.git(existsArgs);
229
+ if (exists.exitCode === 1) {
230
+ return { kind: 'unlogged' };
231
+ }
232
+ if (exists.exitCode !== 0) {
233
+ throw gitFailed(existsArgs, exists);
234
+ }
235
+ const entry = await this.gitOk([
236
+ 'log',
237
+ '-g',
238
+ '-1',
239
+ '--format=%gs',
240
+ `--grep-reflog=${START_ENTRY}`,
241
+ 'HEAD',
242
+ ]);
243
+ return parseRebaseStart(entry.stdout.toString('utf8'));
244
+ }
245
+ /**
246
+ * git log -g -n HEAD_MOVE_SCAN --format="%H %gs" HEAD, newest first (design
247
+ * 14.14). Without a reflog git prints nothing with exit 0, which is also
248
+ * what a reflog holding no entry of the rebase's yields: null either way.
249
+ */
250
+ async headMovedSince(reflogAction) {
251
+ const entries = parseReflogEntries((await this.gitOk([
252
+ 'log',
253
+ '-g',
254
+ '-n',
255
+ String(HEAD_MOVE_SCAN),
256
+ '--format=%H %gs',
257
+ 'HEAD',
258
+ ])).stdout.toString('utf8'));
259
+ const own = `${reflogAction} (`;
260
+ const newest = entries[0];
261
+ if (newest === undefined || newest.subject.startsWith(own)) {
262
+ return null;
263
+ }
264
+ const last = entries.find((entry) => entry.subject.startsWith(own));
265
+ return last === undefined ? null : { from: last.commit, to: newest.commit };
266
+ }
267
+ async commitSubject(commit) {
268
+ const args = ['log', '-1', '--no-walk', '--format=%s', commit];
269
+ return (await this.gitOk(args)).stdout.toString('utf8').replace(/\n$/, '');
270
+ }
271
+ /**
272
+ * Two reads, the cheap one first (design 14.15). --since bounds the log's
273
+ * walk to commits made after enmetu named the orphan (in whole seconds, a
274
+ * second early, since git's committer dates have no fraction); a time that
275
+ * does not parse walks every branch.
276
+ */
277
+ async onLocalBranch(commit, since) {
278
+ const containing = await this.gitOk([
279
+ 'for-each-ref',
280
+ '--count=1',
281
+ '--contains',
282
+ commit,
283
+ '--format=%(refname)',
284
+ 'refs/heads/',
285
+ ]);
286
+ if (containing.stdout.length > 0) {
287
+ return true;
288
+ }
289
+ const sinceMs = Date.parse(since);
290
+ const picked = await this.gitOk([
291
+ 'log',
292
+ '--branches',
293
+ '-F',
294
+ `--grep=(cherry picked from commit ${commit})`,
295
+ ...(Number.isNaN(sinceMs)
296
+ ? []
297
+ : [`--since=${Math.floor(sinceMs / 1000) - 1}`]),
298
+ '-n',
299
+ '1',
300
+ '--format=%H',
301
+ ]);
302
+ return picked.stdout.length > 0;
303
+ }
304
+ async readBlob(blobId) {
305
+ return (await this.gitOk(['cat-file', 'blob', blobId])).stdout;
306
+ }
307
+ async hashFile(path) {
308
+ const args = ['hash-object', '--no-filters', '--', path];
309
+ return parseObjectId((await this.gitOk(args)).stdout, 'git hash-object');
310
+ }
311
+ async worktreeBlobId(path) {
312
+ const args = ['hash-object', '--', path];
313
+ return parseObjectId((await this.gitOk(args)).stdout, 'git hash-object');
314
+ }
315
+ async entryBlobId(path, commit) {
316
+ // The explicit stage 0 keeps a path like "1:x" from being read for a stage number.
317
+ const spec = commit === null ? `:0:${path}` : `${commit}:${path}`;
318
+ const args = ['rev-parse', '-q', '--verify', spec];
319
+ const result = await this.git(args);
320
+ if (result.exitCode === 1) {
321
+ return null;
322
+ }
323
+ if (result.exitCode !== 0) {
324
+ throw gitFailed(args, result);
325
+ }
326
+ return parseObjectId(result.stdout, 'git rev-parse');
327
+ }
328
+ /** git merge-file -p: exit 0 is clean; a conflict count or an error (binary input) is null. */
329
+ async mergeFile(current, base, other) {
330
+ const result = await this.git(['merge-file', '-p', current, base, other]);
331
+ return result.exitCode === 0 ? result.stdout : null;
332
+ }
333
+ /** git merge-base <left> <right>: exit 1 is null (design 12.3). */
334
+ async mergeBase(left, right) {
335
+ const args = ['merge-base', left, right];
336
+ const result = await this.git(args);
337
+ if (result.exitCode === 1) {
338
+ return null;
339
+ }
340
+ if (result.exitCode !== 0) {
341
+ throw gitFailed(args, result);
342
+ }
343
+ return parseObjectId(result.stdout, 'git merge-base');
344
+ }
345
+ /** git diff --name-only -z --no-renames <from> <to> (design 12.3). */
346
+ async changedPaths(from, to) {
347
+ const args = ['diff', '--name-only', '-z', '--no-renames', from, to];
348
+ return parseNulPaths((await this.gitOk(args)).stdout, 'git diff --name-only');
349
+ }
350
+ /**
351
+ * git -c core.hooksPath=/dev/null read-tree --reset <commit> (design 12.7):
352
+ * -m's one-tree merge, which keeps the stat data of matching entries, except
353
+ * that unmerged entries a copied index carries (a conflicted stash pop, a
354
+ * person's merge) are discarded instead of refused (measured with git 2.52:
355
+ * -m exits 128 with "You need to resolve your current index first").
356
+ */
357
+ async seedFrom(commit) {
358
+ await this.gitOk([...HOOKLESS, 'read-tree', '--reset', commit]);
359
+ }
360
+ /** git -c core.hooksPath=/dev/null write-tree (design 12.7). */
361
+ async writeTree() {
362
+ const result = await this.gitOk([...HOOKLESS, 'write-tree']);
363
+ return parseObjectId(result.stdout, 'git write-tree');
364
+ }
365
+ /** git commit-tree <tree> -p <parent> -S|--no-gpg-sign -F - (design 12.7). */
366
+ async commitTree(input) {
367
+ const result = await this.gitOk([
368
+ 'commit-tree',
369
+ input.tree,
370
+ '-p',
371
+ input.parent,
372
+ input.sign ? '-S' : '--no-gpg-sign',
373
+ '-F',
374
+ '-',
375
+ ], input.message);
376
+ return parseObjectId(result.stdout, 'git commit-tree');
377
+ }
378
+ async hookExists(name) {
379
+ const result = await this.gitOk([
380
+ 'rev-parse',
381
+ '--path-format=absolute',
382
+ '--git-path',
383
+ `hooks/${name}`,
384
+ ]);
385
+ const path = result.stdout.toString('utf8').replace(/\n$/, '');
386
+ const facts = await this.fs.facts(path);
387
+ return (facts !== null &&
388
+ (facts.kind === 'symlink' || (facts.kind === 'file' && facts.executable)));
389
+ }
390
+ /**
391
+ * git hook run --ignore-missing <name> [-- <args>] with the runner options
392
+ * { timeoutMs: hookTimeoutMs, outputCapBytes: HOOK_OUTPUT_CAP_BYTES }
393
+ * (design 12.7, Decision 83): exit 0 passed; any other exit rejected with
394
+ * stderr; GIT_TIMEOUT rejected with 'timed out after <n> ms'; GIT_FAILED
395
+ * without an exit code (ended by a signal) rejected with the error's
396
+ * message. Throws only GIT_NOT_FOUND.
397
+ */
398
+ async runHook(name, args) {
399
+ const argv = ['hook', 'run', '--ignore-missing', name];
400
+ if (args.length > 0) {
401
+ argv.push('--', ...args);
402
+ }
403
+ let result;
404
+ try {
405
+ // The runner directly, not this.git: a hook's stderr that mentions
406
+ // index.lock is the hook's verdict, not enmetu's collision.
407
+ result = await this.runner.run(argv, {
408
+ timeoutMs: this.hookTimeoutMs,
409
+ outputCapBytes: HOOK_OUTPUT_CAP_BYTES,
410
+ abort: this.hookAbort,
411
+ });
412
+ }
413
+ catch (error) {
414
+ if (error instanceof GitCommandError && error.code === 'GIT_ABORTED') {
415
+ return { kind: 'rejected', detail: 'ended because enmetu is stopping' };
416
+ }
417
+ if (error instanceof GitCommandError && error.code === 'GIT_TIMEOUT') {
418
+ return {
419
+ kind: 'rejected',
420
+ detail: this.hookTimeoutMs === undefined
421
+ ? error.message
422
+ : `timed out after ${this.hookTimeoutMs} ms`,
423
+ };
424
+ }
425
+ if (error instanceof GitCommandError &&
426
+ error.code === 'GIT_FAILED' &&
427
+ error.exitCode === null) {
428
+ return { kind: 'rejected', detail: error.message };
429
+ }
430
+ throw error;
431
+ }
432
+ if (result.exitCode === 0) {
433
+ return { kind: 'passed' };
434
+ }
435
+ return {
436
+ kind: 'rejected',
437
+ detail: result.stderr === '' ? `exited ${result.exitCode}` : result.stderr,
438
+ };
439
+ }
440
+ async configValue(key) {
441
+ const args = ['config', '--get', key];
442
+ const result = await this.git(args);
443
+ if (result.exitCode === 1) {
444
+ return null;
445
+ }
446
+ if (result.exitCode !== 0) {
447
+ throw gitFailed(args, result);
448
+ }
449
+ return stripOneTrailingNewline(result.stdout.toString('utf8'));
450
+ }
451
+ async stageAll() {
452
+ const args = [...HOOKLESS, 'add', '-A', '--ignore-errors'];
453
+ const result = await this.git(args);
454
+ if (result.exitCode === 0) {
455
+ return { partial: false, detail: '' };
456
+ }
457
+ if (result.exitCode === 1) {
458
+ return { partial: true, detail: result.stderr };
459
+ }
460
+ throw gitFailed(args, result);
461
+ }
462
+ async unstage(paths) {
463
+ // An empty pathspec file would reset the whole index.
464
+ if (paths.length === 0) {
465
+ return;
466
+ }
467
+ // Hookless like stageAll: git runs post-index-change on every index
468
+ // write, and enmetu resets only its own commit index (design 12.7).
469
+ await this.gitOk([
470
+ ...HOOKLESS,
471
+ '--literal-pathspecs',
472
+ 'reset',
473
+ '-q',
474
+ '--pathspec-from-file=-',
475
+ '--pathspec-file-nul',
476
+ ], paths.join('\0'));
477
+ }
478
+ async fetch(remote, remoteRef, intoRef) {
479
+ const run = await this.remoteGit([
480
+ 'fetch',
481
+ '--quiet',
482
+ '--no-tags',
483
+ '--no-recurse-submodules',
484
+ '--no-write-fetch-head',
485
+ // An empty refmap stops git's opportunistic update of the tracking ref
486
+ // that remote.<name>.fetch maps remoteRef to; only intoRef moves.
487
+ '--refmap=',
488
+ remote,
489
+ `+${remoteRef}:${intoRef}`,
490
+ ]);
491
+ if (run.kind === 'timed-out') {
492
+ return { kind: 'failed', detail: run.detail };
493
+ }
494
+ return run.result.exitCode === 0
495
+ ? { kind: 'fetched' }
496
+ : { kind: 'failed', detail: run.result.stderr };
497
+ }
498
+ async updateRef(ref, newSha, oldSha) {
499
+ await this.gitOk([
500
+ ...HOOKLESS,
501
+ 'update-ref',
502
+ '-m',
503
+ 'enmetu',
504
+ ref,
505
+ newSha,
506
+ oldSha ?? '',
507
+ ]);
508
+ }
509
+ /**
510
+ * reflogAction is the attempt's token: git writes it into HEAD's reflog
511
+ * with the rebase's checkout, which rebaseStart reads back (Decision 104).
512
+ */
513
+ async rebase(ontoSha, reflogAction) {
514
+ const result = await this.git([
515
+ ...HOOKLESS,
516
+ 'rebase',
517
+ // A person's rebase.backend=apply or rebase.rebaseMerges=true would
518
+ // otherwise change what enmetu replays and pushes (Decision 120).
519
+ '--merge',
520
+ '--no-rebase-merges',
521
+ '--no-autostash',
522
+ '--no-autosquash',
523
+ '--no-update-refs',
524
+ '--no-fork-point',
525
+ '--quiet',
526
+ ontoSha,
527
+ ], { reflogAction });
528
+ if (result.exitCode === 0) {
529
+ return { kind: 'rebased' };
530
+ }
531
+ if (REBASE_IN_PROGRESS.test(result.stderr)) {
532
+ return { kind: 'refused', detail: result.stderr };
533
+ }
534
+ // The rebase directories, not git's message, tell a rebase that stopped
535
+ // from one git refused to start, across versions and locales (Decision 78).
536
+ const [merge, apply] = await Promise.all([
537
+ this.exists('rebase-merge'),
538
+ this.exists('rebase-apply'),
539
+ ]);
540
+ if (merge || apply) {
541
+ return { kind: 'stopped', detail: result.stderr };
542
+ }
543
+ return { kind: 'not-started', detail: result.stderr };
544
+ }
545
+ async abortRebase() {
546
+ // The caller verifies the outcome from the repository state, so neither
547
+ // the exit code nor an index.lock message is interpreted here.
548
+ await this.runner.run([...HOOKLESS, 'rebase', '--abort']);
549
+ }
550
+ /**
551
+ * git rebase --quit, then git switch -q --discard-changes <name>, both
552
+ * hookless (design 14.4, Decision 122). Neither exit code nor an index.lock
553
+ * message is interpreted: the caller verifies HEAD, the branch and the tip.
554
+ */
555
+ async endRebaseAtBranch(branchRef) {
556
+ if (!branchRef.startsWith(BRANCH_PREFIX)) {
557
+ throw new GitOutputError(`endRebaseAtBranch needs a branch under ${BRANCH_PREFIX}, got ${JSON.stringify(branchRef.slice(0, 120))}`);
558
+ }
559
+ const name = branchRef.slice(BRANCH_PREFIX.length);
560
+ await this.runner.run([...HOOKLESS, 'rebase', '--quit']);
561
+ await this.runner.run([
562
+ ...HOOKLESS,
563
+ 'switch',
564
+ '-q',
565
+ '--discard-changes',
566
+ name,
567
+ ]);
568
+ }
569
+ async push(remote, sha, remoteRef) {
570
+ const run = await this.remoteGit([
571
+ 'push',
572
+ '--porcelain',
573
+ '--no-recurse-submodules',
574
+ '--no-follow-tags',
575
+ remote,
576
+ `${sha}:${remoteRef}`,
577
+ ]);
578
+ if (run.kind === 'timed-out') {
579
+ // The push may still have landed; the next cycle's ls-remote tells.
580
+ return { kind: 'failed', detail: run.detail };
581
+ }
582
+ const { result } = run;
583
+ if (result.exitCode === 0) {
584
+ return { kind: 'pushed' };
585
+ }
586
+ if (result.exitCode === 1 &&
587
+ hasRejectedRef(result.stdout.toString('utf8'))) {
588
+ return { kind: 'rejected', detail: result.stderr };
589
+ }
590
+ return { kind: 'failed', detail: result.stderr };
591
+ }
592
+ async storeBlob(path) {
593
+ const args = ['hash-object', '-w', '--no-filters', '--', path];
594
+ return parseObjectId((await this.gitOk(args)).stdout, 'git hash-object');
595
+ }
596
+ async restoreFromIndex(paths) {
597
+ if (paths.length === 0) {
598
+ return;
599
+ }
600
+ await this.gitOk([
601
+ ...HOOKLESS,
602
+ '--literal-pathspecs',
603
+ 'checkout',
604
+ '--quiet',
605
+ '--pathspec-from-file=-',
606
+ '--pathspec-file-nul',
607
+ ], paths.join('\0'));
608
+ }
609
+ /** Runs git and turns an index.lock collision into GIT_INDEX_LOCKED; every other exit code is the caller's to read. */
610
+ async git(args, options) {
611
+ const result = options === undefined
612
+ ? await this.runner.run(args)
613
+ : await this.runner.run(args, options);
614
+ if (result.exitCode !== 0 && result.stderr.includes(INDEX_LOCKED)) {
615
+ throw new GitCommandError(`git ${args.join(' ')}: another git process holds index.lock`, 'GIT_INDEX_LOCKED', args, result.exitCode, result.stderr);
616
+ }
617
+ return result;
618
+ }
619
+ /**
620
+ * Runs a command that talks to the remote. A timeout there is the network or
621
+ * a credential prompt (ssh connect, passphrase, pinentry), so it comes back as
622
+ * a value the cycle reports (OFFLINE, or PUSH_FAILED with UNPUSHED, D2),
623
+ * never an error that ends the cycle in CYCLE_FAILED.
624
+ */
625
+ async remoteGit(args) {
626
+ try {
627
+ return { kind: 'ran', result: await this.git(args) };
628
+ }
629
+ catch (error) {
630
+ if (error instanceof GitCommandError && error.code === 'GIT_TIMEOUT') {
631
+ return { kind: 'timed-out', detail: error.message };
632
+ }
633
+ throw error;
634
+ }
635
+ }
636
+ /** Runs git and expects exit code 0. */
637
+ async gitOk(args, stdin) {
638
+ const result = await this.git(args, stdin === undefined ? undefined : { stdin });
639
+ if (result.exitCode !== 0) {
640
+ throw gitFailed(args, result);
641
+ }
642
+ return result;
643
+ }
644
+ async verifyCommit(ref) {
645
+ const args = ['rev-parse', '-q', '--verify', `${ref}^{commit}`];
646
+ const result = await this.git(args);
647
+ if (result.exitCode === 1) {
648
+ return null;
649
+ }
650
+ if (result.exitCode !== 0) {
651
+ throw gitFailed(args, result);
652
+ }
653
+ return parseObjectId(result.stdout, 'git rev-parse');
654
+ }
655
+ async exists(file) {
656
+ return (await this.fs.facts(join(this.gitDir, file))) !== null;
657
+ }
658
+ async rebaseStateFile(name) {
659
+ const text = (await this.fs.readText(join(this.gitDir, 'rebase-merge', name))) ??
660
+ (await this.fs.readText(join(this.gitDir, 'rebase-apply', name)));
661
+ if (text === null) {
662
+ return null;
663
+ }
664
+ return parseObjectId(Buffer.from(text), `rebase ${name}`);
665
+ }
666
+ /** The staged files in diff order, with the size of each blob whose content will be read. */
667
+ async stagedBlobs(maxBytes) {
668
+ const entries = parseRawDiff((await this.gitOk(STAGED_DIFF_ARGS)).stdout);
669
+ const regular = entries.filter((entry) => REGULAR_FILE_MODES.includes(entry.mode));
670
+ const sizes = await this.blobSizes(regular.map((entry) => entry.blobId));
671
+ let next = 0;
672
+ return entries.map((entry) => {
673
+ if (!REGULAR_FILE_MODES.includes(entry.mode)) {
674
+ return { path: entry.path, blobId: entry.blobId, size: null };
675
+ }
676
+ const size = sizes[next];
677
+ next += 1;
678
+ if (size === undefined) {
679
+ throw new GitOutputError(`git cat-file --batch-check gave no size for blob ${entry.blobId}`);
680
+ }
681
+ return {
682
+ path: entry.path,
683
+ blobId: entry.blobId,
684
+ size: size <= maxBytes ? size : null,
685
+ };
686
+ });
687
+ }
688
+ /** Sizes in request order; the blob ids of a staged diff must all exist. */
689
+ async blobSizes(blobIds) {
690
+ if (blobIds.length === 0) {
691
+ return [];
692
+ }
693
+ const result = await this.gitOk(['cat-file', '--batch-check'], idLines(blobIds));
694
+ const checked = parseBatchCheck(result.stdout.toString('utf8'));
695
+ if (checked.length !== blobIds.length) {
696
+ throw new GitOutputError(`git cat-file --batch-check answered ${checked.length} of ${blobIds.length} blobs`);
697
+ }
698
+ return checked.map((entry, index) => {
699
+ if (entry.kind === 'missing' || entry.id !== blobIds[index]) {
700
+ throw new GitOutputError(`git cat-file --batch-check has no blob ${blobIds[index] ?? entry.id}`);
701
+ }
702
+ return entry.size;
703
+ });
704
+ }
705
+ /**
706
+ * Reads the readable blobs from `start` on, in diff order, until the next
707
+ * one would push the chunk past GIT_BATCH_CHUNK_BYTES; the first blob is
708
+ * always taken, so a blob larger than a chunk is read alone.
709
+ */
710
+ async readChunk(blobs, start) {
711
+ const indexes = [];
712
+ const ids = [];
713
+ let total = 0;
714
+ for (let index = start; index < blobs.length; index += 1) {
715
+ const blob = blobs[index];
716
+ if (blob === undefined || blob.size === null) {
717
+ continue;
718
+ }
719
+ if (indexes.length > 0 && total + blob.size > GIT_BATCH_CHUNK_BYTES) {
720
+ break;
721
+ }
722
+ indexes.push(index);
723
+ ids.push(blob.blobId);
724
+ total += blob.size;
725
+ }
726
+ const result = await this.gitOk(['cat-file', '--batch'], idLines(ids));
727
+ const read = parseBatch(result.stdout);
728
+ if (read.length !== ids.length) {
729
+ throw new GitOutputError(`git cat-file --batch returned ${read.length} of ${ids.length} blobs`);
730
+ }
731
+ const chunk = new Map();
732
+ read.forEach((blob, position) => {
733
+ const index = indexes[position];
734
+ if (index === undefined || blob.id !== ids[position]) {
735
+ throw new GitOutputError(`git cat-file --batch returned blob ${blob.id} out of order`);
736
+ }
737
+ chunk.set(index, blob.content);
738
+ });
739
+ return chunk;
740
+ }
741
+ }
742
+ function gitFailed(args, result) {
743
+ const firstLine = result.stderr.split('\n')[0] ?? '';
744
+ return new GitCommandError(`git ${args.join(' ')} exited ${result.exitCode}: ${firstLine}`, 'GIT_FAILED', args, result.exitCode, result.stderr);
745
+ }
746
+ function idLines(ids) {
747
+ return ids.map((id) => `${id}\n`).join('');
748
+ }
749
+ function stripOneTrailingNewline(text) {
750
+ return text.endsWith('\n') ? text.slice(0, -1) : text;
751
+ }
752
+ function parseObjectId(stdout, source) {
753
+ const id = stdout.toString('utf8').trim();
754
+ if (!OBJECT_ID.test(id)) {
755
+ throw new GitOutputError(`unexpected ${source} output: ${JSON.stringify(id.slice(0, 120))}`);
756
+ }
757
+ return id;
758
+ }
759
+ /**
760
+ * `-z` path output: every path is NUL-terminated, so output that does not end
761
+ * in NUL was cut off. Paths are text from outside and are never interpreted.
762
+ */
763
+ function parseNulPaths(stdout, source) {
764
+ const text = stdout.toString('utf8');
765
+ if (text === '') {
766
+ return [];
767
+ }
768
+ if (!text.endsWith('\0')) {
769
+ throw new GitOutputError(`unexpected ${source} output: ${JSON.stringify(text.slice(0, 120))}`);
770
+ }
771
+ return text.slice(0, -1).split('\0');
772
+ }
773
+ /**
774
+ * The newest start entry's subject, `<action> (start): <rest>`; empty output
775
+ * is a reflog without one. The action is text from outside, compared, never
776
+ * interpreted.
777
+ */
778
+ function parseRebaseStart(text) {
779
+ const subject = stripOneTrailingNewline(text);
780
+ if (subject === '') {
781
+ return { kind: 'none' };
782
+ }
783
+ const end = subject.indexOf(START_ENTRY);
784
+ if (end === -1 || subject.includes('\n')) {
785
+ throw new GitOutputError(`unexpected git log -g output: ${JSON.stringify(subject.slice(0, 120))}`);
786
+ }
787
+ return { kind: 'entry', action: subject.slice(0, end) };
788
+ }
789
+ /** `%H %gs` lines of git log -g: an object id, one space, the entry's message. */
790
+ function parseReflogEntries(text) {
791
+ return stripOneTrailingNewline(text)
792
+ .split('\n')
793
+ .filter((line) => line !== '')
794
+ .map((line) => {
795
+ const space = line.indexOf(' ');
796
+ const commit = space === -1 ? line : line.slice(0, space);
797
+ if (!OBJECT_ID.test(commit)) {
798
+ throw new GitOutputError(`unexpected git log -g output: ${JSON.stringify(line.slice(0, 120))}`);
799
+ }
800
+ return { commit, subject: line.slice(space + 1) };
801
+ });
802
+ }
803
+ /** head-name's one line: a branch ref, or `detached HEAD` (null). */
804
+ function parseHeadName(text) {
805
+ const name = stripOneTrailingNewline(text);
806
+ if (name === DETACHED_HEAD_NAME) {
807
+ return null;
808
+ }
809
+ if (!name.startsWith(BRANCH_PREFIX) ||
810
+ name.length === BRANCH_PREFIX.length ||
811
+ name.includes('\n')) {
812
+ throw new GitOutputError(`unexpected rebase head-name: ${JSON.stringify(name.slice(0, 120))}`);
813
+ }
814
+ return name;
815
+ }
816
+ function parseRefName(stdout) {
817
+ const ref = stripOneTrailingNewline(stdout.toString('utf8'));
818
+ if (!ref.startsWith('refs/') || ref.includes('\n')) {
819
+ throw new GitOutputError(`unexpected git symbolic-ref output: ${JSON.stringify(ref.slice(0, 120))}`);
820
+ }
821
+ return ref;
822
+ }