@aaronshaf/ger 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/commands/review.ts +203 -203
- package/src/cli/index.ts +2 -0
- package/src/prompts/default-review.md +45 -39
- package/src/prompts/system-inline-review.md +50 -25
- package/src/prompts/system-overall-review.md +33 -8
- package/src/services/ai-enhanced.ts +3 -2
- package/src/services/ai.ts +12 -4
- package/src/services/git-worktree.ts +297 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +111 -0
- package/tests/ai-service.test.ts +3 -3
- package/tests/review.test.ts +94 -628
- package/tests/unit/git-branch-detection.test.ts +83 -0
- package/tests/unit/git-worktree.test.ts +54 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**YOUR ENTIRE OUTPUT MUST BE WRAPPED IN <response></response> TAGS.**
|
|
4
4
|
**NEVER USE BACKTICKS ANYWHERE IN YOUR RESPONSE - they cause shell execution errors.**
|
|
5
5
|
|
|
6
|
-
Output ONLY a JSON array wrapped in response tags. No other text before or after the tags.
|
|
6
|
+
Output ONLY a JSON array wrapped in response tags. **EMPTY ARRAY IS PERFECTLY VALID** for clean code without issues. No other text before or after the tags.
|
|
7
7
|
|
|
8
8
|
## JSON Structure for Inline Comments
|
|
9
9
|
|
|
@@ -59,30 +59,55 @@ Line numbers refer to the final file (REVISION), not the diff.
|
|
|
59
59
|
|
|
60
60
|
## Priority Guidelines for Inline Comments
|
|
61
61
|
|
|
62
|
-
### ALWAYS Comment On
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
- Missing
|
|
71
|
-
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
62
|
+
### ALWAYS Comment On (Real Problems Only)
|
|
63
|
+
- **Bugs**: Logic errors, null pointer risks, incorrect algorithms
|
|
64
|
+
- **Security**: Injection vulnerabilities, auth bypasses, data leaks
|
|
65
|
+
- **Crashes**: Unhandled exceptions, resource exhaustion, infinite loops
|
|
66
|
+
- **Data loss**: Operations that corrupt or lose user data
|
|
67
|
+
|
|
68
|
+
### SOMETIMES Comment On (If Significant)
|
|
69
|
+
- **Performance**: N+1 queries, memory leaks, algorithmic complexity issues
|
|
70
|
+
- **Error handling**: Missing try/catch for operations that commonly fail
|
|
71
|
+
- **Type safety**: Dangerous casts, missing validation for external input
|
|
72
|
+
|
|
73
|
+
### NEVER Comment On (Time Wasters)
|
|
74
|
+
- **Style/formatting**: Let automated tools handle this
|
|
75
|
+
- **Working code**: If it functions correctly, leave it alone
|
|
76
|
+
- **Personal preferences**: "I would have done this differently"
|
|
77
|
+
- **Nitpicks**: Variable names, spacing, minor wording
|
|
78
|
+
- **Compliments**: Don't waste time praising obvious competence
|
|
79
|
+
|
|
80
|
+
## GIT REPOSITORY ACCESS
|
|
81
|
+
|
|
82
|
+
You are running in a git repository with full access to:
|
|
83
|
+
- git diff, git show, git log for understanding changes and context
|
|
84
|
+
- git blame for code ownership and history
|
|
85
|
+
- All project files for architectural understanding
|
|
86
|
+
- Use these commands to provide comprehensive, accurate reviews
|
|
82
87
|
|
|
83
88
|
## FINAL REMINDER
|
|
84
89
|
|
|
85
|
-
Your ENTIRE output must be a JSON array wrapped in <response></response> tags
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
**CRITICAL: Your ENTIRE output must be a JSON array wrapped in <response></response> tags.**
|
|
91
|
+
|
|
92
|
+
Example formats:
|
|
93
|
+
```
|
|
94
|
+
<response>
|
|
95
|
+
[]
|
|
96
|
+
</response>
|
|
97
|
+
```
|
|
98
|
+
(Empty array for clean code - this is GOOD!)
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
<response>
|
|
102
|
+
[{"file": "auth.js", "line": 42, "message": "🤖 SQL injection vulnerability: query uses string concatenation"}]
|
|
103
|
+
</response>
|
|
104
|
+
```
|
|
105
|
+
(Only comment on real problems)
|
|
106
|
+
|
|
107
|
+
**REQUIREMENTS**:
|
|
108
|
+
- Every message must start with "🤖 "
|
|
109
|
+
- Never use backticks in your response
|
|
110
|
+
- Empty arrays are encouraged for clean code
|
|
111
|
+
- Focus on bugs, security, crashes - ignore style preferences
|
|
112
|
+
- Use git commands to understand context before commenting
|
|
113
|
+
- NO TEXT OUTSIDE THE <response></response> TAGS
|
|
@@ -41,11 +41,10 @@ Gerrit uses a LIMITED markdown subset. Follow these rules EXACTLY:
|
|
|
41
41
|
|
|
42
42
|
**YOUR ENTIRE OUTPUT MUST BE WRAPPED IN <response></response> TAGS.**
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Start with "🤖 [Your Tool Name] ([Your Model])" then provide a **CONCISE** engineering assessment. Examples:
|
|
45
45
|
- If you are Claude Sonnet 4: "🤖 Claude (Sonnet 4)"
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
- etc.
|
|
46
|
+
- For clean code: "No significant issues found. Change is ready for merge."
|
|
47
|
+
- For problematic code: Focus only on critical/important issues, skip minor concerns
|
|
49
48
|
|
|
50
49
|
## Example Output Format
|
|
51
50
|
|
|
@@ -139,14 +138,40 @@ The security issues are blocking and must be fixed. The performance concerns sho
|
|
|
139
138
|
- Skip trivial issues unless they indicate patterns
|
|
140
139
|
- Include concrete fix suggestions
|
|
141
140
|
|
|
141
|
+
## GIT REPOSITORY ACCESS
|
|
142
|
+
|
|
143
|
+
You are running in a git repository with full access to:
|
|
144
|
+
- git diff, git show, git log for understanding changes and context
|
|
145
|
+
- git blame for code ownership and history
|
|
146
|
+
- All project files for architectural understanding
|
|
147
|
+
- Use these commands to explore the codebase and provide comprehensive reviews
|
|
148
|
+
|
|
142
149
|
## FINAL REMINDER
|
|
143
150
|
|
|
144
|
-
Your ENTIRE output must be wrapped in <response></response> tags
|
|
145
|
-
|
|
146
|
-
|
|
151
|
+
**CRITICAL: Your ENTIRE output must be wrapped in <response></response> tags.**
|
|
152
|
+
|
|
153
|
+
Example format:
|
|
154
|
+
```
|
|
155
|
+
<response>
|
|
156
|
+
🤖 Claude (Sonnet 4)
|
|
157
|
+
|
|
158
|
+
OVERALL ASSESSMENT
|
|
159
|
+
|
|
160
|
+
Your review content here...
|
|
161
|
+
</response>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
MANDATORY REQUIREMENTS:
|
|
165
|
+
- Start with "🤖 [Your Tool Name] ([Your Model])"
|
|
166
|
+
- Be CONCISE - engineers value brevity over verbosity
|
|
167
|
+
- For clean code, simply state "No significant issues found"
|
|
168
|
+
- Focus on material problems, skip style preferences and compliments
|
|
169
|
+
- Use Gerrit's limited markdown format - NO backticks, NO markdown bold/italic
|
|
170
|
+
- Use git commands to understand context before writing review
|
|
171
|
+
- NO TEXT OUTSIDE THE <response></response> TAGS
|
|
147
172
|
|
|
148
173
|
CRITICAL FORMATTING RULES:
|
|
149
174
|
- Add blank lines between sections and before/after code blocks
|
|
150
|
-
- Use exactly 4 spaces to start each line of code blocks
|
|
175
|
+
- Use exactly 4 spaces to start each line of code blocks
|
|
151
176
|
- Keep code blocks simple and readable
|
|
152
177
|
- Add proper spacing for readability
|
|
@@ -79,12 +79,12 @@ export const AiServiceEnhanced = Layer.effect(
|
|
|
79
79
|
return responseMatch[1].trim()
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
const runPrompt = (prompt: string, input: string) =>
|
|
82
|
+
const runPrompt = (prompt: string, input: string = '', options: { cwd?: string } = {}) =>
|
|
83
83
|
Effect.gen(function* () {
|
|
84
84
|
const tool = yield* detectAiTool()
|
|
85
85
|
|
|
86
86
|
// Prepare the command based on the tool
|
|
87
|
-
const fullInput = `${prompt}\n\n${input}`
|
|
87
|
+
const fullInput = input ? `${prompt}\n\n${input}` : prompt
|
|
88
88
|
let command: string
|
|
89
89
|
|
|
90
90
|
switch (tool) {
|
|
@@ -114,6 +114,7 @@ export const AiServiceEnhanced = Layer.effect(
|
|
|
114
114
|
const child = require('node:child_process').spawn(command, {
|
|
115
115
|
shell: true,
|
|
116
116
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
117
|
+
cwd: options.cwd || process.cwd(),
|
|
117
118
|
})
|
|
118
119
|
|
|
119
120
|
// Write input to stdin
|
package/src/services/ai.ts
CHANGED
|
@@ -25,7 +25,8 @@ export class AiService extends Context.Tag('AiService')<
|
|
|
25
25
|
{
|
|
26
26
|
readonly runPrompt: (
|
|
27
27
|
prompt: string,
|
|
28
|
-
input
|
|
28
|
+
input?: string,
|
|
29
|
+
options?: { cwd?: string },
|
|
29
30
|
) => Effect.Effect<string, AiServiceError | NoAiToolFoundError | AiResponseParseError>
|
|
30
31
|
readonly detectAiTool: () => Effect.Effect<string, NoAiToolFoundError>
|
|
31
32
|
readonly extractResponseTag: (output: string) => Effect.Effect<string, AiResponseParseError>
|
|
@@ -76,7 +77,7 @@ export const AiServiceLive = Layer.succeed(
|
|
|
76
77
|
return responseMatch[1].trim()
|
|
77
78
|
}),
|
|
78
79
|
|
|
79
|
-
runPrompt: (prompt: string, input: string) =>
|
|
80
|
+
runPrompt: (prompt: string, input: string = '', options: { cwd?: string } = {}) =>
|
|
80
81
|
Effect.gen(function* () {
|
|
81
82
|
const tool = yield* Effect.gen(function* () {
|
|
82
83
|
// Try to detect available AI tools in order of preference
|
|
@@ -101,7 +102,7 @@ export const AiServiceLive = Layer.succeed(
|
|
|
101
102
|
})
|
|
102
103
|
|
|
103
104
|
// Prepare the command based on the tool
|
|
104
|
-
const fullInput = `${prompt}\n\n${input}`
|
|
105
|
+
const fullInput = input ? `${prompt}\n\n${input}` : prompt
|
|
105
106
|
let command: string
|
|
106
107
|
|
|
107
108
|
switch (tool) {
|
|
@@ -127,6 +128,7 @@ export const AiServiceLive = Layer.succeed(
|
|
|
127
128
|
const child = require('node:child_process').spawn(command, {
|
|
128
129
|
shell: true,
|
|
129
130
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
131
|
+
cwd: options.cwd || process.cwd(),
|
|
130
132
|
})
|
|
131
133
|
|
|
132
134
|
// Write input to stdin
|
|
@@ -168,9 +170,15 @@ export const AiServiceLive = Layer.succeed(
|
|
|
168
170
|
const responseMatch = result.stdout.match(/<response>([\s\S]*?)<\/response>/i)
|
|
169
171
|
|
|
170
172
|
if (!responseMatch || !responseMatch[1]) {
|
|
173
|
+
// Enhanced error message with truncated output for debugging
|
|
174
|
+
const truncatedOutput =
|
|
175
|
+
result.stdout.length > 500
|
|
176
|
+
? result.stdout.substring(0, 500) + '...[truncated]'
|
|
177
|
+
: result.stdout
|
|
178
|
+
|
|
171
179
|
return yield* Effect.fail(
|
|
172
180
|
new AiResponseParseError({
|
|
173
|
-
message:
|
|
181
|
+
message: `No <response> tag found in AI output. Raw output: ${truncatedOutput}`,
|
|
174
182
|
rawOutput: result.stdout,
|
|
175
183
|
}),
|
|
176
184
|
)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { Effect, Console, pipe, Layer, Context } from 'effect'
|
|
2
|
+
import { Schema } from 'effect'
|
|
3
|
+
import * as os from 'node:os'
|
|
4
|
+
import * as path from 'node:path'
|
|
5
|
+
import * as fs from 'node:fs/promises'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
|
|
8
|
+
// Error types
|
|
9
|
+
export class WorktreeCreationError extends Schema.TaggedError<WorktreeCreationError>()(
|
|
10
|
+
'WorktreeCreationError',
|
|
11
|
+
{
|
|
12
|
+
message: Schema.String,
|
|
13
|
+
cause: Schema.optional(Schema.Unknown),
|
|
14
|
+
},
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
export class PatchsetFetchError extends Schema.TaggedError<PatchsetFetchError>()(
|
|
18
|
+
'PatchsetFetchError',
|
|
19
|
+
{
|
|
20
|
+
message: Schema.String,
|
|
21
|
+
cause: Schema.optional(Schema.Unknown),
|
|
22
|
+
},
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
export class DirtyRepoError extends Schema.TaggedError<DirtyRepoError>()('DirtyRepoError', {
|
|
26
|
+
message: Schema.String,
|
|
27
|
+
}) {}
|
|
28
|
+
|
|
29
|
+
export class NotGitRepoError extends Schema.TaggedError<NotGitRepoError>()('NotGitRepoError', {
|
|
30
|
+
message: Schema.String,
|
|
31
|
+
}) {}
|
|
32
|
+
|
|
33
|
+
export type GitError = WorktreeCreationError | PatchsetFetchError | DirtyRepoError | NotGitRepoError
|
|
34
|
+
|
|
35
|
+
// Worktree info
|
|
36
|
+
export interface WorktreeInfo {
|
|
37
|
+
path: string
|
|
38
|
+
changeId: string
|
|
39
|
+
originalCwd: string
|
|
40
|
+
timestamp: number
|
|
41
|
+
pid: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Git command runner with Effect
|
|
45
|
+
const runGitCommand = (
|
|
46
|
+
args: string[],
|
|
47
|
+
options: { cwd?: string } = {},
|
|
48
|
+
): Effect.Effect<string, GitError, never> =>
|
|
49
|
+
Effect.async<string, GitError, never>((resume) => {
|
|
50
|
+
const child = spawn('git', args, {
|
|
51
|
+
cwd: options.cwd || process.cwd(),
|
|
52
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
let stdout = ''
|
|
56
|
+
let stderr = ''
|
|
57
|
+
|
|
58
|
+
child.stdout?.on('data', (data) => {
|
|
59
|
+
stdout += data.toString()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
child.stderr?.on('data', (data) => {
|
|
63
|
+
stderr += data.toString()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
child.on('close', (code) => {
|
|
67
|
+
if (code === 0) {
|
|
68
|
+
resume(Effect.succeed(stdout.trim()))
|
|
69
|
+
} else {
|
|
70
|
+
const errorMessage = `Git command failed: git ${args.join(' ')}\nStderr: ${stderr}`
|
|
71
|
+
|
|
72
|
+
// Classify error based on command and output
|
|
73
|
+
if (args[0] === 'worktree' && args[1] === 'add') {
|
|
74
|
+
resume(Effect.fail(new WorktreeCreationError({ message: errorMessage })))
|
|
75
|
+
} else if (args[0] === 'fetch' || args[0] === 'checkout') {
|
|
76
|
+
resume(Effect.fail(new PatchsetFetchError({ message: errorMessage })))
|
|
77
|
+
} else {
|
|
78
|
+
resume(Effect.fail(new WorktreeCreationError({ message: errorMessage })))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
child.on('error', (error) => {
|
|
84
|
+
resume(
|
|
85
|
+
Effect.fail(
|
|
86
|
+
new WorktreeCreationError({
|
|
87
|
+
message: `Failed to spawn git: ${error.message}`,
|
|
88
|
+
cause: error,
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Check if current directory is a git repository
|
|
96
|
+
const validateGitRepo = (): Effect.Effect<void, NotGitRepoError, never> =>
|
|
97
|
+
pipe(
|
|
98
|
+
runGitCommand(['rev-parse', '--git-dir']),
|
|
99
|
+
Effect.mapError(
|
|
100
|
+
() => new NotGitRepoError({ message: 'Current directory is not a git repository' }),
|
|
101
|
+
),
|
|
102
|
+
Effect.map(() => undefined),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// Check if working directory is clean
|
|
106
|
+
const validateCleanRepo = (): Effect.Effect<void, DirtyRepoError, never> =>
|
|
107
|
+
pipe(
|
|
108
|
+
runGitCommand(['status', '--porcelain']),
|
|
109
|
+
Effect.mapError(() => new DirtyRepoError({ message: 'Failed to check repository status' })),
|
|
110
|
+
Effect.flatMap((output) =>
|
|
111
|
+
output.trim() === ''
|
|
112
|
+
? Effect.succeed(undefined)
|
|
113
|
+
: Effect.fail(
|
|
114
|
+
new DirtyRepoError({
|
|
115
|
+
message:
|
|
116
|
+
'Working directory has uncommitted changes. Please commit or stash changes before review.',
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Generate unique worktree path
|
|
123
|
+
const generateWorktreePath = (changeId: string): string => {
|
|
124
|
+
const timestamp = Date.now()
|
|
125
|
+
const pid = process.pid
|
|
126
|
+
const uniqueId = `${changeId}-${timestamp}-${pid}`
|
|
127
|
+
return path.join(os.homedir(), '.ger', 'worktrees', uniqueId)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Ensure .ger directory exists
|
|
131
|
+
const ensureGerDirectory = (): Effect.Effect<void, never, never> =>
|
|
132
|
+
Effect.tryPromise({
|
|
133
|
+
try: async () => {
|
|
134
|
+
const gerDir = path.join(os.homedir(), '.ger', 'worktrees')
|
|
135
|
+
await fs.mkdir(gerDir, { recursive: true })
|
|
136
|
+
},
|
|
137
|
+
catch: () => undefined, // Ignore errors, will fail later if directory can't be created
|
|
138
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(undefined)))
|
|
139
|
+
|
|
140
|
+
// Build Gerrit refspec for change
|
|
141
|
+
const buildRefspec = (changeNumber: string, patchsetNumber: number = 1): string => {
|
|
142
|
+
// Extract change number from changeId if it contains non-numeric characters
|
|
143
|
+
const numericChangeNumber = changeNumber.replace(/\D/g, '')
|
|
144
|
+
return `refs/changes/${numericChangeNumber.slice(-2)}/${numericChangeNumber}/${patchsetNumber}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Get the current HEAD commit hash to avoid branch conflicts
|
|
148
|
+
const getCurrentCommit = (): Effect.Effect<string, GitError, never> =>
|
|
149
|
+
pipe(
|
|
150
|
+
runGitCommand(['rev-parse', 'HEAD']),
|
|
151
|
+
Effect.map((output) => output.trim()),
|
|
152
|
+
Effect.catchAll(() =>
|
|
153
|
+
// Fallback: try to get commit from default branch
|
|
154
|
+
pipe(
|
|
155
|
+
runGitCommand(['rev-parse', 'origin/main']),
|
|
156
|
+
Effect.catchAll(() => runGitCommand(['rev-parse', 'origin/master'])),
|
|
157
|
+
Effect.catchAll(() => Effect.succeed('HEAD')),
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
// Get latest patchset number for a change
|
|
163
|
+
const getLatestPatchsetNumber = (
|
|
164
|
+
changeId: string,
|
|
165
|
+
): Effect.Effect<number, PatchsetFetchError, never> =>
|
|
166
|
+
pipe(
|
|
167
|
+
runGitCommand(['ls-remote', 'origin', `refs/changes/*/${changeId.replace(/\D/g, '')}/*`]),
|
|
168
|
+
Effect.mapError(
|
|
169
|
+
(error) =>
|
|
170
|
+
new PatchsetFetchError({ message: `Failed to get patchset info: ${error.message}` }),
|
|
171
|
+
),
|
|
172
|
+
Effect.map((output) => {
|
|
173
|
+
const lines = output.split('\n').filter((line) => line.trim())
|
|
174
|
+
if (lines.length === 0) {
|
|
175
|
+
return 1 // Default to patchset 1 if no refs found
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Extract patchset numbers and return the highest
|
|
179
|
+
const patchsetNumbers = lines
|
|
180
|
+
.map((line) => {
|
|
181
|
+
const match = line.match(/refs\/changes\/\d+\/\d+\/(\d+)$/)
|
|
182
|
+
return match ? parseInt(match[1], 10) : 0
|
|
183
|
+
})
|
|
184
|
+
.filter((num) => num > 0)
|
|
185
|
+
|
|
186
|
+
return patchsetNumbers.length > 0 ? Math.max(...patchsetNumbers) : 1
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
// GitWorktreeService implementation
|
|
191
|
+
export interface GitWorktreeServiceImpl {
|
|
192
|
+
validatePreconditions: () => Effect.Effect<void, GitError, never>
|
|
193
|
+
createWorktree: (changeId: string) => Effect.Effect<WorktreeInfo, GitError, never>
|
|
194
|
+
fetchAndCheckoutPatchset: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, GitError, never>
|
|
195
|
+
cleanup: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, never, never>
|
|
196
|
+
getChangedFiles: () => Effect.Effect<string[], GitError, never>
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
|
|
200
|
+
validatePreconditions: () =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
yield* validateGitRepo()
|
|
203
|
+
yield* validateCleanRepo()
|
|
204
|
+
yield* Console.log('✓ Git repository validation passed')
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
createWorktree: (changeId: string) =>
|
|
208
|
+
Effect.gen(function* () {
|
|
209
|
+
yield* Console.log(`→ Creating worktree for change ${changeId}...`)
|
|
210
|
+
|
|
211
|
+
// Get current commit hash to avoid branch conflicts
|
|
212
|
+
const currentCommit = yield* getCurrentCommit()
|
|
213
|
+
yield* Console.log(`→ Using base commit: ${currentCommit.substring(0, 7)}`)
|
|
214
|
+
|
|
215
|
+
// Ensure .ger directory exists
|
|
216
|
+
yield* ensureGerDirectory()
|
|
217
|
+
|
|
218
|
+
// Generate unique path
|
|
219
|
+
const worktreePath = generateWorktreePath(changeId)
|
|
220
|
+
const originalCwd = process.cwd()
|
|
221
|
+
|
|
222
|
+
// Create worktree using commit hash (no branch conflicts)
|
|
223
|
+
yield* runGitCommand(['worktree', 'add', '--detach', worktreePath, currentCommit])
|
|
224
|
+
|
|
225
|
+
const worktreeInfo: WorktreeInfo = {
|
|
226
|
+
path: worktreePath,
|
|
227
|
+
changeId,
|
|
228
|
+
originalCwd,
|
|
229
|
+
timestamp: Date.now(),
|
|
230
|
+
pid: process.pid,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
yield* Console.log(`✓ Worktree created at ${worktreePath}`)
|
|
234
|
+
return worktreeInfo
|
|
235
|
+
}),
|
|
236
|
+
|
|
237
|
+
fetchAndCheckoutPatchset: (worktreeInfo: WorktreeInfo) =>
|
|
238
|
+
Effect.gen(function* () {
|
|
239
|
+
yield* Console.log(`→ Fetching and checking out patchset for ${worktreeInfo.changeId}...`)
|
|
240
|
+
|
|
241
|
+
// Get latest patchset number
|
|
242
|
+
const patchsetNumber = yield* getLatestPatchsetNumber(worktreeInfo.changeId)
|
|
243
|
+
const refspec = buildRefspec(worktreeInfo.changeId, patchsetNumber)
|
|
244
|
+
|
|
245
|
+
yield* Console.log(`→ Using refspec: ${refspec}`)
|
|
246
|
+
|
|
247
|
+
// Fetch the change
|
|
248
|
+
yield* runGitCommand(['fetch', 'origin', refspec], { cwd: worktreeInfo.path })
|
|
249
|
+
|
|
250
|
+
// Checkout FETCH_HEAD
|
|
251
|
+
yield* runGitCommand(['checkout', 'FETCH_HEAD'], { cwd: worktreeInfo.path })
|
|
252
|
+
|
|
253
|
+
yield* Console.log(`✓ Checked out patchset ${patchsetNumber} for ${worktreeInfo.changeId}`)
|
|
254
|
+
}),
|
|
255
|
+
|
|
256
|
+
cleanup: (worktreeInfo: WorktreeInfo) =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
yield* Console.log(`→ Cleaning up worktree for ${worktreeInfo.changeId}...`)
|
|
259
|
+
|
|
260
|
+
// Always restore original working directory first
|
|
261
|
+
try {
|
|
262
|
+
process.chdir(worktreeInfo.originalCwd)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
yield* Console.warn(`Warning: Could not restore original directory: ${error}`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Attempt to remove worktree (don't fail if this doesn't work)
|
|
268
|
+
yield* pipe(
|
|
269
|
+
runGitCommand(['worktree', 'remove', '--force', worktreeInfo.path]),
|
|
270
|
+
Effect.catchAll((error) =>
|
|
271
|
+
Effect.gen(function* () {
|
|
272
|
+
yield* Console.warn(`Warning: Could not remove worktree: ${error.message}`)
|
|
273
|
+
yield* Console.warn(`Manual cleanup may be required: ${worktreeInfo.path}`)
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
yield* Console.log(`✓ Cleanup completed for ${worktreeInfo.changeId}`)
|
|
279
|
+
}),
|
|
280
|
+
|
|
281
|
+
getChangedFiles: () =>
|
|
282
|
+
Effect.gen(function* () {
|
|
283
|
+
// Get list of changed files in current worktree
|
|
284
|
+
const output = yield* runGitCommand(['diff', '--name-only', 'HEAD~1'])
|
|
285
|
+
const files = output.split('\n').filter((file) => file.trim())
|
|
286
|
+
return files
|
|
287
|
+
}),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Export service tag for dependency injection
|
|
291
|
+
export class GitWorktreeService extends Context.Tag('GitWorktreeService')<
|
|
292
|
+
GitWorktreeService,
|
|
293
|
+
GitWorktreeServiceImpl
|
|
294
|
+
>() {}
|
|
295
|
+
|
|
296
|
+
// Export service layer
|
|
297
|
+
export const GitWorktreeServiceLive = Layer.succeed(GitWorktreeService, GitWorktreeServiceImplLive)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ChangeInfo, CommentInfo, MessageInfo } from '@/schemas/gerrit'
|
|
2
|
+
import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
|
|
3
|
+
|
|
4
|
+
export const formatChangeAsXML = (change: ChangeInfo): string[] => {
|
|
5
|
+
const lines: string[] = []
|
|
6
|
+
lines.push(` <change>`)
|
|
7
|
+
lines.push(` <id>${escapeXML(change.change_id)}</id>`)
|
|
8
|
+
lines.push(` <number>${change._number}</number>`)
|
|
9
|
+
lines.push(` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`)
|
|
10
|
+
lines.push(` <status>${escapeXML(change.status)}</status>`)
|
|
11
|
+
lines.push(` <project>${escapeXML(change.project)}</project>`)
|
|
12
|
+
lines.push(` <branch>${escapeXML(change.branch)}</branch>`)
|
|
13
|
+
lines.push(` <owner>`)
|
|
14
|
+
if (change.owner?.name) {
|
|
15
|
+
lines.push(` <name><![CDATA[${sanitizeCDATA(change.owner.name)}]]></name>`)
|
|
16
|
+
}
|
|
17
|
+
if (change.owner?.email) {
|
|
18
|
+
lines.push(` <email>${escapeXML(change.owner.email)}</email>`)
|
|
19
|
+
}
|
|
20
|
+
lines.push(` </owner>`)
|
|
21
|
+
lines.push(` <created>${escapeXML(change.created || '')}</created>`)
|
|
22
|
+
lines.push(` <updated>${escapeXML(change.updated || '')}</updated>`)
|
|
23
|
+
lines.push(` </change>`)
|
|
24
|
+
return lines
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const formatCommentsAsXML = (comments: readonly CommentInfo[]): string[] => {
|
|
28
|
+
const lines: string[] = []
|
|
29
|
+
lines.push(` <comments>`)
|
|
30
|
+
lines.push(` <count>${comments.length}</count>`)
|
|
31
|
+
for (const comment of comments) {
|
|
32
|
+
lines.push(` <comment>`)
|
|
33
|
+
if (comment.id) lines.push(` <id>${escapeXML(comment.id)}</id>`)
|
|
34
|
+
if (comment.path) {
|
|
35
|
+
lines.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
|
|
36
|
+
}
|
|
37
|
+
if (comment.line) lines.push(` <line>${comment.line}</line>`)
|
|
38
|
+
if (comment.author?.name) {
|
|
39
|
+
lines.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
|
|
40
|
+
}
|
|
41
|
+
if (comment.updated) lines.push(` <updated>${escapeXML(comment.updated)}</updated>`)
|
|
42
|
+
if (comment.message) {
|
|
43
|
+
lines.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
|
|
44
|
+
}
|
|
45
|
+
if (comment.unresolved) lines.push(` <unresolved>true</unresolved>`)
|
|
46
|
+
lines.push(` </comment>`)
|
|
47
|
+
}
|
|
48
|
+
lines.push(` </comments>`)
|
|
49
|
+
return lines
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const formatMessagesAsXML = (messages: readonly MessageInfo[]): string[] => {
|
|
53
|
+
const lines: string[] = []
|
|
54
|
+
lines.push(` <messages>`)
|
|
55
|
+
lines.push(` <count>${messages.length}</count>`)
|
|
56
|
+
for (const message of messages) {
|
|
57
|
+
lines.push(` <message>`)
|
|
58
|
+
lines.push(` <id>${escapeXML(message.id)}</id>`)
|
|
59
|
+
if (message.author?.name) {
|
|
60
|
+
lines.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
|
|
61
|
+
}
|
|
62
|
+
if (message.author?._account_id) {
|
|
63
|
+
lines.push(` <author_id>${message.author._account_id}</author_id>`)
|
|
64
|
+
}
|
|
65
|
+
lines.push(` <date>${escapeXML(message.date)}</date>`)
|
|
66
|
+
if (message._revision_number) {
|
|
67
|
+
lines.push(` <revision>${message._revision_number}</revision>`)
|
|
68
|
+
}
|
|
69
|
+
if (message.tag) {
|
|
70
|
+
lines.push(` <tag>${escapeXML(message.tag)}</tag>`)
|
|
71
|
+
}
|
|
72
|
+
lines.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
|
|
73
|
+
lines.push(` </message>`)
|
|
74
|
+
}
|
|
75
|
+
lines.push(` </messages>`)
|
|
76
|
+
return lines
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const flattenComments = (
|
|
80
|
+
commentsMap: Record<string, readonly CommentInfo[]>,
|
|
81
|
+
): CommentInfo[] => {
|
|
82
|
+
const comments: CommentInfo[] = []
|
|
83
|
+
for (const [path, fileComments] of Object.entries(commentsMap)) {
|
|
84
|
+
for (const comment of fileComments) {
|
|
85
|
+
comments.push({ ...comment, path })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return comments
|
|
89
|
+
}
|