@ivotoby/postgram-cli 1.38.3 → 1.40.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.
@@ -0,0 +1,306 @@
1
+ // cli/src/tracking/where.ts: pgm where — features and workspaces grouped by stage.
2
+ import { existsSync } from 'node:fs';
3
+ import { shortId } from '../shared.js';
4
+ import { runTrackingCommand } from './api.js';
5
+ import { usageExit } from './config.js';
6
+ import { formatAge, isTranscriptGone, renderResume } from './resume.js';
7
+ export function collectWorkspaces(response) {
8
+ const workspaces = [];
9
+ for (const group of response.groups) {
10
+ if ('features' in group) {
11
+ for (const feature of group.features) {
12
+ workspaces.push(...feature.workspaces);
13
+ }
14
+ }
15
+ else {
16
+ workspaces.push(...group.workspaces);
17
+ }
18
+ }
19
+ return workspaces;
20
+ }
21
+ export async function syncMissing(workspaces, deps) {
22
+ let changed = false;
23
+ const failedIds = new Set();
24
+ for (const workspace of workspaces) {
25
+ if (workspace.machine !== deps.localAlias) {
26
+ continue;
27
+ }
28
+ const present = deps.exists(workspace.path);
29
+ if (!present && workspace.missing_since === null) {
30
+ try {
31
+ await deps.patch(workspace.id, true);
32
+ changed = true;
33
+ }
34
+ catch {
35
+ failedIds.add(workspace.id);
36
+ }
37
+ }
38
+ else if (present && workspace.missing_since !== null) {
39
+ try {
40
+ await deps.patch(workspace.id, false);
41
+ changed = true;
42
+ }
43
+ catch {
44
+ // the workspace is present; nothing to mark locally
45
+ }
46
+ }
47
+ }
48
+ return { changed, failedIds };
49
+ }
50
+ function displayRisks(workspace, input) {
51
+ const risks = [...workspace.risks];
52
+ if (input.failedIds.has(workspace.id) &&
53
+ !input.exists(workspace.path) &&
54
+ !risks.includes('missing')) {
55
+ risks.push('missing');
56
+ }
57
+ return risks;
58
+ }
59
+ function riskText(risk, workspace) {
60
+ if (risk === 'dirty') {
61
+ const count = workspace.git?.dirty_count ?? null;
62
+ return count !== null ? `dirty:${count}` : risk;
63
+ }
64
+ if (risk === 'unpushed') {
65
+ const count = workspace.git?.unpushed_count ?? null;
66
+ return count !== null ? `unpushed:${count}` : risk;
67
+ }
68
+ return risk;
69
+ }
70
+ function transcriptGone(workspace, input) {
71
+ return isTranscriptGone(workspace, input.localAlias, input.exists);
72
+ }
73
+ function augmentedWorkspace(workspace, input) {
74
+ const gone = transcriptGone(workspace, input);
75
+ return {
76
+ ...workspace,
77
+ risks: displayRisks(workspace, input),
78
+ git: workspace.git !== null ? { ...workspace.git } : null,
79
+ resume_session: workspace.resume_session !== null ? { ...workspace.resume_session } : null,
80
+ sessions: workspace.sessions.map((session) => ({ ...session })),
81
+ resume: renderResume(workspace, {
82
+ localAlias: input.localAlias,
83
+ templates: input.templates,
84
+ transcriptGone: gone
85
+ }),
86
+ transcript_gone: gone
87
+ };
88
+ }
89
+ export function augmentWhere(response, input) {
90
+ const groups = response.groups.map((group) => {
91
+ if ('features' in group) {
92
+ const features = group.features.map((feature) => ({
93
+ ...feature,
94
+ pull_requests: feature.pull_requests.map((pullRequest) => ({
95
+ ...pullRequest
96
+ })),
97
+ workspaces: feature.workspaces.map((workspace) => augmentedWorkspace(workspace, input))
98
+ }));
99
+ return { ...group, features };
100
+ }
101
+ return {
102
+ ...group,
103
+ workspaces: group.workspaces.map((workspace) => augmentedWorkspace(workspace, input))
104
+ };
105
+ });
106
+ return { ...response, groups };
107
+ }
108
+ const PATH_WIDTH = 70;
109
+ /**
110
+ * Home-relative for local workspaces. A path that is still too long loses its
111
+ * leading segments, never its end: the end is what tells worktrees apart.
112
+ */
113
+ function displayPath(workspace, input) {
114
+ let shown = workspace.path;
115
+ const home = input.homeDir;
116
+ if (home !== undefined &&
117
+ workspace.machine === input.localAlias &&
118
+ (shown === home || shown.startsWith(`${home}/`))) {
119
+ shown = `~${shown.slice(home.length)}`;
120
+ }
121
+ if (shown.length <= PATH_WIDTH) {
122
+ return shown;
123
+ }
124
+ const segments = shown.split('/');
125
+ let tail = segments.pop() ?? '';
126
+ while (segments.length > 1) {
127
+ const next = segments[segments.length - 1] ?? '';
128
+ if (`…/${next}/${tail}`.length > PATH_WIDTH) {
129
+ break;
130
+ }
131
+ tail = `${next}/${tail}`;
132
+ segments.pop();
133
+ }
134
+ return `…/${tail}`;
135
+ }
136
+ function workspaceLines(workspace, input) {
137
+ const risks = displayRisks(workspace, input).map((risk) => riskText(risk, workspace));
138
+ let line1 = ` ${workspace.machine} ${workspace.branch ?? '-'}`;
139
+ if (risks.length > 0) {
140
+ line1 = `${line1} ${risks.join(' ')}`;
141
+ }
142
+ const gitAge = workspace.git_state_at !== null
143
+ ? formatAge(workspace.git_state_at, input.now)
144
+ : '?';
145
+ let sessions;
146
+ if (workspace.sessions.length === 0) {
147
+ sessions = 'no sessions';
148
+ }
149
+ else {
150
+ const gone = transcriptGone(workspace, input);
151
+ const resumeId = workspace.resume_session?.id ?? null;
152
+ sessions = workspace.sessions
153
+ .map((session) => {
154
+ let text = `${session.agent_kind}/${session.status} ${formatAge(session.last_activity_at, input.now)}`;
155
+ if (session.dormant) {
156
+ text = `${text} dormant`;
157
+ }
158
+ if (gone && resumeId !== null && session.id === resumeId) {
159
+ text = `${text} transcript gone`;
160
+ }
161
+ return text;
162
+ })
163
+ .join(', ');
164
+ }
165
+ const line2 = ` ${displayPath(workspace, input)} git ${gitAge} ${sessions}`;
166
+ const resume = renderResume(workspace, {
167
+ localAlias: input.localAlias,
168
+ templates: input.templates,
169
+ transcriptGone: transcriptGone(workspace, input)
170
+ });
171
+ return [line1, line2, ` resume: ${resume}`];
172
+ }
173
+ function riskLabels(workspace, input) {
174
+ return displayRisks(workspace, input).map((risk) => riskText(risk, workspace));
175
+ }
176
+ function latestWorkspace(workspaces) {
177
+ let latest = null;
178
+ for (const workspace of workspaces) {
179
+ if (latest === null ||
180
+ Date.parse(workspace.last_activity_at) > Date.parse(latest.last_activity_at)) {
181
+ latest = workspace;
182
+ }
183
+ }
184
+ return latest;
185
+ }
186
+ function compactFeatureLines(workspaces, input) {
187
+ const withRisks = workspaces.filter((workspace) => displayRisks(workspace, input).length > 0).length;
188
+ const latest = latestWorkspace(workspaces);
189
+ const lines = [
190
+ ` ${workspaces.length} workspaces, ${withRisks} with risks (--expand lists them)`
191
+ ];
192
+ if (latest !== null) {
193
+ lines.push(` latest: ${latest.machine} ${latest.branch ?? '-'} ${displayPath(latest, input)} ${formatAge(latest.last_activity_at, input.now)}`, ` resume: ${renderResume(latest, {
194
+ localAlias: input.localAlias,
195
+ templates: input.templates,
196
+ transcriptGone: transcriptGone(latest, input)
197
+ })}`);
198
+ }
199
+ return lines;
200
+ }
201
+ function compactWorkspaceLine(workspace, input) {
202
+ const parts = [displayPath(workspace, input), workspace.machine, workspace.branch ?? '-'];
203
+ const risks = riskLabels(workspace, input);
204
+ if (risks.length > 0) {
205
+ parts.push(risks.join(' '));
206
+ }
207
+ return ` ${parts.join(' ')}`;
208
+ }
209
+ export function renderWhere(response, input) {
210
+ const lines = [];
211
+ for (const group of response.groups) {
212
+ if ('features' in group) {
213
+ if (group.features.length === 0) {
214
+ continue;
215
+ }
216
+ lines.push(`== ${group.label} (${group.features.length}) ==`);
217
+ for (const feature of group.features) {
218
+ const pullRequests = feature.pull_requests
219
+ .map((pullRequest) => ` PR:${pullRequest.state}`)
220
+ .join('');
221
+ lines.push(`${shortId(feature.id)} ${feature.title} [${feature.stage}]${pullRequests}`);
222
+ if (input.expand === true || feature.workspaces.length <= 1) {
223
+ for (const workspace of feature.workspaces) {
224
+ lines.push(...workspaceLines(workspace, input));
225
+ }
226
+ }
227
+ else {
228
+ lines.push(...compactFeatureLines(feature.workspaces, input));
229
+ }
230
+ }
231
+ }
232
+ else {
233
+ if (group.workspaces.length === 0) {
234
+ continue;
235
+ }
236
+ lines.push(`== ${group.label} (${group.workspaces.length}) ==`);
237
+ for (const workspace of group.workspaces) {
238
+ if (input.expand === true || group.workspaces.length <= 1) {
239
+ lines.push(...workspaceLines(workspace, input));
240
+ }
241
+ else {
242
+ lines.push(compactWorkspaceLine(workspace, input));
243
+ }
244
+ }
245
+ }
246
+ }
247
+ return lines;
248
+ }
249
+ export async function runWhere(options, deps) {
250
+ const query = {
251
+ view: options.actions ? 'actions' : 'stage',
252
+ ...(options.machine !== undefined ? { machine: options.machine } : {}),
253
+ ...(options.repo !== undefined ? { repo: options.repo } : {}),
254
+ includeReleased: options.all,
255
+ includeDone: options.all,
256
+ includeEnded: options.includeEnded
257
+ };
258
+ let response = await deps.client.where(query);
259
+ const { changed, failedIds } = await syncMissing(collectWorkspaces(response), {
260
+ localAlias: deps.localAlias,
261
+ exists: deps.exists,
262
+ patch: (id, missing) => deps.client.patchWorkspace(id, { missing })
263
+ });
264
+ if (changed) {
265
+ response = await deps.client.where(query);
266
+ }
267
+ const input = {
268
+ localAlias: deps.localAlias,
269
+ templates: deps.templates,
270
+ now: deps.now,
271
+ exists: deps.exists,
272
+ failedIds,
273
+ ...(deps.homeDir !== undefined ? { homeDir: deps.homeDir } : {}),
274
+ ...(options.expand === true ? { expand: true } : {})
275
+ };
276
+ return deps.json ? augmentWhere(response, input) : renderWhere(response, input);
277
+ }
278
+ export function registerWhereCommand(program) {
279
+ const command = usageExit(program
280
+ .command('where')
281
+ .description('Show features and workspaces grouped by stage')
282
+ .option('--actions', 'group by the next action instead of stage')
283
+ .option('--machine <alias>', 'only workspaces on this machine')
284
+ .option('--repo <remote>', 'only workspaces of this repo')
285
+ .option('--all', 'include released and done features')
286
+ .option('--include-ended', 'include ended sessions')
287
+ .option('--expand', 'list every workspace instead of a summary per feature')).action(async () => {
288
+ const opts = command.opts();
289
+ await runTrackingCommand(command, (context) => runWhere({
290
+ actions: opts.actions === true,
291
+ ...(opts.machine !== undefined ? { machine: opts.machine } : {}),
292
+ ...(opts.repo !== undefined ? { repo: opts.repo } : {}),
293
+ all: opts.all === true,
294
+ includeEnded: opts.includeEnded === true,
295
+ expand: opts.expand === true
296
+ }, {
297
+ client: context.client,
298
+ json: context.json,
299
+ localAlias: context.config.alias,
300
+ templates: context.config.resume,
301
+ now: new Date(),
302
+ exists: existsSync,
303
+ homeDir: context.homeDir
304
+ }));
305
+ });
306
+ }
@@ -0,0 +1,94 @@
1
+ // cli/src/tracking/workspace-commands.ts: pgm workspace prune.
2
+ import { existsSync } from 'node:fs';
3
+ import { shortId } from '../shared.js';
4
+ import { runTrackingCommand } from './api.js';
5
+ import { isIgnoredPath, usageExit } from './config.js';
6
+ /**
7
+ * Removes workspace registrations of this machine whose directory is gone
8
+ * (and, with `ignored`, those under ignore_paths). Features are never touched.
9
+ */
10
+ export async function runWorkspacePrune(options, deps) {
11
+ const { workspaces } = await deps.client.listWorkspaces({
12
+ machine: deps.config.alias
13
+ });
14
+ const result = {
15
+ removed: [],
16
+ failed: [],
17
+ dry_run: options.dryRun
18
+ };
19
+ for (const workspace of workspaces) {
20
+ if (workspace.machine !== deps.config.alias) {
21
+ continue;
22
+ }
23
+ let reason = null;
24
+ if (!deps.exists(workspace.path)) {
25
+ reason = 'missing';
26
+ }
27
+ else if (options.ignored === true &&
28
+ isIgnoredPath(workspace.path, deps.config.ignorePaths)) {
29
+ reason = 'ignored';
30
+ }
31
+ if (reason === null) {
32
+ continue;
33
+ }
34
+ if (!options.dryRun) {
35
+ try {
36
+ await deps.client.deleteWorkspace(workspace.id);
37
+ }
38
+ catch (error) {
39
+ result.failed.push({
40
+ id: workspace.id,
41
+ path: workspace.path,
42
+ error: error instanceof Error ? error.message : 'delete failed'
43
+ });
44
+ continue;
45
+ }
46
+ }
47
+ result.removed.push({
48
+ id: workspace.id,
49
+ path: workspace.path,
50
+ feature_id: workspace.feature_id,
51
+ reason
52
+ });
53
+ }
54
+ return result;
55
+ }
56
+ export function formatPrune(result) {
57
+ const verb = result.dry_run ? 'would remove' : 'removed';
58
+ const lines = result.removed.map((entry) => {
59
+ const feature = entry.feature_id !== null ? ` (feature ${shortId(entry.feature_id)})` : '';
60
+ return `${verb} ${entry.path} [${entry.reason}]${feature}`;
61
+ });
62
+ for (const entry of result.failed) {
63
+ lines.push(`failed ${entry.path}: ${entry.error}`);
64
+ }
65
+ if (lines.length === 0) {
66
+ lines.push('nothing to prune');
67
+ }
68
+ return lines;
69
+ }
70
+ export function registerWorkspaceCommands(program) {
71
+ const workspace = usageExit(program.command('workspace').description('Manage workspace registrations'));
72
+ const prune = usageExit(workspace
73
+ .command('prune')
74
+ .description('Remove registrations of this machine whose directory is gone (features are kept)')
75
+ .option('--dry-run', 'show what would be removed without deleting')
76
+ .option('--ignored', 'also remove registrations that match ignore_paths in ~/.pgmrc'));
77
+ prune.action(async () => {
78
+ const opts = prune.opts();
79
+ await runTrackingCommand(prune, async (context) => {
80
+ const result = await runWorkspacePrune({
81
+ dryRun: opts.dryRun === true,
82
+ ...(opts.ignored === true ? { ignored: true } : {})
83
+ }, {
84
+ client: context.client,
85
+ config: context.config,
86
+ exists: existsSync
87
+ });
88
+ if (result.failed.length > 0) {
89
+ process.exitCode = 1;
90
+ }
91
+ return context.json ? result : formatPrune(result);
92
+ });
93
+ });
94
+ }
@@ -0,0 +1,273 @@
1
+ // cli/src/tracking/worktree.ts: pgm worktree new — create a feature worktree.
2
+ import { existsSync } from 'node:fs';
3
+ import { realpath } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { AppError, ErrorCode } from '../errors.js';
7
+ import { readStdinText, shortId } from '../shared.js';
8
+ import { runTrackingCommand } from './api.js';
9
+ import { UsageError, slug, usageExit } from './config.js';
10
+ import { readBootId, resolveLocalWorkspace, runGit, writeMarker } from './local.js';
11
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const SHORT_ID_RE = /^[0-9a-f]{8}$/;
13
+ const GIT_TIMEOUT_MS = 10_000;
14
+ export function taskTitle(content) {
15
+ const first = (content ?? '').split('\n')[0] ?? '';
16
+ return first.trim().slice(0, 80);
17
+ }
18
+ export function matchFeature(arg, tasks) {
19
+ if (UUID_RE.test(arg)) {
20
+ return tasks.filter((task) => task.id === arg);
21
+ }
22
+ if (SHORT_ID_RE.test(arg)) {
23
+ return tasks.filter((task) => task.id.startsWith(arg));
24
+ }
25
+ const title = arg.trim().toLowerCase();
26
+ return tasks.filter((task) => task.isFeature && task.title.toLowerCase() === title);
27
+ }
28
+ export async function listTaskSummaries(listTasks) {
29
+ const summaries = [];
30
+ for (let offset = 0;; offset += 100) {
31
+ const page = await listTasks({ limit: 100, offset });
32
+ for (const item of page.items) {
33
+ summaries.push({
34
+ id: item.id,
35
+ title: taskTitle(item.content),
36
+ isFeature: item.metadata.kind === 'feature'
37
+ });
38
+ }
39
+ if (offset + 100 >= page.total) {
40
+ break;
41
+ }
42
+ }
43
+ return summaries;
44
+ }
45
+ export async function runWorktreeNew(feature, options, deps) {
46
+ const git = (args) => deps.run(args, { cwd: deps.cwd, timeoutMs: GIT_TIMEOUT_MS });
47
+ const toplevel = await git(['rev-parse', '--show-toplevel']);
48
+ if (toplevel.ok === false) {
49
+ throw new AppError(ErrorCode.VALIDATION, 'not in a git repository');
50
+ }
51
+ const commonDir = await git([
52
+ 'rev-parse',
53
+ '--path-format=absolute',
54
+ '--git-common-dir'
55
+ ]);
56
+ if (commonDir.ok === false) {
57
+ throw new AppError(ErrorCode.INTERNAL, 'could not determine the git common dir');
58
+ }
59
+ const repoRoot = path.dirname(commonDir.stdout);
60
+ const tasks = await listTaskSummaries(deps.listTasks);
61
+ const matches = matchFeature(feature, tasks);
62
+ if (matches.length > 1) {
63
+ for (const task of matches) {
64
+ deps.warn(`${shortId(task.id)} ${task.title}`);
65
+ }
66
+ throw new AppError(ErrorCode.CONFLICT, `multiple features match '${feature}'`);
67
+ }
68
+ let featureId;
69
+ let title;
70
+ if (matches.length === 1) {
71
+ const match = matches[0];
72
+ if (match === undefined) {
73
+ throw new AppError(ErrorCode.INTERNAL, 'no matching feature');
74
+ }
75
+ featureId = match.id;
76
+ title = match.title;
77
+ }
78
+ else {
79
+ if (!options.create) {
80
+ const confirmed = await deps.confirm(`Create feature "${feature}"?`);
81
+ if (!confirmed) {
82
+ throw new AppError(ErrorCode.NOT_FOUND, `no feature matches '${feature}'`);
83
+ }
84
+ }
85
+ const created = await deps.client.createFeature({ content: feature });
86
+ featureId = created.entity.id;
87
+ title = taskTitle(feature);
88
+ }
89
+ const short = shortId(featureId);
90
+ const name = slug(title);
91
+ const branch = `feat/${short}-${name}`;
92
+ const worktreePath = options.path !== undefined
93
+ ? path.resolve(deps.cwd, options.path)
94
+ : path.join(deps.config.worktreeRoot ?? `${repoRoot}.worktrees`, `${short}-${name}`);
95
+ if (deps.exists(worktreePath)) {
96
+ throw new UsageError(`worktree path already exists: ${worktreePath}`);
97
+ }
98
+ const branchExists = await git([
99
+ 'rev-parse',
100
+ '--verify',
101
+ '--quiet',
102
+ `refs/heads/${branch}`
103
+ ]);
104
+ if (branchExists.ok) {
105
+ throw new UsageError(`branch already exists: ${branch}`);
106
+ }
107
+ const originHead = await git([
108
+ 'rev-parse',
109
+ '--verify',
110
+ '--quiet',
111
+ 'refs/remotes/origin/HEAD'
112
+ ]);
113
+ const base = options.base ?? (originHead.ok ? 'origin/HEAD' : 'HEAD');
114
+ const add = await git([
115
+ 'worktree',
116
+ 'add',
117
+ '-b',
118
+ branch,
119
+ worktreePath,
120
+ base
121
+ ]);
122
+ if (add.ok === false) {
123
+ throw new AppError(ErrorCode.INTERNAL, `git worktree add failed${add.timedOut ? ' (timed out)' : ''} (branch: ${branch}, base: ${base})`);
124
+ }
125
+ await deps.writeMarker(worktreePath, featureId);
126
+ const local = await deps.resolveWorkspace(worktreePath);
127
+ if (local.kind === 'timeout') {
128
+ deps.warn('git timed out; skipping the workspace report');
129
+ }
130
+ else {
131
+ try {
132
+ await deps.client.report({
133
+ source: 'hook',
134
+ machine: {
135
+ alias: deps.config.alias,
136
+ hostname: deps.hostname,
137
+ boot_id: await deps.bootId()
138
+ },
139
+ workspace: local.workspace,
140
+ feature_id: featureId
141
+ });
142
+ }
143
+ catch (error) {
144
+ deps.warn(`report failed: ${error instanceof Error ? error.message : 'unknown error'}`);
145
+ }
146
+ }
147
+ return [worktreePath];
148
+ }
149
+ function plural(count, noun) {
150
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
151
+ }
152
+ /**
153
+ * Removes a linked worktree only when nothing would be lost: no changed files
154
+ * and no commits that no remote has. There is no force flag and `git worktree
155
+ * remove` runs without --force. The workspace registration goes afterwards.
156
+ */
157
+ export async function runWorktreeRemove(target, deps) {
158
+ const abs = path.resolve(deps.cwd, target);
159
+ if (!deps.exists(abs)) {
160
+ throw new UsageError(`no such directory: ${abs}`);
161
+ }
162
+ const local = await deps.resolveWorkspace(abs);
163
+ if (local.kind === 'timeout') {
164
+ throw new AppError(ErrorCode.INTERNAL, 'git timed out reading the worktree state; nothing was removed');
165
+ }
166
+ if (local.repoRoot === null) {
167
+ throw new UsageError(`not a git worktree: ${abs}`);
168
+ }
169
+ if (local.path !== (await deps.realpath(abs))) {
170
+ throw new UsageError(`not the root of a worktree: pass ${local.path} instead of ${abs}`);
171
+ }
172
+ if (local.repoRoot === local.path) {
173
+ throw new UsageError(`refusing to remove the main working tree: ${local.path}`);
174
+ }
175
+ const git = local.workspace.git ?? null;
176
+ const reasons = [];
177
+ if (git === null) {
178
+ reasons.push('git state is unknown');
179
+ }
180
+ else {
181
+ if (git.dirty_count > 0) {
182
+ reasons.push(plural(git.dirty_count, 'changed file'));
183
+ }
184
+ if (git.unpushed_count > 0) {
185
+ reasons.push(`${plural(git.unpushed_count, 'unpushed commit')}${git.has_upstream ? '' : ' (no upstream; no remote has them)'}`);
186
+ }
187
+ }
188
+ if (reasons.length > 0) {
189
+ throw new AppError(ErrorCode.CONFLICT, `refusing to remove ${local.path}: ${reasons.join(', ')}`);
190
+ }
191
+ const commonDir = await deps.run(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: local.path, timeoutMs: GIT_TIMEOUT_MS });
192
+ if (commonDir.ok === false) {
193
+ throw new AppError(ErrorCode.INTERNAL, 'could not determine the git common dir');
194
+ }
195
+ const removed = await deps.run(['--git-dir', commonDir.stdout, 'worktree', 'remove', local.path], { cwd: local.repoRoot, timeoutMs: GIT_TIMEOUT_MS });
196
+ if (removed.ok === false) {
197
+ throw new AppError(ErrorCode.INTERNAL, `git worktree remove failed${removed.timedOut ? ' (timed out)' : ''}; nothing was unregistered`);
198
+ }
199
+ let unregistered = false;
200
+ try {
201
+ const looked = await deps.client.lookup(deps.config.alias, local.path);
202
+ if (looked.workspace !== null && looked.workspace.path === local.path) {
203
+ await deps.client.deleteWorkspace(looked.workspace.id);
204
+ unregistered = true;
205
+ }
206
+ }
207
+ catch (error) {
208
+ deps.warn(`worktree removed, but unregistering failed: ${error instanceof Error ? error.message : 'unknown error'}; run \`pgm workspace prune\` later`);
209
+ }
210
+ return { path: local.path, removed: true, unregistered };
211
+ }
212
+ async function promptConfirm(question) {
213
+ process.stderr.write(`${question} [y/N] `);
214
+ const answer = (await readStdinText()).trim().toLowerCase();
215
+ return answer === 'y' || answer === 'yes';
216
+ }
217
+ export function registerWorktreeCommand(program) {
218
+ const worktree = usageExit(program
219
+ .command('worktree')
220
+ .description('Manage feature worktrees'));
221
+ const newCommand = usageExit(worktree
222
+ .command('new <feature>')
223
+ .description('Create a worktree for a feature (creating it with --create or when confirmed)')
224
+ .option('--base <ref>', 'base ref for the new branch (default: origin/HEAD or HEAD)')
225
+ .option('--path <dir>', 'worktree path (default: <worktree_root>/<8-hex>-<slug>)')
226
+ .option('--create', 'create the feature when it does not exist'));
227
+ const removeCommand = usageExit(worktree
228
+ .command('rm <path>')
229
+ .description('Remove a clean worktree and unregister it. Refuses when there are changed files or unpushed commits; there is no force flag'));
230
+ removeCommand.action(async (target) => {
231
+ await runTrackingCommand(removeCommand, async (context) => {
232
+ const result = await runWorktreeRemove(target, {
233
+ client: context.client,
234
+ config: context.config,
235
+ cwd: process.cwd(),
236
+ run: runGit,
237
+ exists: existsSync,
238
+ realpath: (p) => realpath(p),
239
+ resolveWorkspace: (cwd) => resolveLocalWorkspace(cwd, { budgetMs: 5000 }),
240
+ warn: (line) => process.stderr.write(`${line}\n`)
241
+ });
242
+ return context.json
243
+ ? result
244
+ : [
245
+ `removed ${result.path}${result.unregistered ? ' and unregistered the workspace' : ''}`
246
+ ];
247
+ });
248
+ });
249
+ newCommand.action(async (feature) => {
250
+ const opts = newCommand.opts();
251
+ await runTrackingCommand(newCommand, async (context) => {
252
+ const paths = await runWorktreeNew(feature, {
253
+ create: opts.create === true,
254
+ ...(opts.base !== undefined ? { base: opts.base } : {}),
255
+ ...(opts.path !== undefined ? { path: opts.path } : {})
256
+ }, {
257
+ client: context.client,
258
+ config: context.config,
259
+ cwd: process.cwd(),
260
+ hostname: os.hostname(),
261
+ listTasks: (input) => context.pgm.listTasks(input),
262
+ run: runGit,
263
+ exists: existsSync,
264
+ confirm: promptConfirm,
265
+ bootId: readBootId,
266
+ resolveWorkspace: (cwd) => resolveLocalWorkspace(cwd),
267
+ writeMarker,
268
+ warn: (line) => process.stderr.write(`${line}\n`)
269
+ });
270
+ return context.json ? { path: paths[0] ?? '' } : paths;
271
+ });
272
+ });
273
+ }