decap-cms-backend-github 3.5.0 → 3.7.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/dist/esm/API.js CHANGED
@@ -7,8 +7,8 @@ import result from 'lodash/result';
7
7
  import trimStart from 'lodash/trimStart';
8
8
  import trim from 'lodash/trim';
9
9
  import { oneLine } from 'common-tags';
10
- import { getAllResponses, APIError, EditorialWorkflowError, localForage, basename, readFileMetadata, CMS_BRANCH_PREFIX, generateContentKey, DEFAULT_PR_BODY, MERGE_COMMIT_MESSAGE, PreviewState, parseContentKey, branchFromContentKey, isCMSLabel, labelToStatus, statusToLabel, contentKeyFromBranch, requestWithBackoff, unsentRequest, throwOnConflictingBranches } from 'decap-cms-lib-util';
11
10
  import { dirname } from 'path';
11
+ import { getAllResponses, APIError, EditorialWorkflowError, localForage, basename, readFileMetadata, CMS_BRANCH_PREFIX, generateContentKey, DEFAULT_PR_BODY, MERGE_COMMIT_MESSAGE, PreviewState, parseContentKey, branchFromContentKey, isCMSLabel, labelToStatus, statusToLabel, contentKeyFromBranch, requestWithBackoff, unsentRequest, throwOnConflictingBranches } from 'decap-cms-lib-util';
12
12
  export const API_NAME = 'GitHub';
13
13
  export const MOCK_PULL_REQUEST = -1;
14
14
  var GitHubCommitStatusState = /*#__PURE__*/function (GitHubCommitStatusState) {
@@ -93,7 +93,8 @@ export default class API {
93
93
  }
94
94
  return this._userPromise.then(user => ({
95
95
  name: user.name || 'Unknown',
96
- login: user.login
96
+ login: user.login,
97
+ email: user.email ?? undefined
97
98
  }));
98
99
  }
