@hyperdrive.bot/gut 0.1.8 ā 0.1.10
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/README.md +1048 -1
- package/dist/base-command.d.ts +22 -0
- package/dist/base-command.js +99 -0
- package/dist/commands/add.d.ts +14 -0
- package/dist/commands/add.js +70 -0
- package/dist/commands/affected.d.ts +23 -0
- package/dist/commands/affected.js +323 -0
- package/dist/commands/audit.d.ts +33 -0
- package/dist/commands/audit.js +594 -0
- package/dist/commands/back.d.ts +6 -0
- package/dist/commands/back.js +29 -0
- package/dist/commands/checkout.d.ts +14 -0
- package/dist/commands/checkout.js +124 -0
- package/dist/commands/commit.d.ts +11 -0
- package/dist/commands/commit.js +107 -0
- package/dist/commands/context.d.ts +6 -0
- package/dist/commands/context.js +32 -0
- package/dist/commands/contexts.d.ts +7 -0
- package/dist/commands/contexts.js +88 -0
- package/dist/commands/deps.d.ts +10 -0
- package/dist/commands/deps.js +100 -0
- package/dist/commands/entity/add.d.ts +16 -0
- package/dist/commands/entity/add.js +103 -0
- package/dist/commands/entity/clone-all.d.ts +18 -0
- package/dist/commands/entity/clone-all.js +166 -0
- package/dist/commands/entity/clone.d.ts +17 -0
- package/dist/commands/entity/clone.js +132 -0
- package/dist/commands/entity/list.d.ts +11 -0
- package/dist/commands/entity/list.js +80 -0
- package/dist/commands/entity/remove.d.ts +12 -0
- package/dist/commands/entity/remove.js +54 -0
- package/dist/commands/extract.d.ts +35 -0
- package/dist/commands/extract.js +483 -0
- package/dist/commands/focus.d.ts +19 -0
- package/dist/commands/focus.js +137 -0
- package/dist/commands/graph.d.ts +18 -0
- package/dist/commands/graph.js +273 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +75 -0
- package/dist/commands/insights.d.ts +21 -0
- package/dist/commands/insights.js +465 -0
- package/dist/commands/patterns.d.ts +40 -0
- package/dist/commands/patterns.js +405 -0
- package/dist/commands/pull.d.ts +11 -0
- package/dist/commands/pull.js +121 -0
- package/dist/commands/push.d.ts +11 -0
- package/dist/commands/push.js +97 -0
- package/dist/commands/quick-setup.d.ts +20 -0
- package/dist/commands/quick-setup.js +417 -0
- package/dist/commands/recent.d.ts +9 -0
- package/dist/commands/recent.js +51 -0
- package/dist/commands/related.d.ts +23 -0
- package/dist/commands/related.js +255 -0
- package/dist/commands/repos.d.ts +17 -0
- package/dist/commands/repos.js +184 -0
- package/dist/commands/stack.d.ts +10 -0
- package/dist/commands/stack.js +78 -0
- package/dist/commands/status.d.ts +13 -0
- package/dist/commands/status.js +193 -0
- package/dist/commands/sync.d.ts +11 -0
- package/dist/commands/sync.js +139 -0
- package/dist/commands/ticket/focus.d.ts +20 -0
- package/dist/commands/ticket/focus.js +217 -0
- package/dist/commands/ticket/get.d.ts +15 -0
- package/dist/commands/ticket/get.js +168 -0
- package/dist/commands/ticket/hint.d.ts +16 -0
- package/dist/commands/ticket/hint.js +147 -0
- package/dist/commands/ticket/index.d.ts +10 -0
- package/dist/commands/ticket/index.js +60 -0
- package/dist/commands/ticket/list.d.ts +13 -0
- package/dist/commands/ticket/list.js +120 -0
- package/dist/commands/ticket/sync.d.ts +14 -0
- package/dist/commands/ticket/sync.js +85 -0
- package/dist/commands/ticket/update.d.ts +17 -0
- package/dist/commands/ticket/update.js +142 -0
- package/dist/commands/unfocus.d.ts +6 -0
- package/dist/commands/unfocus.js +19 -0
- package/dist/commands/used-by.d.ts +13 -0
- package/dist/commands/used-by.js +110 -0
- package/dist/commands/workspace.d.ts +22 -0
- package/dist/commands/workspace.js +372 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +16 -0
- package/dist/models/entity.model.d.ts +234 -0
- package/dist/models/entity.model.js +1 -0
- package/dist/models/ticket.model.d.ts +117 -0
- package/dist/models/ticket.model.js +43 -0
- package/dist/services/auth.service.d.ts +15 -0
- package/dist/services/auth.service.js +26 -0
- package/dist/services/config.service.d.ts +34 -0
- package/dist/services/config.service.js +234 -0
- package/dist/services/entity.service.d.ts +20 -0
- package/dist/services/entity.service.js +127 -0
- package/dist/services/focus.service.d.ts +71 -0
- package/dist/services/focus.service.js +614 -0
- package/dist/services/git.service.d.ts +39 -0
- package/dist/services/git.service.js +188 -0
- package/dist/services/gut-api.service.d.ts +53 -0
- package/dist/services/gut-api.service.js +99 -0
- package/dist/services/ticket.service.d.ts +84 -0
- package/dist/services/ticket.service.js +207 -0
- package/dist/utils/display.d.ts +26 -0
- package/dist/utils/display.js +145 -0
- package/dist/utils/filesystem.d.ts +32 -0
- package/dist/utils/filesystem.js +198 -0
- package/dist/utils/index.d.ts +13 -0
- package/dist/utils/index.js +14 -0
- package/dist/utils/validation.d.ts +22 -0
- package/dist/utils/validation.js +192 -0
- package/oclif.manifest.json +2006 -0
- package/package.json +11 -2
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as os from 'node:os';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import ora from 'ora';
|
|
8
|
+
import { BaseCommand } from '../base-command.js';
|
|
9
|
+
export default class Extract extends BaseCommand {
|
|
10
|
+
static args = {
|
|
11
|
+
folderPath: Args.string({
|
|
12
|
+
description: 'Path to the folder to extract from the current repository',
|
|
13
|
+
name: 'folderPath',
|
|
14
|
+
required: true,
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
static description = 'Extract a folder from the current repository into its own repository with full git history';
|
|
18
|
+
static examples = [
|
|
19
|
+
'<%= config.bin %> <%= command.id %> packages/my-package --gitlab-token <token> --project-path my-group/my-package',
|
|
20
|
+
'<%= config.bin %> <%= command.id %> src/components --gitlab-token <token> --project-path my-group/frontend/components --target-path .',
|
|
21
|
+
'<%= config.bin %> <%= command.id %> libs/shared --gitlab-token <token> --project-path company/backend/libs/shared-utils',
|
|
22
|
+
];
|
|
23
|
+
static flags = {
|
|
24
|
+
'dry-run': Flags.boolean({
|
|
25
|
+
default: false,
|
|
26
|
+
description: 'Show what would be done without actually doing it',
|
|
27
|
+
}),
|
|
28
|
+
force: Flags.boolean({
|
|
29
|
+
char: 'f',
|
|
30
|
+
default: false,
|
|
31
|
+
description: 'Force extraction even if target directory exists',
|
|
32
|
+
}),
|
|
33
|
+
'gitlab-token': Flags.string({
|
|
34
|
+
description: 'GitLab personal access token for creating projects',
|
|
35
|
+
required: false,
|
|
36
|
+
}),
|
|
37
|
+
'gitlab-url': Flags.string({
|
|
38
|
+
default: 'https://gitlab.com',
|
|
39
|
+
description: 'GitLab instance URL',
|
|
40
|
+
}),
|
|
41
|
+
'keep-temp': Flags.boolean({
|
|
42
|
+
default: false,
|
|
43
|
+
description: 'Keep temporary directory after extraction',
|
|
44
|
+
}),
|
|
45
|
+
'project-path': Flags.string({
|
|
46
|
+
description: 'GitLab project path (group/project-name)',
|
|
47
|
+
required: false,
|
|
48
|
+
}),
|
|
49
|
+
'target-path': Flags.string({
|
|
50
|
+
default: '.',
|
|
51
|
+
description: 'Target path in the new repository (default: same as source folder)',
|
|
52
|
+
}),
|
|
53
|
+
'temp-dir': Flags.string({
|
|
54
|
+
description: 'Custom temporary directory path',
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
get requiresInit() {
|
|
58
|
+
return false; // This command can work outside of gut workspace
|
|
59
|
+
}
|
|
60
|
+
async catch(error) {
|
|
61
|
+
if (error.code === 'OCLIF_REQUIRED_ARG_ERROR' ||
|
|
62
|
+
(error.args && error.args.some((arg) => arg.name === 'folderPath'))) {
|
|
63
|
+
this.error(`Please specify a folder path to extract.
|
|
64
|
+
|
|
65
|
+
Usage: gut extract <folder-path>
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
gut extract packages/my-package
|
|
69
|
+
gut extract src/components
|
|
70
|
+
gut extract libs/shared
|
|
71
|
+
|
|
72
|
+
GitLab project paths support nested groups:
|
|
73
|
+
my-group/my-project
|
|
74
|
+
company/frontend/components
|
|
75
|
+
organization/team/backend/services
|
|
76
|
+
|
|
77
|
+
Use --help for more information.`);
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
async run() {
|
|
82
|
+
const { args, flags } = await this.parse(Extract);
|
|
83
|
+
const { folderPath } = args;
|
|
84
|
+
const { 'dry-run': dryRun, force, 'gitlab-token': gitlabToken, 'gitlab-url': gitlabUrl, 'keep-temp': keepTemp, 'project-path': projectPath, 'target-path': targetPath, 'temp-dir': customTempDir, } = flags;
|
|
85
|
+
const spinner = ora();
|
|
86
|
+
try {
|
|
87
|
+
// Validate we're in a git repository
|
|
88
|
+
if (!await this.gitService.isRepository(process.cwd())) {
|
|
89
|
+
this.error('Current directory is not a git repository. Please run this command from within a git repository.');
|
|
90
|
+
}
|
|
91
|
+
// Validate folder path is provided
|
|
92
|
+
if (!folderPath || folderPath.trim() === '') {
|
|
93
|
+
this.error('Please specify a folder path to extract. Example: gut extract packages/my-package');
|
|
94
|
+
}
|
|
95
|
+
// Validate folder exists
|
|
96
|
+
const sourcePath = path.resolve(folderPath);
|
|
97
|
+
if (!fs.existsSync(sourcePath)) {
|
|
98
|
+
this.error(`Folder does not exist: ${sourcePath}\nPlease check the path and try again.`);
|
|
99
|
+
}
|
|
100
|
+
// Validate it's actually a directory
|
|
101
|
+
if (!fs.statSync(sourcePath).isDirectory()) {
|
|
102
|
+
this.error(`Path is not a directory: ${sourcePath}\nPlease specify a folder path.`);
|
|
103
|
+
}
|
|
104
|
+
// Get user inputs if not provided
|
|
105
|
+
const config = await this.collectConfiguration({
|
|
106
|
+
gitlabToken,
|
|
107
|
+
gitlabUrl,
|
|
108
|
+
projectPath,
|
|
109
|
+
});
|
|
110
|
+
// Check if GitLab project exists first
|
|
111
|
+
const existingProject = await this.checkExistingProject(config, spinner);
|
|
112
|
+
if (existingProject) {
|
|
113
|
+
this.log(chalk.green('ā GitLab project already exists - skipping extraction'));
|
|
114
|
+
this.log(chalk.dim(` Repository URL: ${existingProject.web_url}`));
|
|
115
|
+
this.log(chalk.dim(` SSH URL: ${existingProject.ssh_url_to_repo}`));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (dryRun) {
|
|
119
|
+
this.log(chalk.yellow('DRY RUN - No actual changes will be made'));
|
|
120
|
+
this.log(`Would extract: ${sourcePath}`);
|
|
121
|
+
this.log(`Target repository: ${config.projectPath}`);
|
|
122
|
+
this.log(`GitLab URL: ${config.gitlabUrl}`);
|
|
123
|
+
this.log(`Target path in new repo: ${targetPath}`);
|
|
124
|
+
this.log('Would create new GitLab project');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Create temporary directory
|
|
128
|
+
const tempDir = customTempDir || path.join(os.tmpdir(), `gut-extract-${Date.now()}`);
|
|
129
|
+
spinner.start('Setting up temporary workspace...');
|
|
130
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
131
|
+
spinner.succeed('Temporary workspace created');
|
|
132
|
+
try {
|
|
133
|
+
// Step 1: Clone current repository to temp
|
|
134
|
+
await this.cloneToTemp(tempDir, spinner);
|
|
135
|
+
// Step 2: Get all branches and filter each one
|
|
136
|
+
const branches = await this.getAllBranches(tempDir, spinner);
|
|
137
|
+
await this.filterAllBranches(tempDir, folderPath, targetPath, branches, spinner);
|
|
138
|
+
// Step 3: Create GitLab project
|
|
139
|
+
const projectInfo = await this.createGitLabProject(config, spinner);
|
|
140
|
+
// Step 4: Add remote and push all branches
|
|
141
|
+
await this.pushToNewRepository(tempDir, projectInfo.ssh_url_to_repo, branches, spinner);
|
|
142
|
+
// Step 5: Clone new repository to final location
|
|
143
|
+
await this.cloneNewRepository(projectInfo, folderPath, force, spinner);
|
|
144
|
+
this.log(chalk.green('ā Successfully extracted folder to new repository!'));
|
|
145
|
+
this.log(chalk.dim(` Repository URL: ${projectInfo.web_url}`));
|
|
146
|
+
this.log(chalk.dim(` SSH URL: ${projectInfo.ssh_url_to_repo}`));
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
// Cleanup temp directory unless requested to keep
|
|
150
|
+
if (!keepTemp) {
|
|
151
|
+
spinner.start('Cleaning up temporary files...');
|
|
152
|
+
execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
|
|
153
|
+
spinner.succeed('Cleanup completed');
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
this.log(chalk.yellow(`Temporary directory preserved at: ${tempDir}`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
spinner.fail();
|
|
162
|
+
this.error(error instanceof Error ? error.message : String(error));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async checkExistingProject(config, spinner) {
|
|
166
|
+
spinner.start('Checking if GitLab project exists...');
|
|
167
|
+
try {
|
|
168
|
+
const checkResponse = await fetch(`${config.gitlabUrl}/api/v4/projects/${encodeURIComponent(config.projectPath)}`, {
|
|
169
|
+
headers: {
|
|
170
|
+
'PRIVATE-TOKEN': config.gitlabToken,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
if (checkResponse.ok) {
|
|
174
|
+
const existingProject = await checkResponse.json();
|
|
175
|
+
spinner.succeed(`GitLab project already exists: ${existingProject.web_url}`);
|
|
176
|
+
return existingProject;
|
|
177
|
+
}
|
|
178
|
+
spinner.succeed('GitLab project does not exist - will create new one');
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
spinner.warn(`Could not check project existence: ${error instanceof Error ? error.message : String(error)}`);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async cloneNewRepository(projectInfo, originalFolderPath, force, spinner) {
|
|
187
|
+
const folderName = path.basename(originalFolderPath);
|
|
188
|
+
const targetPath = path.resolve(folderName);
|
|
189
|
+
if (fs.existsSync(targetPath)) {
|
|
190
|
+
if (!force) {
|
|
191
|
+
this.error(`Directory ${targetPath} already exists. Use --force to overwrite.`);
|
|
192
|
+
}
|
|
193
|
+
spinner.start(`Removing existing directory: ${targetPath}`);
|
|
194
|
+
execSync(`rm -rf "${targetPath}"`, { stdio: 'pipe' });
|
|
195
|
+
spinner.succeed('Existing directory removed');
|
|
196
|
+
}
|
|
197
|
+
spinner.start('Cloning new repository...');
|
|
198
|
+
execSync(`git clone "${projectInfo.ssh_url_to_repo}" "${targetPath}"`, { stdio: 'pipe' });
|
|
199
|
+
spinner.succeed(`New repository cloned to: ${targetPath}`);
|
|
200
|
+
// Force pull to ensure all branches are available locally
|
|
201
|
+
spinner.start('Updating local repository...');
|
|
202
|
+
execSync('git fetch --all', { cwd: targetPath, stdio: 'pipe' });
|
|
203
|
+
spinner.succeed('Repository updated');
|
|
204
|
+
}
|
|
205
|
+
async cloneToTemp(tempDir, spinner) {
|
|
206
|
+
spinner.start('Cloning current repository...');
|
|
207
|
+
const currentRepo = path.basename(process.cwd());
|
|
208
|
+
const tempRepoPath = path.join(tempDir, currentRepo);
|
|
209
|
+
const remoteUrl = await this.gitService.getRemoteUrl(process.cwd());
|
|
210
|
+
if (!remoteUrl) {
|
|
211
|
+
// If no remote, create a local clone
|
|
212
|
+
execSync(`cp -R . "${tempRepoPath}"`, { stdio: 'pipe' });
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
execSync(`git clone "${remoteUrl}" "${tempRepoPath}"`, { stdio: 'pipe' });
|
|
216
|
+
}
|
|
217
|
+
spinner.succeed('Repository cloned to temporary location');
|
|
218
|
+
}
|
|
219
|
+
async collectConfiguration(provided) {
|
|
220
|
+
const config = { ...provided };
|
|
221
|
+
if (!config.gitlabToken) {
|
|
222
|
+
// Try to get from environment variable first
|
|
223
|
+
config.gitlabToken = process.env.GITLAB_TOKEN || process.env.GL_TOKEN;
|
|
224
|
+
if (!config.gitlabToken) {
|
|
225
|
+
this.error('GitLab token is required. Provide via --gitlab-token flag or GITLAB_TOKEN environment variable.');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!config.projectPath) {
|
|
229
|
+
this.error('GitLab project path is required. Provide via --project-path flag (e.g., my-group/my-project).');
|
|
230
|
+
}
|
|
231
|
+
return config;
|
|
232
|
+
}
|
|
233
|
+
async createGitLabProject(config, spinner) {
|
|
234
|
+
const pathParts = config.projectPath.split('/');
|
|
235
|
+
const projectName = pathParts.at(-1);
|
|
236
|
+
const groupPath = pathParts.slice(0, -1);
|
|
237
|
+
if (groupPath.length === 0) {
|
|
238
|
+
throw new Error('Project path must include at least one group (e.g., my-group/my-project)');
|
|
239
|
+
}
|
|
240
|
+
// Ensure all nested groups exist
|
|
241
|
+
const finalGroupId = await this.ensureGroupPath(config, groupPath, spinner);
|
|
242
|
+
// Create the project
|
|
243
|
+
spinner.start(`Creating GitLab project: ${projectName}`);
|
|
244
|
+
const createResponse = await fetch(`${config.gitlabUrl}/api/v4/projects`, {
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
initialize_with_readme: false,
|
|
247
|
+
name: projectName,
|
|
248
|
+
namespace_id: finalGroupId,
|
|
249
|
+
path: projectName,
|
|
250
|
+
visibility: 'private',
|
|
251
|
+
}),
|
|
252
|
+
headers: {
|
|
253
|
+
'Content-Type': 'application/json',
|
|
254
|
+
'PRIVATE-TOKEN': config.gitlabToken,
|
|
255
|
+
},
|
|
256
|
+
method: 'POST',
|
|
257
|
+
});
|
|
258
|
+
if (!createResponse.ok) {
|
|
259
|
+
const errorText = await createResponse.text();
|
|
260
|
+
let errorData;
|
|
261
|
+
try {
|
|
262
|
+
errorData = JSON.parse(errorText);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
throw new Error(`Failed to create GitLab project: ${errorText}`);
|
|
266
|
+
}
|
|
267
|
+
if (errorData.message && typeof errorData.message === 'object' && errorData.message.path?.includes('already been taken')) {
|
|
268
|
+
// Project was created concurrently, try to fetch it
|
|
269
|
+
const retryResponse = await fetch(`${config.gitlabUrl}/api/v4/projects/${encodeURIComponent(config.projectPath)}`, {
|
|
270
|
+
headers: {
|
|
271
|
+
'PRIVATE-TOKEN': config.gitlabToken,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
if (retryResponse.ok) {
|
|
275
|
+
const existingProject = await retryResponse.json();
|
|
276
|
+
spinner.succeed(`Using GitLab project (created concurrently): ${existingProject.web_url}`);
|
|
277
|
+
return existingProject;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`Failed to create GitLab project: ${typeof errorData.message === 'string' ? errorData.message : errorText}`);
|
|
281
|
+
}
|
|
282
|
+
const project = await createResponse.json();
|
|
283
|
+
spinner.succeed(`GitLab project created: ${project.web_url}`);
|
|
284
|
+
return project;
|
|
285
|
+
}
|
|
286
|
+
async ensureGroupPath(config, groupPath, spinner) {
|
|
287
|
+
let currentParentId = null;
|
|
288
|
+
let currentPath = '';
|
|
289
|
+
for (const groupName of groupPath) {
|
|
290
|
+
currentPath = currentPath ? `${currentPath}/${groupName}` : groupName;
|
|
291
|
+
spinner.start(`Checking group: ${currentPath}`);
|
|
292
|
+
// Try to find existing group
|
|
293
|
+
const groupResponse = await fetch(`${config.gitlabUrl}/api/v4/groups/${encodeURIComponent(currentPath)}`, {
|
|
294
|
+
headers: {
|
|
295
|
+
'PRIVATE-TOKEN': config.gitlabToken,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
if (groupResponse.ok) {
|
|
299
|
+
// Group exists
|
|
300
|
+
const group = await groupResponse.json();
|
|
301
|
+
currentParentId = group.id;
|
|
302
|
+
spinner.succeed(`Found existing group: ${currentPath}`);
|
|
303
|
+
}
|
|
304
|
+
else if (groupResponse.status === 404) {
|
|
305
|
+
// Group doesn't exist, create it
|
|
306
|
+
spinner.start(`Creating group: ${groupName}`);
|
|
307
|
+
const createGroupBody = {
|
|
308
|
+
name: groupName,
|
|
309
|
+
path: groupName,
|
|
310
|
+
visibility: 'private',
|
|
311
|
+
};
|
|
312
|
+
if (currentParentId !== null) {
|
|
313
|
+
createGroupBody.parent_id = currentParentId;
|
|
314
|
+
}
|
|
315
|
+
const createResponse = await fetch(`${config.gitlabUrl}/api/v4/groups`, {
|
|
316
|
+
body: JSON.stringify(createGroupBody),
|
|
317
|
+
headers: {
|
|
318
|
+
'Content-Type': 'application/json',
|
|
319
|
+
'PRIVATE-TOKEN': config.gitlabToken,
|
|
320
|
+
},
|
|
321
|
+
method: 'POST',
|
|
322
|
+
});
|
|
323
|
+
if (!createResponse.ok) {
|
|
324
|
+
const errorText = await createResponse.text();
|
|
325
|
+
let errorData;
|
|
326
|
+
try {
|
|
327
|
+
errorData = JSON.parse(errorText);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
throw new Error(`Failed to create group '${groupName}': ${errorText}`);
|
|
331
|
+
}
|
|
332
|
+
if (errorData.message && typeof errorData.message === 'object' && errorData.message.path?.includes('already been taken')) {
|
|
333
|
+
// Group was created by another process, try to fetch it
|
|
334
|
+
const retryResponse = await fetch(`${config.gitlabUrl}/api/v4/groups/${encodeURIComponent(currentPath)}`, {
|
|
335
|
+
headers: {
|
|
336
|
+
'PRIVATE-TOKEN': config.gitlabToken,
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
if (retryResponse.ok) {
|
|
340
|
+
const group = await retryResponse.json();
|
|
341
|
+
currentParentId = group.id;
|
|
342
|
+
spinner.succeed(`Found group (created concurrently): ${currentPath}`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
throw new Error(`Failed to create group '${groupName}': ${typeof errorData.message === 'string' ? errorData.message : errorText}`);
|
|
347
|
+
}
|
|
348
|
+
const newGroup = await createResponse.json();
|
|
349
|
+
currentParentId = newGroup.id;
|
|
350
|
+
spinner.succeed(`Created group: ${currentPath}`);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// Other error
|
|
354
|
+
const errorText = await groupResponse.text();
|
|
355
|
+
throw new Error(`Failed to access group '${currentPath}': ${errorText}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (currentParentId === null) {
|
|
359
|
+
throw new Error('Failed to resolve final group ID');
|
|
360
|
+
}
|
|
361
|
+
return currentParentId;
|
|
362
|
+
}
|
|
363
|
+
async filterAllBranches(tempDir, folderPath, targetPath, branches, spinner) {
|
|
364
|
+
const repoPath = path.join(tempDir, path.basename(process.cwd()));
|
|
365
|
+
// Ensure we have the correct relative path for git filtering
|
|
366
|
+
let relativeFolderPath;
|
|
367
|
+
if (path.isAbsolute(folderPath)) {
|
|
368
|
+
relativeFolderPath = path.relative(process.cwd(), folderPath);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
relativeFolderPath = folderPath;
|
|
372
|
+
}
|
|
373
|
+
// Normalize path separators for git commands
|
|
374
|
+
relativeFolderPath = relativeFolderPath.replaceAll('\\', '/');
|
|
375
|
+
this.log(chalk.dim(`Filtering path: ${relativeFolderPath} -> ${targetPath}`));
|
|
376
|
+
// Use git filter-repo (modern replacement for filter-branch)
|
|
377
|
+
// filter-repo processes all branches at once, which is more efficient
|
|
378
|
+
spinner.start('Filtering all branches...');
|
|
379
|
+
try {
|
|
380
|
+
if (targetPath === '.') {
|
|
381
|
+
// Extract to root - use subdirectory filter approach
|
|
382
|
+
execSync(`git filter-repo --subdirectory-filter "${relativeFolderPath}" --force`, {
|
|
383
|
+
cwd: repoPath,
|
|
384
|
+
stdio: 'pipe',
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// Extract and rename path
|
|
389
|
+
execSync(`git filter-repo --path "${relativeFolderPath}" --path-rename "${relativeFolderPath}:${targetPath}" --force`, {
|
|
390
|
+
cwd: repoPath,
|
|
391
|
+
stdio: 'pipe',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
spinner.succeed('All branches filtered using git filter-repo');
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Fallback to filter-branch if filter-repo is not available
|
|
398
|
+
spinner.warn('git filter-repo not available, using git filter-branch (slower)');
|
|
399
|
+
for (const branch of branches) {
|
|
400
|
+
spinner.start(`Processing branch: ${branch}`);
|
|
401
|
+
try {
|
|
402
|
+
// Checkout branch
|
|
403
|
+
execSync(`git checkout -B ${branch} origin/${branch}`, {
|
|
404
|
+
cwd: repoPath,
|
|
405
|
+
stdio: 'pipe',
|
|
406
|
+
});
|
|
407
|
+
if (targetPath === '.') {
|
|
408
|
+
// Extract folder contents to root
|
|
409
|
+
execSync(`git filter-branch --prune-empty --subdirectory-filter "${relativeFolderPath}" --force`, {
|
|
410
|
+
cwd: repoPath,
|
|
411
|
+
stdio: 'pipe',
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
// Extract and move to target path
|
|
416
|
+
execSync(`git filter-branch --prune-empty --tree-filter 'if [ -d "${relativeFolderPath}" ]; then mkdir -p "${targetPath}"; mv "${relativeFolderPath}"/* "${targetPath}/" 2>/dev/null || true; rm -rf "${relativeFolderPath}"; fi' --force`, {
|
|
417
|
+
cwd: repoPath,
|
|
418
|
+
stdio: 'pipe',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
spinner.succeed(`Processed branch: ${branch}`);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
spinner.warn(`Skipped branch ${branch}: ${error instanceof Error ? error.message : String(error)}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async getAllBranches(tempDir, spinner) {
|
|
430
|
+
spinner.start('Discovering all branches...');
|
|
431
|
+
const repoPath = path.join(tempDir, path.basename(process.cwd()));
|
|
432
|
+
// Get all remote branches
|
|
433
|
+
const remoteBranches = execSync('git branch -r', {
|
|
434
|
+
cwd: repoPath,
|
|
435
|
+
encoding: 'utf8',
|
|
436
|
+
}).split('\n')
|
|
437
|
+
.map(branch => branch.trim())
|
|
438
|
+
.filter(branch => branch && !branch.includes('HEAD'))
|
|
439
|
+
.map(branch => branch.replace('origin/', ''));
|
|
440
|
+
// Get local branches
|
|
441
|
+
const localBranches = execSync('git branch', {
|
|
442
|
+
cwd: repoPath,
|
|
443
|
+
encoding: 'utf8',
|
|
444
|
+
}).split('\n')
|
|
445
|
+
.map(branch => branch.trim().replace('* ', ''))
|
|
446
|
+
.filter(branch => branch);
|
|
447
|
+
// Combine and deduplicate
|
|
448
|
+
const allBranches = [...new Set([...localBranches, ...remoteBranches])];
|
|
449
|
+
// Ensure master/main is processed first
|
|
450
|
+
const priorityBranches = ['master', 'main'];
|
|
451
|
+
const sortedBranches = [];
|
|
452
|
+
for (const priority of priorityBranches) {
|
|
453
|
+
if (allBranches.includes(priority)) {
|
|
454
|
+
sortedBranches.push(priority);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Add remaining branches
|
|
458
|
+
for (const branch of allBranches) {
|
|
459
|
+
if (!priorityBranches.includes(branch)) {
|
|
460
|
+
sortedBranches.push(branch);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
spinner.succeed(`Found ${sortedBranches.length} branches to process (master/main first)`);
|
|
464
|
+
return sortedBranches;
|
|
465
|
+
}
|
|
466
|
+
async pushToNewRepository(tempDir, repositoryUrl, branches, spinner) {
|
|
467
|
+
const repoPath = path.join(tempDir, path.basename(process.cwd()));
|
|
468
|
+
spinner.start('Adding new remote...');
|
|
469
|
+
execSync(`git remote add new-origin "${repositoryUrl}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
470
|
+
spinner.succeed('Remote added');
|
|
471
|
+
for (const branch of branches) {
|
|
472
|
+
spinner.start(`Pushing branch: ${branch}`);
|
|
473
|
+
try {
|
|
474
|
+
execSync(`git checkout ${branch}`, { cwd: repoPath, stdio: 'pipe' });
|
|
475
|
+
execSync(`git push new-origin ${branch}`, { cwd: repoPath, stdio: 'pipe' });
|
|
476
|
+
spinner.succeed(`Pushed branch: ${branch}`);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
spinner.warn(`Failed to push branch ${branch}: ${error instanceof Error ? error.message : String(error)}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BaseCommand } from '../base-command.js';
|
|
2
|
+
export default class Focus extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
entityName: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
entityTypeOrName: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static examples: string[];
|
|
9
|
+
static flags: {
|
|
10
|
+
add: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
clear: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
duration: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
mode: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
remember: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
};
|
|
16
|
+
static strict: boolean;
|
|
17
|
+
run(): Promise<void>;
|
|
18
|
+
private isEntityType;
|
|
19
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../base-command.js';
|
|
3
|
+
import { ENTITY_TYPES } from '../models/entity.model.js';
|
|
4
|
+
export default class Focus extends BaseCommand {
|
|
5
|
+
static args = {
|
|
6
|
+
entityName: Args.string({
|
|
7
|
+
description: 'Entity name (when first arg is entity type)',
|
|
8
|
+
name: 'entityName',
|
|
9
|
+
required: false,
|
|
10
|
+
}),
|
|
11
|
+
entityTypeOrName: Args.string({
|
|
12
|
+
description: 'Entity type (client/prospect/company/initiative) or entity name',
|
|
13
|
+
name: 'entityTypeOrName',
|
|
14
|
+
required: false,
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
static description = 'Set focus on one or more entities with optional type and mode';
|
|
18
|
+
static examples = [
|
|
19
|
+
'<%= config.bin %> <%= command.id %> my-app',
|
|
20
|
+
'<%= config.bin %> <%= command.id %> client mindtools',
|
|
21
|
+
'<%= config.bin %> <%= command.id %> client mindtools --mode delivery',
|
|
22
|
+
'<%= config.bin %> <%= command.id %> prospect jazida --mode research',
|
|
23
|
+
'<%= config.bin %> <%= command.id %> my-app auth-service',
|
|
24
|
+
'<%= config.bin %> <%= command.id %> --clear',
|
|
25
|
+
];
|
|
26
|
+
static flags = {
|
|
27
|
+
add: Flags.boolean({
|
|
28
|
+
char: 'a',
|
|
29
|
+
description: 'add to current focus instead of replacing',
|
|
30
|
+
}),
|
|
31
|
+
clear: Flags.boolean({
|
|
32
|
+
char: 'c',
|
|
33
|
+
description: 'clear current focus',
|
|
34
|
+
exclusive: ['add', 'mode'],
|
|
35
|
+
}),
|
|
36
|
+
duration: Flags.string({
|
|
37
|
+
char: 'd',
|
|
38
|
+
description: 'focus duration (e.g., 2h, 90m)',
|
|
39
|
+
}),
|
|
40
|
+
mode: Flags.string({
|
|
41
|
+
char: 'm',
|
|
42
|
+
description: 'focus mode (delivery, strategy, audit, debug, research, proposal)',
|
|
43
|
+
options: ['delivery', 'strategy', 'audit', 'debug', 'research', 'proposal'],
|
|
44
|
+
}),
|
|
45
|
+
remember: Flags.boolean({
|
|
46
|
+
char: 'r',
|
|
47
|
+
description: 'remember current focus before switching',
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
static strict = false; // Allow multiple arguments
|
|
51
|
+
async run() {
|
|
52
|
+
const { argv, flags } = await this.parse(Focus);
|
|
53
|
+
// Clear focus
|
|
54
|
+
if (flags.clear) {
|
|
55
|
+
await this.focusService.clearFocus();
|
|
56
|
+
this.log('ā Focus cleared');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Show current focus if no arguments
|
|
60
|
+
if (argv.length === 0) {
|
|
61
|
+
const focus = await this.focusService.getCurrentFocus();
|
|
62
|
+
if (!focus) {
|
|
63
|
+
this.log('No current focus');
|
|
64
|
+
this.log('\nSet focus with: gut focus <entity-name>');
|
|
65
|
+
this.log('Or: gut focus <entity-type> <entity-name>');
|
|
66
|
+
this.log('\nExamples:');
|
|
67
|
+
this.log(' gut focus client mindtools --mode delivery');
|
|
68
|
+
this.log(' gut focus prospect jazida --mode research');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const entities = await this.focusService.getFocusedEntities();
|
|
72
|
+
this.log(`\nšÆ Current focus: ${await this.focusService.getFocusDescription()}`);
|
|
73
|
+
if (focus.mode) {
|
|
74
|
+
this.log(`š Mode: ${focus.mode}`);
|
|
75
|
+
}
|
|
76
|
+
if (entities.length > 0) {
|
|
77
|
+
this.log('\nFocused entities:');
|
|
78
|
+
for (const entity of entities) {
|
|
79
|
+
this.log(` ${this.getTypeEmoji(entity.type)} ${entity.name} (${entity.type}) - ${entity.path}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
// Parse arguments - support both "entity-name" and "entity-type entity-name" formats
|
|
86
|
+
let entityNames = [];
|
|
87
|
+
let entityType;
|
|
88
|
+
if (argv.length >= 2 && this.isEntityType(String(argv[0]))) {
|
|
89
|
+
// Format: gut focus client mindtools
|
|
90
|
+
entityType = argv[0];
|
|
91
|
+
entityNames = argv.slice(1).map(String);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Format: gut focus mindtools (backward compatibility)
|
|
95
|
+
entityNames = argv.map(String);
|
|
96
|
+
}
|
|
97
|
+
if (flags.remember) {
|
|
98
|
+
// Save current focus to stack before switching
|
|
99
|
+
await this.focusService.pushFocusToStack();
|
|
100
|
+
}
|
|
101
|
+
if (flags.add) {
|
|
102
|
+
// Add to existing focus
|
|
103
|
+
await this.focusService.addToFocus(entityNames, {
|
|
104
|
+
duration: flags.duration,
|
|
105
|
+
entityType,
|
|
106
|
+
mode: flags.mode,
|
|
107
|
+
});
|
|
108
|
+
this.log(`ā Added to focus: ${entityNames.join(', ')}`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Set new focus
|
|
112
|
+
await this.focusService.setFocus(entityNames, {
|
|
113
|
+
duration: flags.duration,
|
|
114
|
+
entityType,
|
|
115
|
+
mode: flags.mode,
|
|
116
|
+
});
|
|
117
|
+
const modeText = flags.mode ? ` (${flags.mode} mode)` : '';
|
|
118
|
+
this.log(`ā Focus set to: ${entityNames.join(', ')}${modeText}`);
|
|
119
|
+
}
|
|
120
|
+
// Show what's now focused
|
|
121
|
+
const entities = await this.focusService.getFocusedEntities();
|
|
122
|
+
if (entities.length > 1) {
|
|
123
|
+
this.log(`\nNow focused on ${entities.length} entities:`);
|
|
124
|
+
for (const entity of entities) {
|
|
125
|
+
this.log(` ${this.getTypeEmoji(entity.type)} ${entity.name} (${entity.type})`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
131
|
+
this.error(message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
isEntityType(value) {
|
|
135
|
+
return ENTITY_TYPES.includes(value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BaseCommand } from '../base-command.js';
|
|
2
|
+
export default class Graph extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
depth: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'focus-only': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
private buildRelationshipGraph;
|
|
12
|
+
private displayAsciiGraph;
|
|
13
|
+
private displayDotGraph;
|
|
14
|
+
private getEdgeColor;
|
|
15
|
+
private getNodeColor;
|
|
16
|
+
private getRelationshipIcon;
|
|
17
|
+
private inferRelationships;
|
|
18
|
+
}
|