@ivotoby/postgram-cli 1.39.0 → 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.
- package/README.md +64 -2
- package/dist/pgm.js +2 -0
- package/dist/tracking/api.js +3 -0
- package/dist/tracking/config.js +34 -1
- package/dist/tracking/feature-commands.js +70 -0
- package/dist/tracking/feature-sync.js +151 -0
- package/dist/tracking/here.js +14 -1
- package/dist/tracking/scan.js +388 -70
- 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/README.md
CHANGED
|
@@ -123,20 +123,81 @@ expand when the compact result already answers a direct fact.
|
|
|
123
123
|
|
|
124
124
|
## Workspace tracking
|
|
125
125
|
|
|
126
|
-
Track features, machines, workspaces and agent sessions across worktrees
|
|
126
|
+
Track features, machines, workspaces and agent sessions across worktrees.
|
|
127
|
+
For onboarding and daily use, see [docs/workspace-tracking.md](../docs/workspace-tracking.md).
|
|
127
128
|
|
|
128
129
|
```bash
|
|
129
130
|
pgm here # report the current workspace and session
|
|
130
131
|
pgm hooks install # install the Claude Code hooks (--pi installs the pi extension)
|
|
131
132
|
pgm hooks uninstall # remove the installed hooks
|
|
132
|
-
pgm where # features and workspaces grouped by stage
|
|
133
|
+
pgm where # features and workspaces grouped by stage, one summary per feature (--expand lists every workspace, --all includes done and released)
|
|
133
134
|
pgm feature add "my feature" # create a feature (also: stage, pr, link, unlink)
|
|
135
|
+
pgm feature done 1234abcd # mark the feature task done; pgm where hides it unless --all
|
|
136
|
+
pgm feature sync [1234abcd] # update PR states from GitHub with gh and apply the stage rules
|
|
134
137
|
pgm machine set --alias codebox # set this machine's alias or connect target (also: pgm machine list)
|
|
135
138
|
pgm repo set <remote> --release-on-merge true|false # record the repository for this worktree
|
|
136
139
|
pgm worktree new 1234abcd # create a worktree for a feature (UUID, 8-hex short id or title; --create creates the feature)
|
|
137
140
|
pgm scan --root ~/src # discover worktrees and record their features (needs --root <dir> or scan_roots; --yes-exact, --dry-run)
|
|
141
|
+
pgm scan --json # read-only inventory for agents: one entry per worktree, nothing is sent
|
|
142
|
+
pgm scan --apply plan.json # apply an approved plan (--dry-run validates and shows the result only)
|
|
143
|
+
pgm worktree rm <path> # remove a clean, pushed worktree and unregister it (no force flag)
|
|
144
|
+
pgm workspace prune # remove registrations of this machine whose directory is gone (--dry-run, --ignored)
|
|
138
145
|
```
|
|
139
146
|
|
|
147
|
+
The interactive `pgm scan` needs a terminal. Without one it exits with a
|
|
148
|
+
message that points to `--json`, `--apply` and `--yes-exact`.
|
|
149
|
+
|
|
150
|
+
### Scan inventory and plan
|
|
151
|
+
|
|
152
|
+
`pgm scan --json` prints:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"machine": "codebox",
|
|
157
|
+
"generated_at": "2026-11-01T10:00:00.000Z",
|
|
158
|
+
"roots": ["/home/me/workspace"],
|
|
159
|
+
"worktrees": [
|
|
160
|
+
{
|
|
161
|
+
"path": "/home/me/workspace/app.worktrees/1a2b3c4d-kw-rank",
|
|
162
|
+
"repo_remote": "git@github.com:owner/app.git",
|
|
163
|
+
"branch": "feat/1a2b3c4d-kw-rank",
|
|
164
|
+
"last_commit": { "date": "2026-10-30T09:12:00+00:00", "subject": "fix: ranking" },
|
|
165
|
+
"git": { "dirty_count": 0, "unpushed_count": 1, "has_upstream": true },
|
|
166
|
+
"workspace_id": null,
|
|
167
|
+
"feature": null,
|
|
168
|
+
"marker_feature_id": null,
|
|
169
|
+
"sessions": { "count": 2, "last_activity_at": "2026-10-30T10:00:00.000Z" },
|
|
170
|
+
"candidates": [
|
|
171
|
+
{ "kind": "short-id", "feature_id": "1a2b3c4d-...", "title": "Keyword rank fusion" }
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`feature` is the link Postgram already has. `candidates` (marker, short-id,
|
|
179
|
+
search) are only computed for worktrees without a link. `git` is `null` when
|
|
180
|
+
git timed out (then `note` says so).
|
|
181
|
+
|
|
182
|
+
`pgm scan --apply plan.json` takes one entry per worktree path:
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"entries": [
|
|
187
|
+
{ "path": "/home/me/workspace/app.worktrees/1a2b3c4d-kw-rank", "action": "link", "feature_id": "1a2b3c4d" },
|
|
188
|
+
{ "path": "/home/me/workspace/app.worktrees/spike", "action": "create", "title": "Spike: new cache" },
|
|
189
|
+
{ "path": "/home/me/workspace/app.worktrees/old", "action": "skip" }
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The whole plan is validated before anything is written: paths must be
|
|
195
|
+
absolute, unique, existing worktree roots outside `ignore_paths`; `link` needs
|
|
196
|
+
a feature id (UUID or 8-hex) that exists; `create` needs a title. One invalid
|
|
197
|
+
entry rejects the plan. Entries then run one by one and the result lists
|
|
198
|
+
`linked`, `created`, `skipped` or `failed` per path (exit code 1 when any
|
|
199
|
+
failed). With `--json` the result is `{ "results": [...], "failed": n }`.
|
|
200
|
+
|
|
140
201
|
New `~/.pgmrc` keys (read alongside `api_url`/`api_key`):
|
|
141
202
|
|
|
142
203
|
| Key | Default |
|
|
@@ -145,6 +206,7 @@ New `~/.pgmrc` keys (read alongside `api_url`/`api_key`):
|
|
|
145
206
|
| `scan_roots` | `[]` |
|
|
146
207
|
| `worktree_root` | `null` (meaning `<repo_root>.worktrees`) |
|
|
147
208
|
| `here_timeout_ms` | `2000` |
|
|
209
|
+
| `ignore_paths` | `[]`. Directories `pgm here`, the hooks and `pgm scan` skip. An entry matches that directory exactly; an entry ending in `/**` matches everything below it. `~` is expanded. Example: `["~/workspace"]` skips the workspace root but not the repos in it. |
|
|
148
210
|
| `resume` | per-agent-kind `with_id`/`without_id` command templates |
|
|
149
211
|
|
|
150
212
|
## Memory Roles
|
package/dist/pgm.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerHereCommand } from './tracking/here.js';
|
|
|
13
13
|
import { registerHooksCommands } from './tracking/hooks.js';
|
|
14
14
|
import { registerScanCommand } from './tracking/scan.js';
|
|
15
15
|
import { registerWhereCommand } from './tracking/where.js';
|
|
16
|
+
import { registerWorkspaceCommands } from './tracking/workspace-commands.js';
|
|
16
17
|
import { registerWorktreeCommand } from './tracking/worktree.js';
|
|
17
18
|
import { buildSyncManifest } from './sync-walk.js';
|
|
18
19
|
import { compactEdgeResponse, compactEntityListResponse, compactGraphResponse, compactSearchResponse, compactStoredEntityResponse, entityListResponseToToon, graphResponseToToon, searchResponseToToon } from './search-output.js';
|
|
@@ -920,4 +921,5 @@ registerWhereCommand(program);
|
|
|
920
921
|
registerFeatureCommands(program);
|
|
921
922
|
registerWorktreeCommand(program);
|
|
922
923
|
registerScanCommand(program);
|
|
924
|
+
registerWorkspaceCommands(program);
|
|
923
925
|
await program.parseAsync(process.argv);
|
package/dist/tracking/api.js
CHANGED
|
@@ -25,6 +25,9 @@ export function createTrackingClient(options) {
|
|
|
25
25
|
patchWorkspace(id, body) {
|
|
26
26
|
return request(options, `/api/workspaces/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
|
27
27
|
},
|
|
28
|
+
deleteWorkspace(id) {
|
|
29
|
+
return request(options, `/api/workspaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
30
|
+
},
|
|
28
31
|
putMachine(alias, connectTarget) {
|
|
29
32
|
return request(options, `/api/machines/${encodeURIComponent(alias)}`, { method: 'PUT', body: { connect_target: connectTarget } });
|
|
30
33
|
},
|
package/dist/tracking/config.js
CHANGED
|
@@ -35,6 +35,23 @@ export function expandHome(value, homeDir) {
|
|
|
35
35
|
}
|
|
36
36
|
return value;
|
|
37
37
|
}
|
|
38
|
+
function trimTrailingSlashes(value) {
|
|
39
|
+
return value.length > 1 ? value.replace(/\/+$/, '') || '/' : value;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* An entry matches that directory exactly. An entry ending in `/**` matches
|
|
43
|
+
* everything below the directory, but not the directory itself.
|
|
44
|
+
*/
|
|
45
|
+
export function isIgnoredPath(target, ignorePaths) {
|
|
46
|
+
const candidate = trimTrailingSlashes(target);
|
|
47
|
+
return ignorePaths.some((entry) => {
|
|
48
|
+
if (entry.endsWith('/**')) {
|
|
49
|
+
const base = trimTrailingSlashes(entry.slice(0, -3));
|
|
50
|
+
return candidate.startsWith(base === '/' ? '/' : `${base}/`);
|
|
51
|
+
}
|
|
52
|
+
return candidate === trimTrailingSlashes(entry);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
38
55
|
function isRecord(value) {
|
|
39
56
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
57
|
}
|
|
@@ -68,6 +85,14 @@ export function parseTrackingConfig(raw, input) {
|
|
|
68
85
|
const hereTimeoutMs = typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0
|
|
69
86
|
? timeout
|
|
70
87
|
: 2000;
|
|
88
|
+
const ignorePaths = [];
|
|
89
|
+
if (isRecord(raw) && Array.isArray(raw.ignore_paths)) {
|
|
90
|
+
for (const entry of raw.ignore_paths) {
|
|
91
|
+
if (typeof entry === 'string' && entry !== '') {
|
|
92
|
+
ignorePaths.push(expandHome(entry, input.homeDir));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
71
96
|
const resume = cloneResume(DEFAULT_RESUME);
|
|
72
97
|
const rawResume = isRecord(raw) && isRecord(raw.resume) ? raw.resume : {};
|
|
73
98
|
for (const kind of AGENT_KINDS) {
|
|
@@ -83,7 +108,15 @@ export function parseTrackingConfig(raw, input) {
|
|
|
83
108
|
resume[kind].without_id = kindConfig.without_id;
|
|
84
109
|
}
|
|
85
110
|
}
|
|
86
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
alias,
|
|
113
|
+
connectTarget,
|
|
114
|
+
scanRoots,
|
|
115
|
+
worktreeRoot,
|
|
116
|
+
hereTimeoutMs,
|
|
117
|
+
ignorePaths,
|
|
118
|
+
resume
|
|
119
|
+
};
|
|
87
120
|
}
|
|
88
121
|
export async function loadTrackingConfig(input) {
|
|
89
122
|
const homeDir = input?.homeDir ?? os.homedir();
|
|
@@ -6,6 +6,7 @@ import { parseCommaList, shortId } from '../shared.js';
|
|
|
6
6
|
import { runTrackingCommand } from './api.js';
|
|
7
7
|
import { UsageError, usageExit, writeMachineConfig } from './config.js';
|
|
8
8
|
import { readBootId, removeMarker, resolveLocalWorkspace, writeMarker } from './local.js';
|
|
9
|
+
import { formatSync, runFeatureSync, runGhPrState } from './feature-sync.js';
|
|
9
10
|
import { FEATURE_STAGES } from './types.js';
|
|
10
11
|
export async function runFeatureAdd(title, options, deps) {
|
|
11
12
|
const tags = parseCommaList(options.tags);
|
|
@@ -25,6 +26,54 @@ export async function runFeatureStage(id, stage, deps) {
|
|
|
25
26
|
? response
|
|
26
27
|
: [`${shortId(response.entity.id)} ${response.previous_stage ?? '-'} → ${stage}`];
|
|
27
28
|
}
|
|
29
|
+
const FULL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
30
|
+
const SHORT_HEX = /^[0-9a-f]{8}$/i;
|
|
31
|
+
async function resolveTaskId(id, deps) {
|
|
32
|
+
if (FULL_UUID.test(id)) {
|
|
33
|
+
return id.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
if (!SHORT_HEX.test(id)) {
|
|
36
|
+
throw new UsageError('feature id must be a full UUID or an 8-hex short id');
|
|
37
|
+
}
|
|
38
|
+
const prefix = id.toLowerCase();
|
|
39
|
+
const matches = [];
|
|
40
|
+
for (let offset = 0;; offset += 100) {
|
|
41
|
+
const page = await deps.pgm.listTasks({ limit: 100, offset });
|
|
42
|
+
for (const item of page.items) {
|
|
43
|
+
if (item.id.startsWith(prefix)) {
|
|
44
|
+
matches.push(item.id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (page.items.length < 100) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (matches.length > 1) {
|
|
52
|
+
throw new AppError(ErrorCode.CONFLICT, `feature id ${id} is ambiguous`);
|
|
53
|
+
}
|
|
54
|
+
const [match] = matches;
|
|
55
|
+
if (match === undefined) {
|
|
56
|
+
throw new AppError(ErrorCode.NOT_FOUND, `no feature matches ${id}`);
|
|
57
|
+
}
|
|
58
|
+
return match;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Marks the feature's task done. `pgm where` already hides done tasks unless
|
|
62
|
+
* --all is given, so a feature needs no stage of its own for this.
|
|
63
|
+
*/
|
|
64
|
+
export async function runFeatureDone(id, deps) {
|
|
65
|
+
const taskId = await resolveTaskId(id, deps);
|
|
66
|
+
const current = await deps.pgm.recallEntity(taskId);
|
|
67
|
+
if (current.entity.status === 'done') {
|
|
68
|
+
return deps.json
|
|
69
|
+
? { id: taskId, status: 'done', changed: false }
|
|
70
|
+
: [`${shortId(taskId)} already done`];
|
|
71
|
+
}
|
|
72
|
+
const completed = await deps.pgm.completeTask(taskId, current.entity.version);
|
|
73
|
+
return deps.json
|
|
74
|
+
? { id: taskId, status: completed.entity.status, changed: true }
|
|
75
|
+
: [`${shortId(taskId)} done`];
|
|
76
|
+
}
|
|
28
77
|
const PR_STATES = ['open', 'merged', 'closed'];
|
|
29
78
|
export async function runFeaturePr(id, url, options, deps) {
|
|
30
79
|
const state = options.state ?? 'open';
|
|
@@ -185,6 +234,27 @@ export function registerFeatureCommands(program) {
|
|
|
185
234
|
.option('--state <state>', 'open, merged or closed', 'open')).action(async (id, url, options, command) => {
|
|
186
235
|
await runTrackingCommand(command, (context) => runFeaturePr(id, url, options, featureDeps(context)));
|
|
187
236
|
});
|
|
237
|
+
usageExit(feature
|
|
238
|
+
.command('done')
|
|
239
|
+
.description('Mark a feature done so pgm where hides it (pgm where --all shows it)')
|
|
240
|
+
.argument('<id>', 'feature id (UUID or 8-hex)')).action(async (id, _options, command) => {
|
|
241
|
+
await runTrackingCommand(command, (context) => runFeatureDone(id, { pgm: context.pgm, json: context.json }));
|
|
242
|
+
});
|
|
243
|
+
usageExit(feature
|
|
244
|
+
.command('sync')
|
|
245
|
+
.description('Update PR states from GitHub (needs gh) and apply the stage rules')
|
|
246
|
+
.argument('[id]', 'only this feature (UUID or 8-hex)')).action(async (id, _options, command) => {
|
|
247
|
+
await runTrackingCommand(command, async (context) => {
|
|
248
|
+
const result = await runFeatureSync(id, {
|
|
249
|
+
client: context.client,
|
|
250
|
+
gh: (url) => runGhPrState(url)
|
|
251
|
+
});
|
|
252
|
+
if (result.errors.length > 0) {
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
}
|
|
255
|
+
return context.json ? result : formatSync(result);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
188
258
|
usageExit(feature
|
|
189
259
|
.command('link')
|
|
190
260
|
.description('Link a workspace to a feature')
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// cli/src/tracking/feature-sync.ts: pgm feature sync — follow GitHub PR state.
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { AppError, ErrorCode } from '../errors.js';
|
|
4
|
+
import { shortId } from '../shared.js';
|
|
5
|
+
import { UsageError } from './config.js';
|
|
6
|
+
const GH_STATES = {
|
|
7
|
+
OPEN: 'open',
|
|
8
|
+
MERGED: 'merged',
|
|
9
|
+
CLOSED: 'closed'
|
|
10
|
+
};
|
|
11
|
+
export function parseGhState(stdout) {
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(stdout);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const state = parsed.state;
|
|
23
|
+
return typeof state === 'string' ? (GH_STATES[state] ?? null) : null;
|
|
24
|
+
}
|
|
25
|
+
export function classifyGhFailure(error) {
|
|
26
|
+
const stderr = error.stderr ?? '';
|
|
27
|
+
if (error.code === 'ENOENT') {
|
|
28
|
+
return { ok: false, kind: 'missing', message: 'gh is not installed' };
|
|
29
|
+
}
|
|
30
|
+
if (/gh auth login|not logged in|not logged into|authentication/i.test(stderr)) {
|
|
31
|
+
return { ok: false, kind: 'unauthenticated', message: 'gh is not logged in' };
|
|
32
|
+
}
|
|
33
|
+
const first = stderr.split('\n').find((line) => line.trim() !== '');
|
|
34
|
+
return { ok: false, kind: 'error', message: first?.trim() ?? 'gh failed' };
|
|
35
|
+
}
|
|
36
|
+
const defaultExec = (cmd, args) => new Promise((resolve, reject) => {
|
|
37
|
+
execFile(cmd, args, { timeout: 20_000 }, (error, stdout, stderr) => {
|
|
38
|
+
if (error !== null) {
|
|
39
|
+
reject(Object.assign(error, { stderr }));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
resolve({ stdout });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
export async function runGhPrState(url, exec = defaultExec) {
|
|
47
|
+
let stdout;
|
|
48
|
+
try {
|
|
49
|
+
({ stdout } = await exec('gh', ['pr', 'view', url, '--json', 'state']));
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const failure = error;
|
|
53
|
+
return classifyGhFailure({ code: failure.code, stderr: failure.stderr });
|
|
54
|
+
}
|
|
55
|
+
const state = parseGhState(stdout);
|
|
56
|
+
return state === null
|
|
57
|
+
? { ok: false, kind: 'error', message: 'unexpected output from gh pr view' }
|
|
58
|
+
: { ok: true, state };
|
|
59
|
+
}
|
|
60
|
+
function collectFeatures(response) {
|
|
61
|
+
const features = [];
|
|
62
|
+
for (const group of response.groups) {
|
|
63
|
+
if ('features' in group) {
|
|
64
|
+
features.push(...group.features);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return features;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Asks gh for the state of every PR that is not merged yet and sends changes
|
|
71
|
+
* through the feature PR endpoint, so the server's derived-stage rules apply.
|
|
72
|
+
* A merged PR is final and is not asked again.
|
|
73
|
+
*/
|
|
74
|
+
export async function runFeatureSync(id, deps) {
|
|
75
|
+
const response = await deps.client.where(id === undefined
|
|
76
|
+
? { view: 'stage' }
|
|
77
|
+
: { view: 'stage', includeReleased: true, includeDone: true });
|
|
78
|
+
let features = collectFeatures(response);
|
|
79
|
+
if (id !== undefined) {
|
|
80
|
+
const wanted = id.toLowerCase();
|
|
81
|
+
features = features.filter((feature) => feature.id === wanted || feature.id.startsWith(wanted));
|
|
82
|
+
if (features.length === 0) {
|
|
83
|
+
throw new AppError(ErrorCode.NOT_FOUND, `no feature matches ${id}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const result = { checked: 0, unchanged: 0, changed: [], errors: [] };
|
|
87
|
+
const answers = new Map();
|
|
88
|
+
for (const feature of features) {
|
|
89
|
+
for (const pull of feature.pull_requests) {
|
|
90
|
+
if (pull.state === 'merged') {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
let answer = answers.get(pull.url);
|
|
94
|
+
if (answer === undefined) {
|
|
95
|
+
answer = await deps.gh(pull.url);
|
|
96
|
+
answers.set(pull.url, answer);
|
|
97
|
+
}
|
|
98
|
+
if (!answer.ok && answer.kind === 'missing') {
|
|
99
|
+
throw new UsageError('gh (GitHub CLI) was not found on PATH. Install it from https://cli.github.com to use `pgm feature sync`.');
|
|
100
|
+
}
|
|
101
|
+
if (!answer.ok && answer.kind === 'unauthenticated') {
|
|
102
|
+
throw new UsageError('gh is not logged in. Run `gh auth login`, then retry `pgm feature sync`.');
|
|
103
|
+
}
|
|
104
|
+
result.checked += 1;
|
|
105
|
+
if (!answer.ok) {
|
|
106
|
+
result.errors.push({
|
|
107
|
+
feature_id: feature.id,
|
|
108
|
+
url: pull.url,
|
|
109
|
+
error: answer.message
|
|
110
|
+
});
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (answer.state === pull.state) {
|
|
114
|
+
result.unchanged += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const updated = await deps.client.addPullRequest(feature.id, {
|
|
119
|
+
url: pull.url,
|
|
120
|
+
state: answer.state
|
|
121
|
+
});
|
|
122
|
+
result.changed.push({
|
|
123
|
+
feature_id: feature.id,
|
|
124
|
+
url: pull.url,
|
|
125
|
+
from: pull.state,
|
|
126
|
+
to: answer.state,
|
|
127
|
+
stage_from: updated.from,
|
|
128
|
+
stage_to: updated.to
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
result.errors.push({
|
|
133
|
+
feature_id: feature.id,
|
|
134
|
+
url: pull.url,
|
|
135
|
+
error: error instanceof Error ? error.message : 'update failed'
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
export function formatSync(result) {
|
|
143
|
+
const lines = result.changed.map((change) => `${shortId(change.feature_id)} ${change.url} ${change.from} → ${change.to}${change.stage_from !== change.stage_to
|
|
144
|
+
? `, stage ${change.stage_from} → ${change.stage_to}`
|
|
145
|
+
: ''}`);
|
|
146
|
+
for (const error of result.errors) {
|
|
147
|
+
lines.push(`error ${shortId(error.feature_id)} ${error.url}: ${error.error}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push(`checked ${result.checked} pull requests: ${result.changed.length} changed, ${result.unchanged} unchanged, ${result.errors.length} failed`);
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
package/dist/tracking/here.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { readStdinText, resolvePgmConfig, shortId } from '../shared.js';
|
|
6
6
|
import { createTrackingClient } from './api.js';
|
|
7
|
-
import { loadTrackingConfig, usageExit } from './config.js';
|
|
7
|
+
import { isIgnoredPath, loadTrackingConfig, usageExit } from './config.js';
|
|
8
8
|
import { readBootId, readMarker, resolveLocalWorkspace } from './local.js';
|
|
9
9
|
import { AGENT_KINDS } from './types.js';
|
|
10
10
|
const HOOK_EVENTS = {
|
|
@@ -79,6 +79,12 @@ function asSessionEvent(value) {
|
|
|
79
79
|
function featureLine(feature) {
|
|
80
80
|
return `Postgram: this worktree belongs to feature ${shortId(feature.id)} "${feature.title}" (stage: ${feature.stage ?? 'building'}).`;
|
|
81
81
|
}
|
|
82
|
+
function ignoredResult(options, target) {
|
|
83
|
+
return {
|
|
84
|
+
stdout: options.hook ? '' : `ignored ${target} (ignore_paths)\n`,
|
|
85
|
+
exitCode: 0
|
|
86
|
+
};
|
|
87
|
+
}
|
|
82
88
|
export async function runHere(options, deps) {
|
|
83
89
|
let agent;
|
|
84
90
|
let sessionId;
|
|
@@ -125,6 +131,10 @@ export async function runHere(options, deps) {
|
|
|
125
131
|
return { stdout: '', exitCode: 0 };
|
|
126
132
|
}
|
|
127
133
|
const config = deps.config;
|
|
134
|
+
// Checked before any git call so an ignored directory costs nothing.
|
|
135
|
+
if (isIgnoredPath(cwd, config.ignorePaths)) {
|
|
136
|
+
return ignoredResult(options, cwd);
|
|
137
|
+
}
|
|
128
138
|
if (deps.client === null) {
|
|
129
139
|
await deps.log('missing credentials');
|
|
130
140
|
return { stdout: '', exitCode: 0 };
|
|
@@ -136,6 +146,9 @@ export async function runHere(options, deps) {
|
|
|
136
146
|
await deps.log('git timeout');
|
|
137
147
|
return { stdout: '', exitCode: 0 };
|
|
138
148
|
}
|
|
149
|
+
if (isIgnoredPath(local.path, config.ignorePaths)) {
|
|
150
|
+
return ignoredResult(options, local.path);
|
|
151
|
+
}
|
|
139
152
|
const marker = await deps.readMarker(local.path);
|
|
140
153
|
const bootId = await deps.bootId();
|
|
141
154
|
const session = sessionId !== null || event !== null
|
package/dist/tracking/scan.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// cli/src/tracking/scan.ts: pgm scan — discover worktrees and record their features.
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import readline from 'node:readline';
|
|
6
7
|
import { shortId } from '../shared.js';
|
|
7
8
|
import { runTrackingCommand } from './api.js';
|
|
8
|
-
import { slug, usageExit, UsageError } from './config.js';
|
|
9
|
-
import { readMarker, resolveLocalWorkspace, writeMarker } from './local.js';
|
|
9
|
+
import { expandHome, isIgnoredPath, slug, usageExit, UsageError } from './config.js';
|
|
10
|
+
import { readMarker, resolveLocalWorkspace, runGit, writeMarker } from './local.js';
|
|
10
11
|
import { discoverWorktrees, findClaudeSessions, findPiSessions } from './scan-discovery.js';
|
|
11
12
|
import { syncMissing } from './where.js';
|
|
12
13
|
export const BRANCH_SHORT_ID = /(?:^|\/)([0-9a-f]{8})(?:-|$)/;
|
|
@@ -69,13 +70,51 @@ function sessionsFor(worktree, claude, pi) {
|
|
|
69
70
|
...(pi.get(worktree.path) ?? [])
|
|
70
71
|
];
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
function scanRoots(options, deps) {
|
|
73
74
|
const roots = options.root.length > 0
|
|
74
75
|
? options.root.map((root) => path.resolve(root))
|
|
75
76
|
: deps.config.scanRoots;
|
|
76
77
|
if (roots.length === 0) {
|
|
77
78
|
throw new UsageError('no scan roots: pass --root <dir> or set scan_roots in ~/.pgmrc');
|
|
78
79
|
}
|
|
80
|
+
return roots;
|
|
81
|
+
}
|
|
82
|
+
async function discoverNotIgnored(roots, depth, deps) {
|
|
83
|
+
const worktrees = await deps.discover(roots, depth);
|
|
84
|
+
return worktrees.filter((worktree) => !isIgnoredPath(worktree.path, deps.config.ignorePaths));
|
|
85
|
+
}
|
|
86
|
+
async function findSessions(paths, deps) {
|
|
87
|
+
const claudeDir = deps.env.CLAUDE_CONFIG_DIR ?? path.join(deps.homeDir, '.claude');
|
|
88
|
+
const piDir = deps.env.PI_CODING_AGENT_DIR ?? path.join(deps.homeDir, '.pi', 'agent');
|
|
89
|
+
const claude = await deps.claudeSessions(paths, claudeDir);
|
|
90
|
+
for (const note of claude.notes) {
|
|
91
|
+
deps.info(note);
|
|
92
|
+
}
|
|
93
|
+
const pi = await deps.piSessions(paths, piDir);
|
|
94
|
+
return { claude: claude.sessions, pi };
|
|
95
|
+
}
|
|
96
|
+
async function recordLocal(local, featureId, sessions, deps) {
|
|
97
|
+
const body = {
|
|
98
|
+
source: 'scan',
|
|
99
|
+
machine: {
|
|
100
|
+
alias: deps.config.alias,
|
|
101
|
+
hostname: deps.hostname,
|
|
102
|
+
boot_id: null
|
|
103
|
+
},
|
|
104
|
+
workspace: local.workspace,
|
|
105
|
+
feature_id: featureId,
|
|
106
|
+
sessions
|
|
107
|
+
};
|
|
108
|
+
await deps.client.report(body);
|
|
109
|
+
if ((await deps.readMarker(local.path)) !== featureId) {
|
|
110
|
+
await deps.writeMarker(local.path, featureId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export async function runScan(options, deps) {
|
|
114
|
+
if (!options.yesExact && !deps.isTty()) {
|
|
115
|
+
throw new UsageError('pgm scan needs a terminal for its interactive flow. Without one, use `pgm scan --json` to list worktrees, `pgm scan --apply <plan.json>` to apply a plan, or `pgm scan --yes-exact`.');
|
|
116
|
+
}
|
|
117
|
+
const roots = scanRoots(options, deps);
|
|
79
118
|
const depth = parseDepth(options.depth);
|
|
80
119
|
const knownResponse = await deps.client.listWorkspaces({
|
|
81
120
|
machine: deps.config.alias
|
|
@@ -88,15 +127,8 @@ export async function runScan(options, deps) {
|
|
|
88
127
|
});
|
|
89
128
|
}
|
|
90
129
|
const known = new Set(knownResponse.workspaces.map((workspace) => workspace.path));
|
|
91
|
-
const worktrees = await
|
|
92
|
-
const
|
|
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);
|
|
130
|
+
const worktrees = await discoverNotIgnored(roots, depth, deps);
|
|
131
|
+
const { claude, pi } = await findSessions(worktrees.map((worktree) => worktree.path), deps);
|
|
100
132
|
const tasks = await deps.tasks();
|
|
101
133
|
const lines = [];
|
|
102
134
|
for (const worktree of worktrees) {
|
|
@@ -158,25 +190,264 @@ export async function runScan(options, deps) {
|
|
|
158
190
|
lines.push(`skipped ${worktree.path} (git timeout)`);
|
|
159
191
|
continue;
|
|
160
192
|
}
|
|
161
|
-
|
|
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
|
-
}
|
|
193
|
+
await recordLocal(local, id, sessionsFor(worktree, claude, pi), deps);
|
|
176
194
|
lines.push(`recorded ${worktree.path} → ${shortId(id)}`);
|
|
177
195
|
}
|
|
178
196
|
return lines;
|
|
179
197
|
}
|
|
198
|
+
const INVENTORY_CONCURRENCY = 6;
|
|
199
|
+
function sessionSummary(sessions) {
|
|
200
|
+
if (sessions.length === 0) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
let latest = '';
|
|
204
|
+
for (const session of sessions) {
|
|
205
|
+
const observed = session.observed_at ?? '';
|
|
206
|
+
if (observed > latest) {
|
|
207
|
+
latest = observed;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { count: sessions.length, last_activity_at: latest };
|
|
211
|
+
}
|
|
212
|
+
/** Read-only: lists what is on disk and what Postgram knows. Sends nothing. */
|
|
213
|
+
export async function runScanInventory(options, deps) {
|
|
214
|
+
const roots = scanRoots(options, deps);
|
|
215
|
+
const depth = parseDepth(options.depth);
|
|
216
|
+
const known = new Map((await deps.client.listWorkspaces({ machine: deps.config.alias })).workspaces.map((workspace) => [workspace.path, workspace]));
|
|
217
|
+
const worktrees = await discoverNotIgnored(roots, depth, deps);
|
|
218
|
+
const { claude, pi } = await findSessions(worktrees.map((worktree) => worktree.path), deps);
|
|
219
|
+
const tasks = await deps.tasks();
|
|
220
|
+
const titleFor = (id) => tasks.find((task) => task.id === id)?.title ?? '';
|
|
221
|
+
const describe = async (worktree) => {
|
|
222
|
+
const [local, lastCommit] = await Promise.all([
|
|
223
|
+
deps.resolveWorkspace(worktree.path),
|
|
224
|
+
deps.lastCommit(worktree.path)
|
|
225
|
+
]);
|
|
226
|
+
const workspace = known.get(worktree.path) ?? null;
|
|
227
|
+
const linkedId = workspace?.feature_id ?? null;
|
|
228
|
+
const marker = await deps.readMarker(worktree.path);
|
|
229
|
+
const candidates = (linkedId === null
|
|
230
|
+
? await buildProposals(worktree, deps, tasks, false)
|
|
231
|
+
: []).map((proposal) => ({
|
|
232
|
+
kind: proposal.kind,
|
|
233
|
+
feature_id: proposal.featureId,
|
|
234
|
+
title: proposal.title
|
|
235
|
+
}));
|
|
236
|
+
const entry = {
|
|
237
|
+
path: worktree.path,
|
|
238
|
+
repo_remote: local.kind === 'ok'
|
|
239
|
+
? (local.workspace.repo_remote ?? workspace?.repo_remote ?? null)
|
|
240
|
+
: (workspace?.repo_remote ?? null),
|
|
241
|
+
branch: local.kind === 'ok'
|
|
242
|
+
? (local.workspace.branch ?? worktree.branch)
|
|
243
|
+
: worktree.branch,
|
|
244
|
+
last_commit: lastCommit,
|
|
245
|
+
git: local.kind === 'ok' ? (local.workspace.git ?? null) : null,
|
|
246
|
+
workspace_id: workspace?.id ?? null,
|
|
247
|
+
feature: linkedId !== null ? { id: linkedId, title: titleFor(linkedId) } : null,
|
|
248
|
+
marker_feature_id: marker,
|
|
249
|
+
sessions: sessionSummary(sessionsFor(worktree, claude, pi)),
|
|
250
|
+
candidates
|
|
251
|
+
};
|
|
252
|
+
if (local.kind === 'timeout') {
|
|
253
|
+
entry.note = 'git timeout';
|
|
254
|
+
}
|
|
255
|
+
return entry;
|
|
256
|
+
};
|
|
257
|
+
const entries = [];
|
|
258
|
+
for (let i = 0; i < worktrees.length; i += INVENTORY_CONCURRENCY) {
|
|
259
|
+
entries.push(...(await Promise.all(worktrees.slice(i, i + INVENTORY_CONCURRENCY).map(describe))));
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
machine: deps.config.alias,
|
|
263
|
+
generated_at: new Date().toISOString(),
|
|
264
|
+
roots,
|
|
265
|
+
worktrees: entries
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
269
|
+
const SHORT_ID_RE = /^[0-9a-f]{8}$/i;
|
|
270
|
+
function isRecordValue(value) {
|
|
271
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
272
|
+
}
|
|
273
|
+
function resolveTaskId(raw, tasks) {
|
|
274
|
+
const lower = raw.toLowerCase();
|
|
275
|
+
let hits = [];
|
|
276
|
+
if (UUID_RE.test(lower)) {
|
|
277
|
+
hits = tasks.filter((task) => task.id === lower);
|
|
278
|
+
}
|
|
279
|
+
else if (SHORT_ID_RE.test(lower)) {
|
|
280
|
+
hits = tasks.filter((task) => task.id.startsWith(lower));
|
|
281
|
+
}
|
|
282
|
+
const hit = hits[0];
|
|
283
|
+
if (hit === undefined) {
|
|
284
|
+
return { error: `no feature matches ${raw}` };
|
|
285
|
+
}
|
|
286
|
+
if (hits.length > 1) {
|
|
287
|
+
return { error: `feature id ${raw} is ambiguous` };
|
|
288
|
+
}
|
|
289
|
+
return { id: hit.id };
|
|
290
|
+
}
|
|
291
|
+
/** Validates the whole plan; nothing is written while this runs. */
|
|
292
|
+
async function validatePlan(plan, deps) {
|
|
293
|
+
const rawEntries = Array.isArray(plan)
|
|
294
|
+
? plan
|
|
295
|
+
: isRecordValue(plan) && Array.isArray(plan.entries)
|
|
296
|
+
? plan.entries
|
|
297
|
+
: null;
|
|
298
|
+
if (rawEntries === null) {
|
|
299
|
+
throw new UsageError('plan must be {"entries": [...]} or a JSON array of entries');
|
|
300
|
+
}
|
|
301
|
+
const tasks = await deps.tasks();
|
|
302
|
+
const errors = [];
|
|
303
|
+
const valid = [];
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
for (const [index, raw] of rawEntries.entries()) {
|
|
306
|
+
if (!isRecordValue(raw) || typeof raw.path !== 'string' || raw.path === '') {
|
|
307
|
+
errors.push(`entry ${index + 1}: path is required`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const expanded = expandHome(raw.path, deps.homeDir);
|
|
311
|
+
if (!path.isAbsolute(expanded)) {
|
|
312
|
+
errors.push(`${raw.path}: path must be absolute`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const target = path.resolve(expanded);
|
|
316
|
+
if (seen.has(target)) {
|
|
317
|
+
errors.push(`duplicate path ${target}`);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
seen.add(target);
|
|
321
|
+
const action = raw.action;
|
|
322
|
+
if (action === 'skip') {
|
|
323
|
+
valid.push({ entry: { path: target, action: 'skip' } });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
let item;
|
|
327
|
+
if (action === 'link') {
|
|
328
|
+
if (typeof raw.feature_id !== 'string' || raw.feature_id === '') {
|
|
329
|
+
errors.push(`${target}: link needs feature_id`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const resolved = resolveTaskId(raw.feature_id, tasks);
|
|
333
|
+
if ('error' in resolved) {
|
|
334
|
+
errors.push(`${target}: ${resolved.error}`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
item = {
|
|
338
|
+
entry: { path: target, action: 'link', feature_id: raw.feature_id },
|
|
339
|
+
featureId: resolved.id
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
else if (action === 'create') {
|
|
343
|
+
const title = typeof raw.title === 'string' ? raw.title.trim() : '';
|
|
344
|
+
if (title === '') {
|
|
345
|
+
errors.push(`${target}: create needs a non-empty title`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
item = { entry: { path: target, action: 'create', title } };
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
errors.push(`${target}: unknown action '${String(action)}'`);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (isIgnoredPath(target, deps.config.ignorePaths)) {
|
|
355
|
+
errors.push(`${target}: path is in ignore_paths`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!deps.exists(target)) {
|
|
359
|
+
errors.push(`${target}: path does not exist`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const local = await deps.resolveWorkspace(target);
|
|
363
|
+
if (local.kind === 'timeout') {
|
|
364
|
+
errors.push(`${target}: git timed out`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (local.repoRoot === null) {
|
|
368
|
+
errors.push(`${target}: not a git worktree`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (local.path !== (await deps.realpath(target))) {
|
|
372
|
+
errors.push(`${target}: not the root of a worktree (${local.path})`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (isIgnoredPath(local.path, deps.config.ignorePaths)) {
|
|
376
|
+
errors.push(`${target}: path is in ignore_paths`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
valid.push({ ...item, local });
|
|
380
|
+
}
|
|
381
|
+
if (errors.length > 0) {
|
|
382
|
+
throw new UsageError(`plan rejected, nothing was written:\n${errors.map((line) => ` ${line}`).join('\n')}`);
|
|
383
|
+
}
|
|
384
|
+
return valid;
|
|
385
|
+
}
|
|
386
|
+
export async function runScanApply(plan, options, deps) {
|
|
387
|
+
const valid = await validatePlan(plan, deps);
|
|
388
|
+
const paths = valid
|
|
389
|
+
.filter((item) => item.entry.action !== 'skip')
|
|
390
|
+
.map((item) => item.entry.path);
|
|
391
|
+
const sessions = options.dryRun || paths.length === 0
|
|
392
|
+
? { claude: new Map(), pi: new Map() }
|
|
393
|
+
: await findSessions(paths, deps);
|
|
394
|
+
const results = [];
|
|
395
|
+
for (const item of valid) {
|
|
396
|
+
const { entry } = item;
|
|
397
|
+
if (entry.action === 'skip') {
|
|
398
|
+
results.push({ path: entry.path, action: 'skip', status: 'skipped' });
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (options.dryRun) {
|
|
402
|
+
results.push({
|
|
403
|
+
path: entry.path,
|
|
404
|
+
action: entry.action,
|
|
405
|
+
status: entry.action === 'link' ? 'would link' : 'would create',
|
|
406
|
+
...(item.featureId !== undefined ? { feature_id: item.featureId } : {})
|
|
407
|
+
});
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
let featureId = item.featureId;
|
|
411
|
+
try {
|
|
412
|
+
if (entry.action === 'create') {
|
|
413
|
+
const created = await deps.client.createFeature({ content: entry.title });
|
|
414
|
+
featureId = created.entity.id;
|
|
415
|
+
}
|
|
416
|
+
if (featureId === undefined || item.local === undefined) {
|
|
417
|
+
throw new Error('internal: unresolved plan entry');
|
|
418
|
+
}
|
|
419
|
+
await recordLocal(item.local, featureId, sessionsFor({ path: entry.path, branch: null }, sessions.claude, sessions.pi), deps);
|
|
420
|
+
results.push({
|
|
421
|
+
path: entry.path,
|
|
422
|
+
action: entry.action,
|
|
423
|
+
status: entry.action === 'link' ? 'linked' : 'created',
|
|
424
|
+
feature_id: featureId
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
results.push({
|
|
429
|
+
path: entry.path,
|
|
430
|
+
action: entry.action,
|
|
431
|
+
status: 'failed',
|
|
432
|
+
...(featureId !== undefined ? { feature_id: featureId } : {}),
|
|
433
|
+
error: error instanceof Error ? error.message : 'unknown error'
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
results,
|
|
439
|
+
failed: results.filter((result) => result.status === 'failed').length
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
export function formatApplyResults(results) {
|
|
443
|
+
return results.map((result) => {
|
|
444
|
+
const target = result.feature_id !== undefined ? ` → ${shortId(result.feature_id)}` : '';
|
|
445
|
+
if (result.status === 'failed') {
|
|
446
|
+
return `failed ${result.path}${target}: ${result.error ?? 'unknown error'}`;
|
|
447
|
+
}
|
|
448
|
+
return `${result.status} ${result.path}${target}`;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
180
451
|
function firstLineTitle(content) {
|
|
181
452
|
return (content ?? '').split('\n')[0]?.trim() ?? '';
|
|
182
453
|
}
|
|
@@ -203,6 +474,21 @@ function askLine(question) {
|
|
|
203
474
|
rl.on('SIGINT', () => finish(''));
|
|
204
475
|
});
|
|
205
476
|
}
|
|
477
|
+
async function readPlan(file) {
|
|
478
|
+
let text;
|
|
479
|
+
try {
|
|
480
|
+
text = await readFile(file, 'utf8');
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
throw new UsageError(`cannot read plan ${file}: ${error instanceof Error ? error.message : 'read failed'}`);
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
return JSON.parse(text);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
throw new UsageError(`plan ${file} is not valid JSON: ${error instanceof Error ? error.message : 'parse failed'}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
206
492
|
export function registerScanCommand(program) {
|
|
207
493
|
const command = usageExit(program
|
|
208
494
|
.command('scan')
|
|
@@ -210,52 +496,84 @@ export function registerScanCommand(program) {
|
|
|
210
496
|
.option('--root <dir>', 'scan root (repeatable)', collectRoot, [])
|
|
211
497
|
.option('--depth <n>', `directory depth below each root (default ${DEFAULT_DEPTH})`)
|
|
212
498
|
.option('--yes-exact', 'accept marker and short-id proposals, skip the rest')
|
|
213
|
-
.option('--dry-run', 'show what would be recorded without sending')
|
|
499
|
+
.option('--dry-run', 'show what would be recorded without sending')
|
|
500
|
+
.option('--apply <plan.json>', 'apply an approved plan (link, create or skip per worktree path)')).action(async () => {
|
|
214
501
|
const opts = command.opts();
|
|
215
|
-
await runTrackingCommand(command, (context) =>
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
502
|
+
await runTrackingCommand(command, async (context) => {
|
|
503
|
+
const scanOptions = {
|
|
504
|
+
root: opts.root ?? [],
|
|
505
|
+
...(opts.depth !== undefined ? { depth: opts.depth } : {}),
|
|
506
|
+
yesExact: opts.yesExact === true,
|
|
507
|
+
dryRun: opts.dryRun === true
|
|
508
|
+
};
|
|
509
|
+
if (opts.apply !== undefined && scanOptions.yesExact) {
|
|
510
|
+
throw new UsageError('--apply cannot be combined with --yes-exact');
|
|
511
|
+
}
|
|
512
|
+
const deps = {
|
|
513
|
+
client: context.client,
|
|
514
|
+
config: context.config,
|
|
515
|
+
hostname: os.hostname(),
|
|
516
|
+
env: process.env,
|
|
517
|
+
homeDir: context.homeDir,
|
|
518
|
+
tasks: async () => {
|
|
519
|
+
const items = [];
|
|
520
|
+
for (let offset = 0;; offset += 100) {
|
|
521
|
+
const page = await context.pgm.listTasks({ limit: 100, offset });
|
|
522
|
+
for (const item of page.items) {
|
|
523
|
+
items.push({ id: item.id, title: firstLineTitle(item.content) });
|
|
524
|
+
}
|
|
525
|
+
if (page.items.length < 100) {
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
232
528
|
}
|
|
233
|
-
|
|
234
|
-
|
|
529
|
+
return items;
|
|
530
|
+
},
|
|
531
|
+
search: async (query) => {
|
|
532
|
+
const response = await context.pgm.searchEntities({
|
|
533
|
+
query,
|
|
534
|
+
type: 'task',
|
|
535
|
+
limit: 3
|
|
536
|
+
});
|
|
537
|
+
return response.results.map((result) => ({
|
|
538
|
+
id: result.entity.id,
|
|
539
|
+
title: firstLineTitle(result.entity.content)
|
|
540
|
+
}));
|
|
541
|
+
},
|
|
542
|
+
ask: askLine,
|
|
543
|
+
discover: discoverWorktrees,
|
|
544
|
+
claudeSessions: findClaudeSessions,
|
|
545
|
+
piSessions: findPiSessions,
|
|
546
|
+
resolveWorkspace: (p) => resolveLocalWorkspace(p),
|
|
547
|
+
readMarker,
|
|
548
|
+
writeMarker: (worktree, featureId) => writeMarker(worktree, featureId, new Date()),
|
|
549
|
+
exists: existsSync,
|
|
550
|
+
isTty: () => process.stdin.isTTY === true,
|
|
551
|
+
realpath: (p) => realpath(p),
|
|
552
|
+
lastCommit: async (worktree) => {
|
|
553
|
+
const result = await runGit(['log', '-1', '--format=%cI%n%s'], {
|
|
554
|
+
cwd: worktree,
|
|
555
|
+
timeoutMs: 5000
|
|
556
|
+
});
|
|
557
|
+
if (!result.ok || result.stdout === '') {
|
|
558
|
+
return null;
|
|
235
559
|
}
|
|
560
|
+
const [date = '', ...subject] = result.stdout.split('\n');
|
|
561
|
+
return { date, subject: subject.join('\n') };
|
|
562
|
+
},
|
|
563
|
+
info: (line) => process.stderr.write(`${line}\n`)
|
|
564
|
+
};
|
|
565
|
+
if (opts.apply !== undefined) {
|
|
566
|
+
const plan = await readPlan(opts.apply);
|
|
567
|
+
const applied = await runScanApply(plan, { dryRun: scanOptions.dryRun }, deps);
|
|
568
|
+
if (applied.failed > 0) {
|
|
569
|
+
process.exitCode = 1;
|
|
236
570
|
}
|
|
237
|
-
return
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
}));
|
|
571
|
+
return context.json ? applied : formatApplyResults(applied.results);
|
|
572
|
+
}
|
|
573
|
+
if (context.json && !scanOptions.yesExact) {
|
|
574
|
+
return runScanInventory(scanOptions, deps);
|
|
575
|
+
}
|
|
576
|
+
return runScan(scanOptions, deps);
|
|
577
|
+
});
|
|
260
578
|
});
|
|
261
579
|
}
|
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) => {
|