git-chopstick-core 0.1.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.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/examples/get-status.ts +84 -0
- package/package.json +20 -0
- package/src/git/add.ts +16 -0
- package/src/git/apply.ts +154 -0
- package/src/git/authentication.ts +19 -0
- package/src/git/branch.ts +206 -0
- package/src/git/checkout-index.ts +40 -0
- package/src/git/checkout.ts +235 -0
- package/src/git/cherry-pick.ts +504 -0
- package/src/git/clean.ts +9 -0
- package/src/git/clone.ts +86 -0
- package/src/git/coerce-to-buffer.ts +4 -0
- package/src/git/coerce-to-string.ts +4 -0
- package/src/git/commit.ts +136 -0
- package/src/git/config.ts +392 -0
- package/src/git/core.ts +625 -0
- package/src/git/create-tail-stream.ts +36 -0
- package/src/git/credential.ts +83 -0
- package/src/git/description.ts +33 -0
- package/src/git/diff-check.ts +27 -0
- package/src/git/diff-index.ts +116 -0
- package/src/git/diff.ts +880 -0
- package/src/git/environment.ts +116 -0
- package/src/git/exec.ts +285 -0
- package/src/git/fetch.ts +141 -0
- package/src/git/for-each-ref.ts +160 -0
- package/src/git/format-patch.ts +17 -0
- package/src/git/git-delimiter-parser.ts +95 -0
- package/src/git/gitignore.ts +157 -0
- package/src/git/index.ts +36 -0
- package/src/git/init.ts +11 -0
- package/src/git/interpret-trailers.ts +176 -0
- package/src/git/lfs.ts +100 -0
- package/src/git/log.ts +376 -0
- package/src/git/merge-tree.ts +42 -0
- package/src/git/merge.ts +154 -0
- package/src/git/multi-operation-terminal-output.ts +68 -0
- package/src/git/pull.ts +130 -0
- package/src/git/push-terminal-chunk.ts +41 -0
- package/src/git/push.ts +119 -0
- package/src/git/rebase.ts +627 -0
- package/src/git/reflog.ts +127 -0
- package/src/git/refs.ts +63 -0
- package/src/git/remote.ts +143 -0
- package/src/git/reorder.ts +153 -0
- package/src/git/reset.ts +101 -0
- package/src/git/rev-list.ts +201 -0
- package/src/git/rev-parse.ts +92 -0
- package/src/git/revert.ts +55 -0
- package/src/git/rm.ts +31 -0
- package/src/git/show.ts +88 -0
- package/src/git/spawn.ts +38 -0
- package/src/git/squash.ts +173 -0
- package/src/git/stage.ts +97 -0
- package/src/git/stash.ts +302 -0
- package/src/git/status.ts +502 -0
- package/src/git/submodule.ts +212 -0
- package/src/git/tag.ts +134 -0
- package/src/git/update-index.ts +169 -0
- package/src/git/update-ref.ts +50 -0
- package/src/git/var.ts +42 -0
- package/src/git/worktree-include.ts +146 -0
- package/src/git/worktree.ts +219 -0
- package/src/lib/api.ts +7 -0
- package/src/lib/diff-parser.ts +249 -0
- package/src/lib/directory-exists.ts +10 -0
- package/src/lib/errno-exception.ts +12 -0
- package/src/lib/fatal-error.ts +23 -0
- package/src/lib/feature-flag.ts +29 -0
- package/src/lib/file-system.ts +7 -0
- package/src/lib/get-old-path.ts +11 -0
- package/src/lib/git/environment.ts +14 -0
- package/src/lib/git-perf.ts +3 -0
- package/src/lib/helpers/default-branch.ts +3 -0
- package/src/lib/helpers/path.ts +5 -0
- package/src/lib/hooks/with-hooks-env.ts +7 -0
- package/src/lib/merge.ts +3 -0
- package/src/lib/noop.ts +1 -0
- package/src/lib/patch-formatter.ts +18 -0
- package/src/lib/path-exists.ts +7 -0
- package/src/lib/progress/from-process.ts +10 -0
- package/src/lib/progress/index.ts +43 -0
- package/src/lib/progress/revert.ts +17 -0
- package/src/lib/rebase.ts +3 -0
- package/src/lib/remove-remote-prefix.ts +4 -0
- package/src/lib/resolve-git-proxy.ts +3 -0
- package/src/lib/round.ts +4 -0
- package/src/lib/split-buffer.ts +14 -0
- package/src/lib/status-parser.ts +188 -0
- package/src/lib/stores/helpers/find-default-remote.ts +3 -0
- package/src/lib/trampoline/trampoline-environment.ts +8 -0
- package/src/models/branch.ts +78 -0
- package/src/models/cherry-pick.ts +12 -0
- package/src/models/clone-options.ts +6 -0
- package/src/models/commit-identity.ts +35 -0
- package/src/models/commit.ts +44 -0
- package/src/models/computed-action.ts +6 -0
- package/src/models/diff/diff-data.ts +78 -0
- package/src/models/diff/diff-line.ts +36 -0
- package/src/models/diff/diff-selection.ts +165 -0
- package/src/models/diff/image-diff.ts +6 -0
- package/src/models/diff/image.ts +8 -0
- package/src/models/diff/index.ts +6 -0
- package/src/models/diff/raw-diff.ts +41 -0
- package/src/models/git-author.ts +16 -0
- package/src/models/manual-conflict-resolution.ts +4 -0
- package/src/models/merge.ts +6 -0
- package/src/models/multi-commit-operation.ts +6 -0
- package/src/models/progress.ts +67 -0
- package/src/models/rebase.ts +20 -0
- package/src/models/remote.ts +10 -0
- package/src/models/repository.ts +16 -0
- package/src/models/stash-entry.ts +25 -0
- package/src/models/status.ts +275 -0
- package/src/models/submodule.ts +13 -0
- package/src/models/worktree.ts +11 -0
- package/tsconfig.json +17 -0
package/src/git/log.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { git } from './core'
|
|
2
|
+
import {
|
|
3
|
+
CommittedFileChange,
|
|
4
|
+
AppFileStatusKind,
|
|
5
|
+
PlainFileStatus,
|
|
6
|
+
CopiedOrRenamedFileStatus,
|
|
7
|
+
UntrackedFileStatus,
|
|
8
|
+
AppFileStatus,
|
|
9
|
+
SubmoduleStatus,
|
|
10
|
+
} from '../models/status'
|
|
11
|
+
import { Repository } from '../models/repository'
|
|
12
|
+
import { Commit } from '../models/commit'
|
|
13
|
+
import { CommitIdentity } from '../models/commit-identity'
|
|
14
|
+
import { parseRawUnfoldedTrailers } from './interpret-trailers'
|
|
15
|
+
import { createLogParser } from './git-delimiter-parser'
|
|
16
|
+
import { forceUnwrap } from '../lib/fatal-error'
|
|
17
|
+
import assert from 'assert'
|
|
18
|
+
|
|
19
|
+
// File mode 160000 is used by git specifically for submodules:
|
|
20
|
+
// https://github.com/git/git/blob/v2.37.3/cache.h#L62-L69
|
|
21
|
+
const SubmoduleFileMode = '160000'
|
|
22
|
+
|
|
23
|
+
function mapSubmoduleStatusFileModes(
|
|
24
|
+
status: string,
|
|
25
|
+
srcMode: string,
|
|
26
|
+
dstMode: string
|
|
27
|
+
): SubmoduleStatus | undefined {
|
|
28
|
+
return srcMode === SubmoduleFileMode &&
|
|
29
|
+
dstMode === SubmoduleFileMode &&
|
|
30
|
+
status === 'M'
|
|
31
|
+
? {
|
|
32
|
+
commitChanged: true,
|
|
33
|
+
untrackedChanges: false,
|
|
34
|
+
modifiedChanges: false,
|
|
35
|
+
}
|
|
36
|
+
: (srcMode === SubmoduleFileMode && status === 'D') ||
|
|
37
|
+
(dstMode === SubmoduleFileMode && status === 'A')
|
|
38
|
+
? {
|
|
39
|
+
commitChanged: false,
|
|
40
|
+
untrackedChanges: false,
|
|
41
|
+
modifiedChanges: false,
|
|
42
|
+
}
|
|
43
|
+
: undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map the raw status text from Git to an app-friendly value
|
|
48
|
+
* shamelessly borrowed from GitHub Desktop (Windows)
|
|
49
|
+
*/
|
|
50
|
+
function mapStatus(
|
|
51
|
+
rawStatus: string,
|
|
52
|
+
oldPath: string | undefined,
|
|
53
|
+
srcMode: string,
|
|
54
|
+
dstMode: string
|
|
55
|
+
): PlainFileStatus | CopiedOrRenamedFileStatus | UntrackedFileStatus {
|
|
56
|
+
const status = rawStatus.trim()
|
|
57
|
+
const submoduleStatus = mapSubmoduleStatusFileModes(status, srcMode, dstMode)
|
|
58
|
+
|
|
59
|
+
if (status === 'M') {
|
|
60
|
+
return { kind: AppFileStatusKind.Modified, submoduleStatus }
|
|
61
|
+
} // modified
|
|
62
|
+
if (status === 'A') {
|
|
63
|
+
return { kind: AppFileStatusKind.New, submoduleStatus }
|
|
64
|
+
} // added
|
|
65
|
+
if (status === '?') {
|
|
66
|
+
return { kind: AppFileStatusKind.Untracked, submoduleStatus }
|
|
67
|
+
} // untracked
|
|
68
|
+
if (status === 'D') {
|
|
69
|
+
return { kind: AppFileStatusKind.Deleted, submoduleStatus }
|
|
70
|
+
} // deleted
|
|
71
|
+
if (status === 'R' && oldPath != null) {
|
|
72
|
+
return {
|
|
73
|
+
kind: AppFileStatusKind.Renamed,
|
|
74
|
+
oldPath,
|
|
75
|
+
submoduleStatus,
|
|
76
|
+
renameIncludesModifications: false,
|
|
77
|
+
}
|
|
78
|
+
} // renamed
|
|
79
|
+
if (status === 'C' && oldPath != null) {
|
|
80
|
+
return {
|
|
81
|
+
kind: AppFileStatusKind.Copied,
|
|
82
|
+
oldPath,
|
|
83
|
+
submoduleStatus,
|
|
84
|
+
renameIncludesModifications: false,
|
|
85
|
+
}
|
|
86
|
+
} // copied
|
|
87
|
+
|
|
88
|
+
// git log -M --name-status will return a RXXX - where XXX is a percentage
|
|
89
|
+
if (status.match(/R[0-9]+/) && oldPath != null) {
|
|
90
|
+
return {
|
|
91
|
+
kind: AppFileStatusKind.Renamed,
|
|
92
|
+
oldPath,
|
|
93
|
+
submoduleStatus,
|
|
94
|
+
renameIncludesModifications: status !== 'R100',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// git log -C --name-status will return a CXXX - where XXX is a percentage
|
|
99
|
+
if (status.match(/C[0-9]+/) && oldPath != null) {
|
|
100
|
+
return {
|
|
101
|
+
kind: AppFileStatusKind.Copied,
|
|
102
|
+
oldPath,
|
|
103
|
+
submoduleStatus,
|
|
104
|
+
renameIncludesModifications: false,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { kind: AppFileStatusKind.Modified, submoduleStatus }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const isCopyOrRename = (
|
|
112
|
+
status: AppFileStatus
|
|
113
|
+
): status is CopiedOrRenamedFileStatus =>
|
|
114
|
+
status.kind === AppFileStatusKind.Copied ||
|
|
115
|
+
status.kind === AppFileStatusKind.Renamed
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get the repository's commits using `revisionRange` and limited to `limit`
|
|
119
|
+
*/
|
|
120
|
+
export async function getCommits(
|
|
121
|
+
repository: Repository,
|
|
122
|
+
revisionRange?: string,
|
|
123
|
+
limit?: number,
|
|
124
|
+
skip?: number,
|
|
125
|
+
additionalArgs: ReadonlyArray<string> = []
|
|
126
|
+
): Promise<ReadonlyArray<Commit>> {
|
|
127
|
+
const { formatArgs, parse } = createLogParser({
|
|
128
|
+
sha: '%H', // SHA
|
|
129
|
+
shortSha: '%h', // short SHA
|
|
130
|
+
summary: '%s', // summary
|
|
131
|
+
body: '%b', // body
|
|
132
|
+
// author identity string, matching format of GIT_AUTHOR_IDENT.
|
|
133
|
+
// author name <author email> <author date>
|
|
134
|
+
// author date format dependent on --date arg, should be raw
|
|
135
|
+
author: '%an <%ae> %ad',
|
|
136
|
+
committer: '%cn <%ce> %cd',
|
|
137
|
+
parents: '%P', // parent SHAs,
|
|
138
|
+
trailers: '%(trailers:unfold,only)',
|
|
139
|
+
refs: '%D',
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const args = ['log']
|
|
143
|
+
|
|
144
|
+
if (revisionRange !== undefined) {
|
|
145
|
+
args.push(revisionRange)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
args.push('--date=raw')
|
|
149
|
+
|
|
150
|
+
if (limit !== undefined) {
|
|
151
|
+
args.push(`--max-count=${limit}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (skip !== undefined) {
|
|
155
|
+
args.push(`--skip=${skip}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
args.push(
|
|
159
|
+
...formatArgs,
|
|
160
|
+
'--no-show-signature',
|
|
161
|
+
'--no-color',
|
|
162
|
+
...additionalArgs,
|
|
163
|
+
'--'
|
|
164
|
+
)
|
|
165
|
+
const result = await git(args, repository.path, 'getCommits', {
|
|
166
|
+
successExitCodes: new Set([0, 128]),
|
|
167
|
+
encoding: 'buffer',
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// if the repository has an unborn HEAD, return an empty history of commits
|
|
171
|
+
if (result.exitCode === 128) {
|
|
172
|
+
return new Array<Commit>()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const parsed = parse(result.stdout)
|
|
176
|
+
|
|
177
|
+
return parsed.map(commit => {
|
|
178
|
+
// Ref is of the format: (HEAD -> master, tag: some-tag-name, tag: some-other-tag,with-a-comma, origin/master, origin/HEAD)
|
|
179
|
+
// Refs are comma separated, but some like tags can also contain commas in the name, so we split on the pattern ", " and then
|
|
180
|
+
// check each ref for the tag prefix. We used to use the regex /tag: ([^\s,]+)/g)`, but will clip a tag with a comma short.
|
|
181
|
+
const tags = commit.refs
|
|
182
|
+
.toString()
|
|
183
|
+
.split(', ')
|
|
184
|
+
.flatMap(ref => (ref.startsWith('tag: ') ? ref.substring(5) : []))
|
|
185
|
+
|
|
186
|
+
return new Commit(
|
|
187
|
+
commit.sha.toString(),
|
|
188
|
+
commit.shortSha.toString(),
|
|
189
|
+
commit.summary.subarray(0, 100 * 1024).toString(),
|
|
190
|
+
commit.body.subarray(0, 100 * 1024).toString(),
|
|
191
|
+
CommitIdentity.parseIdentity(commit.author.toString()),
|
|
192
|
+
CommitIdentity.parseIdentity(commit.committer.toString()),
|
|
193
|
+
commit.parents.length > 0 ? commit.parents.toString().split(' ') : [],
|
|
194
|
+
// We know for sure that the trailer separator will be ':' since we got
|
|
195
|
+
// them from %(trailers:unfold) above, see `git help log`:
|
|
196
|
+
//
|
|
197
|
+
// "key_value_separator=<SEP>: specify a separator inserted between
|
|
198
|
+
// trailer lines. When this option is not given each trailer key-value
|
|
199
|
+
// pair is separated by ": ". Otherwise it shares the same semantics as
|
|
200
|
+
// separator=<SEP> above."
|
|
201
|
+
parseRawUnfoldedTrailers(commit.trailers.toString(), ':'),
|
|
202
|
+
tags
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** This interface contains information of a changeset. */
|
|
208
|
+
export interface IChangesetData {
|
|
209
|
+
/** Files changed in the changeset. */
|
|
210
|
+
readonly files: ReadonlyArray<CommittedFileChange>
|
|
211
|
+
|
|
212
|
+
/** Number of lines added in the changeset. */
|
|
213
|
+
readonly linesAdded: number
|
|
214
|
+
|
|
215
|
+
/** Number of lines deleted in the changeset. */
|
|
216
|
+
readonly linesDeleted: number
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Get the files that were changed in the given commit. */
|
|
220
|
+
export async function getChangedFiles(
|
|
221
|
+
repository: Repository,
|
|
222
|
+
sha: string
|
|
223
|
+
): Promise<IChangesetData> {
|
|
224
|
+
// opt-in for rename detection (-M) and copies detection (-C)
|
|
225
|
+
// this is equivalent to the user configuring 'diff.renames' to 'copies'
|
|
226
|
+
// NOTE: order here matters - doing -M before -C means copies aren't detected
|
|
227
|
+
const args = [
|
|
228
|
+
'log',
|
|
229
|
+
sha,
|
|
230
|
+
'-C',
|
|
231
|
+
'-M',
|
|
232
|
+
'-m',
|
|
233
|
+
'-1',
|
|
234
|
+
'--no-show-signature',
|
|
235
|
+
'--first-parent',
|
|
236
|
+
'--raw',
|
|
237
|
+
'--format=format:',
|
|
238
|
+
'--numstat',
|
|
239
|
+
'-z',
|
|
240
|
+
'--',
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
const { stdout } = await git(args, repository.path, 'getChangesFiles')
|
|
244
|
+
return parseRawLogWithNumstat(stdout, sha, `${sha}^`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parses output of diff flags -z --raw --numstat.
|
|
249
|
+
*
|
|
250
|
+
* Given the -z flag the new lines are separated by \0 character (left them as
|
|
251
|
+
* new lines below for ease of reading)
|
|
252
|
+
*
|
|
253
|
+
* For modified, added, deleted, untracked:
|
|
254
|
+
* 100644 100644 5716ca5 db3c77d M
|
|
255
|
+
* file_one_path
|
|
256
|
+
* :100644 100644 0835e4f 28096ea M
|
|
257
|
+
* file_two_path
|
|
258
|
+
* 1 0 file_one_path
|
|
259
|
+
* 1 0 file_two_path
|
|
260
|
+
*
|
|
261
|
+
* For copied or renamed:
|
|
262
|
+
* 100644 100644 5716ca5 db3c77d M
|
|
263
|
+
* file_one_original_path
|
|
264
|
+
* file_one_new_path
|
|
265
|
+
* :100644 100644 0835e4f 28096ea M
|
|
266
|
+
* file_two_original_path
|
|
267
|
+
* file_two_new_path
|
|
268
|
+
* 1 0
|
|
269
|
+
* file_one_original_path
|
|
270
|
+
* file_one_new_path
|
|
271
|
+
* 1 0
|
|
272
|
+
* file_two_original_path
|
|
273
|
+
* file_two_new_path
|
|
274
|
+
*/
|
|
275
|
+
|
|
276
|
+
export function parseRawLogWithNumstat(
|
|
277
|
+
stdout: string,
|
|
278
|
+
sha: string,
|
|
279
|
+
parentCommitish: string
|
|
280
|
+
) {
|
|
281
|
+
const files = new Array<CommittedFileChange>()
|
|
282
|
+
let linesAdded = 0
|
|
283
|
+
let linesDeleted = 0
|
|
284
|
+
let numStatCount = 0
|
|
285
|
+
const lines = stdout.split('\0')
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
288
|
+
const line = lines[i]
|
|
289
|
+
if (line.startsWith(':')) {
|
|
290
|
+
const lineComponents = line.split(' ')
|
|
291
|
+
const srcMode = forceUnwrap(
|
|
292
|
+
'Invalid log output (srcMode)',
|
|
293
|
+
lineComponents[0]?.replace(':', '')
|
|
294
|
+
)
|
|
295
|
+
const dstMode = forceUnwrap(
|
|
296
|
+
'Invalid log output (dstMode)',
|
|
297
|
+
lineComponents[1]
|
|
298
|
+
)
|
|
299
|
+
const status = forceUnwrap(
|
|
300
|
+
'Invalid log output (status)',
|
|
301
|
+
lineComponents.at(-1)
|
|
302
|
+
)
|
|
303
|
+
const oldPath = /^R|C/.test(status)
|
|
304
|
+
? forceUnwrap('Missing old path', lines.at(++i))
|
|
305
|
+
: undefined
|
|
306
|
+
|
|
307
|
+
const path = forceUnwrap('Missing path', lines.at(++i))
|
|
308
|
+
|
|
309
|
+
files.push(
|
|
310
|
+
new CommittedFileChange(
|
|
311
|
+
path,
|
|
312
|
+
mapStatus(status, oldPath, srcMode, dstMode),
|
|
313
|
+
sha,
|
|
314
|
+
parentCommitish
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
} else {
|
|
318
|
+
const match = /^(\d+|-)\t(\d+|-)\t/.exec(line)
|
|
319
|
+
const [, added, deleted] = forceUnwrap('Invalid numstat line', match)
|
|
320
|
+
linesAdded += added === '-' ? 0 : parseInt(added, 10)
|
|
321
|
+
linesDeleted += deleted === '-' ? 0 : parseInt(deleted, 10)
|
|
322
|
+
|
|
323
|
+
// If this entry denotes a rename or copy the old and new paths are on
|
|
324
|
+
// two separate fields (separated by \0). Otherwise they're on the same
|
|
325
|
+
// line as the added and deleted lines.
|
|
326
|
+
if (isCopyOrRename(files[numStatCount].status)) {
|
|
327
|
+
i += 2
|
|
328
|
+
}
|
|
329
|
+
numStatCount++
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { files, linesAdded, linesDeleted }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Get the commit for the given ref. */
|
|
337
|
+
export async function getCommit(
|
|
338
|
+
repository: Repository,
|
|
339
|
+
ref: string
|
|
340
|
+
): Promise<Commit | null> {
|
|
341
|
+
const commits = await getCommits(repository, ref, 1)
|
|
342
|
+
if (commits.length < 1) {
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return commits[0]
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Get the author identity for the given shas */
|
|
350
|
+
export async function getAuthors(repository: Repository, shas: string[]) {
|
|
351
|
+
if (shas.length === 0) {
|
|
352
|
+
return []
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { stdout } = await git(
|
|
356
|
+
[
|
|
357
|
+
'log',
|
|
358
|
+
'--format=format:%an <%ae> %ad',
|
|
359
|
+
'--no-walk=unsorted',
|
|
360
|
+
'--date=raw',
|
|
361
|
+
'-z',
|
|
362
|
+
'--stdin',
|
|
363
|
+
],
|
|
364
|
+
repository.path,
|
|
365
|
+
'getAuthors',
|
|
366
|
+
{ stdin: shas.join('\n') }
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
const authors = stdout.split('\0').map(CommitIdentity.parseIdentity)
|
|
370
|
+
|
|
371
|
+
// This can happen if there are duplicate shas in the input, git log will only
|
|
372
|
+
// return the author once for each sha.
|
|
373
|
+
assert.equal(authors.length, shas.length, 'Commit to author mismatch')
|
|
374
|
+
|
|
375
|
+
return authors
|
|
376
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Branch } from '../models/branch'
|
|
2
|
+
import { ComputedAction } from '../models/computed-action'
|
|
3
|
+
import { Repository } from '../models/repository'
|
|
4
|
+
import { git, isGitError } from './core'
|
|
5
|
+
import { GitError } from './exec'
|
|
6
|
+
|
|
7
|
+
type MergeTreeResult =
|
|
8
|
+
| { kind: ComputedAction.Clean }
|
|
9
|
+
| { kind: ComputedAction.Conflicts; conflictedFiles: number }
|
|
10
|
+
| { kind: ComputedAction.Invalid }
|
|
11
|
+
|
|
12
|
+
export async function determineMergeability(
|
|
13
|
+
repository: Repository,
|
|
14
|
+
ours: Branch,
|
|
15
|
+
theirs: Branch
|
|
16
|
+
) {
|
|
17
|
+
return git(
|
|
18
|
+
[
|
|
19
|
+
'merge-tree',
|
|
20
|
+
'--write-tree',
|
|
21
|
+
'--name-only',
|
|
22
|
+
'--no-messages',
|
|
23
|
+
'-z',
|
|
24
|
+
ours.tip.sha,
|
|
25
|
+
theirs.tip.sha,
|
|
26
|
+
],
|
|
27
|
+
repository.path,
|
|
28
|
+
'determineMergeability',
|
|
29
|
+
{ successExitCodes: new Set([0, 1]) }
|
|
30
|
+
)
|
|
31
|
+
.then<MergeTreeResult>(({ stdout }) => {
|
|
32
|
+
const conflictedFiles = (stdout.match(/\0/g)?.length ?? 0) - 1
|
|
33
|
+
return conflictedFiles > 0
|
|
34
|
+
? { kind: ComputedAction.Conflicts, conflictedFiles }
|
|
35
|
+
: { kind: ComputedAction.Clean }
|
|
36
|
+
})
|
|
37
|
+
.catch<MergeTreeResult>(e =>
|
|
38
|
+
isGitError(e, GitError.CannotMergeUnrelatedHistories)
|
|
39
|
+
? Promise.resolve({ kind: ComputedAction.Invalid })
|
|
40
|
+
: Promise.reject(e)
|
|
41
|
+
)
|
|
42
|
+
}
|
package/src/git/merge.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
import { git, HookCallbackOptions } from './core'
|
|
3
|
+
import { GitError } from './exec'
|
|
4
|
+
import { Repository } from '../models/repository'
|
|
5
|
+
import { pathExists } from '../lib/path-exists'
|
|
6
|
+
import { createMultiOperationTerminalOutputCallback } from './multi-operation-terminal-output'
|
|
7
|
+
|
|
8
|
+
export enum MergeResult {
|
|
9
|
+
/** The merge completed successfully */
|
|
10
|
+
Success,
|
|
11
|
+
/**
|
|
12
|
+
* The merge was a noop since the current branch
|
|
13
|
+
* was already up to date with the target branch.
|
|
14
|
+
*/
|
|
15
|
+
AlreadyUpToDate,
|
|
16
|
+
/**
|
|
17
|
+
* The merge failed, likely due to conflicts.
|
|
18
|
+
*/
|
|
19
|
+
Failed,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MergeOptions = {
|
|
23
|
+
/** Whether to perform a squash merge */
|
|
24
|
+
readonly squash?: boolean
|
|
25
|
+
/** Whether to bypass pre-merge and post-merge hooks */
|
|
26
|
+
readonly noVerify?: boolean
|
|
27
|
+
} & HookCallbackOptions
|
|
28
|
+
|
|
29
|
+
/** Merge the named branch into the current branch. */
|
|
30
|
+
export async function merge(
|
|
31
|
+
repository: Repository,
|
|
32
|
+
branch: string,
|
|
33
|
+
options?: MergeOptions
|
|
34
|
+
): Promise<MergeResult> {
|
|
35
|
+
const onTerminalOutputAvailable = options?.onTerminalOutputAvailable
|
|
36
|
+
? createMultiOperationTerminalOutputCallback(
|
|
37
|
+
options?.onTerminalOutputAvailable
|
|
38
|
+
)
|
|
39
|
+
: undefined
|
|
40
|
+
|
|
41
|
+
const args = ['merge']
|
|
42
|
+
|
|
43
|
+
if (options?.squash) {
|
|
44
|
+
args.push('--squash')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options?.noVerify) {
|
|
48
|
+
args.push('--no-verify')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
args.push(branch)
|
|
52
|
+
|
|
53
|
+
const { exitCode, stdout } = await git(args, repository.path, 'merge', {
|
|
54
|
+
expectedErrors: new Set([GitError.MergeConflicts]),
|
|
55
|
+
interceptHooks: ['pre-merge-commit', 'post-merge', 'commit-msg'],
|
|
56
|
+
onHookProgress: options?.onHookProgress,
|
|
57
|
+
onHookFailure: options?.onHookFailure,
|
|
58
|
+
onTerminalOutputAvailable,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (exitCode !== 0) {
|
|
62
|
+
return MergeResult.Failed
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options?.squash) {
|
|
66
|
+
const { exitCode } = await git(
|
|
67
|
+
['commit', '--no-edit'],
|
|
68
|
+
repository.path,
|
|
69
|
+
'createSquashMergeCommit',
|
|
70
|
+
{
|
|
71
|
+
interceptHooks: [
|
|
72
|
+
'pre-merge-commit',
|
|
73
|
+
'prepare-commit-msg',
|
|
74
|
+
'commit-msg',
|
|
75
|
+
'post-commit',
|
|
76
|
+
'pre-auto-gc',
|
|
77
|
+
],
|
|
78
|
+
onHookProgress: options?.onHookProgress,
|
|
79
|
+
onHookFailure: options?.onHookFailure,
|
|
80
|
+
onTerminalOutputAvailable,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
if (exitCode !== 0) {
|
|
84
|
+
return MergeResult.Failed
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return stdout === noopMergeMessage
|
|
89
|
+
? MergeResult.AlreadyUpToDate
|
|
90
|
+
: MergeResult.Success
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const noopMergeMessage = 'Already up to date.\n'
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Find the base commit between two commit-ish identifiers
|
|
97
|
+
*
|
|
98
|
+
* @returns the commit id of the merge base, or null if the two commit-ish
|
|
99
|
+
* identifiers do not have a common base
|
|
100
|
+
*/
|
|
101
|
+
export async function getMergeBase(
|
|
102
|
+
repository: Repository,
|
|
103
|
+
firstCommitish: string,
|
|
104
|
+
secondCommitish: string
|
|
105
|
+
): Promise<string | null> {
|
|
106
|
+
const process = await git(
|
|
107
|
+
['merge-base', firstCommitish, secondCommitish],
|
|
108
|
+
repository.path,
|
|
109
|
+
'merge-base',
|
|
110
|
+
{
|
|
111
|
+
// - 1 is returned if a common ancestor cannot be resolved
|
|
112
|
+
// - 128 is returned if a ref cannot be found
|
|
113
|
+
// "warning: ignoring broken ref refs/remotes/origin/main."
|
|
114
|
+
successExitCodes: new Set([0, 1, 128]),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if (process.exitCode === 1 || process.exitCode === 128) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return process.stdout.trim()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Abort a mid-flight (conflicted) merge
|
|
127
|
+
*
|
|
128
|
+
* @param repository where to abort the merge
|
|
129
|
+
*/
|
|
130
|
+
export async function abortMerge(repository: Repository): Promise<void> {
|
|
131
|
+
await git(['merge', '--abort'], repository.path, 'abortMerge')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check the `.git/MERGE_HEAD` file exists in a repository to confirm
|
|
136
|
+
* that it is in a conflicted state.
|
|
137
|
+
*/
|
|
138
|
+
export async function isMergeHeadSet(repository: Repository): Promise<boolean> {
|
|
139
|
+
const path = join(repository.resolvedGitDir, 'MERGE_HEAD')
|
|
140
|
+
return await pathExists(path)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check the `.git/SQUASH_MSG` file exists in a repository
|
|
145
|
+
* This would indicate we did a merge --squash and have not committed.. indicating
|
|
146
|
+
* we have detected a conflict.
|
|
147
|
+
*
|
|
148
|
+
* Note: If we abort the merge, this doesn't get cleared automatically which
|
|
149
|
+
* could lead to this being erroneously available in a non merge --squashing scenario.
|
|
150
|
+
*/
|
|
151
|
+
export async function isSquashMsgSet(repository: Repository): Promise<boolean> {
|
|
152
|
+
const path = join(repository.resolvedGitDir, 'SQUASH_MSG')
|
|
153
|
+
return await pathExists(path)
|
|
154
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import noop from '../lib/noop'
|
|
2
|
+
import {
|
|
3
|
+
TerminalOutput,
|
|
4
|
+
TerminalOutputCallback,
|
|
5
|
+
TerminalOutputListener,
|
|
6
|
+
} from './core'
|
|
7
|
+
import { pushTerminalChunk } from './push-terminal-chunk'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates a callback that aggregates terminal output from multiple Git
|
|
11
|
+
* operations into a single stream.
|
|
12
|
+
*
|
|
13
|
+
* This function is useful when running multiple Git operations sequentially
|
|
14
|
+
* where you want to present a unified terminal output view. It buffers output
|
|
15
|
+
* from all operations and forwards them to upstream subscribers when requested.
|
|
16
|
+
*
|
|
17
|
+
* The callback maintains an internal buffer (default 256KB) and subscribes to
|
|
18
|
+
* each Git operation's terminal output. When an upstream consumer requests the
|
|
19
|
+
* output, it receives all previously buffered chunks followed by any new chunks
|
|
20
|
+
* as they arrive.
|
|
21
|
+
*
|
|
22
|
+
* @param onTerminalOutputAvailable - The user provided callback which will
|
|
23
|
+
* receive the aggregated terminal output.
|
|
24
|
+
* @returns A callback that can be passed to individual Git operations as the
|
|
25
|
+
* onTerminalOutputAvailable callback to capture their terminal output
|
|
26
|
+
*/
|
|
27
|
+
export const createMultiOperationTerminalOutputCallback = (
|
|
28
|
+
onTerminalOutputAvailable: TerminalOutputCallback,
|
|
29
|
+
capacity = 256 * 1024
|
|
30
|
+
): TerminalOutputCallback => {
|
|
31
|
+
let outputStarted = false
|
|
32
|
+
const chunks: string[] = []
|
|
33
|
+
const upstreamSubscribers = new Set<(chunk: TerminalOutput) => void>()
|
|
34
|
+
|
|
35
|
+
const push = (chunk: string | Buffer) => {
|
|
36
|
+
if (!outputStarted) {
|
|
37
|
+
onTerminalOutputAvailable(function (cb) {
|
|
38
|
+
upstreamSubscribers.add(cb)
|
|
39
|
+
chunks.forEach(c => cb(c))
|
|
40
|
+
return { unsubscribe: () => upstreamSubscribers.delete(cb) }
|
|
41
|
+
})
|
|
42
|
+
outputStarted = true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pushTerminalChunk(chunks, capacity, chunk)
|
|
46
|
+
upstreamSubscribers.forEach(cb => cb(chunk))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Called by each Git operation when terminal output is available. We'll
|
|
50
|
+
// subscribe immediately to capture output from all operations and then
|
|
51
|
+
// forward it to upstream callbacks if/when requested.
|
|
52
|
+
const cb = function (subscribe: TerminalOutputListener) {
|
|
53
|
+
subscribe(c => {
|
|
54
|
+
if (Array.isArray(c)) {
|
|
55
|
+
chunks.forEach(push)
|
|
56
|
+
} else {
|
|
57
|
+
push(c)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// We can't unsubscribe because the user might request terminal output in
|
|
62
|
+
// the future and we need to buffer the output from all operations to
|
|
63
|
+
// ensure we can present the entire output.
|
|
64
|
+
return { unsubscribe: noop }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return cb
|
|
68
|
+
}
|