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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/examples/get-status.ts +84 -0
  4. package/package.json +20 -0
  5. package/src/git/add.ts +16 -0
  6. package/src/git/apply.ts +154 -0
  7. package/src/git/authentication.ts +19 -0
  8. package/src/git/branch.ts +206 -0
  9. package/src/git/checkout-index.ts +40 -0
  10. package/src/git/checkout.ts +235 -0
  11. package/src/git/cherry-pick.ts +504 -0
  12. package/src/git/clean.ts +9 -0
  13. package/src/git/clone.ts +86 -0
  14. package/src/git/coerce-to-buffer.ts +4 -0
  15. package/src/git/coerce-to-string.ts +4 -0
  16. package/src/git/commit.ts +136 -0
  17. package/src/git/config.ts +392 -0
  18. package/src/git/core.ts +625 -0
  19. package/src/git/create-tail-stream.ts +36 -0
  20. package/src/git/credential.ts +83 -0
  21. package/src/git/description.ts +33 -0
  22. package/src/git/diff-check.ts +27 -0
  23. package/src/git/diff-index.ts +116 -0
  24. package/src/git/diff.ts +880 -0
  25. package/src/git/environment.ts +116 -0
  26. package/src/git/exec.ts +285 -0
  27. package/src/git/fetch.ts +141 -0
  28. package/src/git/for-each-ref.ts +160 -0
  29. package/src/git/format-patch.ts +17 -0
  30. package/src/git/git-delimiter-parser.ts +95 -0
  31. package/src/git/gitignore.ts +157 -0
  32. package/src/git/index.ts +36 -0
  33. package/src/git/init.ts +11 -0
  34. package/src/git/interpret-trailers.ts +176 -0
  35. package/src/git/lfs.ts +100 -0
  36. package/src/git/log.ts +376 -0
  37. package/src/git/merge-tree.ts +42 -0
  38. package/src/git/merge.ts +154 -0
  39. package/src/git/multi-operation-terminal-output.ts +68 -0
  40. package/src/git/pull.ts +130 -0
  41. package/src/git/push-terminal-chunk.ts +41 -0
  42. package/src/git/push.ts +119 -0
  43. package/src/git/rebase.ts +627 -0
  44. package/src/git/reflog.ts +127 -0
  45. package/src/git/refs.ts +63 -0
  46. package/src/git/remote.ts +143 -0
  47. package/src/git/reorder.ts +153 -0
  48. package/src/git/reset.ts +101 -0
  49. package/src/git/rev-list.ts +201 -0
  50. package/src/git/rev-parse.ts +92 -0
  51. package/src/git/revert.ts +55 -0
  52. package/src/git/rm.ts +31 -0
  53. package/src/git/show.ts +88 -0
  54. package/src/git/spawn.ts +38 -0
  55. package/src/git/squash.ts +173 -0
  56. package/src/git/stage.ts +97 -0
  57. package/src/git/stash.ts +302 -0
  58. package/src/git/status.ts +502 -0
  59. package/src/git/submodule.ts +212 -0
  60. package/src/git/tag.ts +134 -0
  61. package/src/git/update-index.ts +169 -0
  62. package/src/git/update-ref.ts +50 -0
  63. package/src/git/var.ts +42 -0
  64. package/src/git/worktree-include.ts +146 -0
  65. package/src/git/worktree.ts +219 -0
  66. package/src/lib/api.ts +7 -0
  67. package/src/lib/diff-parser.ts +249 -0
  68. package/src/lib/directory-exists.ts +10 -0
  69. package/src/lib/errno-exception.ts +12 -0
  70. package/src/lib/fatal-error.ts +23 -0
  71. package/src/lib/feature-flag.ts +29 -0
  72. package/src/lib/file-system.ts +7 -0
  73. package/src/lib/get-old-path.ts +11 -0
  74. package/src/lib/git/environment.ts +14 -0
  75. package/src/lib/git-perf.ts +3 -0
  76. package/src/lib/helpers/default-branch.ts +3 -0
  77. package/src/lib/helpers/path.ts +5 -0
  78. package/src/lib/hooks/with-hooks-env.ts +7 -0
  79. package/src/lib/merge.ts +3 -0
  80. package/src/lib/noop.ts +1 -0
  81. package/src/lib/patch-formatter.ts +18 -0
  82. package/src/lib/path-exists.ts +7 -0
  83. package/src/lib/progress/from-process.ts +10 -0
  84. package/src/lib/progress/index.ts +43 -0
  85. package/src/lib/progress/revert.ts +17 -0
  86. package/src/lib/rebase.ts +3 -0
  87. package/src/lib/remove-remote-prefix.ts +4 -0
  88. package/src/lib/resolve-git-proxy.ts +3 -0
  89. package/src/lib/round.ts +4 -0
  90. package/src/lib/split-buffer.ts +14 -0
  91. package/src/lib/status-parser.ts +188 -0
  92. package/src/lib/stores/helpers/find-default-remote.ts +3 -0
  93. package/src/lib/trampoline/trampoline-environment.ts +8 -0
  94. package/src/models/branch.ts +78 -0
  95. package/src/models/cherry-pick.ts +12 -0
  96. package/src/models/clone-options.ts +6 -0
  97. package/src/models/commit-identity.ts +35 -0
  98. package/src/models/commit.ts +44 -0
  99. package/src/models/computed-action.ts +6 -0
  100. package/src/models/diff/diff-data.ts +78 -0
  101. package/src/models/diff/diff-line.ts +36 -0
  102. package/src/models/diff/diff-selection.ts +165 -0
  103. package/src/models/diff/image-diff.ts +6 -0
  104. package/src/models/diff/image.ts +8 -0
  105. package/src/models/diff/index.ts +6 -0
  106. package/src/models/diff/raw-diff.ts +41 -0
  107. package/src/models/git-author.ts +16 -0
  108. package/src/models/manual-conflict-resolution.ts +4 -0
  109. package/src/models/merge.ts +6 -0
  110. package/src/models/multi-commit-operation.ts +6 -0
  111. package/src/models/progress.ts +67 -0
  112. package/src/models/rebase.ts +20 -0
  113. package/src/models/remote.ts +10 -0
  114. package/src/models/repository.ts +16 -0
  115. package/src/models/stash-entry.ts +25 -0
  116. package/src/models/status.ts +275 -0
  117. package/src/models/submodule.ts +13 -0
  118. package/src/models/worktree.ts +11 -0
  119. package/tsconfig.json +17 -0
