@aaronshaf/ger 0.1.0 → 0.1.3
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/bun.lock +29 -4
- package/package.json +2 -1
- package/src/cli/commands/review.ts +221 -213
- package/src/cli/index.ts +13 -4
- 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/git-worktree.ts +297 -0
- package/src/services/review-strategy.ts +373 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +111 -0
- 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
- package/tests/unit/services/review-strategy.test.ts +494 -0
- package/src/services/ai-enhanced.ts +0 -167
- package/src/services/ai.ts +0 -182
- package/tests/ai-service.test.ts +0 -489
package/src/cli/index.ts
CHANGED
|
@@ -25,7 +25,8 @@ import { Command } from 'commander'
|
|
|
25
25
|
import { Effect } from 'effect'
|
|
26
26
|
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
27
27
|
import { ConfigServiceLive } from '@/services/config'
|
|
28
|
-
import {
|
|
28
|
+
import { ReviewStrategyServiceLive } from '@/services/review-strategy'
|
|
29
|
+
import { GitWorktreeServiceLive } from '@/services/git-worktree'
|
|
29
30
|
import { abandonCommand } from './commands/abandon'
|
|
30
31
|
import { commentCommand } from './commands/comment'
|
|
31
32
|
import { commentsCommand } from './commands/comments'
|
|
@@ -367,10 +368,15 @@ program
|
|
|
367
368
|
.option('-y, --yes', 'Skip confirmation prompts when posting comments')
|
|
368
369
|
.option('--debug', 'Show debug output including AI responses')
|
|
369
370
|
.option('--prompt <file>', 'Path to custom review prompt file (e.g., ~/prompts/review.md)')
|
|
371
|
+
.option(
|
|
372
|
+
'--provider <provider>',
|
|
373
|
+
'Preferred AI provider (claude-sdk, claude, gemini, codex, opencode)',
|
|
374
|
+
)
|
|
375
|
+
.option('--system-prompt <prompt>', 'Custom system prompt for the AI')
|
|
370
376
|
.addHelpText(
|
|
371
377
|
'after',
|
|
372
378
|
`
|
|
373
|
-
This command uses AI (claude,
|
|
379
|
+
This command uses AI (Claude SDK, claude CLI, gemini CLI, codex CLI, or opencode CLI) to review a Gerrit change.
|
|
374
380
|
It performs a two-stage review process:
|
|
375
381
|
|
|
376
382
|
1. Generates inline comments for specific code issues
|
|
@@ -381,7 +387,7 @@ Use --comment to post the review to Gerrit (with confirmation prompts).
|
|
|
381
387
|
Use --comment --yes to post without confirmation.
|
|
382
388
|
|
|
383
389
|
Requirements:
|
|
384
|
-
- One of these AI tools must be
|
|
390
|
+
- One of these AI tools must be available: Claude SDK (ANTHROPIC_API_KEY), claude CLI, gemini CLI, codex CLI, or opencode CLI
|
|
385
391
|
- Gerrit credentials must be configured (run 'ger setup' first)
|
|
386
392
|
|
|
387
393
|
Examples:
|
|
@@ -405,10 +411,13 @@ Examples:
|
|
|
405
411
|
yes: options.yes,
|
|
406
412
|
debug: options.debug,
|
|
407
413
|
prompt: options.prompt,
|
|
414
|
+
provider: options.provider,
|
|
415
|
+
systemPrompt: options.systemPrompt,
|
|
408
416
|
}).pipe(
|
|
409
|
-
Effect.provide(
|
|
417
|
+
Effect.provide(ReviewStrategyServiceLive),
|
|
410
418
|
Effect.provide(GerritApiServiceLive),
|
|
411
419
|
Effect.provide(ConfigServiceLive),
|
|
420
|
+
Effect.provide(GitWorktreeServiceLive),
|
|
412
421
|
)
|
|
413
422
|
await Effect.runPromise(effect)
|
|
414
423
|
} catch (error) {
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
# Code Review
|
|
1
|
+
# Engineering Code Review - Signal Over Noise
|
|
2
2
|
|
|
3
|
-
You are
|
|
3
|
+
You are conducting a technical code review for experienced engineers. **PRIORITY: Find actual problems, not generate busy work.**
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Core Principles
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Fully comprehend the author's intent before identifying issues
|
|
9
|
-
- Read COMPLETE files, not just diffs
|
|
10
|
-
- Check if apparent issues are handled elsewhere in the change
|
|
11
|
-
- Consider the broader architectural context
|
|
12
|
-
- Verify you're reviewing the LATEST patchset version
|
|
7
|
+
**SIGNAL > NOISE**: Only comment on issues that materially impact correctness, security, performance, or maintainability. Silence is better than noise.
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
**NO PRAISE NEEDED**: Don't compliment good code. Engineers expect competent code by default.
|
|
10
|
+
|
|
11
|
+
**EMPTY RESPONSES ARE VALID**: Small changes without issues should result in empty inline comments. The overall review can simply note "No significant issues found."
|
|
12
|
+
|
|
13
|
+
**FOCUS ON REAL PROBLEMS**:
|
|
14
|
+
- Bugs that will cause runtime failures
|
|
15
|
+
- Security vulnerabilities
|
|
16
|
+
- Performance bottlenecks
|
|
17
|
+
- Architectural mistakes
|
|
18
|
+
- Missing error handling
|
|
19
|
+
|
|
20
|
+
**ASSUME COMPETENCE**: The author is an experienced engineer who made intentional decisions. Question only when you see genuine problems.
|
|
20
21
|
|
|
21
22
|
## Review Categories (Priority Order)
|
|
22
23
|
|
|
@@ -47,34 +48,39 @@ You are reviewing a Gerrit change set. Provide thorough, constructive feedback f
|
|
|
47
48
|
- **Clarity**: Code that works but could be more readable
|
|
48
49
|
- **Future-Proofing**: Anticipating likely future requirements
|
|
49
50
|
|
|
50
|
-
## What NOT to Review
|
|
51
|
+
## What NOT to Review (Common Time Wasters)
|
|
52
|
+
|
|
53
|
+
- **Code style/formatting**: Handled by automated tools
|
|
54
|
+
- **Personal preferences**: Different != wrong
|
|
55
|
+
- **Compliments**: "Looks good!" wastes everyone's time
|
|
56
|
+
- **Nitpicks**: Minor wording, variable names, spacing
|
|
57
|
+
- **Micro-optimizations**: Unless there's a proven performance problem
|
|
58
|
+
- **Already working code**: If it works and isn't broken, don't fix it
|
|
59
|
+
- **Suggestions for "better" approaches**: Only if current approach has concrete problems
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
- **Style Preferences**: Formatting that doesn't impact readability
|
|
54
|
-
- **Micro-Optimizations**: Unless performance is a stated goal
|
|
55
|
-
- **Personal Preferences**: Unless they violate team standards
|
|
56
|
-
- **Out of Scope**: Issues in unchanged code (unless directly relevant)
|
|
61
|
+
## Before Commenting, Ask Yourself
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
1. **Will this cause a runtime failure?** → Critical issue, comment required
|
|
64
|
+
2. **Will this create a security vulnerability?** → Critical issue, comment required
|
|
65
|
+
3. **Will this significantly harm performance?** → Important issue, comment required
|
|
66
|
+
4. **Will this make the code unmaintainable?** → Consider commenting
|
|
67
|
+
5. **Is this just a different way to solve the same problem?** → Skip it
|
|
59
68
|
|
|
60
|
-
|
|
61
|
-
1. The issue still exists in the current patchset
|
|
62
|
-
2. The fix wouldn't break other functionality
|
|
63
|
-
3. Your understanding of the code's purpose is correct
|
|
64
|
-
4. The issue isn't intentional or documented
|
|
65
|
-
5. The concern is worth the author's time to address
|
|
69
|
+
## Output Guidelines
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
**INLINE COMMENTS**: Only for specific line-level issues. Empty array is perfectly valid.
|
|
72
|
+
- Start with "🤖 "
|
|
73
|
+
- Be direct: "This will cause X bug" not "Consider maybe perhaps changing this"
|
|
74
|
+
- Provide specific fixes when possible
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
- Use questions for clarification, statements for clear issues
|
|
76
|
+
**OVERALL REVIEW**: Required even if no inline comments.
|
|
77
|
+
- For clean code: "No significant issues found. Change is ready."
|
|
78
|
+
- For problematic code: Focus on the most important issues only
|
|
79
|
+
- Skip the pleasantries, get to the point
|
|
74
80
|
|
|
75
|
-
##
|
|
81
|
+
## Success Metrics
|
|
76
82
|
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
83
|
+
- **Good review**: Finds 1-3 real issues that would cause problems
|
|
84
|
+
- **Great review**: Catches a critical bug before production
|
|
85
|
+
- **Bad review**: 10+ nitpicky comments about style preferences
|
|
86
|
+
- **Terrible review**: "Great job! LGTM!" with zero value added
|
|
@@ -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
|
|
@@ -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)
|