shadow-git 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.temp/.sgit/info/exclude +1 -1
- package/CLAUDE.md +2 -1
- package/dist/cli.js +322 -74
- package/package.json +1 -1
- package/src/cli.ts +36 -4
- package/src/commands/forward.ts +3 -3
- package/src/commands/track/add.ts +6 -4
- package/src/commands/track/clone.ts +16 -1
- package/src/commands/track/destroy.ts +5 -0
- package/src/commands/track/ignore.ts +2 -2
- package/src/commands/track/index.ts +12 -0
- package/src/commands/track/init.ts +5 -1
- package/src/commands/track/remove.ts +3 -2
- package/src/commands/track/swap.ts +61 -0
- package/src/commands/track/sync.ts +9 -6
- package/src/commands/track/unswap.ts +49 -0
- package/src/commands/track/verify.ts +11 -9
- package/src/core/errors.ts +31 -1
- package/src/core/exclude.ts +2 -2
- package/src/core/gitignore.ts +18 -3
- package/src/core/repository.ts +8 -0
- package/src/core/swap-mode.ts +83 -0
- package/src/utils/logger.ts +12 -0
- package/tests/commands/forward.test.ts +6 -6
- package/tests/commands/track/add.test.ts +6 -6
- package/tests/commands/track/swap.test.ts +266 -0
- package/tests/commands/track/verify.test.ts +16 -16
- package/tests/core/exclude.test.ts +10 -10
- package/tests/core/gitignore.test.ts +30 -30
- package/tests/helpers/guard-child.mjs +2 -2
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { registerDebugCommands } from './commands/debug/index.js';
|
|
|
6
6
|
import { SgitError } from './core/errors.js';
|
|
7
7
|
import { readManifest } from './core/manifest.js';
|
|
8
8
|
import { setupGitignoreGuard } from './core/gitignore.js';
|
|
9
|
+
import { isSwapMode, assertConsistentState } from './core/swap-mode.js';
|
|
10
|
+
import { SwapModeCorruptError } from './core/errors.js';
|
|
9
11
|
import * as logger from './utils/logger.js';
|
|
10
12
|
|
|
11
13
|
const COMMANDER_COMMANDS = new Set(['track', 'debug']);
|
|
@@ -13,9 +15,37 @@ const COMMANDER_COMMANDS = new Set(['track', 'debug']);
|
|
|
13
15
|
async function run(): Promise<void> {
|
|
14
16
|
const args = process.argv.slice(2);
|
|
15
17
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Detect swap mode (and surface any inconsistent state) up front
|
|
19
|
+
let swapActive = false;
|
|
20
|
+
try {
|
|
21
|
+
const root = await findRoot();
|
|
22
|
+
await assertConsistentState(root);
|
|
23
|
+
swapActive = await isSwapMode(root);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err instanceof SwapModeCorruptError) {
|
|
26
|
+
logger.error(err.message);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
// No git repo, no .sgit, etc. — proceed without swap-mode signals
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Suppress banner for the swap/unswap commands themselves — their own
|
|
33
|
+
// success messages already describe the state transition.
|
|
34
|
+
const suppressBanner =
|
|
35
|
+
args[0] === 'track' && (args[1] === 'swap' || args[1] === 'unswap');
|
|
36
|
+
if (swapActive && !suppressBanner) {
|
|
37
|
+
logger.swapModeBanner();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Skip guard for commands that manage gitignores themselves, and whenever
|
|
41
|
+
// swap mode is active (gitignores are already disabled until unswap).
|
|
42
|
+
const selfManagedGuard =
|
|
43
|
+
args[0] === 'track' &&
|
|
44
|
+
(args[1] === 'init' ||
|
|
45
|
+
args[1] === 'clone' ||
|
|
46
|
+
args[1] === 'swap' ||
|
|
47
|
+
args[1] === 'unswap');
|
|
48
|
+
const skipGuard = selfManagedGuard || args[0] === 'debug' || swapActive;
|
|
19
49
|
|
|
20
50
|
// Set up gitignore guard
|
|
21
51
|
let guardCleanup: (() => void) | undefined;
|
|
@@ -55,6 +85,8 @@ Track commands:
|
|
|
55
85
|
sgit track unignore Stop ignoring paths
|
|
56
86
|
sgit track sync Regenerate exclude files
|
|
57
87
|
sgit track verify Validate staged files
|
|
88
|
+
sgit track swap Swap .git with .sgit for IDE compatibility
|
|
89
|
+
sgit track unswap Exit swap mode
|
|
58
90
|
sgit track destroy Remove shadow repository
|
|
59
91
|
sgit track completion Install shell completions
|
|
60
92
|
|
|
@@ -66,7 +98,7 @@ All other commands are forwarded to git in the shadow repository:
|
|
|
66
98
|
} else {
|
|
67
99
|
const root = await findRoot();
|
|
68
100
|
await validateShadowRepo(root);
|
|
69
|
-
const exitCode = forwardToGit(root, args);
|
|
101
|
+
const exitCode = await forwardToGit(root, args);
|
|
70
102
|
guardCleanup?.();
|
|
71
103
|
guardCleanup = undefined;
|
|
72
104
|
process.exit(exitCode);
|
package/src/commands/forward.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveShadowDir } from '../core/swap-mode.js';
|
|
3
3
|
|
|
4
|
-
export function forwardToGit(root: string, args: string[]): number {
|
|
5
|
-
const sgitDir =
|
|
4
|
+
export async function forwardToGit(root: string, args: string[]): Promise<number> {
|
|
5
|
+
const sgitDir = await resolveShadowDir(root);
|
|
6
6
|
const result = spawnSync(
|
|
7
7
|
'git',
|
|
8
8
|
[`--git-dir=${sgitDir}`, `--work-tree=${root}`, ...args],
|
|
@@ -11,14 +11,16 @@ import {
|
|
|
11
11
|
PathTrackedByMainError,
|
|
12
12
|
SgitError,
|
|
13
13
|
} from '../../core/errors.js';
|
|
14
|
+
import { resolveShadowDir, resolveMainDir } from '../../core/swap-mode.js';
|
|
14
15
|
import * as logger from '../../utils/logger.js';
|
|
15
16
|
|
|
16
|
-
const BLOCKED_BASENAMES = new Set(['.gitignore', '.gitignore-
|
|
17
|
+
const BLOCKED_BASENAMES = new Set(['.gitignore', '.gitignore.sgit-parked']);
|
|
17
18
|
|
|
18
19
|
export async function trackAdd(paths: string[], opts: { adopt?: boolean } = {}): Promise<void> {
|
|
19
20
|
const root = await findRoot();
|
|
20
21
|
await validateShadowRepo(root);
|
|
21
|
-
const sgitDir =
|
|
22
|
+
const sgitDir = await resolveShadowDir(root);
|
|
23
|
+
const mainDir = await resolveMainDir(root);
|
|
22
24
|
|
|
23
25
|
const added: string[] = [];
|
|
24
26
|
|
|
@@ -51,7 +53,7 @@ export async function trackAdd(paths: string[], opts: { adopt?: boolean } = {}):
|
|
|
51
53
|
let trackedByMain = false;
|
|
52
54
|
try {
|
|
53
55
|
await execGit(['ls-files', '--error-unmatch', checkPath], {
|
|
54
|
-
gitDir:
|
|
56
|
+
gitDir: mainDir,
|
|
55
57
|
workTree: root,
|
|
56
58
|
});
|
|
57
59
|
trackedByMain = true;
|
|
@@ -64,7 +66,7 @@ export async function trackAdd(paths: string[], opts: { adopt?: boolean } = {}):
|
|
|
64
66
|
const rmArgs = normalized.endsWith('/')
|
|
65
67
|
? ['rm', '--cached', '-r', checkPath]
|
|
66
68
|
: ['rm', '--cached', checkPath];
|
|
67
|
-
await execGit(rmArgs, { gitDir:
|
|
69
|
+
await execGit(rmArgs, { gitDir: mainDir, workTree: root });
|
|
68
70
|
} else if (!opts.adopt) {
|
|
69
71
|
throw new PathTrackedByMainError(normalized);
|
|
70
72
|
}
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
import { execGit } from '../../core/git.js';
|
|
13
13
|
import { installMainHook, installShadowHook, installShadowSyncHooks } from '../../core/hooks.js';
|
|
14
14
|
import { syncExcludes } from './sync.js';
|
|
15
|
-
import { AlreadyInitializedError, SgitError } from '../../core/errors.js';
|
|
15
|
+
import { AlreadyInitializedError, SwapModeActiveError, SgitError } from '../../core/errors.js';
|
|
16
|
+
import { isSwapMode } from '../../core/swap-mode.js';
|
|
16
17
|
import type { Manifest } from '../../core/manifest.js';
|
|
17
18
|
import * as logger from '../../utils/logger.js';
|
|
18
19
|
|
|
@@ -21,6 +22,9 @@ export async function trackClone(
|
|
|
21
22
|
opts: { onConflict?: 1 | 2 | 3 } = {},
|
|
22
23
|
): Promise<void> {
|
|
23
24
|
const root = await findRoot();
|
|
25
|
+
if (await isSwapMode(root)) {
|
|
26
|
+
throw new SwapModeActiveError('clone');
|
|
27
|
+
}
|
|
24
28
|
await validateNotWorktree(root);
|
|
25
29
|
await validateNotSubmodule(root);
|
|
26
30
|
await validateGitIdentity(root);
|
|
@@ -49,6 +53,17 @@ export async function trackClone(
|
|
|
49
53
|
await execGit(['config', 'core.bare', 'false'], { gitDir: sgitDir });
|
|
50
54
|
await execGit(['config', 'core.worktree', root], { gitDir: sgitDir });
|
|
51
55
|
|
|
56
|
+
// `git clone --bare` sets a mirror-style fetch refspec which is wrong for
|
|
57
|
+
// a working (non-bare) repo — overwrite with the normal refspec so that
|
|
58
|
+
// `sgit fetch` populates refs/remotes/origin/* and refs like `origin/main`
|
|
59
|
+
// resolve as expected.
|
|
60
|
+
await execGit(
|
|
61
|
+
['config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'],
|
|
62
|
+
{ gitDir: sgitDir },
|
|
63
|
+
);
|
|
64
|
+
// Populate remote-tracking refs from the already-fetched objects.
|
|
65
|
+
await execGit(['fetch', 'origin'], { gitDir: sgitDir });
|
|
66
|
+
|
|
52
67
|
// Copy user identity from main repo
|
|
53
68
|
try {
|
|
54
69
|
const name = await execGit(['config', 'user.name'], { cwd: root });
|
|
@@ -5,6 +5,8 @@ import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
|
5
5
|
import { uninstallMainHook } from '../../core/hooks.js';
|
|
6
6
|
import { execGit } from '../../core/git.js';
|
|
7
7
|
import { SGIT_BLOCK_START, SGIT_BLOCK_END } from '../../core/exclude.js';
|
|
8
|
+
import { isSwapMode } from '../../core/swap-mode.js';
|
|
9
|
+
import { SwapModeActiveError } from '../../core/errors.js';
|
|
8
10
|
import * as logger from '../../utils/logger.js';
|
|
9
11
|
|
|
10
12
|
export async function trackDestroy(opts: {
|
|
@@ -12,6 +14,9 @@ export async function trackDestroy(opts: {
|
|
|
12
14
|
keepFiles?: boolean;
|
|
13
15
|
}): Promise<void> {
|
|
14
16
|
const root = await findRoot();
|
|
17
|
+
if (await isSwapMode(root)) {
|
|
18
|
+
throw new SwapModeActiveError('destroy');
|
|
19
|
+
}
|
|
15
20
|
await validateShadowRepo(root);
|
|
16
21
|
const sgitDir = join(root, '.sgit');
|
|
17
22
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
1
|
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
3
2
|
import { execGit } from '../../core/git.js';
|
|
4
3
|
import { normalizePath, isPathCoveredByManifest } from '../../core/paths.js';
|
|
5
4
|
import { addIgnore, readManifest } from '../../core/manifest.js';
|
|
6
5
|
import { syncExcludes } from './sync.js';
|
|
7
6
|
import { PathNotTrackedError } from '../../core/errors.js';
|
|
7
|
+
import { resolveShadowDir } from '../../core/swap-mode.js';
|
|
8
8
|
import * as logger from '../../utils/logger.js';
|
|
9
9
|
|
|
10
10
|
export async function trackIgnore(paths: string[]): Promise<void> {
|
|
11
11
|
const root = await findRoot();
|
|
12
12
|
await validateShadowRepo(root);
|
|
13
|
-
const sgitDir =
|
|
13
|
+
const sgitDir = await resolveShadowDir(root);
|
|
14
14
|
|
|
15
15
|
const added: string[] = [];
|
|
16
16
|
|
|
@@ -12,6 +12,8 @@ import { trackDestroy } from './destroy.js';
|
|
|
12
12
|
import { trackIgnore } from './ignore.js';
|
|
13
13
|
import { trackUnignore } from './unignore.js';
|
|
14
14
|
import { trackCompletion } from './completion.js';
|
|
15
|
+
import { trackSwap } from './swap.js';
|
|
16
|
+
import { trackUnswap } from './unswap.js';
|
|
15
17
|
|
|
16
18
|
export function registerTrackCommands(program: Command): void {
|
|
17
19
|
const track = program
|
|
@@ -94,6 +96,16 @@ export function registerTrackCommands(program: Command): void {
|
|
|
94
96
|
trackCompletion(action, opts),
|
|
95
97
|
),
|
|
96
98
|
);
|
|
99
|
+
|
|
100
|
+
track
|
|
101
|
+
.command('swap')
|
|
102
|
+
.description('Swap .git with .sgit so IDE tools see the shadow repo as the active git')
|
|
103
|
+
.action(wrapAction(trackSwap));
|
|
104
|
+
|
|
105
|
+
track
|
|
106
|
+
.command('unswap')
|
|
107
|
+
.description('Exit swap mode and restore the main repository')
|
|
108
|
+
.action(wrapAction(trackUnswap));
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
export function wrapAction(
|
|
@@ -14,11 +14,15 @@ import {
|
|
|
14
14
|
generateShadowExclude,
|
|
15
15
|
} from '../../core/exclude.js';
|
|
16
16
|
import { installMainHook, installShadowHook, installShadowSyncHooks } from '../../core/hooks.js';
|
|
17
|
-
import { AlreadyInitializedError } from '../../core/errors.js';
|
|
17
|
+
import { AlreadyInitializedError, SwapModeActiveError } from '../../core/errors.js';
|
|
18
|
+
import { isSwapMode } from '../../core/swap-mode.js';
|
|
18
19
|
import * as logger from '../../utils/logger.js';
|
|
19
20
|
|
|
20
21
|
export async function trackInit(): Promise<void> {
|
|
21
22
|
const root = await findRoot();
|
|
23
|
+
if (await isSwapMode(root)) {
|
|
24
|
+
throw new SwapModeActiveError('init');
|
|
25
|
+
}
|
|
22
26
|
await validateNotWorktree(root);
|
|
23
27
|
await validateNotSubmodule(root);
|
|
24
28
|
await validateGitIdentity(root);
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolve, relative, sep } from 'node:path';
|
|
2
2
|
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
3
3
|
import { execGit } from '../../core/git.js';
|
|
4
4
|
import { removePath, readManifest } from '../../core/manifest.js';
|
|
5
5
|
import { syncExcludes } from './sync.js';
|
|
6
6
|
import { PathNotTrackedError } from '../../core/errors.js';
|
|
7
|
+
import { resolveShadowDir } from '../../core/swap-mode.js';
|
|
7
8
|
import * as logger from '../../utils/logger.js';
|
|
8
9
|
|
|
9
10
|
export async function trackRemove(paths: string[]): Promise<void> {
|
|
10
11
|
const root = await findRoot();
|
|
11
12
|
await validateShadowRepo(root);
|
|
12
|
-
const sgitDir =
|
|
13
|
+
const sgitDir = await resolveShadowDir(root);
|
|
13
14
|
|
|
14
15
|
const removed: string[] = [];
|
|
15
16
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { rename } from 'node:fs/promises';
|
|
3
|
+
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
4
|
+
import { readManifest } from '../../core/manifest.js';
|
|
5
|
+
import {
|
|
6
|
+
disableGitignores,
|
|
7
|
+
enableGitignores,
|
|
8
|
+
} from '../../core/gitignore.js';
|
|
9
|
+
import {
|
|
10
|
+
isSwapMode,
|
|
11
|
+
assertNoInProgressOps,
|
|
12
|
+
SWAP_MARKER,
|
|
13
|
+
} from '../../core/swap-mode.js';
|
|
14
|
+
import { AlreadySwappedError } from '../../core/errors.js';
|
|
15
|
+
import * as logger from '../../utils/logger.js';
|
|
16
|
+
|
|
17
|
+
export async function trackSwap(): Promise<void> {
|
|
18
|
+
const root = await findRoot();
|
|
19
|
+
await validateShadowRepo(root);
|
|
20
|
+
|
|
21
|
+
if (await isSwapMode(root)) {
|
|
22
|
+
throw new AlreadySwappedError();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await assertNoInProgressOps(join(root, '.git'));
|
|
26
|
+
|
|
27
|
+
const manifest = await readManifest(root);
|
|
28
|
+
const gitPath = join(root, '.git');
|
|
29
|
+
const sgitPath = join(root, '.sgit');
|
|
30
|
+
const disabledPath = join(root, SWAP_MARKER);
|
|
31
|
+
|
|
32
|
+
// Disable gitignores first (they must stay disabled throughout swap mode)
|
|
33
|
+
disableGitignores(root, manifest.paths);
|
|
34
|
+
|
|
35
|
+
let gitRenamed = false;
|
|
36
|
+
try {
|
|
37
|
+
await rename(gitPath, disabledPath);
|
|
38
|
+
gitRenamed = true;
|
|
39
|
+
await rename(sgitPath, gitPath);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
// Rollback: restore .git and re-enable gitignores
|
|
42
|
+
if (gitRenamed) {
|
|
43
|
+
try {
|
|
44
|
+
await rename(disabledPath, gitPath);
|
|
45
|
+
} catch {
|
|
46
|
+
// Leave as-is; user must recover manually
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
enableGitignores(root, manifest.paths);
|
|
51
|
+
} catch {
|
|
52
|
+
// best effort
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
logger.success(
|
|
58
|
+
'Swap mode enabled. .sgit is now .git; main git is parked at .git.sgit-parked.',
|
|
59
|
+
);
|
|
60
|
+
logger.info("Run 'sgit track unswap' to exit.");
|
|
61
|
+
}
|
|
@@ -7,28 +7,31 @@ import {
|
|
|
7
7
|
generateMainExclude,
|
|
8
8
|
generateShadowExclude,
|
|
9
9
|
} from '../../core/exclude.js';
|
|
10
|
+
import { resolveShadowDir, resolveMainDir } from '../../core/swap-mode.js';
|
|
10
11
|
import * as logger from '../../utils/logger.js';
|
|
11
12
|
|
|
12
13
|
export async function syncExcludes(root: string): Promise<void> {
|
|
13
14
|
const manifest = await readManifest(root);
|
|
15
|
+
const mainDir = await resolveMainDir(root);
|
|
16
|
+
const shadowDir = await resolveShadowDir(root);
|
|
14
17
|
|
|
15
|
-
// Regenerate
|
|
16
|
-
const mainExcludePath = join(
|
|
18
|
+
// Regenerate <main>/info/exclude
|
|
19
|
+
const mainExcludePath = join(mainDir, 'info', 'exclude');
|
|
17
20
|
let existingExclude = '';
|
|
18
21
|
try {
|
|
19
22
|
existingExclude = await readFile(mainExcludePath, 'utf-8');
|
|
20
23
|
} catch {
|
|
21
24
|
// File might not exist
|
|
22
25
|
}
|
|
23
|
-
await mkdir(join(
|
|
26
|
+
await mkdir(join(mainDir, 'info'), { recursive: true });
|
|
24
27
|
await writeFile(
|
|
25
28
|
mainExcludePath,
|
|
26
29
|
generateMainExclude(existingExclude, manifest.paths),
|
|
27
30
|
);
|
|
28
31
|
|
|
29
|
-
// Regenerate
|
|
30
|
-
const sgitExcludePath = join(
|
|
31
|
-
await mkdir(join(
|
|
32
|
+
// Regenerate <shadow>/info/exclude
|
|
33
|
+
const sgitExcludePath = join(shadowDir, 'info', 'exclude');
|
|
34
|
+
await mkdir(join(shadowDir, 'info'), { recursive: true });
|
|
32
35
|
await writeFile(sgitExcludePath, generateShadowExclude(manifest.paths, manifest.ignore));
|
|
33
36
|
|
|
34
37
|
// Warn for missing paths
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { rename } from 'node:fs/promises';
|
|
3
|
+
import { findRoot } from '../../core/repository.js';
|
|
4
|
+
import { readManifest } from '../../core/manifest.js';
|
|
5
|
+
import { enableGitignores } from '../../core/gitignore.js';
|
|
6
|
+
import {
|
|
7
|
+
isSwapMode,
|
|
8
|
+
assertNoInProgressOps,
|
|
9
|
+
SWAP_MARKER,
|
|
10
|
+
} from '../../core/swap-mode.js';
|
|
11
|
+
import { NotSwappedError } from '../../core/errors.js';
|
|
12
|
+
import * as logger from '../../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
export async function trackUnswap(): Promise<void> {
|
|
15
|
+
const root = await findRoot();
|
|
16
|
+
|
|
17
|
+
if (!(await isSwapMode(root))) {
|
|
18
|
+
throw new NotSwappedError();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const gitPath = join(root, '.git');
|
|
22
|
+
const sgitPath = join(root, '.sgit');
|
|
23
|
+
const disabledPath = join(root, SWAP_MARKER);
|
|
24
|
+
|
|
25
|
+
// In swap mode, .git is the shadow — make sure it's idle
|
|
26
|
+
await assertNoInProgressOps(gitPath);
|
|
27
|
+
|
|
28
|
+
let shadowRenamed = false;
|
|
29
|
+
try {
|
|
30
|
+
await rename(gitPath, sgitPath);
|
|
31
|
+
shadowRenamed = true;
|
|
32
|
+
await rename(disabledPath, gitPath);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (shadowRenamed) {
|
|
35
|
+
try {
|
|
36
|
+
await rename(sgitPath, gitPath);
|
|
37
|
+
} catch {
|
|
38
|
+
// Leave for manual recovery
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Re-enable gitignores using the now-restored manifest
|
|
45
|
+
const manifest = await readManifest(root);
|
|
46
|
+
enableGitignores(root, manifest.paths);
|
|
47
|
+
|
|
48
|
+
logger.success('Swap mode disabled. Main git restored.');
|
|
49
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
1
|
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
3
2
|
import { execGit } from '../../core/git.js';
|
|
4
3
|
import { readManifest } from '../../core/manifest.js';
|
|
5
4
|
import { isPathCoveredByManifest } from '../../core/paths.js';
|
|
6
5
|
import { findStaleGitignores, isGuardActive } from '../../core/gitignore.js';
|
|
7
6
|
import { SgitError } from '../../core/errors.js';
|
|
7
|
+
import { resolveShadowDir, resolveMainDir, isSwapMode } from '../../core/swap-mode.js';
|
|
8
8
|
import * as logger from '../../utils/logger.js';
|
|
9
9
|
|
|
10
10
|
export interface VerifyResult {
|
|
@@ -16,12 +16,13 @@ export async function verifyMain(root: string): Promise<VerifyResult> {
|
|
|
16
16
|
const manifest = await readManifest(root);
|
|
17
17
|
const violations: string[] = [];
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// Skip stale check when guard is active (normal command in flight) or
|
|
20
|
+
// when swap mode is active (gitignores are intentionally disabled).
|
|
21
|
+
if (!isGuardActive(root) && !(await isSwapMode(root))) {
|
|
21
22
|
const stale = findStaleGitignores(root, manifest.paths);
|
|
22
23
|
violations.push(
|
|
23
24
|
...stale.map(
|
|
24
|
-
(f) => `${f} (stale .gitignore-
|
|
25
|
+
(f) => `${f} (stale .gitignore.sgit-parked — rename back to .gitignore)`,
|
|
25
26
|
),
|
|
26
27
|
);
|
|
27
28
|
}
|
|
@@ -30,7 +31,7 @@ export async function verifyMain(root: string): Promise<VerifyResult> {
|
|
|
30
31
|
// a shadow-tracked file from main git is correct behavior)
|
|
31
32
|
try {
|
|
32
33
|
const staged = await execGit(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], {
|
|
33
|
-
gitDir:
|
|
34
|
+
gitDir: await resolveMainDir(root),
|
|
34
35
|
workTree: root,
|
|
35
36
|
});
|
|
36
37
|
if (staged.trim()) {
|
|
@@ -57,15 +58,16 @@ export async function verifyShadow(root: string): Promise<VerifyResult> {
|
|
|
57
58
|
violations: ['.sgit.json (missing or invalid on disk)'],
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
|
-
const sgitDir =
|
|
61
|
+
const sgitDir = await resolveShadowDir(root);
|
|
61
62
|
const violations: string[] = [];
|
|
62
63
|
|
|
63
|
-
//
|
|
64
|
-
|
|
64
|
+
// Skip stale check when guard is active (normal command in flight) or
|
|
65
|
+
// when swap mode is active (gitignores are intentionally disabled).
|
|
66
|
+
if (!isGuardActive(root) && !(await isSwapMode(root))) {
|
|
65
67
|
const stale = findStaleGitignores(root, manifest.paths);
|
|
66
68
|
violations.push(
|
|
67
69
|
...stale.map(
|
|
68
|
-
(f) => `${f} (stale .gitignore-
|
|
70
|
+
(f) => `${f} (stale .gitignore.sgit-parked — rename back to .gitignore)`,
|
|
69
71
|
),
|
|
70
72
|
);
|
|
71
73
|
}
|
package/src/core/errors.ts
CHANGED
|
@@ -53,7 +53,7 @@ export class StaleGitignoreError extends SgitError {
|
|
|
53
53
|
constructor(files: string[]) {
|
|
54
54
|
super(
|
|
55
55
|
[
|
|
56
|
-
'found stale .gitignore-
|
|
56
|
+
'found stale .gitignore.sgit-parked files (a previous sgit command did not complete cleanly):',
|
|
57
57
|
...files.map((f) => ` ${f}`),
|
|
58
58
|
'',
|
|
59
59
|
'rename them back to .gitignore manually, then retry.',
|
|
@@ -61,3 +61,33 @@ export class StaleGitignoreError extends SgitError {
|
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
export class AlreadySwappedError extends SgitError {
|
|
66
|
+
constructor() {
|
|
67
|
+
super("shadow-git is already in swap mode; run 'sgit track unswap' to exit.");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class NotSwappedError extends SgitError {
|
|
72
|
+
constructor() {
|
|
73
|
+
super('shadow-git is not in swap mode.');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class SwapModeActiveError extends SgitError {
|
|
78
|
+
constructor(op: string) {
|
|
79
|
+
super(`cannot ${op} while in swap mode; run 'sgit track unswap' first.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class SwapModeCorruptError extends SgitError {
|
|
84
|
+
constructor(details: string) {
|
|
85
|
+
super(`shadow-git state is inconsistent:\n${details}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class InProgressGitOpError extends SgitError {
|
|
90
|
+
constructor(gitDir: string, marker: string) {
|
|
91
|
+
super(`cannot proceed: ${gitDir} has an in-progress operation (${marker}). Finish or abort it first.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/core/exclude.ts
CHANGED
|
@@ -32,7 +32,7 @@ export function generateMainExclude(
|
|
|
32
32
|
preserved.pop();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const sgitEntries = ['.sgit', '.sgit.json', '.gitignore-
|
|
35
|
+
const sgitEntries = ['.sgit', '.sgit.json', '.git.sgit-parked', '.gitignore.sgit-parked', ...manifestPaths];
|
|
36
36
|
|
|
37
37
|
return [
|
|
38
38
|
...preserved,
|
|
@@ -85,7 +85,7 @@ export function generateShadowExclude(manifestPaths: string[], ignorePaths: stri
|
|
|
85
85
|
'!.sgit.json',
|
|
86
86
|
...entryLines,
|
|
87
87
|
...ignorePaths,
|
|
88
|
-
'.gitignore-
|
|
88
|
+
'.gitignore.sgit-parked',
|
|
89
89
|
SGIT_BLOCK_END,
|
|
90
90
|
].join('\n') + '\n';
|
|
91
91
|
}
|
package/src/core/gitignore.ts
CHANGED
|
@@ -7,18 +7,31 @@ import {
|
|
|
7
7
|
unlinkSync,
|
|
8
8
|
} from 'node:fs';
|
|
9
9
|
import { join, relative, sep } from 'node:path';
|
|
10
|
-
import { StaleGitignoreError } from './errors.js';
|
|
10
|
+
import { StaleGitignoreError, SwapModeActiveError } from './errors.js';
|
|
11
|
+
import { isSwapModeSync } from './swap-mode.js';
|
|
11
12
|
import * as logger from '../utils/logger.js';
|
|
12
13
|
|
|
13
|
-
const DISABLED_NAME = '.gitignore-
|
|
14
|
+
const DISABLED_NAME = '.gitignore.sgit-parked';
|
|
14
15
|
const LOCK_FILE = '.gitignore-guard';
|
|
15
16
|
|
|
17
|
+
// Refuse to touch gitignore state while swap mode is active — swap/unswap are
|
|
18
|
+
// the only commands that transition this state, and they call these helpers
|
|
19
|
+
// outside the swap window (swap before the marker exists, unswap after it is
|
|
20
|
+
// removed). Any other caller (CLI guard, debug gitignores, ad-hoc use) would
|
|
21
|
+
// de-sync the invariant documented in CLAUDE.md.
|
|
22
|
+
function refuseIfSwapped(root: string, op: string): void {
|
|
23
|
+
if (isSwapModeSync(root)) {
|
|
24
|
+
throw new SwapModeActiveError(op);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
// --- Public API ---
|
|
17
29
|
|
|
18
30
|
export function disableGitignores(
|
|
19
31
|
root: string,
|
|
20
32
|
manifestPaths: string[],
|
|
21
33
|
): string[] {
|
|
34
|
+
refuseIfSwapped(root, 'disable gitignores');
|
|
22
35
|
const gitignores = findInTrackedDirs(root, manifestPaths, '.gitignore');
|
|
23
36
|
if (gitignores.length === 0) return [];
|
|
24
37
|
|
|
@@ -41,6 +54,7 @@ export function enableGitignores(
|
|
|
41
54
|
root: string,
|
|
42
55
|
manifestPaths: string[],
|
|
43
56
|
): string[] {
|
|
57
|
+
refuseIfSwapped(root, 'enable gitignores');
|
|
44
58
|
const disabled = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
|
|
45
59
|
const enabled: string[] = [];
|
|
46
60
|
for (const relPath of disabled) {
|
|
@@ -77,6 +91,7 @@ export function setupGitignoreGuard(
|
|
|
77
91
|
root: string,
|
|
78
92
|
manifestPaths: string[],
|
|
79
93
|
): () => void {
|
|
94
|
+
refuseIfSwapped(root, 'set up gitignore guard');
|
|
80
95
|
const lockPath = join(root, '.sgit', LOCK_FILE);
|
|
81
96
|
|
|
82
97
|
// Nested invocation — another sgit process owns the guard
|
|
@@ -88,7 +103,7 @@ export function setupGitignoreGuard(
|
|
|
88
103
|
const stale = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
|
|
89
104
|
if (stale.length > 0) {
|
|
90
105
|
logger.warn(
|
|
91
|
-
'found stale .gitignore-
|
|
106
|
+
'found stale .gitignore.sgit-parked files from a previous crash; restoring',
|
|
92
107
|
);
|
|
93
108
|
enableGitignores(root, manifestPaths);
|
|
94
109
|
}
|
package/src/core/repository.ts
CHANGED
|
@@ -28,8 +28,16 @@ export async function validateGitIdentity(root: string): Promise<void> {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export async function validateShadowRepo(root: string): Promise<void> {
|
|
31
|
+
// In swap mode, .sgit is renamed to .git and the marker .git.sgit-parked exists
|
|
31
32
|
try {
|
|
32
33
|
await access(join(root, '.sgit'), constants.F_OK);
|
|
34
|
+
return;
|
|
35
|
+
} catch {
|
|
36
|
+
// fall through
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await access(join(root, '.git.sgit-parked'), constants.F_OK);
|
|
40
|
+
return;
|
|
33
41
|
} catch {
|
|
34
42
|
throw new NotInitializedError();
|
|
35
43
|
}
|