shadow-git 1.0.3 → 1.1.1
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/CLAUDE.md +5 -1
- package/README.md +13 -0
- package/dist/cli.js +498 -75
- package/package.json +1 -1
- package/src/cli.ts +69 -5
- package/src/commands/forward.ts +3 -3
- package/src/commands/track/add.ts +6 -4
- package/src/commands/track/clone.ts +16 -1
- package/src/commands/track/destroy.ts +5 -0
- package/src/commands/track/ignore.ts +2 -2
- package/src/commands/track/index.ts +12 -0
- package/src/commands/track/init.ts +5 -1
- package/src/commands/track/remove.ts +3 -2
- package/src/commands/track/swap.ts +61 -0
- package/src/commands/track/sync.ts +9 -6
- package/src/commands/track/unswap.ts +49 -0
- package/src/commands/track/verify.ts +11 -9
- package/src/core/errors.ts +31 -1
- package/src/core/exclude.ts +2 -2
- package/src/core/gitignore.ts +18 -3
- package/src/core/repository.ts +8 -0
- package/src/core/swap-mode.ts +83 -0
- package/src/core/update-check.ts +214 -0
- package/src/utils/logger.ts +20 -0
- package/tests/commands/forward.test.ts +6 -6
- package/tests/commands/track/add.test.ts +6 -6
- package/tests/commands/track/swap.test.ts +266 -0
- package/tests/commands/track/verify.test.ts +16 -16
- package/tests/core/exclude.test.ts +10 -10
- package/tests/core/gitignore.test.ts +30 -30
- package/tests/core/update-check.test.ts +494 -0
- package/tests/helpers/guard-child.mjs +2 -2
- package/.claude/settings.local.json +0 -43
- package/.temp/.sgit/COMMIT_EDITMSG +0 -1
- package/.temp/.sgit/HEAD +0 -1
- package/.temp/.sgit/config +0 -8
- package/.temp/.sgit/description +0 -1
- package/.temp/.sgit/hooks/applypatch-msg.sample +0 -15
- package/.temp/.sgit/hooks/commit-msg.sample +0 -24
- package/.temp/.sgit/hooks/fsmonitor-watchman.sample +0 -174
- package/.temp/.sgit/hooks/post-update.sample +0 -8
- package/.temp/.sgit/hooks/pre-applypatch.sample +0 -14
- package/.temp/.sgit/hooks/pre-commit +0 -6
- package/.temp/.sgit/hooks/pre-commit.sample +0 -49
- package/.temp/.sgit/hooks/pre-merge-commit.sample +0 -13
- package/.temp/.sgit/hooks/pre-push.sample +0 -53
- package/.temp/.sgit/hooks/pre-rebase.sample +0 -169
- package/.temp/.sgit/hooks/pre-receive.sample +0 -24
- package/.temp/.sgit/hooks/prepare-commit-msg.sample +0 -42
- package/.temp/.sgit/hooks/push-to-checkout.sample +0 -78
- package/.temp/.sgit/hooks/sendemail-validate.sample +0 -77
- package/.temp/.sgit/hooks/update.sample +0 -128
- package/.temp/.sgit/index +0 -0
- package/.temp/.sgit/info/exclude +0 -20
- package/.temp/.sgit/logs/HEAD +0 -1
- package/.temp/.sgit/logs/refs/heads/master +0 -1
- package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
- package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
- package/.temp/.sgit/objects/34/b3190a8a836593fb568a56c073a416a56fbf6d +0 -0
- package/.temp/.sgit/objects/70/ad6bbbfefd330d72a43dc2a810e6ef42269911 +0 -0
- package/.temp/.sgit/objects/90/4e09c7aadaa32d2426ab5464a975d5151ef195 +0 -0
- package/.temp/.sgit/objects/a4/6358feef49515d5f85d1d3a64e929182da7149 +0 -3
- package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
- package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
- package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
- package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
- package/.temp/.sgit/refs/heads/master +0 -1
- package/.temp/.sgit.json +0 -10
- package/.temp/README.md +0 -0
- package/.temp/docs/guide.md +0 -10
- package/.temp/lib/__init__.py +0 -1
- package/.temp/lib/utils.py +0 -5
- package/.temp/rec1/rec2/test +0 -0
- package/.temp/src/config.py +0 -3
- package/.temp/src/main.py +0 -5
- package/.temp/temp/shouldnt-track +0 -0
- package/.temp/temp.md +0 -3
- package/.temp/test/1 +0 -0
- package/.temp/tests/test_utils.py +0 -7
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
beforeEach,
|
|
6
|
+
afterEach,
|
|
7
|
+
vi,
|
|
8
|
+
type MockInstance,
|
|
9
|
+
} from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
utimesSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { homedir, tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
|
|
23
|
+
vi.mock('node:child_process', async (importOriginal) => ({
|
|
24
|
+
...(await importOriginal<typeof import('node:child_process')>()),
|
|
25
|
+
spawn: spawnMock,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
UPDATE_CHECK_ARG,
|
|
30
|
+
getCacheDir,
|
|
31
|
+
isNewerVersion,
|
|
32
|
+
runUpdateCheckWorker,
|
|
33
|
+
setupUpdateNotifier,
|
|
34
|
+
} from '../../src/core/update-check';
|
|
35
|
+
|
|
36
|
+
const HOUR = 60 * 60 * 1000;
|
|
37
|
+
const ENTRY = '/opt/shadow-git/dist/cli.js';
|
|
38
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/-/package/shadow-git/dist-tags';
|
|
39
|
+
const HANDLED_ENV = 'SGIT_UPDATE_NOTICE_HANDLED';
|
|
40
|
+
|
|
41
|
+
describe('isNewerVersion', () => {
|
|
42
|
+
it('detects newer major, minor and patch versions', () => {
|
|
43
|
+
expect(isNewerVersion('1.1.1', '1.1.0')).toBe(true);
|
|
44
|
+
expect(isNewerVersion('1.2.0', '1.1.9')).toBe(true);
|
|
45
|
+
expect(isNewerVersion('2.0.0', '1.9.9')).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('compares numerically, not alphabetically', () => {
|
|
49
|
+
expect(isNewerVersion('1.10.0', '1.9.0')).toBe(true);
|
|
50
|
+
expect(isNewerVersion('1.9.0', '1.10.0')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('is false for the same version', () => {
|
|
54
|
+
expect(isNewerVersion('1.1.0', '1.1.0')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('is false when the installed version is ahead of the registry', () => {
|
|
58
|
+
expect(isNewerVersion('1.0.3', '1.1.0')).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('treats a release as newer than its own prerelease', () => {
|
|
62
|
+
expect(isNewerVersion('1.2.0', '1.2.0-beta.1')).toBe(true);
|
|
63
|
+
expect(isNewerVersion('1.2.0-beta.1', '1.2.0')).toBe(false);
|
|
64
|
+
expect(isNewerVersion('1.2.0-beta.1', '1.1.0')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('is false for anything that is not a version', () => {
|
|
68
|
+
expect(isNewerVersion('latest', '1.1.0')).toBe(false);
|
|
69
|
+
expect(isNewerVersion('1.2', '1.1.0')).toBe(false);
|
|
70
|
+
expect(isNewerVersion('1.2.0\n', '1.1.0')).toBe(false);
|
|
71
|
+
expect(isNewerVersion('9.9.9\x1b[31m', '1.1.0')).toBe(false);
|
|
72
|
+
expect(isNewerVersion('1.2.0', 'unknown')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('update check', () => {
|
|
77
|
+
let cacheHome: string;
|
|
78
|
+
let dir: string;
|
|
79
|
+
let stderrSpy: MockInstance<typeof process.stderr.write>;
|
|
80
|
+
let child: { on: ReturnType<typeof vi.fn>; unref: ReturnType<typeof vi.fn> };
|
|
81
|
+
let exitListenersBefore: Function[];
|
|
82
|
+
const origStdoutTTY = process.stdout.isTTY;
|
|
83
|
+
const origStderrTTY = process.stderr.isTTY;
|
|
84
|
+
|
|
85
|
+
const statePath = () => join(dir, 'update-check.json');
|
|
86
|
+
const lockPath = () => join(dir, 'update-check.lock');
|
|
87
|
+
|
|
88
|
+
function writeState(state: object): void {
|
|
89
|
+
mkdirSync(dir, { recursive: true });
|
|
90
|
+
writeFileSync(statePath(), JSON.stringify(state));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readState(): { checkedAt: number; latest: string } {
|
|
94
|
+
return JSON.parse(readFileSync(statePath(), 'utf-8'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// One user-typed sgit invocation. The marker an earlier invocation left in
|
|
98
|
+
// this process's environment is cleared — a real one starts without it.
|
|
99
|
+
function invoke(args: string[] = ['status']): void {
|
|
100
|
+
process.env[HANDLED_ENV] = '';
|
|
101
|
+
setupUpdateNotifier('1.1.0', ENTRY, args);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ends the invocation: runs the 'exit' listeners it registered, once
|
|
105
|
+
function fireExit(): void {
|
|
106
|
+
for (const listener of process.listeners('exit')) {
|
|
107
|
+
if (!exitListenersBefore.includes(listener)) {
|
|
108
|
+
process.removeListener('exit', listener as () => void);
|
|
109
|
+
(listener as (code: number) => void)(0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function printed(): string {
|
|
115
|
+
return stderrSpy.mock.calls.map((call) => String(call[0])).join('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
cacheHome = mkdtempSync(join(tmpdir(), 'sgit-update-'));
|
|
120
|
+
dir = join(cacheHome, 'shadow-git');
|
|
121
|
+
vi.stubEnv('XDG_CACHE_HOME', cacheHome);
|
|
122
|
+
vi.stubEnv('NO_UPDATE_NOTIFIER', '');
|
|
123
|
+
vi.stubEnv(HANDLED_ENV, '');
|
|
124
|
+
process.stdout.isTTY = true;
|
|
125
|
+
process.stderr.isTTY = true;
|
|
126
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
127
|
+
child = { on: vi.fn(), unref: vi.fn() };
|
|
128
|
+
spawnMock.mockReset().mockReturnValue(child);
|
|
129
|
+
exitListenersBefore = process.listeners('exit');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
for (const listener of process.listeners('exit')) {
|
|
134
|
+
if (!exitListenersBefore.includes(listener)) {
|
|
135
|
+
process.removeListener('exit', listener as () => void);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
process.stdout.isTTY = origStdoutTTY;
|
|
139
|
+
process.stderr.isTTY = origStderrTTY;
|
|
140
|
+
vi.restoreAllMocks();
|
|
141
|
+
vi.unstubAllEnvs();
|
|
142
|
+
vi.unstubAllGlobals();
|
|
143
|
+
rmSync(cacheHome, { recursive: true, force: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------
|
|
147
|
+
// getCacheDir
|
|
148
|
+
// ---------------------------------------------------------------
|
|
149
|
+
describe('getCacheDir', () => {
|
|
150
|
+
it('uses XDG_CACHE_HOME when set', () => {
|
|
151
|
+
expect(getCacheDir()).toBe(join(cacheHome, 'shadow-git'));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('falls back to ~/.cache when XDG_CACHE_HOME is unset or relative', () => {
|
|
155
|
+
const fallback = join(homedir(), '.cache', 'shadow-git');
|
|
156
|
+
vi.stubEnv('XDG_CACHE_HOME', '');
|
|
157
|
+
expect(getCacheDir()).toBe(fallback);
|
|
158
|
+
vi.stubEnv('XDG_CACHE_HOME', 'relative/cache');
|
|
159
|
+
expect(getCacheDir()).toBe(fallback);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------
|
|
164
|
+
// setupUpdateNotifier — when the registry check runs
|
|
165
|
+
// ---------------------------------------------------------------
|
|
166
|
+
describe('scheduling the check', () => {
|
|
167
|
+
it('starts a detached check on the very first run', () => {
|
|
168
|
+
invoke();
|
|
169
|
+
|
|
170
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
171
|
+
const [command, args, options] = spawnMock.mock.calls[0];
|
|
172
|
+
expect(command).toBe(process.execPath);
|
|
173
|
+
expect(args).toEqual([ENTRY, UPDATE_CHECK_ARG]);
|
|
174
|
+
expect(options).toMatchObject({ detached: true, stdio: 'ignore' });
|
|
175
|
+
expect(child.unref).toHaveBeenCalledOnce();
|
|
176
|
+
expect(existsSync(lockPath())).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not check again within a day of a successful check', () => {
|
|
180
|
+
writeState({ checkedAt: Date.now() - 23 * HOUR, latest: '1.1.0' });
|
|
181
|
+
invoke();
|
|
182
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('checks again once a day has passed', () => {
|
|
186
|
+
writeState({ checkedAt: Date.now() - 25 * HOUR, latest: '1.1.0' });
|
|
187
|
+
invoke();
|
|
188
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('checks again when the last check is dated in the future', () => {
|
|
192
|
+
writeState({ checkedAt: Date.now() + 48 * HOUR, latest: '1.1.0' });
|
|
193
|
+
invoke();
|
|
194
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('treats a corrupt state file as never checked', () => {
|
|
198
|
+
mkdirSync(dir, { recursive: true });
|
|
199
|
+
writeFileSync(statePath(), '{ not json');
|
|
200
|
+
invoke();
|
|
201
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does not start a second check while one is in flight', () => {
|
|
205
|
+
invoke();
|
|
206
|
+
invoke();
|
|
207
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('takes over a lock left behind by a worker that died', () => {
|
|
211
|
+
mkdirSync(dir, { recursive: true });
|
|
212
|
+
writeFileSync(lockPath(), '12345');
|
|
213
|
+
const twoMinutesAgo = (Date.now() - 2 * 60 * 1000) / 1000;
|
|
214
|
+
utimesSync(lockPath(), twoMinutesAgo, twoMinutesAgo);
|
|
215
|
+
|
|
216
|
+
invoke();
|
|
217
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('releases the lock when the worker cannot be spawned', () => {
|
|
221
|
+
spawnMock.mockImplementation(() => {
|
|
222
|
+
throw new Error('spawn EAGAIN');
|
|
223
|
+
});
|
|
224
|
+
expect(() => invoke()).not.toThrow();
|
|
225
|
+
expect(existsSync(lockPath())).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('releases the lock when the worker fails to start asynchronously', () => {
|
|
229
|
+
invoke();
|
|
230
|
+
expect(existsSync(lockPath())).toBe(true);
|
|
231
|
+
|
|
232
|
+
const [event, onError] = child.on.mock.calls[0];
|
|
233
|
+
expect(event).toBe('error');
|
|
234
|
+
onError(new Error('spawn ENOENT'));
|
|
235
|
+
expect(existsSync(lockPath())).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('stays out of the way when the cache directory cannot be created', () => {
|
|
239
|
+
const file = join(cacheHome, 'not-a-directory');
|
|
240
|
+
writeFileSync(file, '');
|
|
241
|
+
vi.stubEnv('XDG_CACHE_HOME', file);
|
|
242
|
+
|
|
243
|
+
expect(() => invoke()).not.toThrow();
|
|
244
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
245
|
+
expect(() => fireExit()).not.toThrow();
|
|
246
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------
|
|
251
|
+
// setupUpdateNotifier — when it does nothing at all
|
|
252
|
+
// ---------------------------------------------------------------
|
|
253
|
+
describe('disabled', () => {
|
|
254
|
+
function expectUntouched(): void {
|
|
255
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
256
|
+
expect(process.listeners('exit')).toEqual(exitListenersBefore);
|
|
257
|
+
expect(existsSync(dir)).toBe(false);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
it('does nothing when stdout is not a terminal', () => {
|
|
261
|
+
process.stdout.isTTY = false;
|
|
262
|
+
invoke();
|
|
263
|
+
expectUntouched();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('does nothing when stderr is not a terminal', () => {
|
|
267
|
+
process.stderr.isTTY = false;
|
|
268
|
+
invoke();
|
|
269
|
+
expectUntouched();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('does nothing when NO_UPDATE_NOTIFIER is set', () => {
|
|
273
|
+
vi.stubEnv('NO_UPDATE_NOTIFIER', '1');
|
|
274
|
+
invoke();
|
|
275
|
+
expectUntouched();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('does nothing for the hook-driven track verify', () => {
|
|
279
|
+
invoke(['track', 'verify', '--main']);
|
|
280
|
+
expectUntouched();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('prints no notice even when an update is known', () => {
|
|
284
|
+
writeState({ checkedAt: Date.now(), latest: '1.2.0' });
|
|
285
|
+
process.stdout.isTTY = false;
|
|
286
|
+
invoke();
|
|
287
|
+
fireExit();
|
|
288
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------
|
|
293
|
+
// setupUpdateNotifier — sgit started underneath another sgit (git hooks)
|
|
294
|
+
// ---------------------------------------------------------------
|
|
295
|
+
describe('nested invocations', () => {
|
|
296
|
+
it('leaves the check and the notice to the outermost sgit', () => {
|
|
297
|
+
writeState({ checkedAt: Date.now() - 25 * HOUR, latest: '1.2.0' });
|
|
298
|
+
invoke();
|
|
299
|
+
spawnMock.mockClear();
|
|
300
|
+
const outerListeners = process.listeners('exit');
|
|
301
|
+
|
|
302
|
+
// What a hook-run `sgit track sync` does, inheriting the environment
|
|
303
|
+
setupUpdateNotifier('1.1.0', ENTRY, ['track', 'sync']);
|
|
304
|
+
|
|
305
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
306
|
+
expect(process.listeners('exit')).toEqual(outerListeners);
|
|
307
|
+
fireExit();
|
|
308
|
+
expect(stderrSpy).toHaveBeenCalledOnce();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('keeps nested invocations quiet even when the outer one is not interactive', () => {
|
|
312
|
+
writeState({ checkedAt: Date.now(), latest: '1.2.0' });
|
|
313
|
+
process.stdout.isTTY = false;
|
|
314
|
+
invoke();
|
|
315
|
+
process.stdout.isTTY = true;
|
|
316
|
+
|
|
317
|
+
setupUpdateNotifier('1.1.0', ENTRY, ['track', 'sync']);
|
|
318
|
+
|
|
319
|
+
fireExit();
|
|
320
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------
|
|
325
|
+
// setupUpdateNotifier — the notice printed when the command ends
|
|
326
|
+
// ---------------------------------------------------------------
|
|
327
|
+
describe('notice', () => {
|
|
328
|
+
it('is printed on exit when a newer version is known', () => {
|
|
329
|
+
writeState({ checkedAt: Date.now(), latest: '1.2.0' });
|
|
330
|
+
invoke();
|
|
331
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
332
|
+
|
|
333
|
+
fireExit();
|
|
334
|
+
|
|
335
|
+
expect(printed()).toContain('Update available: shadow-git 1.1.0 → 1.2.0');
|
|
336
|
+
expect(printed()).toContain("Please run 'npm install -g shadow-git' to update.");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('is printed on every run for as long as the update is available', () => {
|
|
340
|
+
writeState({ checkedAt: Date.now(), latest: '1.2.0' });
|
|
341
|
+
for (let run = 1; run <= 3; run++) {
|
|
342
|
+
invoke();
|
|
343
|
+
fireExit();
|
|
344
|
+
expect(stderrSpy).toHaveBeenCalledTimes(run);
|
|
345
|
+
}
|
|
346
|
+
// The daily check is a separate matter: no run started one
|
|
347
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('does not touch the recorded check when printing', () => {
|
|
351
|
+
const state = { checkedAt: Date.now() - 2 * HOUR, latest: '1.2.0' };
|
|
352
|
+
writeState(state);
|
|
353
|
+
invoke();
|
|
354
|
+
fireExit();
|
|
355
|
+
expect(readState()).toEqual(state);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('is still printed from the last known result while a new check is pending', () => {
|
|
359
|
+
writeState({ checkedAt: Date.now() - 25 * HOUR, latest: '1.2.0' });
|
|
360
|
+
invoke();
|
|
361
|
+
fireExit();
|
|
362
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
363
|
+
expect(printed()).toContain('Update available: shadow-git 1.1.0 → 1.2.0');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('picks up a result the worker wrote while the command was running', () => {
|
|
367
|
+
invoke();
|
|
368
|
+
writeState({ checkedAt: Date.now(), latest: '1.2.0' });
|
|
369
|
+
fireExit();
|
|
370
|
+
expect(printed()).toContain('Update available: shadow-git 1.1.0 → 1.2.0');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('is not printed when already up to date', () => {
|
|
374
|
+
writeState({ checkedAt: Date.now(), latest: '1.1.0' });
|
|
375
|
+
invoke();
|
|
376
|
+
fireExit();
|
|
377
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('is not printed when the installed version is ahead of the registry', () => {
|
|
381
|
+
writeState({ checkedAt: Date.now(), latest: '1.0.3' });
|
|
382
|
+
invoke();
|
|
383
|
+
fireExit();
|
|
384
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('is not printed before any check has completed', () => {
|
|
388
|
+
invoke();
|
|
389
|
+
fireExit();
|
|
390
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('never throws out of the exit listener', () => {
|
|
394
|
+
writeState({ checkedAt: Date.now(), latest: '1.2.0' });
|
|
395
|
+
invoke();
|
|
396
|
+
stderrSpy.mockImplementation(() => {
|
|
397
|
+
throw new Error('write EIO');
|
|
398
|
+
});
|
|
399
|
+
expect(() => fireExit()).not.toThrow();
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------
|
|
404
|
+
// runUpdateCheckWorker — the detached process
|
|
405
|
+
// ---------------------------------------------------------------
|
|
406
|
+
describe('runUpdateCheckWorker', () => {
|
|
407
|
+
function stubFetch(impl: () => Promise<Response>): ReturnType<typeof vi.fn> {
|
|
408
|
+
const fetchMock = vi.fn(impl);
|
|
409
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
410
|
+
return fetchMock;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const registryAnswers = (latest: string) => async () =>
|
|
414
|
+
new Response(JSON.stringify({ latest }), { status: 200 });
|
|
415
|
+
|
|
416
|
+
beforeEach(() => {
|
|
417
|
+
// The parent creates the directory and takes the lock before spawning
|
|
418
|
+
mkdirSync(dir, { recursive: true });
|
|
419
|
+
writeFileSync(lockPath(), '12345');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('records the latest version reported by the npm registry', async () => {
|
|
423
|
+
const fetchMock = stubFetch(registryAnswers('1.2.0'));
|
|
424
|
+
const before = Date.now();
|
|
425
|
+
|
|
426
|
+
await runUpdateCheckWorker();
|
|
427
|
+
|
|
428
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
429
|
+
expect(fetchMock.mock.calls[0][0]).toBe(REGISTRY_URL);
|
|
430
|
+
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
|
|
431
|
+
const state = readState();
|
|
432
|
+
expect(Object.keys(state).sort()).toEqual(['checkedAt', 'latest']);
|
|
433
|
+
expect(state.latest).toBe('1.2.0');
|
|
434
|
+
expect(state.checkedAt).toBeGreaterThanOrEqual(before);
|
|
435
|
+
expect(existsSync(lockPath())).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Every way the check can fail must look the same: no error, no output,
|
|
439
|
+
// nothing recorded (so the next invocation tries again), lock released.
|
|
440
|
+
const failures: Record<string, () => Promise<Response>> = {
|
|
441
|
+
'there is no internet connection': async () => {
|
|
442
|
+
throw new TypeError('fetch failed');
|
|
443
|
+
},
|
|
444
|
+
'the request times out': async () => {
|
|
445
|
+
throw new DOMException('The operation timed out.', 'TimeoutError');
|
|
446
|
+
},
|
|
447
|
+
'the registry is down': async () =>
|
|
448
|
+
new Response('Service Unavailable', { status: 503 }),
|
|
449
|
+
'the registry answers with an error status': async () =>
|
|
450
|
+
new Response(JSON.stringify({ latest: '9.9.9' }), { status: 500 }),
|
|
451
|
+
'the answer is not JSON': async () =>
|
|
452
|
+
new Response('<html>captive portal</html>', { status: 200 }),
|
|
453
|
+
'the answer has no latest version': async () =>
|
|
454
|
+
new Response(JSON.stringify({ next: '2.0.0-rc.1' }), { status: 200 }),
|
|
455
|
+
'the answer is JSON null': async () => new Response('null', { status: 200 }),
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
for (const [name, impl] of Object.entries(failures)) {
|
|
459
|
+
it(`stays silent and records nothing when ${name}`, async () => {
|
|
460
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
461
|
+
stubFetch(impl);
|
|
462
|
+
|
|
463
|
+
await expect(runUpdateCheckWorker()).resolves.toBeUndefined();
|
|
464
|
+
|
|
465
|
+
expect(existsSync(statePath())).toBe(false);
|
|
466
|
+
expect(existsSync(lockPath())).toBe(false);
|
|
467
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
|
468
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
it('leaves the previous result untouched when the check fails', async () => {
|
|
473
|
+
const previous = { checkedAt: Date.now() - 25 * HOUR, latest: '1.1.0' };
|
|
474
|
+
writeState(previous);
|
|
475
|
+
stubFetch(async () => {
|
|
476
|
+
throw new TypeError('fetch failed');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
await runUpdateCheckWorker();
|
|
480
|
+
|
|
481
|
+
expect(readState()).toEqual(previous);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('checks again on the next invocation after a failed check', async () => {
|
|
485
|
+
stubFetch(async () => {
|
|
486
|
+
throw new TypeError('fetch failed');
|
|
487
|
+
});
|
|
488
|
+
await runUpdateCheckWorker();
|
|
489
|
+
|
|
490
|
+
invoke();
|
|
491
|
+
expect(spawnMock).toHaveBeenCalledOnce();
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* This script directly implements the guard's cleanup mechanism using plain
|
|
7
7
|
* node:fs so it can run without TypeScript transpilation. It renames
|
|
8
|
-
* .gitignore → .gitignore-
|
|
8
|
+
* .gitignore → .gitignore.sgit-parked, registers signal/exit handlers, then
|
|
9
9
|
* exits in the specified way.
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
@@ -20,7 +20,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
20
20
|
|
|
21
21
|
const [,, root, exitMode] = process.argv;
|
|
22
22
|
const manifestPaths = ['secret/'];
|
|
23
|
-
const DISABLED_NAME = '.gitignore-
|
|
23
|
+
const DISABLED_NAME = '.gitignore.sgit-parked';
|
|
24
24
|
|
|
25
25
|
// --- Minimal guard implementation (mirrors src/core/gitignore.ts) ---
|
|
26
26
|
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(git config:*)",
|
|
5
|
-
"Bash(echo \"exit: $?\")",
|
|
6
|
-
"Bash(npm install:*)",
|
|
7
|
-
"Bash(npm run:*)",
|
|
8
|
-
"Bash(node:*)",
|
|
9
|
-
"Bash(git add:*)",
|
|
10
|
-
"Bash(git commit:*)",
|
|
11
|
-
"Bash(npx vitest:*)",
|
|
12
|
-
"Read(//tmp/sgit-manual-test/**)",
|
|
13
|
-
"Bash(.git/hooks/pre-commit)",
|
|
14
|
-
"Bash(echo \"HOOK EXIT: $?\")",
|
|
15
|
-
"Bash(bash -x .git/hooks/pre-commit)",
|
|
16
|
-
"Bash(npx tsc:*)",
|
|
17
|
-
"Bash(README.md:*)",
|
|
18
|
-
"Bash(src/main.py:*)",
|
|
19
|
-
"Bash(lib/utils.py:*)",
|
|
20
|
-
"Bash(lib/__init__.py:*)",
|
|
21
|
-
"Bash(tests/test_utils.py:*)",
|
|
22
|
-
"Bash(docs/guide.md:*)",
|
|
23
|
-
"Bash(src/config.py:*)",
|
|
24
|
-
"Bash(sgit --help)",
|
|
25
|
-
"Bash(sgit track:*)",
|
|
26
|
-
"Bash(/home/hayk/Projects/shadow-git/.temp/temp.md:*)",
|
|
27
|
-
"Bash(git check-ignore:*)",
|
|
28
|
-
"Bash(node -e \":*)",
|
|
29
|
-
"Bash(echo:*)",
|
|
30
|
-
"Read(//home/hayk/**)",
|
|
31
|
-
"Read(//home/hayk/.config/fish/**)",
|
|
32
|
-
"Bash(bash --version)",
|
|
33
|
-
"Bash(complete -p)",
|
|
34
|
-
"Bash(pkg-config --variable=completionsdir bash-completion)",
|
|
35
|
-
"Bash(dpkg -l bash-completion)",
|
|
36
|
-
"Bash(git diff:*)",
|
|
37
|
-
"Bash(git --git-dir=.sgit --work-tree=. diff --cached --name-only)",
|
|
38
|
-
"Bash(git --git-dir=.sgit --work-tree=. reset HEAD temp/shouldnt-track)",
|
|
39
|
-
"Bash(git --git-dir=.sgit --work-tree=. add .)",
|
|
40
|
-
"Bash(git stash:*)"
|
|
41
|
-
]
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
sgit: initialize shadow repository
|
package/.temp/.sgit/HEAD
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
ref: refs/heads/master
|
package/.temp/.sgit/config
DELETED
package/.temp/.sgit/description
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Unnamed repository; edit this file 'description' to name the repository.
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
#
|
|
3
|
-
# An example hook script to check the commit log message taken by
|
|
4
|
-
# applypatch from an e-mail message.
|
|
5
|
-
#
|
|
6
|
-
# The hook should exit with non-zero status after issuing an
|
|
7
|
-
# appropriate message if it wants to stop the commit. The hook is
|
|
8
|
-
# allowed to edit the commit message file.
|
|
9
|
-
#
|
|
10
|
-
# To enable this hook, rename this file to "applypatch-msg".
|
|
11
|
-
|
|
12
|
-
. git-sh-setup
|
|
13
|
-
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
|
|
14
|
-
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
|
|
15
|
-
:
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
#
|
|
3
|
-
# An example hook script to check the commit log message.
|
|
4
|
-
# Called by "git commit" with one argument, the name of the file
|
|
5
|
-
# that has the commit message. The hook should exit with non-zero
|
|
6
|
-
# status after issuing an appropriate message if it wants to stop the
|
|
7
|
-
# commit. The hook is allowed to edit the commit message file.
|
|
8
|
-
#
|
|
9
|
-
# To enable this hook, rename this file to "commit-msg".
|
|
10
|
-
|
|
11
|
-
# Uncomment the below to add a Signed-off-by line to the message.
|
|
12
|
-
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
|
|
13
|
-
# hook is more suited to it.
|
|
14
|
-
#
|
|
15
|
-
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
|
16
|
-
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
|
|
17
|
-
|
|
18
|
-
# This example catches duplicate Signed-off-by lines.
|
|
19
|
-
|
|
20
|
-
test "" = "$(grep '^Signed-off-by: ' "$1" |
|
|
21
|
-
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
|
|
22
|
-
echo >&2 Duplicate Signed-off-by lines.
|
|
23
|
-
exit 1
|
|
24
|
-
}
|