@ivotoby/postgram-cli 1.39.0 → 1.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -2
- package/dist/client.js +3 -0
- package/dist/pgm.js +3 -0
- package/dist/tracking/api.js +3 -0
- package/dist/tracking/config.js +34 -1
- package/dist/tracking/feature-commands.js +73 -0
- package/dist/tracking/feature-sync.js +159 -0
- package/dist/tracking/here.js +21 -1
- package/dist/tracking/scan.js +399 -71
- package/dist/tracking/where.js +87 -12
- package/dist/tracking/workspace-commands.js +94 -0
- package/dist/tracking/worktree.js +86 -0
- package/package.json +1 -1
package/dist/tracking/where.js
CHANGED
|
@@ -105,9 +105,33 @@ export function augmentWhere(response, input) {
|
|
|
105
105
|
});
|
|
106
106
|
return { ...response, groups };
|
|
107
107
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
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}`;
|
|
111
135
|
}
|
|
112
136
|
function workspaceLines(workspace, input) {
|
|
113
137
|
const risks = displayRisks(workspace, input).map((risk) => riskText(risk, workspace));
|
|
@@ -138,13 +162,49 @@ function workspaceLines(workspace, input) {
|
|
|
138
162
|
})
|
|
139
163
|
.join(', ');
|
|
140
164
|
}
|
|
141
|
-
const line2 = ` ${workspace
|
|
165
|
+
const line2 = ` ${displayPath(workspace, input)} git ${gitAge} ${sessions}`;
|
|
142
166
|
const resume = renderResume(workspace, {
|
|
143
167
|
localAlias: input.localAlias,
|
|
144
168
|
templates: input.templates,
|
|
145
169
|
transcriptGone: transcriptGone(workspace, input)
|
|
146
170
|
});
|
|
147
|
-
return [
|
|
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(' ')}`;
|
|
148
208
|
}
|
|
149
209
|
export function renderWhere(response, input) {
|
|
150
210
|
const lines = [];
|
|
@@ -159,8 +219,13 @@ export function renderWhere(response, input) {
|
|
|
159
219
|
.map((pullRequest) => ` PR:${pullRequest.state}`)
|
|
160
220
|
.join('');
|
|
161
221
|
lines.push(`${shortId(feature.id)} ${feature.title} [${feature.stage}]${pullRequests}`);
|
|
162
|
-
|
|
163
|
-
|
|
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));
|
|
164
229
|
}
|
|
165
230
|
}
|
|
166
231
|
}
|
|
@@ -170,7 +235,12 @@ export function renderWhere(response, input) {
|
|
|
170
235
|
}
|
|
171
236
|
lines.push(`== ${group.label} (${group.workspaces.length}) ==`);
|
|
172
237
|
for (const workspace of group.workspaces) {
|
|
173
|
-
|
|
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
|
+
}
|
|
174
244
|
}
|
|
175
245
|
}
|
|
176
246
|
}
|
|
@@ -199,7 +269,9 @@ export async function runWhere(options, deps) {
|
|
|
199
269
|
templates: deps.templates,
|
|
200
270
|
now: deps.now,
|
|
201
271
|
exists: deps.exists,
|
|
202
|
-
failedIds
|
|
272
|
+
failedIds,
|
|
273
|
+
...(deps.homeDir !== undefined ? { homeDir: deps.homeDir } : {}),
|
|
274
|
+
...(options.expand === true ? { expand: true } : {})
|
|
203
275
|
};
|
|
204
276
|
return deps.json ? augmentWhere(response, input) : renderWhere(response, input);
|
|
205
277
|
}
|
|
@@ -211,21 +283,24 @@ export function registerWhereCommand(program) {
|
|
|
211
283
|
.option('--machine <alias>', 'only workspaces on this machine')
|
|
212
284
|
.option('--repo <remote>', 'only workspaces of this repo')
|
|
213
285
|
.option('--all', 'include released and done features')
|
|
214
|
-
.option('--include-ended', 'include ended sessions')
|
|
286
|
+
.option('--include-ended', 'include ended sessions')
|
|
287
|
+
.option('--expand', 'list every workspace instead of a summary per feature')).action(async () => {
|
|
215
288
|
const opts = command.opts();
|
|
216
289
|
await runTrackingCommand(command, (context) => runWhere({
|
|
217
290
|
actions: opts.actions === true,
|
|
218
291
|
...(opts.machine !== undefined ? { machine: opts.machine } : {}),
|
|
219
292
|
...(opts.repo !== undefined ? { repo: opts.repo } : {}),
|
|
220
293
|
all: opts.all === true,
|
|
221
|
-
includeEnded: opts.includeEnded === true
|
|
294
|
+
includeEnded: opts.includeEnded === true,
|
|
295
|
+
expand: opts.expand === true
|
|
222
296
|
}, {
|
|
223
297
|
client: context.client,
|
|
224
298
|
json: context.json,
|
|
225
299
|
localAlias: context.config.alias,
|
|
226
300
|
templates: context.config.resume,
|
|
227
301
|
now: new Date(),
|
|
228
|
-
exists: existsSync
|
|
302
|
+
exists: existsSync,
|
|
303
|
+
homeDir: context.homeDir
|
|
229
304
|
}));
|
|
230
305
|
});
|
|
231
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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// cli/src/tracking/worktree.ts: pgm worktree new — create a feature worktree.
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
+
import { realpath } from 'node:fs/promises';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { AppError, ErrorCode } from '../errors.js';
|
|
@@ -145,6 +146,69 @@ export async function runWorktreeNew(feature, options, deps) {
|
|
|
145
146
|
}
|
|
146
147
|
return [worktreePath];
|
|
147
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
|
+
}
|
|
148
212
|
async function promptConfirm(question) {
|
|
149
213
|
process.stderr.write(`${question} [y/N] `);
|
|
150
214
|
const answer = (await readStdinText()).trim().toLowerCase();
|
|
@@ -160,6 +224,28 @@ export function registerWorktreeCommand(program) {
|
|
|
160
224
|
.option('--base <ref>', 'base ref for the new branch (default: origin/HEAD or HEAD)')
|
|
161
225
|
.option('--path <dir>', 'worktree path (default: <worktree_root>/<8-hex>-<slug>)')
|
|
162
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
|
+
});
|
|
163
249
|
newCommand.action(async (feature) => {
|
|
164
250
|
const opts = newCommand.opts();
|
|
165
251
|
await runTrackingCommand(newCommand, async (context) => {
|