99
100
  async hasWriteAccess() {
@@ -757,9 +758,10 @@ export default class API {
757
758
  const contentKey = this.generateContentKey(options.collectionName, slug);
758
759
  const branch = branchFromContentKey(contentKey);
759
760
  const unpublished = options.unpublished || false;
761
+ const hasSubfolders = options.hasSubfolders !== false; // default to true
760
762
  if (!unpublished) {
761
763
  const branchData = await this.getDefaultBranch();
762
- const changeTree = await this.updateTree(branchData.commit.sha, files);
764
+ const changeTree = await this.updateTree(branchData.commit.sha, files, this.branch, hasSubfolders);
763
765
  const commitResponse = await this.commit(options.commitMessage, changeTree);
764
766
  if (this.useOpenAuthoring) {
765
767
  await this.createBranch(branch, commitResponse.sha);
@@ -787,7 +789,7 @@ export default class API {
787
789
  // rebase the branch before applying new changes
788
790
  const rebasedHead = await this.rebaseBranch(branch);
789
791
  const treeFiles = mediaFilesToRemove.concat(files);
790
- const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
792
+ const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch, hasSubfolders);
791
793
  const commit = await this.commit(options.commitMessage, changeTree);
792
794
  return this.patchBranch(branch, commit.sha, {
793
795
  force: true
@@ -1103,7 +1105,7 @@ export default class API {
1103
1105
  item.sha = response.sha;
1104
1106
  return item;
1105
1107
  }
1106
- async updateTree(baseSha, files, branch = this.branch) {
1108
+ async updateTree(baseSha, files, branch = this.branch, hasSubfolders = true) {
1107
1109
  const toMove = [];
1108
1110
  const tree = files.reduce((acc, file) => {
1109
1111
  const entry = {
@@ -1128,27 +1130,47 @@ export default class API {
1128
1130
  to,
1129
1131
  sha
1130
1132
  } of toMove) {
1131
- const sourceDir = dirname(from);
1132
- const destDir = dirname(to);
1133
- const files = await this.listFiles(sourceDir, {
1134
- branch,
1135
- depth: 100
1136
- });
1137
- for (const file of files) {
1138
- // delete current path
1133
+ if (!hasSubfolders) {
1134
+ // New behavior (subfolders: false): Only move the specific file
1135
+ // Delete the file at the old path
1139
1136
  tree.push({
1140
- path: file.path,
1137
+ path: trimStart(from, '/'),
1141
1138
  mode: '100644',
1142
1139
  type: 'blob',
1143
1140
  sha: null
1144
1141
  });
1145
- // create in new path
1142
+ // Create the file at the new path
1146
1143
  tree.push({
1147
- path: file.path.replace(sourceDir, destDir),
1144
+ path: trimStart(to, '/'),
1148
1145
  mode: '100644',
1149
1146
  type: 'blob',
1150
- sha: file.path === from ? sha : file.id
1147
+ sha
1148
+ });
1149
+ } else {
1150
+ // Legacy behavior (subfolders: true, default): Move all files in the directory
1151
+ // This is for collections where all files in a folder represent a single entry
1152
+ const sourceDir = dirname(from);
1153
+ const destDir = dirname(to);
1154
+ const files = await this.listFiles(sourceDir, {
1155
+ branch,
1156
+ depth: 100
1151
1157
  });
1158
+ for (const file of files) {
1159
+ // delete current path
1160
+ tree.push({
1161
+ path: file.path,
1162
+ mode: '100644',
1163
+ type: 'blob',
1164
+ sha: null
1165
+ });
1166
+ // create in new path
1167
+ tree.push({
1168
+ path: file.path.replace(sourceDir, destDir),
1169
+ mode: '100644',
1170
+ type: 'blob',
1171
+ sha: file.path === from ? sha : file.id
1172
+ });
1173
+ }
1152
1174
  }
1153
1175
  }
1154
1176
  const newTree = await this.createTree(baseSha, tree);
@@ -0,0 +1,391 @@
1
+ /**
2
+ * GitHub Notes Polling System
3
+ *
4
+ * ETag-based polling manager that efficiently checks for changes in GitHub Issues
5
+ * Used for a real-time feel of notes updates without excessive API calls leveraging conditional requests (304) that don't count on Github rate limits.
6
+ *
7
+ * @module polling
8
+ */
9
+
10
+ // Redux action types
11
+ export const NOTES_POLLING_START = 'NOTES_POLLING_START';
12
+ export const NOTES_POLLING_STOP = 'NOTES_POLLING_STOP';
13
+ export const NOTES_POLLING_UPDATE = 'NOTES_POLLING_UPDATE';
14
+ export const NOTES_CHANGE_DETECTED = 'NOTES_CHANGE_DETECTED';
15
+ export class ETagPollingManager {
16
+ currentWatch = null;
17
+ currentIssueKey = null;
18
+ pollingInterval = 15000;
19
+ intervalId = null;
20
+ isDocumentVisible = true;
21
+ isPolling = false;
22
+ pendingRetryTimeout = null;
23
+ constructor(api, pollingInterval = 15000) {
24
+ this.api = api;
25
+ this.pollingInterval = pollingInterval;
26
+ this.setupVisibilityListener();
27
+ }
28
+
29
+ /**
30
+ * Setup Page Visibility API listener
31
+ * Pauses polling when tab is hidden
32
+ */
33
+ setupVisibilityListener() {
34
+ if (typeof document !== 'undefined') {
35
+ document.addEventListener('visibilitychange', () => {
36
+ this.isDocumentVisible = !document.hidden;
37
+ if (this.isDocumentVisible) {
38
+ this.startPolling();
39
+ this.checkAllIssuesNow();
40
+ } else {
41
+ this.stopPolling();
42
+ }
43
+ });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Start watching an issue for changes
49
+ * This will automatically stop watching any previously watched issue
50
+ *
51
+ * @param issueNumber - GitHub issue number
52
+ * @param collection - Collection name
53
+ * @param slug - Entry slug
54
+ * @returns Function to stop watching
55
+ */
56
+ async watchIssue(issueNumber, collection, slug, callbacks, initialState = null) {
57
+ const issueKey = this.getIssueKey(collection, slug);
58
+
59
+ // STOP ANY EXISTING WATCH FIRST
60
+ if (this.currentWatch) {
61
+ this.stopCurrentWatch();
62
+ }
63
+
64
+ // Get initial state if not provided
65
+ if (!initialState) {
66
+ try {
67
+ initialState = await this.api.getIssueState(issueNumber);
68
+ } catch (error) {
69
+ console.error('[DecapNotes Polling] Failed to get initial state:', error);
70
+ }
71
+ }
72
+ this.currentWatch = {
73
+ issueNumber,
74
+ collection,
75
+ slug,
76
+ etag: null,
77
+ lastState: initialState,
78
+ onUpdate: callbacks.onUpdate,
79
+ onChange: callbacks.onChange,
80
+ retryCount: 0,
81
+ maxRetries: 5
82
+ };
83
+ this.currentIssueKey = issueKey;
84
+
85
+ // Start polling if not already running
86
+ if (!this.intervalId && this.isDocumentVisible) {
87
+ this.startPolling();
88
+ }
89
+
90
+ // Do an immediate check
91
+ this.checkCurrentIssue();
92
+
93
+ // Return unwatch function
94
+ return () => this.stopCurrentWatch();
95
+ }
96
+
97
+ /**
98
+ * Watch issue with retry logic for newly created issues
99
+ */
100
+ async watchIssueWithRetry(collection, slug, callbacks, maxRetries = 5, retryDelay = 2000) {
101
+ const issueKey = this.getIssueKey(collection, slug);
102
+
103
+ // STOP ANY EXISTING WATCH FIRST
104
+ if (this.currentWatch) {
105
+ this.stopCurrentWatch();
106
+ }
107
+ const attemptWatch = async attempt => {
108
+ try {
109
+ const issue = await this.api.findEntryIssue(collection, slug);
110
+ if (issue) {
111
+ return await this.watchIssue(issue.number, collection, slug, callbacks);
112
+ }
113
+ if (attempt < maxRetries) {
114
+ return new Promise((resolve, reject) => {
115
+ this.pendingRetryTimeout = setTimeout(async () => {
116
+ this.pendingRetryTimeout = null;
117
+ try {
118
+ const unwatchFn = await attemptWatch(attempt + 1);
119
+ resolve(unwatchFn);
120
+ } catch (error) {
121
+ reject(error);
122
+ }
123
+ }, retryDelay);
124
+ });
125
+ }
126
+ console.log(`[DecapNotes Polling] No issue found for ${issueKey} after ${maxRetries} attempts. This is expected if there are no notes for this entry yet.`);
127
+ // Return a no-op unwatch function
128
+ return () => {
129
+ /* no-op */
130
+ };
131
+ } catch (error) {
132
+ console.error(`[DecapNotes Polling] Error finding issue for ${issueKey}:`, error);
133
+ if (attempt < maxRetries) {
134
+ return new Promise((resolve, reject) => {
135
+ this.pendingRetryTimeout = setTimeout(async () => {
136
+ this.pendingRetryTimeout = null;
137
+ try {
138
+ const unwatchFn = await attemptWatch(attempt + 1);
139
+ resolve(unwatchFn);
140
+ } catch (err) {
141
+ reject(err);
142
+ }
143
+ }, retryDelay);
144
+ });
145
+ }
146
+ throw error;
147
+ }
148
+ };
149
+ return attemptWatch(1);
150
+ }
151
+
152
+ /**
153
+ * Stop watching the current issue - complete cleanup
154
+ */
155
+ stopCurrentWatch() {
156
+ if (!this.currentWatch) {
157
+ return;
158
+ }
159
+
160
+ // Clear any pending retry timeout
161
+ if (this.pendingRetryTimeout) {
162
+ clearTimeout(this.pendingRetryTimeout);
163
+ this.pendingRetryTimeout = null;
164
+ }
165
+
166
+ // Clear current watch
167
+ this.currentWatch = null;
168
+ this.currentIssueKey = null;
169
+
170
+ // Stop polling since there's nothing to watch
171
+ this.stopPolling();
172
+ }
173
+
174
+ /**
175
+ * Start the polling loop
176
+ */
177
+ startPolling() {
178
+ if (this.intervalId || !this.isDocumentVisible || !this.currentWatch) return;
179
+ console.log(`[DecapNotes Polling] Starting polling loop (${this.pollingInterval}ms interval) for ${this.currentIssueKey}`);
180
+ this.intervalId = setInterval(() => {
181
+ this.pollAllIssues();
182
+ }, this.pollingInterval);
183
+ }
184
+
185
+ /**
186
+ * Stop the polling loop
187
+ */
188
+ stopPolling() {
189
+ if (this.intervalId) {
190
+ console.log('[DecapNotes Polling] Stopping polling loop');
191
+ clearInterval(this.intervalId);
192
+ this.intervalId = null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Poll current watched issue
198
+ */
199
+ async pollAllIssues() {
200
+ await this.checkCurrentIssue();
201
+ }
202
+
203
+ /**
204
+ * Check current issue for changes using ETag
205
+ */
206
+ async checkCurrentIssue() {
207
+ if (!this.currentWatch) {
208
+ return;
209
+ }
210
+ if (this.isPolling) {
211
+ return;
212
+ }
213
+ this.isPolling = true;
214
+ try {
215
+ const watch = this.currentWatch;
216
+ const response = await this.api.getIssueWithETag(watch.issueNumber, watch.etag);
217
+ if (response.status === 304) {
218
+ return;
219
+ }
220
+ if (response.status === 200) {
221
+ const newState = response.data;
222
+ const newETag = response.etag;
223
+
224
+ // Update ETag
225
+ watch.etag = newETag || null;
226
+
227
+ // Detect specific changes
228
+ const changes = this.detectChanges(watch.lastState, newState);
229
+ if (changes.length > 0) {
230
+ // Convert comments to notes
231
+ const newNotes = newState.comments.map(comment => ({
232
+ ...this.api.parseCommentToNote(comment),
233
+ issueUrl: newState.html_url
234
+ }));
235
+ if (watch.onUpdate) {
236
+ watch.onUpdate(newNotes, changes);
237
+ }
238
+ if (watch.onChange) {
239
+ changes.forEach(change => {
240
+ watch.onChange(change);
241
+ });
242
+ }
243
+ }
244
+
245
+ // Update stored state
246
+ watch.lastState = newState;
247
+ }
248
+ } catch (error) {
249
+ if (error && typeof error === 'object' && 'status' in error && error.status !== 304) {
250
+ console.error(`[DecapNotes Polling] Error checking ${this.currentIssueKey}:`, error);
251
+ }
252
+ } finally {
253
+ this.isPolling = false;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Immediately check current issue
259
+ */
260
+ async checkAllIssuesNow() {
261
+ await this.checkCurrentIssue();
262
+ }
263
+
264
+ /**
265
+ * Manually trigger a check - only works if this is the current entry
266
+ */
267
+ async checkIssueNow(collection, slug) {
268
+ const issueKey = this.getIssueKey(collection, slug);
269
+ if (this.currentIssueKey !== issueKey) {
270
+ console.warn(`[DecapNotes Polling] Cannot check ${issueKey} - currently watching ${this.currentIssueKey}`);
271
+ return;
272
+ }
273
+ await this.checkCurrentIssue();
274
+ }
275
+
276
+ /**
277
+ * Detect what changed between two states
278
+ */
279
+ detectChanges(previous, current) {
280
+ if (!previous) {
281
+ return [];
282
+ }
283
+ const changes = [];
284
+
285
+ // New comments
286
+ const newComments = current.comments.filter(comment => !previous.comments.some(prev => prev.id === comment.id));
287
+ newComments.forEach(comment => {
288
+ changes.push({
289
+ type: 'comment_added',
290
+ data: comment,
291
+ timestamp: comment.created_at
292
+ });
293
+ });
294
+
295
+ // Updated comments
296
+ current.comments.forEach(comment => {
297
+ const prevComment = previous.comments.find(prev => prev.id === comment.id);
298
+ if (prevComment && prevComment.updated_at !== comment.updated_at) {
299
+ changes.push({
300
+ type: 'comment_updated',
301
+ data: comment,
302
+ previousData: prevComment,
303
+ timestamp: comment.updated_at
304
+ });
305
+ }
306
+ });
307
+
308
+ // Deleted comments
309
+ const deletedComments = previous.comments.filter(prevComment => !current.comments.some(comment => comment.id === prevComment.id));
310
+ deletedComments.forEach(comment => {
311
+ changes.push({
312
+ type: 'comment_deleted',
313
+ data: comment,
314
+ timestamp: new Date().toISOString()
315
+ });
316
+ });
317
+
318
+ // Issue state changed
319
+ if (previous.state !== current.state) {
320
+ changes.push({
321
+ type: 'issue_state_changed',
322
+ data: {
323
+ from: previous.state,
324
+ to: current.state
325
+ },
326
+ timestamp: current.updated_at
327
+ });
328
+ }
329
+
330
+ // Labels changed
331
+ if (this.hasLabelsChanged(previous.labels, current.labels)) {
332
+ changes.push({
333
+ type: 'issue_labels_changed',
334
+ data: {
335
+ from: previous.labels,
336
+ to: current.labels
337
+ },
338
+ timestamp: current.updated_at
339
+ });
340
+ }
341
+ return changes;
342
+ }
343
+
344
+ /**
345
+ * Check if labels changed
346
+ */
347
+ hasLabelsChanged(previous, current) {
348
+ if (previous.length !== current.length) return true;
349
+ const prevNames = previous.map(l => l.name).sort();
350
+ const currNames = current.map(l => l.name).sort();
351
+ return prevNames.join(',') !== currNames.join(',');
352
+ }
353
+
354
+ /**
355
+ * Get issue key for storage
356
+ */
357
+ getIssueKey(collection, slug) {
358
+ return `${collection}/${slug}`;
359
+ }
360
+
361
+ /**
362
+ * Get polling status
363
+ */
364
+ getStatus() {
365
+ return {
366
+ isPolling: this.intervalId !== null,
367
+ currentWatch: this.currentIssueKey,
368
+ watchedCount: this.currentWatch ? 1 : 0,
369
+ pollingInterval: this.pollingInterval,
370
+ isDocumentVisible: this.isDocumentVisible,
371
+ hasPendingRetry: this.pendingRetryTimeout !== null
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Clean up - stop all polling
377
+ */
378
+ destroy() {
379
+ console.log('[DecapNotes Polling] Destroying polling manager');
380
+
381
+ // Clear pending retry
382
+ if (this.pendingRetryTimeout) {
383
+ clearTimeout(this.pendingRetryTimeout);
384
+ this.pendingRetryTimeout = null;
385
+ }
386
+
387
+ // Stop current watch
388
+ this.stopCurrentWatch();
389
+ }
390
+ }
391
+ export default ETagPollingManager;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "decap-cms-backend-github",
3
3
  "description": "GitHub backend for Decap CMS",
4
- "version": "3.5.0",
4
+ "version": "3.7.0",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-backend-github",
7
7
  "bugs": "https://github.com/decaporg/decap-cms/issues",
@@ -44,5 +44,5 @@
44
44
  "browser": {
45
45
  "path": "path-browserify"
46
46
  },
47
- "gitHead": "0c1b7c7d63ba6ddb3de3692fbb6b16ac6ebb44d5"
47
+ "gitHead": "ae01ad6f2b139f93472c8f0d049d35277d8583ca"
48
48
  }
package/src/API.ts CHANGED
@@ -7,6 +7,7 @@ import result from 'lodash/result';
7
7
  import trimStart from 'lodash/trimStart';
8
8
  import trim from 'lodash/trim';
9
9
  import { oneLine } from 'common-tags';
10
+ import { dirname } from 'path';
10
11
  import {
11
12
  getAllResponses,
12
13
  APIError,
@@ -29,7 +30,6 @@ import {
29
30
  unsentRequest,
30
31
  throwOnConflictingBranches,
31
32
  } from 'decap-cms-lib-util';
32
- import { dirname } from 'path';
33
33
 
34
34
  import type {
35
35
  AssetProxy,
@@ -253,13 +253,14 @@ export default class API {
253
253
 
254
254
  static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Decap CMS';
255
255
 
256
- user(): Promise<{ name: string; login: string }> {
256
+ user(): Promise<{ name: string; login: string; email?: string }> {
257
257
  if (!this._userPromise) {
258
258
  this._userPromise = this.getUser({ token: this.token });
259
259
  }
260
260
  return this._userPromise.then(user => ({
261
261
  name: user.name || 'Unknown',
262
262
  login: user.login,
263
+ email: user.email ?? undefined,
263
264
  }));
264
265
  }
265
266
 
@@ -1010,9 +1011,15 @@ export default class API {
1010
1011
  const contentKey = this.generateContentKey(options.collectionName as string, slug);
1011
1012
  const branch = branchFromContentKey(contentKey);
1012
1013
  const unpublished = options.unpublished || false;
1014
+ const hasSubfolders = options.hasSubfolders !== false; // default to true
1013
1015
  if (!unpublished) {
1014
1016
  const branchData = await this.getDefaultBranch();
1015
- const changeTree = await this.updateTree(branchData.commit.sha, files);
1017
+ const changeTree = await this.updateTree(
1018
+ branchData.commit.sha,
1019
+ files,
1020
+ this.branch,
1021
+ hasSubfolders,
1022
+ );
1016
1023
  const commitResponse = await this.commit(options.commitMessage, changeTree);
1017
1024
 
1018
1025
  if (this.useOpenAuthoring) {
@@ -1047,7 +1054,7 @@ export default class API {
1047
1054
  // rebase the branch before applying new changes
1048
1055
  const rebasedHead = await this.rebaseBranch(branch);
1049
1056
  const treeFiles = mediaFilesToRemove.concat(files);
1050
- const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
1057
+ const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch, hasSubfolders);
1051
1058
  const commit = await this.commit(options.commitMessage, changeTree);
1052
1059
 
1053
1060
  return this.patchBranch(branch, commit.sha, { force: true });
@@ -1417,6 +1424,7 @@ export default class API {
1417
1424
  baseSha: string,
1418
1425
  files: { path: string; sha: string | null; newPath?: string }[],
1419
1426
  branch = this.branch,
1427
+ hasSubfolders = true,
1420
1428
  ) {
1421
1429
  const toMove: { from: string; to: string; sha: string }[] = [];
1422
1430
  const tree = files.reduce((acc, file) => {
@@ -1437,24 +1445,44 @@ export default class API {
1437
1445
  }, [] as TreeEntry[]);
1438
1446
 
1439
1447
  for (const { from, to, sha } of toMove) {
1440
- const sourceDir = dirname(from);
1441
- const destDir = dirname(to);
1442
- const files = await this.listFiles(sourceDir, { branch, depth: 100 });
1443
- for (const file of files) {
1444
- // delete current path
1448
+ if (!hasSubfolders) {
1449
+ // New behavior (subfolders: false): Only move the specific file
1450
+ // Delete the file at the old path
1445
1451
  tree.push({
1446
- path: file.path,
1452
+ path: trimStart(from, '/'),
1447
1453
  mode: '100644',
1448
1454
  type: 'blob',
1449
1455
  sha: null,
1450
1456
  });
1451
- // create in new path
1457
+ // Create the file at the new path
1452
1458
  tree.push({
1453
- path: file.path.replace(sourceDir, destDir),
1459
+ path: trimStart(to, '/'),
1454
1460
  mode: '100644',
1455
1461
  type: 'blob',
1456
- sha: file.path === from ? sha : file.id,
1462
+ sha,
1457
1463
  });
1464
+ } else {
1465
+ // Legacy behavior (subfolders: true, default): Move all files in the directory
1466
+ // This is for collections where all files in a folder represent a single entry
1467
+ const sourceDir = dirname(from);
1468
+ const destDir = dirname(to);
1469
+ const files = await this.listFiles(sourceDir, { branch, depth: 100 });
1470
+ for (const file of files) {
1471
+ // delete current path
1472
+ tree.push({
1473
+ path: file.path,
1474
+ mode: '100644',
1475
+ type: 'blob',
1476
+ sha: null,
1477
+ });
1478
+ // create in new path
1479
+ tree.push({
1480
+ path: file.path.replace(sourceDir, destDir),
1481
+ mode: '100644',
1482
+ type: 'blob',
1483
+ sha: file.path === from ? sha : file.id,
1484
+ });
1485
+ }
1458
1486
  }
1459
1487
  }
1460
1488
 
@@ -1 +0,0 @@
1
- export {};