shadow-git 0.1.0 → 1.0.2

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.
Files changed (54) hide show
  1. package/.claude/settings.local.json +5 -1
  2. package/.temp/.sgit/index +0 -0
  3. package/.temp/.sgit/info/exclude +16 -7
  4. package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
  5. package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
  6. package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
  7. package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
  8. package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
  9. package/.temp/.sgit.json +6 -2
  10. package/.temp/README.md +0 -3
  11. package/.temp/rec1/rec2/test +0 -0
  12. package/.temp/temp/shouldnt-track +0 -0
  13. package/.temp/test/1 +0 -0
  14. package/CLAUDE.md +10 -2
  15. package/README.md +16 -29
  16. package/dist/cli.js +810 -119
  17. package/package.json +1 -1
  18. package/src/cli.ts +50 -19
  19. package/src/commands/debug/gitignores.ts +28 -0
  20. package/src/commands/debug/index.ts +32 -0
  21. package/src/commands/track/add.ts +11 -0
  22. package/src/commands/track/clone.ts +213 -0
  23. package/src/commands/track/completion.ts +50 -15
  24. package/src/commands/track/destroy.ts +97 -19
  25. package/src/commands/track/ignore.ts +55 -0
  26. package/src/commands/track/index.ts +23 -1
  27. package/src/commands/track/init.ts +5 -2
  28. package/src/commands/track/sync.ts +1 -1
  29. package/src/commands/track/unignore.ts +50 -0
  30. package/src/commands/track/verify.ts +93 -20
  31. package/src/core/errors.ts +23 -2
  32. package/src/core/exclude.ts +40 -43
  33. package/src/core/gitignore.ts +242 -0
  34. package/src/core/hooks.ts +19 -0
  35. package/src/core/manifest.ts +33 -1
  36. package/src/core/paths.ts +21 -3
  37. package/src/core/repository.ts +17 -2
  38. package/tests/commands/track/add.test.ts +31 -1
  39. package/tests/commands/track/clone.test.ts +315 -0
  40. package/tests/commands/track/completion.test.ts +1 -1
  41. package/tests/commands/track/destroy.test.ts +68 -2
  42. package/tests/commands/track/ignore.test.ts +109 -0
  43. package/tests/commands/track/init.test.ts +27 -4
  44. package/tests/commands/track/remove.test.ts +1 -1
  45. package/tests/commands/track/sync.test.ts +3 -3
  46. package/tests/commands/track/unignore.test.ts +72 -0
  47. package/tests/commands/track/verify.test.ts +132 -1
  48. package/tests/core/exclude.test.ts +107 -19
  49. package/tests/core/gitignore.test.ts +561 -0
  50. package/tests/core/hooks.test.ts +34 -0
  51. package/tests/core/paths.test.ts +24 -0
  52. package/tests/core/repository.test.ts +39 -5
  53. package/tests/helpers/guard-child.mjs +125 -0
  54. package/tests/helpers.ts +37 -0
@@ -1,31 +1,29 @@
1
- export const SGIT_MARKER = '# sgit';
2
- export const SGIT_MANAGED_HEADER = '# sgit-managed (do not edit below this line)';
1
+ export const SGIT_BLOCK_START = '# sgit-managed';
2
+ export const SGIT_BLOCK_END = '# end: sgit-managed';
3
3
 
