shadow-git 1.0.3 → 1.1.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/.temp/.sgit/info/exclude +1 -1
- package/CLAUDE.md +2 -1
- package/dist/cli.js +322 -74
- package/package.json +1 -1
- package/src/cli.ts +36 -4
- 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/utils/logger.ts +12 -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/helpers/guard-child.mjs +2 -2
|
@@ -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
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -19,3 +19,15 @@ 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
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -46,25 +46,25 @@ describe('track verify', () => {
|
|
|
46
46
|
expect(result.valid).toBe(true);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
it('blocks stale .gitignore-
|
|
49
|
+
it('blocks stale .gitignore.sgit-parked when no guard is active', async () => {
|
|
50
50
|
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
51
51
|
await writeFile(join(tempDir, 'docs', 'file.md'), 'content');
|
|
52
52
|
await trackAdd(['docs']);
|
|
53
53
|
|
|
54
|
-
// Create a stale .gitignore-
|
|
54
|
+
// Create a stale .gitignore.sgit-parked
|
|
55
55
|
await writeFile(
|
|
56
|
-
join(tempDir, 'docs', '.gitignore-
|
|
56
|
+
join(tempDir, 'docs', '.gitignore.sgit-parked'),
|
|
57
57
|
'stale\n',
|
|
58
58
|
);
|
|
59
59
|
|
|
60
60
|
const result = await verifyMain(tempDir);
|
|
61
61
|
expect(result.valid).toBe(false);
|
|
62
62
|
expect(
|
|
63
|
-
result.violations.some((v) => v.includes('.gitignore-
|
|
63
|
+
result.violations.some((v) => v.includes('.gitignore.sgit-parked')),
|
|
64
64
|
).toBe(true);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
it('skips stale .gitignore-
|
|
67
|
+
it('skips stale .gitignore.sgit-parked check when guard is active', async () => {
|
|
68
68
|
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
69
69
|
await writeFile(join(tempDir, 'docs', 'file.md'), 'content');
|
|
70
70
|
await trackAdd(['docs']);
|
|
@@ -74,13 +74,13 @@ describe('track verify', () => {
|
|
|
74
74
|
String(process.pid),
|
|
75
75
|
);
|
|
76
76
|
await writeFile(
|
|
77
|
-
join(tempDir, 'docs', '.gitignore-
|
|
77
|
+
join(tempDir, 'docs', '.gitignore.sgit-parked'),
|
|
78
78
|
'managed by guard\n',
|
|
79
79
|
);
|
|
80
80
|
|
|
81
81
|
const result = await verifyMain(tempDir);
|
|
82
82
|
expect(
|
|
83
|
-
result.violations.some((v) => v.includes('.gitignore-
|
|
83
|
+
result.violations.some((v) => v.includes('.gitignore.sgit-parked')),
|
|
84
84
|
).toBe(false);
|
|
85
85
|
|
|
86
86
|
unlinkSync(join(tempDir, '.sgit', '.gitignore-guard'));
|
|
@@ -147,43 +147,43 @@ describe('track verify', () => {
|
|
|
147
147
|
expect(result.violations.some((v) => v.includes('.sgit.json') && v.includes('cannot delete'))).toBe(true);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
it('blocks stale .gitignore-
|
|
150
|
+
it('blocks stale .gitignore.sgit-parked when no guard is active', async () => {
|
|
151
151
|
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
152
152
|
await writeFile(join(tempDir, 'docs', 'file.md'), 'content');
|
|
153
153
|
await trackAdd(['docs']);
|
|
154
154
|
|
|
155
|
-
// Create a stale .gitignore-
|
|
155
|
+
// Create a stale .gitignore.sgit-parked
|
|
156
156
|
await writeFile(
|
|
157
|
-
join(tempDir, 'docs', '.gitignore-
|
|
157
|
+
join(tempDir, 'docs', '.gitignore.sgit-parked'),
|
|
158
158
|
'stale\n',
|
|
159
159
|
);
|
|
160
160
|
|
|
161
161
|
const result = await verifyShadow(tempDir);
|
|
162
162
|
expect(result.valid).toBe(false);
|
|
163
163
|
expect(
|
|
164
|
-
result.violations.some((v) => v.includes('.gitignore-
|
|
164
|
+
result.violations.some((v) => v.includes('.gitignore.sgit-parked')),
|
|
165
165
|
).toBe(true);
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
it('skips stale .gitignore-
|
|
168
|
+
it('skips stale .gitignore.sgit-parked check when guard is active', async () => {
|
|
169
169
|
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
170
170
|
await writeFile(join(tempDir, 'docs', 'file.md'), 'content');
|
|
171
171
|
await trackAdd(['docs']);
|
|
172
172
|
|
|
173
|
-
// Simulate active guard: create lock with current PID + stale .
|
|
173
|
+
// Simulate active guard: create lock with current PID + stale .gitignore.sgit-parked
|
|
174
174
|
writeFileSync(
|
|
175
175
|
join(tempDir, '.sgit', '.gitignore-guard'),
|
|
176
176
|
String(process.pid),
|
|
177
177
|
);
|
|
178
178
|
await writeFile(
|
|
179
|
-
join(tempDir, 'docs', '.gitignore-
|
|
179
|
+
join(tempDir, 'docs', '.gitignore.sgit-parked'),
|
|
180
180
|
'managed by guard\n',
|
|
181
181
|
);
|
|
182
182
|
|
|
183
183
|
const result = await verifyShadow(tempDir);
|
|
184
|
-
// Should not report
|
|
184
|
+
// Should not report stale files as violations when guard is active
|
|
185
185
|
expect(
|
|
186
|
-
result.violations.some((v) => v.includes('.gitignore-
|
|
186
|
+
result.violations.some((v) => v.includes('.gitignore.sgit-parked')),
|
|
187
187
|
).toBe(false);
|
|
188
188
|
|
|
189
189
|
// Cleanup
|
|
@@ -50,17 +50,17 @@ describe('generateMainExclude', () => {
|
|
|
50
50
|
expect(result).toContain('notes/todo.md\n');
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
it('includes .gitignore-
|
|
53
|
+
it('includes .gitignore.sgit-parked in managed block', () => {
|
|
54
54
|
const result = generateMainExclude('', ['notes.md']);
|
|
55
|
-
expect(result).toContain('.gitignore-
|
|
55
|
+
expect(result).toContain('.gitignore.sgit-parked\n');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it('orders entries: .sgit, .sgit.json, .gitignore-
|
|
58
|
+
it('orders entries: .sgit, .sgit.json, .gitignore.sgit-parked, then manifest paths', () => {
|
|
59
59
|
const result = generateMainExclude('', ['z.txt', 'a.txt']);
|
|
60
60
|
const lines = result.split('\n');
|
|
61
61
|
const sgitLine = lines.indexOf('.sgit');
|
|
62
62
|
const sgitJsonLine = lines.indexOf('.sgit.json');
|
|
63
|
-
const batLine = lines.indexOf('.gitignore-
|
|
63
|
+
const batLine = lines.indexOf('.gitignore.sgit-parked');
|
|
64
64
|
const zLine = lines.indexOf('z.txt');
|
|
65
65
|
const aLine = lines.indexOf('a.txt');
|
|
66
66
|
expect(sgitLine).toBeLessThan(sgitJsonLine);
|
|
@@ -158,19 +158,19 @@ describe('generateShadowExclude', () => {
|
|
|
158
158
|
expect(lines).not.toContain('!./');
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
it('re-ignores .gitignore-
|
|
161
|
+
it('re-ignores .gitignore.sgit-parked after allow-list', () => {
|
|
162
162
|
const result = generateShadowExclude(['docs/']);
|
|
163
163
|
const lines = result.split('\n');
|
|
164
164
|
const allowLine = lines.indexOf('!docs/**');
|
|
165
|
-
const batLine = lines.indexOf('.gitignore-
|
|
165
|
+
const batLine = lines.indexOf('.gitignore.sgit-parked');
|
|
166
166
|
expect(batLine).toBeGreaterThan(-1);
|
|
167
167
|
expect(batLine).toBeGreaterThan(allowLine);
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
-
it('includes .gitignore-
|
|
170
|
+
it('includes .gitignore.sgit-parked even for empty manifest', () => {
|
|
171
171
|
const result = generateShadowExclude([]);
|
|
172
172
|
const lines = result.split('\n');
|
|
173
|
-
expect(lines).toContain('.gitignore-
|
|
173
|
+
expect(lines).toContain('.gitignore.sgit-parked');
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
it('has no per-path comment lines', () => {
|
|
@@ -186,12 +186,12 @@ describe('generateShadowExclude', () => {
|
|
|
186
186
|
expect(lines).toContain('temp2/cache/');
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
it('places ignore rules after un-ignore and before .gitignore-
|
|
189
|
+
it('places ignore rules after un-ignore and before .gitignore.sgit-parked', () => {
|
|
190
190
|
const result = generateShadowExclude(['docs/'], ['docs/cache/']);
|
|
191
191
|
const lines = result.split('\n');
|
|
192
192
|
const unignoreLine = lines.indexOf('!docs/**');
|
|
193
193
|
const ignoreLine = lines.indexOf('docs/cache/');
|
|
194
|
-
const batLine = lines.indexOf('.gitignore-
|
|
194
|
+
const batLine = lines.indexOf('.gitignore.sgit-parked');
|
|
195
195
|
expect(ignoreLine).toBeGreaterThan(unignoreLine);
|
|
196
196
|
expect(ignoreLine).toBeLessThan(batLine);
|
|
197
197
|
});
|