feat-forge 1.0.1
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/LICENSE +661 -0
- package/README.md +350 -0
- package/dist/cli.js +306 -0
- package/dist/commands/AbstractCommands.js +16 -0
- package/dist/commands/AgentCommands.js +14 -0
- package/dist/commands/BranchCommands.js +400 -0
- package/dist/commands/CompletionCommands.js +702 -0
- package/dist/commands/EnvCommands.js +56 -0
- package/dist/commands/FeatureCommands.js +4 -0
- package/dist/commands/FixCommands.js +4 -0
- package/dist/commands/InitCommands.js +380 -0
- package/dist/commands/MaintenanceCommands.js +39 -0
- package/dist/commands/ModeCommands.js +15 -0
- package/dist/commands/ProxyCommands.js +14 -0
- package/dist/commands/ReleaseCommands.js +4 -0
- package/dist/commands/ServicesCommands.js +95 -0
- package/dist/commands/SubBranchCommands.js +49 -0
- package/dist/commands/types/InitOptions.js +1 -0
- package/dist/foundation/BranchContext.js +427 -0
- package/dist/foundation/ForgeConfig.js +264 -0
- package/dist/foundation/ForgeConfigFile.js +391 -0
- package/dist/foundation/ForgeContext.js +169 -0
- package/dist/foundation/NpmHelper.js +131 -0
- package/dist/foundation/PathHelper.js +56 -0
- package/dist/foundation/PortAllocator.js +192 -0
- package/dist/foundation/Proxy.js +176 -0
- package/dist/foundation/Repository.js +431 -0
- package/dist/foundation/errors/ForgeError.js +9 -0
- package/dist/foundation/errors/_error.config.js +12 -0
- package/dist/foundation/errors/generated/ForgeBadStateError.js +11 -0
- package/dist/foundation/errors/generated/ForgeConfigError.js +11 -0
- package/dist/foundation/errors/generated/ForgeExpectMainRepositoryError.js +11 -0
- package/dist/foundation/errors/generated/ForgeModeNotDefinedError.js +11 -0
- package/dist/foundation/errors/generated/ForgeNotInActiveBranchError.js +11 -0
- package/dist/foundation/errors/generated/ForgePortAllocationsLoadError.js +11 -0
- package/dist/foundation/errors/generated/ForgePortNotAssignedError.js +11 -0
- package/dist/foundation/errors/generated/ForgePortRangeExhaustedError.js +11 -0
- package/dist/foundation/errors/generated/ForgeServicesScanError.js +11 -0
- package/dist/foundation/errors/generated/ForgeServicesValidationError.js +11 -0
- package/dist/foundation/errors/index.js +13 -0
- package/dist/foundation/types/AIAgent.js +1 -0
- package/dist/foundation/types/AIAgentName.js +11 -0
- package/dist/foundation/types/DeepPartial.js +1 -0
- package/dist/foundation/types/IDE.js +1 -0
- package/dist/foundation/types/IDEName.js +7 -0
- package/dist/foundation/types/ModeConfig.js +1 -0
- package/dist/foundation/types/RepositoryInfos.js +1 -0
- package/dist/foundation/types/Services.js +156 -0
- package/dist/foundation/types/ShellName.js +11 -0
- package/dist/lib/agents.js +47 -0
- package/dist/lib/bootstrap.js +54 -0
- package/dist/lib/branch.js +4 -0
- package/dist/lib/config.js +65 -0
- package/dist/lib/constants.js +13 -0
- package/dist/lib/env.js +20 -0
- package/dist/lib/fs.js +156 -0
- package/dist/lib/git.js +170 -0
- package/dist/lib/hooks.js +98 -0
- package/dist/lib/ide.js +75 -0
- package/dist/lib/merger.js +103 -0
- package/dist/lib/platform.js +13 -0
- package/dist/lib/prompt.js +134 -0
- package/dist/lib/proxy-dashboard.js +75 -0
- package/dist/lib/scanner.js +118 -0
- package/dist/lib/services.js +132 -0
- package/dist/lib/slug.js +35 -0
- package/dist/lib/templates.js +115 -0
- package/dist/lib/validator.js +15 -0
- package/dist/templates/SPEC.md +21 -0
- package/dist/templates/TODO.md +5 -0
- package/dist/templates/agent/001.general.Omnibus.agent.md +4 -0
- package/dist/templates/agent/002.discovery.Inventorius.agent.md +4 -0
- package/dist/templates/agent/003.design.Architecturius.agent.md +8 -0
- package/dist/templates/agent/004.plan.Strategos.agent.md +8 -0
- package/dist/templates/agent/005.tdd.TestDrivenCodificius.agent.md +8 -0
- package/dist/templates/agent/006.code.Codificius.agent.md +8 -0
- package/dist/templates/agent/007.simplify.Consolidarius.agent.md +8 -0
- package/dist/templates/agent/008.review.Auditorix.agent.md +8 -0
- package/dist/templates/agent/009.testwriter.TestScriptor.agent.md +8 -0
- package/dist/templates/agent/010.testexecutor.TestExecutor.agent.md +8 -0
- package/dist/templates/agent/011.commit.Scribus.agent.md +10 -0
- package/dist/templates/agent/CONTEXT.code.md +145 -0
- package/dist/templates/agent/CONTEXT.spec.md +98 -0
- package/dist/templates/agent/Copilot/Code.agent.md +28 -0
- package/dist/templates/agent/Copilot/CodeCommit.agent.md +16 -0
- package/dist/templates/agent/Copilot/Feature-Builder.agent.md +49 -0
- package/dist/templates/agent/Copilot/Reviewer.agent.md +17 -0
- package/dist/templates/agent/Copilot/Simplifier.agent.md +21 -0
- package/dist/templates/agent/Copilot/Specs.agent.md +66 -0
- package/dist/templates/agent/Copilot/SpecsCommit.agent.md +19 -0
- package/dist/templates/agent/Copilot/TODO-Reader.agent.md +18 -0
- package/dist/templates/agent/Copilot/Tester.agent.md +12 -0
- package/package.json +76 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { branchNameAsPath } from '../lib/branch.js';
|
|
2
|
+
import { HookEvent } from '../lib/hooks.js';
|
|
3
|
+
import { replaceTemplateMarkers, resolveAgentFileCustomTemplate, resolveSpecFileCustomTemplate } from '../lib/templates.js';
|
|
4
|
+
import { readdir, rm, symlink } from 'fs/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { refreshCopilotAgentContextFiles } from '../lib/agents.js';
|
|
7
|
+
import { TemporaryFolderType } from '../lib/constants.js';
|
|
8
|
+
import { ensureDir, ensureLineInFile, pathExists, readTextFile, writeTextFile } from '../lib/fs.js';
|
|
9
|
+
import { getGitStatusPorcelain, runGit } from '../lib/git.js';
|
|
10
|
+
import { createIDEWorkspaces } from '../lib/ide.js';
|
|
11
|
+
import { promptConfirm, promptForBranch } from '../lib/prompt.js';
|
|
12
|
+
import { WorktreeRepository } from './Repository.js';
|
|
13
|
+
import { AIAgentName } from './types/AIAgentName.js';
|
|
14
|
+
import { slugify } from '../lib/slug.js';
|
|
15
|
+
import { ForgeNotInActiveBranchError } from './errors/index.js';
|
|
16
|
+
export class BranchContext {
|
|
17
|
+
context;
|
|
18
|
+
branchName;
|
|
19
|
+
path;
|
|
20
|
+
repositories;
|
|
21
|
+
active;
|
|
22
|
+
constructor(context, branchName, path, repositories, active) {
|
|
23
|
+
this.context = context;
|
|
24
|
+
this.branchName = branchName;
|
|
25
|
+
this.path = path;
|
|
26
|
+
this.repositories = repositories;
|
|
27
|
+
this.active = active;
|
|
28
|
+
}
|
|
29
|
+
isRootBranch() {
|
|
30
|
+
return !this.isFeatureBranch() && !this.isFixBranch() && !this.isReleaseBranch();
|
|
31
|
+
}
|
|
32
|
+
isFeatureBranch() {
|
|
33
|
+
return this.branchName.startsWith(this.context.options.git.featureBranchPrefix);
|
|
34
|
+
}
|
|
35
|
+
isFixBranch() {
|
|
36
|
+
return this.branchName.startsWith(this.context.options.git.fixBranchPrefix);
|
|
37
|
+
}
|
|
38
|
+
isReleaseBranch() {
|
|
39
|
+
return this.branchName.startsWith(this.context.options.git.releaseBranchPrefix);
|
|
40
|
+
}
|
|
41
|
+
static async loadFromPath(context, branchName, branchRootPath) {
|
|
42
|
+
// search folder in the BranchRootPath that matches a context repository name
|
|
43
|
+
if ((await pathExists(branchRootPath)) === false) {
|
|
44
|
+
throw new Error(`Branch root path does not exist: ${branchRootPath}`);
|
|
45
|
+
}
|
|
46
|
+
const repositories = await BranchContext.loadWorktreeRepositories(context, branchRootPath);
|
|
47
|
+
return new BranchContext(context, branchName, branchRootPath, repositories, true);
|
|
48
|
+
}
|
|
49
|
+
static async loadWorktreeRepositories(context, branchRootPath) {
|
|
50
|
+
const items = await readdir(branchRootPath, { withFileTypes: true });
|
|
51
|
+
const repoDirs = items.filter((item) => item.isDirectory() && context.repositories.some((repo) => repo.name === item.name));
|
|
52
|
+
if (repoDirs.length === 0) {
|
|
53
|
+
throw new Error(`No repository folder found in Branch path: ${branchRootPath}`);
|
|
54
|
+
}
|
|
55
|
+
const repositories = repoDirs.map((dir) => {
|
|
56
|
+
const rootRepo = context.repositories.find((r) => r.name === dir.name);
|
|
57
|
+
return new WorktreeRepository(context, { name: rootRepo.name, path: path.join(branchRootPath, dir.name), main: rootRepo.main }, rootRepo);
|
|
58
|
+
});
|
|
59
|
+
return repositories;
|
|
60
|
+
}
|
|
61
|
+
static async findNearestBranchContext(context, startDir = process.cwd()) {
|
|
62
|
+
const currentDir = path.resolve(startDir);
|
|
63
|
+
const worktreesRoot = path.resolve(context.paths.worktreesRoot);
|
|
64
|
+
const matchingBranch = (await context.mainRepo.getBranches()).find((branchName) => {
|
|
65
|
+
const branchWorktreePath = context.paths.getBranchRootPath(branchName);
|
|
66
|
+
if (currentDir.startsWith(branchWorktreePath)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
71
|
+
if (!matchingBranch) {
|
|
72
|
+
throw new ForgeNotInActiveBranchError(`No active Branch context found for current directory: ${currentDir}`);
|
|
73
|
+
}
|
|
74
|
+
// If we found a matching branch, we can directly load the Branch context from its worktree path
|
|
75
|
+
const branchRootPath = path.join(worktreesRoot, branchNameAsPath(matchingBranch));
|
|
76
|
+
return BranchContext.loadFromPath(context, matchingBranch, branchRootPath);
|
|
77
|
+
}
|
|
78
|
+
mustBeActive() {
|
|
79
|
+
if (!this.active) {
|
|
80
|
+
throw new Error('This operation requires an active Branch context.');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
get folders() {
|
|
84
|
+
return this.context.options.folders;
|
|
85
|
+
}
|
|
86
|
+
get files() {
|
|
87
|
+
return this.context.options.files;
|
|
88
|
+
}
|
|
89
|
+
get branchNameAsPath() {
|
|
90
|
+
return branchNameAsPath(this.branchName);
|
|
91
|
+
}
|
|
92
|
+
get branchNameSlug() {
|
|
93
|
+
return slugify(this.branchName, false, false);
|
|
94
|
+
}
|
|
95
|
+
get mainRepo() {
|
|
96
|
+
return this.repositories.find((repo) => repo.main);
|
|
97
|
+
}
|
|
98
|
+
get secondaryRepos() {
|
|
99
|
+
return this.repositories.filter((repo) => !repo.main);
|
|
100
|
+
}
|
|
101
|
+
get modeFilePath() {
|
|
102
|
+
return path.join(this.path, this.context.options.files.forgeMode);
|
|
103
|
+
}
|
|
104
|
+
get activeSpecPath() {
|
|
105
|
+
return path.join(this.path, this.folders.activeSpec);
|
|
106
|
+
}
|
|
107
|
+
getInPath(...segments) {
|
|
108
|
+
return path.join(this.path, ...segments);
|
|
109
|
+
}
|
|
110
|
+
hasRepo(repoName) {
|
|
111
|
+
return this.repositories.some((repo) => repo.name === repoName);
|
|
112
|
+
}
|
|
113
|
+
getRepo(repoName) {
|
|
114
|
+
if (!this.hasRepo(repoName))
|
|
115
|
+
throw new Error(`Repository ${repoName} is not part of the Branch context`);
|
|
116
|
+
return this.repositories.find((r) => r.name === repoName);
|
|
117
|
+
}
|
|
118
|
+
async getTemporaryRepo(repoName, type) {
|
|
119
|
+
const rootRepo = this.context.getRepo(repoName);
|
|
120
|
+
const tempRepo = await rootRepo.getTemporaryWorktree(this.branchName, type);
|
|
121
|
+
this.repositories.push(tempRepo);
|
|
122
|
+
return tempRepo;
|
|
123
|
+
}
|
|
124
|
+
getTemplatePath(...segments) {
|
|
125
|
+
return this.mainRepo.getTemplatePath(...segments);
|
|
126
|
+
}
|
|
127
|
+
getAgentTemplatePath(...segments) {
|
|
128
|
+
return this.mainRepo.getAgentTemplatePath(...segments);
|
|
129
|
+
}
|
|
130
|
+
async hasModeFile() {
|
|
131
|
+
return pathExists(this.modeFilePath);
|
|
132
|
+
}
|
|
133
|
+
async getMode() {
|
|
134
|
+
const modeFile = this.modeFilePath;
|
|
135
|
+
if (!(await this.hasModeFile())) {
|
|
136
|
+
throw new Error(`Mode file not found for branch ${this.branchName}. Expected at: ${modeFile}`);
|
|
137
|
+
}
|
|
138
|
+
const raw = (await readTextFile(modeFile)).trim().toLowerCase();
|
|
139
|
+
return this.context.getMode(raw);
|
|
140
|
+
}
|
|
141
|
+
async setMode(mode) {
|
|
142
|
+
this.context.modeExistsOrThrow(mode); // will throw if mode does not exist in config
|
|
143
|
+
const modeFile = this.modeFilePath;
|
|
144
|
+
await writeTextFile(modeFile, `${mode}\n`);
|
|
145
|
+
}
|
|
146
|
+
async initMode(mode) {
|
|
147
|
+
if (!(await this.hasModeFile())) {
|
|
148
|
+
mode = mode ?? this.context.getDefaultMode().name;
|
|
149
|
+
this.context.modeExistsOrThrow(mode); // will throw if mode does not exist in config
|
|
150
|
+
await this.setMode(mode);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async deleteBranch() {
|
|
154
|
+
// do not allow deletion of main,master,etc branches by forge
|
|
155
|
+
if (this.context.options.git.protectedBranches.includes(this.branchName)) {
|
|
156
|
+
throw new Error(`Branch ${this.branchName} is protected and cannot be deleted.`);
|
|
157
|
+
}
|
|
158
|
+
// Must be done on all repositories, not only the one from the Branch
|
|
159
|
+
// It also makes more sense to execute this command on the "root" repositories
|
|
160
|
+
// Note: This will not work if the branch is still used by a worktree
|
|
161
|
+
await Promise.all(this.context.repositories.map((repo) => repo.deleteBranch(this.branchName)));
|
|
162
|
+
}
|
|
163
|
+
async getDirtyRepositories() {
|
|
164
|
+
this.mustBeActive();
|
|
165
|
+
const dirtyRepositories = [];
|
|
166
|
+
for (const repo of this.repositories) {
|
|
167
|
+
const status = await getGitStatusPorcelain(repo.path);
|
|
168
|
+
if (status.length > 0) {
|
|
169
|
+
dirtyRepositories.push(repo);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return dirtyRepositories;
|
|
173
|
+
}
|
|
174
|
+
async refreshAgentContextFiles(mode) {
|
|
175
|
+
const { agents } = this.context;
|
|
176
|
+
const modeConfig = mode ? this.context.getMode(mode) : await this.getMode();
|
|
177
|
+
// Create the root [AGENTNAME].md files in the Branch's
|
|
178
|
+
await Promise.all(agents.map(async (agent) => {
|
|
179
|
+
const targetFile = this.getInPath(agent.agentFile);
|
|
180
|
+
let content = await resolveAgentFileCustomTemplate(this.context, this.mainRepo, modeConfig.agentFile);
|
|
181
|
+
content = replaceTemplateMarkers(content, this.context, this);
|
|
182
|
+
console.log('Refreshing agent context file for agent', agent.name, 'at', targetFile);
|
|
183
|
+
return writeTextFile(targetFile, content);
|
|
184
|
+
}));
|
|
185
|
+
// Create the agent context files specific to one agent
|
|
186
|
+
await Promise.all(agents.map(async (agent) => {
|
|
187
|
+
switch (agent.name) {
|
|
188
|
+
case AIAgentName.COPILOT:
|
|
189
|
+
// Copilot specific a .github/agents/xxxxxx.agent.md
|
|
190
|
+
await refreshCopilotAgentContextFiles(this.context, this);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}));
|
|
194
|
+
// If agentsInEachRepo is true
|
|
195
|
+
// also create the [AGENTNAME].md files in each repository .forge-agents/ folder, so that they can be run locally to the branch
|
|
196
|
+
// Note: The idea of the .forge-agents/ folder is to not mess up with repository root [AGENTNAME].md files that might be used for other purposes
|
|
197
|
+
if (this.context.options.process.agentsInEachRepo) {
|
|
198
|
+
await Promise.all(this.repositories.map(async (repo) => {
|
|
199
|
+
return Promise.all(agents.map(async (agent) => {
|
|
200
|
+
const targetFile = repo.getAgentPath(agent.agentFile);
|
|
201
|
+
let content = await resolveAgentFileCustomTemplate(this.context, this.mainRepo, modeConfig.agentFile);
|
|
202
|
+
content = replaceTemplateMarkers(content, this.context, this, repo);
|
|
203
|
+
console.log('Refreshing agent context file for agent', agent.name, 'at', targetFile);
|
|
204
|
+
return writeTextFile(targetFile, content);
|
|
205
|
+
}));
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
// Execute postRefreshAgents hooks after agent context files are refreshed
|
|
209
|
+
await this.executeHook(HookEvent.POST_REFRESH_AGENTS, { branch: this.branchName });
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Ensure :
|
|
213
|
+
* - .active-spec (or the cusomized name) is in .gitignore for one or more repo roots.
|
|
214
|
+
* - .forge-agents/* is in .gitignore for one or more repo roots.
|
|
215
|
+
*/
|
|
216
|
+
async ensureGitIgnore(repository) {
|
|
217
|
+
let changes = 0;
|
|
218
|
+
const gitignorePath = path.join(repository.path, '.gitignore');
|
|
219
|
+
changes += await ensureLineInFile(gitignorePath, this.folders.activeSpec);
|
|
220
|
+
changes += await ensureLineInFile(gitignorePath, this.folders.repoAgents);
|
|
221
|
+
if (changes > 0) {
|
|
222
|
+
await repository.commit(`chore: add forge specific files to .gitignore`, [gitignorePath]);
|
|
223
|
+
}
|
|
224
|
+
return changes;
|
|
225
|
+
}
|
|
226
|
+
async initBranch() {
|
|
227
|
+
let hiddenChanges = 0; // Changes were made on temporary worktrees
|
|
228
|
+
for (const rootRepo of this.context.repositories) {
|
|
229
|
+
let worktreeRepo;
|
|
230
|
+
if (this.hasRepo(rootRepo.name)) {
|
|
231
|
+
// worktree already exists, we can use it to check for spec files without affecting the main branch
|
|
232
|
+
worktreeRepo = this.getRepo(rootRepo.name);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
worktreeRepo = await this.getTemporaryRepo(rootRepo.name, TemporaryFolderType.BRANCH_INIT);
|
|
236
|
+
}
|
|
237
|
+
// Check for spec files in the main repository
|
|
238
|
+
if (worktreeRepo.isMainRepository()) {
|
|
239
|
+
const fileChanges = await this.ensureBranchSpecFiles(worktreeRepo, this.branchName);
|
|
240
|
+
if (fileChanges.length) {
|
|
241
|
+
await worktreeRepo.commit(`docs(${this.branchName}): init branch spec files`, fileChanges);
|
|
242
|
+
if (worktreeRepo.temporary) {
|
|
243
|
+
hiddenChanges += fileChanges.length;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (this.context.options.process.manageGitIgnore) {
|
|
248
|
+
const gitIgnoreChanges = await this.ensureGitIgnore(worktreeRepo);
|
|
249
|
+
if (gitIgnoreChanges > 0 && worktreeRepo.temporary) {
|
|
250
|
+
hiddenChanges += gitIgnoreChanges;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (worktreeRepo.temporary) {
|
|
254
|
+
await worktreeRepo.remove();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return hiddenChanges;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Ensure the spec files exist for a branch directory without overwriting existing files.
|
|
261
|
+
* Also creates the agent subdirectory (empty, ready for symlinks).
|
|
262
|
+
*/
|
|
263
|
+
async ensureBranchSpecFiles(repo, branchName) {
|
|
264
|
+
repo.mustBeMainRepository();
|
|
265
|
+
let fileChanges = [];
|
|
266
|
+
const branchSpecPath = repo.getSpecPath(branchName);
|
|
267
|
+
await ensureDir(branchSpecPath);
|
|
268
|
+
// Create main spec files
|
|
269
|
+
for (const fileName of this.context.config.specFiles) {
|
|
270
|
+
const filePath = path.join(branchSpecPath, fileName);
|
|
271
|
+
if (await pathExists(filePath)) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const content = await resolveSpecFileCustomTemplate(this.context, repo, fileName);
|
|
275
|
+
await writeTextFile(filePath, content);
|
|
276
|
+
fileChanges.push(filePath);
|
|
277
|
+
}
|
|
278
|
+
return fileChanges;
|
|
279
|
+
}
|
|
280
|
+
async start() {
|
|
281
|
+
const branchRoot = this.path;
|
|
282
|
+
await ensureDir(branchRoot);
|
|
283
|
+
this.repositories = await this.ensureWorktrees();
|
|
284
|
+
// Set active Branch pointer
|
|
285
|
+
await this.setActiveSpec();
|
|
286
|
+
// Set initial mode to spec if not defined
|
|
287
|
+
await this.initMode(); // default to spec mode on start
|
|
288
|
+
// Refresh agent context files based on mode (will create symlinks to template files or user overrides)
|
|
289
|
+
await this.refreshAgentContextFiles();
|
|
290
|
+
// Create IDE workspaces if configured
|
|
291
|
+
if (this.context.ides.length > 0) {
|
|
292
|
+
await createIDEWorkspaces(this.branchName, this.path, this.mainRepo.name, this.repositories, this.context.ides, this.context.agents);
|
|
293
|
+
}
|
|
294
|
+
// Execute bootstrap scripts in each repository
|
|
295
|
+
for (const repository of this.repositories) {
|
|
296
|
+
try {
|
|
297
|
+
// Copy configured files from root repos to worktrees
|
|
298
|
+
await repository.rootRepository.copyFilesToWorktree(repository);
|
|
299
|
+
// Execute bootstrap script if defined in the repository
|
|
300
|
+
await repository.executeBootstrapScript();
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
// Soft error here, so that the Branch can still be started even if a bootstrap script fails, which can be useful for debugging or if the bootstrap script is not critical
|
|
304
|
+
console.error(`❌ Failed to execute bootstrap script in ${repository.name}`, error);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Execute postBranchStart hooks in each repository
|
|
308
|
+
await this.executeHook(HookEvent.POST_START, { branch: this.branchName });
|
|
309
|
+
this.active = true;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Execute hooks for a specific event across all repositories in this branch.
|
|
313
|
+
* Soft errors are used so that the branch operation can continue even if a hook fails.
|
|
314
|
+
*
|
|
315
|
+
* Hook parameters are passed to scripts as environment variables with FORGE_HOOK_ prefix.
|
|
316
|
+
* Example: params { sourceBranch: 'feature/xyz' } becomes FORGE_HOOK_SOURCEBRANCH
|
|
317
|
+
*
|
|
318
|
+
* @param eventType - Type of hook event to execute
|
|
319
|
+
* @param params - Optional parameters to pass to hooks as environment variables
|
|
320
|
+
*/
|
|
321
|
+
async executeHook(eventType, params) {
|
|
322
|
+
for (const repository of this.repositories) {
|
|
323
|
+
try {
|
|
324
|
+
const executedHooks = await repository.executeHook(eventType, params);
|
|
325
|
+
if (executedHooks.length > 0) {
|
|
326
|
+
console.log(`📌 Executed ${executedHooks.length} ${eventType} hook(s) in ${repository.name}: ${executedHooks.join(', ')}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
// Soft error: allow the branch operation to continue even if a hook fails
|
|
331
|
+
console.error(`❌ Failed to execute ${eventType} hooks in ${repository.name}`, error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async ensureWorktrees() {
|
|
336
|
+
return Promise.all(this.context.repositories.map((repo) => {
|
|
337
|
+
return this.ensureWorktreeForRepo(repo);
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
async ensureWorktreeForRepo(repo) {
|
|
341
|
+
if (await repo.hasWorktree(this.branchName)) {
|
|
342
|
+
return repo.getWorktree(this.branchName);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
return repo.addWorktree(this.branchName);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async setActiveSpec() {
|
|
349
|
+
// Create .active-spec symlink in the Branch root pointing to the current spec file in the main repository worktree
|
|
350
|
+
const specRealPath = this.mainRepo.getSpecPath(this.branchName);
|
|
351
|
+
const activeSpecPath = this.activeSpecPath;
|
|
352
|
+
await rm(activeSpecPath, { force: true });
|
|
353
|
+
await symlink(path.relative(path.dirname(activeSpecPath), specRealPath), activeSpecPath);
|
|
354
|
+
// If activeSpecInEachRepo is true, also create/update .active-spec symlink in each repository worktree pointing to the same spec file in the main repository worktree
|
|
355
|
+
if (this.context.options.process.activeSpecInEachRepo) {
|
|
356
|
+
await Promise.all(this.repositories.map((repo) => repo.setActiveSpec(this)));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async collectRepositoriesStatus() {
|
|
360
|
+
const status = {};
|
|
361
|
+
for (const repo of this.repositories) {
|
|
362
|
+
status[repo.name] = await repo.getStatus(this.branchName);
|
|
363
|
+
}
|
|
364
|
+
return status;
|
|
365
|
+
}
|
|
366
|
+
async stop() {
|
|
367
|
+
// Execute preStop hooks before stopping the branch
|
|
368
|
+
await this.executeHook(HookEvent.PRE_STOP, { branch: this.branchName });
|
|
369
|
+
// Check for uncommitted changes in worktrees
|
|
370
|
+
const dirtyRepositories = await this.getDirtyRepositories();
|
|
371
|
+
// Handle dirty worktrees if any exist
|
|
372
|
+
if (dirtyRepositories.length > 0) {
|
|
373
|
+
for (const repo of dirtyRepositories) {
|
|
374
|
+
const repoShouldProceed = await repo.promptDirtyActions();
|
|
375
|
+
if (!repoShouldProceed) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async delete() {
|
|
382
|
+
// Execute preDelete hooks before deleting the branch
|
|
383
|
+
await this.executeHook(HookEvent.PRE_DELETE, { branch: this.branchName });
|
|
384
|
+
// Remove worktrees for all repositories
|
|
385
|
+
await Promise.all(this.repositories.map((repo) => repo.remove()));
|
|
386
|
+
if (await pathExists(this.path)) {
|
|
387
|
+
if ((await readdir(this.path)).length !== 0) {
|
|
388
|
+
// Remove Branch path if empty
|
|
389
|
+
const confirm = await promptConfirm(`Branch path ${this.path} is not empty. Do you want to remove it?`);
|
|
390
|
+
if (!confirm) {
|
|
391
|
+
console.log(`Please manually clean up the Branch path: ${this.path}`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
await rm(this.path, { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
this.active = false;
|
|
398
|
+
}
|
|
399
|
+
async archive() {
|
|
400
|
+
await this.executeHook(HookEvent.PRE_ARCHIVE, { branch: this.branchName });
|
|
401
|
+
let worktreeRepo;
|
|
402
|
+
if (this.hasRepo(this.mainRepo.name)) {
|
|
403
|
+
// worktree already exists, we can use it to create the Branch files without affecting the main branch
|
|
404
|
+
worktreeRepo = this.getRepo(this.mainRepo.name);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
// create a temporary worktree to move the Branch files without affecting the main branch
|
|
408
|
+
worktreeRepo = await this.getTemporaryRepo(this.mainRepo.name, TemporaryFolderType.BRANCH_ARCHIVE);
|
|
409
|
+
}
|
|
410
|
+
const archivePath = path.join(worktreeRepo.specsArchivePath, branchNameAsPath(this.branchName));
|
|
411
|
+
const branchPath = worktreeRepo.getSpecPath(this.branchName);
|
|
412
|
+
// Create archives directory
|
|
413
|
+
await ensureDir(path.dirname(archivePath));
|
|
414
|
+
// Move Branch to archive using git mv
|
|
415
|
+
await runGit(worktreeRepo.path, ['mv', branchPath, archivePath]);
|
|
416
|
+
// Commit the archive
|
|
417
|
+
await worktreeRepo.commit(`docs(${this.branchName}): archive Branch`, [archivePath]);
|
|
418
|
+
console.log(`✓ Branch \"${this.branchName}\" archived successfully.`);
|
|
419
|
+
console.log(` Moved: ${this.context.options.folders.specs}/${this.branchName}/ → ${this.context.options.folders.specs}/${this.context.options.folders.archive}/${this.branchName}/`);
|
|
420
|
+
// Ask for merge
|
|
421
|
+
const confirmMerge = await promptConfirm('Do you want to merge the archived branch to another branch? (recommended to keep trace in main/dev branch)');
|
|
422
|
+
if (confirmMerge) {
|
|
423
|
+
const targetBranch = await promptForBranch(this.context.mainRepo.path, 'Select target branch to merge archived branch:', this.context.options.git.featureBranchPrefix, false);
|
|
424
|
+
await worktreeRepo.rootRepository.merge(this.branchName, targetBranch);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { FEAT_FORGE_CONFIG_FILE } from '../lib/constants.js';
|
|
3
|
+
import { merge } from '../lib/merger.js';
|
|
4
|
+
import { ForgeConfigError } from './errors/index.js';
|
|
5
|
+
import { AIAgentName } from './types/AIAgentName.js';
|
|
6
|
+
import { IDEName } from './types/IDEName.js';
|
|
7
|
+
import { ForgeOptions, } from './ForgeConfigFile.js';
|
|
8
|
+
export const DEFAULT_SPEC_FILES = ['SPEC.md', 'TODO.md'];
|
|
9
|
+
export const DEFAULT_MODES = [
|
|
10
|
+
{
|
|
11
|
+
name: 'general',
|
|
12
|
+
agentFile: '001.general.Omnibus.agent.md',
|
|
13
|
+
description: 'Default mode with no special behavior',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'discovery',
|
|
17
|
+
agentFile: '002.discovery.Inventorius.agent.md',
|
|
18
|
+
description: 'Help to define a functional perimeter for a feature',
|
|
19
|
+
default: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'design',
|
|
23
|
+
agentFile: '003.design.Architecturius.agent.md',
|
|
24
|
+
description: 'Help to define an architecture for a feature',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'plan',
|
|
28
|
+
agentFile: '004.plan.Strategos.agent.md',
|
|
29
|
+
description: 'Generate a plan for a feature, in SPEC.md & TODO.md by default',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'tdd',
|
|
33
|
+
agentFile: '005.tdd.TestDrivenCodificius.agent.md',
|
|
34
|
+
description: 'Test Driven Development agent to implement the feature',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'code',
|
|
38
|
+
agentFile: '006.code.Codificius.agent.md',
|
|
39
|
+
description: 'Code agent to implement the feature',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'simplify',
|
|
43
|
+
agentFile: '007.simplify.Consolidarius.agent.md',
|
|
44
|
+
description: 'Simplify and consolidate the feature',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'review',
|
|
48
|
+
agentFile: '008.review.Auditorix.agent.md',
|
|
49
|
+
description: 'Review the feature and ensure code guidelines',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'test-writer',
|
|
53
|
+
agentFile: '009.testwriter.TestScriptor.agent.md',
|
|
54
|
+
description: 'Write unit/functional tests',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'test-executor',
|
|
58
|
+
agentFile: '010.testexecutor.TestExecutor.agent.md',
|
|
59
|
+
description: 'Execute unit/functional tests',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'commit',
|
|
63
|
+
agentFile: '011.commit.Scribus.agent.md',
|
|
64
|
+
description: 'Propose commit message based changes related to the feature and then commit',
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
export class ForgeConfig {
|
|
68
|
+
rootDir;
|
|
69
|
+
repositories;
|
|
70
|
+
agents;
|
|
71
|
+
ides;
|
|
72
|
+
options;
|
|
73
|
+
specFiles;
|
|
74
|
+
modes;
|
|
75
|
+
constructor(configPath, configFile) {
|
|
76
|
+
this.rootDir = path.resolve(configFile.rootDir ? path.resolve(configPath, configFile.rootDir) : configPath);
|
|
77
|
+
this.specFiles = configFile.specFiles || DEFAULT_SPEC_FILES;
|
|
78
|
+
// standardize config entries
|
|
79
|
+
this.modes = this.standardizeModes(configFile.modes || DEFAULT_MODES);
|
|
80
|
+
this.repositories = this.standardizeRepositories(configFile.repositories);
|
|
81
|
+
this.ides = this.standardizeIDEs(configFile.ides);
|
|
82
|
+
this.agents = this.standardizeAgents(configFile.agents);
|
|
83
|
+
this.options = this.standardizeOptions(configFile.options);
|
|
84
|
+
}
|
|
85
|
+
getPath(...segments) {
|
|
86
|
+
return path.resolve(this.rootDir, ...segments); // validate path segments
|
|
87
|
+
}
|
|
88
|
+
standardizeModes(rawModes) {
|
|
89
|
+
if (!Array.isArray(rawModes)) {
|
|
90
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: "modes" must be an array.`);
|
|
91
|
+
}
|
|
92
|
+
if (rawModes.length === 0) {
|
|
93
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: At least one mode must be defined in the "modes" array.`);
|
|
94
|
+
}
|
|
95
|
+
const modes = rawModes.map((entry) => {
|
|
96
|
+
const agentFile = entry.agentFile?.trim();
|
|
97
|
+
if (!agentFile) {
|
|
98
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: Each mode entry must have a non-empty "agentFile" property.`);
|
|
99
|
+
}
|
|
100
|
+
const name = entry.name?.trim() || agentFile.replace(/\.[^/.]+$/, ''); // default name from agentFile if not provided
|
|
101
|
+
if (!name) {
|
|
102
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: Each mode entry must have a non-empty "name" or valid "agentFile" property.`);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
name,
|
|
106
|
+
agentFile,
|
|
107
|
+
description: entry.description?.trim(),
|
|
108
|
+
default: !!entry.default,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
ForgeConfig.checkDefaultMode(modes);
|
|
112
|
+
return modes;
|
|
113
|
+
}
|
|
114
|
+
standardizeRepositories(repos) {
|
|
115
|
+
const repositories = repos
|
|
116
|
+
.map((entry) => {
|
|
117
|
+
if (typeof entry === 'string') {
|
|
118
|
+
return {
|
|
119
|
+
path: entry,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
return entry;
|
|
124
|
+
})
|
|
125
|
+
.map((repositoryConfig) => {
|
|
126
|
+
if (typeof repositoryConfig.path !== 'string' || repositoryConfig.path.trim() === '') {
|
|
127
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: Repository entries must have a non-empty "path".`);
|
|
128
|
+
}
|
|
129
|
+
const rootPath = repositoryConfig.path.trim();
|
|
130
|
+
return {
|
|
131
|
+
name: repositoryConfig.name ?? path.basename(rootPath),
|
|
132
|
+
path: this.getPath(rootPath),
|
|
133
|
+
main: !!(repositoryConfig.main ?? false),
|
|
134
|
+
copyFiles: repositoryConfig.copyFiles ?? [],
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
ForgeConfig.checkMainRepo(repositories);
|
|
138
|
+
return repositories;
|
|
139
|
+
}
|
|
140
|
+
standardizeIDEs(ides) {
|
|
141
|
+
if (!ides) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(ides)) {
|
|
145
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: "ides" must be an array.`);
|
|
146
|
+
}
|
|
147
|
+
return ides
|
|
148
|
+
.map((entry) => {
|
|
149
|
+
if (typeof entry === 'string') {
|
|
150
|
+
return {
|
|
151
|
+
name: entry,
|
|
152
|
+
createWorkspace: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
else
|
|
156
|
+
return entry;
|
|
157
|
+
})
|
|
158
|
+
.map((ideConfig) => {
|
|
159
|
+
// Full config object
|
|
160
|
+
const ideName = Object.values(IDEName).find((name) => name === ideConfig.name);
|
|
161
|
+
if (!ideName) {
|
|
162
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: IDE config objects must have a valid "name" property.`);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
name: ideName,
|
|
166
|
+
createWorkspace: ideConfig.createWorkspace ?? true,
|
|
167
|
+
settings: ideConfig.settings ?? {},
|
|
168
|
+
openCommand: ideConfig.openCommand,
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
standardizeAgents(agents) {
|
|
173
|
+
if (!agents) {
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
name: null,
|
|
177
|
+
agentFile: 'AGENTS.md',
|
|
178
|
+
requiresIDEConfig: false,
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
if (!Array.isArray(agents)) {
|
|
183
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: "agents" must be an array.`);
|
|
184
|
+
}
|
|
185
|
+
return agents
|
|
186
|
+
.map((entry) => {
|
|
187
|
+
if (typeof entry === 'string') {
|
|
188
|
+
return {
|
|
189
|
+
name: entry,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
else
|
|
193
|
+
return entry;
|
|
194
|
+
})
|
|
195
|
+
.map((agentConfig) => {
|
|
196
|
+
const name = agentConfig.name?.trim() ?? null;
|
|
197
|
+
let agentFile = agentConfig.agentFile?.trim() ?? null;
|
|
198
|
+
if (!name && !agentFile) {
|
|
199
|
+
throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: Agent config objects must have either a valid "name" or an "agentFile" property.`);
|
|
200
|
+
}
|
|
201
|
+
if (!agentFile) {
|
|
202
|
+
agentFile = ForgeConfig.getAgentFile(name);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
name,
|
|
206
|
+
agentFile,
|
|
207
|
+
requiresIDEConfig: name ? ForgeConfig.agentRequiresIDEConfig(name) : false,
|
|
208
|
+
settings: agentConfig.settings,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
standardizeOptions(options) {
|
|
213
|
+
return merge(new ForgeOptions(), options || {});
|
|
214
|
+
}
|
|
215
|
+
static checkDefaultMode(modes) {
|
|
216
|
+
const defaultModes = modes.filter((mode) => mode.default);
|
|
217
|
+
if (defaultModes.length > 1) {
|
|
218
|
+
throw new ForgeConfigError('Multiple modes cannot be marked as default in the configuration.');
|
|
219
|
+
}
|
|
220
|
+
if (defaultModes.length === 0) {
|
|
221
|
+
modes[0].default = true; // set first mode as default if none marked
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
static checkMainRepo(repositories) {
|
|
225
|
+
if (repositories.filter((repo) => repo.main).length > 1) {
|
|
226
|
+
throw new ForgeConfigError('Multiple repositories cannot be marked as main in the configuration.');
|
|
227
|
+
}
|
|
228
|
+
let mainRepo = repositories.find((repo) => repo.main);
|
|
229
|
+
if (!mainRepo) {
|
|
230
|
+
if (repositories.length > 0) {
|
|
231
|
+
repositories[0].main = true;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
throw new ForgeConfigError('At least one repository must be defined in the configuration.');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Get default agent file name for known agents
|
|
240
|
+
*/
|
|
241
|
+
static getAgentFile(agentName) {
|
|
242
|
+
const agentFiles = {
|
|
243
|
+
[AIAgentName.CLAUDE]: 'CLAUDE.md',
|
|
244
|
+
[AIAgentName.COPILOT]: 'AGENTS.md',
|
|
245
|
+
[AIAgentName.GEMINI]: 'GEMINI.md',
|
|
246
|
+
[AIAgentName.CODEX]: 'AGENTS.md',
|
|
247
|
+
[AIAgentName.CURSOR]: 'AGENTS.md',
|
|
248
|
+
};
|
|
249
|
+
return agentFiles[agentName] || 'AGENTS.md';
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Check if agent requires IDE configuration
|
|
253
|
+
*/
|
|
254
|
+
static agentRequiresIDEConfig(agentName) {
|
|
255
|
+
const agentRequiringIDEConfig = {
|
|
256
|
+
[AIAgentName.COPILOT]: true,
|
|
257
|
+
[AIAgentName.CURSOR]: true,
|
|
258
|
+
[AIAgentName.CLAUDE]: false,
|
|
259
|
+
[AIAgentName.GEMINI]: false,
|
|
260
|
+
[AIAgentName.CODEX]: false,
|
|
261
|
+
};
|
|
262
|
+
return !!agentRequiringIDEConfig[agentName];
|
|
263
|
+
}
|
|
264
|
+
}
|