vdelta 0.10.1 → 0.10.2
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/tree-digest.js +99 -14
- package/dist/tree-digest.js.map +1 -1
- package/package.json +1 -1
- package/spec/veridelta-1.md +13 -6
package/dist/tree-digest.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* claudedocs/2026-07-16-expA-tree-digest.md, ported verbatim):
|
|
4
4
|
* a git tree object id over tracked+staged+unstaged+untracked files,
|
|
5
5
|
* excluding committed-gitignore'd paths, computed against a dedicated
|
|
6
|
-
* throwaway index
|
|
6
|
+
* throwaway index, with host-dependent git settings pinned.
|
|
7
7
|
* Rendered as a bare 40-hex OID (documented deviation from sha256:).
|
|
8
8
|
*
|
|
9
9
|
* treeDigest() is read-only with respect to the observed repo: all git
|
|
@@ -13,10 +13,51 @@
|
|
|
13
13
|
* handles linked worktrees whose objects live in the common dir) are made
|
|
14
14
|
* available for reads through GIT_ALTERNATE_OBJECT_DIRECTORIES. The
|
|
15
15
|
* throwaway directory is removed again once the digest has been computed.
|
|
16
|
+
*
|
|
17
|
+
* The throwaway index (GIT_INDEX_FILE) is seeded from a byte copy of the
|
|
18
|
+
* repository's real index file (resolved via `--git-path index`, which
|
|
19
|
+
* yields the per-worktree index for linked worktrees) with its mtime
|
|
20
|
+
* preserved, so git's stat cache is honoured and `add -A` re-hashes only
|
|
21
|
+
* files whose stat data changed, keeping cost proportional to dirty files
|
|
22
|
+
* instead of tracked files. `add -A` + `write-tree` reconcile the index
|
|
23
|
+
* with the working tree so the resulting tree id is independent of the
|
|
24
|
+
* seed (verified for the S1-S17 scenarios of issue #81 and enforced by
|
|
25
|
+
* tests/unit/tree-digest-parity.test.ts).
|
|
26
|
+
*
|
|
27
|
+
* (1) racy-git: correctness of the stat-cache shortcut rests on git's
|
|
28
|
+
* racy-git handling (entries whose mtime is not older than the index
|
|
29
|
+
* file's mtime are re-read rather than trusted, and racily-clean entries
|
|
30
|
+
* are size-smudged at write time); the copy preserves the real index's
|
|
31
|
+
* mtime so these semantics are identical to git's own; the issue's 20/20
|
|
32
|
+
* same-size-same-second sample is evidence, not proof; environments that
|
|
33
|
+
* rewrite file stat data behind git's back (tools that restore mtimes,
|
|
34
|
+
* some sync/backup agents) can break this premise -- when in doubt such
|
|
35
|
+
* environments should be treated as unverified.
|
|
36
|
+
*
|
|
37
|
+
* (2) split-index / extensions: an index with the `link` extension
|
|
38
|
+
* (core.splitIndex) still resolves `sharedindex.*` from the real git dir,
|
|
39
|
+
* so a copied index works (S14 in #81); however core.untrackedCache,
|
|
40
|
+
* core.fsmonitor and index.sparse were all unset in every repo the change
|
|
41
|
+
* was validated against, and behaviour with those enabled is UNVERIFIED.
|
|
42
|
+
* assume-unchanged (CE_VALID) and skip-worktree (sparse-checkout) entries
|
|
43
|
+
* are handled explicitly: `add -A` skips them via ie_match_stat without
|
|
44
|
+
* stat'ing the working tree, so a copied index carrying either flag on a
|
|
45
|
+
* changed file would silently keep the stale HEAD content. After copying,
|
|
46
|
+
* `git ls-files -v` is run against the throwaway index and, if any entry
|
|
47
|
+
* reports a lowercase tag (assume-unchanged) or an `S`/`s` tag
|
|
48
|
+
* (skip-worktree), the copy is discarded and seeding falls back to
|
|
49
|
+
* `read-tree` (note 3) instead, which always re-stats every path via
|
|
50
|
+
* `add -A`. Covered by S16 (assume-unchanged) and S17 (skip-worktree) in
|
|
51
|
+
* tests/unit/tree-digest-parity.test.ts.
|
|
52
|
+
*
|
|
53
|
+
* (3) fallback: when the real index file does not exist (fresh `git
|
|
54
|
+
* init`, checkout from bare, `rm .git/index`) or cannot be copied, the
|
|
55
|
+
* index is seeded with `git read-tree HEAD` (or `read-tree --empty` for
|
|
56
|
+
* an unborn HEAD) exactly as before; this fallback MUST be kept.
|
|
16
57
|
*/
|
|
17
58
|
import { execFile } from 'node:child_process';
|
|
18
59
|
import { randomUUID } from 'node:crypto';
|
|
19
|
-
import { mkdir, rm } from 'node:fs/promises';
|
|
60
|
+
import { copyFile, mkdir, rm, stat, utimes } from 'node:fs/promises';
|
|
20
61
|
import { tmpdir } from 'node:os';
|
|
21
62
|
import { delimiter, join } from 'node:path';
|
|
22
63
|
import { promisify } from 'node:util';
|
|
@@ -38,17 +79,59 @@ async function git(worktree, args, env) {
|
|
|
38
79
|
});
|
|
39
80
|
return stdout.trim();
|
|
40
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Seed the throwaway index by copying the repository's real index file
|
|
84
|
+
* byte-for-byte, preserving its mtime (see header note 1: preserving
|
|
85
|
+
* mtime keeps racy-git behaviour identical to git's own handling of the
|
|
86
|
+
* real index). Returns false when the real index does not exist or the
|
|
87
|
+
* copy cannot be completed, in which case any partial copy is removed and
|
|
88
|
+
* the caller MUST fall back to `read-tree` (header note 3).
|
|
89
|
+
*/
|
|
90
|
+
async function seedFromRealIndex(realIndex, idx) {
|
|
91
|
+
try {
|
|
92
|
+
const st = await stat(realIndex);
|
|
93
|
+
if (!st.isFile())
|
|
94
|
+
return false;
|
|
95
|
+
await copyFile(realIndex, idx);
|
|
96
|
+
await utimes(idx, st.atime, st.mtime);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
await rm(idx, { force: true });
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `git ls-files -v` tags each entry H (cached), S (skip-worktree), M
|
|
106
|
+
* (unmerged), R (removed), C (modified/changed) or K (to be killed); the
|
|
107
|
+
* tag is lowercased when the entry also has the assume-unchanged
|
|
108
|
+
* (CE_VALID) bit set. `add -A` reconciles the index against the working
|
|
109
|
+
* tree via ie_match_stat, which treats both assume-unchanged and
|
|
110
|
+
* skip-worktree entries as trusted and skips re-stat'ing them -- so a
|
|
111
|
+
* copied real index carrying either flag on a changed file would leave the
|
|
112
|
+
* throwaway index (and therefore the resulting tree) pinned to the stale
|
|
113
|
+
* content. Detect that here so the caller can fall back to a `read-tree`
|
|
114
|
+
* seed, which re-stats every path.
|
|
115
|
+
*/
|
|
116
|
+
function hasUntrustworthyIndexFlags(lsFilesOutput) {
|
|
117
|
+
return lsFilesOutput
|
|
118
|
+
.split('\n')
|
|
119
|
+
.some((line) => line !== '' && /^[a-z]|^[Ss]/.test(line));
|
|
120
|
+
}
|
|
41
121
|
export async function treeDigest(worktree) {
|
|
42
122
|
const idx = join(tmpdir(), `vd-idx-${randomUUID()}`);
|
|
43
123
|
const objDir = join(tmpdir(), `vd-obj-${randomUUID()}`);
|
|
44
124
|
try {
|
|
45
125
|
await mkdir(objDir, { recursive: true });
|
|
46
|
-
const
|
|
126
|
+
const paths = await git(worktree, [
|
|
47
127
|
'rev-parse',
|
|
48
128
|
'--path-format=absolute',
|
|
49
129
|
'--git-path',
|
|
50
130
|
'objects',
|
|
131
|
+
'--git-path',
|
|
132
|
+
'index',
|
|
51
133
|
]);
|
|
134
|
+
const [realObjects = '', realIndex = ''] = paths.split(/\r?\n/);
|
|
52
135
|
const existingAlternates = process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
|
|
53
136
|
const alternates = existingAlternates
|
|
54
137
|
? `${realObjects}${delimiter}${existingAlternates}`
|
|
@@ -58,18 +141,20 @@ export async function treeDigest(worktree) {
|
|
|
58
141
|
GIT_OBJECT_DIRECTORY: objDir,
|
|
59
142
|
GIT_ALTERNATE_OBJECT_DIRECTORIES: alternates,
|
|
60
143
|
};
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
await git(worktree, ['
|
|
64
|
-
|
|
65
|
-
catch {
|
|
66
|
-
hasHead = false;
|
|
67
|
-
}
|
|
68
|
-
if (hasHead) {
|
|
69
|
-
await git(worktree, ['read-tree', 'HEAD'], env);
|
|
144
|
+
let seeded = await seedFromRealIndex(realIndex, idx);
|
|
145
|
+
if (seeded &&
|
|
146
|
+
hasUntrustworthyIndexFlags(await git(worktree, ['ls-files', '-v'], env))) {
|
|
147
|
+
seeded = false;
|
|
70
148
|
}
|
|
71
|
-
|
|
72
|
-
|
|
149
|
+
if (!seeded) {
|
|
150
|
+
let hasHead = true;
|
|
151
|
+
try {
|
|
152
|
+
await git(worktree, ['rev-parse', '--verify', '-q', 'HEAD']);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
hasHead = false;
|
|
156
|
+
}
|
|
157
|
+
await git(worktree, ['read-tree', hasHead ? 'HEAD' : '--empty'], env);
|
|
73
158
|
}
|
|
74
159
|
await git(worktree, ['add', '-A'], env);
|
|
75
160
|
return await git(worktree, ['write-tree'], env);
|
package/dist/tree-digest.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tree-digest.js","sourceRoot":"","sources":["../src/tree-digest.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"tree-digest.js","sourceRoot":"","sources":["../src/tree-digest.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/spec/veridelta-1.md
CHANGED
|
@@ -199,12 +199,19 @@ full digest.
|
|
|
199
199
|
git tree object id over the union of tracked, staged, unstaged, and untracked
|
|
200
200
|
files, excluding paths ignored by committed `.gitignore`/`.gitattributes`
|
|
201
201
|
rules. Implementations MUST compute it against a dedicated, throwaway index
|
|
202
|
-
(`GIT_INDEX_FILE`)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
tree
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
(`GIT_INDEX_FILE`), followed by `git add -A` and `git write-tree`, so that the
|
|
203
|
+
operation never mutates the repository's real index or working tree. The
|
|
204
|
+
throwaway index MAY be seeded from `HEAD` (`git read-tree HEAD`, or
|
|
205
|
+
`read-tree --empty` for an unborn `HEAD`) or from a byte copy of the
|
|
206
|
+
repository's real index file (which carries git's stat cache and avoids
|
|
207
|
+
re-hashing unchanged tracked files); because `git add -A` reconciles the
|
|
208
|
+
index with the working tree before `write-tree`, the resulting id MUST NOT
|
|
209
|
+
depend on which seed was used. To keep the digest deterministic and
|
|
210
|
+
independent of host and time, the computation MUST pin
|
|
211
|
+
`core.autocrlf=false`, `core.eol=lf`, and `core.excludesFile=/dev/null`, and
|
|
212
|
+
MUST NOT depend on file mtimes (an implementation MAY consult git's stat
|
|
213
|
+
cache to skip re-hashing, relying on git's racy-git detection, provided the
|
|
214
|
+
resulting id equals that of a full re-hash). For a clean
|
|
208
215
|
checkout the resulting id is identical to `git rev-parse HEAD^{tree}`, which a
|
|
209
216
|
verifier MAY use directly as the canonical form (subject to the conditions of
|
|
210
217
|
§11.3). The digest records submodules solely by their gitlink commit id; it
|