@ivotoby/postgram-cli 1.38.2 → 1.39.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/README.md +26 -0
- package/dist/client.js +4 -1
- package/dist/pgm.js +12 -0
- package/dist/tracking/api.js +139 -0
- package/dist/tracking/config.js +148 -0
- package/dist/tracking/feature-commands.js +228 -0
- package/dist/tracking/here.js +267 -0
- package/dist/tracking/hooks.js +375 -0
- package/dist/tracking/local.js +175 -0
- package/dist/tracking/resume.js +43 -0
- package/dist/tracking/scan-discovery.js +295 -0
- package/dist/tracking/scan.js +261 -0
- package/dist/tracking/types.js +14 -0
- package/dist/tracking/where.js +231 -0
- package/dist/tracking/worktree.js +187 -0
- package/integrations/pi/postgram.ts +73 -0
- package/package.json +2 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// cli/src/tracking/scan.ts: pgm scan — discover worktrees and record their features.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import { shortId } from '../shared.js';
|
|
7
|
+
import { runTrackingCommand } from './api.js';
|
|
8
|
+
import { slug, usageExit, UsageError } from './config.js';
|
|
9
|
+
import { readMarker, resolveLocalWorkspace, writeMarker } from './local.js';
|
|
10
|
+
import { discoverWorktrees, findClaudeSessions, findPiSessions } from './scan-discovery.js';
|
|
11
|
+
import { syncMissing } from './where.js';
|
|
12
|
+
export const BRANCH_SHORT_ID = /(?:^|\/)([0-9a-f]{8})(?:-|$)/;
|
|
13
|
+
const DEFAULT_DEPTH = 3;
|
|
14
|
+
function parseDepth(raw) {
|
|
15
|
+
if (raw === undefined) {
|
|
16
|
+
return DEFAULT_DEPTH;
|
|
17
|
+
}
|
|
18
|
+
if (!/^[1-9][0-9]*$/.test(raw)) {
|
|
19
|
+
throw new UsageError(`invalid --depth '${raw}': must be a positive integer`);
|
|
20
|
+
}
|
|
21
|
+
return Number.parseInt(raw, 10);
|
|
22
|
+
}
|
|
23
|
+
function withoutFeatPrefix(branch) {
|
|
24
|
+
return branch.startsWith('feat/') ? branch.slice('feat/'.length) : branch;
|
|
25
|
+
}
|
|
26
|
+
function newFeatureTitle(branch, dirName) {
|
|
27
|
+
if (branch === null || branch === '') {
|
|
28
|
+
return dirName;
|
|
29
|
+
}
|
|
30
|
+
return withoutFeatPrefix(branch).replaceAll('-', ' ');
|
|
31
|
+
}
|
|
32
|
+
async function buildProposals(worktree, deps, tasks, yesExact) {
|
|
33
|
+
const proposals = [];
|
|
34
|
+
const titleFor = (id) => tasks.find((task) => task.id === id)?.title ?? '';
|
|
35
|
+
const marker = await deps.readMarker(worktree.path);
|
|
36
|
+
if (marker !== null) {
|
|
37
|
+
proposals.push({ kind: 'marker', featureId: marker, title: titleFor(marker) });
|
|
38
|
+
}
|
|
39
|
+
if (worktree.branch !== null) {
|
|
40
|
+
const match = BRANCH_SHORT_ID.exec(worktree.branch);
|
|
41
|
+
const short = match?.[1];
|
|
42
|
+
if (short !== undefined) {
|
|
43
|
+
const hits = tasks.filter((task) => task.id.startsWith(short));
|
|
44
|
+
if (hits.length === 1) {
|
|
45
|
+
const hit = hits[0];
|
|
46
|
+
if (hit !== undefined) {
|
|
47
|
+
proposals.push({ kind: 'short-id', featureId: hit.id, title: hit.title });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!yesExact) {
|
|
52
|
+
let hits = [];
|
|
53
|
+
try {
|
|
54
|
+
hits = await deps.search(slug(withoutFeatPrefix(worktree.branch)));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
hits = [];
|
|
58
|
+
}
|
|
59
|
+
for (const hit of hits.slice(0, 3)) {
|
|
60
|
+
proposals.push({ kind: 'search', featureId: hit.id, title: hit.title });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return proposals;
|
|
65
|
+
}
|
|
66
|
+
function sessionsFor(worktree, claude, pi) {
|
|
67
|
+
return [
|
|
68
|
+
...(claude.get(worktree.path) ?? []),
|
|
69
|
+
...(pi.get(worktree.path) ?? [])
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
export async function runScan(options, deps) {
|
|
73
|
+
const roots = options.root.length > 0
|
|
74
|
+
? options.root.map((root) => path.resolve(root))
|
|
75
|
+
: deps.config.scanRoots;
|
|
76
|
+
if (roots.length === 0) {
|
|
77
|
+
throw new UsageError('no scan roots: pass --root <dir> or set scan_roots in ~/.pgmrc');
|
|
78
|
+
}
|
|
79
|
+
const depth = parseDepth(options.depth);
|
|
80
|
+
const knownResponse = await deps.client.listWorkspaces({
|
|
81
|
+
machine: deps.config.alias
|
|
82
|
+
});
|
|
83
|
+
if (!options.dryRun) {
|
|
84
|
+
await syncMissing(knownResponse.workspaces, {
|
|
85
|
+
localAlias: deps.config.alias,
|
|
86
|
+
exists: deps.exists,
|
|
87
|
+
patch: (id, missing) => deps.client.patchWorkspace(id, { missing })
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const known = new Set(knownResponse.workspaces.map((workspace) => workspace.path));
|
|
91
|
+
const worktrees = await deps.discover(roots, depth);
|
|
92
|
+
const paths = worktrees.map((worktree) => worktree.path);
|
|
93
|
+
const claudeDir = deps.env.CLAUDE_CONFIG_DIR ?? path.join(deps.homeDir, '.claude');
|
|
94
|
+
const piDir = deps.env.PI_CODING_AGENT_DIR ?? path.join(deps.homeDir, '.pi', 'agent');
|
|
95
|
+
const claude = await deps.claudeSessions(paths, claudeDir);
|
|
96
|
+
for (const note of claude.notes) {
|
|
97
|
+
deps.info(note);
|
|
98
|
+
}
|
|
99
|
+
const pi = await deps.piSessions(paths, piDir);
|
|
100
|
+
const tasks = await deps.tasks();
|
|
101
|
+
const lines = [];
|
|
102
|
+
for (const worktree of worktrees) {
|
|
103
|
+
if (known.has(worktree.path)) {
|
|
104
|
+
lines.push(`known ${worktree.path}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const proposals = await buildProposals(worktree, deps, tasks, options.yesExact);
|
|
108
|
+
let featureId = null;
|
|
109
|
+
if (options.yesExact) {
|
|
110
|
+
const first = proposals[0];
|
|
111
|
+
if (first !== undefined &&
|
|
112
|
+
(first.kind === 'marker' || first.kind === 'short-id')) {
|
|
113
|
+
featureId = first.featureId;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
lines.push(`skipped ${worktree.path}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
deps.info(`${worktree.path}${worktree.branch !== null ? ` (${worktree.branch})` : ''}`);
|
|
122
|
+
proposals.forEach((proposal, index) => {
|
|
123
|
+
deps.info(` ${index + 1}. ${proposal.kind}: ${proposal.title !== '' ? proposal.title : proposal.featureId}`);
|
|
124
|
+
});
|
|
125
|
+
const answer = (await deps.ask('[a]ccept, [1-3] pick, [n]ew feature, [s]kip'))
|
|
126
|
+
.trim()
|
|
127
|
+
.toLowerCase();
|
|
128
|
+
if (answer === 'a') {
|
|
129
|
+
const first = proposals[0];
|
|
130
|
+
if (first !== undefined) {
|
|
131
|
+
featureId = first.featureId;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else if (/^[1-9]$/.test(answer)) {
|
|
135
|
+
const picked = proposals[Number.parseInt(answer, 10) - 1];
|
|
136
|
+
if (picked !== undefined) {
|
|
137
|
+
featureId = picked.featureId;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (answer === 'n') {
|
|
141
|
+
const created = await deps.client.createFeature({
|
|
142
|
+
content: newFeatureTitle(worktree.branch, path.basename(worktree.path))
|
|
143
|
+
});
|
|
144
|
+
featureId = created.entity.id;
|
|
145
|
+
}
|
|
146
|
+
if (featureId === null) {
|
|
147
|
+
lines.push(`skipped ${worktree.path}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const id = featureId;
|
|
152
|
+
if (options.dryRun) {
|
|
153
|
+
lines.push(`would record ${worktree.path} → ${shortId(id)}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const local = await deps.resolveWorkspace(worktree.path);
|
|
157
|
+
if (local.kind === 'timeout') {
|
|
158
|
+
lines.push(`skipped ${worktree.path} (git timeout)`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const body = {
|
|
162
|
+
source: 'scan',
|
|
163
|
+
machine: {
|
|
164
|
+
alias: deps.config.alias,
|
|
165
|
+
hostname: deps.hostname,
|
|
166
|
+
boot_id: null
|
|
167
|
+
},
|
|
168
|
+
workspace: local.workspace,
|
|
169
|
+
feature_id: id,
|
|
170
|
+
sessions: sessionsFor(worktree, claude.sessions, pi)
|
|
171
|
+
};
|
|
172
|
+
await deps.client.report(body);
|
|
173
|
+
if ((await deps.readMarker(worktree.path)) !== id) {
|
|
174
|
+
await deps.writeMarker(worktree.path, id);
|
|
175
|
+
}
|
|
176
|
+
lines.push(`recorded ${worktree.path} → ${shortId(id)}`);
|
|
177
|
+
}
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
function firstLineTitle(content) {
|
|
181
|
+
return (content ?? '').split('\n')[0]?.trim() ?? '';
|
|
182
|
+
}
|
|
183
|
+
function collectRoot(value, previous) {
|
|
184
|
+
return [...previous, value];
|
|
185
|
+
}
|
|
186
|
+
function askLine(question) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
let settled = false;
|
|
189
|
+
const rl = readline.createInterface({
|
|
190
|
+
input: process.stdin,
|
|
191
|
+
output: process.stderr
|
|
192
|
+
});
|
|
193
|
+
const finish = (answer) => {
|
|
194
|
+
if (settled) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
settled = true;
|
|
198
|
+
rl.close();
|
|
199
|
+
resolve(answer.trim());
|
|
200
|
+
};
|
|
201
|
+
rl.question(question, (answer) => finish(answer));
|
|
202
|
+
rl.on('close', () => finish(''));
|
|
203
|
+
rl.on('SIGINT', () => finish(''));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
export function registerScanCommand(program) {
|
|
207
|
+
const command = usageExit(program
|
|
208
|
+
.command('scan')
|
|
209
|
+
.description('Discover worktrees under the scan roots and record their features')
|
|
210
|
+
.option('--root <dir>', 'scan root (repeatable)', collectRoot, [])
|
|
211
|
+
.option('--depth <n>', `directory depth below each root (default ${DEFAULT_DEPTH})`)
|
|
212
|
+
.option('--yes-exact', 'accept marker and short-id proposals, skip the rest')
|
|
213
|
+
.option('--dry-run', 'show what would be recorded without sending')).action(async () => {
|
|
214
|
+
const opts = command.opts();
|
|
215
|
+
await runTrackingCommand(command, (context) => runScan({
|
|
216
|
+
root: opts.root ?? [],
|
|
217
|
+
...(opts.depth !== undefined ? { depth: opts.depth } : {}),
|
|
218
|
+
yesExact: opts.yesExact === true,
|
|
219
|
+
dryRun: opts.dryRun === true
|
|
220
|
+
}, {
|
|
221
|
+
client: context.client,
|
|
222
|
+
config: context.config,
|
|
223
|
+
hostname: os.hostname(),
|
|
224
|
+
env: process.env,
|
|
225
|
+
homeDir: context.homeDir,
|
|
226
|
+
tasks: async () => {
|
|
227
|
+
const items = [];
|
|
228
|
+
for (let offset = 0;; offset += 100) {
|
|
229
|
+
const page = await context.pgm.listTasks({ limit: 100, offset });
|
|
230
|
+
for (const item of page.items) {
|
|
231
|
+
items.push({ id: item.id, title: firstLineTitle(item.content) });
|
|
232
|
+
}
|
|
233
|
+
if (page.items.length < 100) {
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return items;
|
|
238
|
+
},
|
|
239
|
+
search: async (query) => {
|
|
240
|
+
const response = await context.pgm.searchEntities({
|
|
241
|
+
query,
|
|
242
|
+
type: 'task',
|
|
243
|
+
limit: 3
|
|
244
|
+
});
|
|
245
|
+
return response.results.map((result) => ({
|
|
246
|
+
id: result.entity.id,
|
|
247
|
+
title: firstLineTitle(result.entity.content)
|
|
248
|
+
}));
|
|
249
|
+
},
|
|
250
|
+
ask: askLine,
|
|
251
|
+
discover: discoverWorktrees,
|
|
252
|
+
claudeSessions: findClaudeSessions,
|
|
253
|
+
piSessions: findPiSessions,
|
|
254
|
+
resolveWorkspace: (p) => resolveLocalWorkspace(p),
|
|
255
|
+
readMarker,
|
|
256
|
+
writeMarker: (worktree, featureId) => writeMarker(worktree, featureId, new Date()),
|
|
257
|
+
exists: existsSync,
|
|
258
|
+
info: (line) => process.stderr.write(`${line}\n`)
|
|
259
|
+
}));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
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 CUT_WIDTH = 60;
|
|
109
|
+
function cut(line) {
|
|
110
|
+
return line.length > CUT_WIDTH ? `${line.slice(0, CUT_WIDTH - 1)}…` : line;
|
|
111
|
+
}
|
|
112
|
+
function workspaceLines(workspace, input) {
|
|
113
|
+
const risks = displayRisks(workspace, input).map((risk) => riskText(risk, workspace));
|
|
114
|
+
let line1 = ` ${workspace.machine} ${workspace.branch ?? '-'}`;
|
|
115
|
+
if (risks.length > 0) {
|
|
116
|
+
line1 = `${line1} ${risks.join(' ')}`;
|
|
117
|
+
}
|
|
118
|
+
const gitAge = workspace.git_state_at !== null
|
|
119
|
+
? formatAge(workspace.git_state_at, input.now)
|
|
120
|
+
: '?';
|
|
121
|
+
let sessions;
|
|
122
|
+
if (workspace.sessions.length === 0) {
|
|
123
|
+
sessions = 'no sessions';
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const gone = transcriptGone(workspace, input);
|
|
127
|
+
const resumeId = workspace.resume_session?.id ?? null;
|
|
128
|
+
sessions = workspace.sessions
|
|
129
|
+
.map((session) => {
|
|
130
|
+
let text = `${session.agent_kind}/${session.status} ${formatAge(session.last_activity_at, input.now)}`;
|
|
131
|
+
if (session.dormant) {
|
|
132
|
+
text = `${text} dormant`;
|
|
133
|
+
}
|
|
134
|
+
if (gone && resumeId !== null && session.id === resumeId) {
|
|
135
|
+
text = `${text} transcript gone`;
|
|
136
|
+
}
|
|
137
|
+
return text;
|
|
138
|
+
})
|
|
139
|
+
.join(', ');
|
|
140
|
+
}
|
|
141
|
+
const line2 = ` ${workspace.path} git ${gitAge} ${sessions}`;
|
|
142
|
+
const resume = renderResume(workspace, {
|
|
143
|
+
localAlias: input.localAlias,
|
|
144
|
+
templates: input.templates,
|
|
145
|
+
transcriptGone: transcriptGone(workspace, input)
|
|
146
|
+
});
|
|
147
|
+
return [cut(line1), cut(line2), ` resume: ${resume}`];
|
|
148
|
+
}
|
|
149
|
+
export function renderWhere(response, input) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
for (const group of response.groups) {
|
|
152
|
+
if ('features' in group) {
|
|
153
|
+
if (group.features.length === 0) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
lines.push(`== ${group.label} (${group.features.length}) ==`);
|
|
157
|
+
for (const feature of group.features) {
|
|
158
|
+
const pullRequests = feature.pull_requests
|
|
159
|
+
.map((pullRequest) => ` PR:${pullRequest.state}`)
|
|
160
|
+
.join('');
|
|
161
|
+
lines.push(`${shortId(feature.id)} ${feature.title} [${feature.stage}]${pullRequests}`);
|
|
162
|
+
for (const workspace of feature.workspaces) {
|
|
163
|
+
lines.push(...workspaceLines(workspace, input));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
if (group.workspaces.length === 0) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
lines.push(`== ${group.label} (${group.workspaces.length}) ==`);
|
|
172
|
+
for (const workspace of group.workspaces) {
|
|
173
|
+
lines.push(...workspaceLines(workspace, input));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return lines;
|
|
178
|
+
}
|
|
179
|
+
export async function runWhere(options, deps) {
|
|
180
|
+
const query = {
|
|
181
|
+
view: options.actions ? 'actions' : 'stage',
|
|
182
|
+
...(options.machine !== undefined ? { machine: options.machine } : {}),
|
|
183
|
+
...(options.repo !== undefined ? { repo: options.repo } : {}),
|
|
184
|
+
includeReleased: options.all,
|
|
185
|
+
includeDone: options.all,
|
|
186
|
+
includeEnded: options.includeEnded
|
|
187
|
+
};
|
|
188
|
+
let response = await deps.client.where(query);
|
|
189
|
+
const { changed, failedIds } = await syncMissing(collectWorkspaces(response), {
|
|
190
|
+
localAlias: deps.localAlias,
|
|
191
|
+
exists: deps.exists,
|
|
192
|
+
patch: (id, missing) => deps.client.patchWorkspace(id, { missing })
|
|
193
|
+
});
|
|
194
|
+
if (changed) {
|
|
195
|
+
response = await deps.client.where(query);
|
|
196
|
+
}
|
|
197
|
+
const input = {
|
|
198
|
+
localAlias: deps.localAlias,
|
|
199
|
+
templates: deps.templates,
|
|
200
|
+
now: deps.now,
|
|
201
|
+
exists: deps.exists,
|
|
202
|
+
failedIds
|
|
203
|
+
};
|
|
204
|
+
return deps.json ? augmentWhere(response, input) : renderWhere(response, input);
|
|
205
|
+
}
|
|
206
|
+
export function registerWhereCommand(program) {
|
|
207
|
+
const command = usageExit(program
|
|
208
|
+
.command('where')
|
|
209
|
+
.description('Show features and workspaces grouped by stage')
|
|
210
|
+
.option('--actions', 'group by the next action instead of stage')
|
|
211
|
+
.option('--machine <alias>', 'only workspaces on this machine')
|
|
212
|
+
.option('--repo <remote>', 'only workspaces of this repo')
|
|
213
|
+
.option('--all', 'include released and done features')
|
|
214
|
+
.option('--include-ended', 'include ended sessions')).action(async () => {
|
|
215
|
+
const opts = command.opts();
|
|
216
|
+
await runTrackingCommand(command, (context) => runWhere({
|
|
217
|
+
actions: opts.actions === true,
|
|
218
|
+
...(opts.machine !== undefined ? { machine: opts.machine } : {}),
|
|
219
|
+
...(opts.repo !== undefined ? { repo: opts.repo } : {}),
|
|
220
|
+
all: opts.all === true,
|
|
221
|
+
includeEnded: opts.includeEnded === true
|
|
222
|
+
}, {
|
|
223
|
+
client: context.client,
|
|
224
|
+
json: context.json,
|
|
225
|
+
localAlias: context.config.alias,
|
|
226
|
+
templates: context.config.resume,
|
|
227
|
+
now: new Date(),
|
|
228
|
+
exists: existsSync
|
|
229
|
+
}));
|
|
230
|
+
});
|
|
231
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// cli/src/tracking/worktree.ts: pgm worktree new — create a feature worktree.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AppError, ErrorCode } from '../errors.js';
|
|
6
|
+
import { readStdinText, shortId } from '../shared.js';
|
|
7
|
+
import { runTrackingCommand } from './api.js';
|
|
8
|
+
import { UsageError, slug, usageExit } from './config.js';
|
|
9
|
+
import { readBootId, resolveLocalWorkspace, runGit, writeMarker } from './local.js';
|
|
10
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
const SHORT_ID_RE = /^[0-9a-f]{8}$/;
|
|
12
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
13
|
+
export function taskTitle(content) {
|
|
14
|
+
const first = (content ?? '').split('\n')[0] ?? '';
|
|
15
|
+
return first.trim().slice(0, 80);
|
|
16
|
+
}
|
|
17
|
+
export function matchFeature(arg, tasks) {
|
|
18
|
+
if (UUID_RE.test(arg)) {
|
|
19
|
+
return tasks.filter((task) => task.id === arg);
|
|
20
|
+
}
|
|
21
|
+
if (SHORT_ID_RE.test(arg)) {
|
|
22
|
+
return tasks.filter((task) => task.id.startsWith(arg));
|
|
23
|
+
}
|
|
24
|
+
const title = arg.trim().toLowerCase();
|
|
25
|
+
return tasks.filter((task) => task.isFeature && task.title.toLowerCase() === title);
|
|
26
|
+
}
|
|
27
|
+
export async function listTaskSummaries(listTasks) {
|
|
28
|
+
const summaries = [];
|
|
29
|
+
for (let offset = 0;; offset += 100) {
|
|
30
|
+
const page = await listTasks({ limit: 100, offset });
|
|
31
|
+
for (const item of page.items) {
|
|
32
|
+
summaries.push({
|
|
33
|
+
id: item.id,
|
|
34
|
+
title: taskTitle(item.content),
|
|
35
|
+
isFeature: item.metadata.kind === 'feature'
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (offset + 100 >= page.total) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return summaries;
|
|
43
|
+
}
|
|
44
|
+
export async function runWorktreeNew(feature, options, deps) {
|
|
45
|
+
const git = (args) => deps.run(args, { cwd: deps.cwd, timeoutMs: GIT_TIMEOUT_MS });
|
|
46
|
+
const toplevel = await git(['rev-parse', '--show-toplevel']);
|
|
47
|
+
if (toplevel.ok === false) {
|
|
48
|
+
throw new AppError(ErrorCode.VALIDATION, 'not in a git repository');
|
|
49
|
+
}
|
|
50
|
+
const commonDir = await git([
|
|
51
|
+
'rev-parse',
|
|
52
|
+
'--path-format=absolute',
|
|
53
|
+
'--git-common-dir'
|
|
54
|
+
]);
|
|
55
|
+
if (commonDir.ok === false) {
|
|
56
|
+
throw new AppError(ErrorCode.INTERNAL, 'could not determine the git common dir');
|
|
57
|
+
}
|
|
58
|
+
const repoRoot = path.dirname(commonDir.stdout);
|
|
59
|
+
const tasks = await listTaskSummaries(deps.listTasks);
|
|
60
|
+
const matches = matchFeature(feature, tasks);
|
|
61
|
+
if (matches.length > 1) {
|
|
62
|
+
for (const task of matches) {
|
|
63
|
+
deps.warn(`${shortId(task.id)} ${task.title}`);
|
|
64
|
+
}
|
|
65
|
+
throw new AppError(ErrorCode.CONFLICT, `multiple features match '${feature}'`);
|
|
66
|
+
}
|
|
67
|
+
let featureId;
|
|
68
|
+
let title;
|
|
69
|
+
if (matches.length === 1) {
|
|
70
|
+
const match = matches[0];
|
|
71
|
+
if (match === undefined) {
|
|
72
|
+
throw new AppError(ErrorCode.INTERNAL, 'no matching feature');
|
|
73
|
+
}
|
|
74
|
+
featureId = match.id;
|
|
75
|
+
title = match.title;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
if (!options.create) {
|
|
79
|
+
const confirmed = await deps.confirm(`Create feature "${feature}"?`);
|
|
80
|
+
if (!confirmed) {
|
|
81
|
+
throw new AppError(ErrorCode.NOT_FOUND, `no feature matches '${feature}'`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const created = await deps.client.createFeature({ content: feature });
|
|
85
|
+
featureId = created.entity.id;
|
|
86
|
+
title = taskTitle(feature);
|
|
87
|
+
}
|
|
88
|
+
const short = shortId(featureId);
|
|
89
|
+
const name = slug(title);
|
|
90
|
+
const branch = `feat/${short}-${name}`;
|
|
91
|
+
const worktreePath = options.path !== undefined
|
|
92
|
+
? path.resolve(deps.cwd, options.path)
|
|
93
|
+
: path.join(deps.config.worktreeRoot ?? `${repoRoot}.worktrees`, `${short}-${name}`);
|
|
94
|
+
if (deps.exists(worktreePath)) {
|
|
95
|
+
throw new UsageError(`worktree path already exists: ${worktreePath}`);
|
|
96
|
+
}
|
|
97
|
+
const branchExists = await git([
|
|
98
|
+
'rev-parse',
|
|
99
|
+
'--verify',
|
|
100
|
+
'--quiet',
|
|
101
|
+
`refs/heads/${branch}`
|
|
102
|
+
]);
|
|
103
|
+
if (branchExists.ok) {
|
|
104
|
+
throw new UsageError(`branch already exists: ${branch}`);
|
|
105
|
+
}
|
|
106
|
+
const originHead = await git([
|
|
107
|
+
'rev-parse',
|
|
108
|
+
'--verify',
|
|
109
|
+
'--quiet',
|
|
110
|
+
'refs/remotes/origin/HEAD'
|
|
111
|
+
]);
|
|
112
|
+
const base = options.base ?? (originHead.ok ? 'origin/HEAD' : 'HEAD');
|
|
113
|
+
const add = await git([
|
|
114
|
+
'worktree',
|
|
115
|
+
'add',
|
|
116
|
+
'-b',
|
|
117
|
+
branch,
|
|
118
|
+
worktreePath,
|
|
119
|
+
base
|
|
120
|
+
]);
|
|
121
|
+
if (add.ok === false) {
|
|
122
|
+
throw new AppError(ErrorCode.INTERNAL, `git worktree add failed${add.timedOut ? ' (timed out)' : ''} (branch: ${branch}, base: ${base})`);
|
|
123
|
+
}
|
|
124
|
+
await deps.writeMarker(worktreePath, featureId);
|
|
125
|
+
const local = await deps.resolveWorkspace(worktreePath);
|
|
126
|
+
if (local.kind === 'timeout') {
|
|
127
|
+
deps.warn('git timed out; skipping the workspace report');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
try {
|
|
131
|
+
await deps.client.report({
|
|
132
|
+
source: 'hook',
|
|
133
|
+
machine: {
|
|
134
|
+
alias: deps.config.alias,
|
|
135
|
+
hostname: deps.hostname,
|
|
136
|
+
boot_id: await deps.bootId()
|
|
137
|
+
},
|
|
138
|
+
workspace: local.workspace,
|
|
139
|
+
feature_id: featureId
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
deps.warn(`report failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [worktreePath];
|
|
147
|
+
}
|
|
148
|
+
async function promptConfirm(question) {
|
|
149
|
+
process.stderr.write(`${question} [y/N] `);
|
|
150
|
+
const answer = (await readStdinText()).trim().toLowerCase();
|
|
151
|
+
return answer === 'y' || answer === 'yes';
|
|
152
|
+
}
|
|
153
|
+
export function registerWorktreeCommand(program) {
|
|
154
|
+
const worktree = usageExit(program
|
|
155
|
+
.command('worktree')
|
|
156
|
+
.description('Manage feature worktrees'));
|
|
157
|
+
const newCommand = usageExit(worktree
|
|
158
|
+
.command('new <feature>')
|
|
159
|
+
.description('Create a worktree for a feature (creating it with --create or when confirmed)')
|
|
160
|
+
.option('--base <ref>', 'base ref for the new branch (default: origin/HEAD or HEAD)')
|
|
161
|
+
.option('--path <dir>', 'worktree path (default: <worktree_root>/<8-hex>-<slug>)')
|
|
162
|
+
.option('--create', 'create the feature when it does not exist'));
|
|
163
|
+
newCommand.action(async (feature) => {
|
|
164
|
+
const opts = newCommand.opts();
|
|
165
|
+
await runTrackingCommand(newCommand, async (context) => {
|
|
166
|
+
const paths = await runWorktreeNew(feature, {
|
|
167
|
+
create: opts.create === true,
|
|
168
|
+
...(opts.base !== undefined ? { base: opts.base } : {}),
|
|
169
|
+
...(opts.path !== undefined ? { path: opts.path } : {})
|
|
170
|
+
}, {
|
|
171
|
+
client: context.client,
|
|
172
|
+
config: context.config,
|
|
173
|
+
cwd: process.cwd(),
|
|
174
|
+
hostname: os.hostname(),
|
|
175
|
+
listTasks: (input) => context.pgm.listTasks(input),
|
|
176
|
+
run: runGit,
|
|
177
|
+
exists: existsSync,
|
|
178
|
+
confirm: promptConfirm,
|
|
179
|
+
bootId: readBootId,
|
|
180
|
+
resolveWorkspace: (cwd) => resolveLocalWorkspace(cwd),
|
|
181
|
+
writeMarker,
|
|
182
|
+
warn: (line) => process.stderr.write(`${line}\n`)
|
|
183
|
+
});
|
|
184
|
+
return context.json ? { path: paths[0] ?? '' } : paths;
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|