@hyperdrive.bot/gut 0.1.10 → 0.1.11
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 +3 -3
- 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 +140 -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
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
// Resolve base branch per-entity: use super-repo branch if it exists,
|
|
57
|
+
// otherwise fall back to the entity's current branch
|
|
58
|
+
let entityBaseBranch = baseBranch;
|
|
59
|
+
const branchExistsInEntity = await this.gitService.branchExists(entityMainPath, baseBranch);
|
|
60
|
+
if (!branchExistsInEntity) {
|
|
61
|
+
entityBaseBranch = await this.gitService.getCurrentBranch(entityMainPath);
|
|
62
|
+
this.log(chalk.yellow(`⚠ Entity "${entity.name}" does not have branch "${baseBranch}", using "${entityBaseBranch}" instead`));
|
|
63
|
+
}
|
|
64
|
+
// Remove submodule stub
|
|
65
|
+
fs.rmSync(entityWtPath, { force: true, recursive: true });
|
|
66
|
+
// Create entity worktree
|
|
67
|
+
await this.gitService.worktreeAdd(entityMainPath, entityWtPath, args.name, entityBaseBranch);
|
|
68
|
+
createdWorktrees.push({ repoPath: entityMainPath, wtPath: entityWtPath });
|
|
69
|
+
this.log(chalk.green(`✓ Entity worktree: ${entity.name} → ${args.name}`));
|
|
70
|
+
entityRecords.push({
|
|
71
|
+
branch: args.name,
|
|
72
|
+
entityName: entity.name,
|
|
73
|
+
entityPath: entityRelativePath,
|
|
74
|
+
worktreePath: entityWtPath,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Atomic rollback (AC: 9)
|
|
80
|
+
for (const entry of [...createdWorktrees].reverse()) {
|
|
81
|
+
await this.gitService.worktreeRemove(entry.repoPath, entry.wtPath, true).catch(() => { });
|
|
82
|
+
}
|
|
83
|
+
await this.gitService.worktreePrune(workspaceRoot).catch(() => { });
|
|
84
|
+
for (const entity of focusedEntities) {
|
|
85
|
+
const entityRelativePath = entity.path.replace(/^\.\//, '');
|
|
86
|
+
const entityMainPath = path.join(workspaceRoot, entityRelativePath);
|
|
87
|
+
await this.gitService.worktreePrune(entityMainPath).catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
this.log(chalk.red('✗ Worktree creation failed. All partial worktrees cleaned up.'));
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
this.error(message);
|
|
92
|
+
}
|
|
93
|
+
// --- pnpm install (AC: 4) ---
|
|
94
|
+
if (flags.install) {
|
|
95
|
+
const startTime = Date.now();
|
|
96
|
+
try {
|
|
97
|
+
await this.runPnpmInstall(wtPath);
|
|
98
|
+
const seconds = Math.round((Date.now() - startTime) / 1000);
|
|
99
|
+
this.log(chalk.green(`✓ Dependencies installed (pnpm install: ${seconds}s)`));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
this.log(chalk.yellow('⚠ pnpm install failed — worktree is still usable without dependencies'));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// --- Save state (AC: 10) ---
|
|
106
|
+
const record = {
|
|
107
|
+
baseBranch,
|
|
108
|
+
createdAt: Date.now(),
|
|
109
|
+
entities: entityRecords,
|
|
110
|
+
name: args.name,
|
|
111
|
+
path: wtPath,
|
|
112
|
+
};
|
|
113
|
+
const state = this.worktreeService.loadState();
|
|
114
|
+
state.worktrees.push(record);
|
|
115
|
+
this.worktreeService.saveState(state);
|
|
116
|
+
// --- Summary output (AC: 11) ---
|
|
117
|
+
this.log('');
|
|
118
|
+
this.log('Worktree ready:');
|
|
119
|
+
this.log(` Path: ${wtPath}`);
|
|
120
|
+
this.log(` Branch: ${args.name}`);
|
|
121
|
+
this.log(` Entities: ${entityRecords.map(e => e.entityName).join(', ')}`);
|
|
122
|
+
this.log('');
|
|
123
|
+
this.log('Use with bmad-workflow:');
|
|
124
|
+
this.log(` bmad-workflow workflow <prd> --cwd ${wtPath}`);
|
|
125
|
+
}
|
|
126
|
+
runPnpmInstall(cwd) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const child = spawn('pnpm', ['install'], { cwd, stdio: 'inherit' });
|
|
129
|
+
child.on('close', (code) => {
|
|
130
|
+
if (code === 0) {
|
|
131
|
+
resolve();
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
reject(new Error(`pnpm install exited with code ${code}`));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
child.on('error', reject);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -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
|
*/
|
|
@@ -96,12 +96,16 @@ export class TicketService {
|
|
|
96
96
|
/**
|
|
97
97
|
* Sync ticket with external source
|
|
98
98
|
*/
|
|
99
|
-
async syncTicket(ticketId, direction = 'push') {
|
|
99
|
+
async syncTicket(ticketId, direction = 'push', options) {
|
|
100
100
|
if (this.useAuth && this.apiService) {
|
|
101
|
-
return this.apiService.syncTicket(ticketId, direction);
|
|
101
|
+
return this.apiService.syncTicket(ticketId, direction, options);
|
|
102
102
|
}
|
|
103
103
|
// Fallback to unauthenticated mode
|
|
104
|
-
const
|
|
104
|
+
const params = new URLSearchParams({ direction });
|
|
105
|
+
if (options?.noEnrich) {
|
|
106
|
+
params.set('noEnrich', 'true');
|
|
107
|
+
}
|
|
108
|
+
const response = await this.makeFetchRequest('POST', `/gut/tickets/${encodeURIComponent(ticketId)}/sync?${params.toString()}`);
|
|
105
109
|
return response;
|
|
106
110
|
}
|
|
107
111
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WorktreeRecord, WorktreeState } from '../models/entity.model.js';
|
|
2
|
+
import type { ConfigService } from './config.service.js';
|
|
3
|
+
import type { FocusService } from './focus.service.js';
|
|
4
|
+
import type { GitService } from './git.service.js';
|
|
5
|
+
export declare class WorktreeService {
|
|
6
|
+
private readonly configService;
|
|
7
|
+
private readonly gitService;
|
|
8
|
+
private readonly focusService;
|
|
9
|
+
private readonly stateFile;
|
|
10
|
+
private readonly stateFileTmp;
|
|
11
|
+
constructor(configService: ConfigService, gitService: GitService, focusService: FocusService);
|
|
12
|
+
get(name: string): WorktreeRecord | null;
|
|
13
|
+
list(): WorktreeRecord[];
|
|
14
|
+
loadState(): WorktreeState;
|
|
15
|
+
saveState(state: WorktreeState): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export class WorktreeService {
|
|
4
|
+
configService;
|
|
5
|
+
gitService;
|
|
6
|
+
focusService;
|
|
7
|
+
stateFile;
|
|
8
|
+
stateFileTmp;
|
|
9
|
+
constructor(configService, gitService, focusService) {
|
|
10
|
+
this.configService = configService;
|
|
11
|
+
this.gitService = gitService;
|
|
12
|
+
this.focusService = focusService;
|
|
13
|
+
this.stateFile = path.join(this.configService.getGutDir(), 'worktrees.json');
|
|
14
|
+
this.stateFileTmp = `${this.stateFile}.tmp`;
|
|
15
|
+
}
|
|
16
|
+
get(name) {
|
|
17
|
+
const state = this.loadState();
|
|
18
|
+
return state.worktrees.find(w => w.name === name) ?? null;
|
|
19
|
+
}
|
|
20
|
+
list() {
|
|
21
|
+
return this.loadState().worktrees;
|
|
22
|
+
}
|
|
23
|
+
loadState() {
|
|
24
|
+
if (!fs.existsSync(this.stateFile)) {
|
|
25
|
+
return { worktrees: [] };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const content = fs.readFileSync(this.stateFile, 'utf-8');
|
|
29
|
+
const parsed = JSON.parse(content);
|
|
30
|
+
if (!Array.isArray(parsed.worktrees)) {
|
|
31
|
+
return { worktrees: [] };
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
console.warn('Corrupt worktrees.json, resetting state');
|
|
37
|
+
return { worktrees: [] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
saveState(state) {
|
|
41
|
+
const serialized = JSON.stringify(state, null, 2);
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(this.stateFileTmp, serialized, 'utf-8');
|
|
44
|
+
fs.renameSync(this.stateFileTmp, this.stateFile);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// Cleanup tmp file on failure
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(this.stateFileTmp)) {
|
|
50
|
+
fs.unlinkSync(this.stateFileTmp);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Ignore cleanup errors
|
|
55
|
+
}
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
throw new Error(`Failed to save worktree state: ${message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|