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,83 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { constants, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
SwapModeCorruptError,
|
|
6
|
+
InProgressGitOpError,
|
|
7
|
+
} from './errors.js';
|
|
8
|
+
|
|
9
|
+
export const SWAP_MARKER = '.git.sgit-parked';
|
|
10
|
+
|
|
11
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
12
|
+
try {
|
|
13
|
+
await access(path, constants.F_OK);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function isSwapMode(root: string): Promise<boolean> {
|
|
21
|
+
return pathExists(join(root, SWAP_MARKER));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isSwapModeSync(root: string): boolean {
|
|
25
|
+
return existsSync(join(root, SWAP_MARKER));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveShadowDir(root: string): Promise<string> {
|
|
29
|
+
return (await isSwapMode(root))
|
|
30
|
+
? join(root, '.git')
|
|
31
|
+
: join(root, '.sgit');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function resolveMainDir(root: string): Promise<string> {
|
|
35
|
+
return (await isSwapMode(root))
|
|
36
|
+
? join(root, SWAP_MARKER)
|
|
37
|
+
: join(root, '.git');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const IN_PROGRESS_MARKERS = [
|
|
41
|
+
'index.lock',
|
|
42
|
+
'MERGE_HEAD',
|
|
43
|
+
'CHERRY_PICK_HEAD',
|
|
44
|
+
'REVERT_HEAD',
|
|
45
|
+
'rebase-apply',
|
|
46
|
+
'rebase-merge',
|
|
47
|
+
'BISECT_LOG',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export async function assertNoInProgressOps(gitDir: string): Promise<void> {
|
|
51
|
+
for (const marker of IN_PROGRESS_MARKERS) {
|
|
52
|
+
if (await pathExists(join(gitDir, marker))) {
|
|
53
|
+
throw new InProgressGitOpError(gitDir, marker);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function assertConsistentState(root: string): Promise<void> {
|
|
59
|
+
const hasDisabled = await pathExists(join(root, SWAP_MARKER));
|
|
60
|
+
const hasGit = await pathExists(join(root, '.git'));
|
|
61
|
+
const hasSgit = await pathExists(join(root, '.sgit'));
|
|
62
|
+
|
|
63
|
+
if (hasDisabled && !hasGit) {
|
|
64
|
+
throw new SwapModeCorruptError(
|
|
65
|
+
[
|
|
66
|
+
`found ${SWAP_MARKER}/ but no .git/ — a swap-mode transition was interrupted.`,
|
|
67
|
+
hasSgit
|
|
68
|
+
? `to recover: rename ${SWAP_MARKER} back to .git (.sgit is still intact).`
|
|
69
|
+
: `to recover: rename ${SWAP_MARKER} back to .git, or rename whichever of .sgit/${SWAP_MARKER} you want active to .git.`,
|
|
70
|
+
].join('\n'),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (hasDisabled && hasSgit) {
|
|
75
|
+
throw new SwapModeCorruptError(
|
|
76
|
+
[
|
|
77
|
+
`found both ${SWAP_MARKER}/ and .sgit/ — unexpected state.`,
|
|
78
|
+
'normally only one exists at a time.',
|
|
79
|
+
`investigate and remove the stale one before proceeding.`,
|
|
80
|
+
].join('\n'),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
statSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { isAbsolute, join } from 'node:path';
|
|
11
|
+
import * as logger from '../utils/logger.js';
|
|
12
|
+
|
|
13
|
+
const PACKAGE_NAME = 'shadow-git';
|
|
14
|
+
const DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
17
|
+
// A lock older than this was left behind by a worker that died mid-check
|
|
18
|
+
const LOCK_STALE_MS = 60_000;
|
|
19
|
+
const STATE_FILE = 'update-check.json';
|
|
20
|
+
const LOCK_FILE = 'update-check.lock';
|
|
21
|
+
|
|
22
|
+
// x.y.z with an optional prerelease and build suffix
|
|
23
|
+
const VERSION_RE =
|
|
24
|
+
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
25
|
+
|
|
26
|
+
// First CLI argument of the detached process that asks the registry
|
|
27
|
+
export const UPDATE_CHECK_ARG = '__update-check';
|
|
28
|
+
|
|
29
|
+
// Tells sgit processes started underneath this one (git hooks run
|
|
30
|
+
// `sgit track sync`) that the outermost invocation takes care of the notice
|
|
31
|
+
const HANDLED_ENV = 'SGIT_UPDATE_NOTICE_HANDLED';
|
|
32
|
+
|
|
33
|
+
interface UpdateState {
|
|
34
|
+
checkedAt: number; // last time the registry answered (ms since epoch)
|
|
35
|
+
latest: string; // version behind the `latest` dist-tag at that time
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Public API ---
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Called once per CLI invocation. Hands the daily registry check to a detached
|
|
42
|
+
* process, so the command itself never waits on the network, and prints the
|
|
43
|
+
* update notice when the command ends — on every run for as long as a newer
|
|
44
|
+
* version is known.
|
|
45
|
+
*/
|
|
46
|
+
export function setupUpdateNotifier(
|
|
47
|
+
currentVersion: string,
|
|
48
|
+
entry: string,
|
|
49
|
+
args: string[],
|
|
50
|
+
): void {
|
|
51
|
+
const nested = Boolean(process.env[HANDLED_ENV]);
|
|
52
|
+
process.env[HANDLED_ENV] = '1';
|
|
53
|
+
if (nested || !isEnabled(args)) return;
|
|
54
|
+
try {
|
|
55
|
+
const dir = getCacheDir();
|
|
56
|
+
if (isCheckDue(readState(dir)) && acquireLock(dir)) {
|
|
57
|
+
spawnWorker(dir, entry);
|
|
58
|
+
}
|
|
59
|
+
process.on('exit', () => showNotice(dir, currentVersion));
|
|
60
|
+
} catch {
|
|
61
|
+
// The update check must never get in the way of the actual command
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Body of the detached process: asks the npm registry for the latest version
|
|
67
|
+
* and records the answer. Any failure (offline, registry down, timeout) is
|
|
68
|
+
* silent and records nothing, so the next invocation simply checks again.
|
|
69
|
+
*/
|
|
70
|
+
export async function runUpdateCheckWorker(): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
const dir = getCacheDir();
|
|
73
|
+
try {
|
|
74
|
+
const latest = await fetchLatestVersion();
|
|
75
|
+
if (latest !== undefined) {
|
|
76
|
+
writeState(dir, { checkedAt: Date.now(), latest });
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
releaseLock(dir);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// stay silent
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isNewerVersion(latest: string, current: string): boolean {
|
|
87
|
+
const a = VERSION_RE.exec(latest);
|
|
88
|
+
const b = VERSION_RE.exec(current);
|
|
89
|
+
if (!a || !b) return false;
|
|
90
|
+
|
|
91
|
+
for (let i = 1; i <= 3; i++) {
|
|
92
|
+
const diff = Number(a[i]) - Number(b[i]);
|
|
93
|
+
if (diff !== 0) return diff > 0;
|
|
94
|
+
}
|
|
95
|
+
// Same x.y.z: a release is newer than its own prerelease. Two prereleases
|
|
96
|
+
// of the same version are not ordered — `latest` is never a prerelease.
|
|
97
|
+
return a[4] === undefined && b[4] !== undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getCacheDir(): string {
|
|
101
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
102
|
+
const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), '.cache');
|
|
103
|
+
return join(base, PACKAGE_NAME);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Internal helpers ---
|
|
107
|
+
|
|
108
|
+
function isEnabled(args: string[]): boolean {
|
|
109
|
+
// Only when a person is at the terminal — pipes and scripts stay untouched
|
|
110
|
+
if (!process.stdout.isTTY || !process.stderr.isTTY) return false;
|
|
111
|
+
if (process.env.NO_UPDATE_NOTIFIER) return false;
|
|
112
|
+
// `track verify` is run by the pre-commit hooks, not typed by the user
|
|
113
|
+
if (args[0] === 'track' && args[1] === 'verify') return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* True when no check has succeeded yet, the last one is at least a day old,
|
|
119
|
+
* or it is dated in the future (the clock was changed).
|
|
120
|
+
*/
|
|
121
|
+
function isCheckDue(state: UpdateState | undefined): boolean {
|
|
122
|
+
if (!state) return true;
|
|
123
|
+
const age = Date.now() - state.checkedAt;
|
|
124
|
+
return age < 0 || age >= DAY_MS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function showNotice(dir: string, currentVersion: string): void {
|
|
128
|
+
try {
|
|
129
|
+
// Read again — the worker may have finished while the command was running
|
|
130
|
+
const latest = readState(dir)?.latest;
|
|
131
|
+
if (latest !== undefined && isNewerVersion(latest, currentVersion)) {
|
|
132
|
+
logger.updateNotice(currentVersion, latest);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Must not throw: an 'exit' listener that throws keeps the ones after it
|
|
136
|
+
// from running, and the gitignore guard restores .gitignore files in one
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function spawnWorker(dir: string, entry: string): void {
|
|
141
|
+
try {
|
|
142
|
+
const child = spawn(process.execPath, [entry, UPDATE_CHECK_ARG], {
|
|
143
|
+
cwd: dir, // not the user's repo — a lingering cwd can block deleting it
|
|
144
|
+
detached: true,
|
|
145
|
+
stdio: 'ignore',
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
});
|
|
148
|
+
child.on('error', () => releaseLock(dir));
|
|
149
|
+
child.unref();
|
|
150
|
+
} catch {
|
|
151
|
+
releaseLock(dir);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function fetchLatestVersion(): Promise<string | undefined> {
|
|
156
|
+
const res = await fetch(DIST_TAGS_URL, {
|
|
157
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) return undefined;
|
|
160
|
+
|
|
161
|
+
const tags = (await res.json()) as { latest?: unknown } | null;
|
|
162
|
+
return typeof tags?.latest === 'string' ? tags.latest : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readState(dir: string): UpdateState | undefined {
|
|
166
|
+
try {
|
|
167
|
+
const data = JSON.parse(
|
|
168
|
+
readFileSync(join(dir, STATE_FILE), 'utf-8'),
|
|
169
|
+
) as Partial<UpdateState> | null;
|
|
170
|
+
if (typeof data?.checkedAt !== 'number' || typeof data.latest !== 'string') {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
return { checkedAt: data.checkedAt, latest: data.latest };
|
|
174
|
+
} catch {
|
|
175
|
+
// Missing or corrupt — same as never having checked
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function writeState(dir: string, state: UpdateState): void {
|
|
181
|
+
writeFileSync(join(dir, STATE_FILE), JSON.stringify(state, null, 2) + '\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The lock keeps a burst of invocations from each starting their own worker.
|
|
186
|
+
* It is held from spawn until the worker exits; the worker removes it whether
|
|
187
|
+
* the check succeeded or not.
|
|
188
|
+
*/
|
|
189
|
+
function acquireLock(dir: string): boolean {
|
|
190
|
+
const lockPath = join(dir, LOCK_FILE);
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(dir, { recursive: true });
|
|
193
|
+
writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
|
194
|
+
return true;
|
|
195
|
+
} catch {
|
|
196
|
+
// Already locked, or the cache directory is not writable
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const age = Math.abs(Date.now() - statSync(lockPath).mtimeMs);
|
|
200
|
+
if (age < LOCK_STALE_MS) return false;
|
|
201
|
+
writeFileSync(lockPath, String(process.pid)); // take over the stale lock
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function releaseLock(dir: string): void {
|
|
209
|
+
try {
|
|
210
|
+
unlinkSync(join(dir, LOCK_FILE));
|
|
211
|
+
} catch {
|
|
212
|
+
// already released
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -19,3 +19,23 @@ export function warn(msg: string): void {
|
|
|
19
19
|
export function error(msg: string): void {
|
|
20
20
|
process.stderr.write(`${RED}${msg}${RESET}\n`);
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
export function swapModeBanner(): void {
|
|
24
|
+
const msg = [
|
|
25
|
+
'────────────────────────────────────────────────────────────',
|
|
26
|
+
'shadow-git is in SWAP MODE',
|
|
27
|
+
'main git is parked at .git.sgit-parked; .sgit is active at .git',
|
|
28
|
+
'this mode is for IDE/tool compatibility — read-only recommended',
|
|
29
|
+
"run 'sgit track unswap' before making main-repo changes",
|
|
30
|
+
'────────────────────────────────────────────────────────────',
|
|
31
|
+
].join('\n');
|
|
32
|
+
process.stderr.write(`${YELLOW}${msg}${RESET}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function updateNotice(current: string, latest: string): void {
|
|
36
|
+
const msg = [
|
|
37
|
+
`Update available: shadow-git ${current} → ${latest}`,
|
|
38
|
+
"Please run 'npm install -g shadow-git' to update.",
|
|
39
|
+
].join('\n');
|
|
40
|
+
process.stderr.write(`\n${CYAN}${msg}${RESET}\n`);
|
|
41
|
+
}
|
|
@@ -37,19 +37,19 @@ describe('forwardToGit', () => {
|
|
|
37
37
|
await removeTempDir(tempDir);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
it('forwards git commands to shadow repo and returns exit code', () => {
|
|
41
|
-
const exitCode = forwardToGit(tempDir, ['log', '--oneline']);
|
|
40
|
+
it('forwards git commands to shadow repo and returns exit code', async () => {
|
|
41
|
+
const exitCode = await forwardToGit(tempDir, ['log', '--oneline']);
|
|
42
42
|
expect(exitCode).toBe(0);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it('returns non-zero exit code for failing commands', () => {
|
|
46
|
-
const exitCode = forwardToGit(tempDir, ['log', '--invalid-flag-xyz']);
|
|
45
|
+
it('returns non-zero exit code for failing commands', async () => {
|
|
46
|
+
const exitCode = await forwardToGit(tempDir, ['log', '--invalid-flag-xyz']);
|
|
47
47
|
expect(exitCode).not.toBe(0);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it('uses .sgit as git-dir', () => {
|
|
50
|
+
it('uses .sgit as git-dir', async () => {
|
|
51
51
|
// rev-parse --git-dir should show the .sgit path
|
|
52
|
-
const exitCode = forwardToGit(tempDir, ['rev-parse', '--git-dir']);
|
|
52
|
+
const exitCode = await forwardToGit(tempDir, ['rev-parse', '--git-dir']);
|
|
53
53
|
expect(exitCode).toBe(0);
|
|
54
54
|
});
|
|
55
55
|
});
|
|
@@ -150,17 +150,17 @@ describe('track add', () => {
|
|
|
150
150
|
);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
-
it('rejects .gitignore-
|
|
154
|
-
await writeFile(join(tempDir, '.gitignore-
|
|
155
|
-
await expect(trackAdd(['.gitignore-
|
|
153
|
+
it('rejects .gitignore.sgit-parked files', async () => {
|
|
154
|
+
await writeFile(join(tempDir, '.gitignore.sgit-parked'), 'stale\n');
|
|
155
|
+
await expect(trackAdd(['.gitignore.sgit-parked'])).rejects.toThrow(
|
|
156
156
|
'.gitignore files are managed by sgit',
|
|
157
157
|
);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
it('rejects nested .gitignore-
|
|
160
|
+
it('rejects nested .gitignore.sgit-parked files', async () => {
|
|
161
161
|
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
162
|
-
await writeFile(join(tempDir, 'docs', '.gitignore-
|
|
163
|
-
await expect(trackAdd(['docs/.gitignore-
|
|
162
|
+
await writeFile(join(tempDir, 'docs', '.gitignore.sgit-parked'), 'stale\n');
|
|
163
|
+
await expect(trackAdd(['docs/.gitignore.sgit-parked'])).rejects.toThrow(
|
|
164
164
|
'.gitignore files are managed by sgit',
|
|
165
165
|
);
|
|
166
166
|
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
writeFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
unlink,
|
|
7
|
+
rename,
|
|
8
|
+
readFile,
|
|
9
|
+
} from 'node:fs/promises';
|
|
10
|
+
import { constants } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { trackInit } from '../../../src/commands/track/init';
|
|
13
|
+
import { trackAdd } from '../../../src/commands/track/add';
|
|
14
|
+
import { trackList } from '../../../src/commands/track/list';
|
|
15
|
+
import { trackSync } from '../../../src/commands/track/sync';
|
|
16
|
+
import { trackClone } from '../../../src/commands/track/clone';
|
|
17
|
+
import { trackDestroy } from '../../../src/commands/track/destroy';
|
|
18
|
+
import { trackSwap } from '../../../src/commands/track/swap';
|
|
19
|
+
import { trackUnswap } from '../../../src/commands/track/unswap';
|
|
20
|
+
import { verifyShadow, verifyMain } from '../../../src/commands/track/verify';
|
|
21
|
+
import {
|
|
22
|
+
disableGitignores,
|
|
23
|
+
enableGitignores,
|
|
24
|
+
setupGitignoreGuard,
|
|
25
|
+
} from '../../../src/core/gitignore';
|
|
26
|
+
import {
|
|
27
|
+
isSwapMode,
|
|
28
|
+
resolveShadowDir,
|
|
29
|
+
resolveMainDir,
|
|
30
|
+
assertConsistentState,
|
|
31
|
+
SWAP_MARKER,
|
|
32
|
+
} from '../../../src/core/swap-mode';
|
|
33
|
+
import { swapModeBanner } from '../../../src/utils/logger';
|
|
34
|
+
import {
|
|
35
|
+
AlreadySwappedError,
|
|
36
|
+
NotSwappedError,
|
|
37
|
+
SwapModeActiveError,
|
|
38
|
+
SwapModeCorruptError,
|
|
39
|
+
InProgressGitOpError,
|
|
40
|
+
} from '../../../src/core/errors';
|
|
41
|
+
import { execGit } from '../../../src/core/git';
|
|
42
|
+
import { createTempGitRepo, removeTempDir } from '../../helpers';
|
|
43
|
+
|
|
44
|
+
async function exists(path: string): Promise<boolean> {
|
|
45
|
+
try {
|
|
46
|
+
await access(path, constants.F_OK);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('track swap / unswap', () => {
|
|
54
|
+
let tempDir: string;
|
|
55
|
+
const originalCwd = process.cwd();
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
tempDir = await createTempGitRepo();
|
|
59
|
+
process.chdir(tempDir);
|
|
60
|
+
await trackInit();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
process.chdir(originalCwd);
|
|
65
|
+
await removeTempDir(tempDir);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('swaps .git and .sgit on swap, restores on unswap', async () => {
|
|
69
|
+
await writeFile(join(tempDir, 'note.md'), 'hello');
|
|
70
|
+
await trackAdd(['note.md']);
|
|
71
|
+
|
|
72
|
+
expect(await exists(join(tempDir, '.git'))).toBe(true);
|
|
73
|
+
expect(await exists(join(tempDir, '.sgit'))).toBe(true);
|
|
74
|
+
expect(await exists(join(tempDir, SWAP_MARKER))).toBe(false);
|
|
75
|
+
|
|
76
|
+
await trackSwap();
|
|
77
|
+
|
|
78
|
+
expect(await isSwapMode(tempDir)).toBe(true);
|
|
79
|
+
expect(await exists(join(tempDir, '.git'))).toBe(true);
|
|
80
|
+
expect(await exists(join(tempDir, '.sgit'))).toBe(false);
|
|
81
|
+
expect(await exists(join(tempDir, SWAP_MARKER))).toBe(true);
|
|
82
|
+
|
|
83
|
+
// .git is now the shadow — should have .sgit.json committed
|
|
84
|
+
const lsFiles = await execGit(['ls-files'], {
|
|
85
|
+
gitDir: join(tempDir, '.git'),
|
|
86
|
+
workTree: tempDir,
|
|
87
|
+
});
|
|
88
|
+
expect(lsFiles).toContain('.sgit.json');
|
|
89
|
+
|
|
90
|
+
await trackUnswap();
|
|
91
|
+
|
|
92
|
+
expect(await isSwapMode(tempDir)).toBe(false);
|
|
93
|
+
expect(await exists(join(tempDir, '.git'))).toBe(true);
|
|
94
|
+
expect(await exists(join(tempDir, '.sgit'))).toBe(true);
|
|
95
|
+
expect(await exists(join(tempDir, SWAP_MARKER))).toBe(false);
|
|
96
|
+
|
|
97
|
+
// .git is restored to main — log should show 'initial' from createTempGitRepo
|
|
98
|
+
const mainLog = await execGit(['log', '--oneline'], {
|
|
99
|
+
gitDir: join(tempDir, '.git'),
|
|
100
|
+
workTree: tempDir,
|
|
101
|
+
});
|
|
102
|
+
expect(mainLog).toContain('initial');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('disables gitignores inside tracked dirs while swapped', async () => {
|
|
106
|
+
await mkdir(join(tempDir, 'notes'), { recursive: true });
|
|
107
|
+
await writeFile(join(tempDir, 'notes', 'todo.md'), 'x');
|
|
108
|
+
await writeFile(join(tempDir, 'notes', '.gitignore'), '*.tmp\n');
|
|
109
|
+
await trackAdd(['notes/']);
|
|
110
|
+
|
|
111
|
+
await trackSwap();
|
|
112
|
+
|
|
113
|
+
expect(await exists(join(tempDir, 'notes', '.gitignore'))).toBe(false);
|
|
114
|
+
expect(
|
|
115
|
+
await exists(join(tempDir, 'notes', '.gitignore.sgit-parked')),
|
|
116
|
+
).toBe(true);
|
|
117
|
+
|
|
118
|
+
await trackUnswap();
|
|
119
|
+
|
|
120
|
+
expect(await exists(join(tempDir, 'notes', '.gitignore'))).toBe(true);
|
|
121
|
+
expect(
|
|
122
|
+
await exists(join(tempDir, 'notes', '.gitignore.sgit-parked')),
|
|
123
|
+
).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('resolves shadow and main dirs correctly in both modes', async () => {
|
|
127
|
+
expect(await resolveShadowDir(tempDir)).toBe(join(tempDir, '.sgit'));
|
|
128
|
+
expect(await resolveMainDir(tempDir)).toBe(join(tempDir, '.git'));
|
|
129
|
+
|
|
130
|
+
await trackSwap();
|
|
131
|
+
|
|
132
|
+
expect(await resolveShadowDir(tempDir)).toBe(join(tempDir, '.git'));
|
|
133
|
+
expect(await resolveMainDir(tempDir)).toBe(join(tempDir, SWAP_MARKER));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('allows other track commands while swapped', async () => {
|
|
137
|
+
await writeFile(join(tempDir, 'note.md'), 'hi');
|
|
138
|
+
await trackAdd(['note.md']);
|
|
139
|
+
|
|
140
|
+
await trackSwap();
|
|
141
|
+
|
|
142
|
+
// list
|
|
143
|
+
const paths = await trackList();
|
|
144
|
+
expect(paths).toContain('note.md');
|
|
145
|
+
|
|
146
|
+
// add a new file
|
|
147
|
+
await writeFile(join(tempDir, 'note2.md'), 'bye');
|
|
148
|
+
await trackAdd(['note2.md']);
|
|
149
|
+
expect(await trackList()).toContain('note2.md');
|
|
150
|
+
|
|
151
|
+
// sync regenerates excludes at the swapped locations
|
|
152
|
+
await trackSync();
|
|
153
|
+
const shadowExclude = await readFile(
|
|
154
|
+
join(tempDir, '.git', 'info', 'exclude'),
|
|
155
|
+
'utf-8',
|
|
156
|
+
);
|
|
157
|
+
expect(shadowExclude).toContain('!note.md');
|
|
158
|
+
expect(shadowExclude).toContain('!note2.md');
|
|
159
|
+
|
|
160
|
+
const mainExclude = await readFile(
|
|
161
|
+
join(tempDir, SWAP_MARKER, 'info', 'exclude'),
|
|
162
|
+
'utf-8',
|
|
163
|
+
);
|
|
164
|
+
expect(mainExclude).toContain('note.md');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('swapModeBanner writes yellow banner to stderr', () => {
|
|
168
|
+
const writes: string[] = [];
|
|
169
|
+
const spy = vi
|
|
170
|
+
.spyOn(process.stderr, 'write')
|
|
171
|
+
.mockImplementation((chunk: unknown) => {
|
|
172
|
+
writes.push(String(chunk));
|
|
173
|
+
return true;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
swapModeBanner();
|
|
177
|
+
|
|
178
|
+
spy.mockRestore();
|
|
179
|
+
|
|
180
|
+
const output = writes.join('');
|
|
181
|
+
expect(output).toContain('SWAP MODE');
|
|
182
|
+
expect(output).toContain('\x1b[33m'); // yellow
|
|
183
|
+
expect(output).toContain('\x1b[0m'); // reset
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('errors on double-enter and double-exit', async () => {
|
|
187
|
+
await trackSwap();
|
|
188
|
+
await expect(trackSwap()).rejects.toBeInstanceOf(AlreadySwappedError);
|
|
189
|
+
|
|
190
|
+
await trackUnswap();
|
|
191
|
+
await expect(trackUnswap()).rejects.toBeInstanceOf(NotSwappedError);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('refuses init, clone, and destroy while swapped', async () => {
|
|
195
|
+
await trackSwap();
|
|
196
|
+
|
|
197
|
+
await expect(trackInit()).rejects.toBeInstanceOf(SwapModeActiveError);
|
|
198
|
+
await expect(trackClone('https://example.invalid/repo.git')).rejects.toBeInstanceOf(
|
|
199
|
+
SwapModeActiveError,
|
|
200
|
+
);
|
|
201
|
+
await expect(trackDestroy({ force: true })).rejects.toBeInstanceOf(
|
|
202
|
+
SwapModeActiveError,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('refuses swap if main git has in-progress op', async () => {
|
|
207
|
+
await writeFile(join(tempDir, '.git', 'index.lock'), '');
|
|
208
|
+
|
|
209
|
+
await expect(trackSwap()).rejects.toBeInstanceOf(InProgressGitOpError);
|
|
210
|
+
|
|
211
|
+
// no swap happened
|
|
212
|
+
expect(await isSwapMode(tempDir)).toBe(false);
|
|
213
|
+
|
|
214
|
+
await unlink(join(tempDir, '.git', 'index.lock'));
|
|
215
|
+
await trackSwap();
|
|
216
|
+
expect(await isSwapMode(tempDir)).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('detects corrupt state when .git is missing but marker exists', async () => {
|
|
220
|
+
await trackSwap();
|
|
221
|
+
|
|
222
|
+
// Simulate crash between rename steps: rename .git back away,
|
|
223
|
+
// so .git.sgit-parked exists but no .git
|
|
224
|
+
await rename(join(tempDir, '.git'), join(tempDir, '.sgit'));
|
|
225
|
+
|
|
226
|
+
await expect(assertConsistentState(tempDir)).rejects.toBeInstanceOf(
|
|
227
|
+
SwapModeCorruptError,
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('verify does not flag disabled gitignores as stale while swapped', async () => {
|
|
232
|
+
await mkdir(join(tempDir, 'notes'), { recursive: true });
|
|
233
|
+
await writeFile(join(tempDir, 'notes', 'todo.md'), 'x');
|
|
234
|
+
await writeFile(join(tempDir, 'notes', '.gitignore'), '*.tmp\n');
|
|
235
|
+
await trackAdd(['notes/']);
|
|
236
|
+
|
|
237
|
+
await trackSwap();
|
|
238
|
+
|
|
239
|
+
// .gitignore.sgit-parked exists inside a tracked dir — no guard is active,
|
|
240
|
+
// but swap mode should suppress the stale-file violation.
|
|
241
|
+
const shadowResult = await verifyShadow(tempDir);
|
|
242
|
+
expect(shadowResult.violations).toEqual([]);
|
|
243
|
+
|
|
244
|
+
const mainResult = await verifyMain(tempDir);
|
|
245
|
+
expect(mainResult.violations).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('detects corrupt state when both .sgit and marker exist', async () => {
|
|
249
|
+
// Manually create .git.sgit-parked alongside existing .sgit
|
|
250
|
+
await mkdir(join(tempDir, SWAP_MARKER));
|
|
251
|
+
|
|
252
|
+
await expect(assertConsistentState(tempDir)).rejects.toBeInstanceOf(
|
|
253
|
+
SwapModeCorruptError,
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('disableGitignores/enableGitignores/setupGitignoreGuard refuse while swapped', async () => {
|
|
258
|
+
await trackSwap();
|
|
259
|
+
|
|
260
|
+
expect(() => disableGitignores(tempDir, [])).toThrow(SwapModeActiveError);
|
|
261
|
+
expect(() => enableGitignores(tempDir, [])).toThrow(SwapModeActiveError);
|
|
262
|
+
expect(() => setupGitignoreGuard(tempDir, [])).toThrow(
|
|
263
|
+
SwapModeActiveError,
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|