4
4
  export function generateMainExclude(
5
5
  existingContent: string,
6
6
  manifestPaths: string[],
7
7
  ): string {
8
- // Preserve non-sgit lines, stripping marker lines and their associated entries
8
+ // Preserve lines outside the managed block, stripping legacy formats too
9
9
  const lines = existingContent.split('\n');
10
10
  const preserved: string[] = [];
11
+ let insideBlock = false;
11
12
  let skipNext = false;
12
13
  for (const line of lines) {
13
- if (line === SGIT_MANAGED_HEADER) {
14
- skipNext = false;
14
+ if (line === SGIT_BLOCK_START || line === '# sgit-managed (do not edit below this line)') {
15
+ insideBlock = true;
15
16
  continue;
16
17
  }
17
- if (line === SGIT_MARKER) {
18
- skipNext = true;
19
- continue;
20
- }
21
- // Strip legacy inline-comment format: "entry # sgit"
22
- if (line.endsWith(` ${SGIT_MARKER}`)) {
23
- continue;
24
- }
25
- if (skipNext) {
26
- skipNext = false;
18
+ if (line === SGIT_BLOCK_END) {
19
+ insideBlock = false;
27
20
  continue;
28
21
  }
22
+ if (insideBlock) continue;
23
+ // Strip legacy per-line marker and the entry that follows it
24
+ if (line === '# sgit') { skipNext = true; continue; }
25
+ if (skipNext) { skipNext = false; continue; }
26
+ if (line.endsWith(' # sgit')) continue;
29
27
  preserved.push(line);
30
28
  }
31
29
 
@@ -34,68 +32,67 @@ export function generateMainExclude(
34
32
  preserved.pop();
35
33
  }
36
34
 
37
- const sgitEntries = ['.sgit', '.sgit.json', ...manifestPaths];
38
- const sgitLines = sgitEntries.flatMap((entry) => [SGIT_MARKER, entry]);
35
+ const sgitEntries = ['.sgit', '.sgit.json', '.gitignore-sgit.bat', ...manifestPaths];
39
36
 
40
- const sections = [
37
+ return [
41
38
  ...preserved,
42
39
  '',
43
- SGIT_MANAGED_HEADER,
44
- ...sgitLines,
45
- ];
46
-
47
- return sections.join('\n') + '\n';
40
+ SGIT_BLOCK_START,
41
+ ...sgitEntries,
42
+ SGIT_BLOCK_END,
43
+ ].join('\n') + '\n';
48
44
  }
49
45
 
50
- export function generateShadowExclude(manifestPaths: string[]): string {
51
- const lines: string[] = [
52
- '# Exclude everything by default',
53
- '*',
54
- '',
55
- '# .sgit.json (always tracked)',
56
- '!.sgit.json',
57
- ];
58
-
46
+ export function generateShadowExclude(manifestPaths: string[], ignorePaths: string[] = []): string {
59
47
  const emitted = new Set<string>();
48
+ const entryLines: string[] = [];
60
49
 
61
50
  for (const trackedPath of manifestPaths) {
62
- const pathLines: string[] = [];
63
51
  const ancestors = getAncestors(trackedPath);
64
52
 
65
53
  for (const ancestor of ancestors) {
66
54
  const unignore = `!${ancestor}/`;
67
55
  const reignore = `${ancestor}/*`;
68
56
  if (!emitted.has(unignore)) {
69
- pathLines.push(unignore);
57
+ entryLines.push(unignore);
70
58
  emitted.add(unignore);
71
59
  }
72
60
  if (!emitted.has(reignore)) {
73
- pathLines.push(reignore);
61
+ entryLines.push(reignore);
74
62
  emitted.add(reignore);
75
63
  }
76
64
  }
77
65
 
78
66
  const target = `!${trackedPath}`;
79
67
  if (!emitted.has(target)) {
80
- pathLines.push(target);
68
+ entryLines.push(target);
81
69
  emitted.add(target);
82
70
  }
83
71
 
84
- if (pathLines.length > 0) {
85
- lines.push('');
86
- lines.push(`# ${trackedPath}`);
87
- lines.push(...pathLines);
72
+ // For directory entries, recursively include all contents
73
+ if (trackedPath.endsWith('/')) {
74
+ const recursive = `!${trackedPath}**`;
75
+ if (!emitted.has(recursive)) {
76
+ entryLines.push(recursive);
77
+ emitted.add(recursive);
78
+ }
88
79
  }
89
80
  }
90
81
 
91
- return lines.join('\n') + '\n';
82
+ return [
83
+ SGIT_BLOCK_START,
84
+ '*',
85
+ '!.sgit.json',
86
+ ...entryLines,
87
+ ...ignorePaths,
88
+ '.gitignore-sgit.bat',
89
+ SGIT_BLOCK_END,
90
+ ].join('\n') + '\n';
92
91
  }
93
92
 
94
93
  function getAncestors(path: string): string[] {
95
- // Remove trailing slash for directories
96
94
  const cleanPath = path.endsWith('/') ? path.slice(0, -1) : path;
97
95
  const parts = cleanPath.split('/');
98
- // Remove the last part (the file/dir itself)
99
96
  parts.pop();
100
97
 
101
98
  const ancestors: string[] = [];
@@ -0,0 +1,242 @@
1
+ import {
2
+ readdirSync,
3
+ renameSync,
4
+ existsSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ unlinkSync,
8
+ } from 'node:fs';
9
+ import { join, relative, sep } from 'node:path';
10
+ import { StaleGitignoreError } from './errors.js';
11
+ import * as logger from '../utils/logger.js';
12
+
13
+ const DISABLED_NAME = '.gitignore-sgit.bat';
14
+ const LOCK_FILE = '.gitignore-guard';
15
+
16
+ // --- Public API ---
17
+
18
+ export function disableGitignores(
19
+ root: string,
20
+ manifestPaths: string[],
21
+ ): string[] {
22
+ const gitignores = findInTrackedDirs(root, manifestPaths, '.gitignore');
23
+ if (gitignores.length === 0) return [];
24
+
25
+ const stale = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
26
+ if (stale.length > 0) {
27
+ throw new StaleGitignoreError(stale);
28
+ }
29
+
30
+ const disabled: string[] = [];
31
+ for (const relPath of gitignores) {
32
+ const absPath = join(root, relPath);
33
+ const dir = absPath.substring(0, absPath.length - '.gitignore'.length);
34
+ renameSync(absPath, dir + DISABLED_NAME);
35
+ disabled.push(relPath);
36
+ }
37
+ return disabled;
38
+ }
39
+
40
+ export function enableGitignores(
41
+ root: string,
42
+ manifestPaths: string[],
43
+ ): string[] {
44
+ const disabled = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
45
+ const enabled: string[] = [];
46
+ for (const relPath of disabled) {
47
+ const absPath = join(root, relPath);
48
+ const restoredPath =
49
+ absPath.substring(0, absPath.length - DISABLED_NAME.length) +
50
+ '.gitignore';
51
+ renameSync(absPath, restoredPath);
52
+ enabled.push(relPath);
53
+ }
54
+ return enabled;
55
+ }
56
+
57
+ export function findStaleGitignores(
58
+ root: string,
59
+ manifestPaths: string[],
60
+ ): string[] {
61
+ return findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
62
+ }
63
+
64
+ export function isGuardActive(root: string): boolean {
65
+ const lockPath = join(root, '.sgit', LOCK_FILE);
66
+ try {
67
+ const pid = parseInt(readFileSync(lockPath, 'utf-8').trim(), 10);
68
+ if (isNaN(pid)) return false;
69
+ process.kill(pid, 0); // signal 0 checks existence without sending
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ export function setupGitignoreGuard(
77
+ root: string,
78
+ manifestPaths: string[],
79
+ ): () => void {
80
+ const lockPath = join(root, '.sgit', LOCK_FILE);
81
+
82
+ // Nested invocation — another sgit process owns the guard
83
+ if (isGuardActive(root)) {
84
+ return () => {};
85
+ }
86
+
87
+ // Clean up stale state from a crashed process
88
+ const stale = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
89
+ if (stale.length > 0) {
90
+ logger.warn(
91
+ 'found stale .gitignore-sgit.bat files from a previous crash; restoring',
92
+ );
93
+ enableGitignores(root, manifestPaths);
94
+ }
95
+ cleanStaleLock(lockPath);
96
+
97
+ // Disable gitignores
98
+ const gitignores = findInTrackedDirs(root, manifestPaths, '.gitignore');
99
+ if (gitignores.length === 0) {
100
+ return () => {};
101
+ }
102
+
103
+ for (const relPath of gitignores) {
104
+ const absPath = join(root, relPath);
105
+ const dir = absPath.substring(0, absPath.length - '.gitignore'.length);
106
+ renameSync(absPath, dir + DISABLED_NAME);
107
+ }
108
+
109
+ // Write lock with our PID
110
+ try {
111
+ writeFileSync(lockPath, String(process.pid));
112
+ } catch {
113
+ // .sgit dir may be missing in edge cases — non-fatal
114
+ }
115
+
116
+ let cleaned = false;
117
+ const cleanup = () => {
118
+ if (cleaned) return;
119
+ cleaned = true;
120
+ try {
121
+ enableGitignores(root, manifestPaths);
122
+ } catch {
123
+ // best effort
124
+ }
125
+ try {
126
+ unlinkSync(lockPath);
127
+ } catch {
128
+ // best effort
129
+ }
130
+ };
131
+
132
+ const onSigint = () => {
133
+ cleanup();
134
+ process.exit(130);
135
+ };
136
+ const onSigterm = () => {
137
+ cleanup();
138
+ process.exit(143);
139
+ };
140
+
141
+ process.on('SIGINT', onSigint);
142
+ process.on('SIGTERM', onSigterm);
143
+ process.on('exit', cleanup);
144
+
145
+ return () => {
146
+ process.removeListener('SIGINT', onSigint);
147
+ process.removeListener('SIGTERM', onSigterm);
148
+ process.removeListener('exit', cleanup);
149
+ cleanup();
150
+ };
151
+ }
152
+
153
+ // --- Internal helpers ---
154
+
155
+ /**
156
+ * Find files named `targetName` in all directories relevant to the manifest:
157
+ * 1. Ancestor directories of every manifest path (including repo root)
158
+ * 2. Recursive walk inside every directory-type manifest entry
159
+ */
160
+ function findInTrackedDirs(
161
+ root: string,
162
+ manifestPaths: string[],
163
+ targetName: string,
164
+ ): string[] {
165
+ const found = new Set<string>();
166
+
167
+ for (const mPath of manifestPaths) {
168
+ // 1. Check ancestor directories (including root) for every path
169
+ for (const ancestor of getAncestorDirs(mPath)) {
170
+ const dirAbs = ancestor === '' ? root : join(root, ancestor);
171
+ const targetPath = join(dirAbs, targetName);
172
+ if (existsSync(targetPath)) {
173
+ found.add(targetPath);
174
+ }
175
+ }
176
+
177
+ // 2. For directory entries, also recursively walk inside
178
+ if (mPath.endsWith('/')) {
179
+ const dirAbs = join(root, mPath);
180
+ if (existsSync(dirAbs)) {
181
+ walkSync(dirAbs, targetName, found);
182
+ }
183
+ }
184
+ }
185
+
186
+ return [...found].map((abs) => relative(root, abs).split(sep).join('/'));
187
+ }
188
+
189
+ /**
190
+ * Return ancestor directory paths for a manifest path.
191
+ * '' represents the repo root.
192
+ *
193
+ * 'rec1/rec2/test' → ['', 'rec1', 'rec1/rec2']
194
+ * 'temp2/' → ['']
195
+ * 'README.md' → ['']
196
+ */
197
+ function getAncestorDirs(path: string): string[] {
198
+ const clean = path.endsWith('/') ? path.slice(0, -1) : path;
199
+ const parts = clean.split('/');
200
+ parts.pop(); // drop the leaf (file or dir name)
201
+
202
+ const ancestors: string[] = [''];
203
+ for (let i = 1; i <= parts.length; i++) {
204
+ ancestors.push(parts.slice(0, i).join('/'));
205
+ }
206
+ return ancestors;
207
+ }
208
+
209
+ function walkSync(
210
+ dir: string,
211
+ targetName: string,
212
+ results: Set<string>,
213
+ ): void {
214
+ let entries;
215
+ try {
216
+ entries = readdirSync(dir, { withFileTypes: true });
217
+ } catch {
218
+ return;
219
+ }
220
+ for (const entry of entries) {
221
+ if (
222
+ entry.isDirectory() &&
223
+ (entry.name === '.git' || entry.name === '.sgit')
224
+ ) {
225
+ continue;
226
+ }
227
+ const fullPath = join(dir, entry.name);
228
+ if (entry.isDirectory()) {
229
+ walkSync(fullPath, targetName, results);
230
+ } else if (entry.name === targetName) {
231
+ results.add(fullPath);
232
+ }
233
+ }
234
+ }
235
+
236
+ function cleanStaleLock(lockPath: string): void {
237
+ try {
238
+ unlinkSync(lockPath);
239
+ } catch {
240
+ // no lock to clean
241
+ }
242
+ }
package/src/core/hooks.ts CHANGED
@@ -43,6 +43,18 @@ else
43
43
  fi
44
44
  `;
45
45
 
46
+ const SHADOW_POST_MERGE_HOOK = `#!/bin/sh
47
+ if command -v sgit >/dev/null 2>&1; then
48
+ sgit track sync
49
+ fi
50
+ `;
51
+
52
+ const SHADOW_POST_CHECKOUT_HOOK = `#!/bin/sh
53
+ if command -v sgit >/dev/null 2>&1; then
54
+ sgit track sync
55
+ fi
56
+ `;
57
+
46
58
  export async function getHookDir(root: string): Promise<string> {
47
59
  try {
48
60
  const hookPath = await execGit(['config', 'core.hooksPath'], {
@@ -101,6 +113,13 @@ export async function installShadowHook(sgitDir: string): Promise<void> {
101
113
  await writeFile(join(hooksDir, 'pre-commit'), SHADOW_HOOK, { mode: 0o755 });
102
114
  }
103
115
 
116
+ export async function installShadowSyncHooks(sgitDir: string): Promise<void> {
117
+ const hooksDir = join(sgitDir, 'hooks');
118
+ await mkdir(hooksDir, { recursive: true });
119
+ await writeFile(join(hooksDir, 'post-merge'), SHADOW_POST_MERGE_HOOK, { mode: 0o755 });
120
+ await writeFile(join(hooksDir, 'post-checkout'), SHADOW_POST_CHECKOUT_HOOK, { mode: 0o755 });
121
+ }
122
+
104
123
  export async function uninstallMainHook(root: string): Promise<void> {
105
124
  const hookDir = await getHookDir(root);
106
125
  const preCommitPath = join(hookDir, 'pre-commit');
@@ -3,12 +3,17 @@ import { join } from 'node:path';
3
3
 
4
4
  export interface Manifest {
5
5
  paths: string[];
6
+ ignore: string[];
6
7
  [key: string]: unknown;
7
8
  }
8
9
 
9
10
  export async function readManifest(root: string): Promise<Manifest> {
10
11
  const content = await readFile(join(root, '.sgit.json'), 'utf-8');
11
- return JSON.parse(content) as Manifest;
12
+ const data = JSON.parse(content) as Manifest;
13
+ if (!Array.isArray(data.ignore)) {
14
+ data.ignore = [];
15
+ }
16
+ return data;
12
17
  }
13
18
 
14
19
  export async function writeManifest(
@@ -47,3 +52,30 @@ export async function removePath(
47
52
  await writeManifest(root, manifest);
48
53
  return true;
49
54
  }
55
+
56
+ export async function addIgnore(
57
+ root: string,
58
+ normalizedPath: string,
59
+ ): Promise<boolean> {
60
+ const manifest = await readManifest(root);
61
+ if (manifest.ignore.includes(normalizedPath)) {
62
+ return false;
63
+ }
64
+ manifest.ignore.push(normalizedPath);
65
+ await writeManifest(root, manifest);
66
+ return true;
67
+ }
68
+
69
+ export async function removeIgnore(
70
+ root: string,
71
+ normalizedPath: string,
72
+ ): Promise<boolean> {
73
+ const manifest = await readManifest(root);
74
+ const index = manifest.ignore.indexOf(normalizedPath);
75
+ if (index === -1) {
76
+ return false;
77
+ }
78
+ manifest.ignore.splice(index, 1);
79
+ await writeManifest(root, manifest);
80
+ return true;
81
+ }
package/src/core/paths.ts CHANGED
@@ -36,6 +36,7 @@ export async function normalizePath(
36
36
  export function isPathCoveredByManifest(
37
37
  filePath: string,
38
38
  manifestPaths: string[],
39
+ ignorePaths: string[] = [],
39
40
  ): boolean {
40
41
  const caseInsensitive = process.platform === 'darwin';
41
42
  const normalize = caseInsensitive
@@ -44,15 +45,32 @@ export function isPathCoveredByManifest(
44
45
 
45
46
  const normalizedFile = normalize(filePath);
46
47
 
48
+ let covered = false;
47
49
  for (const mPath of manifestPaths) {
48
50
  const normalizedManifest = normalize(mPath);
49
- if (normalizedManifest === normalizedFile) return true;
51
+ if (normalizedManifest === normalizedFile) { covered = true; break; }
50
52
  if (
51
53
  normalizedManifest.endsWith('/') &&
52
54
  normalizedFile.startsWith(normalizedManifest)
53
55
  ) {
54
- return true;
56
+ covered = true;
57
+ break;
55
58
  }
56
59
  }
57
- return false;
60
+
61
+ if (!covered) return false;
62
+
63
+ // Check if the path is excluded by ignore list
64
+ for (const iPath of ignorePaths) {
65
+ const normalizedIgnore = normalize(iPath);
66
+ if (normalizedIgnore === normalizedFile) return false;
67
+ if (
68
+ normalizedIgnore.endsWith('/') &&
69
+ normalizedFile.startsWith(normalizedIgnore)
70
+ ) {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ return true;
58
76
  }
@@ -2,14 +2,29 @@ import { access, lstat } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { execGit } from './git.js';
5
- import { NotGitRepoError, NotInitializedError, SgitError } from './errors.js';
5
+ import { NoGitIdentityError, NotGitRepoError, NotInitializedError, SgitError } from './errors.js';
6
6
 
7
7
  export async function findRoot(): Promise<string> {
8
+ const cwd = process.cwd();
8
9
  try {
9
- return await execGit(['rev-parse', '--show-toplevel']);
10
+ await access(join(cwd, '.git'), constants.F_OK);
10
11
  } catch {
11
12
  throw new NotGitRepoError();
12
13
  }
14
+ return cwd;
15
+ }
16
+
17
+ export async function validateGitIdentity(root: string): Promise<void> {
18
+ try {
19
+ await execGit(['config', 'user.name'], { cwd: root });
20
+ } catch {
21
+ throw new NoGitIdentityError();
22
+ }
23
+ try {
24
+ await execGit(['config', 'user.email'], { cwd: root });
25
+ } catch {
26
+ throw new NoGitIdentityError();
27
+ }
13
28
  }
14
29
 
15
30
  export async function validateShadowRepo(root: string): Promise<void> {
@@ -45,7 +45,7 @@ describe('track add', () => {
45
45
  join(tempDir, '.git', 'info', 'exclude'),
46
46
  'utf-8',
47
47
  );
48
- expect(mainExclude).toContain('# sgit\nnotes/todo.md\n');
48
+ expect(mainExclude).toContain('notes/todo.md\n');
49
49
  });
50
50
 
51
51
  it('adds a directory with trailing slash', async () => {
@@ -135,6 +135,36 @@ describe('track add', () => {
135
135
  await access(join(tempDir, 'secrets', 'key.pem'), constants.F_OK);
136
136
  });
137
137
 
138
+ it('rejects .gitignore files', async () => {
139
+ await writeFile(join(tempDir, '.gitignore'), '*.log\n');
140
+ await expect(trackAdd(['.gitignore'])).rejects.toThrow(
141
+ '.gitignore files are managed by sgit',
142
+ );
143
+ });
144
+
145
+ it('rejects nested .gitignore files', async () => {
146
+ await mkdir(join(tempDir, 'docs'), { recursive: true });
147
+ await writeFile(join(tempDir, 'docs', '.gitignore'), 'stuff\n');
148
+ await expect(trackAdd(['docs/.gitignore'])).rejects.toThrow(
149
+ '.gitignore files are managed by sgit',
150
+ );
151
+ });
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(
156
+ '.gitignore files are managed by sgit',
157
+ );
158
+ });
159
+
160
+ it('rejects nested .gitignore-sgit.bat files', async () => {
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(
164
+ '.gitignore files are managed by sgit',
165
+ );
166
+ });
167
+
138
168
  it('adds multiple paths at once', async () => {
139
169
  await writeFile(join(tempDir, 'a.txt'), 'a');
140
170
  await writeFile(join(tempDir, 'b.txt'), 'b');