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,400 @@
|
|
|
1
|
+
import { getDefaultIDECommand, getWorkspaceFileName } from '../lib/ide.js';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { BranchContext } from '../foundation/BranchContext.js';
|
|
4
|
+
import { pathExists } from '../lib/fs.js';
|
|
5
|
+
import { checkoutBranch, displayOperationSummary, gitBranchExists, GitOperation } from '../lib/git.js';
|
|
6
|
+
import { promptChoice, promptConfirm, promptForBranch } from '../lib/prompt.js';
|
|
7
|
+
import { confirmSlugOrThrow } from '../lib/slug.js';
|
|
8
|
+
import { AbstractCommands } from './AbstractCommands.js';
|
|
9
|
+
export class BranchCommands extends AbstractCommands {
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// PUBLIC COMMAND METHODS
|
|
12
|
+
// ============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* Create a new branch folder and initialize missing spec files.
|
|
15
|
+
* - Does not create worktrees or set active branch - use `start` for that.
|
|
16
|
+
* - But branch is ready for `start` after this command, since spec files are initialized
|
|
17
|
+
*/
|
|
18
|
+
async create(rawSlug) {
|
|
19
|
+
const branchName = await confirmSlugOrThrow(rawSlug);
|
|
20
|
+
if (await this.context.isBranchActive(branchName)) {
|
|
21
|
+
console.log(`Branch "${branchName}" is already active. Use "forge start ${branchName}" to start working on it.`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (await this.context.hasBranch(branchName)) {
|
|
25
|
+
console.log(`Branch "${branchName}" already exists in the repository. You can start it with "forge start ${branchName}".`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const baseBranch = await promptForBranch(this.context.mainRepo.path, 'Select base branch for the new branch (showing branches of main repo, the branch must exist in all repos):', this.context.options.git.featureBranchPrefix, true);
|
|
29
|
+
// Prepare branch: validate branchName, ensure branch and spec exist
|
|
30
|
+
await this.prepareBranch(branchName, baseBranch);
|
|
31
|
+
console.log(`Branch "${branchName}" created and spec files initialized. Use 'forge start ${branchName}' to start working on it using git worktrees.`);
|
|
32
|
+
const mergeBack = await promptConfirm(`Do you want to merge the new branch "${branchName}" to another branch now?\n
|
|
33
|
+
(if you want to keep track of its spec, but not start to work on it yet)`);
|
|
34
|
+
if (mergeBack) {
|
|
35
|
+
await this.merge(rawSlug);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Start the development of a branch by creating worktrees (if not already created) and setting the active branch pointer.
|
|
40
|
+
* - Does create worktrees and set active branch
|
|
41
|
+
* - But it doesn't merge spec files to main branch if they were not already created
|
|
42
|
+
* This is useful when you want to start working instantly on a Branch and merge it when finished.
|
|
43
|
+
*/
|
|
44
|
+
async start(rawSlug) {
|
|
45
|
+
const branchName = await confirmSlugOrThrow(rawSlug);
|
|
46
|
+
let baseBranch = undefined;
|
|
47
|
+
if (!(await this.context.hasBranch(branchName))) {
|
|
48
|
+
baseBranch = await promptForBranch(this.context.mainRepo.path, 'Select base branch for the new branch (showing branches of main repo, the branch must exist in all repos):', this.context.options.git.featureBranchPrefix, true);
|
|
49
|
+
}
|
|
50
|
+
// Prepare branch: validate branchName, ensure branch and spec exist
|
|
51
|
+
await (await this.prepareBranch(branchName, baseBranch)).start();
|
|
52
|
+
// Propose to open the branch in the configured IDE
|
|
53
|
+
if (this.context.ides.length > 0) {
|
|
54
|
+
const shouldOpen = await promptConfirm(`Open branch "${branchName}" in ${this.context.ides[0].name}?`);
|
|
55
|
+
if (shouldOpen) {
|
|
56
|
+
await this.open(branchName);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* List all branch worktrees with their git branches.
|
|
62
|
+
*/
|
|
63
|
+
async list(prefix = '') {
|
|
64
|
+
let branchContexts = await this.context.loadActiveBranchesContexts();
|
|
65
|
+
if (prefix) {
|
|
66
|
+
branchContexts = branchContexts.filter((context) => context.branchName.startsWith(prefix));
|
|
67
|
+
}
|
|
68
|
+
// Display each branch with branch information
|
|
69
|
+
console.log(`Active branch${branchContexts.length > 1 ? 'es' : ''}${prefix ? ` with prefix "${prefix}"` : ''}: (${branchContexts.length} found)`);
|
|
70
|
+
for (const branchContext of branchContexts) {
|
|
71
|
+
// Collect branch information for this branch
|
|
72
|
+
const repositoriesStatus = await branchContext.collectRepositoriesStatus();
|
|
73
|
+
// Format branch information
|
|
74
|
+
const { info: branchInfo, onExpectedBranch, isInconsistent, } = this.formatBranchContextRepositoriesStatus(branchContext, repositoriesStatus);
|
|
75
|
+
// Use red color for inconsistent branches
|
|
76
|
+
// Use orange color for branches that are not on the expected branch, but consistent across repos (e.g. all on "main" instead of the branch branch)
|
|
77
|
+
// Use green color for branches that are on the expected branch
|
|
78
|
+
const RED = '\x1b[31m';
|
|
79
|
+
const ORANGE = '\x1b[33m';
|
|
80
|
+
const GREEN = '\x1b[32m';
|
|
81
|
+
const RESET = '\x1b[0m';
|
|
82
|
+
const COLOR = isInconsistent ? RED : !onExpectedBranch ? ORANGE : GREEN;
|
|
83
|
+
const output = ` - ${branchContext.branchName}${COLOR}${branchInfo}${RESET}`;
|
|
84
|
+
console.log(output);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resync all repos in a branch worktree to the correct branch.
|
|
89
|
+
*/
|
|
90
|
+
async resync(rawSlug) {
|
|
91
|
+
const branchName = await confirmSlugOrThrow(rawSlug);
|
|
92
|
+
const branchContext = await this.context.loadBranchContext(branchName);
|
|
93
|
+
console.log(`Resyncing worktrees to branch \"${branchName}\"...`);
|
|
94
|
+
let hasErrors = false;
|
|
95
|
+
// Resync each repository worktree
|
|
96
|
+
for (const repository of branchContext.repositories) {
|
|
97
|
+
const hadError = await this.resyncRepository(branchContext, repository);
|
|
98
|
+
if (hadError) {
|
|
99
|
+
hasErrors = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const rootRepository of this.context.repositories) {
|
|
103
|
+
if (!branchContext.repositories.some((r) => r.rootRepository.name === rootRepository.name)) {
|
|
104
|
+
console.log(` ⚠ ${rootRepository.name}: not part of branch context, trying to init it`);
|
|
105
|
+
const worktreeRepository = await branchContext.ensureWorktreeForRepo(rootRepository);
|
|
106
|
+
worktreeRepository.setActiveSpec(branchContext);
|
|
107
|
+
branchContext.repositories.push(worktreeRepository);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Display summary
|
|
111
|
+
if (hasErrors) {
|
|
112
|
+
console.log('\\n⚠ Resync completed with errors.');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
console.log('\\n✓ All repos resynced successfully.');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Stop a branch by removing its worktrees and clearing active pointer.
|
|
120
|
+
*/
|
|
121
|
+
async stop(rawSlug) {
|
|
122
|
+
const branchName = await confirmSlugOrThrow(rawSlug);
|
|
123
|
+
// Clean up any orphaned worktrees first
|
|
124
|
+
await this.context.cleanOrphanedWorktrees(branchName);
|
|
125
|
+
if (!(await this.context.isBranchActive(branchName))) {
|
|
126
|
+
console.log(`Branch "${branchName}" is not active. Nothing to stop.`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const branchContext = await this.context.loadBranchContext(branchName);
|
|
130
|
+
// Verify clean state before stopping
|
|
131
|
+
await branchContext.stop();
|
|
132
|
+
// Clean up all worktrees and branch directory
|
|
133
|
+
await branchContext.delete();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Archive a branch by moving it from .branchs/<branchName>/ to .branchs/.archives/<branchName>/
|
|
137
|
+
* and committing the change in the branch branch.
|
|
138
|
+
*/
|
|
139
|
+
async archive(rawSlug) {
|
|
140
|
+
const branchName = await confirmSlugOrThrow(rawSlug);
|
|
141
|
+
let branchContext = await this.context.getBranchContext(branchName);
|
|
142
|
+
// First stop the branch to clean up worktrees and ensure there are no uncommitted changes, but don't delete the branch context yet
|
|
143
|
+
await branchContext.stop();
|
|
144
|
+
// Move the specs files to the archive folder and commit + merge
|
|
145
|
+
await branchContext.archive();
|
|
146
|
+
// Clean up all worktrees and branch directory
|
|
147
|
+
await branchContext.delete();
|
|
148
|
+
const confirmDeleteBranch = await promptConfirm(`Do you also want to delete the branch "${branchContext.branchName}" in all repos?\nThis cannot be undone, but you can always recreate the branch later if needed.`);
|
|
149
|
+
if (confirmDeleteBranch) {
|
|
150
|
+
await branchContext.deleteBranch();
|
|
151
|
+
console.log(`Branch "${branchContext.branchName}" deleted.`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.log(`Branch "${branchContext.branchName}" not deleted. You can delete it manually later if you change your mind.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Open a branch in the configured IDE.
|
|
159
|
+
* If a workspace file exists, it will be opened; otherwise, the branch directory is opened.
|
|
160
|
+
*
|
|
161
|
+
* @param rawSlug - Optional branch slug. If not provided, finds the nearest branch context.
|
|
162
|
+
* @throws {Error} If no IDE is configured
|
|
163
|
+
*/
|
|
164
|
+
async open(rawSlug) {
|
|
165
|
+
// 1. Resolve feature context
|
|
166
|
+
const branchContext = rawSlug
|
|
167
|
+
? await this.context.loadBranchContext(await confirmSlugOrThrow(rawSlug))
|
|
168
|
+
: await BranchContext.findNearestBranchContext(this.context);
|
|
169
|
+
// 2. Check that at least one IDE is configured
|
|
170
|
+
if (!this.context.ides || this.context.ides.length === 0) {
|
|
171
|
+
throw new Error('No IDE configured. Add an IDE to your .feat-forge.json configuration.');
|
|
172
|
+
}
|
|
173
|
+
// 3. Use the first IDE from the configuration
|
|
174
|
+
const ide = this.context.ides[0];
|
|
175
|
+
// 4. Resolve the CLI command
|
|
176
|
+
const command = ide.openCommand || getDefaultIDECommand(ide.name);
|
|
177
|
+
// 5. Determine the target to open
|
|
178
|
+
const workspaceFile = branchContext.getInPath(await getWorkspaceFileName(branchContext.branchName, ide.name));
|
|
179
|
+
const workspaceExists = await pathExists(workspaceFile);
|
|
180
|
+
const target = workspaceExists ? workspaceFile : branchContext.path;
|
|
181
|
+
// 6. Log informative messages
|
|
182
|
+
if (!workspaceExists) {
|
|
183
|
+
console.log('Workspace file not found, opening branch directory');
|
|
184
|
+
}
|
|
185
|
+
console.log(`Opening "${branchContext.branchName}" in ${ide.name}...`);
|
|
186
|
+
// 7. Spawn the IDE process in detached mode
|
|
187
|
+
const subprocess = execa(command, [target], { detached: true, stdio: 'ignore', cleanup: false });
|
|
188
|
+
subprocess.unref();
|
|
189
|
+
// Prevent unhandled rejection if command is not found
|
|
190
|
+
subprocess.catch((error) => {
|
|
191
|
+
console.error(`Failed to open IDE: ${error.message}`);
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async merge(rawSlug) {
|
|
196
|
+
const branchName = await confirmSlugOrThrow(rawSlug);
|
|
197
|
+
let targetRepos;
|
|
198
|
+
// If the feature is active, we only merge the repos that are part of the feature context (in case some configured repos were not part of the feature when it was started)
|
|
199
|
+
// If the feature is not active, we merge all repos to ensure the feature branch is up to date everywhere.
|
|
200
|
+
const isFeatureActive = await this.context.isBranchActive(branchName);
|
|
201
|
+
if (isFeatureActive) {
|
|
202
|
+
const branchContext = await this.context.loadBranchContext(branchName);
|
|
203
|
+
// If the feature is active, we verify that all repos in the feature context are clean before merging
|
|
204
|
+
await this.verifyCleanBranch(branchContext);
|
|
205
|
+
targetRepos = branchContext.repositories.map((wtRepo) => wtRepo.rootRepository);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
targetRepos = this.context.repositories;
|
|
209
|
+
}
|
|
210
|
+
const targetBranch = await promptForBranch(this.context.mainRepo.path, 'Select target branch (showing branches of main repo, the branch must exist in all repos):', this.context.options.git.featureBranchPrefix, false);
|
|
211
|
+
console.log(`\nMerging : ${branchName}`);
|
|
212
|
+
console.log(`\n📍 Target branch: ${targetBranch}\n`);
|
|
213
|
+
let results = [];
|
|
214
|
+
// Sequential merge for more readable logging.
|
|
215
|
+
// Also merge commit editor will be opened sequentially, which is better UX than multiple merge commit editors opening at the same time if there are multiple repos to merge.
|
|
216
|
+
for (const repo of targetRepos) {
|
|
217
|
+
results.push(await repo.merge(branchName, targetBranch));
|
|
218
|
+
}
|
|
219
|
+
await this.displayMergeSummaryAndProposeAction(results, branchName);
|
|
220
|
+
}
|
|
221
|
+
async rebase(rawSlug) {
|
|
222
|
+
const branchName = await confirmSlugOrThrow(rawSlug);
|
|
223
|
+
let branchContext;
|
|
224
|
+
let targetRepos;
|
|
225
|
+
// If the feature is active, we only rebase the repos that are part of the feature context (in case some configured repos were not part of the feature when it was started)
|
|
226
|
+
// If the feature is not active, we rebase all repos to ensure the feature branch is up to date everywhere.
|
|
227
|
+
const isFeatureActive = await this.context.isBranchActive(branchName);
|
|
228
|
+
if (!isFeatureActive) {
|
|
229
|
+
throw new Error(`Branch "${branchName}" is not active. Rebase only possible on active branches. Do a "forge start ${branchName}" to start the branch first.`);
|
|
230
|
+
}
|
|
231
|
+
branchContext = await this.context.loadBranchContext(branchName);
|
|
232
|
+
// If the feature is active, we verify that all repos in the feature context are clean before rebasing
|
|
233
|
+
await this.verifyCleanBranch(branchContext);
|
|
234
|
+
targetRepos = branchContext.repositories;
|
|
235
|
+
const baseBranch = await promptForBranch(this.context.mainRepo.path, 'Select base branch (showing branches of main repo, the branch must exist in all repos):', this.context.options.git.featureBranchPrefix, true);
|
|
236
|
+
console.log(`\nRebasing branch: ${branchName}`);
|
|
237
|
+
console.log(`\n📍 Base branch: ${baseBranch}\n`);
|
|
238
|
+
let results = [];
|
|
239
|
+
// Sequential rebase for more readable logging.
|
|
240
|
+
// Also rebase commit editor will be opened sequentially, which is better UX than multiple rebase commit editors opening at the same time if there are multiple repos to rebase.
|
|
241
|
+
for (const repo of targetRepos) {
|
|
242
|
+
results.push(await repo.rebase(branchName, baseBranch));
|
|
243
|
+
}
|
|
244
|
+
await this.displayRebaseSummary(results, branchName);
|
|
245
|
+
}
|
|
246
|
+
// ============================================================================
|
|
247
|
+
// PRIVATE UTILITY METHODS
|
|
248
|
+
// ============================================================================
|
|
249
|
+
/**
|
|
250
|
+
* Prepare branch + spec initialization shared by create/start.
|
|
251
|
+
*/
|
|
252
|
+
async prepareBranch(branchName, baseBranch) {
|
|
253
|
+
let invisibleRepoChanges = 0;
|
|
254
|
+
// Ensure branch exists in all repos (creates branch if missing, but does not check it out)
|
|
255
|
+
await this.context.ensureBranch(branchName, baseBranch); // doesn't realy count as changes
|
|
256
|
+
let branchContext;
|
|
257
|
+
if (await this.context.isBranchActive(branchName)) {
|
|
258
|
+
// The branch is already active, load the real context
|
|
259
|
+
branchContext = await this.context.loadBranchContext(branchName);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// The branch is not active yet, create an empty context to prepare the branch (this will create the spec files in a temporary location and merge them to the main branch, but won't create the worktrees yet)
|
|
263
|
+
branchContext = this.context.makeBranchContext(branchName);
|
|
264
|
+
}
|
|
265
|
+
// Ensure branch spec files exist in the main repo branch, creating them in a temporary worktree if needed
|
|
266
|
+
invisibleRepoChanges += await branchContext.initBranch();
|
|
267
|
+
return branchContext;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Format branch information for display.
|
|
271
|
+
*
|
|
272
|
+
* Shows a single branch name if consistent across repos,
|
|
273
|
+
* or all repo-branch pairs if inconsistent (highlighted in red).
|
|
274
|
+
*
|
|
275
|
+
* @param branches - Map of repository names to branch names
|
|
276
|
+
* @returns Formatted branch info string and inconsistency flag
|
|
277
|
+
*/
|
|
278
|
+
formatBranchContextRepositoriesStatus(branchContext, repositoriesStatus) {
|
|
279
|
+
const uniqueBranches = new Set(Object.values(repositoriesStatus).map((status) => status.branch));
|
|
280
|
+
if (uniqueBranches.size === 0) {
|
|
281
|
+
return { info: ' (no branch info)', isInconsistent: false, onExpectedBranch: false };
|
|
282
|
+
}
|
|
283
|
+
if (uniqueBranches.size === 1) {
|
|
284
|
+
// All repos on same branch
|
|
285
|
+
const currentBranch = Array.from(uniqueBranches)[0];
|
|
286
|
+
return {
|
|
287
|
+
info: ` (branch: ${currentBranch})`,
|
|
288
|
+
isInconsistent: false,
|
|
289
|
+
onExpectedBranch: currentBranch === branchContext.branchName,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// Different branches across repos - show all
|
|
293
|
+
const branchList = Object.entries(repositoriesStatus)
|
|
294
|
+
.map(([repo, status]) => `${repo}: ${status.branch}`)
|
|
295
|
+
.join(', ');
|
|
296
|
+
return { info: ` (${branchList})`, isInconsistent: true, onExpectedBranch: false };
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Resync a single repository worktree to the expected branch.
|
|
300
|
+
*
|
|
301
|
+
* @param repoName - Name of the repository
|
|
302
|
+
* @param worktreePath - Path to the worktree
|
|
303
|
+
* @param expectedBranch - Expected branch name
|
|
304
|
+
* @returns true if an error occurred, false otherwise
|
|
305
|
+
*/
|
|
306
|
+
async resyncRepository(branchContext, repository) {
|
|
307
|
+
// Check if worktree exists
|
|
308
|
+
if (!(await pathExists(repository.path))) {
|
|
309
|
+
console.log(` ⚠ ${repository.name}: worktree not found, skipping`);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
const { branch, dirty, onExpectedBranch } = await repository.getStatus(branchContext.branchName);
|
|
313
|
+
const expectedBranch = branchContext.branchName;
|
|
314
|
+
// Check current branch
|
|
315
|
+
if (onExpectedBranch) {
|
|
316
|
+
console.log(` ✓ ${repository.name}: already on ${expectedBranch}`);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
// Check for uncommitted changes
|
|
320
|
+
if (dirty) {
|
|
321
|
+
console.log(` ✗ ${repository.name}: has uncommitted changes, cannot switch branch`);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
// Check if expected branch exists
|
|
325
|
+
if (!(await gitBranchExists(repository.path, expectedBranch))) {
|
|
326
|
+
// This should never happen ...
|
|
327
|
+
console.log(` ✗ ${repository.name}: branch ${expectedBranch} does not exist`);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
// Attempt to checkout expected branch
|
|
331
|
+
try {
|
|
332
|
+
await checkoutBranch(repository.path, expectedBranch);
|
|
333
|
+
console.log(` ✓ ${repository.name}: switched from ${branch} to ${expectedBranch}`);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
console.log(` ✗ ${repository.name}: failed to checkout ${expectedBranch}`);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Display merge summary and propose next action to the user.
|
|
343
|
+
*
|
|
344
|
+
* Shows categorized results of all merge operations and, if all were successful,
|
|
345
|
+
* prompts the user to choose what to do next with the feature.
|
|
346
|
+
*
|
|
347
|
+
* @param results - Array of merge results
|
|
348
|
+
* @param branchName - The feature slug
|
|
349
|
+
*/
|
|
350
|
+
async displayMergeSummaryAndProposeAction(results, branchName) {
|
|
351
|
+
const { successful } = displayOperationSummary(results, GitOperation.Merge);
|
|
352
|
+
// If all successful, propose next action
|
|
353
|
+
if (successful.length === results.length) {
|
|
354
|
+
await this.proposeNextAction(branchName);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Propose next action after successful merge and execute user's choice.
|
|
359
|
+
*
|
|
360
|
+
* Offers three options:
|
|
361
|
+
* 1. Archive the branch (recommended) - moves to .archives
|
|
362
|
+
* 2. Stop the branch - removes worktrees but keeps branches
|
|
363
|
+
* 3. Do nothing - keeps branch active
|
|
364
|
+
*
|
|
365
|
+
* @param branchName - The branch name
|
|
366
|
+
*/
|
|
367
|
+
async proposeNextAction(branchName) {
|
|
368
|
+
const selection = await promptChoice('What would you like to do next?', [
|
|
369
|
+
{ value: '1', name: 'Do nothing (keep branch active)' },
|
|
370
|
+
{ value: '2', name: 'Stop branch (keep branches)' },
|
|
371
|
+
{ value: '3', name: 'Archive branch (recommended)' },
|
|
372
|
+
]);
|
|
373
|
+
switch (selection) {
|
|
374
|
+
case '3':
|
|
375
|
+
console.log(`\n📦 Archiving branch "${branchName}"...`);
|
|
376
|
+
await this.archive(branchName);
|
|
377
|
+
break;
|
|
378
|
+
case '2':
|
|
379
|
+
console.log(`\n⏸️ Stopping branch "${branchName}"...`);
|
|
380
|
+
await this.stop(branchName);
|
|
381
|
+
break;
|
|
382
|
+
case '1':
|
|
383
|
+
console.log('\n✅ Branch remains active. You can continue working on it.');
|
|
384
|
+
break;
|
|
385
|
+
default:
|
|
386
|
+
console.log('\n✅ No action taken.');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Display rebase summary to the user.
|
|
391
|
+
*
|
|
392
|
+
* Shows categorized results of all rebase operations.
|
|
393
|
+
*
|
|
394
|
+
* @param results - Array of rebase results
|
|
395
|
+
* @param slug - The feature slug
|
|
396
|
+
*/
|
|
397
|
+
async displayRebaseSummary(results, slug) {
|
|
398
|
+
displayOperationSummary(results, GitOperation.Rebase);
|
|
399
|
+
}
|
|
400
|
+
}
|