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
|
@@ -1,21 +1,68 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
import { readFile, writeFile, rm } from 'node:fs/promises';
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { readFile, writeFile, rm, rmdir, unlink } from 'node:fs/promises';
|
|
3
3
|
import { createInterface } from 'node:readline';
|
|
4
4
|
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
5
5
|
import { uninstallMainHook } from '../../core/hooks.js';
|
|
6
|
-
import {
|
|
6
|
+
import { execGit } from '../../core/git.js';
|
|
7
|
+
import { SGIT_BLOCK_START, SGIT_BLOCK_END } from '../../core/exclude.js';
|
|
7
8
|
import * as logger from '../../utils/logger.js';
|
|
8
9
|
|
|
9
10
|
export async function trackDestroy(opts: {
|
|
10
11
|
force?: boolean;
|
|
12
|
+
keepFiles?: boolean;
|
|
11
13
|
}): Promise<void> {
|
|
12
14
|
const root = await findRoot();
|
|
13
15
|
await validateShadowRepo(root);
|
|
16
|
+
const sgitDir = join(root, '.sgit');
|
|
17
|
+
|
|
18
|
+
// Get list of tracked files (for deletion and status check)
|
|
19
|
+
let trackedFiles: string[] = [];
|
|
20
|
+
try {
|
|
21
|
+
const output = await execGit(['ls-tree', '-r', '--name-only', 'HEAD'], {
|
|
22
|
+
gitDir: sgitDir,
|
|
23
|
+
workTree: root,
|
|
24
|
+
});
|
|
25
|
+
if (output.trim()) {
|
|
26
|
+
trackedFiles = output.trim().split('\n').filter(f => f !== '.sgit.json');
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// Empty repo or no commits
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check for uncommitted changes
|
|
33
|
+
let uncommittedStatus = '';
|
|
34
|
+
try {
|
|
35
|
+
uncommittedStatus = await execGit(['status', '--porcelain'], {
|
|
36
|
+
gitDir: sgitDir,
|
|
37
|
+
workTree: root,
|
|
38
|
+
});
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore
|
|
41
|
+
}
|
|
42
|
+
const hasUncommitted = uncommittedStatus.trim().length > 0;
|
|
14
43
|
|
|
15
44
|
if (!opts.force) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
45
|
+
// Show uncommitted changes warning if any
|
|
46
|
+
if (hasUncommitted) {
|
|
47
|
+
logger.warn('there are uncommitted changes in the shadow repository:');
|
|
48
|
+
for (const line of uncommittedStatus.trim().split('\n')) {
|
|
49
|
+
logger.info(` ${line}`);
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let message: string;
|
|
55
|
+
if (opts.keepFiles) {
|
|
56
|
+
message = hasUncommitted
|
|
57
|
+
? 'This will remove the shadow repository. Tracked files remain on disk but shadow history will be lost. Continue?'
|
|
58
|
+
: 'This will remove the shadow repository. Tracked files will remain on disk. Continue?';
|
|
59
|
+
} else {
|
|
60
|
+
message = hasUncommitted
|
|
61
|
+
? `This will permanently remove the shadow repository and delete ${trackedFiles.length} tracked file(s). Uncommitted changes will be lost. Continue?`
|
|
62
|
+
: `This will permanently remove the shadow repository and delete ${trackedFiles.length} tracked file(s). Continue?`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const confirmed = await confirm(message);
|
|
19
66
|
if (!confirmed) {
|
|
20
67
|
logger.info('Aborted.');
|
|
21
68
|
return;
|
|
@@ -28,24 +75,21 @@ export async function trackDestroy(opts: {
|
|
|
28
75
|
const content = await readFile(excludePath, 'utf-8');
|
|
29
76
|
const lines = content.split('\n');
|
|
30
77
|
const result: string[] = [];
|
|
78
|
+
let insideBlock = false;
|
|
31
79
|
let skipNext = false;
|
|
32
80
|
for (const line of lines) {
|
|
33
|
-
if (line ===
|
|
34
|
-
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (line === SGIT_MARKER) {
|
|
38
|
-
skipNext = true;
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
// Strip legacy inline-comment format: "entry # sgit"
|
|
42
|
-
if (line.endsWith(` ${SGIT_MARKER}`)) {
|
|
81
|
+
if (line === SGIT_BLOCK_START || line === '# sgit-managed (do not edit below this line)') {
|
|
82
|
+
insideBlock = true;
|
|
43
83
|
continue;
|
|
44
84
|
}
|
|
45
|
-
if (
|
|
46
|
-
|
|
85
|
+
if (line === SGIT_BLOCK_END) {
|
|
86
|
+
insideBlock = false;
|
|
47
87
|
continue;
|
|
48
88
|
}
|
|
89
|
+
if (insideBlock) continue;
|
|
90
|
+
if (line === '# sgit') { skipNext = true; continue; }
|
|
91
|
+
if (skipNext) { skipNext = false; continue; }
|
|
92
|
+
if (line.endsWith(' # sgit')) continue;
|
|
49
93
|
result.push(line);
|
|
50
94
|
}
|
|
51
95
|
const cleaned = result.join('\n');
|
|
@@ -58,13 +102,47 @@ export async function trackDestroy(opts: {
|
|
|
58
102
|
// Uninstall main repo hook
|
|
59
103
|
await uninstallMainHook(root);
|
|
60
104
|
|
|
105
|
+
// Delete tracked files if not keeping
|
|
106
|
+
if (!opts.keepFiles && trackedFiles.length > 0) {
|
|
107
|
+
for (const file of trackedFiles) {
|
|
108
|
+
try {
|
|
109
|
+
await unlink(join(root, file));
|
|
110
|
+
} catch {
|
|
111
|
+
// File might already be missing
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Clean up empty directories (bottom-up)
|
|
116
|
+
const dirs = new Set<string>();
|
|
117
|
+
for (const file of trackedFiles) {
|
|
118
|
+
let dir = dirname(file);
|
|
119
|
+
while (dir !== '.') {
|
|
120
|
+
dirs.add(dir);
|
|
121
|
+
dir = dirname(dir);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Sort longest first so we delete deepest directories first
|
|
125
|
+
const sortedDirs = [...dirs].sort((a, b) => b.length - a.length);
|
|
126
|
+
for (const dir of sortedDirs) {
|
|
127
|
+
try {
|
|
128
|
+
await rmdir(join(root, dir));
|
|
129
|
+
} catch {
|
|
130
|
+
// Directory not empty or doesn't exist — skip
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
61
135
|
// Delete .sgit/
|
|
62
136
|
await rm(join(root, '.sgit'), { recursive: true, force: true });
|
|
63
137
|
|
|
64
138
|
// Delete .sgit.json
|
|
65
139
|
await rm(join(root, '.sgit.json'), { force: true });
|
|
66
140
|
|
|
67
|
-
|
|
141
|
+
if (opts.keepFiles) {
|
|
142
|
+
logger.success('Shadow repository removed. Tracked files kept on disk.');
|
|
143
|
+
} else {
|
|
144
|
+
logger.success('Shadow repository and tracked files removed.');
|
|
145
|
+
}
|
|
68
146
|
}
|
|
69
147
|
|
|
70
148
|
async function confirm(message: string): Promise<boolean> {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
3
|
+
import { execGit } from '../../core/git.js';
|
|
4
|
+
import { normalizePath, isPathCoveredByManifest } from '../../core/paths.js';
|
|
5
|
+
import { addIgnore, readManifest } from '../../core/manifest.js';
|
|
6
|
+
import { syncExcludes } from './sync.js';
|
|
7
|
+
import { PathNotTrackedError } from '../../core/errors.js';
|
|
8
|
+
import * as logger from '../../utils/logger.js';
|
|
9
|
+
|
|
10
|
+
export async function trackIgnore(paths: string[]): Promise<void> {
|
|
11
|
+
const root = await findRoot();
|
|
12
|
+
await validateShadowRepo(root);
|
|
13
|
+
const sgitDir = join(root, '.sgit');
|
|
14
|
+
|
|
15
|
+
const added: string[] = [];
|
|
16
|
+
|
|
17
|
+
for (const inputPath of paths) {
|
|
18
|
+
const normalized = await normalizePath(inputPath, root);
|
|
19
|
+
|
|
20
|
+
// Must be covered by a tracked path
|
|
21
|
+
const manifest = await readManifest(root);
|
|
22
|
+
if (!isPathCoveredByManifest(normalized, manifest.paths)) {
|
|
23
|
+
throw new PathNotTrackedError(normalized);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Add to ignore list
|
|
27
|
+
const wasAdded = await addIgnore(root, normalized);
|
|
28
|
+
if (wasAdded) {
|
|
29
|
+
added.push(normalized);
|
|
30
|
+
|
|
31
|
+
// Untrack from shadow repo index if tracked
|
|
32
|
+
const rmPath = normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;
|
|
33
|
+
try {
|
|
34
|
+
const rmArgs = normalized.endsWith('/')
|
|
35
|
+
? ['rm', '--cached', '-r', rmPath]
|
|
36
|
+
: ['rm', '--cached', rmPath];
|
|
37
|
+
await execGit(rmArgs, { gitDir: sgitDir, workTree: root });
|
|
38
|
+
} catch {
|
|
39
|
+
// Not in index
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Sync exclude files
|
|
45
|
+
await syncExcludes(root);
|
|
46
|
+
|
|
47
|
+
if (added.length > 0) {
|
|
48
|
+
logger.success(`Ignoring ${added.length} path(s):`);
|
|
49
|
+
for (const p of added) {
|
|
50
|
+
logger.info(` ${p}`);
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
logger.info('All paths already ignored.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -7,7 +7,10 @@ import { trackAdd } from './add.js';
|
|
|
7
7
|
import { trackRemove } from './remove.js';
|
|
8
8
|
import { trackList } from './list.js';
|
|
9
9
|
import { trackVerify } from './verify.js';
|
|
10
|
+
import { trackClone } from './clone.js';
|
|
10
11
|
import { trackDestroy } from './destroy.js';
|
|
12
|
+
import { trackIgnore } from './ignore.js';
|
|
13
|
+
import { trackUnignore } from './unignore.js';
|
|
11
14
|
import { trackCompletion } from './completion.js';
|
|
12
15
|
|
|
13
16
|
export function registerTrackCommands(program: Command): void {
|
|
@@ -20,6 +23,12 @@ export function registerTrackCommands(program: Command): void {
|
|
|
20
23
|
.description('Initialize shadow repository')
|
|
21
24
|
.action(wrapAction(trackInit));
|
|
22
25
|
|
|
26
|
+
track
|
|
27
|
+
.command('clone')
|
|
28
|
+
.description('Clone a shadow repository from a remote URL')
|
|
29
|
+
.argument('<url>')
|
|
30
|
+
.action(wrapAction(async (url: string) => trackClone(url)));
|
|
31
|
+
|
|
23
32
|
track
|
|
24
33
|
.command('add')
|
|
25
34
|
.description('Start shadow-tracking paths')
|
|
@@ -54,12 +63,25 @@ export function registerTrackCommands(program: Command): void {
|
|
|
54
63
|
),
|
|
55
64
|
);
|
|
56
65
|
|
|
66
|
+
track
|
|
67
|
+
.command('ignore')
|
|
68
|
+
.description('Ignore paths within tracked directories')
|
|
69
|
+
.argument('<paths...>')
|
|
70
|
+
.action(wrapAction(async (paths: string[]) => trackIgnore(paths)));
|
|
71
|
+
|
|
72
|
+
track
|
|
73
|
+
.command('unignore')
|
|
74
|
+
.description('Stop ignoring paths within tracked directories')
|
|
75
|
+
.argument('<paths...>')
|
|
76
|
+
.action(wrapAction(async (paths: string[]) => trackUnignore(paths)));
|
|
77
|
+
|
|
57
78
|
track
|
|
58
79
|
.command('destroy')
|
|
59
80
|
.description('Remove shadow repository entirely')
|
|
60
81
|
.option('--force', 'Skip confirmation prompt')
|
|
82
|
+
.option('--keep-files', 'Keep tracked files on disk (default: delete them)')
|
|
61
83
|
.action(
|
|
62
|
-
wrapAction(async (opts: { force?: boolean }) => trackDestroy(opts)),
|
|
84
|
+
wrapAction(async (opts: { force?: boolean; keepFiles?: boolean }) => trackDestroy(opts)),
|
|
63
85
|
);
|
|
64
86
|
|
|
65
87
|
track
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
findRoot,
|
|
6
6
|
validateNotWorktree,
|
|
7
7
|
validateNotSubmodule,
|
|
8
|
+
validateGitIdentity,
|
|
8
9
|
} from '../../core/repository.js';
|
|
9
10
|
import { execGit } from '../../core/git.js';
|
|
10
11
|
import { writeManifest } from '../../core/manifest.js';
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
generateMainExclude,
|
|
13
14
|
generateShadowExclude,
|
|
14
15
|
} from '../../core/exclude.js';
|
|
15
|
-
import { installMainHook, installShadowHook } from '../../core/hooks.js';
|
|
16
|
+
import { installMainHook, installShadowHook, installShadowSyncHooks } from '../../core/hooks.js';
|
|
16
17
|
import { AlreadyInitializedError } from '../../core/errors.js';
|
|
17
18
|
import * as logger from '../../utils/logger.js';
|
|
18
19
|
|
|
@@ -20,6 +21,7 @@ export async function trackInit(): Promise<void> {
|
|
|
20
21
|
const root = await findRoot();
|
|
21
22
|
await validateNotWorktree(root);
|
|
22
23
|
await validateNotSubmodule(root);
|
|
24
|
+
await validateGitIdentity(root);
|
|
23
25
|
|
|
24
26
|
const sgitDir = join(root, '.sgit');
|
|
25
27
|
|
|
@@ -63,7 +65,7 @@ export async function trackInit(): Promise<void> {
|
|
|
63
65
|
);
|
|
64
66
|
|
|
65
67
|
// Create .sgit.json
|
|
66
|
-
await writeManifest(root, { paths: [] });
|
|
68
|
+
await writeManifest(root, { paths: [], ignore: [] });
|
|
67
69
|
|
|
68
70
|
// Update .git/info/exclude
|
|
69
71
|
const mainExcludePath = join(root, '.git', 'info', 'exclude');
|
|
@@ -79,6 +81,7 @@ export async function trackInit(): Promise<void> {
|
|
|
79
81
|
// Install hooks
|
|
80
82
|
await installMainHook(root);
|
|
81
83
|
await installShadowHook(sgitDir);
|
|
84
|
+
await installShadowSyncHooks(sgitDir);
|
|
82
85
|
|
|
83
86
|
// Stage and commit .sgit.json
|
|
84
87
|
await execGit(['add', '-f', '.sgit.json'], {
|
|
@@ -29,7 +29,7 @@ export async function syncExcludes(root: string): Promise<void> {
|
|
|
29
29
|
// Regenerate .sgit/info/exclude
|
|
30
30
|
const sgitExcludePath = join(root, '.sgit', 'info', 'exclude');
|
|
31
31
|
await mkdir(join(root, '.sgit', 'info'), { recursive: true });
|
|
32
|
-
await writeFile(sgitExcludePath, generateShadowExclude(manifest.paths));
|
|
32
|
+
await writeFile(sgitExcludePath, generateShadowExclude(manifest.paths, manifest.ignore));
|
|
33
33
|
|
|
34
34
|
// Warn for missing paths
|
|
35
35
|
for (const p of manifest.paths) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { resolve, relative, sep } from 'node:path';
|
|
2
|
+
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
3
|
+
import { removeIgnore, readManifest } from '../../core/manifest.js';
|
|
4
|
+
import { syncExcludes } from './sync.js';
|
|
5
|
+
import { SgitError } from '../../core/errors.js';
|
|
6
|
+
import * as logger from '../../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
export async function trackUnignore(paths: string[]): Promise<void> {
|
|
9
|
+
const root = await findRoot();
|
|
10
|
+
await validateShadowRepo(root);
|
|
11
|
+
|
|
12
|
+
const removed: string[] = [];
|
|
13
|
+
|
|
14
|
+
for (const inputPath of paths) {
|
|
15
|
+
// Normalize without stat (same approach as remove.ts)
|
|
16
|
+
const absolute = resolve(process.cwd(), inputPath);
|
|
17
|
+
let normalized = relative(root, absolute).split(sep).join('/');
|
|
18
|
+
if (inputPath.endsWith('/') && !normalized.endsWith('/')) {
|
|
19
|
+
normalized += '/';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Find matching entry in ignore list (try exact, with/without trailing slash)
|
|
23
|
+
const manifest = await readManifest(root);
|
|
24
|
+
const exactPath =
|
|
25
|
+
manifest.ignore.find((p) => p === normalized) ||
|
|
26
|
+
manifest.ignore.find((p) => p === normalized + '/') ||
|
|
27
|
+
(normalized.endsWith('/')
|
|
28
|
+
? manifest.ignore.find((p) => p === normalized.slice(0, -1))
|
|
29
|
+
: undefined);
|
|
30
|
+
|
|
31
|
+
if (!exactPath) {
|
|
32
|
+
throw new SgitError(`${normalized} is not in the ignore list`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const wasRemoved = await removeIgnore(root, exactPath);
|
|
36
|
+
if (wasRemoved) {
|
|
37
|
+
removed.push(exactPath);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Sync exclude files
|
|
42
|
+
await syncExcludes(root);
|
|
43
|
+
|
|
44
|
+
if (removed.length > 0) {
|
|
45
|
+
logger.success(`Unignored ${removed.length} path(s):`);
|
|
46
|
+
for (const p of removed) {
|
|
47
|
+
logger.info(` ${p}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -3,6 +3,7 @@ import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
|
3
3
|
import { execGit } from '../../core/git.js';
|
|
4
4
|
import { readManifest } from '../../core/manifest.js';
|
|
5
5
|
import { isPathCoveredByManifest } from '../../core/paths.js';
|
|
6
|
+
import { findStaleGitignores, isGuardActive } from '../../core/gitignore.js';
|
|
6
7
|
import { SgitError } from '../../core/errors.js';
|
|
7
8
|
import * as logger from '../../utils/logger.js';
|
|
8
9
|
|
|
@@ -13,44 +14,116 @@ export interface VerifyResult {
|
|
|
13
14
|
|
|
14
15
|
export async function verifyMain(root: string): Promise<VerifyResult> {
|
|
15
16
|
const manifest = await readManifest(root);
|
|
16
|
-
|
|
17
|
+
const violations: string[] = [];
|
|
18
|
+
|
|
19
|
+
// Check for stale .gitignore-sgit.bat (skip if a guard is active)
|
|
20
|
+
if (!isGuardActive(root)) {
|
|
21
|
+
const stale = findStaleGitignores(root, manifest.paths);
|
|
22
|
+
violations.push(
|
|
23
|
+
...stale.map(
|
|
24
|
+
(f) => `${f} (stale .gitignore-sgit.bat — rename back to .gitignore)`,
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check staged files aren't shadow-tracked (exclude deletions — removing
|
|
30
|
+
// a shadow-tracked file from main git is correct behavior)
|
|
17
31
|
try {
|
|
18
|
-
staged = await execGit(['diff', '--cached', '--name-only'], {
|
|
32
|
+
const staged = await execGit(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], {
|
|
19
33
|
gitDir: join(root, '.git'),
|
|
20
34
|
workTree: root,
|
|
21
35
|
});
|
|
36
|
+
if (staged.trim()) {
|
|
37
|
+
const stagedFiles = staged.trim().split('\n');
|
|
38
|
+
violations.push(
|
|
39
|
+
...stagedFiles.filter((f) => isPathCoveredByManifest(f, manifest.paths, manifest.ignore)),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
22
42
|
} catch {
|
|
23
|
-
|
|
43
|
+
// No staged files
|
|
24
44
|
}
|
|
25
45
|
|
|
26
|
-
if (!staged.trim()) return { valid: true, violations: [] };
|
|
27
|
-
|
|
28
|
-
const stagedFiles = staged.trim().split('\n');
|
|
29
|
-
const violations = stagedFiles.filter((f) =>
|
|
30
|
-
isPathCoveredByManifest(f, manifest.paths),
|
|
31
|
-
);
|
|
32
|
-
|
|
33
46
|
return { valid: violations.length === 0, violations };
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
export async function verifyShadow(root: string): Promise<VerifyResult> {
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
let manifest: { paths: string[]; ignore: string[] };
|
|
51
|
+
try {
|
|
52
|
+
manifest = await readManifest(root);
|
|
53
|
+
} catch {
|
|
54
|
+
// .sgit.json is missing or corrupt on disk — flag it
|
|
55
|
+
return {
|
|
56
|
+
valid: false,
|
|
57
|
+
violations: ['.sgit.json (missing or invalid on disk)'],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const sgitDir = join(root, '.sgit');
|
|
61
|
+
const violations: string[] = [];
|
|
62
|
+
|
|
63
|
+
// Check for stale .gitignore-sgit.bat (skip if a guard is active)
|
|
64
|
+
if (!isGuardActive(root)) {
|
|
65
|
+
const stale = findStaleGitignores(root, manifest.paths);
|
|
66
|
+
violations.push(
|
|
67
|
+
...stale.map(
|
|
68
|
+
(f) => `${f} (stale .gitignore-sgit.bat — rename back to .gitignore)`,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check .sgit.json is not staged for deletion
|
|
39
74
|
try {
|
|
40
|
-
|
|
41
|
-
gitDir:
|
|
75
|
+
const deleted = await execGit(['diff', '--cached', '--name-only', '--diff-filter=D'], {
|
|
76
|
+
gitDir: sgitDir,
|
|
42
77
|
workTree: root,
|
|
43
78
|
});
|
|
79
|
+
if (deleted.trim().split('\n').includes('.sgit.json')) {
|
|
80
|
+
violations.push('.sgit.json (cannot delete sgit manifest)');
|
|
81
|
+
}
|
|
44
82
|
} catch {
|
|
45
|
-
|
|
83
|
+
// No deletions
|
|
46
84
|
}
|
|
47
85
|
|
|
48
|
-
|
|
86
|
+
// If .sgit.json is staged with modifications, verify content is valid
|
|
87
|
+
try {
|
|
88
|
+
const modified = await execGit(['diff', '--cached', '--name-only', '--diff-filter=M'], {
|
|
89
|
+
gitDir: sgitDir,
|
|
90
|
+
workTree: root,
|
|
91
|
+
});
|
|
92
|
+
if (modified.trim().split('\n').includes('.sgit.json')) {
|
|
93
|
+
const content = await execGit(['show', ':0:.sgit.json'], {
|
|
94
|
+
gitDir: sgitDir,
|
|
95
|
+
workTree: root,
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(content);
|
|
99
|
+
if (!Array.isArray(parsed.paths)) {
|
|
100
|
+
violations.push('.sgit.json (invalid: missing "paths" array)');
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
violations.push('.sgit.json (invalid JSON)');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// No modifications
|
|
108
|
+
}
|
|
49
109
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
110
|
+
// Check staged files are covered by manifest
|
|
111
|
+
try {
|
|
112
|
+
const staged = await execGit(['diff', '--cached', '--name-only'], {
|
|
113
|
+
gitDir: sgitDir,
|
|
114
|
+
workTree: root,
|
|
115
|
+
});
|
|
116
|
+
if (staged.trim()) {
|
|
117
|
+
const stagedFiles = staged.trim().split('\n');
|
|
118
|
+
violations.push(
|
|
119
|
+
...stagedFiles.filter(
|
|
120
|
+
(f) => f !== '.sgit.json' && !isPathCoveredByManifest(f, manifest.paths, manifest.ignore),
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// No staged files
|
|
126
|
+
}
|
|
54
127
|
|
|
55
128
|
return { valid: violations.length === 0, violations };
|
|
56
129
|
}
|
package/src/core/errors.ts
CHANGED
|
@@ -7,7 +7,15 @@ export class SgitError extends Error {
|
|
|
7
7
|
|
|
8
8
|
export class NotGitRepoError extends SgitError {
|
|
9
9
|
constructor() {
|
|
10
|
-
super('not a git repository');
|
|
10
|
+
super('not a git repository (no .git in current directory)');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class NoGitIdentityError extends SgitError {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(
|
|
17
|
+
'git user identity not configured; set user.name and user.email with \'git config\'',
|
|
18
|
+
);
|
|
11
19
|
}
|
|
12
20
|
}
|
|
13
21
|
|
|
@@ -19,7 +27,7 @@ export class AlreadyInitializedError extends SgitError {
|
|
|
19
27
|
|
|
20
28
|
export class NotInitializedError extends SgitError {
|
|
21
29
|
constructor() {
|
|
22
|
-
super('not a shadow git repository; run \'sgit track init\'
|
|
30
|
+
super('not a shadow git repository; run \'sgit track init\' or \'sgit track clone <url>\'');
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -40,3 +48,16 @@ export class PathNotTrackedError extends SgitError {
|
|
|
40
48
|
super(`${path} is not tracked by sgit`);
|
|
41
49
|
}
|
|
42
50
|
}
|
|
51
|
+
|
|
52
|
+
export class StaleGitignoreError extends SgitError {
|
|
53
|
+
constructor(files: string[]) {
|
|
54
|
+
super(
|
|
55
|
+
[
|
|
56
|
+
'found stale .gitignore-sgit.bat files (a previous sgit command did not complete cleanly):',
|
|
57
|
+
...files.map((f) => ` ${f}`),
|
|
58
|
+
'',
|
|
59
|
+
'rename them back to .gitignore manually, then retry.',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|