@ulysses-ai/create-workspace 0.16.0-beta.1 → 0.18.0-beta.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 +5 -5
- package/lib/init.mjs +19 -0
- package/package.json +1 -1
- package/template/.claude/hooks/_utils.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +161 -64
- package/template/.claude/hooks/session-end.mjs +68 -2
- package/template/.claude/hooks/session-start.mjs +35 -1
- package/template/.claude/hooks/subagent-start.mjs +89 -22
- package/template/.claude/lib/session-frontmatter.mjs +28 -0
- package/template/.claude/rules/coherent-revisions.md +1 -1
- package/template/.claude/rules/config-review.md.skip +29 -0
- package/template/.claude/rules/forge-operations.md +51 -0
- package/template/.claude/rules/git-conventions.md +16 -11
- package/template/.claude/rules/goal-driven-work.md +8 -403
- package/template/.claude/rules/honest-pushback.md +37 -37
- package/template/.claude/rules/memory-guidance.md +43 -90
- package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
- package/template/.claude/rules/work-item-tracking.md +30 -72
- package/template/.claude/rules/workspace-structure.md +49 -69
- package/template/.claude/scripts/build-workspace-context.mjs +61 -16
- package/template/.claude/scripts/chat-record.mjs +282 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +363 -36
- package/template/.claude/scripts/context-footprint.mjs +282 -0
- package/template/.claude/scripts/forges/github.mjs +255 -0
- package/template/.claude/scripts/forges/gitlab.mjs +20 -0
- package/template/.claude/scripts/forges/interface.mjs +125 -0
- package/template/.claude/scripts/generate-claude-local.mjs +21 -2
- package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
- package/template/.claude/scripts/task-worktree.mjs +525 -0
- package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
- package/template/.claude/settings.json +5 -13
- package/template/.claude/skills/braindump/SKILL.md +11 -4
- package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
- package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +255 -215
- package/template/.claude/skills/context-placement/SKILL.md +199 -0
- package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
- package/template/.claude/skills/handoff/SKILL.md +11 -4
- package/template/.claude/skills/maintenance/SKILL.md +39 -6
- package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
- package/template/.claude/skills/pause-work/SKILL.md +33 -8
- package/template/.claude/skills/release/SKILL.md +44 -108
- package/template/.claude/skills/start-work/SKILL.md +89 -7
- package/template/.claude/skills/workspace-init/SKILL.md +34 -0
- package/template/.claude/skills/workspace-update/SKILL.md +4 -0
- package/template/.claudeignore +3 -0
- package/template/CLAUDE.md.tmpl +20 -2
- package/template/CODEBASE.md.tmpl +13 -0
- package/template/_gitignore +9 -0
- package/template/repo-claude.md.tmpl +10 -0
- package/template/workspace.json.tmpl +5 -3
- package/template/.claude/hooks/worktree-create.mjs +0 -53
|
@@ -0,0 +1,1571 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Per-workspace migration from the session lifecycle to the task model
|
|
3
|
+
// (gh:147).
|
|
4
|
+
//
|
|
5
|
+
// Workspaces that predate the task model accumulate entries under
|
|
6
|
+
// work-sessions/ — some finished-but-never-completed, some abandoned,
|
|
7
|
+
// some still live. This script is the mechanical half of draining them:
|
|
8
|
+
//
|
|
9
|
+
// --inventory read-only evidence + a proposal (ACTIVE / ABANDONED /
|
|
10
|
+
// MERGEABLE / UNKNOWN / REMOVE_SHELL / LEAVE) per session
|
|
11
|
+
// --backup tag each tip the session holds that no remote branch or
|
|
12
|
+
// tag already points at, push the tag, verify it
|
|
13
|
+
// (--dry-run reports the plan with no side effects)
|
|
14
|
+
// --archive move the session out of the active lifecycle — the whole
|
|
15
|
+
// folder is renamed into {sessions}/.archived/ and git's
|
|
16
|
+
// worktree links are repaired to follow it
|
|
17
|
+
// --enable-task-model
|
|
18
|
+
// flip workspace.sessionModel to "task" (accepts a task
|
|
19
|
+
// worktree root — the one mode allowed off the launcher)
|
|
20
|
+
//
|
|
21
|
+
// NOTHING HERE DELETES. Draining a session means taking it out of the
|
|
22
|
+
// active lifecycle, not destroying it. An earlier design tore sessions
|
|
23
|
+
// down behind a "prove it is safe to delete" check; four independent
|
|
24
|
+
// reviews each found a new place git keeps state that the check missed
|
|
25
|
+
// (tracker drift, ignored files, stale tracking refs, submodules,
|
|
26
|
+
// per-worktree refs, assume-unchanged edits, embedded repositories). A
|
|
27
|
+
// rename keeps all of it by construction: every file, every ref, every
|
|
28
|
+
// index flag travels with the folder, and `git worktree repair` keeps the
|
|
29
|
+
// repositories pointing at it. Deleting an archive is a separate, manual
|
|
30
|
+
// decision the operator makes with their own eyes on it.
|
|
31
|
+
//
|
|
32
|
+
// HARD BOUNDARY: the process only ever touches the workspace it is run
|
|
33
|
+
// in. --root must resolve (real path) to a directory containing
|
|
34
|
+
// workspace.json; every path read or acted on must resolve inside it; a
|
|
35
|
+
// --session name must be a single path segment; and git is only ever run
|
|
36
|
+
// in the workspace repo at the root, the repos under root/repos/, and
|
|
37
|
+
// worktrees under the sessions directory. A session holding a worktree
|
|
38
|
+
// of any other repository is refused, never touched. Remote contact
|
|
39
|
+
// (ls-remote, push) verifies and creates backup tags — it never deletes a
|
|
40
|
+
// remote branch or tag.
|
|
41
|
+
//
|
|
42
|
+
// Output contract: JSON on stdout. Inventory also prints a human-readable
|
|
43
|
+
// table to stderr. A precondition refusal prints {refused: true,
|
|
44
|
+
// reasons: [...]} and exits 1; any other error goes to stderr and exits 2.
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, statSync, mkdirSync, renameSync, readlinkSync,
|
|
48
|
+
} from 'node:fs';
|
|
49
|
+
import { realpathSync } from 'node:fs';
|
|
50
|
+
import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path';
|
|
51
|
+
import { spawnSync } from 'node:child_process';
|
|
52
|
+
import { fileURLToPath } from 'node:url';
|
|
53
|
+
import { readSessionFields } from '../lib/session-frontmatter.mjs';
|
|
54
|
+
import { defaultBranchFor, slugForBranch, WORKSPACE_REPO } from './task-worktree.mjs';
|
|
55
|
+
|
|
56
|
+
function isMainModule(metaUrl) {
|
|
57
|
+
if (!process.argv[1]) return false;
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
60
|
+
} catch { return false; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
64
|
+
// Remote contact must never hang or prompt an operator who is not there:
|
|
65
|
+
// prompts are disabled and both commands get hard timeouts. A timed-out
|
|
66
|
+
// ls-remote degrades the inventory answer to "unknown" and makes backup
|
|
67
|
+
// refuse rather than guess.
|
|
68
|
+
const LS_REMOTE_TIMEOUT_MS = 15000;
|
|
69
|
+
const PUSH_TIMEOUT_MS = 60000;
|
|
70
|
+
|
|
71
|
+
function netOpts(timeoutMs) {
|
|
72
|
+
return { encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Session artifacts live at the top of the workspace worktree on the
|
|
76
|
+
// session branch (workspace-structure.md). They are process output, not
|
|
77
|
+
// content — a branch whose only diff is these files carries nothing
|
|
78
|
+
// worth merging, and a dirty artifact is not dirty content.
|
|
79
|
+
const SESSION_ARTIFACT_PATTERNS = [
|
|
80
|
+
/^session\.md$/,
|
|
81
|
+
/^design-.*\.md$/,
|
|
82
|
+
/^plan-.*\.md$/,
|
|
83
|
+
/^goal-.*\.md$/,
|
|
84
|
+
/^research-.*\.md$/,
|
|
85
|
+
/^crossref-.*\.md$/,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
function isSessionArtifact(file) {
|
|
89
|
+
// "at the worktree top" — the path has no separator at all.
|
|
90
|
+
if (file.includes('/')) return false;
|
|
91
|
+
return SESSION_ARTIFACT_PATTERNS.some((re) => re.test(file));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// .native resolves Windows 8.3 short names; the plain fallback covers
|
|
95
|
+
// filesystems where the native binding is unavailable. Same contract as
|
|
96
|
+
// task-worktree.mjs.
|
|
97
|
+
function realPath(p) {
|
|
98
|
+
try { return realpathSync.native(p); } catch { /* fall through */ }
|
|
99
|
+
try { return realpathSync(p); } catch { /* fall through */ }
|
|
100
|
+
return resolve(p);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// gitFn is injectable so tests can observe or fake git; the default is
|
|
104
|
+
// spawnSync itself, called as (command, args, options).
|
|
105
|
+
function run(gitFn, cwd, args, opts = {}) {
|
|
106
|
+
const res = gitFn('git', ['-C', cwd, ...args], { encoding: 'utf8', ...opts });
|
|
107
|
+
if (res.error) throw new Error(`git: ${res.error.message}`);
|
|
108
|
+
return res;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function okLines(res) {
|
|
112
|
+
return String(res.stdout || '').split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A session name becomes a path segment under the sessions directory —
|
|
116
|
+
// one segment only, no separators or absolute paths, and no leading dot:
|
|
117
|
+
// dot entries are the script's own (.archived/) and discovery never
|
|
118
|
+
// lists them as sessions, so no mode may act on one either.
|
|
119
|
+
function isSessionSegment(name) {
|
|
120
|
+
if (typeof name !== 'string' || name === '' || isAbsolute(name)) return false;
|
|
121
|
+
const segs = name.split(/[\\/]/);
|
|
122
|
+
if (segs.length !== 1) return false;
|
|
123
|
+
return !segs[0].startsWith('.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A project repo name: one path segment under repos/. This is the guard
|
|
127
|
+
// that keeps a crafted tracker (repos: ['../../elsewhere']) from pointing
|
|
128
|
+
// any git command at a repo outside the workspace.
|
|
129
|
+
function isRepoSegment(name) {
|
|
130
|
+
if (typeof name !== 'string' || name === '' || isAbsolute(name)) return false;
|
|
131
|
+
const segs = name.split(/[\\/]/);
|
|
132
|
+
if (segs.length !== 1) return false;
|
|
133
|
+
return !/^\.+$/.test(segs[0]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The root must be a workspace root — otherwise the script would be
|
|
137
|
+
// operating on (and writing into) an arbitrary directory, which is the
|
|
138
|
+
// one thing the hard boundary forbids.
|
|
139
|
+
function resolveRoot(root) {
|
|
140
|
+
const rootDir = realPath(resolve(root));
|
|
141
|
+
if (!existsSync(join(rootDir, 'workspace.json'))) {
|
|
142
|
+
throw new Error(`no workspace.json at ${rootDir} — not a workspace root`);
|
|
143
|
+
}
|
|
144
|
+
return rootDir;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function insideRoot(rootDir, p) {
|
|
148
|
+
const rp = realPath(p);
|
|
149
|
+
return rp === rootDir || rp.startsWith(rootDir + sep);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function insideDir(dir, p) {
|
|
153
|
+
const rp = realPath(p);
|
|
154
|
+
return rp === realPath(dir) || rp.startsWith(realPath(dir) + sep);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readConfig(rootDir) {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(readFileSync(join(rootDir, 'workspace.json'), 'utf-8'));
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The sessions directory is configurable, but a configuration pointing
|
|
166
|
+
// outside the root would violate the boundary — refuse it outright.
|
|
167
|
+
function sessionsDirOf(rootDir) {
|
|
168
|
+
const dir = readConfig(rootDir)?.workspace?.workSessionsDir;
|
|
169
|
+
const name = typeof dir === 'string' && dir !== '' ? dir : 'work-sessions';
|
|
170
|
+
const path = resolve(rootDir, name);
|
|
171
|
+
if (!insideRoot(rootDir, path)) {
|
|
172
|
+
throw new Error(`workspace.workSessionsDir (${name}) resolves outside the workspace root`);
|
|
173
|
+
}
|
|
174
|
+
return path;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Session entries with their nature: a symlinked entry is foreign — it
|
|
178
|
+
// may point anywhere, so it is reported and never followed (N3).
|
|
179
|
+
function listSessionEntries(rootDir) {
|
|
180
|
+
const dir = sessionsDirOf(rootDir);
|
|
181
|
+
if (!existsSync(dir)) return [];
|
|
182
|
+
try {
|
|
183
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
184
|
+
// Dot entries are the script's own (.archived/) or tooling noise —
|
|
185
|
+
// never sessions.
|
|
186
|
+
.filter((e) => !e.name.startsWith('.'))
|
|
187
|
+
.map((e) => ({ name: e.name, foreign: e.isSymbolicLink() }))
|
|
188
|
+
.filter((e) => e.foreign || lstatSync(join(dir, e.name)).isDirectory())
|
|
189
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
} catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function listSessionNames(rootDir) {
|
|
196
|
+
return listSessionEntries(rootDir).filter((e) => !e.foreign).map((e) => e.name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function currentBranch(gitFn, path) {
|
|
200
|
+
try {
|
|
201
|
+
const res = gitFn('git', ['-C', path, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' });
|
|
202
|
+
if (!res || res.status !== 0) return null;
|
|
203
|
+
const name = String(res.stdout).trim();
|
|
204
|
+
return name === 'HEAD' ? null : name; // "HEAD" means detached
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function headSha(gitFn, path) {
|
|
211
|
+
try {
|
|
212
|
+
const res = run(gitFn, path, ['rev-parse', 'HEAD']);
|
|
213
|
+
return res.status === 0 ? String(res.stdout).trim() : null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Porcelain v1 -z records: "XY <path>", NUL-separated, with the rename
|
|
220
|
+
// source in a second NUL field. Ignored entries only appear when
|
|
221
|
+
// --ignored=matching is requested. Null return = git failed, which every
|
|
222
|
+
// caller treats as fail-closed.
|
|
223
|
+
function statusRecords(gitFn, path, extraArgs = []) {
|
|
224
|
+
const res = run(gitFn, path, ['status', '--porcelain=v1', '-z', '--untracked-files=all', ...extraArgs]);
|
|
225
|
+
if (res.status !== 0) return null;
|
|
226
|
+
const parts = String(res.stdout || '').split('\0');
|
|
227
|
+
const records = [];
|
|
228
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
229
|
+
const p = parts[i];
|
|
230
|
+
if (p === '') continue;
|
|
231
|
+
const xy = p.slice(0, 2);
|
|
232
|
+
const entryPath = p.slice(3);
|
|
233
|
+
if (xy[0] === 'R' || xy[0] === 'C') i += 1; // the next NUL field is the rename source
|
|
234
|
+
records.push({ xy, path: entryPath });
|
|
235
|
+
}
|
|
236
|
+
return records;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The base the session branched from, as a ref: origin/{default} when
|
|
240
|
+
// the remote ref exists (the truth about the integration branch), else
|
|
241
|
+
// the local {default}.
|
|
242
|
+
function baseRef(gitFn, path, defaultBranch) {
|
|
243
|
+
const hasOrigin = run(gitFn, path, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${defaultBranch}`]).status === 0;
|
|
244
|
+
return hasOrigin ? `origin/${defaultBranch}` : defaultBranch;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ownRange(gitFn, path, defaultBranch) {
|
|
248
|
+
return `${baseRef(gitFn, path, defaultBranch)}..HEAD`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Commits not on the repo's default branch, or null when the question
|
|
252
|
+
// cannot be answered (e.g. the configured default branch does not exist
|
|
253
|
+
// in this repo — B3). null is "unknown", never "zero": the unbacked and
|
|
254
|
+
// classification logic treats it with suspicion.
|
|
255
|
+
function aheadCount(gitFn, path, range) {
|
|
256
|
+
const res = run(gitFn, path, ['rev-list', '--count', range]);
|
|
257
|
+
if (res.status !== 0) return null;
|
|
258
|
+
const n = parseInt(String(res.stdout).trim(), 10);
|
|
259
|
+
return Number.isFinite(n) ? n : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// When this worktree last got session work of its own: the committer
|
|
263
|
+
// date of the newest commit not on the default branch. An ahead=0
|
|
264
|
+
// worktree's HEAD is just the base tip — its date describes the repo,
|
|
265
|
+
// not the session, and counting it would make every stale session look
|
|
266
|
+
// active whenever the default branch moves. Null = no session commits.
|
|
267
|
+
function lastOwnCommitIso(gitFn, path, range) {
|
|
268
|
+
const res = run(gitFn, path, ['log', '-1', '--format=%cI', range]);
|
|
269
|
+
if (res.status !== 0) return null;
|
|
270
|
+
const s = String(res.stdout).trim();
|
|
271
|
+
return s === '' ? null : s;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Files changed vs the default branch minus session artifacts — the
|
|
275
|
+
// number that answers "is there real content on this branch?".
|
|
276
|
+
function countContentFiles(gitFn, path, defaultBranch) {
|
|
277
|
+
const base = baseRef(gitFn, path, defaultBranch);
|
|
278
|
+
const res = run(gitFn, path, ['diff', '--name-only', `${base}...HEAD`]);
|
|
279
|
+
if (res.status !== 0) return 0;
|
|
280
|
+
const files = okLines(res).filter((f) => !isSessionArtifact(f));
|
|
281
|
+
return files.length;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function remotesOf(gitFn, path) {
|
|
285
|
+
const res = run(gitFn, path, ['remote']);
|
|
286
|
+
if (res.status !== 0) return [];
|
|
287
|
+
return okLines(res);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Does {branch} exist on {remote}, and at what commit? Local tracking
|
|
291
|
+
// refs prove nothing (they are stale the moment anything fetches), so
|
|
292
|
+
// the remote is asked directly. Timeouts and failures degrade to
|
|
293
|
+
// 'unknown' — an inventory must report, not crash, and never hang.
|
|
294
|
+
function lsRemoteBranch(gitFn, cwd, remote, branch) {
|
|
295
|
+
const res = gitFn('git', ['-C', cwd, 'ls-remote', '--heads', remote, branch], netOpts(LS_REMOTE_TIMEOUT_MS));
|
|
296
|
+
if (res.error || res.status !== 0) return { exists: 'unknown', sha: null };
|
|
297
|
+
const line = okLines(res)[0];
|
|
298
|
+
if (!line) return { exists: false, sha: null };
|
|
299
|
+
const [sha] = line.trim().split(/\s+/);
|
|
300
|
+
return { exists: true, sha };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// === S1: remote state per remote ===
|
|
304
|
+
//
|
|
305
|
+
// For each remote holding the branch, record how the local tip relates:
|
|
306
|
+
// same / local-ahead / local-behind / diverged (with counts) when the
|
|
307
|
+
// remote commit is known locally, not-fetched when it is not. This is
|
|
308
|
+
// what tells the operator "finishing needs a force-push decision" BEFORE
|
|
309
|
+
// they choose Finish, rather than after /complete-work's push bounces.
|
|
310
|
+
function remoteStatesFor(gitFn, wtPath, branch, head) {
|
|
311
|
+
const out = {};
|
|
312
|
+
if (!branch) return out;
|
|
313
|
+
for (const remote of remotesOf(gitFn, wtPath)) {
|
|
314
|
+
const probe = lsRemoteBranch(gitFn, wtPath, remote, branch);
|
|
315
|
+
if (probe.exists === 'unknown') {
|
|
316
|
+
out[remote] = { exists: false, sha: null, state: 'unknown' };
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!probe.exists) {
|
|
320
|
+
out[remote] = { exists: false, sha: null, state: 'none' };
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const { sha } = probe;
|
|
324
|
+
if (sha === head) {
|
|
325
|
+
out[remote] = { exists: true, sha, state: 'same' };
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const known = run(gitFn, wtPath, ['cat-file', '-e', `${sha}^{commit}`]).status === 0;
|
|
329
|
+
if (!known) {
|
|
330
|
+
out[remote] = { exists: true, sha, state: 'not-fetched' };
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const lr = run(gitFn, wtPath, ['rev-list', '--left-right', '--count', `${sha}...HEAD`]);
|
|
334
|
+
if (lr.status !== 0) {
|
|
335
|
+
out[remote] = { exists: true, sha, state: 'unknown' };
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const counts = String(lr.stdout).trim().split(/\s+/).map(Number);
|
|
339
|
+
const remoteOnly = counts[0]; // left side: commits only the remote has
|
|
340
|
+
const localOnly = counts[1]; // right side: commits only HEAD has
|
|
341
|
+
if (remoteOnly === 0 && localOnly === 0) out[remote] = { exists: true, sha, state: 'same' };
|
|
342
|
+
else if (localOnly > 0 && remoteOnly === 0) out[remote] = { exists: true, sha, state: 'local-ahead', ahead: localOnly };
|
|
343
|
+
else if (localOnly === 0 && remoteOnly > 0) out[remote] = { exists: true, sha, state: 'local-behind', behind: remoteOnly };
|
|
344
|
+
else out[remote] = { exists: true, sha, state: 'diverged', ahead: localOnly, behind: remoteOnly };
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function describeRemote(name, r) {
|
|
350
|
+
switch (r.state) {
|
|
351
|
+
case 'same': return `${name}:same`;
|
|
352
|
+
case 'local-ahead': return `${name}:ahead +${r.ahead}`;
|
|
353
|
+
case 'local-behind': return `${name}:behind -${r.behind}`;
|
|
354
|
+
case 'diverged': return `${name}:diverged +${r.ahead}/-${r.behind}`;
|
|
355
|
+
default: return `${name}:${r.state}`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The tip is machine-safe on a remote when the remote's copy leaves
|
|
360
|
+
// nothing local-only (same, or local purely behind). Any other state —
|
|
361
|
+
// or no remote holding the branch at all — leaves local-only commits.
|
|
362
|
+
function backedByRemote(remotes) {
|
|
363
|
+
for (const [name, r] of Object.entries(remotes)) {
|
|
364
|
+
if (r.exists && (r.state === 'same' || r.state === 'local-behind')) return name;
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The unbacked message names the repo (two worktrees on the same branch
|
|
370
|
+
// would otherwise print identical lines) and counts the commits that are
|
|
371
|
+
// neither on the primary holding remote nor on the default branch —
|
|
372
|
+
// `rev-list --count HEAD ^<remoteSha> ^<base>` when the base resolves.
|
|
373
|
+
function unbackedMessage(gitFn, rootDir, wt) {
|
|
374
|
+
const parts = Object.entries(wt.remotes).map(([name, r]) => describeRemote(name, r)).join(', ');
|
|
375
|
+
const diverged = Object.values(wt.remotes).some((r) => r.state === 'diverged');
|
|
376
|
+
const holderEntry = Object.entries(wt.remotes).find(([, r]) => r.exists) ?? Object.entries(wt.remotes)[0];
|
|
377
|
+
let count = null;
|
|
378
|
+
if (wt.base) {
|
|
379
|
+
const abs = join(rootDir, wt.path);
|
|
380
|
+
if (holderEntry && holderEntry[1].sha) {
|
|
381
|
+
const res = run(gitFn, abs, ['rev-list', '--count', 'HEAD', `^${holderEntry[1].sha}`, `^${wt.base}`]);
|
|
382
|
+
if (res.status === 0) count = Number(String(res.stdout).trim());
|
|
383
|
+
}
|
|
384
|
+
if (count == null || !Number.isFinite(count)) {
|
|
385
|
+
const res = run(gitFn, abs, ['rev-list', '--count', `${wt.base}..HEAD`]);
|
|
386
|
+
count = res.status === 0 ? Number(String(res.stdout).trim()) : null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const label = repoLabel(wt);
|
|
390
|
+
const head = count != null && Number.isFinite(count)
|
|
391
|
+
? `${label}: ${count} commit(s) on ${wt.branch} are on no remote and not on ${wt.base} (${parts})`
|
|
392
|
+
: `${label}: commit(s) on ${wt.branch} are on no remote (${parts})`;
|
|
393
|
+
// A diverged remote means a rebase rewrote history that was already
|
|
394
|
+
// pushed: a plain push will be rejected, and deciding to force is the
|
|
395
|
+
// operator's, never the script's.
|
|
396
|
+
return diverged ? `${head} — diverged from a remote; finishing needs a force-push decision` : head;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// === S2(c): activity signals beyond commits ===
|
|
400
|
+
|
|
401
|
+
// Newest HEAD-reflog timestamp for a worktree (unix seconds), read from
|
|
402
|
+
// the reflog ENTRY's own time. `--date=unix --format=%gd` renders the
|
|
403
|
+
// selector as HEAD@{<unix-ts>} — the entry timestamp, not %ct (which is
|
|
404
|
+
// the referenced commit's date and reads "old" for a fresh worktree
|
|
405
|
+
// checked out on an old branch). Absence is normal and tolerated.
|
|
406
|
+
function reflogTs(gitFn, wtPath) {
|
|
407
|
+
try {
|
|
408
|
+
const res = gitFn('git', ['-C', wtPath, 'log', '-g', '-1', '--date=unix', '--format=%gd'], { encoding: 'utf8' });
|
|
409
|
+
if (!res || res.status !== 0) return null;
|
|
410
|
+
const m = String(res.stdout).trim().match(/@\{(\d+)\}$/);
|
|
411
|
+
return m ? Number(m[1]) : null;
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Newest mtime among content (non-artifact) dirty paths — a session with
|
|
418
|
+
// no commits and no reflog can still show fresh uncommitted work.
|
|
419
|
+
// Artifact paths are excluded: an uncommitted session.md edit is bookkeeping,
|
|
420
|
+
// not work, and would otherwise make UNKNOWN unreachable.
|
|
421
|
+
function dirtyContentMtimeMs(gitFn, wtPath, kind) {
|
|
422
|
+
const records = statusRecords(gitFn, wtPath);
|
|
423
|
+
if (!records) return null;
|
|
424
|
+
let newest = null;
|
|
425
|
+
for (const r of records) {
|
|
426
|
+
if (r.xy === '!!') continue; // statusRecords without --ignored never yields these; guard anyway
|
|
427
|
+
if (kind === 'workspace' && isSessionArtifact(r.path)) continue;
|
|
428
|
+
try {
|
|
429
|
+
const m = statSync(join(wtPath, r.path)).mtimeMs;
|
|
430
|
+
if (newest === null || m > newest) newest = m;
|
|
431
|
+
} catch { /* deleted/renamed away between status and stat — skip */ }
|
|
432
|
+
}
|
|
433
|
+
return newest;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// === Session shape ===
|
|
437
|
+
|
|
438
|
+
function readTracker(wsDir) {
|
|
439
|
+
const trackerPath = join(wsDir, 'session.md');
|
|
440
|
+
if (!existsSync(trackerPath)) return null;
|
|
441
|
+
try {
|
|
442
|
+
const fields = readSessionFields(trackerPath);
|
|
443
|
+
// normalizeRepos parity with cleanup-work-session.mjs: a scalar or
|
|
444
|
+
// null repos field must never iterate as characters.
|
|
445
|
+
const rawRepos = fields.repos;
|
|
446
|
+
const repos = Array.isArray(rawRepos) ? rawRepos.map(String)
|
|
447
|
+
: rawRepos == null || rawRepos === '' ? [] : [String(rawRepos)];
|
|
448
|
+
return {
|
|
449
|
+
status: typeof fields.status === 'string' ? fields.status : null,
|
|
450
|
+
workItem: typeof fields.workItem === 'string' ? fields.workItem : null,
|
|
451
|
+
branch: typeof fields.branch === 'string' ? fields.branch : null,
|
|
452
|
+
updated: fields.updated != null ? String(fields.updated) : null,
|
|
453
|
+
repos,
|
|
454
|
+
};
|
|
455
|
+
} catch {
|
|
456
|
+
// An unparseable tracker is evidence about the tracker, not the
|
|
457
|
+
// session; the worktrees below still carry the real state.
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// One worktree of a session — the workspace worktree (repo ".") or one
|
|
463
|
+
// nested project worktree (repo = its directory name under repos/).
|
|
464
|
+
// A session's workspace branch and its code are different things, so
|
|
465
|
+
// each is reported on its own line.
|
|
466
|
+
function inspectWorktree(gitFn, rootDir, kind, repo, wtPath) {
|
|
467
|
+
const branch = currentBranch(gitFn, wtPath);
|
|
468
|
+
const defaultBranch = defaultBranchFor(rootDir, repo, gitFn);
|
|
469
|
+
const range = ownRange(gitFn, wtPath, defaultBranch);
|
|
470
|
+
const base = range.slice(0, -('..HEAD'.length)); // the surviving-ref side of the range
|
|
471
|
+
const head = headSha(gitFn, wtPath);
|
|
472
|
+
const dirtyRecords = statusRecords(gitFn, wtPath) || [];
|
|
473
|
+
const info = {
|
|
474
|
+
kind,
|
|
475
|
+
repo,
|
|
476
|
+
path: relative(rootDir, realPath(wtPath)),
|
|
477
|
+
branch,
|
|
478
|
+
dirty: dirtyRecords.length,
|
|
479
|
+
ahead: aheadCount(gitFn, wtPath, range),
|
|
480
|
+
lastOwnCommit: lastOwnCommitIso(gitFn, wtPath, range),
|
|
481
|
+
base,
|
|
482
|
+
defaultBranch,
|
|
483
|
+
reflogAt: reflogTs(gitFn, wtPath),
|
|
484
|
+
remotes: {},
|
|
485
|
+
backedBy: null,
|
|
486
|
+
};
|
|
487
|
+
if (kind === 'workspace') {
|
|
488
|
+
info.trackerBranch = null; // filled by the caller when the tracker is read
|
|
489
|
+
info.contentFiles = countContentFiles(gitFn, wtPath, defaultBranch);
|
|
490
|
+
info.dirtyContent = dirtyRecords.filter((r) => !isSessionArtifact(r.path)).length;
|
|
491
|
+
}
|
|
492
|
+
info.remotes = remoteStatesFor(gitFn, wtPath, branch, head);
|
|
493
|
+
info.backedBy = backedByRemote(info.remotes);
|
|
494
|
+
return info;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// The session's worktrees as they exist right now — nested project
|
|
498
|
+
// worktrees discovered from the directory listing (the tracker's repos
|
|
499
|
+
// list can drift or be missing), each entry recognized as a worktree by
|
|
500
|
+
// its .git link so plain directories are skipped, symlinked entries
|
|
501
|
+
// surfaced as foreign rather than followed.
|
|
502
|
+
function collectSessionWorktrees(gitFn, rootDir, folder) {
|
|
503
|
+
const out = [];
|
|
504
|
+
const wsDir = join(folder, 'workspace');
|
|
505
|
+
if (existsSync(join(wsDir, '.git'))) {
|
|
506
|
+
out.push({ kind: 'workspace', repo: WORKSPACE_REPO, path: wsDir });
|
|
507
|
+
}
|
|
508
|
+
const nested = join(wsDir, 'repos');
|
|
509
|
+
if (existsSync(nested)) {
|
|
510
|
+
for (const entry of readdirSync(nested, { withFileTypes: true })) {
|
|
511
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
512
|
+
const p = join(nested, entry.name);
|
|
513
|
+
if (entry.isSymbolicLink()) {
|
|
514
|
+
out.push({ kind: 'foreign', repo: entry.name, path: p });
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (!existsSync(join(p, '.git'))) continue;
|
|
518
|
+
out.push({ kind: 'project', repo: entry.name, path: p });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Pure classifier: given a session's computed metrics, return its
|
|
526
|
+
* proposal and the human-readable reasons for it. No activity signal at
|
|
527
|
+
* all → UNKNOWN (never ABANDONED — absence of evidence is not
|
|
528
|
+
* abandonment). Active-ness is lastActivity within N days, or any dirty
|
|
529
|
+
* worktree with lastActivity within 2N days. Everything else splits on
|
|
530
|
+
* whether real content survives — committed content files, uncommitted
|
|
531
|
+
* content paths, or project commits mean MERGEABLE; artifact-only,
|
|
532
|
+
* clean, and quiet means ABANDONED. A session that fits neither (e.g.
|
|
533
|
+
* uncommitted project changes on a stale session) falls to MERGEABLE —
|
|
534
|
+
* real uncommitted work is content, and the dirty warning carries the
|
|
535
|
+
* caution.
|
|
536
|
+
*/
|
|
537
|
+
function classify(session, activeDays, now = Date.now()) {
|
|
538
|
+
const ws = session.worktrees.find((w) => w.kind === 'workspace') || null;
|
|
539
|
+
const projects = session.worktrees.filter((w) => w.kind === 'project');
|
|
540
|
+
const lastMs = session.lastActivity != null ? Date.parse(session.lastActivity) : NaN;
|
|
541
|
+
const within = (days) => Number.isFinite(lastMs) && now - lastMs <= days * DAY_MS;
|
|
542
|
+
const anyDirty = session.worktrees.some((w) => w.dirty > 0);
|
|
543
|
+
|
|
544
|
+
if (session.lastActivity == null) {
|
|
545
|
+
return {
|
|
546
|
+
proposal: 'UNKNOWN',
|
|
547
|
+
reasons: ['no activity signal — no session commits, no reflog entries, no content-dirty files, no tracker updated date'],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (within(activeDays)) {
|
|
551
|
+
return { proposal: 'ACTIVE', reasons: [`last activity ${session.lastActivity} is within ${activeDays} days`] };
|
|
552
|
+
}
|
|
553
|
+
if (anyDirty && within(activeDays * 2)) {
|
|
554
|
+
return {
|
|
555
|
+
proposal: 'ACTIVE',
|
|
556
|
+
reasons: [`dirty worktree(s) with last activity ${session.lastActivity} within ${activeDays * 2} days`],
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const content = ws ? ws.contentFiles : 0;
|
|
561
|
+
const dirtyContent = ws ? ws.dirtyContent : 0;
|
|
562
|
+
const aheadProjects = projects.filter((p) => p.ahead > 0);
|
|
563
|
+
const dirtyProjects = projects.filter((p) => p.dirty > 0);
|
|
564
|
+
// An unresolvable default branch means "no content" is UNPROVEN, not
|
|
565
|
+
// established — never ABANDONED on the back of an unknown count.
|
|
566
|
+
const unknownBase = session.worktrees.some((w) => w.ahead == null);
|
|
567
|
+
if (unknownBase) {
|
|
568
|
+
return {
|
|
569
|
+
proposal: 'MERGEABLE',
|
|
570
|
+
reasons: [
|
|
571
|
+
`last activity ${session.lastActivity} is older than ${activeDays} days`,
|
|
572
|
+
'commits-ahead could not be verified (a default branch does not resolve) — content is unknown, so abandonment is not provable',
|
|
573
|
+
],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (content === 0 && dirtyContent === 0 && aheadProjects.length === 0 && dirtyProjects.length === 0) {
|
|
577
|
+
return {
|
|
578
|
+
proposal: 'ABANDONED',
|
|
579
|
+
reasons: [
|
|
580
|
+
`last activity ${session.lastActivity} is older than ${activeDays} days`,
|
|
581
|
+
...(ws ? [`workspace branch carries only session artifacts (${ws.ahead ?? '?'} commit(s), no content files, no uncommitted content)`] : []),
|
|
582
|
+
'no project worktree has commits ahead or uncommitted changes',
|
|
583
|
+
],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const reasons = [];
|
|
587
|
+
if (content > 0) reasons.push(`${content} content file(s) beyond session artifacts on the workspace branch`);
|
|
588
|
+
if (dirtyContent > 0) reasons.push(`${dirtyContent} uncommitted content path(s) in the workspace worktree`);
|
|
589
|
+
for (const p of aheadProjects) reasons.push(`repo "${p.repo}" is ${p.ahead} commit(s) ahead of its default branch`);
|
|
590
|
+
for (const p of dirtyProjects) reasons.push(`repo "${p.repo}" has ${p.dirty} uncommitted change(s)`);
|
|
591
|
+
return { proposal: 'MERGEABLE', reasons };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function collectWarnings(gitFn, rootDir, worktrees, active, trackerBranch) {
|
|
595
|
+
const warnings = [];
|
|
596
|
+
for (const wt of worktrees) {
|
|
597
|
+
if (wt.kind === 'workspace' && wt.branchDrift) {
|
|
598
|
+
warnings.push({
|
|
599
|
+
kind: 'branch-drift',
|
|
600
|
+
message: `tracker says branch ${wt.trackerBranch} but the workspace worktree is on ${wt.branch}`,
|
|
601
|
+
branch: wt.branch,
|
|
602
|
+
trackerBranch: wt.trackerBranch,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (!active && wt.dirty > 0) {
|
|
606
|
+
warnings.push({
|
|
607
|
+
kind: 'dirty',
|
|
608
|
+
message: `${repoLabel({ kind: wt.kind, repo: wt.repo })} has ${wt.dirty} uncommitted change(s) on a session that is not active`,
|
|
609
|
+
repo: wt.repo,
|
|
610
|
+
files: wt.dirty,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
if (wt.ahead > 0 && !wt.backedBy) {
|
|
614
|
+
warnings.push({
|
|
615
|
+
kind: 'unbacked',
|
|
616
|
+
unbacked: true,
|
|
617
|
+
message: unbackedMessage(gitFn, rootDir, wt),
|
|
618
|
+
repo: wt.repo,
|
|
619
|
+
branch: wt.branch,
|
|
620
|
+
ahead: wt.ahead,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (wt.ahead == null) {
|
|
624
|
+
warnings.push({
|
|
625
|
+
kind: 'unknown-base',
|
|
626
|
+
message: `${repoLabel({ kind: wt.kind, repo: wt.repo })}: commits-ahead could not be counted (default branch "${wt.defaultBranch}" does not resolve here) — treat every count as unknown and the content as unverifiable`,
|
|
627
|
+
repo: wt.repo,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return warnings;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function inspectSession(gitFn, rootDir, sessionsDir, name, activeDays, now) {
|
|
635
|
+
const folder = join(sessionsDir, name);
|
|
636
|
+
const wsDir = join(folder, 'workspace');
|
|
637
|
+
if (!existsSync(join(wsDir, '.git'))) {
|
|
638
|
+
const reason = existsSync(wsDir)
|
|
639
|
+
? `workspace/ at ${relative(rootDir, wsDir)} is not a git worktree (empty shell)`
|
|
640
|
+
: `no workspace worktree at ${relative(rootDir, folder)}${sep}workspace`;
|
|
641
|
+
return { name, kind: 'broken', proposal: 'REMOVE_SHELL', reasons: [reason] };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const tracker = readTracker(wsDir);
|
|
645
|
+
const rawWorktrees = collectSessionWorktrees(gitFn, rootDir, folder);
|
|
646
|
+
const worktrees = rawWorktrees
|
|
647
|
+
.filter((w) => w.kind !== 'foreign')
|
|
648
|
+
.map((w) => inspectWorktree(gitFn, rootDir, w.kind, w.repo, w.path));
|
|
649
|
+
const wsWt = worktrees.find((w) => w.kind === 'workspace');
|
|
650
|
+
if (wsWt && tracker) {
|
|
651
|
+
wsWt.trackerBranch = tracker.branch;
|
|
652
|
+
wsWt.branchDrift = Boolean(tracker.branch && wsWt.branch && tracker.branch !== wsWt.branch);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// lastActivity: the newest fact we have — own commits, reflog entries,
|
|
656
|
+
// content-dirty file mtimes, or the tracker's updated field.
|
|
657
|
+
let lastMs = NaN;
|
|
658
|
+
let lastActivity = null;
|
|
659
|
+
const consider = (value) => {
|
|
660
|
+
if (value == null) return;
|
|
661
|
+
const t = typeof value === 'number' ? value : Date.parse(value);
|
|
662
|
+
if (!Number.isFinite(t)) return;
|
|
663
|
+
if (!Number.isFinite(lastMs) || t > lastMs) {
|
|
664
|
+
lastMs = t;
|
|
665
|
+
lastActivity = new Date(t).toISOString();
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
for (const wt of worktrees) {
|
|
669
|
+
consider(wt.lastOwnCommit);
|
|
670
|
+
consider(wt.reflogAt != null ? wt.reflogAt * 1000 : null);
|
|
671
|
+
consider(dirtyContentMtimeMs(gitFn, join(rootDir, wt.path), wt.kind));
|
|
672
|
+
}
|
|
673
|
+
consider(tracker?.updated ?? null);
|
|
674
|
+
|
|
675
|
+
const { proposal, reasons } = classify({ name, worktrees, lastActivity }, activeDays, now);
|
|
676
|
+
const warnings = collectWarnings(gitFn, rootDir, worktrees, proposal === 'ACTIVE', tracker?.branch ?? null);
|
|
677
|
+
return {
|
|
678
|
+
name,
|
|
679
|
+
kind: 'session',
|
|
680
|
+
status: tracker?.status ?? null,
|
|
681
|
+
workItem: tracker?.workItem ?? null,
|
|
682
|
+
lastActivity,
|
|
683
|
+
proposal,
|
|
684
|
+
reasons,
|
|
685
|
+
warnings,
|
|
686
|
+
worktrees,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Read-only inventory of every session under the workspace's sessions
|
|
692
|
+
* directory. The proposal each session gets is a proposal — the note in
|
|
693
|
+
* the result says so, and the skill says so again to the operator.
|
|
694
|
+
* Symlinked entries are reported as foreign (LEAVE) and never followed.
|
|
695
|
+
*/
|
|
696
|
+
function inventory(root, { activeDays = 14, gitFn = spawnSync, now = Date.now() } = {}) {
|
|
697
|
+
const rootDir = resolveRoot(root);
|
|
698
|
+
const sessionsDir = sessionsDirOf(rootDir);
|
|
699
|
+
const sessions = listSessionEntries(rootDir).map((entry) => (
|
|
700
|
+
entry.foreign
|
|
701
|
+
? {
|
|
702
|
+
name: entry.name,
|
|
703
|
+
kind: 'foreign',
|
|
704
|
+
proposal: 'LEAVE',
|
|
705
|
+
reasons: ['entry is a symlink, not a session directory — never followed; reconcile manually'],
|
|
706
|
+
}
|
|
707
|
+
: inspectSession(gitFn, rootDir, sessionsDir, entry.name, activeDays, now)
|
|
708
|
+
));
|
|
709
|
+
return {
|
|
710
|
+
root: rootDir,
|
|
711
|
+
activeDays,
|
|
712
|
+
note: 'Proposals are proposals — inventory evidence only; the operator decides each session.',
|
|
713
|
+
sessions,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// === Backup ===
|
|
718
|
+
//
|
|
719
|
+
// A backup tag is worth pushing only where no remote already holds the
|
|
720
|
+
// commit. Local refs never count as "already held": a stale
|
|
721
|
+
// refs/remotes/* entry or a local tag whose push failed prove nothing.
|
|
722
|
+
|
|
723
|
+
function repoLabel(wt) {
|
|
724
|
+
return wt.kind === 'workspace' ? 'the workspace repo' : `repo "${wt.repo}"`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Does a remote's URL point somewhere OTHER than the workspace itself? A
|
|
728
|
+
// remote whose URL resolves to a local path inside root shares its fate
|
|
729
|
+
// with the thing being deleted (a remote pointing at the repo, or at a
|
|
730
|
+
// sibling repo under root, holds the same objects in the same store), so
|
|
731
|
+
// "the remote has it" proves nothing. file:// URLs and plain paths are
|
|
732
|
+
// local; scheme URLs and scp-style git@host:path are real remotes.
|
|
733
|
+
function parseRemoteUrl(url) {
|
|
734
|
+
if (url.startsWith('file://')) return { local: true, path: fileURLToPath(url) };
|
|
735
|
+
if (/:\/\//.test(url)) return { local: false };
|
|
736
|
+
if (/^[^:@/\s]+@[^:\s]+:/.test(url)) return { local: false };
|
|
737
|
+
return { local: true, path: url };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function remoteQualifies(safety, repoDir, remote) {
|
|
741
|
+
const key = `${repoDir}\0${remote}`;
|
|
742
|
+
if (safety.qualCache.has(key)) return safety.qualCache.get(key);
|
|
743
|
+
let qualifies = false;
|
|
744
|
+
const res = run(safety.gitFn, repoDir, ['remote', 'get-url', remote]);
|
|
745
|
+
if (res.status === 0) {
|
|
746
|
+
const parsed = parseRemoteUrl(String(res.stdout).trim());
|
|
747
|
+
// Relative local paths resolve against the repo, the way git does.
|
|
748
|
+
qualifies = !parsed.local || !insideRoot(safety.rootDir, realPath(resolve(repoDir, parsed.path)));
|
|
749
|
+
}
|
|
750
|
+
// An unreadable URL proves nothing (fail closed).
|
|
751
|
+
safety.qualCache.set(key, qualifies);
|
|
752
|
+
return qualifies;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Every branch and tag a remote holds, mapped by commit sha. Other
|
|
756
|
+
// advertised refs (HEAD, a forge's refs/pull/N/*) are ephemeral and never
|
|
757
|
+
// count. No refspec pattern: patterns silently drop the peeled ^{} lines,
|
|
758
|
+
// which are the ones comparable with commit tips. A failed or timed-out
|
|
759
|
+
// query returns null (unknown) — it proves nothing.
|
|
760
|
+
function remoteRefs(safety, repoDir, remote) {
|
|
761
|
+
const key = `${repoDir}\0${remote}`;
|
|
762
|
+
if (safety.lsCache.has(key)) return safety.lsCache.get(key);
|
|
763
|
+
let map = null;
|
|
764
|
+
const res = safety.gitFn('git', ['-C', repoDir, 'ls-remote', remote], netOpts(LS_REMOTE_TIMEOUT_MS));
|
|
765
|
+
if (!res.error && res.status === 0) {
|
|
766
|
+
map = new Map();
|
|
767
|
+
for (const line of okLines(res)) {
|
|
768
|
+
const [sha, ref] = line.trim().split(/\s+/);
|
|
769
|
+
if (!ref || !/^refs\/(heads|tags)\//.test(ref)) continue;
|
|
770
|
+
if (!map.has(sha)) map.set(sha, ref);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
safety.lsCache.set(key, map);
|
|
774
|
+
return map;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Does a qualifying remote hold a branch or tag at exactly this commit,
|
|
778
|
+
// right now? This only decides whether --backup can skip a tag; archive
|
|
779
|
+
// never depends on it, so it stays deliberately simple — no ancestry
|
|
780
|
+
// walk, no fetch, nothing written locally.
|
|
781
|
+
function tipSafety(safety, repoDir, sha) {
|
|
782
|
+
for (const remote of safety.remotes(repoDir)) {
|
|
783
|
+
if (!remoteQualifies(safety, repoDir, remote)) continue;
|
|
784
|
+
const refs = remoteRefs(safety, repoDir, remote);
|
|
785
|
+
if (!refs) continue;
|
|
786
|
+
const exact = refs.get(sha);
|
|
787
|
+
if (exact) return { safe: true, by: 'remote', remote, ref: exact };
|
|
788
|
+
}
|
|
789
|
+
return { safe: false };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function makeSafety(gitFn, rootDir) {
|
|
793
|
+
const safety = {
|
|
794
|
+
gitFn,
|
|
795
|
+
rootDir,
|
|
796
|
+
lsCache: new Map(),
|
|
797
|
+
qualCache: new Map(),
|
|
798
|
+
remotesCache: new Map(),
|
|
799
|
+
remotes(repoDir) {
|
|
800
|
+
if (!safety.remotesCache.has(repoDir)) {
|
|
801
|
+
const res = run(gitFn, repoDir, ['remote']);
|
|
802
|
+
safety.remotesCache.set(repoDir, res.status === 0 ? okLines(res) : []);
|
|
803
|
+
}
|
|
804
|
+
return safety.remotesCache.get(repoDir);
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
return safety;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// The real path of a worktree's shared git directory — which repository
|
|
811
|
+
// it belongs to.
|
|
812
|
+
function commonDirOf(gitFn, wtPath) {
|
|
813
|
+
const res = run(gitFn, wtPath, ['rev-parse', '--git-common-dir']);
|
|
814
|
+
if (res.status !== 0) return null;
|
|
815
|
+
return realPath(resolve(wtPath, String(res.stdout).trim()));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// The tips a session holds, for --backup: the session branch in the
|
|
819
|
+
// workspace repo and in every project repo it names (the tracker's
|
|
820
|
+
// branch: and repos:, falling back to the workspace worktree's branch and
|
|
821
|
+
// the directories under workspace/repos/), plus every worktree's current
|
|
822
|
+
// HEAD, branch or detached. Repo names that are not single in-root
|
|
823
|
+
// segments are skipped here — git is never run in them — and reported by
|
|
824
|
+
// validateTrackerShape.
|
|
825
|
+
function sessionTips(gitFn, rootDir, folder) {
|
|
826
|
+
const wsDir = join(folder, 'workspace');
|
|
827
|
+
const tracker = readTracker(wsDir);
|
|
828
|
+
const rawWorktrees = collectSessionWorktrees(gitFn, rootDir, folder);
|
|
829
|
+
const worktrees = rawWorktrees.map((w) => ({
|
|
830
|
+
...w,
|
|
831
|
+
rootDir,
|
|
832
|
+
branch: w.kind === 'foreign' ? null : currentBranch(gitFn, w.path),
|
|
833
|
+
head: w.kind === 'foreign' ? null : headSha(gitFn, w.path),
|
|
834
|
+
commonDir: w.kind === 'foreign' ? null : commonDirOf(gitFn, w.path),
|
|
835
|
+
}));
|
|
836
|
+
|
|
837
|
+
let branch = tracker?.branch || null;
|
|
838
|
+
let repos = tracker ? [...tracker.repos] : [];
|
|
839
|
+
const discovered = repos.length === 0;
|
|
840
|
+
if (discovered) {
|
|
841
|
+
const nested = join(wsDir, 'repos');
|
|
842
|
+
if (existsSync(nested)) {
|
|
843
|
+
// Mirror cleanup's discovery: every entry that statSync reports as
|
|
844
|
+
// a directory (symlinks to directories included). Entries that are
|
|
845
|
+
// not really worktrees are caught by the structure allowlist, but
|
|
846
|
+
// the deletion set stays a superset either way.
|
|
847
|
+
repos = readdirSync(nested).filter((n) => {
|
|
848
|
+
try {
|
|
849
|
+
return statSync(join(nested, n)).isDirectory();
|
|
850
|
+
} catch {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (!branch) {
|
|
857
|
+
const wsWt = worktrees.find((w) => w.kind === 'workspace');
|
|
858
|
+
branch = wsWt ? wsWt.branch : null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Tips: the branch ref in every repo cleanup will delete it from, plus
|
|
862
|
+
// every worktree HEAD. Deduped per repo+sha, branch names preferred.
|
|
863
|
+
const tips = [];
|
|
864
|
+
const seen = new Map();
|
|
865
|
+
const addTip = (repoDir, repo, kind, ref, sha) => {
|
|
866
|
+
if (!sha || !existsSync(repoDir)) return;
|
|
867
|
+
const key = `${repoDir}\0${sha}`;
|
|
868
|
+
const prev = seen.get(key);
|
|
869
|
+
if (prev) {
|
|
870
|
+
if (!prev.ref && ref) prev.ref = ref;
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const tip = { repoDir, repo, kind, ref: ref ?? null, sha };
|
|
874
|
+
seen.set(key, tip);
|
|
875
|
+
tips.push(tip);
|
|
876
|
+
};
|
|
877
|
+
if (branch) {
|
|
878
|
+
addTip(rootDir, WORKSPACE_REPO, 'workspace', branch, branchTip(gitFn, rootDir, branch));
|
|
879
|
+
const reposRoot = realPath(join(rootDir, 'repos'));
|
|
880
|
+
for (const repo of repos) {
|
|
881
|
+
if (!isRepoSegment(repo)) continue;
|
|
882
|
+
const repoDir = join(rootDir, 'repos', repo);
|
|
883
|
+
if (!insideDir(reposRoot, repoDir)) continue;
|
|
884
|
+
addTip(repoDir, repo, 'project', branch, branchTip(gitFn, repoDir, branch));
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
for (const wt of worktrees) {
|
|
888
|
+
if (wt.kind === 'foreign') continue;
|
|
889
|
+
addTip(realPath(wt.commonDir ? dirname(wt.commonDir) : wt.path), wt.repo, wt.kind, wt.branch, wt.head);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return { folder, branch, repos, discovered, tracker, worktrees, tips };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function branchTip(gitFn, repoDir, branch) {
|
|
896
|
+
const res = run(gitFn, repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
897
|
+
return res.status === 0 ? String(res.stdout).trim() : null;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// The tracker is data from a branch — never trusted as a path or a ref
|
|
901
|
+
// name. Repo names must be single in-root segments, and the branch must
|
|
902
|
+
// satisfy git's own ref format, before --backup acts on either.
|
|
903
|
+
function validateTrackerShape(gitFn, rootDir, del) {
|
|
904
|
+
const reasons = [];
|
|
905
|
+
if (del.branch) {
|
|
906
|
+
const res = gitFn('git', ['check-ref-format', '--branch', del.branch], { encoding: 'utf8' });
|
|
907
|
+
if (res.error || res.status !== 0) {
|
|
908
|
+
reasons.push(`tracker branch "${del.branch}" is not a valid branch name — reconcile the tracker first`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const reposRoot = realPath(join(rootDir, 'repos'));
|
|
912
|
+
for (const repo of del.repos) {
|
|
913
|
+
if (!isRepoSegment(repo)) {
|
|
914
|
+
reasons.push(`tracker repos entry "${repo}" is not a single path segment — reconcile the tracker first`);
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (!insideDir(reposRoot, join(rootDir, 'repos', repo))) {
|
|
918
|
+
reasons.push(`tracker repos entry "${repo}" escapes ${relative(rootDir, join(rootDir, 'repos'))}/ — reconcile the tracker first`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return reasons;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// --backup acts only on a session of the known shape: workspace/ a
|
|
925
|
+
// worktree of the workspace repo, each workspace/repos/{name} a worktree
|
|
926
|
+
// of root/repos/{name}, tracker consistent with both. Anything else is
|
|
927
|
+
// refused by name and left for the operator (archive still works — it
|
|
928
|
+
// keeps everything regardless of shape).
|
|
929
|
+
function structureReasons(gitFn, rootDir, folder, del) {
|
|
930
|
+
const reasons = [];
|
|
931
|
+
const wsDir = join(folder, 'workspace');
|
|
932
|
+
|
|
933
|
+
for (const entry of readdirSync(folder)) {
|
|
934
|
+
if (entry !== 'workspace') {
|
|
935
|
+
reasons.push(`unexpected entry "${entry}" beside workspace/ — a session folder holds only workspace/; reconcile first`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const wsWt = del.worktrees.find((w) => w.kind === 'workspace');
|
|
940
|
+
if (!wsWt) {
|
|
941
|
+
reasons.push('workspace/ is not a git worktree — reconcile first');
|
|
942
|
+
} else if (!wsWt.commonDir || wsWt.commonDir !== realPath(join(rootDir, '.git'))) {
|
|
943
|
+
reasons.push(`workspace/ is not a worktree of the workspace repo (common dir ${wsWt.commonDir ?? 'unknown'}) — reconcile first`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const nestedDir = join(wsDir, 'repos');
|
|
947
|
+
const nestedNames = [];
|
|
948
|
+
if (existsSync(nestedDir)) {
|
|
949
|
+
for (const entry of readdirSync(nestedDir, { withFileTypes: true })) {
|
|
950
|
+
const p = join(nestedDir, entry.name);
|
|
951
|
+
if (entry.isSymbolicLink()) {
|
|
952
|
+
reasons.push(`repos/${entry.name} is a symlink, not a worktree — reconcile first`);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
if (entry.isFile()) {
|
|
956
|
+
reasons.push(`repos/${entry.name} is a file, not a worktree — reconcile first`);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (!entry.isDirectory()) continue; // sockets/fifos and the like: unreadable, refuse via the file branch if git ever lists them
|
|
960
|
+
if (!existsSync(join(p, '.git'))) {
|
|
961
|
+
reasons.push(`repos/${entry.name} is a plain directory, not a worktree — reconcile first`);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const expected = realPath(join(rootDir, 'repos', entry.name, '.git'));
|
|
965
|
+
const common = commonDirOf(gitFn, p);
|
|
966
|
+
if (!common || common !== expected) {
|
|
967
|
+
reasons.push(`repos/${entry.name} is not a worktree of repos/${entry.name} (plain clone or foreign repository; common dir ${common ?? 'unknown'}) — reconcile first`);
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
nestedNames.push(entry.name);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (del.tracker && del.tracker.repos.length > 0) {
|
|
975
|
+
const tracked = [...del.tracker.repos].map(String).sort();
|
|
976
|
+
if (JSON.stringify(tracked) !== JSON.stringify([...nestedNames].sort())) {
|
|
977
|
+
reasons.push(`tracker repos [${tracked.join(', ')}] do not match the nested worktrees [${[...nestedNames].sort().join(', ')}] — reconcile the tracker first`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (del.tracker?.branch) {
|
|
982
|
+
for (const w of del.worktrees) {
|
|
983
|
+
if (w.kind !== 'foreign' && w.branch && w.branch !== del.tracker.branch) {
|
|
984
|
+
reasons.push(`tracker says branch ${del.tracker.branch} but the ${w.kind} worktree is on ${w.branch} (drift) — reconcile the tracker first`);
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return reasons;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Guards shared by every acting mode: the session folder must be a real
|
|
993
|
+
// directory inside the root (a symlinked entry may point anywhere), and
|
|
994
|
+
// the session hosting the current chat is never acted on from inside
|
|
995
|
+
// itself — on Windows a directory that is a live process's cwd cannot
|
|
996
|
+
// even be renamed.
|
|
997
|
+
function sessionFolderGuards(rootDir, sessionsDir, session, cwd) {
|
|
998
|
+
const folder = join(sessionsDir, session);
|
|
999
|
+
if (!existsSync(folder)) {
|
|
1000
|
+
return { folder, refusal: { refused: true, reasons: [`no session named "${session}" under ${relative(rootDir, sessionsDir)}`] } };
|
|
1001
|
+
}
|
|
1002
|
+
let st = null;
|
|
1003
|
+
try { st = lstatSync(folder); } catch { /* handled below */ }
|
|
1004
|
+
if (!st || !st.isDirectory() || st.isSymbolicLink()) {
|
|
1005
|
+
return { folder, refusal: { refused: true, reasons: [`session entry "${session}" is not a real directory (symlink or missing) — never followed; reconcile manually`] } };
|
|
1006
|
+
}
|
|
1007
|
+
if (!insideRoot(rootDir, folder)) {
|
|
1008
|
+
return { folder, refusal: { refused: true, reasons: [`session folder "${session}" resolves outside the workspace root — refusing`] } };
|
|
1009
|
+
}
|
|
1010
|
+
const cwdReal = realPath(resolve(cwd || '.'));
|
|
1011
|
+
if (cwdReal.startsWith(realPath(folder) + sep) || cwdReal === realPath(folder)) {
|
|
1012
|
+
return { folder, refusal: { refused: true, reasons: [`session "${session}" hosts the current chat — run its backup/archive from the workspace root, not from inside the session`] } };
|
|
1013
|
+
}
|
|
1014
|
+
return { folder, refusal: null };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// The local peeled SHA of an existing tag, or null when the tag is
|
|
1018
|
+
// absent. Refnames cannot contain ^{ (check-ref-format forbids ^), so
|
|
1019
|
+
// appending ^{} to interpolate the peel is safe.
|
|
1020
|
+
function peeledTagSha(gitFn, cwd, tag) {
|
|
1021
|
+
const res = run(gitFn, cwd, ['rev-parse', '-q', '--verify', `refs/tags/${tag}^{}`]);
|
|
1022
|
+
return res.status === 0 ? String(res.stdout).trim() : null;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Does {tag} exist on {remote} at exactly {commit}? Deliberately no
|
|
1026
|
+
// refspec pattern: a pattern filters out the peeled `^{}` line (the
|
|
1027
|
+
// tag object's sha is not the commit's), and the peeled line is exactly
|
|
1028
|
+
// what "at this commit" needs. An annotated tag answers via the peel; a
|
|
1029
|
+
// lightweight tag's only line already is the commit.
|
|
1030
|
+
function tagOnRemoteAt(gitFn, cwd, remote, tag, commit) {
|
|
1031
|
+
const res = gitFn('git', ['-C', cwd, 'ls-remote', '--tags', remote], netOpts(LS_REMOTE_TIMEOUT_MS));
|
|
1032
|
+
if (res.error || res.status !== 0) return false;
|
|
1033
|
+
const peeledRef = `refs/tags/${tag}^{}`;
|
|
1034
|
+
const plainRef = `refs/tags/${tag}`;
|
|
1035
|
+
let plainSha = false;
|
|
1036
|
+
for (const line of okLines(res)) {
|
|
1037
|
+
const [sha, ref] = line.trim().split(/\s+/);
|
|
1038
|
+
if (ref === peeledRef) return sha === commit;
|
|
1039
|
+
if (ref === plainRef) plainSha = sha;
|
|
1040
|
+
}
|
|
1041
|
+
return plainSha === commit;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// S4: where a backup tag is pushed — branch.<b>.pushRemote, then
|
|
1045
|
+
// remote.pushDefault, then branch.<b>.remote, then origin, then the
|
|
1046
|
+
// first configured remote. An explicit --remote overrides everything.
|
|
1047
|
+
function resolvePushRemote(gitFn, repoDir, branch, override, remotes) {
|
|
1048
|
+
if (override) {
|
|
1049
|
+
return remotes.includes(override)
|
|
1050
|
+
? { remote: override }
|
|
1051
|
+
: { remote: null, reason: `--remote ${override} is not configured for this repo (has: ${remotes.join(', ') || 'none'})` };
|
|
1052
|
+
}
|
|
1053
|
+
const cfg = (key) => {
|
|
1054
|
+
const res = run(gitFn, repoDir, ['config', '--get', key]);
|
|
1055
|
+
return res.status === 0 ? String(res.stdout).trim() : null;
|
|
1056
|
+
};
|
|
1057
|
+
const chain = branch
|
|
1058
|
+
? [`branch.${branch}.pushRemote`, 'remote.pushDefault', `branch.${branch}.remote`]
|
|
1059
|
+
: ['remote.pushDefault'];
|
|
1060
|
+
for (const key of chain) {
|
|
1061
|
+
const value = cfg(key);
|
|
1062
|
+
if (value && remotes.includes(value)) return { remote: value };
|
|
1063
|
+
}
|
|
1064
|
+
if (remotes.includes('origin')) return { remote: 'origin' };
|
|
1065
|
+
if (remotes.length > 0) return { remote: remotes[0] };
|
|
1066
|
+
return { remote: null };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Back up every tip the session holds that no qualifying remote already
|
|
1071
|
+
* has as a branch or tag: an annotated, session-scoped `drain/{session}/…`
|
|
1072
|
+
* tag at the tip, pushed to the resolved remote and verified there — an
|
|
1073
|
+
* off-machine copy of the session's commits before it is archived, and
|
|
1074
|
+
* the thing to check before the operator ever deletes an archive. With dryRun the
|
|
1075
|
+
* plan is reported per tip (remote, tag, already-safe) with no side
|
|
1076
|
+
* effects — backup is a decision, and the operator sees exactly what
|
|
1077
|
+
* would be pushed where before saying yes. Idempotent — an existing tag
|
|
1078
|
+
* at the same commit is fine; at a different commit (the branch moved)
|
|
1079
|
+
* the operator must decide, so it refuses.
|
|
1080
|
+
*/
|
|
1081
|
+
function backupSession(root, { session, remote: remoteOverride = null, dryRun = false, gitFn = spawnSync, cwd = process.cwd() } = {}) {
|
|
1082
|
+
const rootDir = resolveRoot(root);
|
|
1083
|
+
if (!isSessionSegment(session)) {
|
|
1084
|
+
throw new Error(`session name must be a single path segment, got: ${session}`);
|
|
1085
|
+
}
|
|
1086
|
+
const sessionsDir = sessionsDirOf(rootDir);
|
|
1087
|
+
const { folder, refusal } = sessionFolderGuards(rootDir, sessionsDir, session, cwd);
|
|
1088
|
+
if (refusal) return refusal;
|
|
1089
|
+
|
|
1090
|
+
const del = sessionTips(gitFn, rootDir, folder);
|
|
1091
|
+
const reasons = [...validateTrackerShape(gitFn, rootDir, del)];
|
|
1092
|
+
if (existsSync(join(folder, 'workspace', '.git'))) {
|
|
1093
|
+
reasons.push(...structureReasons(gitFn, rootDir, folder, del));
|
|
1094
|
+
}
|
|
1095
|
+
const branches = [];
|
|
1096
|
+
const skipped = [];
|
|
1097
|
+
const planned = [];
|
|
1098
|
+
if (reasons.length === 0 && existsSync(join(folder, 'workspace', '.git'))) {
|
|
1099
|
+
const safety = makeSafety(gitFn, rootDir);
|
|
1100
|
+
for (const tip of del.tips) {
|
|
1101
|
+
const verdict = tipSafety(safety, tip.repoDir, tip.sha);
|
|
1102
|
+
if (verdict.safe) {
|
|
1103
|
+
// Already provably on a qualifying remote — pushing a tag for it
|
|
1104
|
+
// would only add noise (and possibly land in a public repo).
|
|
1105
|
+
skipped.push({ repo: tip.repo, ref: tip.ref ?? 'HEAD', commit: tip.sha, safeOn: verdict.remote, safeRef: verdict.ref });
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const shortSha = tip.sha.slice(0, 10);
|
|
1109
|
+
const tagName = tip.ref
|
|
1110
|
+
? `drain/${session}/${slugForBranch(tip.ref)}`
|
|
1111
|
+
: `drain/${session}/${tip.kind === 'workspace' ? 'workspace' : tip.repo}-detached-${shortSha}`;
|
|
1112
|
+
const tagDir = tip.repoDir;
|
|
1113
|
+
const remotes = safety.remotes(tagDir);
|
|
1114
|
+
if (remotes.length === 0) {
|
|
1115
|
+
reasons.push(`${repoLabel(tip)} has no remote to push the backup tag ${tagName} to — decide manually where to back up ${tip.ref ?? shortSha}`);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
const { remote, reason } = resolvePushRemote(gitFn, tagDir, tip.ref, remoteOverride, remotes);
|
|
1119
|
+
if (!remote) {
|
|
1120
|
+
reasons.push(reason || `${repoLabel(tip)}: no push remote resolves — decide manually`);
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
const existing = peeledTagSha(gitFn, tagDir, tagName);
|
|
1124
|
+
if (existing && existing !== tip.sha) {
|
|
1125
|
+
reasons.push(`${repoLabel(tip)}: tag ${tagName} already points at ${existing.slice(0, 10)}…, not the current tip (${shortSha}…); the branch moved — decide manually`);
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (dryRun) {
|
|
1129
|
+
planned.push({
|
|
1130
|
+
repo: tip.repo,
|
|
1131
|
+
branch: tip.ref,
|
|
1132
|
+
detached: !tip.ref,
|
|
1133
|
+
commit: tip.sha,
|
|
1134
|
+
tag: tagName,
|
|
1135
|
+
remote,
|
|
1136
|
+
alreadySafe: false,
|
|
1137
|
+
wouldCreate: !existing,
|
|
1138
|
+
});
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
let createdThisRun = false;
|
|
1142
|
+
if (!existing) {
|
|
1143
|
+
const created = run(gitFn, tagDir, ['tag', '-a', tagName, '-m', `backup before draining session ${session}`, tip.sha]);
|
|
1144
|
+
if (created.status !== 0) {
|
|
1145
|
+
reasons.push(`${repoLabel(tip)}: git tag ${tagName} failed: ${String(created.stderr || '').trim()}`);
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
createdThisRun = true;
|
|
1149
|
+
}
|
|
1150
|
+
const pushed = gitFn('git', ['-C', tagDir, 'push', remote, `refs/tags/${tagName}`], netOpts(PUSH_TIMEOUT_MS));
|
|
1151
|
+
if (pushed.error || pushed.status !== 0) {
|
|
1152
|
+
// A local tag that never reached a remote masquerades as a
|
|
1153
|
+
// backup (and local refs prove nothing) — remove the one we made.
|
|
1154
|
+
if (createdThisRun) run(gitFn, tagDir, ['tag', '-d', tagName]);
|
|
1155
|
+
const detail = pushed.error ? `timed out after ${PUSH_TIMEOUT_MS / 1000}s` : String(pushed.stderr || '').trim();
|
|
1156
|
+
reasons.push(`${repoLabel(tip)}: pushing ${tagName} to ${remote} failed (${detail}) — treat ${tip.ref ?? shortSha} as unbacked`);
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
const verified = tagOnRemoteAt(gitFn, tagDir, remote, tagName, tip.sha);
|
|
1160
|
+
if (!verified) {
|
|
1161
|
+
if (createdThisRun) run(gitFn, tagDir, ['tag', '-d', tagName]);
|
|
1162
|
+
reasons.push(`${repoLabel(tip)}: tag ${tagName} not found on ${remote} after pushing — treat ${tip.ref ?? shortSha} as unbacked`);
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
branches.push({
|
|
1166
|
+
repo: tip.repo,
|
|
1167
|
+
branch: tip.ref,
|
|
1168
|
+
detached: !tip.ref,
|
|
1169
|
+
tag: tagName,
|
|
1170
|
+
commit: tip.sha,
|
|
1171
|
+
remote,
|
|
1172
|
+
pushed: true,
|
|
1173
|
+
verified: true,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (reasons.length > 0) return { refused: true, reasons };
|
|
1178
|
+
if (dryRun) return { session, dryRun: true, tips: planned, skipped };
|
|
1179
|
+
return { session, branches, skipped };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Walk a session folder, never following a symlink, and report what
|
|
1183
|
+
// archive needs to know: every `.git` FILE (a linked worktree or a
|
|
1184
|
+
// submodule checkout — its gitdir: line names the owner), every relative
|
|
1185
|
+
// symlink whose target leaves the folder (it will dangle once the folder
|
|
1186
|
+
// sits one level deeper), and every directory it could not read. An
|
|
1187
|
+
// unread directory could hide a worktree, so it refuses the archive
|
|
1188
|
+
// rather than being skipped. Embedded repositories (`.git` DIRECTORIES)
|
|
1189
|
+
// need no bookkeeping — they move with the folder.
|
|
1190
|
+
function scanSessionFolder(folder) {
|
|
1191
|
+
const out = { markers: [], unreadable: [], outwardLinks: [] };
|
|
1192
|
+
const walk = (dir) => {
|
|
1193
|
+
let entries;
|
|
1194
|
+
try {
|
|
1195
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
out.unreadable.push({ dir, code: err.code || err.message });
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
for (const entry of entries) {
|
|
1201
|
+
const p = join(dir, entry.name);
|
|
1202
|
+
if (entry.isSymbolicLink()) {
|
|
1203
|
+
try {
|
|
1204
|
+
const target = readlinkSync(p);
|
|
1205
|
+
if (!isAbsolute(target) && !insideDir(realPath(folder), resolve(dir, target))) out.outwardLinks.push(p);
|
|
1206
|
+
} catch { /* unreadable link: it moves as-is */ }
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
if (entry.name === '.git') {
|
|
1210
|
+
if (entry.isFile()) out.markers.push(p);
|
|
1211
|
+
continue; // never descend into a git directory
|
|
1212
|
+
}
|
|
1213
|
+
if (entry.isDirectory()) walk(p);
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
walk(folder);
|
|
1217
|
+
return out;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// The admin directory a `.git` file points at, or null.
|
|
1221
|
+
function gitdirOfMarker(markerPath) {
|
|
1222
|
+
try {
|
|
1223
|
+
const m = /^gitdir:\s*(.+)\s*$/m.exec(readFileSync(markerPath, 'utf8'));
|
|
1224
|
+
return m ? realPath(resolve(dirname(markerPath), m[1].trim())) : null;
|
|
1225
|
+
} catch {
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// The repositories this workspace owns: the workspace repo and every
|
|
1231
|
+
// root/repos/{name} clone. Only these are ever repaired.
|
|
1232
|
+
function workspaceRepos(rootDir) {
|
|
1233
|
+
const repos = [{ repo: WORKSPACE_REPO, dir: rootDir, gitDir: realPath(join(rootDir, '.git')) }];
|
|
1234
|
+
const reposRoot = join(rootDir, 'repos');
|
|
1235
|
+
if (existsSync(reposRoot)) {
|
|
1236
|
+
for (const entry of readdirSync(reposRoot, { withFileTypes: true })) {
|
|
1237
|
+
if (!entry.isDirectory() || !isRepoSegment(entry.name)) continue;
|
|
1238
|
+
const dir = join(reposRoot, entry.name);
|
|
1239
|
+
if (existsSync(join(dir, '.git'))) repos.push({ repo: entry.name, dir, gitDir: realPath(join(dir, '.git')) });
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return repos;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Every worktree path a repository has registered, from git itself —
|
|
1246
|
+
// the authority the folder walk is checked against.
|
|
1247
|
+
function registeredWorktrees(gitFn, repoDir) {
|
|
1248
|
+
const res = run(gitFn, repoDir, ['worktree', 'list', '--porcelain']);
|
|
1249
|
+
if (res.status !== 0) return null;
|
|
1250
|
+
return okLines(res).filter((l) => l.startsWith('worktree ')).map((l) => realPath(l.slice(9)));
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Registered worktrees git would prune — their recorded path no longer
|
|
1254
|
+
// exists. Compared before and after a move, a new entry means a link the
|
|
1255
|
+
// move broke.
|
|
1256
|
+
function prunable(gitFn, repoDir) {
|
|
1257
|
+
const res = run(gitFn, repoDir, ['worktree', 'prune', '--dry-run', '-v']);
|
|
1258
|
+
if (res.status !== 0) return null;
|
|
1259
|
+
return okLines(res);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function archiveStamp(now) {
|
|
1263
|
+
return new Date(now).toISOString().replace(/[-:]/g, '').replace(/\..*$/, '');
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Point each owning repository at its worktrees' current location, then
|
|
1267
|
+
// prove it: every worktree resolves to itself, its owner lists it there,
|
|
1268
|
+
// and no owned repository has a newly prunable entry. Git failures of
|
|
1269
|
+
// any kind — including a thrown spawn error — become problems, never
|
|
1270
|
+
// exceptions, so the caller can always roll back.
|
|
1271
|
+
function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
|
|
1272
|
+
const problems = [];
|
|
1273
|
+
try {
|
|
1274
|
+
const byRepo = new Map();
|
|
1275
|
+
for (const w of worktreePaths) {
|
|
1276
|
+
if (!byRepo.has(w.owner.dir)) byRepo.set(w.owner.dir, []);
|
|
1277
|
+
byRepo.get(w.owner.dir).push(w.path);
|
|
1278
|
+
}
|
|
1279
|
+
for (const [repoDir, paths] of byRepo) {
|
|
1280
|
+
const res = run(gitFn, repoDir, ['worktree', 'repair', ...paths]);
|
|
1281
|
+
if (res.error || res.status !== 0) problems.push(`git worktree repair in ${repoDir} failed: ${String(res.stderr || res.error?.message || '').trim()}`);
|
|
1282
|
+
}
|
|
1283
|
+
for (const w of worktreePaths) {
|
|
1284
|
+
const top = run(gitFn, w.path, ['rev-parse', '--show-toplevel']);
|
|
1285
|
+
if (top.error || top.status !== 0 || realPath(String(top.stdout).trim()) !== realPath(w.path)) {
|
|
1286
|
+
problems.push(`worktree at ${w.path} does not resolve to itself after repair`);
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
const listed = registeredWorktrees(gitFn, w.owner.dir);
|
|
1290
|
+
if (!listed || !listed.includes(realPath(w.path))) problems.push(`${w.owner.dir} does not list the worktree at ${w.path}`);
|
|
1291
|
+
}
|
|
1292
|
+
for (const o of owned) {
|
|
1293
|
+
const after = prunable(gitFn, o.dir);
|
|
1294
|
+
if (after === null) { problems.push(`could not check ${o.dir} for broken worktree links`); continue; }
|
|
1295
|
+
const before = new Set(prunableBefore.get(o.dir) || []);
|
|
1296
|
+
const fresh = after.filter((l) => !before.has(l));
|
|
1297
|
+
if (fresh.length > 0) problems.push(`${o.dir} has broken worktree links: ${fresh.join('; ')}`);
|
|
1298
|
+
}
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
problems.push(`git failed while repairing: ${err.message}`);
|
|
1301
|
+
}
|
|
1302
|
+
return problems;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Take a session out of the active lifecycle without destroying anything:
|
|
1307
|
+
* rename its folder into {sessions}/.archived/{session}--{stamp}/ and
|
|
1308
|
+
* repair git's worktree links so every repository follows it. Every
|
|
1309
|
+
* file, ref, stash, index flag and embedded repository is kept, because
|
|
1310
|
+
* nothing is deleted. The session's branches stay checked out in the
|
|
1311
|
+
* archived worktrees until the operator removes them.
|
|
1312
|
+
*
|
|
1313
|
+
* Refused, with nothing moved, when: a directory in the folder cannot be
|
|
1314
|
+
* read (it could hide a worktree); a worktree belongs to a repository
|
|
1315
|
+
* outside this workspace; a submodule checkout is present (its link is
|
|
1316
|
+
* not a worktree link and `worktree repair` cannot fix it); git's own
|
|
1317
|
+
* list of worktrees names one under the folder that the walk did not
|
|
1318
|
+
* find; or the archive directory is a symlink or resolves outside the
|
|
1319
|
+
* workspace. If anything fails after the rename, the folder is renamed
|
|
1320
|
+
* back and repaired, and the result reports the verified state.
|
|
1321
|
+
*/
|
|
1322
|
+
function archiveSession(root, { session, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
|
|
1323
|
+
const rootDir = resolveRoot(root);
|
|
1324
|
+
if (!isSessionSegment(session)) {
|
|
1325
|
+
throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
|
|
1326
|
+
}
|
|
1327
|
+
const sessionsDir = sessionsDirOf(rootDir);
|
|
1328
|
+
const { folder, refusal } = sessionFolderGuards(rootDir, sessionsDir, session, cwd);
|
|
1329
|
+
if (refusal) return refusal;
|
|
1330
|
+
|
|
1331
|
+
const owned = workspaceRepos(rootDir);
|
|
1332
|
+
const ownedByGitDir = new Map(owned.map((o) => [o.gitDir, o]));
|
|
1333
|
+
const scan = scanSessionFolder(folder);
|
|
1334
|
+
const reasons = scan.unreadable.map((u) => `cannot read ${relative(rootDir, u.dir)} (${u.code}) — it could hide a worktree; fix its permissions and retry`);
|
|
1335
|
+
const found = [];
|
|
1336
|
+
for (const marker of scan.markers) {
|
|
1337
|
+
const admin = gitdirOfMarker(marker);
|
|
1338
|
+
const wtPath = dirname(marker);
|
|
1339
|
+
if (!admin) {
|
|
1340
|
+
reasons.push(`${relative(rootDir, marker)} has no readable gitdir — reconcile it manually first`);
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
// A linked worktree's admin dir is exactly {gitDir}/worktrees/{id}.
|
|
1344
|
+
const owner = ownedByGitDir.get(dirname(dirname(admin)));
|
|
1345
|
+
if (owner && dirname(admin) === join(owner.gitDir, 'worktrees')) {
|
|
1346
|
+
found.push({ rel: relative(folder, wtPath) || '.', owner });
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
const insideOwned = owned.some((o) => insideDir(o.gitDir, admin));
|
|
1350
|
+
reasons.push(insideOwned
|
|
1351
|
+
? `${relative(rootDir, wtPath)} is a submodule checkout — moving it would break its link, and worktree repair cannot fix that; finish or keep this session instead`
|
|
1352
|
+
: `${relative(rootDir, wtPath)} is a worktree of a repository outside this workspace (${admin}) — refusing to touch it; move or remove it manually`);
|
|
1353
|
+
}
|
|
1354
|
+
// Git is the authority on which worktrees exist: every one it has
|
|
1355
|
+
// registered under this folder must be one the walk found.
|
|
1356
|
+
const folderReal = realPath(folder);
|
|
1357
|
+
const foundPaths = new Set(found.map((f) => realPath(f.rel === '.' ? folder : join(folder, f.rel))));
|
|
1358
|
+
for (const o of owned) {
|
|
1359
|
+
const listed = registeredWorktrees(gitFn, o.dir);
|
|
1360
|
+
if (listed === null) { reasons.push(`could not list worktrees of ${relative(rootDir, o.dir) || '.'} — refusing rather than guessing`); continue; }
|
|
1361
|
+
for (const p of listed) {
|
|
1362
|
+
if ((p === folderReal || p.startsWith(folderReal + sep)) && !foundPaths.has(p)) {
|
|
1363
|
+
reasons.push(`${relative(rootDir, o.dir) || 'the workspace repo'} has a worktree at ${relative(rootDir, p)} that the folder scan could not see — reconcile it first`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (reasons.length > 0) return { refused: true, reasons };
|
|
1368
|
+
|
|
1369
|
+
const archiveDir = join(sessionsDir, '.archived');
|
|
1370
|
+
let ast = null;
|
|
1371
|
+
try { ast = lstatSync(archiveDir); } catch { /* absent: created below */ }
|
|
1372
|
+
if (ast && (ast.isSymbolicLink() || !ast.isDirectory())) {
|
|
1373
|
+
return { refused: true, reasons: [`${relative(rootDir, archiveDir)} exists but is not a real directory (symlink or file) — archives must stay inside the workspace; move it aside first`] };
|
|
1374
|
+
}
|
|
1375
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
1376
|
+
if (realPath(archiveDir) !== join(realPath(sessionsDir), '.archived') || !insideRoot(rootDir, archiveDir)) {
|
|
1377
|
+
return { refused: true, reasons: [`${relative(rootDir, archiveDir)} resolves outside the workspace — refusing`] };
|
|
1378
|
+
}
|
|
1379
|
+
const dest = join(archiveDir, `${session}--${archiveStamp(now)}`);
|
|
1380
|
+
if (existsSync(dest)) {
|
|
1381
|
+
return { refused: true, reasons: [`${relative(rootDir, dest)} already exists — wait a second and retry`] };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const prunableBefore = new Map(owned.map((o) => [o.dir, prunable(gitFn, o.dir) || []]));
|
|
1385
|
+
try {
|
|
1386
|
+
renameSync(folder, dest);
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
return { refused: true, reasons: [`could not move ${relative(rootDir, folder)}: ${err.code || err.message} — close anything using files in it (editors, terminals, dev servers) and retry`] };
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const at = (base) => found.map((f) => ({ owner: f.owner, path: f.rel === '.' ? base : join(base, f.rel) }));
|
|
1392
|
+
const problems = repairAndVerify(gitFn, owned, at(dest), prunableBefore);
|
|
1393
|
+
if (problems.length > 0) {
|
|
1394
|
+
let restored = false;
|
|
1395
|
+
try { renameSync(dest, folder); restored = true; } catch { /* reported below */ }
|
|
1396
|
+
const where = restored ? folder : dest;
|
|
1397
|
+
const after = repairAndVerify(gitFn, owned, at(where), prunableBefore);
|
|
1398
|
+
const state = after.length === 0
|
|
1399
|
+
? `the session is ${restored ? 'back at' : 'still at'} ${relative(rootDir, where)} with every worktree link verified; nothing was lost`
|
|
1400
|
+
: `the session is at ${relative(rootDir, where)}, but some worktree links are still broken (${after.join('; ')}); nothing has been deleted — do NOT run \`git worktree prune\`; run \`git -C <repo> worktree repair <path>\` for each worktree under ${relative(rootDir, where)}`;
|
|
1401
|
+
return { refused: true, reasons: [...problems, state] };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
return {
|
|
1405
|
+
session,
|
|
1406
|
+
archived: true,
|
|
1407
|
+
from: relative(rootDir, folder),
|
|
1408
|
+
to: relative(rootDir, dest),
|
|
1409
|
+
worktrees: at(dest).map((m) => ({ repo: m.owner.repo, path: relative(rootDir, m.path) })),
|
|
1410
|
+
warnings: scan.outwardLinks.map((l) => `relative symlink ${relative(rootDir, join(dest, relative(folder, l)))} pointed outside the session and no longer resolves after the move — it was kept as-is`),
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// The launcher root is the main worktree of the workspace repo; every
|
|
1415
|
+
// other checkout is a linked worktree. Acting modes require the
|
|
1416
|
+
// launcher; --enable-task-model is the exception (it edits workspace.json
|
|
1417
|
+
// inside a task worktree by design — S6).
|
|
1418
|
+
function isLinkedWorktree(gitFn, rootDir) {
|
|
1419
|
+
const gitDir = run(gitFn, rootDir, ['rev-parse', '--git-dir']);
|
|
1420
|
+
const commonDir = run(gitFn, rootDir, ['rev-parse', '--git-common-dir']);
|
|
1421
|
+
if (gitDir.status !== 0 || commonDir.status !== 0) return false; // not a git repo at all; other checks will fail loudly
|
|
1422
|
+
return realPath(resolve(rootDir, String(gitDir.stdout).trim()))
|
|
1423
|
+
!== realPath(resolve(rootDir, String(commonDir.stdout).trim()));
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Switch the workspace to the task model: flip workspace.sessionModel to
|
|
1428
|
+
* "task", keeping every other key untouched. From the launcher this also
|
|
1429
|
+
* reports the remaining sessions; from a task worktree (the S6 flow)
|
|
1430
|
+
* there is no sessions directory to read — remainingSessions is null and
|
|
1431
|
+
* the note says where the real list comes from.
|
|
1432
|
+
*/
|
|
1433
|
+
function enableTaskModel(root, { gitFn = spawnSync } = {}) {
|
|
1434
|
+
const rootDir = resolveRoot(root);
|
|
1435
|
+
const cfgPath = join(rootDir, 'workspace.json');
|
|
1436
|
+
let cfg;
|
|
1437
|
+
try {
|
|
1438
|
+
cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
throw new Error(`workspace.json unreadable: ${err.message}`);
|
|
1441
|
+
}
|
|
1442
|
+
// Spread-then-set keeps an existing sessionModel in place and every
|
|
1443
|
+
// other key untouched; 2-space JSON with a trailing newline is the
|
|
1444
|
+
// file's house format.
|
|
1445
|
+
cfg.workspace = { ...(cfg.workspace || {}), sessionModel: 'task' };
|
|
1446
|
+
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
1447
|
+
const linked = isLinkedWorktree(gitFn, rootDir);
|
|
1448
|
+
return linked
|
|
1449
|
+
? {
|
|
1450
|
+
sessionModel: 'task',
|
|
1451
|
+
remainingSessions: null,
|
|
1452
|
+
note: 'run from a task worktree — remaining sessions come from --inventory at the launcher root',
|
|
1453
|
+
}
|
|
1454
|
+
: { sessionModel: 'task', remainingSessions: listSessionNames(rootDir) };
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Human-readable inventory rendering for stderr — the operator's table;
|
|
1458
|
+
// the JSON on stdout is the machine copy.
|
|
1459
|
+
function renderTable(result) {
|
|
1460
|
+
const lines = [
|
|
1461
|
+
`${result.sessions.length} session(s); proposals are proposals — the operator decides each one.`,
|
|
1462
|
+
];
|
|
1463
|
+
for (const s of result.sessions) {
|
|
1464
|
+
lines.push('');
|
|
1465
|
+
lines.push(`${s.name} ${s.kind === 'broken' ? 'broken shell' : s.kind === 'foreign' ? 'foreign entry' : s.proposal}` +
|
|
1466
|
+
(s.kind === 'broken' || s.kind === 'foreign' ? '' : ` (status ${s.status ?? '—'}, last activity ${s.lastActivity ?? '—'}, work item ${s.workItem ?? '—'})`));
|
|
1467
|
+
for (const w of s.worktrees || []) {
|
|
1468
|
+
const remotes = Object.entries(w.remotes)
|
|
1469
|
+
.map(([r, v]) => describeRemote(r, v))
|
|
1470
|
+
.join(' ') || 'no remotes';
|
|
1471
|
+
const extra = w.kind === 'workspace' ? ` content:${w.contentFiles}` : '';
|
|
1472
|
+
lines.push(` ${w.kind === 'workspace' ? '(workspace)' : w.repo} ${w.branch ?? 'detached'} ahead:${w.ahead ?? '?'} dirty:${w.dirty}${extra} [${remotes}]`);
|
|
1473
|
+
}
|
|
1474
|
+
for (const r of s.reasons || []) lines.push(` · ${r}`);
|
|
1475
|
+
for (const w of s.warnings || []) lines.push(` ! ${w.message}`);
|
|
1476
|
+
}
|
|
1477
|
+
return `${lines.join('\n')}\n`;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const MODE_FLAGS = new Set(['--inventory', '--backup', '--archive', '--enable-task-model']);
|
|
1481
|
+
const VALUE_FLAGS = new Map([
|
|
1482
|
+
['--root', 'root'],
|
|
1483
|
+
['--session', 'session'],
|
|
1484
|
+
['--active-days', 'activeDays'],
|
|
1485
|
+
['--remote', 'remote'],
|
|
1486
|
+
]);
|
|
1487
|
+
|
|
1488
|
+
function parseArgs(argv) {
|
|
1489
|
+
const args = { root: '.', mode: null, session: null, activeDays: null, remote: null, dryRun: false };
|
|
1490
|
+
const rest = argv.slice(2);
|
|
1491
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1492
|
+
const a = rest[i];
|
|
1493
|
+
if (MODE_FLAGS.has(a)) {
|
|
1494
|
+
if (args.mode) throw new Error(`only one mode flag may be given (already have --${args.mode})`);
|
|
1495
|
+
args.mode = a.slice(2);
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
if (a === '--dry-run') { args.dryRun = true; continue; }
|
|
1499
|
+
const key = VALUE_FLAGS.get(a);
|
|
1500
|
+
if (key) {
|
|
1501
|
+
const v = rest[i + 1];
|
|
1502
|
+
if (v === undefined || v.startsWith('--')) throw new Error(`${a} requires a value`);
|
|
1503
|
+
args[key] = v;
|
|
1504
|
+
i += 1;
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
throw new Error(`unknown argument: ${a}`);
|
|
1508
|
+
}
|
|
1509
|
+
if (!args.mode) throw new Error('one of --inventory, --backup, --archive, --enable-task-model is required');
|
|
1510
|
+
if ((args.mode === 'backup' || args.mode === 'archive') && !args.session) {
|
|
1511
|
+
throw new Error(`--${args.mode} requires --session`);
|
|
1512
|
+
}
|
|
1513
|
+
if (args.session != null && args.mode !== 'backup' && args.mode !== 'archive') {
|
|
1514
|
+
throw new Error('--session is only valid with --backup or --archive');
|
|
1515
|
+
}
|
|
1516
|
+
if (args.session != null && !isSessionSegment(args.session)) {
|
|
1517
|
+
throw new Error(`--session must be a single path segment, got: ${args.session}`);
|
|
1518
|
+
}
|
|
1519
|
+
if (args.activeDays != null) {
|
|
1520
|
+
if (args.mode !== 'inventory') throw new Error('--active-days is only valid with --inventory');
|
|
1521
|
+
const n = Number(args.activeDays);
|
|
1522
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error('--active-days must be a positive integer');
|
|
1523
|
+
args.activeDays = n;
|
|
1524
|
+
}
|
|
1525
|
+
if (args.remote != null && args.mode !== 'backup') {
|
|
1526
|
+
throw new Error('--remote is only valid with --backup');
|
|
1527
|
+
}
|
|
1528
|
+
if (args.dryRun && args.mode !== 'backup') {
|
|
1529
|
+
throw new Error('--dry-run is only valid with --backup');
|
|
1530
|
+
}
|
|
1531
|
+
return args;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function main() {
|
|
1535
|
+
const args = parseArgs(process.argv);
|
|
1536
|
+
const rootDir = resolveRoot(args.root);
|
|
1537
|
+
// S5: every acting mode runs from the launcher; only the task-model
|
|
1538
|
+
// switch is allowed to aim at a (task) worktree root.
|
|
1539
|
+
if (args.mode !== 'enable-task-model' && isLinkedWorktree(spawnSync, rootDir)) {
|
|
1540
|
+
throw new Error(`--root ${rootDir} is a linked worktree — run from the workspace root (the launcher)`);
|
|
1541
|
+
}
|
|
1542
|
+
let out;
|
|
1543
|
+
let code = 0;
|
|
1544
|
+
if (args.mode === 'inventory') {
|
|
1545
|
+
out = inventory(rootDir, { activeDays: args.activeDays ?? 14 });
|
|
1546
|
+
process.stderr.write(renderTable(out));
|
|
1547
|
+
} else if (args.mode === 'backup') {
|
|
1548
|
+
out = backupSession(rootDir, { session: args.session, remote: args.remote, dryRun: args.dryRun });
|
|
1549
|
+
} else if (args.mode === 'archive') {
|
|
1550
|
+
out = archiveSession(rootDir, { session: args.session });
|
|
1551
|
+
} else {
|
|
1552
|
+
out = enableTaskModel(rootDir);
|
|
1553
|
+
}
|
|
1554
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
1555
|
+
if (out && out.refused) {
|
|
1556
|
+
process.stderr.write(`migrate-sessions: refused — ${out.reasons.join('; ')}\n`);
|
|
1557
|
+
code = 1;
|
|
1558
|
+
}
|
|
1559
|
+
return code;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (isMainModule(import.meta.url)) {
|
|
1563
|
+
try {
|
|
1564
|
+
process.exit(main());
|
|
1565
|
+
} catch (err) {
|
|
1566
|
+
process.stderr.write(`migrate-sessions: ${err.message}\n`);
|
|
1567
|
+
process.exit(2);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
export { inventory, backupSession, archiveSession, enableTaskModel, classify, parseArgs };
|