@aaronshaf/ger 3.0.2 → 4.0.0
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/api/gerrit-types.ts +121 -0
- package/src/api/gerrit.ts +55 -96
- package/src/cli/commands/analyze.ts +284 -0
- package/src/cli/commands/cherry.ts +268 -0
- package/src/cli/commands/failures.ts +74 -0
- package/src/cli/commands/list.ts +239 -0
- package/src/cli/commands/rebase.ts +5 -1
- package/src/cli/commands/retrigger.ts +91 -0
- package/src/cli/commands/setup.ts +19 -6
- package/src/cli/commands/tree-cleanup.ts +139 -0
- package/src/cli/commands/tree-rebase.ts +168 -0
- package/src/cli/commands/tree-setup.ts +202 -0
- package/src/cli/commands/trees.ts +107 -0
- package/src/cli/commands/update.ts +73 -0
- package/src/cli/register-analytics-commands.ts +90 -0
- package/src/cli/register-commands.ts +56 -40
- package/src/cli/register-list-commands.ts +105 -0
- package/src/cli/register-tree-commands.ts +128 -0
- package/src/schemas/config.ts +3 -0
- package/src/services/config.ts +15 -0
- package/tests/analyze.test.ts +197 -0
- package/tests/cherry.test.ts +208 -0
- package/tests/failures.test.ts +212 -0
- package/tests/helpers/config-mock.ts +4 -0
- package/tests/list.test.ts +220 -0
- package/tests/retrigger.test.ts +159 -0
- package/tests/tree.test.ts +517 -0
- package/tests/update.test.ts +86 -0
|
@@ -193,11 +193,8 @@ const setupEffect = (configService: ConfigServiceImpl) =>
|
|
|
193
193
|
console.log(chalk.dim(`Detected AI tools: ${availableTools.join(', ')}`))
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// Get default suggestion
|
|
197
|
-
const defaultCommand =
|
|
198
|
-
existingConfig?.aiTool ||
|
|
199
|
-
(availableTools.includes('claude') ? 'claude' : availableTools[0]) ||
|
|
200
|
-
''
|
|
196
|
+
// Get default suggestion — no default to claude
|
|
197
|
+
const defaultCommand = existingConfig?.aiTool || (availableTools[0] ?? '')
|
|
201
198
|
|
|
202
199
|
// AI tool command with smart default
|
|
203
200
|
const aiToolCommand = await input({
|
|
@@ -205,7 +202,20 @@ const setupEffect = (configService: ConfigServiceImpl) =>
|
|
|
205
202
|
availableTools.length > 0
|
|
206
203
|
? 'AI tool command (detected from system)'
|
|
207
204
|
: 'AI tool command (e.g., claude, llm, opencode, gemini)',
|
|
208
|
-
default: defaultCommand ||
|
|
205
|
+
default: defaultCommand || undefined,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
console.log('')
|
|
209
|
+
console.log(chalk.yellow('Optional: CI Retrigger'))
|
|
210
|
+
console.log(
|
|
211
|
+
chalk.dim(
|
|
212
|
+
'Comment to post when triggering a CI build (e.g. a magic trigger string your CI watches for)',
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const retriggerComment = await input({
|
|
217
|
+
message: 'CI retrigger comment (leave blank to skip)',
|
|
218
|
+
default: existingConfig?.retriggerComment ?? undefined,
|
|
209
219
|
})
|
|
210
220
|
|
|
211
221
|
// Build flat config
|
|
@@ -217,6 +227,9 @@ const setupEffect = (configService: ConfigServiceImpl) =>
|
|
|
217
227
|
aiTool: aiToolCommand,
|
|
218
228
|
}),
|
|
219
229
|
aiAutoDetect: !aiToolCommand,
|
|
230
|
+
...(retriggerComment.trim() && {
|
|
231
|
+
retriggerComment: retriggerComment.trim(),
|
|
232
|
+
}),
|
|
220
233
|
}
|
|
221
234
|
|
|
222
235
|
// Validate config using Schema instead of type assertion
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawnSync, execSync } from 'node:child_process'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { Effect } from 'effect'
|
|
5
|
+
import chalk from 'chalk'
|
|
6
|
+
|
|
7
|
+
export interface TreeCleanupOptions {
|
|
8
|
+
xml?: boolean
|
|
9
|
+
json?: boolean
|
|
10
|
+
force?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isInGitRepo = (): boolean => {
|
|
14
|
+
try {
|
|
15
|
+
execSync('git rev-parse --git-dir', { encoding: 'utf8' })
|
|
16
|
+
return true
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const getRepoRoot = (): string =>
|
|
23
|
+
execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
|
|
24
|
+
|
|
25
|
+
const getGerWorktrees = (repoRoot: string): string[] => {
|
|
26
|
+
const gerDir = path.join(repoRoot, '.ger')
|
|
27
|
+
if (!fs.existsSync(gerDir)) return []
|
|
28
|
+
try {
|
|
29
|
+
return fs
|
|
30
|
+
.readdirSync(gerDir)
|
|
31
|
+
.filter((name) => /^\d+$/.test(name))
|
|
32
|
+
.map((name) => path.join(gerDir, name))
|
|
33
|
+
.filter((p) => fs.statSync(p).isDirectory())
|
|
34
|
+
} catch {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const removeWorktree = (worktreePath: string, repoRoot: string, force: boolean): boolean => {
|
|
40
|
+
const args = force
|
|
41
|
+
? ['worktree', 'remove', '--force', worktreePath]
|
|
42
|
+
: ['worktree', 'remove', worktreePath]
|
|
43
|
+
|
|
44
|
+
const result = spawnSync('git', args, { encoding: 'utf8', cwd: repoRoot })
|
|
45
|
+
return result.status === 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const treeCleanupCommand = (
|
|
49
|
+
changeId: string | undefined,
|
|
50
|
+
options: TreeCleanupOptions,
|
|
51
|
+
): Effect.Effect<void, Error, never> =>
|
|
52
|
+
Effect.sync(() => {
|
|
53
|
+
if (!isInGitRepo()) {
|
|
54
|
+
throw new Error('Not in a git repository')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const repoRoot = getRepoRoot()
|
|
58
|
+
|
|
59
|
+
let targets: string[]
|
|
60
|
+
|
|
61
|
+
if (changeId !== undefined) {
|
|
62
|
+
if (!/^\d+$/.test(changeId))
|
|
63
|
+
throw new Error(`Invalid change ID: ${changeId} (must be a numeric change number)`)
|
|
64
|
+
const worktreePath = path.join(repoRoot, '.ger', changeId)
|
|
65
|
+
if (!fs.existsSync(worktreePath)) {
|
|
66
|
+
throw new Error(`No worktree found for change ${changeId}`)
|
|
67
|
+
}
|
|
68
|
+
targets = [worktreePath]
|
|
69
|
+
} else {
|
|
70
|
+
targets = getGerWorktrees(repoRoot)
|
|
71
|
+
if (targets.length === 0) {
|
|
72
|
+
if (options.json) {
|
|
73
|
+
console.log(JSON.stringify({ status: 'success', removed: [] }, null, 2))
|
|
74
|
+
} else if (options.xml) {
|
|
75
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
76
|
+
console.log(`<tree_cleanup>`)
|
|
77
|
+
console.log(` <status>success</status>`)
|
|
78
|
+
console.log(` <removed></removed>`)
|
|
79
|
+
console.log(`</tree_cleanup>`)
|
|
80
|
+
} else {
|
|
81
|
+
console.log(chalk.dim(' No ger-managed worktrees to clean up'))
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const removed: string[] = []
|
|
88
|
+
const failed: string[] = []
|
|
89
|
+
|
|
90
|
+
for (const worktreePath of targets) {
|
|
91
|
+
if (!options.xml && !options.json) {
|
|
92
|
+
console.log(chalk.dim(` Removing ${worktreePath}...`))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ok = removeWorktree(worktreePath, repoRoot, options.force ?? false)
|
|
96
|
+
if (ok) {
|
|
97
|
+
removed.push(worktreePath)
|
|
98
|
+
} else {
|
|
99
|
+
failed.push(worktreePath)
|
|
100
|
+
if (!options.xml && !options.json) {
|
|
101
|
+
console.log(
|
|
102
|
+
chalk.yellow(
|
|
103
|
+
` Warning: Could not remove ${worktreePath} (uncommitted changes? use --force)`,
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Clean up stale worktree metadata
|
|
111
|
+
spawnSync('git', ['worktree', 'prune'], { encoding: 'utf8', cwd: repoRoot })
|
|
112
|
+
|
|
113
|
+
if (options.json) {
|
|
114
|
+
console.log(JSON.stringify({ status: 'success', removed, failed }, null, 2))
|
|
115
|
+
} else if (options.xml) {
|
|
116
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
117
|
+
console.log(`<tree_cleanup>`)
|
|
118
|
+
console.log(` <status>success</status>`)
|
|
119
|
+
console.log(` <removed>`)
|
|
120
|
+
for (const p of removed) {
|
|
121
|
+
console.log(` <path><![CDATA[${p}]]></path>`)
|
|
122
|
+
}
|
|
123
|
+
console.log(` </removed>`)
|
|
124
|
+
console.log(` <failed>`)
|
|
125
|
+
for (const p of failed) {
|
|
126
|
+
console.log(` <path><![CDATA[${p}]]></path>`)
|
|
127
|
+
}
|
|
128
|
+
console.log(` </failed>`)
|
|
129
|
+
console.log(`</tree_cleanup>`)
|
|
130
|
+
} else {
|
|
131
|
+
if (removed.length > 0) {
|
|
132
|
+
console.log(
|
|
133
|
+
chalk.green(`\n ✓ Removed ${removed.length} worktree${removed.length !== 1 ? 's' : ''}`),
|
|
134
|
+
)
|
|
135
|
+
} else {
|
|
136
|
+
console.log(chalk.yellow(' No worktrees were removed'))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { spawnSync, execSync } from 'node:child_process'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import { Effect } from 'effect'
|
|
4
|
+
import chalk from 'chalk'
|
|
5
|
+
import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
|
|
6
|
+
|
|
7
|
+
export interface TreeRebaseOptions {
|
|
8
|
+
onto?: string
|
|
9
|
+
interactive?: boolean
|
|
10
|
+
xml?: boolean
|
|
11
|
+
json?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const isInGitRepo = (): boolean => {
|
|
15
|
+
try {
|
|
16
|
+
execSync('git rev-parse --git-dir', { encoding: 'utf8' })
|
|
17
|
+
return true
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const getRepoRoot = (): string =>
|
|
24
|
+
execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
|
|
25
|
+
|
|
26
|
+
const getCwd = (): string => process.cwd()
|
|
27
|
+
|
|
28
|
+
/** Returns the remote name for the given Gerrit host, or null if not found. */
|
|
29
|
+
const findMatchingRemote = (repoRoot: string, gerritHost: string): string | null => {
|
|
30
|
+
try {
|
|
31
|
+
const output = execSync('git remote -v', { encoding: 'utf8', cwd: repoRoot })
|
|
32
|
+
const gerritHostname = new URL(gerritHost).hostname
|
|
33
|
+
for (const line of output.split('\n')) {
|
|
34
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/)
|
|
35
|
+
if (!match) continue
|
|
36
|
+
const url = match[2]
|
|
37
|
+
try {
|
|
38
|
+
let remoteHostname: string
|
|
39
|
+
if (url.startsWith('git@')) {
|
|
40
|
+
remoteHostname = url.split('@')[1].split(':')[0]
|
|
41
|
+
} else {
|
|
42
|
+
remoteHostname = new URL(url).hostname
|
|
43
|
+
}
|
|
44
|
+
if (remoteHostname === gerritHostname) return match[1]
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore malformed URLs
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// ignore
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const detectBaseBranch = (remote: string): string => {
|
|
56
|
+
// Prefer upstream tracking branch
|
|
57
|
+
try {
|
|
58
|
+
const upstream = execSync('git rev-parse --abbrev-ref HEAD@{u}', {
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
61
|
+
}).trim()
|
|
62
|
+
if (upstream && !upstream.includes('@{u}')) return upstream
|
|
63
|
+
} catch {
|
|
64
|
+
// no upstream set
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fall back to <remote>/main, then <remote>/master
|
|
68
|
+
for (const branch of [`${remote}/main`, `${remote}/master`]) {
|
|
69
|
+
try {
|
|
70
|
+
execSync(`git rev-parse --verify ${branch}`, {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
73
|
+
})
|
|
74
|
+
return branch
|
|
75
|
+
} catch {
|
|
76
|
+
// try next
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `${remote}/main`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Verify the current directory is inside a ger-managed worktree (.ger/<number>). */
|
|
84
|
+
const assertInGerWorktree = (repoRoot: string): void => {
|
|
85
|
+
const cwd = getCwd()
|
|
86
|
+
const gerDir = path.join(repoRoot, '.ger') + path.sep
|
|
87
|
+
if (!cwd.startsWith(gerDir)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Not inside a ger-managed worktree.\nRun "ger tree setup <change-id>" first, then cd into the worktree.`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
// The segment after .ger/ should be a numeric change number
|
|
93
|
+
const rel = cwd.slice(gerDir.length)
|
|
94
|
+
const changeNum = rel.split(path.sep)[0]
|
|
95
|
+
if (!/^\d+$/.test(changeNum)) {
|
|
96
|
+
throw new Error(`Current directory does not look like a ger worktree: ${cwd}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const treeRebaseCommand = (
|
|
101
|
+
options: TreeRebaseOptions,
|
|
102
|
+
// Optional: override gerrit host for testing
|
|
103
|
+
_gerritHostOverride?: string,
|
|
104
|
+
): Effect.Effect<void, Error | ConfigError, ConfigServiceImpl> =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const configService = yield* ConfigService
|
|
107
|
+
const credentials = yield* configService.getCredentials.pipe(Effect.mapError((e): Error => e))
|
|
108
|
+
const gerritHost = _gerritHostOverride ?? credentials.host
|
|
109
|
+
|
|
110
|
+
if (!isInGitRepo()) {
|
|
111
|
+
throw new Error('Not in a git repository')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const repoRoot = getRepoRoot()
|
|
115
|
+
|
|
116
|
+
// Only allow running from inside a ger-managed worktree
|
|
117
|
+
assertInGerWorktree(repoRoot)
|
|
118
|
+
|
|
119
|
+
// Resolve the correct remote matching the configured Gerrit host
|
|
120
|
+
const remote = findMatchingRemote(repoRoot, gerritHost) ?? 'origin'
|
|
121
|
+
|
|
122
|
+
yield* Effect.try({
|
|
123
|
+
try: () => {
|
|
124
|
+
const baseBranch = options.onto ?? detectBaseBranch(remote)
|
|
125
|
+
|
|
126
|
+
if (!options.xml && !options.json) {
|
|
127
|
+
console.log(chalk.bold(`Rebasing onto ${chalk.cyan(baseBranch)}...`))
|
|
128
|
+
console.log(chalk.dim(` Fetching ${remote}...`))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fetchResult = spawnSync('git', ['fetch', remote], { encoding: 'utf8', cwd: repoRoot })
|
|
132
|
+
if (fetchResult.status !== 0) {
|
|
133
|
+
throw new Error(`Failed to fetch ${remote}: ${fetchResult.stderr}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!options.xml && !options.json) {
|
|
137
|
+
console.log(chalk.dim(` Running git rebase ${baseBranch}...`))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const rebaseArgs = options.interactive
|
|
141
|
+
? ['rebase', '-i', baseBranch]
|
|
142
|
+
: ['rebase', baseBranch]
|
|
143
|
+
const rebaseResult = spawnSync('git', rebaseArgs, {
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
stdio: 'inherit',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (rebaseResult.status !== 0) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Rebase failed. Resolve conflicts then run:\n git rebase --continue\nor abort with:\n git rebase --abort`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (options.json) {
|
|
155
|
+
console.log(JSON.stringify({ status: 'success', base: baseBranch }, null, 2))
|
|
156
|
+
} else if (options.xml) {
|
|
157
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
158
|
+
console.log(`<tree_rebase>`)
|
|
159
|
+
console.log(` <status>success</status>`)
|
|
160
|
+
console.log(` <base><![CDATA[${baseBranch}]]></base>`)
|
|
161
|
+
console.log(`</tree_rebase>`)
|
|
162
|
+
} else {
|
|
163
|
+
console.log(chalk.green('\n ✓ Rebase complete'))
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { spawnSync, execSync } from 'node:child_process'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { Effect } from 'effect'
|
|
5
|
+
import chalk from 'chalk'
|
|
6
|
+
import { type ApiError, GerritApiService, type GerritApiServiceImpl } from '@/api/gerrit'
|
|
7
|
+
import { type ConfigError, ConfigService, type ConfigServiceImpl } from '@/services/config'
|
|
8
|
+
|
|
9
|
+
export const TREE_SETUP_HELP_TEXT = `
|
|
10
|
+
Examples:
|
|
11
|
+
# Set up worktree for latest patchset
|
|
12
|
+
$ ger tree setup 12345
|
|
13
|
+
|
|
14
|
+
# Set up worktree for specific patchset
|
|
15
|
+
$ ger tree setup 12345:3
|
|
16
|
+
|
|
17
|
+
# XML output (for LLM pipelines)
|
|
18
|
+
$ ger tree setup 12345 --xml
|
|
19
|
+
|
|
20
|
+
Notes:
|
|
21
|
+
- Worktree is created at <repo-root>/.ger/<change-number>/
|
|
22
|
+
- If worktree already exists, prints the path and exits
|
|
23
|
+
- Use 'ger trees' to list worktrees, 'ger tree cleanup' to remove them`
|
|
24
|
+
|
|
25
|
+
export interface TreeSetupOptions {
|
|
26
|
+
xml?: boolean
|
|
27
|
+
json?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const parseChangeSpec = (changeSpec: string): { changeId: string; patchset?: string } => {
|
|
31
|
+
const parts = changeSpec.split(':')
|
|
32
|
+
return { changeId: parts[0], patchset: parts[1] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const getGitRemotes = (): Record<string, string> => {
|
|
36
|
+
try {
|
|
37
|
+
const output = execSync('git remote -v', { encoding: 'utf8' })
|
|
38
|
+
const remotes: Record<string, string> = {}
|
|
39
|
+
for (const line of output.split('\n')) {
|
|
40
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/)
|
|
41
|
+
if (match) remotes[match[1]] = match[2]
|
|
42
|
+
}
|
|
43
|
+
return remotes
|
|
44
|
+
} catch {
|
|
45
|
+
return {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const findMatchingRemote = (gerritHost: string): string | null => {
|
|
50
|
+
const remotes = getGitRemotes()
|
|
51
|
+
const gerritUrl = new URL(gerritHost)
|
|
52
|
+
const gerritHostname = gerritUrl.hostname
|
|
53
|
+
for (const [name, url] of Object.entries(remotes)) {
|
|
54
|
+
try {
|
|
55
|
+
let remoteHostname: string
|
|
56
|
+
if (url.startsWith('git@')) {
|
|
57
|
+
remoteHostname = url.split('@')[1].split(':')[0]
|
|
58
|
+
} else if (url.includes('://')) {
|
|
59
|
+
remoteHostname = new URL(url).hostname
|
|
60
|
+
} else {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (remoteHostname === gerritHostname) return name
|
|
64
|
+
} catch {
|
|
65
|
+
// ignore malformed URLs
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isInGitRepo = (): boolean => {
|
|
72
|
+
try {
|
|
73
|
+
execSync('git rev-parse --git-dir', { encoding: 'utf8' })
|
|
74
|
+
return true
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const getRepoRoot = (): string => {
|
|
81
|
+
try {
|
|
82
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error('Not in a git repository')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const treeSetupCommand = (
|
|
89
|
+
changeSpec: string,
|
|
90
|
+
options: TreeSetupOptions,
|
|
91
|
+
): Effect.Effect<void, ApiError | ConfigError | Error, GerritApiServiceImpl | ConfigServiceImpl> =>
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
if (!isInGitRepo()) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
'Not in a git repository. Please run this command from within a git repository.',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const repoRoot = getRepoRoot()
|
|
100
|
+
const { changeId, patchset } = parseChangeSpec(changeSpec)
|
|
101
|
+
|
|
102
|
+
const configService = yield* ConfigService
|
|
103
|
+
const credentials = yield* configService.getCredentials
|
|
104
|
+
const matchingRemote = findMatchingRemote(credentials.host)
|
|
105
|
+
|
|
106
|
+
if (!matchingRemote) {
|
|
107
|
+
throw new Error(`No git remote found matching Gerrit host: ${credentials.host}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!options.xml && !options.json) {
|
|
111
|
+
console.log(chalk.bold(`Setting up worktree for change ${chalk.cyan(changeId)}...`))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const gerritApi = yield* GerritApiService
|
|
115
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
116
|
+
|
|
117
|
+
if (!options.xml && !options.json) {
|
|
118
|
+
console.log(chalk.dim(` ${change._number}: ${change.subject}`))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const targetPatchset = patchset ?? 'current'
|
|
122
|
+
const revision = yield* gerritApi.getRevision(changeId, targetPatchset)
|
|
123
|
+
|
|
124
|
+
const workspaceName = change._number.toString()
|
|
125
|
+
if (!/^\d+$/.test(workspaceName)) {
|
|
126
|
+
throw new Error(`Invalid change number: ${workspaceName}`)
|
|
127
|
+
}
|
|
128
|
+
const worktreeDir = path.join(repoRoot, '.ger', workspaceName)
|
|
129
|
+
|
|
130
|
+
if (fs.existsSync(worktreeDir)) {
|
|
131
|
+
if (options.json) {
|
|
132
|
+
console.log(JSON.stringify({ status: 'success', path: worktreeDir, exists: true }, null, 2))
|
|
133
|
+
} else if (options.xml) {
|
|
134
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
135
|
+
console.log(`<tree_setup>`)
|
|
136
|
+
console.log(` <path>${worktreeDir}</path>`)
|
|
137
|
+
console.log(` <exists>true</exists>`)
|
|
138
|
+
console.log(`</tree_setup>`)
|
|
139
|
+
} else {
|
|
140
|
+
console.log(chalk.yellow(' Worktree already exists'))
|
|
141
|
+
console.log(`\n ${chalk.bold('cd')} ${chalk.green(worktreeDir)}`)
|
|
142
|
+
}
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const gerDir = path.join(repoRoot, '.ger')
|
|
147
|
+
if (!fs.existsSync(gerDir)) {
|
|
148
|
+
fs.mkdirSync(gerDir, { recursive: true })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const changeRef = revision.ref
|
|
152
|
+
if (!options.xml && !options.json) {
|
|
153
|
+
console.log(chalk.dim(` Fetching ${changeRef}...`))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fetchResult = spawnSync('git', ['fetch', matchingRemote, changeRef], {
|
|
157
|
+
encoding: 'utf8',
|
|
158
|
+
cwd: repoRoot,
|
|
159
|
+
})
|
|
160
|
+
if (fetchResult.status !== 0) {
|
|
161
|
+
throw new Error(fetchResult.stderr ?? 'Git fetch failed')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!options.xml && !options.json) {
|
|
165
|
+
console.log(chalk.dim(` Creating worktree...`))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const worktreeResult = spawnSync('git', ['worktree', 'add', worktreeDir, 'FETCH_HEAD'], {
|
|
169
|
+
encoding: 'utf8',
|
|
170
|
+
cwd: repoRoot,
|
|
171
|
+
})
|
|
172
|
+
if (worktreeResult.status !== 0) {
|
|
173
|
+
throw new Error(worktreeResult.stderr ?? 'Git worktree add failed')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (options.json) {
|
|
177
|
+
console.log(
|
|
178
|
+
JSON.stringify(
|
|
179
|
+
{
|
|
180
|
+
status: 'success',
|
|
181
|
+
path: worktreeDir,
|
|
182
|
+
change_number: change._number,
|
|
183
|
+
subject: change.subject,
|
|
184
|
+
created: true,
|
|
185
|
+
},
|
|
186
|
+
null,
|
|
187
|
+
2,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
} else if (options.xml) {
|
|
191
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
192
|
+
console.log(`<tree_setup>`)
|
|
193
|
+
console.log(` <path>${worktreeDir}</path>`)
|
|
194
|
+
console.log(` <change_number>${change._number}</change_number>`)
|
|
195
|
+
console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
196
|
+
console.log(` <created>true</created>`)
|
|
197
|
+
console.log(`</tree_setup>`)
|
|
198
|
+
} else {
|
|
199
|
+
console.log(chalk.green('\n ✓ Worktree ready'))
|
|
200
|
+
console.log(`\n ${chalk.bold('cd')} ${chalk.green(worktreeDir)}`)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
|
|
5
|
+
export interface TreesOptions {
|
|
6
|
+
xml?: boolean
|
|
7
|
+
json?: boolean
|
|
8
|
+
all?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface WorktreeEntry {
|
|
12
|
+
path: string
|
|
13
|
+
head: string
|
|
14
|
+
branch: string | null
|
|
15
|
+
isDetached: boolean
|
|
16
|
+
isGerManaged: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const parseWorktreeList = (output: string): WorktreeEntry[] => {
|
|
20
|
+
const entries: WorktreeEntry[] = []
|
|
21
|
+
const blocks = output.trim().split('\n\n')
|
|
22
|
+
|
|
23
|
+
for (const block of blocks) {
|
|
24
|
+
const lines = block.trim().split('\n')
|
|
25
|
+
const pathLine = lines.find((l) => l.startsWith('worktree '))
|
|
26
|
+
const headLine = lines.find((l) => l.startsWith('HEAD '))
|
|
27
|
+
const branchLine = lines.find((l) => l.startsWith('branch '))
|
|
28
|
+
const isDetached = lines.some((l) => l === 'detached')
|
|
29
|
+
|
|
30
|
+
if (!pathLine) continue
|
|
31
|
+
|
|
32
|
+
const worktreePath = pathLine.slice('worktree '.length)
|
|
33
|
+
const head = headLine ? headLine.slice('HEAD '.length) : ''
|
|
34
|
+
const rawBranch = branchLine ? branchLine.slice('branch '.length) : null
|
|
35
|
+
const branch = rawBranch ? rawBranch.replace('refs/heads/', '') : null
|
|
36
|
+
|
|
37
|
+
entries.push({
|
|
38
|
+
path: worktreePath,
|
|
39
|
+
head: head.slice(0, 7),
|
|
40
|
+
branch,
|
|
41
|
+
isDetached,
|
|
42
|
+
isGerManaged: worktreePath.includes('/.ger/'),
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return entries
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isInGitRepo = (): boolean => {
|
|
50
|
+
try {
|
|
51
|
+
execSync('git rev-parse --git-dir', { encoding: 'utf8' })
|
|
52
|
+
return true
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const treesCommand = (options: TreesOptions): Effect.Effect<void, Error, never> =>
|
|
59
|
+
Effect.sync(() => {
|
|
60
|
+
if (!isInGitRepo()) {
|
|
61
|
+
throw new Error('Not in a git repository')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let output: string
|
|
65
|
+
try {
|
|
66
|
+
output = execSync('git worktree list --porcelain', { encoding: 'utf8' })
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error('Failed to list worktrees')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const all = parseWorktreeList(output)
|
|
72
|
+
const entries = options.all ? all : all.filter((e) => e.isGerManaged)
|
|
73
|
+
|
|
74
|
+
if (options.json) {
|
|
75
|
+
console.log(JSON.stringify({ status: 'success', worktrees: entries }, null, 2))
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (options.xml) {
|
|
80
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
81
|
+
console.log(`<worktrees>`)
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
console.log(` <worktree>`)
|
|
84
|
+
console.log(` <path><![CDATA[${entry.path}]]></path>`)
|
|
85
|
+
console.log(` <head>${entry.head}</head>`)
|
|
86
|
+
if (entry.branch) console.log(` <branch><![CDATA[${entry.branch}]]></branch>`)
|
|
87
|
+
console.log(` <detached>${entry.isDetached}</detached>`)
|
|
88
|
+
console.log(` <ger_managed>${entry.isGerManaged}</ger_managed>`)
|
|
89
|
+
console.log(` </worktree>`)
|
|
90
|
+
}
|
|
91
|
+
console.log(`</worktrees>`)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (entries.length === 0) {
|
|
96
|
+
console.log(chalk.dim(' No ger-managed worktrees found'))
|
|
97
|
+
console.log(chalk.dim(` Use ${chalk.white('ger tree setup <change-id>')} to create one`))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log(chalk.bold('Worktrees:'))
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const branchInfo = entry.branch ? chalk.yellow(entry.branch) : chalk.dim('detached HEAD')
|
|
104
|
+
console.log(` ${chalk.green(entry.path)}`)
|
|
105
|
+
console.log(` ${chalk.dim(entry.head)} ${branchInfo}`)
|
|
106
|
+
}
|
|
107
|
+
})
|