gip-remote 1.2.7 → 1.2.8

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 (2) hide show
  1. package/index.js +145 -15
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -210,35 +210,76 @@ class Remote extends ReadyResource {
210
210
  }
211
211
  }
212
212
 
213
- // 4b. Insert/update file records but ONLY for paths whose blob or mode
214
- // actually changed. Earlier versions blindly upserted every file in
215
- // the new tree on every push, which overwrote the commit metadata of
216
- // untouched files with HEAD's author/message/timestamp. The visible
217
- // symptom: a tree view shows every file as "last modified by the
218
- // latest commit", so per-file timestamps become useless.
213
+ // 4b. Compute, for each file in HEAD's tree, the most recent commit IN
214
+ // THIS PUSH that actually changed its blob that's the commit we
215
+ // want to credit on the @gip/files row. If we just stamped every
216
+ // file with HEAD's metadata (the previous behaviour), a fresh push
217
+ // of a multi-commit history would make every file look like it was
218
+ // last edited by HEAD, which is useless for a tree view.
219
219
  //
220
- // Skipping unchanged rows preserves the metadata of the commit that
221
- // last *actually* modified the file. New paths and modified paths
222
- // still take the current commit's metadata as before.
220
+ // Algorithm: walk the commit chain HEAD first-parent through the
221
+ // commits we have in the pack, flatten each commit's tree into a
222
+ // path oid map, and for each path record the most recent commit
223
+ // whose tree differs at that path from its first-parent's tree.
224
+ // We don't follow merge parents — same heuristic GitHub's "blame"
225
+ // uses; it keeps cost predictable and matches user expectation.
226
+ //
227
+ // Cases the fallback below handles:
228
+ // - Pack is shallow and the path was already at HEAD's blob
229
+ // before our oldest commit — keep the existing row as-is.
230
+ // - First push containing a root commit — files appearing in the
231
+ // root take the root commit's metadata.
232
+ const fileLastTouch = computeFileLastTouch(objects, resolvedOid, commit)
233
+ const oldestInPack = fileLastTouch.oldest
234
+
223
235
  for (const file of files) {
224
236
  const existing = await this._db.get('@gip/files', {
225
237
  branch: refName,
226
238
  path: file.path
227
239
  })
228
- if (existing && existing.oid === file.oid && existing.mode === file.mode) {
229
- // Same blob, same mode → file is unchanged in this commit. Leave
230
- // the existing row untouched so its commit metadata stays accurate.
240
+
241
+ let meta = fileLastTouch.byPath.get(file.path)
242
+
243
+ if (!meta) {
244
+ // No commit in our pack changed this file. Either it's been at this
245
+ // blob since before our window, or the pack is shallow.
246
+ if (existing && existing.oid === file.oid && existing.mode === file.mode) {
247
+ // Genuinely unchanged from prior push — leave row alone.
248
+ continue
249
+ }
250
+ // Fall back to the oldest commit we have. Best approximation when a
251
+ // shallow clone is being seeded (we don't have the real introducing
252
+ // commit, but the oldest commit in the pack is a strict upper bound
253
+ // on "when it could have last changed" given what we know).
254
+ meta = {
255
+ author: oldestInPack.author,
256
+ message: oldestInPack.message,
257
+ timestamp: oldestInPack.timestamp
258
+ }
259
+ }
260
+
261
+ // Idempotency: if the existing row already reflects the same blob,
262
+ // mode, and metadata we'd write, skip the insert. Saves a write
263
+ // round-trip on no-op pushes (e.g. retries).
264
+ if (
265
+ existing &&
266
+ existing.oid === file.oid &&
267
+ existing.mode === file.mode &&
268
+ existing.message === meta.message &&
269
+ existing.timestamp === meta.timestamp
270
+ ) {
231
271
  continue
232
272
  }
273
+
233
274
  await this._db.insert('@gip/files', {
234
275
  branch: refName,
235
276
  path: file.path,
236
277
  oid: file.oid,
237
278
  mode: file.mode,
238
279
  size: file.size,
239
- author: commit.author,
240
- message: commit.message,
241
- timestamp: commit.timestamp
280
+ author: meta.author,
281
+ message: meta.message,
282
+ timestamp: meta.timestamp
242
283
  })
243
284
  }
244
285
 
@@ -382,6 +423,95 @@ class Remote extends ReadyResource {
382
423
  }
383
424
  }
