@ulysses-ai/create-workspace 0.18.0-beta.0 → 0.20.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/lib/init.mjs +4 -1
- package/lib/payload.mjs +18 -1
- package/lib/payload.test.mjs +55 -0
- package/lib/scaffold.mjs +23 -6
- package/lib/scaffold.test.mjs +59 -0
- package/lib/upgrade.mjs +20 -0
- package/lib/upgrade.test.mjs +119 -0
- package/package.json +3 -3
- package/template/CLAUDE.md.tmpl +3 -0
- package/template/{.claude → _claude}/lib/freshness.mjs +20 -7
- package/template/_claude/lib/registry-check.mjs +172 -0
- package/template/{.claude → _claude}/rules/forge-operations.md +6 -0
- package/template/{.claude → _claude}/rules/memory-guidance.md +4 -0
- package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
- package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +25 -14
- package/template/{.claude → _claude}/scripts/chat-record.mjs +62 -21
- package/template/_claude/scripts/classify-update.mjs +117 -0
- package/template/{.claude → _claude}/scripts/cleanup-work-session.mjs +4 -2
- package/template/{.claude → _claude}/scripts/context-footprint.mjs +139 -30
- package/template/{.claude → _claude}/scripts/forges/github.mjs +2 -1
- package/template/{.claude → _claude}/scripts/forges/interface.mjs +5 -4
- package/template/_claude/scripts/merge-mode.mjs +61 -0
- package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +7 -1
- package/template/_claude/scripts/migrate-claude-md-freshness-include.mjs +55 -0
- package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +19 -8
- package/template/{.claude → _claude}/scripts/migrate-sessions.mjs +444 -121
- package/template/_claude/scripts/task-pr.mjs +555 -0
- package/template/{.claude → _claude}/scripts/task-worktree.mjs +26 -16
- package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
- package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
- package/template/{.claude → _claude}/skills/braindump/SKILL.md +1 -0
- package/template/{.claude → _claude}/skills/complete-work/SKILL.md +25 -77
- package/template/{.claude → _claude}/skills/context-placement/SKILL.md +8 -5
- package/template/{.claude → _claude}/skills/goal-driven-work/SKILL.md +1 -1
- package/template/{.claude → _claude}/skills/handoff/SKILL.md +1 -0
- package/template/{.claude → _claude}/skills/maintenance/SKILL.md +49 -17
- package/template/{.claude → _claude}/skills/migrate-sessions/SKILL.md +17 -5
- package/template/{.claude → _claude}/skills/release/SKILL.md +6 -2
- package/template/{.claude → _claude}/skills/start-work/SKILL.md +5 -5
- package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +20 -14
- package/template/_claude/skills/workspace-update/SKILL.md +177 -0
- package/template/_gitignore +3 -0
- package/template/workspace.json.tmpl +1 -1
- package/template/.claude/lib/registry-check.mjs +0 -106
- package/template/.claude/scripts/migrate-claude-md-freshness-include.mjs +0 -30
- package/template/.claude/skills/workspace-update/SKILL.md +0 -134
- /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/implementer.md +0 -0
- /package/template/{.claude → _claude}/agents/researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
- /package/template/{.claude → _claude}/hooks/_utils.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/repo-write-detection.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-start.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/subagent-start.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
- /package/template/{.claude → _claude}/lib/session-frontmatter.mjs +0 -0
- /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
- /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/coherent-revisions.md +0 -0
- /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/git-conventions.md +0 -0
- /package/template/{.claude → _claude}/rules/goal-driven-work.md +0 -0
- /package/template/{.claude → _claude}/rules/honest-pushback.md +0 -0
- /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/work-item-tracking.md +0 -0
- /package/template/{.claude → _claude}/rules/workspace-structure.md +0 -0
- /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/workspace-diagnostics.mjs +0 -0
- /package/template/{.claude → _claude}/settings.json +0 -0
- /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/pause-work/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
- /package/template/{.mcp.json → _mcp.json} +0 -0
|
@@ -7,13 +7,32 @@
|
|
|
7
7
|
// some still live. This script is the mechanical half of draining them:
|
|
8
8
|
//
|
|
9
9
|
// --inventory read-only evidence + a proposal (ACTIVE / ABANDONED /
|
|
10
|
-
// MERGEABLE / UNKNOWN / REMOVE_SHELL / LEAVE) per session
|
|
10
|
+
// MERGEABLE / UNKNOWN / REMOVE_SHELL / LEAVE) per session,
|
|
11
|
+
// listing every worktree's remotes by name, fetch URL, and
|
|
12
|
+
// (when it differs) push URL(s)
|
|
11
13
|
// --backup tag each tip the session holds that no remote branch or
|
|
12
|
-
// tag already points at
|
|
14
|
+
// tag already points at — LOCAL tags only, nothing pushed
|
|
13
15
|
// (--dry-run reports the plan with no side effects)
|
|
16
|
+
// --backup --remote
|
|
17
|
+
// push mode: pushes only where explicitly allowed, via
|
|
18
|
+
// --remote-allow <repo>=<remote> (repeatable; "." is the
|
|
19
|
+
// workspace repo) or --remote-allow-all. With neither it
|
|
20
|
+
// pushes nothing, lists the exact URL(s) every repo would
|
|
21
|
+
// push to, and exits non-zero — a plan, not a refusal of
|
|
22
|
+
// the whole idea. A repo's resolved remote can be anyone's
|
|
23
|
+
// (a third-party upstream counts as "origin" too), so an
|
|
24
|
+
// unallowed push is never a guess the script gets to make.
|
|
25
|
+
// Pushing approves and verifies against the PUSH URL(s)
|
|
26
|
+
// (remote.<name>.pushurl, url.*.pushInsteadOf — resolved
|
|
27
|
+
// by git itself), never the fetch URL: a remote whose push
|
|
28
|
+
// targets differ from its fetch URL needs an explicit
|
|
29
|
+
// --remote-allow entry naming it, so no approval is ever
|
|
30
|
+
// granted against a URL the push does not go to.
|
|
14
31
|
// --archive move the session out of the active lifecycle — the whole
|
|
15
32
|
// folder is renamed into {sessions}/.archived/ and git's
|
|
16
|
-
// worktree links are repaired to follow it
|
|
33
|
+
// worktree links are repaired to follow it. Refuses when a
|
|
34
|
+
// worktree holds uncommitted changes (an edited session.md
|
|
35
|
+
// counts) unless --allow-uncommitted says leave them be.
|
|
17
36
|
// --enable-task-model
|
|
18
37
|
// flip workspace.sessionModel to "task" (accepts a task
|
|
19
38
|
// worktree root — the one mode allowed off the launcher)
|
|
@@ -287,6 +306,57 @@ function remotesOf(gitFn, path) {
|
|
|
287
306
|
return okLines(res);
|
|
288
307
|
}
|
|
289
308
|
|
|
309
|
+
// The URL of every configured remote, read from local config. This never
|
|
310
|
+
// touches the network, so it answers even when a remote is unreachable or
|
|
311
|
+
// has never been fetched — the one fact that stops "origin" from being an
|
|
312
|
+
// anonymous label. A repo whose origin is a third-party upstream shows
|
|
313
|
+
// that URL here, whatever ls-remote later says about the branch.
|
|
314
|
+
function remoteUrlsOf(gitFn, path, remotes) {
|
|
315
|
+
const urls = {};
|
|
316
|
+
for (const remote of remotes) {
|
|
317
|
+
const res = run(gitFn, path, ['remote', 'get-url', remote]);
|
|
318
|
+
urls[remote] = res.status === 0 ? String(res.stdout).trim() : null;
|
|
319
|
+
}
|
|
320
|
+
return urls;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The URL of one remote, or null when it cannot be read (fail closed —
|
|
324
|
+
// a push plan that cannot name where it would push is not a plan).
|
|
325
|
+
function remoteUrlOf(gitFn, repoDir, remote) {
|
|
326
|
+
if (!remote) return null;
|
|
327
|
+
const res = run(gitFn, repoDir, ['remote', 'get-url', remote]);
|
|
328
|
+
return res.status === 0 ? String(res.stdout).trim() : null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Where `git push <remote>` would ACTUALLY send, resolved by git itself:
|
|
332
|
+
// every configured pushurl (it is multi-valued — a push goes to each),
|
|
333
|
+
// falling back to the fetch URL, with url.*.pushInsteadOf rewriting
|
|
334
|
+
// applied (`remote get-url` expands insteadOf/pushInsteadOf). Offline,
|
|
335
|
+
// like the fetch URL. The operator reads the fetch URL as "the remote",
|
|
336
|
+
// but their approval lands on the push URL(s) — a remote whose push
|
|
337
|
+
// targets differ from its fetch URL must be approved against the push
|
|
338
|
+
// targets, never against the fetch URL it will not go to.
|
|
339
|
+
function pushUrlsFor(gitFn, repoDir, remote) {
|
|
340
|
+
if (!remote) return [];
|
|
341
|
+
const res = run(gitFn, repoDir, ['remote', 'get-url', '--push', '--all', remote]);
|
|
342
|
+
return res.status === 0 ? okLines(res).filter(Boolean) : null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function pushUrlsOf(gitFn, path, remotes) {
|
|
346
|
+
const urls = {};
|
|
347
|
+
for (const remote of remotes) {
|
|
348
|
+
urls[remote] = pushUrlsFor(gitFn, path, remote);
|
|
349
|
+
}
|
|
350
|
+
return urls;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Do this push target(s) say the same thing as the fetch URL the operator
|
|
354
|
+
// sees? Anything else — a decoy pushurl, a pushInsteadOf rewrite, several
|
|
355
|
+
// pushurls — means the fetch URL is not where a push lands.
|
|
356
|
+
function pushMatchesFetch(fetchUrl, pushUrls) {
|
|
357
|
+
return fetchUrl != null && pushUrls != null && pushUrls.length === 1 && pushUrls[0] === fetchUrl;
|
|
358
|
+
}
|
|
359
|
+
|
|
290
360
|
// Does {branch} exist on {remote}, and at what commit? Local tracking
|
|
291
361
|
// refs prove nothing (they are stale the moment anything fetches), so
|
|
292
362
|
// the remote is asked directly. Timeouts and failures degrade to
|
|
@@ -304,44 +374,53 @@ function lsRemoteBranch(gitFn, cwd, remote, branch) {
|
|
|
304
374
|
//
|
|
305
375
|
// For each remote holding the branch, record how the local tip relates:
|
|
306
376
|
// same / local-ahead / local-behind / diverged (with counts) when the
|
|
307
|
-
// remote commit is known locally, not-fetched when it is not.
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
377
|
+
// remote commit is known locally, not-fetched when it is not. Every entry
|
|
378
|
+
// carries the remote's URL (from local config, so it survives an
|
|
379
|
+
// unreachable or never-fetched remote). This is what tells the operator
|
|
380
|
+
// "finishing needs a force-push decision" BEFORE they choose Finish,
|
|
381
|
+
// rather than after /complete-work's push bounces. A detached worktree
|
|
382
|
+
// still lists its remotes — with state 'no-branch', never as "no remotes".
|
|
383
|
+
function remoteStatesFor(gitFn, wtPath, branch, head, remotesList, urls) {
|
|
311
384
|
const out = {};
|
|
312
|
-
if (!branch)
|
|
313
|
-
|
|
385
|
+
if (!branch) {
|
|
386
|
+
for (const remote of remotesList) {
|
|
387
|
+
out[remote] = { url: urls[remote] ?? null, exists: false, sha: null, state: 'no-branch' };
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
for (const remote of remotesList) {
|
|
392
|
+
const url = urls[remote] ?? null;
|
|
314
393
|
const probe = lsRemoteBranch(gitFn, wtPath, remote, branch);
|
|
315
394
|
if (probe.exists === 'unknown') {
|
|
316
|
-
out[remote] = { exists: false, sha: null, state: 'unknown' };
|
|
395
|
+
out[remote] = { url, exists: false, sha: null, state: 'unknown' };
|
|
317
396
|
continue;
|
|
318
397
|
}
|
|
319
398
|
if (!probe.exists) {
|
|
320
|
-
out[remote] = { exists: false, sha: null, state: 'none' };
|
|
399
|
+
out[remote] = { url, exists: false, sha: null, state: 'none' };
|
|
321
400
|
continue;
|
|
322
401
|
}
|
|
323
402
|
const { sha } = probe;
|
|
324
403
|
if (sha === head) {
|
|
325
|
-
out[remote] = { exists: true, sha, state: 'same' };
|
|
404
|
+
out[remote] = { url, exists: true, sha, state: 'same' };
|
|
326
405
|
continue;
|
|
327
406
|
}
|
|
328
407
|
const known = run(gitFn, wtPath, ['cat-file', '-e', `${sha}^{commit}`]).status === 0;
|
|
329
408
|
if (!known) {
|
|
330
|
-
out[remote] = { exists: true, sha, state: 'not-fetched' };
|
|
409
|
+
out[remote] = { url, exists: true, sha, state: 'not-fetched' };
|
|
331
410
|
continue;
|
|
332
411
|
}
|
|
333
412
|
const lr = run(gitFn, wtPath, ['rev-list', '--left-right', '--count', `${sha}...HEAD`]);
|
|
334
413
|
if (lr.status !== 0) {
|
|
335
|
-
out[remote] = { exists: true, sha, state: 'unknown' };
|
|
414
|
+
out[remote] = { url, exists: true, sha, state: 'unknown' };
|
|
336
415
|
continue;
|
|
337
416
|
}
|
|
338
417
|
const counts = String(lr.stdout).trim().split(/\s+/).map(Number);
|
|
339
418
|
const remoteOnly = counts[0]; // left side: commits only the remote has
|
|
340
419
|
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 };
|
|
420
|
+
if (remoteOnly === 0 && localOnly === 0) out[remote] = { url, exists: true, sha, state: 'same' };
|
|
421
|
+
else if (localOnly > 0 && remoteOnly === 0) out[remote] = { url, exists: true, sha, state: 'local-ahead', ahead: localOnly };
|
|
422
|
+
else if (localOnly === 0 && remoteOnly > 0) out[remote] = { url, exists: true, sha, state: 'local-behind', behind: remoteOnly };
|
|
423
|
+
else out[remote] = { url, exists: true, sha, state: 'diverged', ahead: localOnly, behind: remoteOnly };
|
|
345
424
|
}
|
|
346
425
|
return out;
|
|
347
426
|
}
|
|
@@ -489,7 +568,10 @@ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath) {
|
|
|
489
568
|
info.contentFiles = countContentFiles(gitFn, wtPath, defaultBranch);
|
|
490
569
|
info.dirtyContent = dirtyRecords.filter((r) => !isSessionArtifact(r.path)).length;
|
|
491
570
|
}
|
|
492
|
-
|
|
571
|
+
const remotesList = remotesOf(gitFn, wtPath);
|
|
572
|
+
info.remoteUrls = remoteUrlsOf(gitFn, wtPath, remotesList);
|
|
573
|
+
info.remotePushUrls = pushUrlsOf(gitFn, wtPath, remotesList);
|
|
574
|
+
info.remotes = remoteStatesFor(gitFn, wtPath, branch, head, remotesList, info.remoteUrls);
|
|
493
575
|
info.backedBy = backedByRemote(info.remotes);
|
|
494
576
|
return info;
|
|
495
577
|
}
|
|
@@ -1022,13 +1104,15 @@ function peeledTagSha(gitFn, cwd, tag) {
|
|
|
1022
1104
|
return res.status === 0 ? String(res.stdout).trim() : null;
|
|
1023
1105
|
}
|
|
1024
1106
|
|
|
1025
|
-
// Does {tag} exist
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1031
|
-
|
|
1107
|
+
// Does {tag} exist at exactly {commit} on one specific URL — the push
|
|
1108
|
+
// URL, which is where the push actually landed, not a remote name whose
|
|
1109
|
+
// fetch URL may point elsewhere? Deliberately no refspec pattern: a
|
|
1110
|
+
// pattern filters out the peeled `^{}` line (the tag object's sha is not
|
|
1111
|
+
// the commit's), and the peeled line is exactly what "at this commit"
|
|
1112
|
+
// needs. An annotated tag answers via the peel; a lightweight tag's only
|
|
1113
|
+
// line already is the commit.
|
|
1114
|
+
function tagHeldAtUrl(gitFn, cwd, url, tag, commit) {
|
|
1115
|
+
const res = gitFn('git', ['-C', cwd, 'ls-remote', '--tags', url], netOpts(LS_REMOTE_TIMEOUT_MS));
|
|
1032
1116
|
if (res.error || res.status !== 0) return false;
|
|
1033
1117
|
const peeledRef = `refs/tags/${tag}^{}`;
|
|
1034
1118
|
const plainRef = `refs/tags/${tag}`;
|
|
@@ -1041,15 +1125,20 @@ function tagOnRemoteAt(gitFn, cwd, remote, tag, commit) {
|
|
|
1041
1125
|
return plainSha === commit;
|
|
1042
1126
|
}
|
|
1043
1127
|
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
function
|
|
1048
|
-
if (
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1128
|
+
// A pushed tag counts as backed up only where the push really went: every
|
|
1129
|
+
// push URL of the remote must hold it at the commit (`git push` sends to
|
|
1130
|
+
// each pushurl).
|
|
1131
|
+
function tagOnPushUrls(gitFn, cwd, pushUrls, tag, commit) {
|
|
1132
|
+
if (!pushUrls || pushUrls.length === 0) return false;
|
|
1133
|
+
return pushUrls.every((u) => tagHeldAtUrl(gitFn, cwd, u, tag, commit));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// S4: where a backup tag would be pushed — branch.<b>.pushRemote, then
|
|
1137
|
+
// remote.pushDefault, then branch.<b>.remote, then origin, then the first
|
|
1138
|
+
// configured remote. What this resolves is only a CANDIDATE: push mode
|
|
1139
|
+
// acts on it solely where --remote-allow names it or --remote-allow-all
|
|
1140
|
+
// blesses every repo.
|
|
1141
|
+
function resolvePushRemote(gitFn, repoDir, branch, remotes) {
|
|
1053
1142
|
const cfg = (key) => {
|
|
1054
1143
|
const res = run(gitFn, repoDir, ['config', '--get', key]);
|
|
1055
1144
|
return res.status === 0 ? String(res.stdout).trim() : null;
|
|
@@ -1069,16 +1158,40 @@ function resolvePushRemote(gitFn, repoDir, branch, override, remotes) {
|
|
|
1069
1158
|
/**
|
|
1070
1159
|
* Back up every tip the session holds that no qualifying remote already
|
|
1071
1160
|
* has as a branch or tag: an annotated, session-scoped `drain/{session}/…`
|
|
1072
|
-
* tag at the tip
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
* the
|
|
1161
|
+
* tag at the tip. Local tags only — a backup run never pushes on its own.
|
|
1162
|
+
*
|
|
1163
|
+
* Push mode (`remote: true`, CLI `--backup --remote`) adds pushing, and
|
|
1164
|
+
* pushing is allowed nowhere by default: a `remoteAllow` entry
|
|
1165
|
+
* (`repo=remote`, "." for the workspace repo) permits one repo at a time,
|
|
1166
|
+
* `remoteAllowAll` permits every repo's resolved remote (it wins wherever
|
|
1167
|
+
* both are given — an entry still pins which remote that one repo uses).
|
|
1168
|
+
* With neither, the run lists the exact URL(s) every repo would push to
|
|
1169
|
+
* and exits non-zero — nothing tagged, nothing pushed, a plan handed back
|
|
1170
|
+
* for per-repo decisions. A resolved remote is only a candidate: "origin"
|
|
1171
|
+
* can be a third-party upstream the operator must never push backup tags
|
|
1172
|
+
* to, and the URL (not the name) is what tells them apart.
|
|
1173
|
+
*
|
|
1174
|
+
* Every push decision is made against the PUSH URL(s) — resolved by git
|
|
1175
|
+
* via `remote get-url --push --all`, so pushurl and url.*.pushInsteadOf
|
|
1176
|
+
* are included — because that is where `git push` sends. A remote whose
|
|
1177
|
+
* push URL(s) differ from its fetch URL is refused under allow-all: only
|
|
1178
|
+
* an explicit `--remote-allow <repo>=<remote>` blesses it, and the refusal
|
|
1179
|
+
* prints the push targets, so the approval is always against where the
|
|
1180
|
+
* push actually goes. After pushing, the tag is verified on every push
|
|
1181
|
+
* URL. Where pushing is not allowed, the local tag IS the backup.
|
|
1182
|
+
* Idempotent — an existing tag at the same commit is fine; at a different
|
|
1183
|
+
* commit (the branch moved) the operator must decide, so it refuses. With
|
|
1184
|
+
* dryRun the plan is reported per tip with no side effects.
|
|
1080
1185
|
*/
|
|
1081
|
-
function backupSession(root, {
|
|
1186
|
+
function backupSession(root, {
|
|
1187
|
+
session,
|
|
1188
|
+
remote = false,
|
|
1189
|
+
remoteAllow = [],
|
|
1190
|
+
remoteAllowAll = false,
|
|
1191
|
+
dryRun = false,
|
|
1192
|
+
gitFn = spawnSync,
|
|
1193
|
+
cwd = process.cwd(),
|
|
1194
|
+
} = {}) {
|
|
1082
1195
|
const rootDir = resolveRoot(root);
|
|
1083
1196
|
if (!isSessionSegment(session)) {
|
|
1084
1197
|
throw new Error(`session name must be a single path segment, got: ${session}`);
|
|
@@ -1087,6 +1200,20 @@ function backupSession(root, { session, remote: remoteOverride = null, dryRun =
|
|
|
1087
1200
|
const { folder, refusal } = sessionFolderGuards(rootDir, sessionsDir, session, cwd);
|
|
1088
1201
|
if (refusal) return refusal;
|
|
1089
1202
|
|
|
1203
|
+
// --remote-allow entries parse before anything else acts: repo ("." is
|
|
1204
|
+
// the workspace repo) = remote name, both single clean segments.
|
|
1205
|
+
const allowMap = new Map();
|
|
1206
|
+
for (const entry of remoteAllow) {
|
|
1207
|
+
const eq = entry.indexOf('=');
|
|
1208
|
+
const repo = eq > 0 ? entry.slice(0, eq) : null;
|
|
1209
|
+
const remoteName = eq >= 0 && eq < entry.length - 1 ? entry.slice(eq + 1) : null;
|
|
1210
|
+
const shaped = repo && (repo === WORKSPACE_REPO || isRepoSegment(repo)) && remoteName && !/\s/.test(remoteName);
|
|
1211
|
+
if (!shaped) {
|
|
1212
|
+
return { refused: true, reasons: [`--remote-allow expects <repo>=<remote> ("." is the workspace repo), got: ${entry}`] };
|
|
1213
|
+
}
|
|
1214
|
+
allowMap.set(repo, remoteName);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1090
1217
|
const del = sessionTips(gitFn, rootDir, folder);
|
|
1091
1218
|
const reasons = [...validateTrackerShape(gitFn, rootDir, del)];
|
|
1092
1219
|
if (existsSync(join(folder, 'workspace', '.git'))) {
|
|
@@ -1097,81 +1224,198 @@ function backupSession(root, { session, remote: remoteOverride = null, dryRun =
|
|
|
1097
1224
|
const planned = [];
|
|
1098
1225
|
if (reasons.length === 0 && existsSync(join(folder, 'workspace', '.git'))) {
|
|
1099
1226
|
const safety = makeSafety(gitFn, rootDir);
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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`);
|
|
1227
|
+
|
|
1228
|
+
// An allow entry must name a repo this session holds and a remote
|
|
1229
|
+
// that repo actually configures — both checked before anything is
|
|
1230
|
+
// tagged, so a typo refuses the whole run instead of silently
|
|
1231
|
+
// narrowing the push.
|
|
1232
|
+
const knownRepos = [...new Set(del.tips.map((t) => t.repo))];
|
|
1233
|
+
for (const [repo, remoteName] of allowMap) {
|
|
1234
|
+
if (!knownRepos.includes(repo)) {
|
|
1235
|
+
reasons.push(`--remote-allow names repo "${repo}", which this session does not hold (${knownRepos.join(', ') || 'none'})`);
|
|
1121
1236
|
continue;
|
|
1122
1237
|
}
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1238
|
+
const tip = del.tips.find((t) => t.repo === repo);
|
|
1239
|
+
const remotes = safety.remotes(tip.repoDir);
|
|
1240
|
+
if (!remotes.includes(remoteName)) {
|
|
1241
|
+
reasons.push(`--remote-allow names remote "${remoteName}" for ${repoLabel(tip)}, which is not configured (has: ${remotes.join(', ') || 'none'})`);
|
|
1127
1242
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Pass 1, no side effects: classify each tip, compute the tag it would
|
|
1246
|
+
// get, and resolve where a push would land — the remote's PUSH URL(s)
|
|
1247
|
+
// (where `git push` sends), alongside the fetch URL (what the operator
|
|
1248
|
+
// reads as "the remote"). When they differ, only an explicit allow
|
|
1249
|
+
// entry naming that remote may proceed: allow-all blesses fetch URLs
|
|
1250
|
+
// it has shown, never a push target it has not.
|
|
1251
|
+
const pending = [];
|
|
1252
|
+
if (reasons.length === 0) {
|
|
1253
|
+
for (const tip of del.tips) {
|
|
1254
|
+
const verdict = tipSafety(safety, tip.repoDir, tip.sha);
|
|
1255
|
+
if (verdict.safe) {
|
|
1256
|
+
// Already provably on a qualifying remote — pushing a tag for it
|
|
1257
|
+
// would only add noise (and possibly land in a public repo).
|
|
1258
|
+
skipped.push({ repo: tip.repo, ref: tip.ref ?? 'HEAD', commit: tip.sha, safeOn: verdict.remote, safeRef: verdict.ref });
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const shortSha = tip.sha.slice(0, 10);
|
|
1262
|
+
const tagName = tip.ref
|
|
1263
|
+
? `drain/${session}/${slugForBranch(tip.ref)}`
|
|
1264
|
+
: `drain/${session}/${tip.kind === 'workspace' ? 'workspace' : tip.repo}-detached-${shortSha}`;
|
|
1265
|
+
const existing = peeledTagSha(gitFn, tip.repoDir, tagName);
|
|
1266
|
+
if (existing && existing !== tip.sha) {
|
|
1267
|
+
reasons.push(`${repoLabel(tip)}: tag ${tagName} already points at ${existing.slice(0, 10)}…, not the current tip (${shortSha}…); the branch moved — decide manually`);
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
const allowedRemote = allowMap.get(tip.repo) ?? null;
|
|
1271
|
+
const remotes = safety.remotes(tip.repoDir);
|
|
1272
|
+
// Precedence: an allow entry pins this repo's remote (and is the
|
|
1273
|
+
// only thing that blesses a push/fetch URL divergence); allow-all
|
|
1274
|
+
// covers every OTHER repo with its resolved candidate.
|
|
1275
|
+
const explicitlyAllowed = allowedRemote != null && remotes.includes(allowedRemote);
|
|
1276
|
+
const resolved = explicitlyAllowed
|
|
1277
|
+
? allowedRemote
|
|
1278
|
+
: resolvePushRemote(gitFn, tip.repoDir, tip.ref, remotes).remote;
|
|
1279
|
+
const fetchUrl = remoteUrlOf(gitFn, tip.repoDir, resolved);
|
|
1280
|
+
const pushUrls = pushUrlsFor(gitFn, tip.repoDir, resolved);
|
|
1281
|
+
const willPush = remote && (remoteAllowAll || explicitlyAllowed) && resolved != null;
|
|
1282
|
+
if (willPush && pushUrls == null) {
|
|
1283
|
+
// Fail closed: an approval that cannot name the destination is
|
|
1284
|
+
// not an approval.
|
|
1285
|
+
reasons.push(`${repoLabel(tip)}: could not resolve where a push to "${resolved}" would land (git remote get-url --push --all failed) — fix the remote config or push the tag manually`);
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
if (willPush && !pushMatchesFetch(fetchUrl, pushUrls) && !explicitlyAllowed) {
|
|
1289
|
+
reasons.push(
|
|
1290
|
+
`${repoLabel(tip)}: remote "${resolved}" fetches from ${fetchUrl ?? '(url unreadable)'} but pushes to ${pushUrls.join(', ')}`
|
|
1291
|
+
+ ` — allow-all blesses fetch URLs only; check those push targets and re-run with --remote-allow ${tip.repo}=${resolved}`,
|
|
1292
|
+
);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
pending.push({
|
|
1296
|
+
tip,
|
|
1297
|
+
tagName,
|
|
1298
|
+
existing,
|
|
1299
|
+
shortSha,
|
|
1300
|
+
allowed: remoteAllowAll || explicitlyAllowed,
|
|
1301
|
+
remote: resolved,
|
|
1302
|
+
url: fetchUrl,
|
|
1303
|
+
pushUrls,
|
|
1138
1304
|
});
|
|
1139
|
-
continue;
|
|
1140
1305
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Push mode with no allow at all is a plan, not a start: list every
|
|
1309
|
+
// target's exact push URL(s) — and the fetch URL when they differ —
|
|
1310
|
+
// and hand the per-repo decision back untouched. The plan exits
|
|
1311
|
+
// non-zero wet or dry: pushing is still waiting on a decision.
|
|
1312
|
+
if (reasons.length === 0 && remote && allowMap.size === 0 && !remoteAllowAll && pending.length > 0) {
|
|
1313
|
+
const targets = pending.map((p) => ({
|
|
1314
|
+
repo: p.tip.repo,
|
|
1315
|
+
branch: p.tip.ref ?? null,
|
|
1316
|
+
commit: p.tip.sha,
|
|
1317
|
+
tag: p.tagName,
|
|
1318
|
+
remote: p.remote,
|
|
1319
|
+
url: p.url,
|
|
1320
|
+
pushUrls: p.pushUrls,
|
|
1321
|
+
}));
|
|
1322
|
+
const reasonLines = [
|
|
1323
|
+
'push mode pushes nothing without --remote-allow <repo>=<remote> (repeatable) or --remote-allow-all',
|
|
1324
|
+
...pending.map((p) => {
|
|
1325
|
+
if (!p.remote) return `${repoLabel(p.tip)} has no remote — its backup stays a local tag`;
|
|
1326
|
+
return pushMatchesFetch(p.url, p.pushUrls)
|
|
1327
|
+
? `${repoLabel(p.tip)} would push ${p.tagName} to ${p.remote} (${p.url ?? 'url unreadable'})`
|
|
1328
|
+
: `${repoLabel(p.tip)} would push ${p.tagName} to ${p.remote}: pushes to ${(p.pushUrls ?? []).join(', ') || '(push url unreadable)'} (fetch URL ${p.url ?? 'unreadable'})`;
|
|
1329
|
+
}),
|
|
1330
|
+
];
|
|
1331
|
+
if (dryRun) return { session, dryRun: true, needsAllow: true, targets, reasons: reasonLines };
|
|
1332
|
+
return { refused: true, needsAllow: true, targets, reasons: reasonLines };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Pass 2: act. A pass-1 reason aborts before anything is tagged —
|
|
1336
|
+
// every acting run creates the local tag first (the baseline backup),
|
|
1337
|
+
// and pushing happens only where allowed; a refusal must not leave
|
|
1338
|
+
// half the session tagged behind it.
|
|
1339
|
+
if (reasons.length === 0) {
|
|
1340
|
+
for (const p of pending) {
|
|
1341
|
+
if (dryRun) {
|
|
1342
|
+
// The plan never hides a destination: every tip names its
|
|
1343
|
+
// candidate remote and push URL(s); willPush says whether this
|
|
1344
|
+
// run would actually push there.
|
|
1345
|
+
planned.push({
|
|
1346
|
+
repo: p.tip.repo,
|
|
1347
|
+
branch: p.tip.ref,
|
|
1348
|
+
detached: !p.tip.ref,
|
|
1349
|
+
commit: p.tip.sha,
|
|
1350
|
+
tag: p.tagName,
|
|
1351
|
+
remote: p.remote,
|
|
1352
|
+
url: p.url,
|
|
1353
|
+
pushUrls: p.pushUrls,
|
|
1354
|
+
willPush: Boolean(p.allowed && p.remote),
|
|
1355
|
+
wouldCreate: !p.existing,
|
|
1356
|
+
});
|
|
1146
1357
|
continue;
|
|
1147
1358
|
}
|
|
1148
|
-
createdThisRun =
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1359
|
+
let createdThisRun = false;
|
|
1360
|
+
if (!p.existing) {
|
|
1361
|
+
const created = run(gitFn, p.tip.repoDir, ['tag', '-a', p.tagName, '-m', `backup before draining session ${session}`, p.tip.sha]);
|
|
1362
|
+
if (created.status !== 0) {
|
|
1363
|
+
reasons.push(`${repoLabel(p.tip)}: git tag ${p.tagName} failed: ${String(created.stderr || '').trim()}`);
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
createdThisRun = true;
|
|
1367
|
+
}
|
|
1368
|
+
if (!remote || !p.allowed || !p.remote) {
|
|
1369
|
+
const why = !remote
|
|
1370
|
+
? 'local backup only — push mode is off'
|
|
1371
|
+
: p.remote ? 'not allowed here (no --remote-allow entry) — the local tag is the backup' : 'no remote configured — the local tag is the backup';
|
|
1372
|
+
branches.push({
|
|
1373
|
+
repo: p.tip.repo,
|
|
1374
|
+
branch: p.tip.ref,
|
|
1375
|
+
detached: !p.tip.ref,
|
|
1376
|
+
tag: p.tagName,
|
|
1377
|
+
commit: p.tip.sha,
|
|
1378
|
+
pushed: false,
|
|
1379
|
+
localOnly: true,
|
|
1380
|
+
remote: null,
|
|
1381
|
+
url: null,
|
|
1382
|
+
wouldPushTo: p.remote ? { remote: p.remote, url: p.url, pushUrls: p.pushUrls } : null,
|
|
1383
|
+
reason: why,
|
|
1384
|
+
});
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
const pushTargets = (p.pushUrls && p.pushUrls.length > 0 ? p.pushUrls : [p.url]).filter(Boolean).join(', ');
|
|
1388
|
+
const pushed = gitFn('git', ['-C', p.tip.repoDir, 'push', p.remote, `refs/tags/${p.tagName}`], netOpts(PUSH_TIMEOUT_MS));
|
|
1389
|
+
if (pushed.error || pushed.status !== 0) {
|
|
1390
|
+
// A local tag that never reached a remote masquerades as a
|
|
1391
|
+
// backup (and local refs prove nothing) — remove the one we made.
|
|
1392
|
+
if (createdThisRun) run(gitFn, p.tip.repoDir, ['tag', '-d', p.tagName]);
|
|
1393
|
+
const detail = pushed.error ? `timed out after ${PUSH_TIMEOUT_MS / 1000}s` : String(pushed.stderr || '').trim();
|
|
1394
|
+
reasons.push(`${repoLabel(p.tip)}: pushing ${p.tagName} to ${p.remote} (${pushTargets || 'url unreadable'}) failed (${detail}) — treat ${p.tip.ref ?? p.shortSha} as unbacked`);
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
// Verified where the push really went: every push URL must hold
|
|
1398
|
+
// the tag at the commit. The fetch URL is irrelevant here — a
|
|
1399
|
+
// decoy pushurl is exactly the case it would lie about.
|
|
1400
|
+
const verified = tagOnPushUrls(gitFn, p.tip.repoDir, p.pushUrls, p.tagName, p.tip.sha);
|
|
1401
|
+
if (!verified) {
|
|
1402
|
+
if (createdThisRun) run(gitFn, p.tip.repoDir, ['tag', '-d', p.tagName]);
|
|
1403
|
+
reasons.push(`${repoLabel(p.tip)}: tag ${p.tagName} not found at the push URL(s) ${pushTargets || '(unreadable)'} after pushing — treat ${p.tip.ref ?? p.shortSha} as unbacked`);
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
branches.push({
|
|
1407
|
+
repo: p.tip.repo,
|
|
1408
|
+
branch: p.tip.ref,
|
|
1409
|
+
detached: !p.tip.ref,
|
|
1410
|
+
tag: p.tagName,
|
|
1411
|
+
commit: p.tip.sha,
|
|
1412
|
+
pushed: true,
|
|
1413
|
+
verified: true,
|
|
1414
|
+
remote: p.remote,
|
|
1415
|
+
url: p.url,
|
|
1416
|
+
pushUrls: p.pushUrls,
|
|
1417
|
+
});
|
|
1164
1418
|
}
|
|
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
1419
|
}
|
|
1176
1420
|
}
|
|
1177
1421
|
if (reasons.length > 0) return { refused: true, reasons };
|
|
@@ -1315,11 +1559,15 @@ function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
|
|
|
1315
1559
|
* outside this workspace; a submodule checkout is present (its link is
|
|
1316
1560
|
* not a worktree link and `worktree repair` cannot fix it); git's own
|
|
1317
1561
|
* list of worktrees names one under the folder that the walk did not
|
|
1318
|
-
* find;
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1562
|
+
* find; a worktree holds uncommitted or untracked changes — an edited
|
|
1563
|
+
* session.md counts (unless allowUncommitted: they would ride along
|
|
1564
|
+
* fine, but they deserve a decision: commit them to the session branch,
|
|
1565
|
+
* or explicitly accept archiving them mid-edit); or the archive
|
|
1566
|
+
* directory is a symlink or resolves outside the workspace. If anything
|
|
1567
|
+
* fails after the rename, the folder is renamed back and repaired, and
|
|
1568
|
+
* the result reports the verified state.
|
|
1321
1569
|
*/
|
|
1322
|
-
function archiveSession(root, { session, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
|
|
1570
|
+
function archiveSession(root, { session, allowUncommitted = false, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
|
|
1323
1571
|
const rootDir = resolveRoot(root);
|
|
1324
1572
|
if (!isSessionSegment(session)) {
|
|
1325
1573
|
throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
|
|
@@ -1364,6 +1612,27 @@ function archiveSession(root, { session, gitFn = spawnSync, cwd = process.cwd(),
|
|
|
1364
1612
|
}
|
|
1365
1613
|
}
|
|
1366
1614
|
}
|
|
1615
|
+
// Uncommitted work rides along safely, but silently archiving it buries
|
|
1616
|
+
// it outside the lifecycle: notes committed to the session branch can
|
|
1617
|
+
// still be read and merged, while edits sitting uncommitted in an
|
|
1618
|
+
// archive effectively cannot. So an acting archive names every dirty
|
|
1619
|
+
// worktree — an uncommitted session.md included — and leaves the
|
|
1620
|
+
// decision to the operator.
|
|
1621
|
+
if (!allowUncommitted) {
|
|
1622
|
+
for (const f of found) {
|
|
1623
|
+
const wtPath = f.rel === '.' ? folder : join(folder, f.rel);
|
|
1624
|
+
const rel = relative(rootDir, wtPath);
|
|
1625
|
+
const records = statusRecords(gitFn, wtPath);
|
|
1626
|
+
if (!records) {
|
|
1627
|
+
reasons.push(`could not read the status of ${rel} — refusing rather than guessing; check it yourself before re-running with --allow-uncommitted`);
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
if (records.length > 0) {
|
|
1631
|
+
const sample = records.slice(0, 3).map((r) => r.path).join(', ');
|
|
1632
|
+
reasons.push(`${rel} has ${records.length} uncommitted change(s) (${sample}${records.length > 3 ? ', …' : ''}) — commit them to the session branch, or re-run with --allow-uncommitted to archive them uncommitted`);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1367
1636
|
if (reasons.length > 0) return { refused: true, reasons };
|
|
1368
1637
|
|
|
1369
1638
|
const archiveDir = join(sessionsDir, '.archived');
|
|
@@ -1470,6 +1739,21 @@ function renderTable(result) {
|
|
|
1470
1739
|
.join(' ') || 'no remotes';
|
|
1471
1740
|
const extra = w.kind === 'workspace' ? ` content:${w.contentFiles}` : '';
|
|
1472
1741
|
lines.push(` ${w.kind === 'workspace' ? '(workspace)' : w.repo} ${w.branch ?? 'detached'} ahead:${w.ahead ?? '?'} dirty:${w.dirty}${extra} [${remotes}]`);
|
|
1742
|
+
// The state bracket alone is ambiguous — "origin:none" says the remote
|
|
1743
|
+
// holds no copy of this branch, not that there is no origin. The URL
|
|
1744
|
+
// line settles it: name = exact URL for every configured remote, plus
|
|
1745
|
+
// the push URL(s) whenever a push would land somewhere else — the
|
|
1746
|
+
// fetch URL is what the operator reads, the push URL is where their
|
|
1747
|
+
// approval goes.
|
|
1748
|
+
const urls = Object.entries(w.remoteUrls || {});
|
|
1749
|
+
if (urls.length > 0) {
|
|
1750
|
+
const pushUrls = w.remotePushUrls || {};
|
|
1751
|
+
lines.push(` remotes: ${urls.map(([n, u]) => {
|
|
1752
|
+
const pushes = pushUrls[n];
|
|
1753
|
+
const diverged = pushes != null && !(pushes.length === 1 && pushes[0] === u);
|
|
1754
|
+
return `${n} = ${u ?? '(url unreadable)'}${diverged ? ` (pushes to: ${pushes.join(', ')})` : ''}`;
|
|
1755
|
+
}).join(', ')}`);
|
|
1756
|
+
}
|
|
1473
1757
|
}
|
|
1474
1758
|
for (const r of s.reasons || []) lines.push(` · ${r}`);
|
|
1475
1759
|
for (const w of s.warnings || []) lines.push(` ! ${w.message}`);
|
|
@@ -1482,11 +1766,27 @@ const VALUE_FLAGS = new Map([
|
|
|
1482
1766
|
['--root', 'root'],
|
|
1483
1767
|
['--session', 'session'],
|
|
1484
1768
|
['--active-days', 'activeDays'],
|
|
1769
|
+
['--remote-allow', 'remoteAllow'], // repeatable: <repo>=<remote>
|
|
1770
|
+
]);
|
|
1771
|
+
const BOOL_FLAGS = new Map([
|
|
1772
|
+
['--dry-run', 'dryRun'],
|
|
1485
1773
|
['--remote', 'remote'],
|
|
1774
|
+
['--remote-allow-all', 'remoteAllowAll'],
|
|
1775
|
+
['--allow-uncommitted', 'allowUncommitted'],
|
|
1486
1776
|
]);
|
|
1487
1777
|
|
|
1488
1778
|
function parseArgs(argv) {
|
|
1489
|
-
const args = {
|
|
1779
|
+
const args = {
|
|
1780
|
+
root: '.',
|
|
1781
|
+
mode: null,
|
|
1782
|
+
session: null,
|
|
1783
|
+
activeDays: null,
|
|
1784
|
+
remoteAllow: [],
|
|
1785
|
+
dryRun: false,
|
|
1786
|
+
remote: false,
|
|
1787
|
+
remoteAllowAll: false,
|
|
1788
|
+
allowUncommitted: false,
|
|
1789
|
+
};
|
|
1490
1790
|
const rest = argv.slice(2);
|
|
1491
1791
|
for (let i = 0; i < rest.length; i += 1) {
|
|
1492
1792
|
const a = rest[i];
|
|
@@ -1495,12 +1795,14 @@ function parseArgs(argv) {
|
|
|
1495
1795
|
args.mode = a.slice(2);
|
|
1496
1796
|
continue;
|
|
1497
1797
|
}
|
|
1498
|
-
|
|
1798
|
+
const boolKey = BOOL_FLAGS.get(a);
|
|
1799
|
+
if (boolKey) { args[boolKey] = true; continue; }
|
|
1499
1800
|
const key = VALUE_FLAGS.get(a);
|
|
1500
1801
|
if (key) {
|
|
1501
1802
|
const v = rest[i + 1];
|
|
1502
1803
|
if (v === undefined || v.startsWith('--')) throw new Error(`${a} requires a value`);
|
|
1503
|
-
|
|
1804
|
+
if (a === '--remote-allow') args.remoteAllow.push(v);
|
|
1805
|
+
else args[key] = v;
|
|
1504
1806
|
i += 1;
|
|
1505
1807
|
continue;
|
|
1506
1808
|
}
|
|
@@ -1522,9 +1824,19 @@ function parseArgs(argv) {
|
|
|
1522
1824
|
if (!Number.isInteger(n) || n <= 0) throw new Error('--active-days must be a positive integer');
|
|
1523
1825
|
args.activeDays = n;
|
|
1524
1826
|
}
|
|
1525
|
-
if (args.remote
|
|
1827
|
+
if (args.remote && args.mode !== 'backup') {
|
|
1526
1828
|
throw new Error('--remote is only valid with --backup');
|
|
1527
1829
|
}
|
|
1830
|
+
const hasAllow = args.remoteAllow.length > 0 || args.remoteAllowAll;
|
|
1831
|
+
if (hasAllow && args.mode !== 'backup') {
|
|
1832
|
+
throw new Error('--remote-allow/--remote-allow-all are only valid with --backup');
|
|
1833
|
+
}
|
|
1834
|
+
if (hasAllow && !args.remote) {
|
|
1835
|
+
throw new Error('--remote-allow/--remote-allow-all require --remote (push mode)');
|
|
1836
|
+
}
|
|
1837
|
+
if (args.allowUncommitted && args.mode !== 'archive') {
|
|
1838
|
+
throw new Error('--allow-uncommitted is only valid with --archive');
|
|
1839
|
+
}
|
|
1528
1840
|
if (args.dryRun && args.mode !== 'backup') {
|
|
1529
1841
|
throw new Error('--dry-run is only valid with --backup');
|
|
1530
1842
|
}
|
|
@@ -1545,9 +1857,15 @@ function main() {
|
|
|
1545
1857
|
out = inventory(rootDir, { activeDays: args.activeDays ?? 14 });
|
|
1546
1858
|
process.stderr.write(renderTable(out));
|
|
1547
1859
|
} else if (args.mode === 'backup') {
|
|
1548
|
-
out = backupSession(rootDir, {
|
|
1860
|
+
out = backupSession(rootDir, {
|
|
1861
|
+
session: args.session,
|
|
1862
|
+
remote: args.remote,
|
|
1863
|
+
remoteAllow: args.remoteAllow,
|
|
1864
|
+
remoteAllowAll: args.remoteAllowAll,
|
|
1865
|
+
dryRun: args.dryRun,
|
|
1866
|
+
});
|
|
1549
1867
|
} else if (args.mode === 'archive') {
|
|
1550
|
-
out = archiveSession(rootDir, { session: args.session });
|
|
1868
|
+
out = archiveSession(rootDir, { session: args.session, allowUncommitted: args.allowUncommitted });
|
|
1551
1869
|
} else {
|
|
1552
1870
|
out = enableTaskModel(rootDir);
|
|
1553
1871
|
}
|
|
@@ -1555,6 +1873,11 @@ function main() {
|
|
|
1555
1873
|
if (out && out.refused) {
|
|
1556
1874
|
process.stderr.write(`migrate-sessions: refused — ${out.reasons.join('; ')}\n`);
|
|
1557
1875
|
code = 1;
|
|
1876
|
+
} else if (out && out.needsAllow) {
|
|
1877
|
+
// The dry-run push plan exits non-zero like its non-dry form: either
|
|
1878
|
+
// way pushing is still waiting on an explicit allow.
|
|
1879
|
+
process.stderr.write('migrate-sessions: push plan — pushing needs --remote-allow <repo>=<remote> or --remote-allow-all; nothing was pushed\n');
|
|
1880
|
+
code = 1;
|
|
1558
1881
|
}
|
|
1559
1882
|
return code;
|
|
1560
1883
|
}
|