firstsun-dev_git-files-sync/tests/utils/git-blob-sha.test.ts
ClaudiaFang 2ed5a436b0 perf(refresh): use tree blob SHAs to avoid per-file content fetches
Refresh previously fetched each file's remote content via getFile() to
compare against local content — an O(n) network round-trip per file
even after the existing concurrency pool. Implements the two-phase
hybrid design from the issue discussion:

Phase 1 — bulk classify, no content:
- Extend GitTreeEntry with sha, populated from each service's tree
  listing (GitHub/Gitea: item.sha, GitLab: item.id — GitLab's tree API
  calls the blob SHA "id", not "sha").
- Add gitBlobSha(): sha1("blob " + byteLength + "\0" + contentBytes)
  via crypto.subtle, matching `git hash-object` exactly (verified
  against real git-produced SHAs in tests, including a multi-byte
  UTF-8 case to check byte length vs char length).
- SyncStatusView.refreshFileStatus() now compares a locally-computed
  blob SHA against the tree entry's SHA — no getFile call. Falls back
  to the previous content-based comparison per-entry when a tree entry
  has no SHA or isn't found in the tree at all (new local file).
- Symlink-aware hashing per the issue's follow-up note: a symlink's
  blob content is its target path string, not the content it points
  at. "real" mode hashes the raw local link target (readLocalSymlinkTarget)
  instead of following it; "follow" mode always hashes the followed
  content; "skip" entries are already excluded from the tree map.

Phase 2 — fetch content only when needed:
- Add getBlob(sha, path) to GitServiceInterface: GitHub/Gitea use
  `git/blobs/{sha}` (base64 JSON, shared via a new
  fetchGitHubStyleBlob() base helper); GitLab uses
  `repository/blobs/{sha}/raw` (raw bytes, not JSON-wrapped).
- The diff panel no longer requires content prefetched during refresh.
  FileListItem's Diff button always renders for a modified file and
  fetches remote content on demand via a new onExpandDiff callback,
  showing a loading placeholder while the request is in flight and
  caching the result on the FileStatus so re-expanding doesn't refetch.
- A modified symlink shows "Symlink target changed" instead of running
  a text diff.

Known limitation (pre-existing, not introduced here): comparing raw
file bytes has no CRLF/line-ending normalization, so a repo checked
out elsewhere with core.autocrlf could show a false "modified" status.
The previous content-based comparison (contentsEqual) had the same
exact-byte-equality behavior, so this isn't a regression.

Closes #36
2026-07-13 13:30:10 +00:00

34 lines
1.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { gitBlobSha } from '../../src/utils/git-blob-sha';
// Test vectors verified against `git hash-object --stdin`.
describe('gitBlobSha', () => {
it('matches git for empty content', async () => {
expect(await gitBlobSha('')).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391');
});
it('matches git for a string with no trailing newline', async () => {
expect(await gitBlobSha('hello world')).toBe('95d09f2b10159347eece71399a7e2e907ea3df4f');
});
it('matches git for a string with a trailing newline', async () => {
expect(await gitBlobSha('hello world\n')).toBe('3b18e512dba79e4c8300dd08aeb37f8e728b8dad');
});
it('matches git for multi-byte UTF-8 content', async () => {
// "café" is 5 bytes in UTF-8 (é is 2 bytes) — verifies byte length, not char length, is used.
expect(await gitBlobSha('café')).toBe('1c2e52cfe7542a64cdea57e5fec2fc1739846c03');
});
it('produces the same SHA for equivalent string and ArrayBuffer content', async () => {
const text = 'hello world';
const bytes = new TextEncoder().encode(text);
const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
expect(await gitBlobSha(arrayBuffer)).toBe(await gitBlobSha(text));
});
it('produces different SHAs for different content', async () => {
expect(await gitBlobSha('a')).not.toBe(await gitBlobSha('b'));
});
});