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.
- package/.claude/settings.local.json +5 -1
- package/.temp/.sgit/index +0 -0
- package/.temp/.sgit/info/exclude +16 -7
- package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
- package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
- package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
- package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
- package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
- package/.temp/.sgit.json +6 -2
- package/.temp/README.md +0 -3
- package/.temp/rec1/rec2/test +0 -0
- package/.temp/temp/shouldnt-track +0 -0
- package/.temp/test/1 +0 -0
- package/CLAUDE.md +10 -2
- package/README.md +16 -29
- package/dist/cli.js +810 -119
- package/package.json +1 -1
- package/src/cli.ts +50 -19
- package/src/commands/debug/gitignores.ts +28 -0
- package/src/commands/debug/index.ts +32 -0
- package/src/commands/track/add.ts +11 -0
- package/src/commands/track/clone.ts +213 -0
- package/src/commands/track/completion.ts +50 -15
- package/src/commands/track/destroy.ts +97 -19
- package/src/commands/track/ignore.ts +55 -0
- package/src/commands/track/index.ts +23 -1
- package/src/commands/track/init.ts +5 -2
- package/src/commands/track/sync.ts +1 -1
- package/src/commands/track/unignore.ts +50 -0
- package/src/commands/track/verify.ts +93 -20
- package/src/core/errors.ts +23 -2
- package/src/core/exclude.ts +40 -43
- package/src/core/gitignore.ts +242 -0
- package/src/core/hooks.ts +19 -0
- package/src/core/manifest.ts +33 -1
- package/src/core/paths.ts +21 -3
- package/src/core/repository.ts +17 -2
- package/tests/commands/track/add.test.ts +31 -1
- package/tests/commands/track/clone.test.ts +315 -0
- package/tests/commands/track/completion.test.ts +1 -1
- package/tests/commands/track/destroy.test.ts +68 -2
- package/tests/commands/track/ignore.test.ts +109 -0
- package/tests/commands/track/init.test.ts +27 -4
- package/tests/commands/track/remove.test.ts +1 -1
- package/tests/commands/track/sync.test.ts +3 -3
- package/tests/commands/track/unignore.test.ts +72 -0
- package/tests/commands/track/verify.test.ts +132 -1
- package/tests/core/exclude.test.ts +107 -19
- package/tests/core/gitignore.test.ts +561 -0
- package/tests/core/hooks.test.ts +34 -0
- package/tests/core/paths.test.ts +24 -0
- package/tests/core/repository.test.ts +39 -5
- package/tests/helpers/guard-child.mjs +125 -0
- package/tests/helpers.ts +37 -0
package/src/core/exclude.ts
CHANGED
|
@@ -1,31 +1,29 @@
|
|
|
1
|
-
export const
|
|
2
|
-
export const
|
|
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
|
|
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 ===
|
|
14
|
-
|
|
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 ===
|
|
18
|
-
|
|
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
|
-
|
|
37
|
+
return [
|
|
41
38
|
...preserved,
|
|
42
39
|
'',
|
|
43
|
-
|
|
44
|
-
...
|
|
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
|
-
|
|
57
|
+
entryLines.push(unignore);
|
|
70
58
|
emitted.add(unignore);
|
|
71
59
|
}
|
|
72
60
|
if (!emitted.has(reignore)) {
|
|
73
|
-
|
|
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
|
-
|
|
68
|
+
entryLines.push(target);
|
|
81
69
|
emitted.add(target);
|
|
82
70
|
}
|
|
83
71
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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');
|
package/src/core/manifest.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
51
|
+
if (normalizedManifest === normalizedFile) { covered = true; break; }
|
|
50
52
|
if (
|
|
51
53
|
normalizedManifest.endsWith('/') &&
|
|
52
54
|
normalizedFile.startsWith(normalizedManifest)
|
|
53
55
|
) {
|
|
54
|
-
|
|
56
|
+
covered = true;
|
|
57
|
+
break;
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
|
-
|
|
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
|
}
|
package/src/core/repository.ts
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
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');
|