@@ -0,0 +1,880 @@
1
+ import * as Path from 'path'
2
+
3
+ import { getBlobContents } from './show'
4
+
5
+ import { Repository } from '../models/repository'
6
+ import {
7
+ WorkingDirectoryFileChange,
8
+ FileChange,
9
+ AppFileStatusKind,
10
+ SubmoduleStatus,
11
+ CommittedFileChange,
12
+ } from '../models/status'
13
+ import {
14
+ DiffType,
15
+ IRawDiff,
16
+ IDiff,
17
+ IImageDiff,
18
+ Image,
19
+ LineEndingsChange,
20
+ parseLineEndingText,
21
+ ILargeTextDiff,
22
+ } from '../models/diff'
23
+
24
+ import { DiffParser } from '../lib/diff-parser'
25
+ import { getOldPathOrDefault } from '../lib/get-old-path'
26
+ import { readFile } from 'fs/promises'
27
+ import { forceUnwrap } from '../lib/fatal-error'
28
+ import { git } from './core'
29
+ import { NullTreeSHA } from './diff-index'
30
+ import { GitError } from './exec'
31
+ import { IChangesetData, parseRawLogWithNumstat } from './log'
32
+ import { getConfigValue } from './config'
33
+ import { getMergeBase } from './merge'
34
+ import { IStatusEntry } from '../lib/status-parser'
35
+ import { createLogParser } from './git-delimiter-parser'
36
+ import { enableImagePreviewsForDDSFiles } from '../lib/feature-flag'
37
+ import { unstageAll } from './reset'
38
+ import { stageFiles } from './update-index'
39
+ import { isAbsolute } from 'path'
40
+
41
+ /**
42
+ * V8 has a limit on the size of string it can create (~256MB), and unless we want to
43
+ * trigger an unhandled exception we need to do the encoding conversion by hand.
44
+ *
45
+ * This is a hard limit on how big a buffer can be and still be converted into
46
+ * a string.
47
+ */
48
+ const MaxDiffBufferSize = 70e6 // 70MB in decimal
49
+
50
+ /**
51
+ * Where `MaxDiffBufferSize` is a hard limit, this is a suggested limit. Diffs
52
+ * bigger than this _could_ be displayed but it might cause some slowness.
53
+ */
54
+ const MaxReasonableDiffSize = MaxDiffBufferSize / 16 // ~4.375MB in decimal
55
+
56
+ /**
57
+ * The longest line length we should try to display. If a diff has a line longer
58
+ * than this, we probably shouldn't attempt it
59
+ */
60
+ const MaxCharactersPerLine = 5000
61
+
62
+ /**
63
+ * Utility function to check whether parsing this buffer is going to cause
64
+ * issues at runtime.
65
+ *
66
+ * @param buffer A buffer of binary text from a spawned process
67
+ */
68
+ function isValidBuffer(buffer: Buffer) {
69
+ return buffer.length <= MaxDiffBufferSize
70
+ }
71
+
72
+ /** Is the buffer too large for us to reasonably represent? */
73
+ function isBufferTooLarge(buffer: Buffer) {
74
+ return buffer.length >= MaxReasonableDiffSize
75
+ }
76
+
77
+ /** Is the diff too large for us to reasonably represent? */
78
+ function isDiffTooLarge(diff: IRawDiff) {
79
+ for (const hunk of diff.hunks) {
80
+ for (const line of hunk.lines) {
81
+ if (line.text.length > MaxCharactersPerLine) {
82
+ return true
83
+ }
84
+ }
85
+ }
86
+
87
+ return false
88
+ }
89
+
90
+ /**
91
+ * Defining the list of known extensions we can render inside the app
92
+ */
93
+ const imageFileExtensions = new Set([
94
+ '.png',
95
+ '.jpg',
96
+ '.jpeg',
97
+ '.gif',
98
+ '.ico',
99
+ '.webp',
100
+ '.bmp',
101
+ '.avif',
102
+ '.svg',
103
+ ])
104
+
105
+ if (enableImagePreviewsForDDSFiles()) {
106
+ imageFileExtensions.add('.dds')
107
+ }
108
+
109
+ /**
110
+ * Render the difference between a file in the given commit and its parent
111
+ *
112
+ * @param commitish A commit SHA or some other identifier that ultimately dereferences
113
+ * to a commit.
114
+ */
115
+ export async function getCommitDiff(
116
+ repository: Repository,
117
+ file: FileChange,
118
+ commitish: string,
119
+ hideWhitespaceInDiff: boolean = false
120
+ ): Promise<IDiff> {
121
+ const args = [
122
+ 'log',
123
+ commitish,
124
+ ...(hideWhitespaceInDiff ? ['-w'] : []),
125
+ '-m',
126
+ '-1',
127
+ '--first-parent',
128
+ '--patch-with-raw',
129
+ '--format=',
130
+ '-z',
131
+ '--no-color',
132
+ '--',
133
+ ensureRelativePath(file.path),
134
+ ]
135
+
136
+ if (
137
+ file.status.kind === AppFileStatusKind.Renamed ||
138
+ file.status.kind === AppFileStatusKind.Copied
139
+ ) {
140
+ args.push(ensureRelativePath(file.status.oldPath))
141
+ }
142
+
143
+ const { stdout } = await git(args, repository.path, 'getCommitDiff', {
144
+ encoding: 'buffer',
145
+ })
146
+
147
+ return buildDiff(stdout, repository, file, commitish, commitish)
148
+ }
149
+
150
+ /**
151
+ * Render the diff between two branches with --merge-base for a file
152
+ * (Show what would be the result of merge)
153
+ */
154
+ export async function getBranchMergeBaseDiff(
155
+ repository: Repository,
156
+ file: FileChange,
157
+ baseBranchName: string,
158
+ comparisonBranchName: string,
159
+ hideWhitespaceInDiff: boolean = false,
160
+ latestCommit: string
161
+ ): Promise<IDiff> {
162
+ const args = [
163
+ 'diff',
164
+ '--merge-base',
165
+ baseBranchName,
166
+ comparisonBranchName,
167
+ ...(hideWhitespaceInDiff ? ['-w'] : []),
168
+ '--patch-with-raw',
169
+ '-z',
170
+ '--no-color',
171
+ '--',
172
+ ensureRelativePath(file.path),
173
+ ]
174
+
175
+ if (
176
+ file.status.kind === AppFileStatusKind.Renamed ||
177
+ file.status.kind === AppFileStatusKind.Copied
178
+ ) {
179
+ args.push(ensureRelativePath(file.status.oldPath))
180
+ }
181
+
182
+ const result = await git(args, repository.path, 'getBranchMergeBaseDiff', {
183
+ encoding: 'buffer',
184
+ })
185
+
186
+ return buildDiff(result.stdout, repository, file, latestCommit, latestCommit)
187
+ }
188
+
189
+ /**
190
+ * Render the difference between two commits for a file
191
+ *
192
+ */
193
+ export async function getCommitRangeDiff(
194
+ repository: Repository,
195
+ file: FileChange,
196
+ commits: ReadonlyArray<string>,
197
+ hideWhitespaceInDiff: boolean = false,
198
+ useNullTreeSHA: boolean = false
199
+ ): Promise<IDiff> {
200
+ if (commits.length === 0) {
201
+ throw new Error('No commits to diff...')
202
+ }
203
+
204
+ const oldestCommit = useNullTreeSHA ? NullTreeSHA : commits[0]
205
+ const oldestCommitRef = useNullTreeSHA ? NullTreeSHA : `${commits[0]}^`
206
+ const latestCommit = commits.at(-1) ?? '' // can't be undefined since commits.length > 0
207
+ const args = [
208
+ 'diff',
209
+ oldestCommitRef,
210
+ latestCommit,
211
+ ...(hideWhitespaceInDiff ? ['-w'] : []),
212
+ '--patch-with-raw',
213
+ '--format=',
214
+ '-z',
215
+ '--no-color',
216
+ '--',
217
+ ensureRelativePath(file.path),
218
+ ]
219
+
220
+ if (
221
+ file.status.kind === AppFileStatusKind.Renamed ||
222
+ file.status.kind === AppFileStatusKind.Copied
223
+ ) {
224
+ args.push(ensureRelativePath(file.status.oldPath))
225
+ }
226
+
227
+ const result = await git(args, repository.path, 'getCommitsDiff', {
228
+ encoding: 'buffer',
229
+ expectedErrors: new Set([GitError.BadRevision]),
230
+ })
231
+
232
+ // This should only happen if the oldest commit does not have a parent (ex:
233
+ // initial commit of a branch) and therefore `SHA^` is not a valid reference.
234
+ // In which case, we will retry with the null tree sha.
235
+ if (result.gitError === GitError.BadRevision && useNullTreeSHA === false) {
236
+ return getCommitRangeDiff(
237
+ repository,
238
+ file,
239
+ commits,
240
+ hideWhitespaceInDiff,
241
+ true
242
+ )
243
+ }
244
+
245
+ return buildDiff(result.stdout, repository, file, latestCommit, oldestCommit)
246
+ }
247
+
248
+ /**
249
+ * Get the files that were changed for the merge base comparison of two branches.
250
+ * (What would be the result of a merge)
251
+ */
252
+ export async function getBranchMergeBaseChangedFiles(
253
+ repository: Repository,
254
+ baseBranchName: string,
255
+ comparisonBranchName: string,
256
+ latestComparisonBranchCommitRef: string
257
+ ): Promise<IChangesetData | null> {
258
+ const baseArgs = [
259
+ 'diff',
260
+ '--merge-base',
261
+ baseBranchName,
262
+ comparisonBranchName,
263
+ '-C',
264
+ '-M',
265
+ '-z',
266
+ '--raw',
267
+ '--numstat',
268
+ '--',
269
+ ]
270
+
271
+ const mergeBaseCommit = await getMergeBase(
272
+ repository,
273
+ baseBranchName,
274
+ comparisonBranchName
275
+ )
276
+
277
+ if (mergeBaseCommit === null) {
278
+ return null
279
+ }
280
+
281
+ const result = await git(
282
+ baseArgs,
283
+ repository.path,
284
+ 'getBranchMergeBaseChangedFiles'
285
+ )
286
+
287
+ return parseRawLogWithNumstat(
288
+ result.stdout,
289
+ `${latestComparisonBranchCommitRef}`,
290
+ mergeBaseCommit
291
+ )
292
+ }
293
+
294
+ export async function getCommitRangeChangedFiles(
295
+ repository: Repository,
296
+ shas: ReadonlyArray<string>,
297
+ useNullTreeSHA: boolean = false
298
+ ): Promise<IChangesetData> {
299
+ if (shas.length === 0) {
300
+ throw new Error('No commits to diff...')
301
+ }
302
+
303
+ const oldestCommitRef = useNullTreeSHA ? NullTreeSHA : `${shas[0]}^`
304
+ const latestCommitRef = shas.at(-1) ?? '' // can't be undefined since shas.length > 0
305
+ const baseArgs = [
306
+ 'diff',
307
+ oldestCommitRef,
308
+ latestCommitRef,
309
+ '-C',
310
+ '-M',
311
+ '-z',
312
+ '--raw',
313
+ '--numstat',
314
+ '--',
315
+ ]
316
+
317
+ const { stdout, gitError } = await git(
318
+ baseArgs,
319
+ repository.path,
320
+ 'getCommitRangeChangedFiles',
321
+ {
322
+ expectedErrors: new Set([GitError.BadRevision]),
323
+ }
324
+ )
325
+
326
+ // This should only happen if the oldest commit does not have a parent (ex:
327
+ // initial commit of a branch) and therefore `SHA^` is not a valid reference.
328
+ // In which case, we will retry with the null tree sha.
329
+ if (gitError === GitError.BadRevision && useNullTreeSHA === false) {
330
+ const useNullTreeSHA = true
331
+ return getCommitRangeChangedFiles(repository, shas, useNullTreeSHA)
332
+ }
333
+
334
+ return parseRawLogWithNumstat(stdout, latestCommitRef, oldestCommitRef)
335
+ }
336
+
337
+ /**
338
+ * Render the diff for a file within the repository working directory. The file will be
339
+ * compared against HEAD if it's tracked, if not it'll be compared to an empty file meaning
340
+ * that all content in the file will be treated as additions.
341
+ */
342
+ export async function getWorkingDirectoryDiff(
343
+ repository: Repository,
344
+ file: WorkingDirectoryFileChange,
345
+ hideWhitespaceInDiff: boolean = false
346
+ ): Promise<IDiff> {
347
+ // `--no-ext-diff` should be provided wherever we invoke `git diff` so that any
348
+ // diff.external program configured by the user is ignored
349
+ const args = [
350
+ 'diff',
351
+ ...(hideWhitespaceInDiff ? ['-w'] : []),
352
+ '--no-ext-diff',
353
+ '--patch-with-raw',
354
+ '-z',
355
+ '--no-color',
356
+ ]
357
+ const successExitCodes = new Set([0])
358
+ const isSubmodule = file.status.submoduleStatus !== undefined
359
+
360
+ // For added submodules, we'll use the "default" parameters, which are able
361
+ // to output the submodule commit.
362
+ if (
363
+ !isSubmodule &&
364
+ (file.status.kind === AppFileStatusKind.New ||
365
+ file.status.kind === AppFileStatusKind.Untracked)
366
+ ) {
367
+ // `git diff --no-index` seems to emulate the exit codes from `diff` irrespective of
368
+ // whether you set --exit-code
369
+ //
370
+ // this is the behavior:
371
+ // - 0 if no changes found
372
+ // - 1 if changes found
373
+ // - and error otherwise
374
+ //
375
+ // citation in source:
376
+ // https://github.com/git/git/blob/1f66975deb8402131fbf7c14330d0c7cdebaeaa2/diff-no-index.c#L300
377
+ successExitCodes.add(1)
378
+ args.push('--no-index', '--', '/dev/null', file.path)
379
+ } else if (file.status.kind === AppFileStatusKind.Renamed) {
380
+ // NB: Technically this is incorrect, the best kind of incorrect.
381
+ // In order to show exactly what will end up in the commit we should
382
+ // perform a diff between the new file and the old file as it appears
383
+ // in HEAD. By diffing against the index we won't show any changes
384
+ // already staged to the renamed file which differs from our other diffs.
385
+ // The closest I got to that was running hash-object and then using
386
+ // git diff <blob> <blob> but that seems a bit excessive.
387
+ args.push('--', ensureRelativePath(file.path))
388
+ } else {
389
+ args.push('HEAD', '--', ensureRelativePath(file.path))
390
+ }
391
+
392
+ const { stdout, stderr } = await git(
393
+ args,
394
+ repository.path,
395
+ 'getWorkingDirectoryDiff',
396
+ { successExitCodes, encoding: 'buffer' }
397
+ )
398
+ const lineEndingsChange = parseLineEndingsWarning(stderr)
399
+
400
+ return buildDiff(stdout, repository, file, 'HEAD', 'HEAD', lineEndingsChange)
401
+ }
402
+
403
+ /**
404
+ * Render the diff for a list of files within the repository working directory.
405
+ * The files will be compared against HEAD if it's tracked, if not it'll be
406
+ * compared to an empty file meaning that all content in the file will be
407
+ * treated as additions.
408
+ *
409
+ * @param repository The repository to get the diff for
410
+ * @param files The list of files to get the diff for
411
+ * @param commitish The commitish to compare against, if not provided it will
412
+ * default to HEAD. Mainly used to get a diff that includes
413
+ * both staged changes and the changes in a commit. For example,
414
+ * when the user is amending a commit and wants to generate
415
+ * a commit message based on both the new changes and the
416
+ * changes in the commit.
417
+ */
418
+ export async function getFilesDiffText(
419
+ repository: Repository,
420
+ files: ReadonlyArray<WorkingDirectoryFileChange>,
421
+ commitish?: string
422
+ ): Promise<string> {
423
+ // Clear the staging area, our diffs reflect the difference between the
424
+ // working directory and the last commit (if any) so our commits should
425
+ // do the same thing.
426
+ await unstageAll(repository)
427
+
428
+ await stageFiles(repository, files)
429
+
430
+ // `--no-ext-diff` should be provided wherever we invoke `git diff` so that any
431
+ // diff.external program configured by the user is ignored
432
+ const args = [
433
+ 'diff',
434
+ '--no-ext-diff',
435
+ '--patch-with-raw',
436
+ '--no-color',
437
+ '--staged',
438
+ ...(commitish ? [commitish] : []),
439
+ ]
440
+ const successExitCodes = new Set([0])
441
+
442
+ const { stdout } = await git(args, repository.path, 'getFilesDiffText', {
443
+ successExitCodes,
444
+ encoding: 'buffer',
445
+ })
446
+
447
+ await unstageAll(repository)
448
+
449
+ // No more than 10MB
450
+ if (stdout.length > 10 * 1024 * 1024) {
451
+ throw new Error('Diff is too large to render')
452
+ }
453
+
454
+ // `.toString()` in a promise in case its a large buffer
455
+ const outputString = await (async () => stdout.toString('utf8'))()
456
+ return outputString
457
+ }
458
+
459
+ async function getImageDiff(
460
+ repository: Repository,
461
+ file: FileChange,
462
+ newestCommitish: string,
463
+ oldestCommitish: string
464
+ ): Promise<IImageDiff> {
465
+ let current: Image | undefined = undefined
466
+ let previous: Image | undefined = undefined
467
+
468
+ // Are we looking at a file in the working directory or a file in a commit?
469
+ if (file instanceof WorkingDirectoryFileChange) {
470
+ // No idea what to do about this, a conflicted binary (presumably) file.
471
+ // Ideally we'd show all three versions and let the user pick but that's
472
+ // a bit out of scope for now.
473
+ if (file.status.kind === AppFileStatusKind.Conflicted) {
474
+ return { kind: DiffType.Image }
475
+ }
476
+
477
+ // Does it even exist in the working directory?
478
+ if (file.status.kind !== AppFileStatusKind.Deleted) {
479
+ current = await getWorkingDirectoryImage(repository, file)
480
+ }
481
+
482
+ if (
483
+ file.status.kind !== AppFileStatusKind.New &&
484
+ file.status.kind !== AppFileStatusKind.Untracked
485
+ ) {
486
+ // If we have file.oldPath that means it's a rename so we'll
487
+ // look for that file.
488
+ previous = await getBlobImage(
489
+ repository,
490
+ getOldPathOrDefault(file),
491
+ 'HEAD'
492
+ )
493
+ }
494
+ } else {
495
+ // File status can't be conflicted for a file in a commit
496
+ if (file.status.kind !== AppFileStatusKind.Deleted) {
497
+ current = await getBlobImage(repository, file.path, newestCommitish)
498
+ }
499
+
500
+ // File status can't be conflicted for a file in a commit
501
+ if (
502
+ file.status.kind !== AppFileStatusKind.New &&
503
+ file.status.kind !== AppFileStatusKind.Untracked &&
504
+ file.status.kind !== AppFileStatusKind.Deleted
505
+ ) {
506
+ // TODO: commitish^ won't work for the first commit
507
+ //
508
+ // If we have file.oldPath that means it's a rename so we'll
509
+ // look for that file.
510
+ previous = await getBlobImage(
511
+ repository,
512
+ getOldPathOrDefault(file),
513
+ `${oldestCommitish}^`
514
+ )
515
+ }
516
+
517
+ if (
518
+ file instanceof CommittedFileChange &&
519
+ file.status.kind === AppFileStatusKind.Deleted
520
+ ) {
521
+ previous = await getBlobImage(
522
+ repository,
523
+ getOldPathOrDefault(file),
524
+ file.parentCommitish
525
+ )
526
+ }
527
+ }
528
+
529
+ return {
530
+ kind: DiffType.Image,
531
+ previous: previous,
532
+ current: current,
533
+ }
534
+ }
535
+
536
+ export async function convertDiff(
537
+ repository: Repository,
538
+ file: FileChange,
539
+ diff: IRawDiff,
540
+ newestCommitish: string,
541
+ oldestCommitish: string,
542
+ lineEndingsChange?: LineEndingsChange
543
+ ): Promise<IDiff> {
544
+ const extension = Path.extname(file.path).toLowerCase()
545
+
546
+ // SVG files are text-based but can also be rendered as images. Return an
547
+ // image diff that also includes the text diff so the viewer can show both.
548
+ if (extension === '.svg') {
549
+ const imageDiff = await getImageDiff(
550
+ repository,
551
+ file,
552
+ newestCommitish,
553
+ oldestCommitish
554
+ )
555
+ if (!diff.isBinary) {
556
+ return {
557
+ ...imageDiff,
558
+ textDiff: {
559
+ text: diff.contents,
560
+ hunks: diff.hunks,
561
+ lineEndingsChange,
562
+ maxLineNumber: diff.maxLineNumber,
563
+ hasHiddenBidiChars: diff.hasHiddenBidiChars,
564
+ },
565
+ }
566
+ }
567
+ return imageDiff
568
+ }
569
+
570
+ if (diff.isBinary) {
571
+ // some extension we don't know how to parse, never mind
572
+ if (!imageFileExtensions.has(extension)) {
573
+ return {
574
+ kind: DiffType.Binary,
575
+ }
576
+ } else {
577
+ return getImageDiff(repository, file, newestCommitish, oldestCommitish)
578
+ }
579
+ }
580
+
581
+ return {
582
+ kind: DiffType.Text,
583
+ text: diff.contents,
584
+ hunks: diff.hunks,
585
+ lineEndingsChange,
586
+ maxLineNumber: diff.maxLineNumber,
587
+ hasHiddenBidiChars: diff.hasHiddenBidiChars,
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Map a given file extension to the related data URL media type
593
+ */
594
+ function getMediaType(extension: string) {
595
+ if (extension === '.png') {
596
+ return 'image/png'
597
+ }
598
+ if (extension === '.jpg' || extension === '.jpeg') {
599
+ return 'image/jpg'
600
+ }
601
+ if (extension === '.gif') {
602
+ return 'image/gif'
603
+ }
604
+ if (extension === '.ico') {
605
+ return 'image/x-icon'
606
+ }
607
+ if (extension === '.webp') {
608
+ return 'image/webp'
609
+ }
610
+ if (extension === '.bmp') {
611
+ return 'image/bmp'
612
+ }
613
+ if (extension === '.avif') {
614
+ return 'image/avif'
615
+ }
616
+ if (extension === '.svg') {
617
+ return 'image/svg+xml'
618
+ }
619
+ if (extension === '.dds') {
620
+ return 'image/vnd-ms.dds'
621
+ }
622
+
623
+ // fallback value as per the spec
624
+ return 'text/plain'
625
+ }
626
+
627
+ /**
628
+ * `git diff` will write out messages about the line ending changes it knows
629
+ * about to `stderr` - this rule here will catch this and also the to/from
630
+ * changes based on what the user has configured.
631
+ */
632
+ const lineEndingsChangeRegex =
633
+ /', (CRLF|CR|LF) will be replaced by (CRLF|CR|LF) the .*/
634
+
635
+ /**
636
+ * Utility function for inspecting the stderr output for the line endings
637
+ * warning that Git may report.
638
+ *
639
+ * @param error A buffer of binary text from a spawned process
640
+ */
641
+ function parseLineEndingsWarning(error: Buffer): LineEndingsChange | undefined {
642
+ if (error.length === 0) {
643
+ return undefined
644
+ }
645
+
646
+ const errorText = error.toString('utf-8')
647
+ const match = lineEndingsChangeRegex.exec(errorText)
648
+ if (match) {
649
+ const from = parseLineEndingText(match[1])
650
+ const to = parseLineEndingText(match[2])
651
+ if (from && to) {
652
+ return { from, to }
653
+ }
654
+ }
655
+
656
+ return undefined
657
+ }
658
+
659
+ /**
660
+ * Utility function used by get(Commit|WorkingDirectory)Diff.
661
+ *
662
+ * Parses the output from a diff-like command that uses `--path-with-raw`
663
+ */
664
+ function diffFromRawDiffOutput(output: Buffer): IRawDiff {
665
+ // for now we just assume the diff is UTF-8, but given we have the raw buffer
666
+ // we can try and convert this into other encodings in the future
667
+ const result = output.toString('utf-8')
668
+
669
+ const pieces = result.split('\0')
670
+ const parser = new DiffParser()
671
+ return parser.parse(forceUnwrap(`Invalid diff output`, pieces.at(-1)))
672
+ }
673
+
674
+ async function buildSubmoduleDiff(
675
+ buffer: Buffer,
676
+ repository: Repository,
677
+ file: FileChange,
678
+ status: SubmoduleStatus
679
+ ): Promise<IDiff> {
680
+ const path = file.path
681
+ const fullPath = Path.join(repository.path, path)
682
+ const url = await getConfigValue(repository, `submodule.${path}.url`, true)
683
+
684
+ let oldSHA = null
685
+ let newSHA = null
686
+
687
+ if (
688
+ status.commitChanged ||
689
+ file.status.kind === AppFileStatusKind.New ||
690
+ file.status.kind === AppFileStatusKind.Deleted
691
+ ) {
692
+ const diff = buffer.toString('utf-8')
693
+ const lines = diff.split('\n')
694
+ const baseRegex = 'Subproject commit ([^-]+)(-dirty)?$'
695
+ const oldSHARegex = new RegExp('-' + baseRegex)
696
+ const newSHARegex = new RegExp('\\+' + baseRegex)
697
+ const lineMatch = (regex: RegExp) =>
698
+ lines
699
+ .flatMap(line => {
700
+ const match = line.match(regex)
701
+ return match ? match[1] : []
702
+ })
703
+ .at(0) ?? null
704
+
705
+ oldSHA = lineMatch(oldSHARegex)
706
+ newSHA = lineMatch(newSHARegex)
707
+ }
708
+
709
+ return {
710
+ kind: DiffType.Submodule,
711
+ fullPath,
712
+ path,
713
+ url,
714
+ status,
715
+ oldSHA,
716
+ newSHA,
717
+ }
718
+ }
719
+
720
+ async function buildDiff(
721
+ buffer: Buffer,
722
+ repository: Repository,
723
+ file: FileChange,
724
+ newestCommitish: string,
725
+ oldestCommitish: string,
726
+ lineEndingsChange?: LineEndingsChange
727
+ ): Promise<IDiff> {
728
+ if (file.status.submoduleStatus !== undefined) {
729
+ return buildSubmoduleDiff(
730
+ buffer,
731
+ repository,
732
+ file,
733
+ file.status.submoduleStatus
734
+ )
735
+ }
736
+
737
+ if (!isValidBuffer(buffer)) {
738
+ // the buffer's diff is too large to be renderable in the UI
739
+ return { kind: DiffType.Unrenderable }
740
+ }
741
+
742
+ const diff = diffFromRawDiffOutput(buffer)
743
+
744
+ if (isBufferTooLarge(buffer) || isDiffTooLarge(diff)) {
745
+ // we don't want to render by default
746
+ // but we keep it as an option by
747
+ // passing in text and hunks
748
+ const largeTextDiff: ILargeTextDiff = {
749
+ kind: DiffType.LargeText,
750
+ text: diff.contents,
751
+ hunks: diff.hunks,
752
+ lineEndingsChange,
753
+ maxLineNumber: diff.maxLineNumber,
754
+ hasHiddenBidiChars: diff.hasHiddenBidiChars,
755
+ }
756
+
757
+ return largeTextDiff
758
+ }
759
+
760
+ return convertDiff(
761
+ repository,
762
+ file,
763
+ diff,
764
+ newestCommitish,
765
+ oldestCommitish,
766
+ lineEndingsChange
767
+ )
768
+ }
769
+
770
+ /**
771
+ * Retrieve the binary contents of a blob from the object database
772
+ *
773
+ * Returns an image object containing the base64 encoded string,
774
+ * as <img> tags support the data URI scheme instead of
775
+ * needing to reference a file:// URI
776
+ *
777
+ * https://en.wikipedia.org/wiki/Data_URI_scheme
778
+ */
779
+ export async function getBlobImage(
780
+ repository: Repository,
781
+ path: string,
782
+ commitish: string
783
+ ): Promise<Image> {
784
+ const extension = Path.extname(path)
785
+ const contents = await getBlobContents(repository, commitish, path)
786
+ return new Image(
787
+ contents.buffer,
788
+ contents.toString('base64'),
789
+ getMediaType(extension),
790
+ contents.length
791
+ )
792
+ }
793
+ /**
794
+ * Retrieve the binary contents of a blob from the working directory
795
+ *
796
+ * Returns an image object containing the base64 encoded string,
797
+ * as <img> tags support the data URI scheme instead of
798
+ * needing to reference a file:// URI
799
+ *
800
+ * https://en.wikipedia.org/wiki/Data_URI_scheme
801
+ */
802
+ export async function getWorkingDirectoryImage(
803
+ repository: Repository,
804
+ file: FileChange
805
+ ): Promise<Image> {
806
+ const contents = await readFile(Path.join(repository.path, file.path))
807
+ return new Image(
808
+ contents.buffer,
809
+ contents.toString('base64'),
810
+ getMediaType(Path.extname(file.path)),
811
+ contents.length
812
+ )
813
+ }
814
+
815
+ /**
816
+ * List the modified binary files' paths in the given repository
817
+ *
818
+ * @param repository to run git operation in
819
+ * @param ref ref (sha, branch, etc) to compare the working index against
820
+ *
821
+ * if you're mid-merge pass `'MERGE_HEAD'` to ref to get a diff of `HEAD` vs `MERGE_HEAD`,
822
+ * otherwise you should probably pass `'HEAD'` to get a diff of the working tree vs `HEAD`
823
+ */
824
+ export async function getBinaryPaths(
825
+ repository: Repository,
826
+ ref: string,
827
+ conflictedFilesInIndex: ReadonlyArray<IStatusEntry>
828
+ ): Promise<ReadonlyArray<string>> {
829
+ const [detectedBinaryFiles, conflictedFilesUsingBinaryMergeDriver] =
830
+ await Promise.all([
831
+ getDetectedBinaryFiles(repository, ref),
832
+ getFilesUsingBinaryMergeDriver(repository, conflictedFilesInIndex),
833
+ ])
834
+
835
+ return Array.from(
836
+ new Set([...detectedBinaryFiles, ...conflictedFilesUsingBinaryMergeDriver])
837
+ )
838
+ }
839
+
840
+ /**
841
+ * Runs diff --numstat to get the list of files that have changed and which
842
+ * Git have detected as binary files
843
+ */
844
+ async function getDetectedBinaryFiles(repository: Repository, ref: string) {
845
+ const { stdout } = await git(
846
+ ['diff', '--numstat', '-z', ref],
847
+ repository.path,
848
+ 'getBinaryPaths'
849
+ )
850
+
851
+ return Array.from(stdout.matchAll(binaryListRegex), m => m[1])
852
+ }
853
+
854
+ const binaryListRegex = /-\t-\t(?:\0.+\0)?([^\0]*)/gi
855
+
856
+ async function getFilesUsingBinaryMergeDriver(
857
+ repository: Repository,
858
+ files: ReadonlyArray<IStatusEntry>
859
+ ) {
860
+ const { stdout } = await git(
861
+ ['check-attr', '--stdin', '-z', 'merge'],
862
+ repository.path,
863
+ 'getConflictedFilesUsingBinaryMergeDriver',
864
+ {
865
+ stdin: files.map(f => f.path).join('\0'),
866
+ }
867
+ )
868
+
869
+ return createLogParser({ path: '', attr: '', value: '' })
870
+ .parse(stdout)
871
+ .filter(x => x.attr === 'merge' && x.value === 'binary')
872
+ .map(x => x.path)
873
+ }
874
+
875
+ // Prefix absolute path with `:(top,literal)` to ensure that git treats it as a
876
+ // literal path. This is important for paths that appear to be absolute paths on
877
+ // some platforms and not others. See
878
+ // https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-top
879
+ const ensureRelativePath = (path: string) =>
880
+ isAbsolute(path) ? `:(top,literal)${path}` : path