@hyperdrive.bot/gut 0.1.10 → 0.1.12
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 +130 -5
- package/dist/base-command.d.ts +2 -0
- package/dist/base-command.js +3 -0
- package/dist/commands/auth/login.d.ts +10 -0
- package/dist/commands/auth/login.js +103 -0
- package/dist/commands/auth/logout.d.ts +6 -0
- package/dist/commands/auth/logout.js +39 -0
- package/dist/commands/auth/status.d.ts +9 -0
- package/dist/commands/auth/status.js +87 -0
- package/dist/commands/ticket/config.d.ts +13 -0
- package/dist/commands/ticket/config.js +22 -0
- package/dist/commands/ticket/sync.d.ts +1 -0
- package/dist/commands/ticket/sync.js +62 -8
- package/dist/commands/worktree/create.d.ts +15 -0
- package/dist/commands/worktree/create.js +138 -0
- package/dist/models/entity.model.d.ts +16 -0
- package/dist/models/ticket.model.d.ts +2 -0
- package/dist/services/git.service.d.ts +11 -0
- package/dist/services/git.service.js +57 -0
- package/dist/services/git.service.test.d.ts +1 -0
- package/dist/services/git.service.test.js +101 -0
- package/dist/services/gut-api.service.d.ts +20 -1
- package/dist/services/gut-api.service.js +30 -2
- package/dist/services/tenant.service.d.ts +14 -0
- package/dist/services/tenant.service.js +24 -0
- package/dist/services/ticket.service.d.ts +3 -1
- package/dist/services/ticket.service.js +7 -3
- package/dist/services/worktree.service.d.ts +16 -0
- package/dist/services/worktree.service.js +60 -0
- package/oclif.manifest.json +229 -4
- package/package.json +16 -5
|
@@ -6,16 +6,24 @@ import { TicketService } from '../../services/ticket.service.js';
|
|
|
6
6
|
export default class TicketSync extends BaseCommand {
|
|
7
7
|
static args = {
|
|
8
8
|
ticketId: Args.string({
|
|
9
|
-
description: 'ticket ID to sync',
|
|
9
|
+
description: 'ticket ID to sync (e.g., PROJ-1234)',
|
|
10
10
|
name: 'ticketId',
|
|
11
11
|
required: true
|
|
12
12
|
})
|
|
13
13
|
};
|
|
14
|
-
static description =
|
|
14
|
+
static description = `Sync ticket state with external source (JIRA, GitHub, etc.)
|
|
15
|
+
|
|
16
|
+
For pull direction:
|
|
17
|
+
- If ticket exists in gut: updates from source
|
|
18
|
+
- If ticket doesn't exist: creates it and queues enrichment
|
|
19
|
+
|
|
20
|
+
For push direction:
|
|
21
|
+
- Updates source system with gut ticket state`;
|
|
15
22
|
static examples = [
|
|
16
23
|
'<%= config.bin %> <%= command.id %> PROJ-1234',
|
|
17
24
|
'<%= config.bin %> <%= command.id %> PROJ-1234 --direction push',
|
|
18
|
-
'<%= config.bin %> <%= command.id %> PROJ-1234 --direction pull'
|
|
25
|
+
'<%= config.bin %> <%= command.id %> PROJ-1234 --direction pull',
|
|
26
|
+
'<%= config.bin %> <%= command.id %> PROJ-1234 --direction pull --no-enrich'
|
|
19
27
|
];
|
|
20
28
|
static flags = {
|
|
21
29
|
direction: Flags.string({
|
|
@@ -27,6 +35,10 @@ export default class TicketSync extends BaseCommand {
|
|
|
27
35
|
json: Flags.boolean({
|
|
28
36
|
char: 'j',
|
|
29
37
|
description: 'output as JSON'
|
|
38
|
+
}),
|
|
39
|
+
'no-enrich': Flags.boolean({
|
|
40
|
+
default: false,
|
|
41
|
+
description: 'skip enrichment queue when pulling new tickets'
|
|
30
42
|
})
|
|
31
43
|
};
|
|
32
44
|
get requiresInit() {
|
|
@@ -39,34 +51,60 @@ export default class TicketSync extends BaseCommand {
|
|
|
39
51
|
this.error('API not configured. Set GUT_API_ENDPOINT and GUT_TENANT_ID environment variables.');
|
|
40
52
|
}
|
|
41
53
|
const direction = flags.direction;
|
|
54
|
+
const noEnrich = flags['no-enrich'];
|
|
42
55
|
const directionEmoji = direction === 'push' ? '⬆️' : '⬇️';
|
|
43
56
|
const directionText = direction === 'push'
|
|
44
57
|
? 'gut → source'
|
|
45
58
|
: 'source → gut';
|
|
46
59
|
const spinner = ora(`${directionEmoji} Syncing ticket (${directionText})...`).start();
|
|
47
60
|
try {
|
|
48
|
-
const response = await ticketService.syncTicket(args.ticketId, direction);
|
|
61
|
+
const response = await ticketService.syncTicket(args.ticketId, direction, { noEnrich });
|
|
49
62
|
if (flags.json) {
|
|
50
63
|
spinner.stop();
|
|
51
64
|
this.log(JSON.stringify(response, null, 2));
|
|
52
65
|
return;
|
|
53
66
|
}
|
|
54
|
-
|
|
67
|
+
// Different success messages based on what happened
|
|
68
|
+
if (response.created) {
|
|
69
|
+
spinner.succeed(chalk.green('Created new ticket from source'));
|
|
70
|
+
}
|
|
71
|
+
else if (response.synced) {
|
|
55
72
|
spinner.succeed('Sync completed');
|
|
56
73
|
}
|
|
57
74
|
else {
|
|
58
75
|
spinner.warn('Sync completed with warnings');
|
|
59
76
|
}
|
|
60
77
|
this.log('');
|
|
61
|
-
|
|
78
|
+
// Show ticket info with create/update indicator
|
|
79
|
+
const statusIcon = response.created ? chalk.green('🆕') : chalk.blue('🔄');
|
|
80
|
+
this.log(chalk.bold(`${statusIcon} Ticket: ${response.ticketId}`));
|
|
62
81
|
this.log(` 🔗 Source: ${response.source.type}`);
|
|
63
82
|
this.log(` 🌐 URL: ${response.source.externalUrl}`);
|
|
83
|
+
// Show enrichment status for pull operations
|
|
84
|
+
if (direction === 'pull') {
|
|
85
|
+
if (response.enrichmentQueued) {
|
|
86
|
+
this.log(` 🔍 Enrichment: ${chalk.cyan('Queued')}`);
|
|
87
|
+
}
|
|
88
|
+
else if (response.created && noEnrich) {
|
|
89
|
+
this.log(` 🔍 Enrichment: ${chalk.yellow('Skipped (--no-enrich)')}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
64
92
|
if (response.actions.length > 0) {
|
|
65
93
|
this.log('');
|
|
66
94
|
this.log(chalk.bold('📝 Actions performed:'));
|
|
67
95
|
for (const action of response.actions) {
|
|
68
96
|
const isError = action.toLowerCase().includes('error');
|
|
69
|
-
const
|
|
97
|
+
const isWarning = action.includes('⚠️');
|
|
98
|
+
let icon;
|
|
99
|
+
if (isError) {
|
|
100
|
+
icon = chalk.red('✗');
|
|
101
|
+
}
|
|
102
|
+
else if (isWarning) {
|
|
103
|
+
icon = chalk.yellow('!');
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
icon = chalk.green('✓');
|
|
107
|
+
}
|
|
70
108
|
this.log(` ${icon} ${action}`);
|
|
71
109
|
}
|
|
72
110
|
}
|
|
@@ -75,11 +113,27 @@ export default class TicketSync extends BaseCommand {
|
|
|
75
113
|
this.log(chalk.dim(' No actions performed'));
|
|
76
114
|
}
|
|
77
115
|
this.log('');
|
|
116
|
+
// Show next steps for new tickets
|
|
117
|
+
if (response.created && response.enrichmentQueued) {
|
|
118
|
+
this.log(chalk.dim('💡 Next: Run ') + chalk.cyan(`gut ticket status ${response.ticketId}`) + chalk.dim(' to check enrichment progress'));
|
|
119
|
+
}
|
|
120
|
+
else if (response.created && !response.enrichmentQueued) {
|
|
121
|
+
this.log(chalk.dim('💡 Next: Run ') + chalk.cyan(`gut ticket sync ${response.ticketId} --pull`) + chalk.dim(' to trigger enrichment'));
|
|
122
|
+
}
|
|
78
123
|
}
|
|
79
124
|
catch (error) {
|
|
80
125
|
spinner.fail('Sync failed');
|
|
81
126
|
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
-
|
|
127
|
+
// Provide helpful error messages
|
|
128
|
+
if (message.includes('404') && direction === 'push') {
|
|
129
|
+
this.error(`Ticket not found in gut. Use ${chalk.cyan('--direction pull')} to create from source.`);
|
|
130
|
+
}
|
|
131
|
+
else if (message.includes('404') && direction === 'pull') {
|
|
132
|
+
this.error(`Ticket not found in source system. Check the ticket ID: ${args.ticketId}`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
this.error(`Failed to sync ticket: ${message}`);
|
|
136
|
+
}
|
|
83
137
|
}
|
|
84
138
|
}
|
|
85
139
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base-command.js';
|
|
2
|
+
export default class WorktreeCreate extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
'base-dir': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
from: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
install: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
};
|
|
13
|
+
run(): Promise<void>;
|
|
14
|
+
private runPnpmInstall;
|
|
15
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { BaseCommand } from '../../base-command.js';
|
|
7
|
+
export default class WorktreeCreate extends BaseCommand {
|
|
8
|
+
static args = {
|
|
9
|
+
name: Args.string({ description: 'Branch name for the worktree', required: true }),
|
|
10
|
+
};
|
|
11
|
+
static description = 'Create mirrored worktrees for super-repo and focused entities';
|
|
12
|
+
static examples = [
|
|
13
|
+
'<%= config.bin %> worktree create workflow/deploy-batch',
|
|
14
|
+
'<%= config.bin %> worktree create workflow/deploy-batch --from master --install',
|
|
15
|
+
'<%= config.bin %> worktree create feature/story-42 --base-dir /home/user/worktrees',
|
|
16
|
+
];
|
|
17
|
+
static flags = {
|
|
18
|
+
'base-dir': Flags.string({ default: '/tmp/gut-worktrees', description: 'Root directory for worktrees' }),
|
|
19
|
+
from: Flags.string({ description: 'Base branch to create from (defaults to current branch)' }),
|
|
20
|
+
install: Flags.boolean({ default: false, description: 'Run pnpm install after creation' }),
|
|
21
|
+
};
|
|
22
|
+
async run() {
|
|
23
|
+
const { args, flags } = await this.parse(WorktreeCreate);
|
|
24
|
+
const workspaceRoot = this.configService.getWorkspaceRoot();
|
|
25
|
+
const slug = args.name.replace(/\//g, '-');
|
|
26
|
+
const wtPath = path.join(flags['base-dir'], slug);
|
|
27
|
+
const baseBranch = flags.from ?? await this.gitService.getCurrentBranch(workspaceRoot);
|
|
28
|
+
// --- Validation (AC: 7) ---
|
|
29
|
+
const existing = this.worktreeService.get(args.name);
|
|
30
|
+
if (existing) {
|
|
31
|
+
this.error(`Worktree "${args.name}" already exists at ${existing.path}. Remove it first with: gut worktree remove ${args.name}`);
|
|
32
|
+
}
|
|
33
|
+
const existingWorktrees = await this.gitService.worktreeList(workspaceRoot);
|
|
34
|
+
const branchCollision = existingWorktrees.find(w => w.branch === args.name);
|
|
35
|
+
if (branchCollision) {
|
|
36
|
+
this.error(`Branch "${args.name}" already has a worktree at ${branchCollision.path}. Remove it first or use a different name.`);
|
|
37
|
+
}
|
|
38
|
+
// --- Validation (AC: 8) ---
|
|
39
|
+
const focusedEntities = await this.focusService.getFocusedEntities();
|
|
40
|
+
if (focusedEntities.length === 0) {
|
|
41
|
+
this.error('No entities focused. Use "gut focus <entity>" first.');
|
|
42
|
+
}
|
|
43
|
+
// --- Create worktrees with atomic rollback (AC: 1, 2, 9) ---
|
|
44
|
+
const createdWorktrees = [];
|
|
45
|
+
const entityRecords = [];
|
|
46
|
+
try {
|
|
47
|
+
// Super-repo worktree (AC: 1)
|
|
48
|
+
await this.gitService.worktreeAdd(workspaceRoot, wtPath, args.name, baseBranch);
|
|
49
|
+
createdWorktrees.push({ repoPath: workspaceRoot, wtPath });
|
|
50
|
+
this.log(chalk.green('✓ Super-repo worktree created at ' + wtPath));
|
|
51
|
+
// Per-entity worktrees (AC: 2)
|
|
52
|
+
for (const entity of focusedEntities) {
|
|
53
|
+
const entityRelativePath = entity.path.replace(/^\.\//, '');
|
|
54
|
+
const entityWtPath = path.join(wtPath, entityRelativePath);
|
|
55
|
+
const entityMainPath = path.join(workspaceRoot, entityRelativePath);
|
|
56
|
+
// Always use the entity's current branch as base — the super-repo branch
|
|
57
|
+
// may exist but be stale (e.g., api has master but works on develop)
|
|
58
|
+
const entityBaseBranch = await this.gitService.getCurrentBranch(entityMainPath);
|
|
59
|
+
if (entityBaseBranch !== baseBranch) {
|
|
60
|
+
this.log(chalk.yellow(`⚠ Entity "${entity.name}" is on "${entityBaseBranch}" (super-repo: "${baseBranch}")`));
|
|
61
|
+
}
|
|
62
|
+
// Remove submodule stub
|
|
63
|
+
fs.rmSync(entityWtPath, { force: true, recursive: true });
|
|
64
|
+
// Create entity worktree
|
|
65
|
+
await this.gitService.worktreeAdd(entityMainPath, entityWtPath, args.name, entityBaseBranch);
|
|
66
|
+
createdWorktrees.push({ repoPath: entityMainPath, wtPath: entityWtPath });
|
|
67
|
+
this.log(chalk.green(`✓ Entity worktree: ${entity.name} → ${args.name}`));
|
|
68
|
+
entityRecords.push({
|
|
69
|
+
branch: args.name,
|
|
70
|
+
entityName: entity.name,
|
|
71
|
+
entityPath: entityRelativePath,
|
|
72
|
+
worktreePath: entityWtPath,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Atomic rollback (AC: 9)
|
|
78
|
+
for (const entry of [...createdWorktrees].reverse()) {
|
|
79
|
+
await this.gitService.worktreeRemove(entry.repoPath, entry.wtPath, true).catch(() => { });
|
|
80
|
+
}
|
|
81
|
+
await this.gitService.worktreePrune(workspaceRoot).catch(() => { });
|
|
82
|
+
for (const entity of focusedEntities) {
|
|
83
|
+
const entityRelativePath = entity.path.replace(/^\.\//, '');
|
|
84
|
+
const entityMainPath = path.join(workspaceRoot, entityRelativePath);
|
|
85
|
+
await this.gitService.worktreePrune(entityMainPath).catch(() => { });
|
|
86
|
+
}
|
|
87
|
+
this.log(chalk.red('✗ Worktree creation failed. All partial worktrees cleaned up.'));
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
this.error(message);
|
|
90
|
+
}
|
|
91
|
+
// --- pnpm install (AC: 4) ---
|
|
92
|
+
if (flags.install) {
|
|
93
|
+
const startTime = Date.now();
|
|
94
|
+
try {
|
|
95
|
+
await this.runPnpmInstall(wtPath);
|
|
96
|
+
const seconds = Math.round((Date.now() - startTime) / 1000);
|
|
97
|
+
this.log(chalk.green(`✓ Dependencies installed (pnpm install: ${seconds}s)`));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
this.log(chalk.yellow('⚠ pnpm install failed — worktree is still usable without dependencies'));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// --- Save state (AC: 10) ---
|
|
104
|
+
const record = {
|
|
105
|
+
baseBranch,
|
|
106
|
+
createdAt: Date.now(),
|
|
107
|
+
entities: entityRecords,
|
|
108
|
+
name: args.name,
|
|
109
|
+
path: wtPath,
|
|
110
|
+
};
|
|
111
|
+
const state = this.worktreeService.loadState();
|
|
112
|
+
state.worktrees.push(record);
|
|
113
|
+
this.worktreeService.saveState(state);
|
|
114
|
+
// --- Summary output (AC: 11) ---
|
|
115
|
+
this.log('');
|
|
116
|
+
this.log('Worktree ready:');
|
|
117
|
+
this.log(` Path: ${wtPath}`);
|
|
118
|
+
this.log(` Branch: ${args.name}`);
|
|
119
|
+
this.log(` Entities: ${entityRecords.map(e => e.entityName).join(', ')}`);
|
|
120
|
+
this.log('');
|
|
121
|
+
this.log('Use with bmad-workflow:');
|
|
122
|
+
this.log(` bmad-workflow workflow <prd> --cwd ${wtPath}`);
|
|
123
|
+
}
|
|
124
|
+
runPnpmInstall(cwd) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const child = spawn('pnpm', ['install'], { cwd, stdio: 'inherit' });
|
|
127
|
+
child.on('close', (code) => {
|
|
128
|
+
if (code === 0) {
|
|
129
|
+
resolve();
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
reject(new Error(`pnpm install exited with code ${code}`));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
child.on('error', reject);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -232,3 +232,19 @@ export interface RepoStatus {
|
|
|
232
232
|
path: string;
|
|
233
233
|
untracked: string[];
|
|
234
234
|
}
|
|
235
|
+
export interface WorktreeEntityRecord {
|
|
236
|
+
branch: string;
|
|
237
|
+
entityName: string;
|
|
238
|
+
entityPath: string;
|
|
239
|
+
worktreePath: string;
|
|
240
|
+
}
|
|
241
|
+
export interface WorktreeRecord {
|
|
242
|
+
baseBranch: string;
|
|
243
|
+
createdAt: number;
|
|
244
|
+
entities: WorktreeEntityRecord[];
|
|
245
|
+
name: string;
|
|
246
|
+
path: string;
|
|
247
|
+
}
|
|
248
|
+
export interface WorktreeState {
|
|
249
|
+
worktrees: WorktreeRecord[];
|
|
250
|
+
}
|
|
@@ -86,11 +86,13 @@ export interface HintResponse {
|
|
|
86
86
|
export interface SyncResponse {
|
|
87
87
|
ticketId: string;
|
|
88
88
|
synced: boolean;
|
|
89
|
+
created: boolean;
|
|
89
90
|
source: {
|
|
90
91
|
type: string;
|
|
91
92
|
externalUrl: string;
|
|
92
93
|
};
|
|
93
94
|
actions: string[];
|
|
95
|
+
enrichmentQueued: boolean;
|
|
94
96
|
}
|
|
95
97
|
/**
|
|
96
98
|
* List tickets response from the API
|
|
@@ -17,6 +17,11 @@ export interface PullOptions {
|
|
|
17
17
|
rebase?: boolean;
|
|
18
18
|
strategy?: string;
|
|
19
19
|
}
|
|
20
|
+
export interface WorktreeInfo {
|
|
21
|
+
branch: string | null;
|
|
22
|
+
head: string;
|
|
23
|
+
path: string;
|
|
24
|
+
}
|
|
20
25
|
export declare class GitService {
|
|
21
26
|
add(repoPath: string, files: string[]): Promise<void>;
|
|
22
27
|
addRemote(repoPath: string, name: string, url: string): Promise<void>;
|
|
@@ -28,6 +33,7 @@ export declare class GitService {
|
|
|
28
33
|
execSync(args: string[], options?: GitOptions): string;
|
|
29
34
|
fetch(repoPath: string): Promise<void>;
|
|
30
35
|
getCurrentBranch(repoPath: string): Promise<string>;
|
|
36
|
+
branchExists(repoPath: string, branch: string): Promise<boolean>;
|
|
31
37
|
getRemoteUrl(repoPath: string, remote?: string): Promise<null | string>;
|
|
32
38
|
getStatus(repoPath: string): Promise<RepoStatus>;
|
|
33
39
|
hasChanges(repoPath: string): Promise<boolean>;
|
|
@@ -36,4 +42,9 @@ export declare class GitService {
|
|
|
36
42
|
isRepository(repoPath: string): Promise<boolean>;
|
|
37
43
|
pull(repoPath: string, options?: PullOptions): Promise<void>;
|
|
38
44
|
push(repoPath: string, options?: PushOptions): Promise<void>;
|
|
45
|
+
worktreeAdd(repoPath: string, worktreePath: string, branch: string, baseBranch?: string): Promise<void>;
|
|
46
|
+
worktreeList(repoPath: string): Promise<WorktreeInfo[]>;
|
|
47
|
+
worktreePrune(repoPath: string): Promise<void>;
|
|
48
|
+
worktreeRemove(repoPath: string, worktreePath: string, force?: boolean): Promise<void>;
|
|
49
|
+
private parseWorktreeList;
|
|
39
50
|
}
|
|
@@ -85,6 +85,15 @@ export class GitService {
|
|
|
85
85
|
const branch = await this.exec(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoPath });
|
|
86
86
|
return branch.trim();
|
|
87
87
|
}
|
|
88
|
+
async branchExists(repoPath, branch) {
|
|
89
|
+
try {
|
|
90
|
+
await this.exec(['rev-parse', '--verify', `refs/heads/${branch}`], { cwd: repoPath });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
88
97
|
async getRemoteUrl(repoPath, remote = 'origin') {
|
|
89
98
|
try {
|
|
90
99
|
const url = await this.exec(['remote', 'get-url', remote], { cwd: repoPath });
|
|
@@ -185,4 +194,52 @@ export class GitService {
|
|
|
185
194
|
}
|
|
186
195
|
await this.exec(args, { cwd: repoPath });
|
|
187
196
|
}
|
|
197
|
+
async worktreeAdd(repoPath, worktreePath, branch, baseBranch) {
|
|
198
|
+
const args = ['worktree', 'add', worktreePath, '-b', branch];
|
|
199
|
+
if (baseBranch) {
|
|
200
|
+
args.push(baseBranch);
|
|
201
|
+
}
|
|
202
|
+
await this.exec(args, { cwd: repoPath });
|
|
203
|
+
}
|
|
204
|
+
async worktreeList(repoPath) {
|
|
205
|
+
const output = await this.exec(['worktree', 'list', '--porcelain'], { cwd: repoPath });
|
|
206
|
+
return this.parseWorktreeList(output);
|
|
207
|
+
}
|
|
208
|
+
async worktreePrune(repoPath) {
|
|
209
|
+
await this.exec(['worktree', 'prune'], { cwd: repoPath });
|
|
210
|
+
}
|
|
211
|
+
async worktreeRemove(repoPath, worktreePath, force = false) {
|
|
212
|
+
const args = ['worktree', 'remove', worktreePath];
|
|
213
|
+
if (force) {
|
|
214
|
+
args.push('--force');
|
|
215
|
+
}
|
|
216
|
+
await this.exec(args, { cwd: repoPath });
|
|
217
|
+
}
|
|
218
|
+
parseWorktreeList(output) {
|
|
219
|
+
if (!output.trim()) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
const blocks = output.trim().split('\n\n');
|
|
223
|
+
return blocks.filter(block => block.trim()).map(block => {
|
|
224
|
+
const lines = block.trim().split('\n');
|
|
225
|
+
let worktreePath = '';
|
|
226
|
+
let head = '';
|
|
227
|
+
let branch = null;
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
if (line.startsWith('worktree ')) {
|
|
230
|
+
worktreePath = line.slice('worktree '.length);
|
|
231
|
+
}
|
|
232
|
+
else if (line.startsWith('HEAD ')) {
|
|
233
|
+
head = line.slice('HEAD '.length);
|
|
234
|
+
}
|
|
235
|
+
else if (line.startsWith('branch ')) {
|
|
236
|
+
branch = line.slice('branch refs/heads/'.length);
|
|
237
|
+
}
|
|
238
|
+
else if (line === 'detached') {
|
|
239
|
+
branch = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { branch, head, path: worktreePath };
|
|
243
|
+
});
|
|
244
|
+
}
|
|
188
245
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { GitService } from './git.service.js';
|
|
3
|
+
describe('GitService worktree methods', () => {
|
|
4
|
+
let gitService;
|
|
5
|
+
let execSpy;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
gitService = new GitService();
|
|
8
|
+
execSpy = vi.spyOn(gitService, 'exec').mockResolvedValue('');
|
|
9
|
+
});
|
|
10
|
+
describe('worktreeAdd', () => {
|
|
11
|
+
it('calls exec with correct args', async () => {
|
|
12
|
+
await gitService.worktreeAdd('/repo', '/tmp/wt', 'feat/x');
|
|
13
|
+
expect(execSpy).toHaveBeenCalledWith(['worktree', 'add', '/tmp/wt', '-b', 'feat/x'], { cwd: '/repo' });
|
|
14
|
+
});
|
|
15
|
+
it('includes baseBranch when provided', async () => {
|
|
16
|
+
await gitService.worktreeAdd('/repo', '/tmp/wt', 'feat/x', 'main');
|
|
17
|
+
expect(execSpy).toHaveBeenCalledWith(['worktree', 'add', '/tmp/wt', '-b', 'feat/x', 'main'], { cwd: '/repo' });
|
|
18
|
+
});
|
|
19
|
+
it('rejects when git exits non-zero', async () => {
|
|
20
|
+
execSpy.mockRejectedValue(new Error('Git command failed: fatal: branch already exists'));
|
|
21
|
+
await expect(gitService.worktreeAdd('/repo', '/tmp/wt', 'feat/x'))
|
|
22
|
+
.rejects.toThrow('Git command failed');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('worktreeList', () => {
|
|
26
|
+
it('parses multi-entry porcelain output', async () => {
|
|
27
|
+
const porcelainOutput = [
|
|
28
|
+
'worktree /path/to/main',
|
|
29
|
+
'HEAD abc123def456',
|
|
30
|
+
'branch refs/heads/main',
|
|
31
|
+
'',
|
|
32
|
+
'worktree /tmp/gut-worktrees/feat-x',
|
|
33
|
+
'HEAD def789abc012',
|
|
34
|
+
'branch refs/heads/feat/x',
|
|
35
|
+
'',
|
|
36
|
+
].join('\n');
|
|
37
|
+
execSpy.mockResolvedValue(porcelainOutput);
|
|
38
|
+
const result = await gitService.worktreeList('/repo');
|
|
39
|
+
expect(result).toEqual([
|
|
40
|
+
{ path: '/path/to/main', head: 'abc123def456', branch: 'main' },
|
|
41
|
+
{ path: '/tmp/gut-worktrees/feat-x', head: 'def789abc012', branch: 'feat/x' },
|
|
42
|
+
]);
|
|
43
|
+
expect(execSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], { cwd: '/repo' });
|
|
44
|
+
});
|
|
45
|
+
it('returns empty array for empty output', async () => {
|
|
46
|
+
execSpy.mockResolvedValue('');
|
|
47
|
+
const result = await gitService.worktreeList('/repo');
|
|
48
|
+
expect(result).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
it('handles detached HEAD entry', async () => {
|
|
51
|
+
const porcelainOutput = [
|
|
52
|
+
'worktree /path/to/main',
|
|
53
|
+
'HEAD abc123',
|
|
54
|
+
'branch refs/heads/main',
|
|
55
|
+
'',
|
|
56
|
+
'worktree /tmp/detached',
|
|
57
|
+
'HEAD 111222333',
|
|
58
|
+
'detached',
|
|
59
|
+
'',
|
|
60
|
+
].join('\n');
|
|
61
|
+
execSpy.mockResolvedValue(porcelainOutput);
|
|
62
|
+
const result = await gitService.worktreeList('/repo');
|
|
63
|
+
expect(result).toHaveLength(2);
|
|
64
|
+
expect(result[0].branch).toBe('main');
|
|
65
|
+
expect(result[1].branch).toBeNull();
|
|
66
|
+
expect(result[1].path).toBe('/tmp/detached');
|
|
67
|
+
expect(result[1].head).toBe('111222333');
|
|
68
|
+
});
|
|
69
|
+
it('rejects when git exits non-zero', async () => {
|
|
70
|
+
execSpy.mockRejectedValue(new Error('Git command failed: not a git repository'));
|
|
71
|
+
await expect(gitService.worktreeList('/repo'))
|
|
72
|
+
.rejects.toThrow('Git command failed');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('worktreeRemove', () => {
|
|
76
|
+
it('calls exec with correct args', async () => {
|
|
77
|
+
await gitService.worktreeRemove('/repo', '/tmp/wt');
|
|
78
|
+
expect(execSpy).toHaveBeenCalledWith(['worktree', 'remove', '/tmp/wt'], { cwd: '/repo' });
|
|
79
|
+
});
|
|
80
|
+
it('appends --force when force is true', async () => {
|
|
81
|
+
await gitService.worktreeRemove('/repo', '/tmp/wt', true);
|
|
82
|
+
expect(execSpy).toHaveBeenCalledWith(['worktree', 'remove', '/tmp/wt', '--force'], { cwd: '/repo' });
|
|
83
|
+
});
|
|
84
|
+
it('rejects when git exits non-zero', async () => {
|
|
85
|
+
execSpy.mockRejectedValue(new Error('Git command failed: not a valid worktree'));
|
|
86
|
+
await expect(gitService.worktreeRemove('/repo', '/tmp/wt'))
|
|
87
|
+
.rejects.toThrow('Git command failed');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('worktreePrune', () => {
|
|
91
|
+
it('calls exec with correct args', async () => {
|
|
92
|
+
await gitService.worktreePrune('/repo');
|
|
93
|
+
expect(execSpy).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/repo' });
|
|
94
|
+
});
|
|
95
|
+
it('rejects when git exits non-zero', async () => {
|
|
96
|
+
execSpy.mockRejectedValue(new Error('Git command failed: error'));
|
|
97
|
+
await expect(gitService.worktreePrune('/repo'))
|
|
98
|
+
.rejects.toThrow('Git command failed');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -41,13 +41,32 @@ export declare class GutApiService extends SigV4ApiClient {
|
|
|
41
41
|
/**
|
|
42
42
|
* Sync ticket with external source
|
|
43
43
|
*/
|
|
44
|
-
syncTicket(ticketId: string, direction?: 'pull' | 'push'
|
|
44
|
+
syncTicket(ticketId: string, direction?: 'pull' | 'push', options?: {
|
|
45
|
+
noEnrich?: boolean;
|
|
46
|
+
}): Promise<SyncResponse>;
|
|
45
47
|
/**
|
|
46
48
|
* Update a ticket
|
|
47
49
|
*/
|
|
48
50
|
updateTicket(ticketId: string, updates: UpdateTicketRequest): Promise<GutTicket>;
|
|
51
|
+
/**
|
|
52
|
+
* Get tenant source configuration
|
|
53
|
+
*/
|
|
54
|
+
getSourceConfig(): Promise<SourceConfig | null>;
|
|
55
|
+
/**
|
|
56
|
+
* Save tenant source configuration
|
|
57
|
+
*/
|
|
58
|
+
saveSourceConfig(config: SourceConfig): Promise<SourceConfig>;
|
|
49
59
|
/**
|
|
50
60
|
* Test API connection
|
|
51
61
|
*/
|
|
52
62
|
testConnection(): Promise<Record<string, unknown>>;
|
|
53
63
|
}
|
|
64
|
+
export interface SourceConfig {
|
|
65
|
+
type: string;
|
|
66
|
+
baseUrl: string;
|
|
67
|
+
credentials: {
|
|
68
|
+
secretArn: string;
|
|
69
|
+
};
|
|
70
|
+
projectMapping?: Record<string, string>;
|
|
71
|
+
webhookSecret?: string;
|
|
72
|
+
}
|
|
@@ -81,8 +81,12 @@ export class GutApiService extends SigV4ApiClient {
|
|
|
81
81
|
/**
|
|
82
82
|
* Sync ticket with external source
|
|
83
83
|
*/
|
|
84
|
-
async syncTicket(ticketId, direction = 'push') {
|
|
85
|
-
|
|
84
|
+
async syncTicket(ticketId, direction = 'push', options) {
|
|
85
|
+
const params = new URLSearchParams({ direction });
|
|
86
|
+
if (options?.noEnrich) {
|
|
87
|
+
params.set('noEnrich', 'true');
|
|
88
|
+
}
|
|
89
|
+
return this.makeSignedRequest('POST', `/gut/tickets/${encodeURIComponent(ticketId)}/sync?${params.toString()}`);
|
|
86
90
|
}
|
|
87
91
|
/**
|
|
88
92
|
* Update a ticket
|
|
@@ -90,6 +94,30 @@ export class GutApiService extends SigV4ApiClient {
|
|
|
90
94
|
async updateTicket(ticketId, updates) {
|
|
91
95
|
return this.makeSignedRequest('PATCH', `/gut/tickets/${encodeURIComponent(ticketId)}`, updates);
|
|
92
96
|
}
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Config Methods
|
|
99
|
+
// ============================================================================
|
|
100
|
+
/**
|
|
101
|
+
* Get tenant source configuration
|
|
102
|
+
*/
|
|
103
|
+
async getSourceConfig() {
|
|
104
|
+
try {
|
|
105
|
+
return await this.makeSignedRequest('GET', '/gut/config/source');
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const err = error;
|
|
109
|
+
if (err.response?.status === 404) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Save tenant source configuration
|
|
117
|
+
*/
|
|
118
|
+
async saveSourceConfig(config) {
|
|
119
|
+
return this.makeSignedRequest('PUT', '/gut/config/source', config);
|
|
120
|
+
}
|
|
93
121
|
/**
|
|
94
122
|
* Test API connection
|
|
95
123
|
*/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gut Tenant Service
|
|
3
|
+
*
|
|
4
|
+
* Uses shared TenantService from cli-auth with gut-specific configuration.
|
|
5
|
+
*/
|
|
6
|
+
import { TenantService, type TenantServiceConfig } from '@hyperdrive.bot/cli-auth';
|
|
7
|
+
/**
|
|
8
|
+
* Gut-specific tenant service configuration
|
|
9
|
+
*/
|
|
10
|
+
export declare const GUT_TENANT_CONFIG: TenantServiceConfig;
|
|
11
|
+
/**
|
|
12
|
+
* Create a configured TenantService instance for gut
|
|
13
|
+
*/
|
|
14
|
+
export declare function createTenantService(domain?: string): TenantService;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gut Tenant Service
|
|
3
|
+
*
|
|
4
|
+
* Uses shared TenantService from cli-auth with gut-specific configuration.
|
|
5
|
+
*/
|
|
6
|
+
import { TenantService } from '@hyperdrive.bot/cli-auth';
|
|
7
|
+
/**
|
|
8
|
+
* Gut-specific tenant service configuration
|
|
9
|
+
*/
|
|
10
|
+
export const GUT_TENANT_CONFIG = {
|
|
11
|
+
appName: 'gut',
|
|
12
|
+
defaultBootstrapUrl: 'https://api.hyperdrive.bot/tenant/bootstrap',
|
|
13
|
+
bootstrapUrlEnvVar: 'GUT_BOOTSTRAP_URL',
|
|
14
|
+
tenantDomainEnvVar: 'GUT_TENANT_DOMAIN',
|
|
15
|
+
apiUrlEnvVar: 'GUT_API_ENDPOINT',
|
|
16
|
+
primaryApiName: 'gut',
|
|
17
|
+
additionalApiNames: ['hyperdrive'],
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Create a configured TenantService instance for gut
|
|
21
|
+
*/
|
|
22
|
+
export function createTenantService(domain) {
|
|
23
|
+
return new TenantService(GUT_TENANT_CONFIG, domain);
|
|
24
|
+
}
|
|
@@ -51,7 +51,9 @@ export declare class TicketService {
|
|
|
51
51
|
/**
|
|
52
52
|
* Sync ticket with external source
|
|
53
53
|
*/
|
|
54
|
-
syncTicket(ticketId: string, direction?: 'pull' | 'push'
|
|
54
|
+
syncTicket(ticketId: string, direction?: 'pull' | 'push', options?: {
|
|
55
|
+
noEnrich?: boolean;
|
|
56
|
+
}): Promise<SyncResponse>;
|
|
55
57
|
/**
|
|
56
58
|
* Update a ticket
|
|
57
59
|
*/
|