384
425
 
426
+ /**
427
+ * Walk the first-parent commit chain (within `objects`) starting at HEAD,
428
+ * and for each path in HEAD's tree return the metadata of the most recent
429
+ * commit whose tree differs at that path from its parent's tree.
430
+ *
431
+ * Returns:
432
+ * - byPath: Map<path, { author, message, timestamp }> for paths we
433
+ * could attribute within the pack.
434
+ * - oldest: the oldest commit reached (used as a fallback by callers
435
+ * when the pack is shallow and a path was already at its
436
+ * current blob before the pack window).
437
+ *
438
+ * Pure function — does no IO, just inspects the in-memory `objects` map.
439
+ */
440
+ function computeFileLastTouch(objects, headOid, headCommit) {
441
+ // 1. Walk the chain through first-parent edges, stopping at the first
442
+ // parent we don't have. Stash both the parsed commit and the OID.
443
+ const chain = []
444
+ let curOid = headOid
445
+ let cur = headCommit
446
+ for (;;) {
447
+ chain.push({ oid: curOid, commit: cur })
448
+ if (!cur.parents || cur.parents.length === 0) break
449
+ const parentOid = cur.parents[0]
450
+ const parentObj = objects.get(parentOid)
451
+ if (!parentObj || parentObj.type !== 'commit') break
452
+ cur = parseCommit(parentObj.data)
453
+ curOid = parentOid
454
+ }
455
+
456
+ // 2. Flatten each commit's tree into a path → oid map. walkTree handles
457
+ // nested trees and skips missing sub-trees gracefully (returns []),
458
+ // which is what we want for incremental packs that don't re-send
459
+ // unchanged sub-trees.
460
+ const treeMaps = chain.map(({ commit }) => {
461
+ const map = new Map()
462
+ for (const f of walkTree(objects, commit.tree, '')) {
463
+ map.set(f.path, f.oid)
464
+ }
465
+ return map
466
+ })
467
+
468
+ // 3. Walk newest-first; record the change point for each HEAD path.
469
+ const byPath = new Map()
470
+ const remaining = new Set(treeMaps[0].keys())
471
+
472
+ for (let i = 0; i < chain.length && remaining.size > 0; i++) {
473
+ const cur = treeMaps[i]
474
+ const isLast = i === chain.length - 1
475
+ const par = isLast ? null : treeMaps[i + 1]
476
+ // True root commit (no parents at all) — files appearing here are new,
477
+ // so they're attributed to this commit. We must NOT do the same when
478
+ // we ran out of pack (parents exist, just not in pack), or we'd make
479
+ // up an attribution that isn't real.
480
+ const isRoot = isLast && (!chain[i].commit.parents || chain[i].commit.parents.length === 0)
481
+
482
+ for (const path of remaining) {
483
+ const curOidAtPath = cur.get(path)
484
+ if (curOidAtPath === undefined) continue
485
+
486
+ if (par) {
487
+ const parOidAtPath = par.get(path)
488
+ if (parOidAtPath !== curOidAtPath) {
489
+ byPath.set(path, {
490
+ author: chain[i].commit.author,
491
+ message: chain[i].commit.message,
492
+ timestamp: chain[i].commit.timestamp
493
+ })
494
+ remaining.delete(path)
495
+ }
496
+ } else if (isRoot) {
497
+ byPath.set(path, {
498
+ author: chain[i].commit.author,
499
+ message: chain[i].commit.message,
500
+ timestamp: chain[i].commit.timestamp
501
+ })
502
+ remaining.delete(path)
503
+ }
504
+ // else: shallow boundary — caller falls back to existing row /
505
+ // oldest-commit metadata.
506
+ }
507
+ }
508
+
509
+ return {
510
+ byPath,
511
+ oldest: chain[chain.length - 1].commit
512
+ }
513
+ }
514
+
385
515
  module.exports = {
386
516
  Remote,
387
517
  RemoteDrive,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gip-remote",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Git+Pear remote DB for handling git data",
5
5
  "main": "index.js",
6
6
  "scripts": {