@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.
@@ -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 || 'claude',
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
+ })