pi-odd-worktree 0.0.0-stage → 1.0.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/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T3 (odd-viewer S2,S6) tests: lib/odd-docs.js — discovery and reading of
|
|
3
|
+
// the current checkout's odd documents.
|
|
4
|
+
//
|
|
5
|
+
// Contract under test: only `odd/tasks/*.md` of the given root, recursive
|
|
6
|
+
// never, ordered most-recently-modified first with the display name as the
|
|
7
|
+
// deterministic tie-break (S10) — an unreadable timestamp is kept and
|
|
8
|
+
// ordered by name after the readable ones — each entry carrying the display
|
|
9
|
+
// name WITHOUT the extension and path; a root whose `odd/tasks` directory is
|
|
10
|
+
// missing reads as an empty list (not an error); a missing or non-directory
|
|
11
|
+
// root fails closed; reading returns the file text and an unreadable path
|
|
12
|
+
// reports an error instead of empty content.
|
|
13
|
+
//
|
|
14
|
+
// S13 (clone-aware recency) adds a second contract on top of that order: a
|
|
15
|
+
// fresh clone stamps every file with the same checkout mtime, so mtime alone
|
|
16
|
+
// says nothing about recency. Every case below is a REAL temporary git
|
|
17
|
+
// fixture driven with fixed-argv git calls: a clone with identical mtimes and
|
|
18
|
+
// different commit dates, an uncommitted edit, an untracked document, a
|
|
19
|
+
// non-git directory, git missing, git failing, garbage git output, a shallow
|
|
20
|
+
// clone, the bounded call count, and determinism.
|
|
21
|
+
|
|
22
|
+
const { test, describe, after } = require('node:test');
|
|
23
|
+
const assert = require('node:assert/strict');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { spawnSync, execFileSync } = require('node:child_process');
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
listOddDocs,
|
|
31
|
+
listOddDocsAsync,
|
|
32
|
+
listOddDocsLocal,
|
|
33
|
+
readOddDoc,
|
|
34
|
+
commitCacheInfo,
|
|
35
|
+
COMMIT_CACHE_LIMIT,
|
|
36
|
+
} = require('../lib/odd-docs.js');
|
|
37
|
+
|
|
38
|
+
const roots = [];
|
|
39
|
+
after(() => {
|
|
40
|
+
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function fixture(files) {
|
|
44
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-'));
|
|
45
|
+
roots.push(root);
|
|
46
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
47
|
+
const target = path.join(root, relative);
|
|
48
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
49
|
+
fs.writeFileSync(target, content);
|
|
50
|
+
}
|
|
51
|
+
return root;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Explicit stamps keep the ordering assertions independent of how fast the
|
|
55
|
+
// fixture happens to be written (S10).
|
|
56
|
+
function setMtime(root, relative, iso) {
|
|
57
|
+
const when = new Date(iso);
|
|
58
|
+
fs.utimesSync(path.join(root, relative), when, when);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- real git fixtures (S13) ----------------------------------------------
|
|
62
|
+
// Isolated config so the host's git config cannot change what the fixture
|
|
63
|
+
// records, and LC_ALL=C so git output and messages stay stable.
|
|
64
|
+
const GIT_ENV = {
|
|
65
|
+
...process.env,
|
|
66
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
67
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
68
|
+
LC_ALL: 'C',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function git(cwd, args, env = {}) {
|
|
72
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...GIT_ENV, ...env } });
|
|
73
|
+
assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`);
|
|
74
|
+
return (result.stdout || '').trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeRepo(tag) {
|
|
78
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `odd-docs-git-${tag}-`));
|
|
79
|
+
roots.push(dir);
|
|
80
|
+
git(dir, ['init', '-q']);
|
|
81
|
+
git(dir, ['config', 'user.email', 'test@example.com']);
|
|
82
|
+
git(dir, ['config', 'user.name', 'Test']);
|
|
83
|
+
return fs.realpathSync(dir);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeDoc(root, relative, content) {
|
|
87
|
+
const target = path.join(root, relative);
|
|
88
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
89
|
+
fs.writeFileSync(target, content);
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Commit everything with an EXPLICIT committer/author date, so the commit
|
|
94
|
+
// timestamps the listing reads are the ones the test names instead of the
|
|
95
|
+
// clock at fixture-build time.
|
|
96
|
+
function commitAll(root, iso, message) {
|
|
97
|
+
git(root, ['add', '-A']);
|
|
98
|
+
git(root, ['commit', '-q', '-m', message], { GIT_AUTHOR_DATE: iso, GIT_COMMITTER_DATE: iso });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function names(result) {
|
|
102
|
+
assert.equal(result.ok, true, 'the listing never fails over recency');
|
|
103
|
+
return result.docs.map((doc) => doc.name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An executable stub reached through the `gitBin` seam, so "git unavailable"
|
|
107
|
+
// and "git fails" are real process failures instead of mocked modules.
|
|
108
|
+
function stubGit(tag, script) {
|
|
109
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `odd-docs-stub-${tag}-`));
|
|
110
|
+
roots.push(dir);
|
|
111
|
+
const file = path.join(dir, 'git');
|
|
112
|
+
fs.writeFileSync(file, script);
|
|
113
|
+
fs.chmodSync(file, 0o755);
|
|
114
|
+
return file;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A pass-through git that records every invocation's argv, so the ONE-call
|
|
118
|
+
// budget is asserted from observed behaviour rather than from the source.
|
|
119
|
+
function recordingGit(tag, logFile) {
|
|
120
|
+
const real = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
|
|
121
|
+
return stubGit(
|
|
122
|
+
tag,
|
|
123
|
+
`#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(logFile)}\nexec ${JSON.stringify(real)} "$@"\n`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function recordedCalls(logFile) {
|
|
128
|
+
if (!fs.existsSync(logFile)) return [];
|
|
129
|
+
return fs
|
|
130
|
+
.readFileSync(logFile, 'utf8')
|
|
131
|
+
.split('\n')
|
|
132
|
+
.map((line) => line.trim())
|
|
133
|
+
.filter((line) => line.length > 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Count one kind of recorded invocation: the history walk, the local-edit read,
|
|
137
|
+
// or the HEAD read that keys the commit-time memo.
|
|
138
|
+
function countCalls(logFile, pattern) {
|
|
139
|
+
return recordedCalls(logFile).filter((call) => pattern.test(call)).length;
|
|
140
|
+
}
|
|
141
|
+
const LOG_CALL = /(^|\s)log -z /;
|
|
142
|
+
const STATUS_CALL = /(^|\s)status --porcelain/;
|
|
143
|
+
const HEAD_CALL = /(^|\s)rev-parse /;
|
|
144
|
+
|
|
145
|
+
describe('listOddDocs', () => {
|
|
146
|
+
test('lists odd/tasks markdown with the display name, file and absolute path, without the extension or path', () => {
|
|
147
|
+
const root = fixture({
|
|
148
|
+
'odd/tasks/beta.md': '# beta\n',
|
|
149
|
+
'odd/tasks/alpha.md': '# alpha\n',
|
|
150
|
+
'odd/notes.md': '# notes\n',
|
|
151
|
+
'odd/tasks/nested/deep.md': '# deep\n',
|
|
152
|
+
'README.md': '# readme\n',
|
|
153
|
+
'odd/tasks/ignored.txt': 'no\n',
|
|
154
|
+
});
|
|
155
|
+
// This test pins membership and row shape, not order: give both
|
|
156
|
+
// documents explicit stamps so the assertion cannot depend on which
|
|
157
|
+
// file the fixture wrote first.
|
|
158
|
+
setMtime(root, 'odd/tasks/beta.md', '2026-01-01T00:00:00Z');
|
|
159
|
+
setMtime(root, 'odd/tasks/alpha.md', '2026-02-01T00:00:00Z');
|
|
160
|
+
const result = listOddDocs(root);
|
|
161
|
+
assert.equal(result.ok, true);
|
|
162
|
+
assert.deepEqual(result.docs.map((doc) => doc.name), ['alpha', 'beta']);
|
|
163
|
+
assert.deepEqual(result.docs.map((doc) => doc.file), ['alpha.md', 'beta.md']);
|
|
164
|
+
assert.deepEqual(Object.keys(result.docs[0]), ['name', 'file', 'path'], 'ordering changes nothing in the row shape');
|
|
165
|
+
for (const doc of result.docs) {
|
|
166
|
+
assert.ok(path.isAbsolute(doc.path), 'each entry carries an absolute path');
|
|
167
|
+
assert.ok(doc.path.endsWith(path.join('odd', 'tasks', doc.file)));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('orders documents by modification time, newest first (S10)', () => {
|
|
172
|
+
const root = fixture({ 'odd/tasks/alpha.md': '# alpha\n', 'odd/tasks/beta.md': '# beta\n' });
|
|
173
|
+
setMtime(root, 'odd/tasks/alpha.md', '2026-01-01T00:00:00Z');
|
|
174
|
+
setMtime(root, 'odd/tasks/beta.md', '2026-02-01T00:00:00Z');
|
|
175
|
+
assert.deepEqual(listOddDocs(root).docs.map((doc) => doc.name), ['beta', 'alpha']);
|
|
176
|
+
// Reversing the stamps must reverse the order: the rule is the
|
|
177
|
+
// timestamp, never the directory order or the name.
|
|
178
|
+
setMtime(root, 'odd/tasks/alpha.md', '2026-03-01T00:00:00Z');
|
|
179
|
+
assert.deepEqual(listOddDocs(root).docs.map((doc) => doc.name), ['alpha', 'beta']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('equal modification times fall back to the display name (S10)', () => {
|
|
183
|
+
const root = fixture({
|
|
184
|
+
'odd/tasks/alpha.md': '# alpha\n',
|
|
185
|
+
'odd/tasks/beta.md': '# beta\n',
|
|
186
|
+
'odd/tasks/gamma.md': '# gamma\n',
|
|
187
|
+
});
|
|
188
|
+
setMtime(root, 'odd/tasks/alpha.md', '2026-01-01T00:00:00Z');
|
|
189
|
+
setMtime(root, 'odd/tasks/beta.md', '2026-02-01T00:00:00Z');
|
|
190
|
+
setMtime(root, 'odd/tasks/gamma.md', '2026-02-01T00:00:00Z');
|
|
191
|
+
assert.deepEqual(
|
|
192
|
+
listOddDocs(root).docs.map((doc) => doc.name),
|
|
193
|
+
['beta', 'gamma', 'alpha'],
|
|
194
|
+
'the tied pair keeps name order and still outranks the older document',
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a document whose timestamp cannot be read is kept, by name after the readable ones (S10)', () => {
|
|
199
|
+
const root = fixture({
|
|
200
|
+
'odd/tasks/aaa.md': '# aaa\n',
|
|
201
|
+
'odd/tasks/mmm.md': '# mmm\n',
|
|
202
|
+
'odd/tasks/bbb.md': '# bbb\n',
|
|
203
|
+
'odd/tasks/zzz.md': '# zzz\n',
|
|
204
|
+
});
|
|
205
|
+
setMtime(root, 'odd/tasks/aaa.md', '2026-01-01T00:00:00Z');
|
|
206
|
+
setMtime(root, 'odd/tasks/mmm.md', '2026-02-01T00:00:00Z');
|
|
207
|
+
// Deliberately the two newest stamps: an unreadable timestamp must not
|
|
208
|
+
// rank the entry and must never drop it from the list.
|
|
209
|
+
setMtime(root, 'odd/tasks/bbb.md', '2026-03-01T00:00:00Z');
|
|
210
|
+
setMtime(root, 'odd/tasks/zzz.md', '2026-04-01T00:00:00Z');
|
|
211
|
+
const realStat = fs.statSync;
|
|
212
|
+
fs.statSync = (target, ...rest) => {
|
|
213
|
+
if (/[\\/](bbb|zzz)\.md$/.test(String(target))) throw new Error('EACCES: timestamp unreadable');
|
|
214
|
+
return realStat.call(fs, target, ...rest);
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
const docs = listOddDocs(root).docs;
|
|
218
|
+
assert.equal(docs.length, 4, 'no document is dropped for an unreadable timestamp');
|
|
219
|
+
assert.deepEqual(docs.map((doc) => doc.name), ['mmm', 'aaa', 'bbb', 'zzz']);
|
|
220
|
+
} finally {
|
|
221
|
+
fs.statSync = realStat;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a checkout without odd/tasks is an empty list, not an error', () => {
|
|
226
|
+
const root = fixture({ 'README.md': '# readme\n' });
|
|
227
|
+
const result = listOddDocs(root);
|
|
228
|
+
assert.equal(result.ok, true);
|
|
229
|
+
assert.deepEqual(result.docs, []);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('a missing root fails closed', () => {
|
|
233
|
+
const missing = path.join(os.tmpdir(), `odd-docs-missing-${process.pid}-${Date.now()}`);
|
|
234
|
+
assert.equal(listOddDocs(missing).ok, false);
|
|
235
|
+
assert.equal(listOddDocs('').ok, false);
|
|
236
|
+
assert.equal(listOddDocs(null).ok, false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('readOddDoc', () => {
|
|
241
|
+
test('returns the file bytes as text', () => {
|
|
242
|
+
const root = fixture({ 'odd/tasks/alpha.md': '# alpha\n\n## Specs\nS1. text\n' });
|
|
243
|
+
const [doc] = listOddDocs(root).docs;
|
|
244
|
+
const read = readOddDoc(doc);
|
|
245
|
+
assert.equal(read.ok, true);
|
|
246
|
+
assert.match(read.text, /## Specs/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('an unreadable path reports an error instead of empty content', () => {
|
|
250
|
+
const root = fixture({ 'odd/tasks/alpha.md': '# alpha\n' });
|
|
251
|
+
const [doc] = listOddDocs(root).docs;
|
|
252
|
+
fs.rmSync(doc.path);
|
|
253
|
+
const read = readOddDoc(doc);
|
|
254
|
+
assert.equal(read.ok, false);
|
|
255
|
+
assert.equal(typeof read.error, 'string');
|
|
256
|
+
assert.ok(read.error.length > 0);
|
|
257
|
+
assert.equal(read.oddTitle, undefined);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// S13: recency that survives a fresh clone. The priority the listing applies
|
|
263
|
+
// per document is (1) an uncommitted local edit — ranked at
|
|
264
|
+
// max(its own mtime, its own commit time), so a local edit is never older than
|
|
265
|
+
// the commit it edits; (2) the last commit time touching the file; (3) the
|
|
266
|
+
// file mtime, whenever git, its history, or the file's tracking is unusable.
|
|
267
|
+
// Documents are then ordered by that timestamp descending, then display name,
|
|
268
|
+
// then raw file name — all code-unit comparisons, never localeCompare.
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
describe('clone-aware recency (S13)', () => {
|
|
271
|
+
test('a fresh clone orders by commit date, not by the identical checkout mtime', () => {
|
|
272
|
+
const source = makeRepo('clone-source');
|
|
273
|
+
writeDoc(source, 'odd/tasks/aaa.md', '# aaa\n');
|
|
274
|
+
commitAll(source, '2026-01-01T00:00:00Z', 'older document');
|
|
275
|
+
writeDoc(source, 'odd/tasks/zzz.md', '# zzz\n');
|
|
276
|
+
commitAll(source, '2026-06-01T00:00:00Z', 'newer document');
|
|
277
|
+
|
|
278
|
+
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-clone-'));
|
|
279
|
+
roots.push(parent);
|
|
280
|
+
const clone = path.join(parent, 'checkout');
|
|
281
|
+
git(parent, ['clone', '-q', source, clone]);
|
|
282
|
+
|
|
283
|
+
// The fresh-clone property under test: one checkout stamp for every file,
|
|
284
|
+
// so mtime cannot distinguish them. Alphabetical would put `aaa` first.
|
|
285
|
+
setMtime(clone, 'odd/tasks/aaa.md', '2026-12-01T00:00:00Z');
|
|
286
|
+
setMtime(clone, 'odd/tasks/zzz.md', '2026-12-01T00:00:00Z');
|
|
287
|
+
assert.equal(
|
|
288
|
+
fs.statSync(path.join(clone, 'odd/tasks/aaa.md')).mtimeMs,
|
|
289
|
+
fs.statSync(path.join(clone, 'odd/tasks/zzz.md')).mtimeMs,
|
|
290
|
+
'both documents carry the identical clone mtime',
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const listing = listOddDocs(clone);
|
|
294
|
+
assert.deepEqual(names(listing), ['zzz', 'aaa'], 'the commit dates decide, newest first');
|
|
295
|
+
assert.notDeepEqual(names(listing), ['aaa', 'zzz'], 'recency is never alphabetical: the view toggles a copy');
|
|
296
|
+
assert.deepEqual(
|
|
297
|
+
Object.keys(listing.docs[0]),
|
|
298
|
+
['name', 'file', 'path'],
|
|
299
|
+
'the recency keys stay out of the public row shape',
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// And mtime must not override a commit even when it contradicts it.
|
|
303
|
+
setMtime(clone, 'odd/tasks/aaa.md', '2026-09-01T00:00:00Z');
|
|
304
|
+
setMtime(clone, 'odd/tasks/zzz.md', '2026-07-01T00:00:00Z');
|
|
305
|
+
assert.deepEqual(listOddDocs(clone).docs.map((doc) => doc.name), ['zzz', 'aaa']);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('an uncommitted local edit outranks its own commit, and never ranks below it', () => {
|
|
309
|
+
const root = makeRepo('dirty');
|
|
310
|
+
writeDoc(root, 'odd/tasks/alpha.md', '# alpha\n');
|
|
311
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'alpha');
|
|
312
|
+
writeDoc(root, 'odd/tasks/gamma.md', '# gamma\n');
|
|
313
|
+
commitAll(root, '2025-06-01T00:00:00Z', 'gamma');
|
|
314
|
+
writeDoc(root, 'odd/tasks/beta.md', '# beta\n');
|
|
315
|
+
commitAll(root, '2026-06-01T00:00:00Z', 'beta');
|
|
316
|
+
|
|
317
|
+
// Clean baseline: pure commit recency, no local edit involved.
|
|
318
|
+
assert.deepEqual(names(listOddDocs(root)), ['beta', 'alpha', 'gamma']);
|
|
319
|
+
|
|
320
|
+
// An uncommitted edit is newer than the commit it edits, so it moves the
|
|
321
|
+
// document ahead of a document whose commit is newer than its own.
|
|
322
|
+
writeDoc(root, 'odd/tasks/alpha.md', '# alpha\n\nL36: edited, not committed\n');
|
|
323
|
+
assert.deepEqual(names(listOddDocs(root)), ['alpha', 'beta', 'gamma']);
|
|
324
|
+
|
|
325
|
+
// A backdated mtime does not demote the edit below its own commit: it
|
|
326
|
+
// ranks at max(mtime, commit) = 2026-01-01, still above gamma (2025-06-01)
|
|
327
|
+
// and still below beta (2026-06-01). A bare-mtime rule would sort it last.
|
|
328
|
+
setMtime(root, 'odd/tasks/alpha.md', '2020-01-01T00:00:00Z');
|
|
329
|
+
assert.deepEqual(names(listOddDocs(root)), ['beta', 'alpha', 'gamma']);
|
|
330
|
+
|
|
331
|
+
// Committing the edit turns the local signal back into a commit signal.
|
|
332
|
+
commitAll(root, '2026-08-01T00:00:00Z', 'alpha edit');
|
|
333
|
+
assert.deepEqual(names(listOddDocs(root)), ['alpha', 'beta', 'gamma']);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('an untracked document falls back to its own mtime and is never dropped', () => {
|
|
337
|
+
const root = makeRepo('untracked');
|
|
338
|
+
writeDoc(root, 'odd/tasks/committed.md', '# committed\n');
|
|
339
|
+
commitAll(root, '2026-05-01T00:00:00Z', 'committed');
|
|
340
|
+
writeDoc(root, 'odd/tasks/draft.md', '# draft\n');
|
|
341
|
+
|
|
342
|
+
// Untracked, and newer than the only commit: the mtime is the only signal.
|
|
343
|
+
setMtime(root, 'odd/tasks/draft.md', '2026-09-01T00:00:00Z');
|
|
344
|
+
const newer = listOddDocs(root);
|
|
345
|
+
assert.equal(newer.docs.length, 2, 'the untracked document is listed');
|
|
346
|
+
assert.deepEqual(names(newer), ['draft', 'committed']);
|
|
347
|
+
|
|
348
|
+
// Backdated, it sorts after the commit — never pinned first just because
|
|
349
|
+
// git has no history for it.
|
|
350
|
+
setMtime(root, 'odd/tasks/draft.md', '2024-01-01T00:00:00Z');
|
|
351
|
+
assert.deepEqual(names(listOddDocs(root)), ['committed', 'draft']);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('a non-git directory falls back to mtime and still lists', () => {
|
|
355
|
+
const root = fixture({ 'odd/tasks/alpha.md': '# alpha\n', 'odd/tasks/beta.md': '# beta\n' });
|
|
356
|
+
setMtime(root, 'odd/tasks/alpha.md', '2024-01-01T00:00:00Z');
|
|
357
|
+
setMtime(root, 'odd/tasks/beta.md', '2025-01-01T00:00:00Z');
|
|
358
|
+
assert.deepEqual(names(listOddDocs(root)), ['beta', 'alpha']);
|
|
359
|
+
setMtime(root, 'odd/tasks/alpha.md', '2026-01-01T00:00:00Z');
|
|
360
|
+
assert.deepEqual(names(listOddDocs(root)), ['alpha', 'beta']);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('git missing, git failing or garbage git output falls back to mtime', () => {
|
|
364
|
+
const root = makeRepo('unavailable');
|
|
365
|
+
writeDoc(root, 'odd/tasks/aaa.md', '# aaa\n');
|
|
366
|
+
commitAll(root, '2026-06-01T00:00:00Z', 'newer commit, older file');
|
|
367
|
+
writeDoc(root, 'odd/tasks/zzz.md', '# zzz\n');
|
|
368
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'older commit, newer file');
|
|
369
|
+
// mtime order is the OPPOSITE of commit order: any fallback is observable.
|
|
370
|
+
setMtime(root, 'odd/tasks/aaa.md', '2024-01-01T00:00:00Z');
|
|
371
|
+
setMtime(root, 'odd/tasks/zzz.md', '2025-01-01T00:00:00Z');
|
|
372
|
+
assert.deepEqual(names(listOddDocs(root)), ['aaa', 'zzz'], 'sanity: git recency is in effect');
|
|
373
|
+
|
|
374
|
+
const missing = listOddDocs(root, { gitBin: path.join(root, 'no-such-git-binary') });
|
|
375
|
+
assert.deepEqual(names(missing), ['zzz', 'aaa'], 'git unavailable: mtime, never an error');
|
|
376
|
+
|
|
377
|
+
const failing = listOddDocs(root, { gitBin: stubGit('failing', '#!/bin/sh\nexit 128\n') });
|
|
378
|
+
assert.deepEqual(names(failing), ['zzz', 'aaa'], 'git failing: mtime, never an error');
|
|
379
|
+
|
|
380
|
+
const garbage = listOddDocs(root, { gitBin: stubGit('garbage', "#!/bin/sh\nprintf 'not a git log\\n'\n") });
|
|
381
|
+
assert.deepEqual(names(garbage), ['zzz', 'aaa'], 'unparseable git output: mtime, never an error');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('a shallow clone without history for a document falls back to its mtime', () => {
|
|
385
|
+
const source = makeRepo('shallow-source');
|
|
386
|
+
writeDoc(source, 'odd/tasks/aaa.md', '# aaa\n');
|
|
387
|
+
writeDoc(source, 'odd/tasks/zzz.md', '# zzz\n');
|
|
388
|
+
commitAll(source, '2026-01-01T00:00:00Z', 'both documents');
|
|
389
|
+
// The tip commit touches nothing under odd/tasks, so a depth-1 clone has
|
|
390
|
+
// no history for either document.
|
|
391
|
+
writeDoc(source, 'README.md', '# readme\n');
|
|
392
|
+
commitAll(source, '2026-02-01T00:00:00Z', 'unrelated');
|
|
393
|
+
|
|
394
|
+
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-shallow-'));
|
|
395
|
+
roots.push(parent);
|
|
396
|
+
const clone = path.join(parent, 'checkout');
|
|
397
|
+
git(parent, ['clone', '-q', '--depth', '1', `file://${source}`, clone]);
|
|
398
|
+
setMtime(clone, 'odd/tasks/aaa.md', '2026-05-01T00:00:00Z');
|
|
399
|
+
setMtime(clone, 'odd/tasks/zzz.md', '2026-04-01T00:00:00Z');
|
|
400
|
+
assert.deepEqual(names(listOddDocs(clone)), ['aaa', 'zzz'], 'no reachable history: mtime decides');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('reads every document\'s commit time in ONE bounded git call, never one per file', {
|
|
404
|
+
skip: process.platform === 'win32' ? 'POSIX stub script' : false,
|
|
405
|
+
}, () => {
|
|
406
|
+
const root = makeRepo('bounded');
|
|
407
|
+
const committed = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot'];
|
|
408
|
+
for (const [index, name] of committed.entries()) {
|
|
409
|
+
writeDoc(root, `odd/tasks/${name}.md`, `# ${name}\n`);
|
|
410
|
+
commitAll(root, `2026-0${index + 1}-01T00:00:00Z`, name);
|
|
411
|
+
}
|
|
412
|
+
const logFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-calls-')), 'calls.log');
|
|
413
|
+
roots.push(path.dirname(logFile));
|
|
414
|
+
|
|
415
|
+
const listing = listOddDocs(root, { gitBin: recordingGit('bounded', logFile) });
|
|
416
|
+
const calls = recordedCalls(logFile);
|
|
417
|
+
assert.ok(
|
|
418
|
+
calls.length <= 3,
|
|
419
|
+
`at most one HEAD read, one commit read and one local-edit read, saw ${calls.length}: ${calls.join(' | ')}`,
|
|
420
|
+
);
|
|
421
|
+
assert.equal(calls.filter((call) => /\slog -z /.test(`${call} `)).length, 1, 'exactly one commit-time call');
|
|
422
|
+
assert.match(calls.join('\n'), /odd\/tasks/, 'the call covers the odd directory, not each document');
|
|
423
|
+
assert.match(calls.join('\n'), /-n \d+/, 'the history walk is explicitly bounded');
|
|
424
|
+
assert.deepEqual(names(listing), committed.slice().reverse(), 'six documents still rank by their own commit');
|
|
425
|
+
|
|
426
|
+
// An empty listing costs no git process at all.
|
|
427
|
+
const emptyRepo = makeRepo('bounded-empty');
|
|
428
|
+
writeDoc(emptyRepo, 'README.md', '# readme\n');
|
|
429
|
+
commitAll(emptyRepo, '2026-01-01T00:00:00Z', 'init');
|
|
430
|
+
const emptyLog = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-calls-empty-')), 'calls.log');
|
|
431
|
+
roots.push(path.dirname(emptyLog));
|
|
432
|
+
assert.deepEqual(listOddDocs(emptyRepo, { gitBin: recordingGit('empty', emptyLog) }).docs, []);
|
|
433
|
+
assert.deepEqual(recordedCalls(emptyLog), [], 'no documents, no git process');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('documents committed together tie-break by name, deterministically', () => {
|
|
437
|
+
const root = makeRepo('determinism');
|
|
438
|
+
// One commit for all three: identical commit timestamps, so only the
|
|
439
|
+
// name tie-break can order them. Code-unit order, not locale order.
|
|
440
|
+
writeDoc(root, 'odd/tasks/Zulu.md', '# Zulu\n');
|
|
441
|
+
writeDoc(root, 'odd/tasks/alpha.md', '# alpha\n');
|
|
442
|
+
writeDoc(root, 'odd/tasks/bravo.md', '# bravo\n');
|
|
443
|
+
commitAll(root, '2026-03-01T00:00:00Z', 'all three');
|
|
444
|
+
|
|
445
|
+
const first = names(listOddDocs(root));
|
|
446
|
+
assert.deepEqual(first, ['Zulu', 'alpha', 'bravo'], 'code-unit name order: Z before a');
|
|
447
|
+
for (let round = 0; round < 4; round += 1) {
|
|
448
|
+
assert.deepEqual(names(listOddDocs(root)), first, 'the order is stable across listings');
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// S13 cost, without giving up correctness: the parsed commit-time map is
|
|
455
|
+
// memoized per repository and HEAD commit. The history walk is the expensive
|
|
456
|
+
// part (seconds on a slow/network checkout) and its answer is a pure function
|
|
457
|
+
// of the commits reachable from HEAD, so an unchanged HEAD cannot change it. The
|
|
458
|
+
// cheap local-edit read still runs on EVERY listing, so an uncommitted edit
|
|
459
|
+
// keeps promoting its document. A new HEAD (new commit), a failed git read, or
|
|
460
|
+
// an evicted entry must all re-derive the order instead of serving a stale one.
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
describe('commit-time memo (S13)', () => {
|
|
463
|
+
test('a repeated listing with an unchanged HEAD walks no history again', {
|
|
464
|
+
skip: process.platform === 'win32' ? 'POSIX stub script' : false,
|
|
465
|
+
}, () => {
|
|
466
|
+
const root = makeRepo('memo-repeat');
|
|
467
|
+
writeDoc(root, 'odd/tasks/aaa.md', '# aaa\n');
|
|
468
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'older document');
|
|
469
|
+
writeDoc(root, 'odd/tasks/zzz.md', '# zzz\n');
|
|
470
|
+
commitAll(root, '2026-06-01T00:00:00Z', 'newer document');
|
|
471
|
+
const logFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-memo-repeat-')), 'calls.log');
|
|
472
|
+
roots.push(path.dirname(logFile));
|
|
473
|
+
const bin = recordingGit('memo-repeat', logFile);
|
|
474
|
+
|
|
475
|
+
const first = names(listOddDocs(root, { gitBin: bin }));
|
|
476
|
+
assert.deepEqual(first, ['zzz', 'aaa'], 'the cold listing still ranks by commit');
|
|
477
|
+
assert.equal(countCalls(logFile, LOG_CALL), 1, 'the cold listing walks history once');
|
|
478
|
+
|
|
479
|
+
const second = names(listOddDocs(root, { gitBin: bin }));
|
|
480
|
+
assert.deepEqual(second, first, 'the same HEAD produces the same order');
|
|
481
|
+
assert.equal(countCalls(logFile, LOG_CALL), 1, 'the warm listing spawns NO second history walk');
|
|
482
|
+
assert.equal(countCalls(logFile, STATUS_CALL), 2, 'the cheap local-edit read still runs every listing');
|
|
483
|
+
assert.equal(countCalls(logFile, HEAD_CALL), 2, 'each listing resolves HEAD to key the memo');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('a new commit invalidates the memo and replaces it, never accumulating', {
|
|
487
|
+
skip: process.platform === 'win32' ? 'POSIX stub script' : false,
|
|
488
|
+
}, () => {
|
|
489
|
+
const root = makeRepo('memo-invalidate');
|
|
490
|
+
writeDoc(root, 'odd/tasks/aaa.md', '# aaa\n');
|
|
491
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'older document');
|
|
492
|
+
writeDoc(root, 'odd/tasks/zzz.md', '# zzz\n');
|
|
493
|
+
commitAll(root, '2026-06-01T00:00:00Z', 'newer document');
|
|
494
|
+
const logFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-memo-invalidate-')), 'calls.log');
|
|
495
|
+
roots.push(path.dirname(logFile));
|
|
496
|
+
const bin = recordingGit('memo-invalidate', logFile);
|
|
497
|
+
|
|
498
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['zzz', 'aaa']);
|
|
499
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['zzz', 'aaa'], 'memo is warm here');
|
|
500
|
+
assert.equal(countCalls(logFile, LOG_CALL), 1);
|
|
501
|
+
|
|
502
|
+
// A new commit moves HEAD, so the memo cannot answer for it any more.
|
|
503
|
+
writeDoc(root, 'odd/tasks/aaa.md', '# aaa\n\n## Specs\nS13: committed later\n');
|
|
504
|
+
commitAll(root, '2026-09-01T00:00:00Z', 'aaa committed later');
|
|
505
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['aaa', 'zzz'], 'the new commit reorders');
|
|
506
|
+
assert.equal(countCalls(logFile, LOG_CALL), 2, 'the new HEAD is walked exactly once');
|
|
507
|
+
|
|
508
|
+
// And the memo holds ONE entry for this checkout, keyed by the new HEAD.
|
|
509
|
+
const entries = commitCacheInfo().entries.filter((entry) => entry.root === root);
|
|
510
|
+
assert.equal(entries.length, 1, 'a new HEAD replaces the old entry instead of adding one');
|
|
511
|
+
assert.equal(entries[0].head, git(root, ['rev-parse', 'HEAD']), 'the entry is keyed by the current HEAD');
|
|
512
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['aaa', 'zzz']);
|
|
513
|
+
assert.equal(countCalls(logFile, LOG_CALL), 2, 'no further walk for the new HEAD');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('an uncommitted edit still reorders while the memo serves the commit times', {
|
|
517
|
+
skip: process.platform === 'win32' ? 'POSIX stub script' : false,
|
|
518
|
+
}, () => {
|
|
519
|
+
const root = makeRepo('memo-dirty');
|
|
520
|
+
writeDoc(root, 'odd/tasks/alpha.md', '# alpha\n');
|
|
521
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'alpha');
|
|
522
|
+
writeDoc(root, 'odd/tasks/beta.md', '# beta\n');
|
|
523
|
+
commitAll(root, '2026-06-01T00:00:00Z', 'beta');
|
|
524
|
+
const logFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-memo-dirty-')), 'calls.log');
|
|
525
|
+
roots.push(path.dirname(logFile));
|
|
526
|
+
const bin = recordingGit('memo-dirty', logFile);
|
|
527
|
+
|
|
528
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['beta', 'alpha'], 'clean baseline');
|
|
529
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['beta', 'alpha'], 'memo is warm here');
|
|
530
|
+
assert.equal(countCalls(logFile, LOG_CALL), 1);
|
|
531
|
+
|
|
532
|
+
// Dirty, uncommitted, HEAD unchanged: only the local-edit read can see it.
|
|
533
|
+
writeDoc(root, 'odd/tasks/alpha.md', '# alpha\n\nL35: edited, not committed\n');
|
|
534
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['alpha', 'beta'], 'the dirty edit is promoted');
|
|
535
|
+
assert.equal(countCalls(logFile, LOG_CALL), 1, 'no history walk was needed to notice the edit');
|
|
536
|
+
assert.equal(countCalls(logFile, STATUS_CALL), 3, 'the dirty check ran on every listing');
|
|
537
|
+
|
|
538
|
+
// A backdated mtime still never demotes it below its own commit.
|
|
539
|
+
setMtime(root, 'odd/tasks/alpha.md', '2020-01-01T00:00:00Z');
|
|
540
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['beta', 'alpha']);
|
|
541
|
+
assert.equal(countCalls(logFile, LOG_CALL), 1);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('a git failure falls back to mtime instead of serving the memo', {
|
|
545
|
+
skip: process.platform === 'win32' ? 'POSIX stub script' : false,
|
|
546
|
+
}, () => {
|
|
547
|
+
const root = makeRepo('memo-fallback');
|
|
548
|
+
writeDoc(root, 'odd/tasks/aaa.md', '# aaa\n');
|
|
549
|
+
commitAll(root, '2026-06-01T00:00:00Z', 'newer commit, older file');
|
|
550
|
+
writeDoc(root, 'odd/tasks/zzz.md', '# zzz\n');
|
|
551
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'older commit, newer file');
|
|
552
|
+
// mtime order is the OPPOSITE of commit order, so any fallback is visible.
|
|
553
|
+
setMtime(root, 'odd/tasks/aaa.md', '2024-01-01T00:00:00Z');
|
|
554
|
+
setMtime(root, 'odd/tasks/zzz.md', '2025-01-01T00:00:00Z');
|
|
555
|
+
|
|
556
|
+
assert.deepEqual(names(listOddDocs(root)), ['aaa', 'zzz'], 'sanity: the memo now holds the commit order');
|
|
557
|
+
|
|
558
|
+
const failing = stubGit('memo-failing', '#!/bin/sh\nexit 128\n');
|
|
559
|
+
assert.deepEqual(
|
|
560
|
+
names(listOddDocs(root, { gitBin: failing })),
|
|
561
|
+
['zzz', 'aaa'],
|
|
562
|
+
'git failing: mtime order, never the memoized commit order',
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// A HEAD that cannot be resolved is not a memo key: no entry is invented
|
|
566
|
+
// for it, and the real entry is untouched.
|
|
567
|
+
const entries = commitCacheInfo().entries.filter((entry) => entry.root === root);
|
|
568
|
+
assert.equal(entries.length, 1, 'the failed read adds no entry');
|
|
569
|
+
assert.equal(entries[0].head, git(root, ['rev-parse', 'HEAD']));
|
|
570
|
+
|
|
571
|
+
// Garbage on the HEAD read is a cache miss, not a stale hit.
|
|
572
|
+
const garbage = stubGit('memo-garbage', "#!/bin/sh\nprintf 'not a commit id\\n'\n");
|
|
573
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: garbage })), ['zzz', 'aaa'], 'garbage: mtime, never a stale order');
|
|
574
|
+
assert.equal(
|
|
575
|
+
commitCacheInfo().entries.filter((entry) => entry.root === root).length,
|
|
576
|
+
1,
|
|
577
|
+
'a garbage HEAD adds no entry',
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test('the memo is bounded, so many checkouts cannot grow it without limit', {
|
|
582
|
+
skip: process.platform === 'win32' ? 'POSIX stub script' : false,
|
|
583
|
+
}, () => {
|
|
584
|
+
const listing = [];
|
|
585
|
+
for (let index = 0; index <= COMMIT_CACHE_LIMIT; index += 1) {
|
|
586
|
+
const root = makeRepo(`memo-bound-${index}`);
|
|
587
|
+
writeDoc(root, 'odd/tasks/only.md', `# only ${index}\n`);
|
|
588
|
+
commitAll(root, '2026-01-01T00:00:00Z', `only ${index}`);
|
|
589
|
+
const logFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), `odd-docs-bound-${index}-`)), 'calls.log');
|
|
590
|
+
roots.push(path.dirname(logFile));
|
|
591
|
+
const bin = recordingGit(`memo-bound-${index}`, logFile);
|
|
592
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['only']);
|
|
593
|
+
listing.push({ root, logFile, bin });
|
|
594
|
+
}
|
|
595
|
+
const info = commitCacheInfo();
|
|
596
|
+
assert.ok(info.size <= info.limit, `memo size ${info.size} stays within its bound ${info.limit}`);
|
|
597
|
+
assert.equal(info.limit, COMMIT_CACHE_LIMIT);
|
|
598
|
+
|
|
599
|
+
// One more checkout than the bound was listed, so the oldest entry is gone:
|
|
600
|
+
// listing it again re-derives the order instead of serving a dropped entry.
|
|
601
|
+
const oldest = listing[0];
|
|
602
|
+
assert.deepEqual(names(listOddDocs(oldest.root, { gitBin: oldest.bin })), ['only']);
|
|
603
|
+
assert.equal(countCalls(oldest.logFile, LOG_CALL), 2, 'an evicted checkout is walked again');
|
|
604
|
+
|
|
605
|
+
// The newest one is still remembered.
|
|
606
|
+
const newest = listing[listing.length - 1];
|
|
607
|
+
assert.deepEqual(names(listOddDocs(newest.root, { gitBin: newest.bin })), ['only']);
|
|
608
|
+
assert.equal(countCalls(newest.logFile, LOG_CALL), 1, 'a remembered checkout is not walked again');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('the memo holds commit times only, never document contents', () => {
|
|
612
|
+
const root = makeRepo('memo-contents');
|
|
613
|
+
writeDoc(root, 'odd/tasks/alpha.md', '# alpha\n\nL35: private text that must not be cached\n');
|
|
614
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'alpha');
|
|
615
|
+
assert.deepEqual(names(listOddDocs(root)), ['alpha'], 'the listing populates the memo');
|
|
616
|
+
|
|
617
|
+
const info = commitCacheInfo();
|
|
618
|
+
assert.deepEqual(Object.keys(info).sort(), ['entries', 'limit', 'size'], 'the diagnostic is read-only and small');
|
|
619
|
+
const entry = info.entries.find((candidate) => candidate.root === root);
|
|
620
|
+
assert.ok(entry, 'the listed checkout is memoized');
|
|
621
|
+
assert.equal(entry.head, git(root, ['rev-parse', 'HEAD']));
|
|
622
|
+
assert.deepEqual(entry.times.map(([file]) => file), ['alpha.md'], 'the memo keys are file names');
|
|
623
|
+
for (const [file, ms] of entry.times) {
|
|
624
|
+
assert.equal(typeof file, 'string');
|
|
625
|
+
assert.equal(typeof ms, 'number');
|
|
626
|
+
assert.ok(Number.isFinite(ms), 'every memoized value is a commit time in ms');
|
|
627
|
+
}
|
|
628
|
+
assert.ok(
|
|
629
|
+
!JSON.stringify(info.entries).includes('private text'),
|
|
630
|
+
'no document content reaches the memo',
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// Interactive listing (S21 «se traba»): the viewer is an INTERACTIVE path, so
|
|
637
|
+
// the listing has to answer without freezing the host event loop on Git, and
|
|
638
|
+
// the first frame must not wait for a history walk at all. Two additions carry
|
|
639
|
+
// that, and both must agree with the synchronous listing document for
|
|
640
|
+
// document:
|
|
641
|
+
//
|
|
642
|
+
// listOddDocsLocal(root) — the cheap scan alone: `odd/tasks` on disk ordered
|
|
643
|
+
// by the documented mtime fallback, with no Git
|
|
644
|
+
// process of any kind. The first frame paints from
|
|
645
|
+
// this, and it decides whether there is anything to
|
|
646
|
+
// read at all.
|
|
647
|
+
// listOddDocsAsync(root) — the same listing `listOddDocs` returns, with every
|
|
648
|
+
// Git read awaited off the loop instead of spawned
|
|
649
|
+
// synchronously. Same rows, same order, same
|
|
650
|
+
// degradations: an answer git cannot give leaves the
|
|
651
|
+
// mtime fallback exactly as it does synchronously.
|
|
652
|
+
//
|
|
653
|
+
// A slow Git is simulated deterministically through the injected async runner
|
|
654
|
+
// (the package convention `(bin, argv, cwd) => Promise<{status,stdout,stderr}>`)
|
|
655
|
+
// and through a real recording git binary, never by mocking the module.
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
describe('interactive listing (S21)', () => {
|
|
658
|
+
// The package's async runner convention, with a deterministic extra delay per
|
|
659
|
+
// Git call, delegating the actual read to the real binary so the output is
|
|
660
|
+
// the same one a host would see.
|
|
661
|
+
function delayedRunner(delayMs) {
|
|
662
|
+
return async (bin, args, cwd) => {
|
|
663
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
664
|
+
return spawnSync(bin, args, { cwd, encoding: 'utf8', env: GIT_ENV });
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function cloneWithConflictingSignals(tag) {
|
|
669
|
+
const source = makeRepo(`${tag}-source`);
|
|
670
|
+
writeDoc(source, 'odd/tasks/aaa.md', '# aaa\n');
|
|
671
|
+
commitAll(source, '2026-01-01T00:00:00Z', 'older document');
|
|
672
|
+
writeDoc(source, 'odd/tasks/zzz.md', '# zzz\n');
|
|
673
|
+
commitAll(source, '2026-06-01T00:00:00Z', 'newer document');
|
|
674
|
+
const parent = fs.mkdtempSync(path.join(os.tmpdir(), `odd-docs-${tag}-clone-`));
|
|
675
|
+
roots.push(parent);
|
|
676
|
+
const clone = path.join(parent, 'checkout');
|
|
677
|
+
git(parent, ['clone', '-q', source, clone]);
|
|
678
|
+
// mtime contradicts the commit dates: `aaa` is the newer file on disk.
|
|
679
|
+
setMtime(clone, 'odd/tasks/aaa.md', '2026-09-01T00:00:00Z');
|
|
680
|
+
setMtime(clone, 'odd/tasks/zzz.md', '2026-07-01T00:00:00Z');
|
|
681
|
+
return clone;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
test('the local scan lists every document by mtime, reading no Git signal at all', () => {
|
|
685
|
+
const clone = cloneWithConflictingSignals('local');
|
|
686
|
+
const local = listOddDocsLocal(clone);
|
|
687
|
+
assert.equal(local.ok, true);
|
|
688
|
+
assert.equal(local.root, clone);
|
|
689
|
+
assert.deepEqual(
|
|
690
|
+
local.docs.map((doc) => doc.name),
|
|
691
|
+
['aaa', 'zzz'],
|
|
692
|
+
'the cheap scan is the mtime fallback: had it read git, the commit dates would decide',
|
|
693
|
+
);
|
|
694
|
+
assert.deepEqual(
|
|
695
|
+
local.docs.map((doc) => doc.name),
|
|
696
|
+
listOddDocs(clone).docs.map((doc) => doc.name).reverse(),
|
|
697
|
+
'and it is the same document set the full listing returns, in mtime order',
|
|
698
|
+
);
|
|
699
|
+
assert.deepEqual(Object.keys(local.docs[0]), ['name', 'file', 'path'], 'the row shape is unchanged');
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test('the local scan still fails closed and still treats a missing odd/tasks as empty', () => {
|
|
703
|
+
assert.equal(listOddDocsLocal('').ok, false);
|
|
704
|
+
assert.equal(listOddDocsLocal(null).ok, false);
|
|
705
|
+
assert.equal(listOddDocsLocal(path.join(os.tmpdir(), `odd-docs-local-missing-${process.pid}`)).ok, false);
|
|
706
|
+
const empty = fixture({ 'README.md': '# readme\n' });
|
|
707
|
+
assert.deepEqual(listOddDocsLocal(empty), { ok: true, root: empty, docs: [] });
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test('the async listing resolves the same documents in the same order as the synchronous one', async () => {
|
|
711
|
+
const root = makeRepo('async-same');
|
|
712
|
+
writeDoc(root, 'odd/tasks/alpha.md', '# alpha\n');
|
|
713
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'alpha');
|
|
714
|
+
writeDoc(root, 'odd/tasks/bravo.md', '# bravo\n');
|
|
715
|
+
commitAll(root, '2026-05-01T00:00:00Z', 'bravo');
|
|
716
|
+
|
|
717
|
+
const asyncListing = await listOddDocsAsync(root);
|
|
718
|
+
assert.equal(asyncListing.ok, true);
|
|
719
|
+
assert.equal(asyncListing.root, root);
|
|
720
|
+
assert.deepEqual(asyncListing.docs.map((doc) => doc.name), ['bravo', 'alpha'], 'commit recency, newest first');
|
|
721
|
+
assert.deepEqual(
|
|
722
|
+
asyncListing.docs,
|
|
723
|
+
listOddDocs(root).docs,
|
|
724
|
+
'both readers describe the same checkout identically, row for row',
|
|
725
|
+
);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test('the async listing leaves the host event loop free while Git answers', async () => {
|
|
729
|
+
const root = makeRepo('async-free');
|
|
730
|
+
writeDoc(root, 'odd/tasks/alpha.md', '# alpha\n');
|
|
731
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'alpha');
|
|
732
|
+
|
|
733
|
+
let settled = false;
|
|
734
|
+
let ticked = false;
|
|
735
|
+
const pending = listOddDocsAsync(root, { runAsync: delayedRunner(60) });
|
|
736
|
+
assert.equal(typeof pending.then, 'function', 'the interactive reader hands back a promise');
|
|
737
|
+
pending.then(() => { settled = true; });
|
|
738
|
+
setTimeout(() => { ticked = true; }, 10);
|
|
739
|
+
// One macrotask after the call, the loop is already turning while every
|
|
740
|
+
// Git read is still sleeping: nothing on this path is `spawnSync`.
|
|
741
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
742
|
+
assert.equal(ticked, true, 'the host got a turn while git was still answering');
|
|
743
|
+
assert.equal(settled, false, 'the listing had not answered yet, so the turn came from the wait being off-loop');
|
|
744
|
+
const listing = await pending;
|
|
745
|
+
assert.deepEqual(listing.docs.map((doc) => doc.name), ['alpha']);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test('a git that cannot answer degrades to mtime instead of failing the listing', async () => {
|
|
749
|
+
const root = makeRepo('async-degrade');
|
|
750
|
+
writeDoc(root, 'odd/tasks/aaa.md', '# aaa\n');
|
|
751
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'aaa');
|
|
752
|
+
writeDoc(root, 'odd/tasks/zzz.md', '# zzz\n');
|
|
753
|
+
commitAll(root, '2026-06-01T00:00:00Z', 'zzz');
|
|
754
|
+
setMtime(root, 'odd/tasks/aaa.md', '2026-09-01T00:00:00Z');
|
|
755
|
+
setMtime(root, 'odd/tasks/zzz.md', '2026-07-01T00:00:00Z');
|
|
756
|
+
|
|
757
|
+
const failing = async () => ({ status: 1, stdout: '', stderr: 'fatal: no history' });
|
|
758
|
+
const listing = await listOddDocsAsync(root, { runAsync: failing });
|
|
759
|
+
assert.equal(listing.ok, true, 'recency never fails the listing');
|
|
760
|
+
assert.deepEqual(names(listing), ['aaa', 'zzz'], 'the mtime fallback ranks the documents');
|
|
761
|
+
assert.deepEqual(names(listing), names(listOddDocsLocal(root)), 'identical to the cheap scan');
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('an empty checkout and a missing root cost no Git process at all', async () => {
|
|
765
|
+
const calls = [];
|
|
766
|
+
const counting = async (bin, args, cwd) => {
|
|
767
|
+
calls.push({ bin, args, cwd });
|
|
768
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
769
|
+
};
|
|
770
|
+
const empty = fixture({ 'README.md': '# readme\n' });
|
|
771
|
+
assert.deepEqual(await listOddDocsAsync(empty, { runAsync: counting }), { ok: true, root: empty, docs: [] });
|
|
772
|
+
assert.deepEqual(calls, [], 'no documents, no git process');
|
|
773
|
+
const missing = path.join(os.tmpdir(), `odd-docs-async-missing-${process.pid}`);
|
|
774
|
+
assert.equal((await listOddDocsAsync(missing, { runAsync: counting })).ok, false);
|
|
775
|
+
assert.deepEqual(calls, [], 'a root that is not a checkout never reaches git');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('the async listing shares the commit-time memo, so a slow walk is paid once', {
|
|
779
|
+
skip: process.platform === 'win32' ? 'POSIX stub script' : false,
|
|
780
|
+
}, async () => {
|
|
781
|
+
const root = makeRepo('async-memo');
|
|
782
|
+
writeDoc(root, 'odd/tasks/aaa.md', '# aaa\n');
|
|
783
|
+
commitAll(root, '2026-01-01T00:00:00Z', 'older');
|
|
784
|
+
writeDoc(root, 'odd/tasks/zzz.md', '# zzz\n');
|
|
785
|
+
commitAll(root, '2026-06-01T00:00:00Z', 'newer');
|
|
786
|
+
const logFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-docs-async-memo-')), 'calls.log');
|
|
787
|
+
roots.push(path.dirname(logFile));
|
|
788
|
+
const bin = recordingGit('async-memo', logFile);
|
|
789
|
+
|
|
790
|
+
// The synchronous listing paid the walk through the recording binary.
|
|
791
|
+
assert.deepEqual(names(listOddDocs(root, { gitBin: bin })), ['zzz', 'aaa']);
|
|
792
|
+
assert.equal(countCalls(logFile, LOG_CALL), 1, 'the premise: one history walk happened');
|
|
793
|
+
|
|
794
|
+
// The async twin resolves the identical order and, on an unchanged HEAD,
|
|
795
|
+
// walks no history again — it reaches the SAME memo through the default
|
|
796
|
+
// async runner, so neither reader serves an order it did not derive.
|
|
797
|
+
const listing = await listOddDocsAsync(root, { gitBin: bin });
|
|
798
|
+
assert.deepEqual(names(listing), ['zzz', 'aaa'], 'the async reader returns the git-ranked order');
|
|
799
|
+
assert.equal(countCalls(logFile, LOG_CALL), 1, 'an unchanged HEAD is not walked a second time');
|
|
800
|
+
assert.equal(countCalls(logFile, HEAD_CALL), 2, 'it still re-reads HEAD to know the memo is current');
|
|
801
|
+
assert.equal(countCalls(logFile, STATUS_CALL), 2, 'and it still re-reads the local-edit signal');
|
|
802
|
+
});
|
|
803
|
+
});
|