shadow-git 1.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadow-git",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "A secondary git repository for tracking files excluded from your main repo",
5
5
  "type": "module",
6
6
  "bin": {
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
- // Skip guard for commands that manage gitignores themselves
17
- const skipGuard =
18
- (args[0] === 'track' && (args[1] === 'init' || args[1] === 'clone')) || args[0] === 'debug';
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);
@@ -1,8 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { join } from 'node:path';
2
+ import { resolveShadowDir } from '../core/swap-mode.js';
3
3
 
4
- export function forwardToGit(root: string, args: string[]): number {
5
- const sgitDir = join(root, '.sgit');
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-sgit.bat']);
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 = join(root, '.sgit');
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: join(root, '.git'),
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: join(root, '.git'), workTree: root });
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 = join(root, '.sgit');
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 { join, resolve, relative, sep } from 'node:path';
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 = join(root, '.sgit');
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 .git/info/exclude
16
- const mainExcludePath = join(root, '.git', 'info', 'exclude');
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(root, '.git', 'info'), { recursive: true });
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 .sgit/info/exclude
30
- const sgitExcludePath = join(root, '.sgit', 'info', 'exclude');
31
- await mkdir(join(root, '.sgit', 'info'), { recursive: true });
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
- // Check for stale .gitignore-sgit.bat (skip if a guard is active)
20
- if (!isGuardActive(root)) {
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-sgit.bat — rename back to .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: join(root, '.git'),
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 = join(root, '.sgit');
61
+ const sgitDir = await resolveShadowDir(root);
61
62
  const violations: string[] = [];
62
63
 
63
- // Check for stale .gitignore-sgit.bat (skip if a guard is active)
64
- if (!isGuardActive(root)) {
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-sgit.bat — rename back to .gitignore)`,
70
+ (f) => `${f} (stale .gitignore.sgit-parked — rename back to .gitignore)`,
69
71
  ),
70
72
  );
71
73
  }
@@ -53,7 +53,7 @@ export class StaleGitignoreError extends SgitError {
53
53
  constructor(files: string[]) {
54
54
  super(
55
55
  [
56
- 'found stale .gitignore-sgit.bat files (a previous sgit command did not complete cleanly):',
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
+ }
@@ -32,7 +32,7 @@ export function generateMainExclude(
32
32
  preserved.pop();
33
33
  }
34
34
 
35
- const sgitEntries = ['.sgit', '.sgit.json', '.gitignore-sgit.bat', ...manifestPaths];
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-sgit.bat',
88
+ '.gitignore.sgit-parked',
89
89
  SGIT_BLOCK_END,
90
90
  ].join('\n') + '\n';
91
91
  }
@@ -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-sgit.bat';
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-sgit.bat files from a previous crash; restoring',
106
+ 'found stale .gitignore.sgit-parked files from a previous crash; restoring',
92
107
  );
93
108
  enableGitignores(root, manifestPaths);
94
109
  }
@@ -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
  }