shadow-git 0.1.0 → 1.0.3

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 +17 -30
  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,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 { SGIT_MARKER, SGIT_MANAGED_HEADER } from '../../core/exclude.js';
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
- const confirmed = await confirm(
17
- 'This will permanently remove the shadow repository. Continue?',
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 === SGIT_MANAGED_HEADER) {
34
- skipNext = false;
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 (skipNext) {
46
- skipNext = false;
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
- logger.success('Shadow repository removed.');
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
- let staged: string;
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
- return { valid: true, violations: [] };
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
- const manifest = await readManifest(root);
38
- let staged: string;
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
- staged = await execGit(['diff', '--cached', '--name-only'], {
41
- gitDir: join(root, '.sgit'),
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
- return { valid: true, violations: [] };
83
+ // No deletions
46
84
  }
47
85
 
48
- if (!staged.trim()) return { valid: true, violations: [] };
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
- const stagedFiles = staged.trim().split('\n');
51
- const violations = stagedFiles.filter(
52
- (f) => f !== '.sgit.json' && !isPathCoveredByManifest(f, manifest.paths),
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
  }
@@ -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\' first');
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
+ }