rafcode 2.5.0-0 → 2.5.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/CLAUDE.md +1 -1
- package/RAF/ahwvrz-legacy-sunset/decisions.md +10 -0
- package/RAF/ahwvrz-legacy-sunset/input.md +10 -0
- package/RAF/ahwvrz-legacy-sunset/outcomes/01-remove-migrate-command.md +30 -0
- package/RAF/ahwvrz-legacy-sunset/outcomes/02-fix-resume-worktree-resolution.md +62 -0
- package/RAF/ahwvrz-legacy-sunset/plans/01-remove-migrate-command.md +65 -0
- package/RAF/ahwvrz-legacy-sunset/plans/02-fix-resume-worktree-resolution.md +72 -0
- package/RAF/ahwzmc-echo-forge/decisions.md +15 -0
- package/RAF/ahwzmc-echo-forge/input.md +4 -0
- package/RAF/ahwzmc-echo-forge/outcomes/01-change-low-effort-default-to-sonnet.md +57 -0
- package/RAF/ahwzmc-echo-forge/outcomes/02-add-no-worktree-flag.md +79 -0
- package/RAF/ahwzmc-echo-forge/outcomes/03-update-readme.md +75 -0
- package/RAF/ahwzmc-echo-forge/plans/01-change-low-effort-default-to-sonnet.md +57 -0
- package/RAF/ahwzmc-echo-forge/plans/02-add-no-worktree-flag.md +51 -0
- package/RAF/ahwzmc-echo-forge/plans/03-update-readme.md +48 -0
- package/RAF/aifqwf-fix-amend-commit-again/decisions.md +7 -0
- package/RAF/aifqwf-fix-amend-commit-again/input.md +2 -0
- package/RAF/aifqwf-fix-amend-commit-again/outcomes/01-update-effort-mapping-defaults.md +35 -0
- package/RAF/aifqwf-fix-amend-commit-again/outcomes/02-fix-amend-worktree-commit.md +50 -0
- package/RAF/aifqwf-fix-amend-commit-again/plans/01-update-effort-mapping-defaults.md +37 -0
- package/RAF/aifqwf-fix-amend-commit-again/plans/02-fix-amend-worktree-commit.md +55 -0
- package/README.md +26 -29
- package/dist/commands/do.d.ts.map +1 -1
- package/dist/commands/do.js +1 -0
- package/dist/commands/do.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +51 -39
- package/dist/commands/plan.js.map +1 -1
- package/dist/core/git.d.ts.map +1 -1
- package/dist/core/git.js +20 -4
- package/dist/core/git.js.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/prompts/amend.d.ts.map +1 -1
- package/dist/prompts/amend.js +3 -1
- package/dist/prompts/amend.js.map +1 -1
- package/dist/prompts/planning.d.ts.map +1 -1
- package/dist/prompts/planning.js +4 -1
- package/dist/prompts/planning.js.map +1 -1
- package/dist/types/config.d.ts +0 -4
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +2 -2
- package/dist/types/config.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/do.ts +1 -0
- package/src/commands/plan.ts +54 -42
- package/src/core/git.ts +23 -4
- package/src/index.ts +0 -2
- package/src/prompts/amend.ts +3 -1
- package/src/prompts/config-docs.md +7 -7
- package/src/prompts/planning.ts +4 -1
- package/src/types/config.ts +2 -7
- package/tests/unit/commit-planning-artifacts-worktree.test.ts +113 -0
- package/tests/unit/commit-planning-artifacts.test.ts +1 -1
- package/tests/unit/config-command.test.ts +2 -2
- package/tests/unit/config.test.ts +14 -14
- package/tests/unit/plan-resume-worktree-resolution.test.ts +153 -0
- package/tests/unit/worktree-flag-override.test.ts +186 -0
- package/src/commands/migrate.ts +0 -269
- package/tests/unit/migrate-command.test.ts +0 -197
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for worktree resolution in the --resume command.
|
|
5
|
+
*
|
|
6
|
+
* When `raf plan --resume <identifier>` is run, it should:
|
|
7
|
+
* 1. Search worktrees first
|
|
8
|
+
* 2. Fall back to main repo if not found in worktree
|
|
9
|
+
* 3. Auto-detect worktree mode without requiring --worktree flag
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
describe('Plan Resume - Worktree Resolution Logic', () => {
|
|
13
|
+
describe('resolution flow logic', () => {
|
|
14
|
+
it('should prioritize worktree over main repo when both exist', () => {
|
|
15
|
+
// This test verifies the conceptual resolution order used in runResumeCommand:
|
|
16
|
+
// 1. Try worktree resolution via resolveWorktreeProjectByIdentifier()
|
|
17
|
+
// 2. If found and valid → use worktree path and worktree root as CWD
|
|
18
|
+
// 3. If not found → fall back to main repo resolution
|
|
19
|
+
|
|
20
|
+
// Simulating the flow:
|
|
21
|
+
const worktreeFound = true;
|
|
22
|
+
const worktreeValid = true;
|
|
23
|
+
|
|
24
|
+
let useWorktree = false;
|
|
25
|
+
let useMainRepo = false;
|
|
26
|
+
|
|
27
|
+
// Step 1: Try worktree
|
|
28
|
+
if (worktreeFound && worktreeValid) {
|
|
29
|
+
useWorktree = true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Step 2: Fall back to main repo if worktree not used
|
|
33
|
+
if (!useWorktree) {
|
|
34
|
+
useMainRepo = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
expect(useWorktree).toBe(true);
|
|
38
|
+
expect(useMainRepo).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should fall back to main repo when worktree is invalid', () => {
|
|
42
|
+
const worktreeFound = true;
|
|
43
|
+
const worktreeValid = false;
|
|
44
|
+
|
|
45
|
+
let useWorktree = false;
|
|
46
|
+
let useMainRepo = false;
|
|
47
|
+
|
|
48
|
+
if (worktreeFound && worktreeValid) {
|
|
49
|
+
useWorktree = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!useWorktree) {
|
|
53
|
+
useMainRepo = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
expect(useWorktree).toBe(false);
|
|
57
|
+
expect(useMainRepo).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should fall back to main repo when worktree not found', () => {
|
|
61
|
+
const worktreeFound = false;
|
|
62
|
+
|
|
63
|
+
let useWorktree = false;
|
|
64
|
+
let useMainRepo = false;
|
|
65
|
+
|
|
66
|
+
if (worktreeFound) {
|
|
67
|
+
useWorktree = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!useWorktree) {
|
|
71
|
+
useMainRepo = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
expect(useWorktree).toBe(false);
|
|
75
|
+
expect(useMainRepo).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('resumeCwd determination', () => {
|
|
80
|
+
it('should set resumeCwd to worktree root when project found in valid worktree', () => {
|
|
81
|
+
// Simulated values
|
|
82
|
+
const worktreeRoot = '/Users/user/.raf/worktrees/RAF/ahwvrz-legacy-sunset';
|
|
83
|
+
const projectPath = '/Users/user/.raf/worktrees/RAF/ahwvrz-legacy-sunset/RAF/ahwvrz-legacy-sunset';
|
|
84
|
+
|
|
85
|
+
const worktreeValid = true;
|
|
86
|
+
let resumeCwd: string | undefined;
|
|
87
|
+
|
|
88
|
+
if (worktreeValid) {
|
|
89
|
+
resumeCwd = worktreeRoot;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
expect(resumeCwd).toBe(worktreeRoot);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should set resumeCwd to project path when using main repo', () => {
|
|
96
|
+
const mainRepoProjectPath = '/Users/user/myapp/RAF/ahwvrz-legacy-sunset';
|
|
97
|
+
|
|
98
|
+
const worktreeFound = false;
|
|
99
|
+
let resumeCwd: string | undefined;
|
|
100
|
+
|
|
101
|
+
if (!worktreeFound) {
|
|
102
|
+
resumeCwd = mainRepoProjectPath;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
expect(resumeCwd).toBe(mainRepoProjectPath);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('variable initialization', () => {
|
|
110
|
+
it('should handle undefined variables correctly when worktree is found', () => {
|
|
111
|
+
let projectPath: string | undefined;
|
|
112
|
+
let resumeCwd: string | undefined;
|
|
113
|
+
let folderName: string | undefined;
|
|
114
|
+
|
|
115
|
+
// Simulate worktree resolution
|
|
116
|
+
const worktreeResolution = {
|
|
117
|
+
folder: 'ahwvrz-legacy-sunset',
|
|
118
|
+
worktreeRoot: '/Users/user/.raf/worktrees/RAF/ahwvrz-legacy-sunset',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (worktreeResolution) {
|
|
122
|
+
folderName = worktreeResolution.folder;
|
|
123
|
+
projectPath = `/path/to/${folderName}`;
|
|
124
|
+
resumeCwd = worktreeResolution.worktreeRoot;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expect(folderName).toBeDefined();
|
|
128
|
+
expect(projectPath).toBeDefined();
|
|
129
|
+
expect(resumeCwd).toBeDefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should handle undefined variables correctly when falling back to main repo', () => {
|
|
133
|
+
let projectPath: string | undefined;
|
|
134
|
+
let resumeCwd: string | undefined;
|
|
135
|
+
let folderName: string | undefined;
|
|
136
|
+
|
|
137
|
+
// Worktree resolution returns null
|
|
138
|
+
const worktreeResolution = null;
|
|
139
|
+
|
|
140
|
+
// Skip worktree assignment since it's null
|
|
141
|
+
if (!projectPath && !worktreeResolution) {
|
|
142
|
+
// Main repo resolution
|
|
143
|
+
projectPath = '/Users/user/myapp/RAF/ahwvrz-legacy-sunset';
|
|
144
|
+
folderName = 'ahwvrz-legacy-sunset';
|
|
145
|
+
resumeCwd = projectPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
expect(folderName).toBeDefined();
|
|
149
|
+
expect(projectPath).toBeDefined();
|
|
150
|
+
expect(resumeCwd).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { createPlanCommand } from '../../src/commands/plan.js';
|
|
6
|
+
import { createDoCommand } from '../../src/commands/do.js';
|
|
7
|
+
import { getWorktreeDefault, resetConfigCache, saveConfig } from '../../src/utils/config.js';
|
|
8
|
+
|
|
9
|
+
describe('Worktree Flag Override', () => {
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
let originalHome: string | undefined;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-worktree-flag-test-'));
|
|
15
|
+
originalHome = process.env.HOME;
|
|
16
|
+
process.env.HOME = tempDir;
|
|
17
|
+
resetConfigCache();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
process.env.HOME = originalHome;
|
|
22
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
+
resetConfigCache();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('Commander.js --no-worktree flag parsing', () => {
|
|
27
|
+
it('should parse --worktree as true', () => {
|
|
28
|
+
const planCommand = createPlanCommand();
|
|
29
|
+
// Use parseOptions instead of parse to avoid running the action
|
|
30
|
+
planCommand.parseOptions(['--worktree']);
|
|
31
|
+
const opts = planCommand.opts();
|
|
32
|
+
expect(opts.worktree).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should parse --no-worktree as false', () => {
|
|
36
|
+
const planCommand = createPlanCommand();
|
|
37
|
+
planCommand.parseOptions(['--no-worktree']);
|
|
38
|
+
const opts = planCommand.opts();
|
|
39
|
+
expect(opts.worktree).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should parse omitted flag as undefined', () => {
|
|
43
|
+
const planCommand = createPlanCommand();
|
|
44
|
+
planCommand.parseOptions([]);
|
|
45
|
+
const opts = planCommand.opts();
|
|
46
|
+
expect(opts.worktree).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should parse --worktree for do command as true', () => {
|
|
50
|
+
const doCommand = createDoCommand();
|
|
51
|
+
doCommand.parseOptions(['--worktree']);
|
|
52
|
+
const opts = doCommand.opts();
|
|
53
|
+
expect(opts.worktree).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should parse --no-worktree for do command as false', () => {
|
|
57
|
+
const doCommand = createDoCommand();
|
|
58
|
+
doCommand.parseOptions(['--no-worktree']);
|
|
59
|
+
const opts = doCommand.opts();
|
|
60
|
+
expect(opts.worktree).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should parse omitted flag for do command as undefined', () => {
|
|
64
|
+
const doCommand = createDoCommand();
|
|
65
|
+
doCommand.parseOptions([]);
|
|
66
|
+
const opts = doCommand.opts();
|
|
67
|
+
expect(opts.worktree).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('Config resolution with --no-worktree flag', () => {
|
|
72
|
+
it('should resolve to true when --worktree flag is passed (regardless of config)', () => {
|
|
73
|
+
// Simulate: options.worktree = true (from --worktree flag)
|
|
74
|
+
const options = { worktree: true };
|
|
75
|
+
// With nullish coalescing, explicit true takes precedence
|
|
76
|
+
const resolved = options.worktree ?? getWorktreeDefault();
|
|
77
|
+
expect(resolved).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should resolve to false when --no-worktree flag is passed (regardless of config)', () => {
|
|
81
|
+
// Simulate: options.worktree = false (from --no-worktree flag)
|
|
82
|
+
const options = { worktree: false };
|
|
83
|
+
// With nullish coalescing, explicit false takes precedence
|
|
84
|
+
const resolved = options.worktree ?? getWorktreeDefault();
|
|
85
|
+
expect(resolved).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should resolve to config default when no flag is passed', () => {
|
|
89
|
+
// Simulate: options.worktree = undefined (no flag passed)
|
|
90
|
+
const options = { worktree: undefined };
|
|
91
|
+
// With nullish coalescing, undefined falls back to getWorktreeDefault()
|
|
92
|
+
const resolved = options.worktree ?? getWorktreeDefault();
|
|
93
|
+
// We can't assert a specific value here since it depends on the user's actual config
|
|
94
|
+
expect(typeof resolved).toBe('boolean');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Tri-state behavior verification', () => {
|
|
99
|
+
it('should correctly handle all three states (true/false/undefined) in plan command', () => {
|
|
100
|
+
// State 1: --worktree (explicit true)
|
|
101
|
+
const planCmd1 = createPlanCommand();
|
|
102
|
+
planCmd1.parseOptions(['--worktree']);
|
|
103
|
+
const opts1 = planCmd1.opts();
|
|
104
|
+
expect(opts1.worktree).toBe(true);
|
|
105
|
+
const resolved1 = opts1.worktree ?? getWorktreeDefault();
|
|
106
|
+
expect(resolved1).toBe(true);
|
|
107
|
+
|
|
108
|
+
// State 2: --no-worktree (explicit false)
|
|
109
|
+
const planCmd2 = createPlanCommand();
|
|
110
|
+
planCmd2.parseOptions(['--no-worktree']);
|
|
111
|
+
const opts2 = planCmd2.opts();
|
|
112
|
+
expect(opts2.worktree).toBe(false);
|
|
113
|
+
const resolved2 = opts2.worktree ?? getWorktreeDefault();
|
|
114
|
+
expect(resolved2).toBe(false);
|
|
115
|
+
|
|
116
|
+
// State 3: omitted (undefined, falls back to config)
|
|
117
|
+
const planCmd3 = createPlanCommand();
|
|
118
|
+
planCmd3.parseOptions([]);
|
|
119
|
+
const opts3 = planCmd3.opts();
|
|
120
|
+
expect(opts3.worktree).toBeUndefined();
|
|
121
|
+
const resolved3 = opts3.worktree ?? getWorktreeDefault();
|
|
122
|
+
// Should be a boolean (actual value depends on config)
|
|
123
|
+
expect(typeof resolved3).toBe('boolean');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should correctly handle all three states (true/false/undefined) in do command', () => {
|
|
127
|
+
// State 1: --worktree (explicit true)
|
|
128
|
+
const doCmd1 = createDoCommand();
|
|
129
|
+
doCmd1.parseOptions(['--worktree']);
|
|
130
|
+
const opts1 = doCmd1.opts();
|
|
131
|
+
expect(opts1.worktree).toBe(true);
|
|
132
|
+
const resolved1 = opts1.worktree ?? getWorktreeDefault();
|
|
133
|
+
expect(resolved1).toBe(true);
|
|
134
|
+
|
|
135
|
+
// State 2: --no-worktree (explicit false)
|
|
136
|
+
const doCmd2 = createDoCommand();
|
|
137
|
+
doCmd2.parseOptions(['--no-worktree']);
|
|
138
|
+
const opts2 = doCmd2.opts();
|
|
139
|
+
expect(opts2.worktree).toBe(false);
|
|
140
|
+
const resolved2 = opts2.worktree ?? getWorktreeDefault();
|
|
141
|
+
expect(resolved2).toBe(false);
|
|
142
|
+
|
|
143
|
+
// State 3: omitted (undefined, falls back to config)
|
|
144
|
+
const doCmd3 = createDoCommand();
|
|
145
|
+
doCmd3.parseOptions([]);
|
|
146
|
+
const opts3 = doCmd3.opts();
|
|
147
|
+
expect(opts3.worktree).toBeUndefined();
|
|
148
|
+
const resolved3 = opts3.worktree ?? getWorktreeDefault();
|
|
149
|
+
// Should be a boolean (actual value depends on config)
|
|
150
|
+
expect(typeof resolved3).toBe('boolean');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('Override semantics', () => {
|
|
155
|
+
it('--no-worktree should override config default (explicit false takes precedence)', () => {
|
|
156
|
+
const planCommand = createPlanCommand();
|
|
157
|
+
planCommand.parseOptions(['--no-worktree']);
|
|
158
|
+
const opts = planCommand.opts();
|
|
159
|
+
const resolved = opts.worktree ?? getWorktreeDefault();
|
|
160
|
+
|
|
161
|
+
expect(opts.worktree).toBe(false); // Flag sets explicit false
|
|
162
|
+
expect(resolved).toBe(false); // Final result is false (flag takes precedence)
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('--worktree should override config default (explicit true takes precedence)', () => {
|
|
166
|
+
const doCommand = createDoCommand();
|
|
167
|
+
doCommand.parseOptions(['--worktree']);
|
|
168
|
+
const opts = doCommand.opts();
|
|
169
|
+
const resolved = opts.worktree ?? getWorktreeDefault();
|
|
170
|
+
|
|
171
|
+
expect(opts.worktree).toBe(true); // Flag sets explicit true
|
|
172
|
+
expect(resolved).toBe(true); // Final result is true (flag takes precedence)
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('omitting flag should fall back to config default', () => {
|
|
176
|
+
const planCommand = createPlanCommand();
|
|
177
|
+
planCommand.parseOptions([]);
|
|
178
|
+
const opts = planCommand.opts();
|
|
179
|
+
const resolved = opts.worktree ?? getWorktreeDefault();
|
|
180
|
+
|
|
181
|
+
expect(opts.worktree).toBeUndefined(); // Flag not set
|
|
182
|
+
expect(typeof resolved).toBe('boolean'); // Falls back to config (which is a boolean)
|
|
183
|
+
expect(resolved).toBe(getWorktreeDefault()); // Final result matches config
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/commands/migrate.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import * as fs from 'node:fs';
|
|
2
|
-
import * as path from 'node:path';
|
|
3
|
-
import { Command } from 'commander';
|
|
4
|
-
import { getRafDir, encodeBase26 } from '../utils/paths.js';
|
|
5
|
-
import { logger } from '../utils/logger.js';
|
|
6
|
-
import { getRepoBasename, computeWorktreeBaseDir } from '../core/worktree.js';
|
|
7
|
-
import type { MigrateCommandOptions } from '../types/config.js';
|
|
8
|
-
|
|
9
|
-
/** 3-char base36 legacy prefix: e.g., "007", "01a", "1zz" */
|
|
10
|
-
const LEGACY_3CHAR_PATTERN = /^([0-9a-z]{3})-(.+)$/;
|
|
11
|
-
|
|
12
|
-
/** 6-char base36 legacy prefix with at least one digit: e.g., "021h44", "00j3k1" */
|
|
13
|
-
const LEGACY_6CHAR_PATTERN = /^([0-9a-z]{6})-(.+)$/;
|
|
14
|
-
|
|
15
|
-
/** Already-migrated base26 prefix: all lowercase letters, no digits */
|
|
16
|
-
const BASE26_PATTERN = /^[a-z]{6}-/;
|
|
17
|
-
|
|
18
|
-
export interface MigrationEntry {
|
|
19
|
-
oldName: string;
|
|
20
|
-
newName: string;
|
|
21
|
-
oldPath: string;
|
|
22
|
-
newPath: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Check if a 6-char prefix contains at least one digit (meaning it's legacy base36, not base26).
|
|
27
|
-
*/
|
|
28
|
-
function hasDigit(str: string): boolean {
|
|
29
|
-
return /[0-9]/.test(str);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Decode a base36 string of any length back to a number.
|
|
34
|
-
*/
|
|
35
|
-
function decodeBase36(str: string): number {
|
|
36
|
-
return parseInt(str, 36);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Scan a directory for legacy project folders and compute migrations.
|
|
41
|
-
* Returns an array of migration entries (old name -> new name).
|
|
42
|
-
*/
|
|
43
|
-
export function detectMigrations(dirPath: string): MigrationEntry[] {
|
|
44
|
-
if (!fs.existsSync(dirPath)) {
|
|
45
|
-
return [];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
49
|
-
const migrations: MigrationEntry[] = [];
|
|
50
|
-
|
|
51
|
-
for (const entry of entries) {
|
|
52
|
-
if (!entry.isDirectory()) continue;
|
|
53
|
-
|
|
54
|
-
const name = entry.name;
|
|
55
|
-
|
|
56
|
-
// Skip already-migrated base26 folders
|
|
57
|
-
if (BASE26_PATTERN.test(name)) continue;
|
|
58
|
-
|
|
59
|
-
// Try 3-char base36 pattern first
|
|
60
|
-
const match3 = name.match(LEGACY_3CHAR_PATTERN);
|
|
61
|
-
if (match3 && match3[1] && match3[2]) {
|
|
62
|
-
const prefix = match3[1];
|
|
63
|
-
const projectName = match3[2];
|
|
64
|
-
const numericValue = decodeBase36(prefix);
|
|
65
|
-
const newPrefix = encodeBase26(numericValue);
|
|
66
|
-
const newName = `${newPrefix}-${projectName}`;
|
|
67
|
-
|
|
68
|
-
migrations.push({
|
|
69
|
-
oldName: name,
|
|
70
|
-
newName,
|
|
71
|
-
oldPath: path.join(dirPath, name),
|
|
72
|
-
newPath: path.join(dirPath, newName),
|
|
73
|
-
});
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Try 6-char base36 pattern (must have at least one digit to distinguish from base26)
|
|
78
|
-
const match6 = name.match(LEGACY_6CHAR_PATTERN);
|
|
79
|
-
if (match6 && match6[1] && match6[2]) {
|
|
80
|
-
const prefix = match6[1];
|
|
81
|
-
if (!hasDigit(prefix)) continue; // Pure letters = already base26
|
|
82
|
-
|
|
83
|
-
const projectName = match6[2];
|
|
84
|
-
const numericValue = decodeBase36(prefix);
|
|
85
|
-
const newPrefix = encodeBase26(numericValue);
|
|
86
|
-
const newName = `${newPrefix}-${projectName}`;
|
|
87
|
-
|
|
88
|
-
migrations.push({
|
|
89
|
-
oldName: name,
|
|
90
|
-
newName,
|
|
91
|
-
oldPath: path.join(dirPath, name),
|
|
92
|
-
newPath: path.join(dirPath, newName),
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return migrations;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Check for collisions in a set of migrations (two old IDs mapping to the same base26 ID).
|
|
102
|
-
* Returns an array of collision descriptions if found.
|
|
103
|
-
*/
|
|
104
|
-
function findCollisions(migrations: MigrationEntry[]): string[] {
|
|
105
|
-
const targetNames = new Map<string, string[]>();
|
|
106
|
-
for (const m of migrations) {
|
|
107
|
-
const existing = targetNames.get(m.newName) ?? [];
|
|
108
|
-
existing.push(m.oldName);
|
|
109
|
-
targetNames.set(m.newName, existing);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const collisions: string[] = [];
|
|
113
|
-
for (const [newName, oldNames] of targetNames) {
|
|
114
|
-
if (oldNames.length > 1) {
|
|
115
|
-
collisions.push(`${oldNames.join(', ')} -> ${newName}`);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return collisions;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Check if a target name already exists on disk (collision with existing folder).
|
|
123
|
-
*/
|
|
124
|
-
function findExistingConflicts(migrations: MigrationEntry[]): string[] {
|
|
125
|
-
const conflicts: string[] = [];
|
|
126
|
-
for (const m of migrations) {
|
|
127
|
-
if (fs.existsSync(m.newPath)) {
|
|
128
|
-
conflicts.push(`${m.oldName} -> ${m.newName} (target already exists)`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return conflicts;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Execute migrations: rename folders on disk.
|
|
136
|
-
* Returns count of successful renames.
|
|
137
|
-
*/
|
|
138
|
-
function executeMigrations(migrations: MigrationEntry[]): { succeeded: number; errors: string[] } {
|
|
139
|
-
let succeeded = 0;
|
|
140
|
-
const errors: string[] = [];
|
|
141
|
-
|
|
142
|
-
for (const m of migrations) {
|
|
143
|
-
try {
|
|
144
|
-
fs.renameSync(m.oldPath, m.newPath);
|
|
145
|
-
succeeded++;
|
|
146
|
-
} catch (error) {
|
|
147
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
148
|
-
errors.push(`Failed to rename ${m.oldName}: ${msg}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return { succeeded, errors };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function createMigrateCommand(): Command {
|
|
156
|
-
const command = new Command('migrate-project-ids-base26')
|
|
157
|
-
.description('Rename project folders from legacy base36 IDs to base26 encoding')
|
|
158
|
-
.option('--dry-run', 'Preview changes without renaming')
|
|
159
|
-
.option('--worktree', 'Also migrate worktree project directories')
|
|
160
|
-
.action(async (options?: MigrateCommandOptions) => {
|
|
161
|
-
await runMigrateCommand(options);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
return command;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function runMigrateCommand(options?: MigrateCommandOptions): Promise<void> {
|
|
168
|
-
const dryRun = options?.dryRun ?? false;
|
|
169
|
-
const includeWorktree = options?.worktree ?? false;
|
|
170
|
-
|
|
171
|
-
// Collect directories to scan
|
|
172
|
-
const dirsToScan: Array<{ label: string; path: string }> = [];
|
|
173
|
-
|
|
174
|
-
// Main RAF directory
|
|
175
|
-
const rafDir = getRafDir();
|
|
176
|
-
dirsToScan.push({ label: 'RAF', path: rafDir });
|
|
177
|
-
|
|
178
|
-
// Worktree directories
|
|
179
|
-
if (includeWorktree) {
|
|
180
|
-
const repoBasename = getRepoBasename();
|
|
181
|
-
if (repoBasename) {
|
|
182
|
-
const worktreeBaseDir = computeWorktreeBaseDir(repoBasename);
|
|
183
|
-
if (fs.existsSync(worktreeBaseDir)) {
|
|
184
|
-
const worktreeEntries = fs.readdirSync(worktreeBaseDir, { withFileTypes: true });
|
|
185
|
-
for (const entry of worktreeEntries) {
|
|
186
|
-
if (entry.isDirectory()) {
|
|
187
|
-
const wtRafDir = path.join(worktreeBaseDir, entry.name, 'RAF');
|
|
188
|
-
if (fs.existsSync(wtRafDir)) {
|
|
189
|
-
dirsToScan.push({ label: `worktree:${entry.name}`, path: wtRafDir });
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
} else {
|
|
195
|
-
logger.warn('Not in a git repository — skipping worktree scan');
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Detect all migrations
|
|
200
|
-
let totalMigrations: MigrationEntry[] = [];
|
|
201
|
-
const migrationsByDir: Array<{ label: string; migrations: MigrationEntry[] }> = [];
|
|
202
|
-
|
|
203
|
-
for (const dir of dirsToScan) {
|
|
204
|
-
const migrations = detectMigrations(dir.path);
|
|
205
|
-
if (migrations.length > 0) {
|
|
206
|
-
migrationsByDir.push({ label: dir.label, migrations });
|
|
207
|
-
totalMigrations = totalMigrations.concat(migrations);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (totalMigrations.length === 0) {
|
|
212
|
-
logger.info('No legacy project folders found. Nothing to migrate.');
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Check for collisions
|
|
217
|
-
const collisions = findCollisions(totalMigrations);
|
|
218
|
-
if (collisions.length > 0) {
|
|
219
|
-
logger.error('Collision detected — multiple old IDs map to the same base26 ID:');
|
|
220
|
-
for (const c of collisions) {
|
|
221
|
-
logger.error(` ${c}`);
|
|
222
|
-
}
|
|
223
|
-
process.exit(1);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Check for existing conflicts (target folder already exists)
|
|
227
|
-
const conflicts = findExistingConflicts(totalMigrations);
|
|
228
|
-
if (conflicts.length > 0) {
|
|
229
|
-
logger.error('Target folder already exists:');
|
|
230
|
-
for (const c of conflicts) {
|
|
231
|
-
logger.error(` ${c}`);
|
|
232
|
-
}
|
|
233
|
-
process.exit(1);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Print summary
|
|
237
|
-
if (dryRun) {
|
|
238
|
-
logger.info('Dry run — no changes will be made:');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
for (const group of migrationsByDir) {
|
|
242
|
-
if (migrationsByDir.length > 1) {
|
|
243
|
-
logger.info(`\n${group.label}:`);
|
|
244
|
-
}
|
|
245
|
-
for (const m of group.migrations) {
|
|
246
|
-
logger.info(` ${m.oldName} -> ${m.newName}`);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (dryRun) {
|
|
251
|
-
logger.info(`\n${totalMigrations.length} folder${totalMigrations.length === 1 ? '' : 's'} would be renamed.`);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Execute migrations
|
|
256
|
-
const { succeeded, errors } = executeMigrations(totalMigrations);
|
|
257
|
-
|
|
258
|
-
if (errors.length > 0) {
|
|
259
|
-
for (const e of errors) {
|
|
260
|
-
logger.error(e);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
logger.success(`${succeeded} folder${succeeded === 1 ? '' : 's'} renamed.`);
|
|
265
|
-
if (errors.length > 0) {
|
|
266
|
-
logger.error(`${errors.length} rename${errors.length === 1 ? '' : 's'} failed.`);
|
|
267
|
-
process.exit(1);
|
|
268
|
-
}
|
|
269
|
-
}
|