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.
@@ -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
+ }
@@ -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-sgit.bat files', async () => {
154
- await writeFile(join(tempDir, '.gitignore-sgit.bat'), 'stale\n');
155
- await expect(trackAdd(['.gitignore-sgit.bat'])).rejects.toThrow(
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-sgit.bat files', async () => {
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-sgit.bat'), 'stale\n');
163
- await expect(trackAdd(['docs/.gitignore-sgit.bat'])).rejects.toThrow(
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-sgit.bat when no guard is active', async () => {
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-sgit.bat
54
+ // Create a stale .gitignore.sgit-parked
55
55
  await writeFile(
56
- join(tempDir, 'docs', '.gitignore-sgit.bat'),
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-sgit.bat')),
63
+ result.violations.some((v) => v.includes('.gitignore.sgit-parked')),
64
64
  ).toBe(true);
65
65
  });
66
66
 
67
- it('skips stale .gitignore-sgit.bat check when guard is active', async () => {
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-sgit.bat'),
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-sgit.bat')),
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-sgit.bat when no guard is active', async () => {
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-sgit.bat
155
+ // Create a stale .gitignore.sgit-parked
156
156
  await writeFile(
157
- join(tempDir, 'docs', '.gitignore-sgit.bat'),
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-sgit.bat')),
164
+ result.violations.some((v) => v.includes('.gitignore.sgit-parked')),
165
165
  ).toBe(true);
166
166
  });
167
167
 
168
- it('skips stale .gitignore-sgit.bat check when guard is active', async () => {
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 .bat
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-sgit.bat'),
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 .bat files as violations when guard is active
184
+ // Should not report stale files as violations when guard is active
185
185
  expect(
186
- result.violations.some((v) => v.includes('.gitignore-sgit.bat')),
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-sgit.bat in managed block', () => {
53
+ it('includes .gitignore.sgit-parked in managed block', () => {
54
54
  const result = generateMainExclude('', ['notes.md']);
55
- expect(result).toContain('.gitignore-sgit.bat\n');
55
+ expect(result).toContain('.gitignore.sgit-parked\n');
56
56
  });
57
57
 
58
- it('orders entries: .sgit, .sgit.json, .gitignore-sgit.bat, then manifest paths', () => {
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-sgit.bat');
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-sgit.bat after allow-list', () => {
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-sgit.bat');
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-sgit.bat even for empty manifest', () => {
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-sgit.bat');
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-sgit.bat', () => {
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-sgit.bat');
194
+ const batLine = lines.indexOf('.gitignore.sgit-parked');
195
195
  expect(ignoreLine).toBeGreaterThan(unignoreLine);
196
196
  expect(ignoreLine).toBeLessThan(batLine);
197
197
  });