@ulysses-ai/create-workspace 0.23.1-beta.0 → 0.23.3-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.
@@ -47,14 +47,24 @@
47
47
  // worktree holds uncommitted changes (an edited session.md
48
48
  // counts) unless --allow-uncommitted says leave them be,
49
49
  // and when a worktree tip holds commits no remote backs
50
- // (they exist only on this machine) unless --allow-unbacked
50
+ // on a branch or under a drain/{session}/* tag (they
51
+ // exist only on this machine) unless --allow-unbacked
51
52
  // records that the operator saw the counts and declined
52
53
  // the backup.
54
+ // --remove-shell
55
+ // remove an ORPHAN_SHELL session folder — no worktree,
56
+ // nothing but empty directories, re-verified empty at
57
+ // removal time; anything else refuses, touching nothing
53
58
  // --enable-task-model
54
59
  // flip workspace.sessionModel to "task" (accepts a task
55
- // worktree root — the one mode allowed off the launcher)
60
+ // worktree root — the one mode allowed off the launcher;
61
+ // --launcher <root> counts the remaining sessions from
62
+ // the launcher when --root is that worktree)
56
63
  //
57
- // NOTHING HERE DELETES. Draining a session means taking it out of the
64
+ // NOTHING HERE DELETES — with one deliberate exception: --remove-shell,
65
+ // whose target is a folder provably empty of files and symlinks
66
+ // (re-verified at removal time, so a plain directory tree is all it can
67
+ // ever lose). Draining a session otherwise means taking it out of the
58
68
  // active lifecycle, not destroying it. An earlier design tore sessions
59
69
  // down behind a "prove it is safe to delete" check; four independent
60
70
  // reviews each found a new place git keeps state that the check missed
@@ -80,7 +90,7 @@
80
90
  // reasons: [...]} and exits 1; any other error goes to stderr and exits 2.
81
91
 
82
92
  import {
83
- readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, statSync, mkdirSync, renameSync, readlinkSync,
93
+ readFileSync, writeFileSync, existsSync, readdirSync, lstatSync, statSync, mkdirSync, renameSync, readlinkSync, rmSync,
84
94
  } from 'node:fs';
85
95
  import { realpathSync } from 'node:fs';
86
96
  import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path';
@@ -633,6 +643,7 @@ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath, fetchState) {
633
643
  repo,
634
644
  path: relative(rootDir, realPath(wtPath)),
635
645
  branch,
646
+ head,
636
647
  dirty: dirtyRecords.length,
637
648
  ahead: aheadCount(gitFn, wtPath, range),
638
649
  lastOwnCommit: lastOwnCommitIso(gitFn, wtPath, range),
@@ -1066,9 +1077,12 @@ function remoteQualifies(safety, repoDir, remote) {
1066
1077
 
1067
1078
  // Every branch and tag a remote holds, mapped by commit sha. Other
1068
1079
  // advertised refs (HEAD, a forge's refs/pull/N/*) are ephemeral and never
1069
- // count. No refspec pattern: patterns silently drop the peeled ^{} lines,
1070
- // which are the ones comparable with commit tips. A failed or timed-out
1071
- // query returns null (unknown) — it proves nothing.
1080
+ // count. No refspec pattern: some git versions drop the peeled ^{} lines
1081
+ // under a pattern, and those lines are the ones comparable with commit
1082
+ // tips (drainTagSafety below does use one — it must name a single
1083
+ // session's tags — and compensates by peeling locally instead of trusting
1084
+ // the peel line). A failed or timed-out query returns null (unknown) — it
1085
+ // proves nothing.
1072
1086
  function remoteRefs(safety, repoDir, remote) {
1073
1087
  const key = `${repoDir}\0${remote}`;
1074
1088
  if (safety.lsCache.has(key)) return safety.lsCache.get(key);
@@ -1101,6 +1115,75 @@ function tipSafety(safety, repoDir, sha) {
1101
1115
  return { safe: false };
1102
1116
  }
1103
1117
 
1118
+ // The archive gate's tag half: --backup --remote is what puts
1119
+ // drain/{session}/{branch-slug} tags on remotes, so the gate must count its
1120
+ // own backup or the documented "backup, then archive" path always refuses.
1121
+ // A tip is backed by tag when a qualifying remote holds a
1122
+ // drain/{session}/* tag whose peeled commit CONTAINS the tip — the tip
1123
+ // itself (what the backup tagged) or any descendant. The query narrows to
1124
+ // the session with a refspec pattern AND every returned line is re-checked
1125
+ // against `refs/tags/drain/{session}/` after stripping a trailing `^{}` —
1126
+ // the pattern is the query, the prefix is the answer's meaning, and a tag
1127
+ // like drain/{session}-suffix/… must never satisfy the other session's
1128
+ // gate. A session name containing glob metacharacters (* ? [ ]) has no
1129
+ // honest pattern — git forbids those characters in ref names anyway, so no
1130
+ // drain tag can exist for it — and is refused up front rather than escaped
1131
+ // into meaning something else. The peel is resolved locally
1132
+ // (cat-file/merge-base peel tag objects; the backup run left the tag
1133
+ // object in the local store), which keeps the check fetch-free and never
1134
+ // dependent on the peeled ^{} line: current git returns it under a
1135
+ // pattern, older gits drop it (the reason remoteRefs above avoids
1136
+ // patterns), and the plain line answers either way once the object is
1137
+ // local. Without the object nothing can be verified and the tip counts as
1138
+ // not backed. A local-only tag proves nothing — the remote is asked
1139
+ // directly — and a failed or timed-out query is reported as a blocker and
1140
+ // counts as not backed (the safe direction: an unreachable remote cannot
1141
+ // vouch for anything).
1142
+ function drainTagSafety(safety, session, wtPath, tip) {
1143
+ const blockers = [];
1144
+ if (/[*?[\]]/.test(session)) {
1145
+ blockers.push(`cannot build a drain-tag pattern for session name "${session}" (it contains glob metacharacters, which git forbids in ref names — no drain tag can exist for it)`);
1146
+ return { backed: false, remote: null, tag: null, blockers };
1147
+ }
1148
+ const out = { backed: false, remote: null, tag: null, blockers };
1149
+ const prefix = `refs/tags/drain/${session}/`;
1150
+ const pattern = `${prefix}*`;
1151
+ for (const remote of safety.remotes(wtPath)) {
1152
+ if (!remoteQualifies(safety, wtPath, remote)) continue;
1153
+ const res = safety.gitFn('git', ['-C', wtPath, 'ls-remote', '--tags', remote, pattern], netOpts(LS_REMOTE_TIMEOUT_MS));
1154
+ if (res.error || res.status !== 0) {
1155
+ // "timed out" only when spawnSync actually timed out; anything else
1156
+ // carries its own message (or git's stderr).
1157
+ const detail = res.error
1158
+ ? (res.error.code === 'ETIMEDOUT'
1159
+ ? `timed out after ${LS_REMOTE_TIMEOUT_MS / 1000}s`
1160
+ : String(res.error.message || res.error))
1161
+ : (String(res.stderr || '').trim() || 'ls-remote failed');
1162
+ out.blockers.push(`could not reach remote "${remote}" (${detail})`);
1163
+ continue;
1164
+ }
1165
+ for (const line of okLines(res)) {
1166
+ const [sha, ref] = line.trim().split(/\s+/);
1167
+ // Explicit session filter: strip the peel suffix first, so both line
1168
+ // kinds are held to the same `drain/{session}/` prefix.
1169
+ const bare = ref && ref.endsWith('^{}') ? ref.slice(0, -3) : ref;
1170
+ if (!sha || !ref || !bare.startsWith(prefix)) continue;
1171
+ if (sha === tip) {
1172
+ out.backed = true; out.remote = remote; out.tag = bare.slice('refs/tags/'.length);
1173
+ break;
1174
+ }
1175
+ const commit = `${sha}^{commit}`; // no-op for a peel line, peels a plain tag-object sha
1176
+ const known = run(safety.gitFn, wtPath, ['cat-file', '-e', commit]).status === 0;
1177
+ if (known && run(safety.gitFn, wtPath, ['merge-base', '--is-ancestor', tip, commit]).status === 0) {
1178
+ out.backed = true; out.remote = remote; out.tag = bare.slice('refs/tags/'.length);
1179
+ break;
1180
+ }
1181
+ }
1182
+ if (out.backed) break;
1183
+ }
1184
+ return out;
1185
+ }
1186
+
1104
1187
  function makeSafety(gitFn, rootDir) {
1105
1188
  const safety = {
1106
1189
  gitFn,
@@ -1793,11 +1876,13 @@ function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
1793
1876
  * session.md counts (unless allowUncommitted: they would ride along
1794
1877
  * fine, but they deserve a decision: commit them to the session branch,
1795
1878
  * or explicitly accept archiving them mid-edit); or a worktree tip holds
1796
- * commits no remote backs — they exist only on this machine, and the
1797
- * archive is safe for them but the moment it is deleted they are gone
1879
+ * commits no remote backs — on a branch, or under a drain/{session}/* tag
1880
+ * the backup step pushed there — so they exist only on this machine, and
1881
+ * the archive is safe for them but the moment it is deleted they are gone
1798
1882
  * (unless allowUnbacked, the operator's recorded decline after seeing
1799
1883
  * the per-repo counts; the refusal names them, and a successful archive
1800
- * that carried unbacked tips reports them in `unbacked`). Also refused
1884
+ * reports tips those tags backed in `backedByTag` and tips that rode
1885
+ * along unbacked in `unbacked`). Also refused
1801
1886
  * when the archive directory is a symlink or resolves outside the
1802
1887
  * workspace. If anything fails after the rename, the folder is renamed
1803
1888
  * back and repaired, and the result reports the verified state.
@@ -1873,10 +1958,15 @@ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked
1873
1958
  // own call. A tip whose commits no remote backs (the inventory's
1874
1959
  // `unbacked` evidence, recomputed here at archive time) exists only on
1875
1960
  // this machine, so the archive moves only once a remote holds every
1876
- // such tip — the backup step's push mode --remote with an allow — or
1877
- // after --allow-unbacked records that the operator saw these counts and
1878
- // declined it. allowUnbacked still reports what rode along.
1961
+ // such tip — on a branch, or under a drain/{session}/* tag the backup
1962
+ // step's push mode put there — or after --allow-unbacked records that
1963
+ // the operator saw these counts and declined the backup. allowUnbacked
1964
+ // still reports what rode along.
1879
1965
  const unbacked = [];
1966
+ const backedByTag = [];
1967
+ const tagBlockers = [];
1968
+ const wtInfos = [];
1969
+ const safety = makeSafety(gitFn, rootDir);
1880
1970
  for (const f of found) {
1881
1971
  const wtPath = f.rel === '.' ? folder : join(folder, f.rel);
1882
1972
  const info = inspectWorktree(
@@ -1884,12 +1974,25 @@ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked
1884
1974
  f.owner.repo === WORKSPACE_REPO ? 'workspace' : 'project',
1885
1975
  f.owner.repo, wtPath, null,
1886
1976
  );
1887
- if (info.ahead > 0 && !info.backedBy) unbacked.push(info);
1977
+ wtInfos.push(info);
1978
+ if (!(info.ahead > 0) || info.backedBy) continue;
1979
+ const tag = info.head
1980
+ ? drainTagSafety(safety, session, wtPath, info.head)
1981
+ : { backed: false, remote: null, tag: null, blockers: [] };
1982
+ if (tag.backed) {
1983
+ backedByTag.push({ repo: info.repo, branch: info.branch, commits: info.ahead, tag: tag.tag, remote: tag.remote });
1984
+ continue;
1985
+ }
1986
+ for (const b of tag.blockers) {
1987
+ tagBlockers.push(`${repoLabel(info)}: the drain-tag check did not clear — ${b} — treated as not backed; resolve it or decide with --allow-unbacked`);
1988
+ }
1989
+ unbacked.push(info);
1888
1990
  }
1889
1991
  if (unbacked.length > 0 && !allowUnbacked) {
1890
1992
  reasons.push(
1891
1993
  ...unbacked.map((wt) => unbackedMessage(gitFn, rootDir, wt)),
1892
- `${unbacked.length} worktree tip(s) above hold commits that exist only on this machine — this clears only when a remote holds them (--backup --remote with the operator's allow pushes backup tags there), or re-run with --allow-unbacked once the operator has seen these counts and explicitly declined the backup`,
1994
+ ...tagBlockers,
1995
+ `${unbacked.length} worktree tip(s) above hold commits that exist only on this machine — this clears only when a remote holds them (a pushed branch, or --backup --remote with the operator's allow pushing backup tags there), or re-run with --allow-unbacked once the operator has seen these counts and explicitly declined the backup`,
1893
1996
  );
1894
1997
  }
1895
1998
  if (reasons.length > 0) return { refused: true, reasons };
@@ -1929,19 +2032,60 @@ function archiveSession(root, { session, allowUncommitted = false, allowUnbacked
1929
2032
  return { refused: true, reasons: [...problems, state] };
1930
2033
  }
1931
2034
 
2035
+ // The archived worktrees keep their branches checked out, so those names
2036
+ // stay taken until the archive is deleted — the result says so, naming
2037
+ // them, so the next task that wants one of those names learns why not.
2038
+ const heldBranches = [...new Set(wtInfos.map((w) => w.branch).filter(Boolean))];
1932
2039
  return {
1933
2040
  session,
1934
2041
  archived: true,
1935
2042
  from: relative(rootDir, folder),
1936
2043
  to: relative(rootDir, dest),
1937
2044
  worktrees: at(dest).map((m) => ({ repo: m.owner.repo, path: relative(rootDir, m.path) })),
2045
+ // Tips the backup's drain tags cover on a remote — backed, just not on
2046
+ // a branch.
2047
+ ...(backedByTag.length > 0 ? { backedByTag } : {}),
1938
2048
  // What the operator accepted riding along unbacked — the same
1939
2049
  // repos and counts the refusal would have named.
1940
2050
  ...(unbacked.length > 0 ? { unbacked: unbacked.map((wt) => ({ repo: wt.repo, branch: wt.branch, commits: wt.ahead })) } : {}),
1941
- 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`),
2051
+ ...(heldBranches.length > 0 ? {
2052
+ heldBranches,
2053
+ note: `the archived worktrees keep ${heldBranches.join(', ')} checked out — a new task cannot reuse ${heldBranches.length === 1 ? 'that branch name' : 'those branch names'} until the archive is deleted`,
2054
+ } : {}),
2055
+ warnings: [
2056
+ ...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`),
2057
+ ...tagBlockers,
2058
+ ],
1942
2059
  };
1943
2060
  }
1944
2061
 
2062
+ // The one thing this script removes: an ORPHAN_SHELL — a session folder
2063
+ // with no worktree left in it and nothing but empty directories, verified
2064
+ // EMPTY AT REMOVAL TIME, not at inventory time. The inventory's `empty`
2065
+ // flag is evidence a shell was empty when read; anything that appeared
2066
+ // since (a stray file, a symlink, a .git marker) means the folder is
2067
+ // content now, and content is archive's to keep, never removal's to
2068
+ // delete. A refusal touches nothing.
2069
+ function removeShell(root, { session, cwd = process.cwd() } = {}) {
2070
+ const rootDir = resolveRoot(root);
2071
+ if (!isSessionSegment(session)) {
2072
+ throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
2073
+ }
2074
+ const sessionsDir = sessionsDirOf(rootDir);
2075
+ const { folder, refusal } = sessionFolderGuards(rootDir, sessionsDir, session, cwd);
2076
+ if (refusal) return refusal;
2077
+ if (!shellIsEmpty(folder)) {
2078
+ return {
2079
+ refused: true,
2080
+ reasons: [
2081
+ `session "${session}" holds files or symlinks, not just empty directories — nothing was removed; archive it instead (--archive keeps everything) or reconcile manually`,
2082
+ ],
2083
+ };
2084
+ }
2085
+ rmSync(folder, { recursive: true, force: true });
2086
+ return { session, removed: true, from: relative(rootDir, folder) };
2087
+ }
2088
+
1945
2089
  // The launcher root is the main worktree of the workspace repo; every
1946
2090
  // other checkout is a linked worktree. Acting modes require the
1947
2091
  // launcher; --enable-task-model is the exception (it edits workspace.json
@@ -1958,11 +2102,39 @@ function isLinkedWorktree(gitFn, rootDir) {
1958
2102
  * Switch the workspace to the task model: flip workspace.sessionModel to
1959
2103
  * "task", keeping every other key untouched. From the launcher this also
1960
2104
  * reports the remaining sessions; from a task worktree (the S6 flow)
1961
- * there is no sessions directory to read — remainingSessions is null and
1962
- * the note says where the real list comes from.
2105
+ * there is no sessions directory to read — pass `launcher` (CLI
2106
+ * `--launcher <root>`) to count them from the launcher anyway, or get
2107
+ * remainingSessions null with a note saying where the real list comes
2108
+ * from. The launcher is validated before anything is edited: it must be
2109
+ * the launcher of the SAME repository as --root (a different workspace's
2110
+ * launcher would happily count that workspace's sessions), and a --root
2111
+ * that is itself the launcher needs no launcher — only the same root
2112
+ * spelled again is accepted.
1963
2113
  */
1964
- function enableTaskModel(root, { gitFn = spawnSync } = {}) {
2114
+ function enableTaskModel(root, { gitFn = spawnSync, launcher = null } = {}) {
1965
2115
  const rootDir = resolveRoot(root);
2116
+ // Validate and resolve the launcher BEFORE editing anything: a wrong
2117
+ // launcher would report a wrong count, and the switch must not land
2118
+ // only to have the run die on the flag afterwards.
2119
+ const linked = isLinkedWorktree(gitFn, rootDir);
2120
+ let launcherDir = null;
2121
+ if (launcher) {
2122
+ launcherDir = resolveRoot(launcher);
2123
+ if (isLinkedWorktree(gitFn, launcherDir)) {
2124
+ throw new Error(`--launcher must be the workspace launcher (its own root), not a linked worktree: ${launcherDir}`);
2125
+ }
2126
+ const rootCommon = commonDirOf(gitFn, rootDir);
2127
+ const launcherCommon = commonDirOf(gitFn, launcherDir);
2128
+ if (!rootCommon || !launcherCommon) {
2129
+ throw new Error(`could not determine the repository of ${!rootCommon ? rootDir : launcherDir} (git rev-parse --git-common-dir failed) — refusing to guess at --launcher`);
2130
+ }
2131
+ if (rootCommon !== launcherCommon) {
2132
+ throw new Error(`--launcher ${launcherDir} belongs to a different repository than --root ${rootDir} — name this workspace's own launcher`);
2133
+ }
2134
+ if (!linked && rootDir !== launcherDir) {
2135
+ throw new Error(`--root ${rootDir} is not a task worktree, so --launcher has nothing to add (got ${launcherDir}) — omit it, or point --root at the switch worktree`);
2136
+ }
2137
+ }
1966
2138
  const cfgPath = join(rootDir, 'workspace.json');
1967
2139
  let cfg;
1968
2140
  try {
@@ -1975,14 +2147,13 @@ function enableTaskModel(root, { gitFn = spawnSync } = {}) {
1975
2147
  // file's house format.
1976
2148
  cfg.workspace = { ...(cfg.workspace || {}), sessionModel: 'task' };
1977
2149
  writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
1978
- const linked = isLinkedWorktree(gitFn, rootDir);
1979
- return linked
1980
- ? {
1981
- sessionModel: 'task',
1982
- remainingSessions: null,
1983
- note: 'run from a task worktree — remaining sessions come from --inventory at the launcher root',
1984
- }
1985
- : { sessionModel: 'task', remainingSessions: listSessionNames(rootDir) };
2150
+ if (!linked) return { sessionModel: 'task', remainingSessions: listSessionNames(rootDir) };
2151
+ if (launcherDir) return { sessionModel: 'task', remainingSessions: listSessionNames(launcherDir) };
2152
+ return {
2153
+ sessionModel: 'task',
2154
+ remainingSessions: null,
2155
+ note: 'run from a task worktree — pass --launcher <launcher root> to count the remaining sessions from it, or run --inventory at the launcher root',
2156
+ };
1986
2157
  }
1987
2158
 
1988
2159
  // Human-readable inventory rendering for stderr — the operator's table;
@@ -2037,12 +2208,13 @@ function renderTable(result) {
2037
2208
  return `${lines.join('\n')}\n`;
2038
2209
  }
2039
2210
 
2040
- const MODE_FLAGS = new Set(['--inventory', '--backup', '--archive', '--enable-task-model']);
2211
+ const MODE_FLAGS = new Set(['--inventory', '--backup', '--archive', '--enable-task-model', '--remove-shell']);
2041
2212
  const VALUE_FLAGS = new Map([
2042
2213
  ['--root', 'root'],
2043
2214
  ['--session', 'session'],
2044
2215
  ['--active-days', 'activeDays'],
2045
2216
  ['--remote-allow', 'remoteAllow'], // repeatable: <repo>=<remote>
2217
+ ['--launcher', 'launcher'], // --enable-task-model only: count sessions from here
2046
2218
  ]);
2047
2219
  const BOOL_FLAGS = new Map([
2048
2220
  ['--dry-run', 'dryRun'],
@@ -2060,6 +2232,7 @@ function parseArgs(argv) {
2060
2232
  session: null,
2061
2233
  activeDays: null,
2062
2234
  remoteAllow: [],
2235
+ launcher: null,
2063
2236
  dryRun: false,
2064
2237
  remote: false,
2065
2238
  remoteAllowAll: false,
@@ -2088,12 +2261,15 @@ function parseArgs(argv) {
2088
2261
  }
2089
2262
  throw new Error(`unknown argument: ${a}`);
2090
2263
  }
2091
- if (!args.mode) throw new Error('one of --inventory, --backup, --archive, --enable-task-model is required');
2092
- if ((args.mode === 'backup' || args.mode === 'archive') && !args.session) {
2264
+ if (!args.mode) throw new Error('one of --inventory, --backup, --archive, --enable-task-model, --remove-shell is required');
2265
+ if ((args.mode === 'backup' || args.mode === 'archive' || args.mode === 'remove-shell') && !args.session) {
2093
2266
  throw new Error(`--${args.mode} requires --session`);
2094
2267
  }
2095
- if (args.session != null && args.mode !== 'backup' && args.mode !== 'archive') {
2096
- throw new Error('--session is only valid with --backup or --archive');
2268
+ if (args.session != null && args.mode !== 'backup' && args.mode !== 'archive' && args.mode !== 'remove-shell') {
2269
+ throw new Error('--session is only valid with --backup, --archive or --remove-shell');
2270
+ }
2271
+ if (args.launcher != null && args.mode !== 'enable-task-model') {
2272
+ throw new Error('--launcher is only valid with --enable-task-model');
2097
2273
  }
2098
2274
  if (args.session != null && !isSessionSegment(args.session)) {
2099
2275
  throw new Error(`--session must be a single path segment, got: ${args.session}`);
@@ -2156,8 +2332,10 @@ function main() {
2156
2332
  allowUncommitted: args.allowUncommitted,
2157
2333
  allowUnbacked: args.allowUnbacked,
2158
2334
  });
2335
+ } else if (args.mode === 'remove-shell') {
2336
+ out = removeShell(rootDir, { session: args.session });
2159
2337
  } else {
2160
- out = enableTaskModel(rootDir);
2338
+ out = enableTaskModel(rootDir, { launcher: args.launcher });
2161
2339
  }
2162
2340
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
2163
2341
  if (out && out.refused) {
@@ -2181,4 +2359,4 @@ if (isMainModule(import.meta.url)) {
2181
2359
  }
2182
2360
  }
2183
2361
 
2184
- export { inventory, backupSession, archiveSession, enableTaskModel, classify, parseArgs };
2362
+ export { inventory, backupSession, archiveSession, removeShell, enableTaskModel, classify, parseArgs };
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ // Three-way merge for the `differs` files of an upgrade payload (gh:193).
3
+ //
4
+ // classify-update.mjs files a payload file as `differs` when the workspace
5
+ // copy and the template's new copy both left the baseline — a local edit
6
+ // meeting a template change. Picking one side whole loses the other, so this
7
+ // script merges instead, using the template files of the version being
8
+ // upgraded FROM as the common ancestor. `--upgrade` stages those at
9
+ // .workspace-update/.template-base/ under live names (lib/upgrade.mjs owns
10
+ // the staging) whenever it could fetch the installed version's npm tarball;
11
+ // offline or unpublished, the base is absent and every file reports noBase.
12
+ //
13
+ // Usage:
14
+ // node template-merge.mjs --root <dir> --payload <dir> [--baseline <file>]
15
+ // [--out <dir>] [--files a,b]
16
+ //
17
+ // --root the workspace root (the update worktree in the remote flow);
18
+ // defaults to the current working directory, never derived from
19
+ // this script's location
20
+ // --payload the staged payload; defaults to <root>/.workspace-update
21
+ // --baseline the baseline base content is validated against; resolves
22
+ // exactly like classify-update's (the root's baseline first,
23
+ // then the payload's reconstructed one) when omitted
24
+ // --out where merged text lands, mirroring each path; defaults to
25
+ // <payload>/.merged — NEVER the workspace file itself
26
+ // --files a comma-separated subset of the differs list to process
27
+ //
28
+ // For each differs file, the base at <payload>/.template-base/<path> must
29
+ // exist and, when the baseline records the path, hash (template-baseline's
30
+ // hashBytes) to the baseline's entry — otherwise the file reports noBase: a
31
+ // base the baseline disproves is not this workspace's ancestor, and merging
32
+ // against it would fabricate conflicts or silently drop local edits. With a
33
+ // base, `git merge-file` merges local vs base vs template; its exit status
34
+ // is the conflict count (0 = clean merge, >0 = conflicts, anything else =
35
+ // error). The merged bytes pass through untouched — a file that isn't
36
+ // valid UTF-8 survives a clean merge byte-exact — and a CRLF local copy is
37
+ // folded to LF for the merge and restored to CRLF after, so a Windows
38
+ // checkout neither conflicts wholesale nor loses its line-ending style.
39
+ //
40
+ // Prints JSON:
41
+ // { "merged": [{ path, conflicts: 0, out }],
42
+ // "conflicted": [{ path, conflicts: N, out }],
43
+ // "noBase": [path],
44
+ // "errors": [{ path, message }],
45
+ // "out": "<resolved output dir>",
46
+ // "templateBase": "<resolved base dir, or null when none was staged>" }
47
+ //
48
+ // Merged text — conflict markers `<<<<<<< local` / `>>>>>>> template`
49
+ // included — lands ONLY in the output dir. Applying an approved result is
50
+ // /workspace-update's job: copying the file into the workspace one
51
+ // operator-approved file at a time, never silently.
52
+
53
+ import {
54
+ existsSync,
55
+ mkdirSync,
56
+ mkdtempSync,
57
+ readFileSync,
58
+ realpathSync,
59
+ rmSync,
60
+ writeFileSync,
61
+ } from 'node:fs';
62
+ import { tmpdir } from 'node:os';
63
+ import { dirname, join, resolve } from 'node:path';
64
+ import { fileURLToPath } from 'node:url';
65
+ import { spawnSync } from 'node:child_process';
66
+ import { classifyUpdate, resolveBaseline } from './classify-update.mjs';
67
+ import { hashBytes } from './template-baseline.mjs';
68
+
69
+ export const TEMPLATE_BASE_DIR = '.template-base';
70
+ export const MERGED_DIR = '.merged';
71
+
72
+ function isMainModule(metaUrl) {
73
+ if (!process.argv[1]) return false;
74
+ try {
75
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
76
+ } catch { return false; }
77
+ }
78
+
79
+ function parseArgs(argv) {
80
+ const args = { root: process.cwd(), payload: null, baseline: null, out: null, files: null };
81
+ for (let i = 2; i < argv.length; i++) {
82
+ const a = argv[i];
83
+ if (a === '--root') args.root = argv[++i];
84
+ else if (a === '--payload') args.payload = argv[++i];
85
+ else if (a === '--baseline') args.baseline = argv[++i];
86
+ else if (a === '--out') args.out = argv[++i];
87
+ else if (a === '--files') args.files = argv[++i];
88
+ else throw new Error(`Unknown arg: ${a}`);
89
+ }
90
+ return args;
91
+ }
92
+
93
+ // git merge-file's exit status is the conflict count, truncated to 127; a
94
+ // status outside 0–127 (or a spawn failure, or binary input git refuses to
95
+ // merge) is an error, not a merge.
96
+ const MAX_CONFLICT_STATUS = 127;
97
+ const MERGE_BUFFER = 16 * 1024 * 1024;
98
+
99
+ function gitMergeFile(local, base, template) {
100
+ // -p prints the merged text to stdout instead of overwriting <local> —
101
+ // the workspace file is never touched. The -L labels name the three sides
102
+ // in the order the files follow, so conflict hunks read
103
+ // `<<<<<<< local` … `>>>>>>> template`. Buffers end to end (no encoding):
104
+ // decoding to a string would turn every non-UTF-8 byte into U+FFFD and
105
+ // corrupt a "clean" merge's output.
106
+ const r = spawnSync('git', [
107
+ 'merge-file', '-p',
108
+ '-L', 'local', '-L', 'base', '-L', 'template',
109
+ local, base, template,
110
+ ], { maxBuffer: MERGE_BUFFER });
111
+ if (r.error || r.status === null || r.status < 0 || r.status > MAX_CONFLICT_STATUS) {
112
+ const detail = (r.stderr && r.stderr.toString('utf8').trim())
113
+ || (r.error && r.error.message)
114
+ || `git merge-file exited with status ${r.status}`;
115
+ return { error: detail };
116
+ }
117
+ return { conflicts: r.status, bytes: r.stdout ?? Buffer.alloc(0) };
118
+ }
119
+
120
+ // ---------- line endings ----------
121
+ //
122
+ // The baseline's hashes fold CRLF to LF, but git merge-file compares bytes:
123
+ // a CRLF working copy against LF base and template inputs conflicts on
124
+ // every line of the file. When the local copy is CRLF text, fold all three
125
+ // inputs to LF in temp files for the merge, then restore the local style on
126
+ // the result. Binary (NUL-bearing) and LF-local files take the byte-exact
127
+ // path with no temp files.
128
+
129
+ // latin1 round-trips bytes 1:1 — the same trick hashBytes uses — so the
130
+ // fold never mangles bytes that aren't valid UTF-8.
131
+ function foldCrLf(bytes) {
132
+ if (bytes.includes(0)) return bytes;
133
+ return Buffer.from(bytes.toString('latin1').replace(/\r\n/g, '\n'), 'latin1');
134
+ }
135
+
136
+ function isCrlfText(bytes) {
137
+ return !bytes.includes(0) && bytes.includes('\r\n');
138
+ }
139
+
140
+ function writeTempInput(tmpDir, name, bytes) {
141
+ const p = join(tmpDir, name);
142
+ writeFileSync(p, bytes);
143
+ return p;
144
+ }
145
+
146
+ /**
147
+ * Merge one file trio, returning { conflicts, bytes } or { error }. When
148
+ * the local copy is CRLF text the merge runs on LF-folded copies under a
149
+ * temp dir (removed afterwards) and the result comes back in the local
150
+ * CRLF style — every `\n` in the folded output stood for a line the local
151
+ * file ends with CRLF. The payload's base and template files, and the
152
+ * workspace file, are only ever read here.
153
+ */
154
+ function mergeTrio(local, base, template) {
155
+ const localBytes = readFileSync(local);
156
+ if (!isCrlfText(localBytes)) return gitMergeFile(local, base, template);
157
+ const tmpDir = mkdtempSync(join(tmpdir(), 'template-merge-'));
158
+ try {
159
+ const merged = gitMergeFile(
160
+ writeTempInput(tmpDir, 'local', foldCrLf(localBytes)),
161
+ writeTempInput(tmpDir, 'base', foldCrLf(readFileSync(base))),
162
+ writeTempInput(tmpDir, 'template', foldCrLf(readFileSync(template))),
163
+ );
164
+ if (merged.error) return merged;
165
+ const crlf = Buffer.from(merged.bytes.toString('latin1').replace(/\n/g, '\r\n'), 'latin1');
166
+ return { conflicts: merged.conflicts, bytes: crlf };
167
+ } finally {
168
+ rmSync(tmpDir, { recursive: true, force: true });
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Merge the payload's `differs` files three-way. Classification comes from
174
+ * classifyUpdate (imported, not re-derived) so the merge set is exactly what
175
+ * Step 2 of /workspace-update showed the operator. Merged text — clean or
176
+ * conflicted — is written under `out` (default <payload>/.merged/) mirroring
177
+ * each path; the workspace files themselves are never written here.
178
+ */
179
+ export function mergeTemplateFiles({ root, payload = null, baseline = null, out = null, files = null }) {
180
+ const absRoot = resolve(root);
181
+ const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
182
+ if (!existsSync(absPayload)) {
183
+ throw new Error(`No payload found at ${absPayload} — run npx @ulysses-ai/create-workspace --upgrade first`);
184
+ }
185
+
186
+ const classification = classifyUpdate({ root: absRoot, payload: absPayload, baseline });
187
+ const differsSet = new Set(classification.differs);
188
+ const baseDir = join(absPayload, TEMPLATE_BASE_DIR);
189
+
190
+ const result = {
191
+ merged: [],
192
+ conflicted: [],
193
+ noBase: [],
194
+ errors: [],
195
+ out: resolve(out ?? join(absPayload, MERGED_DIR)),
196
+ templateBase: existsSync(baseDir) ? baseDir : null,
197
+ };
198
+
199
+ let targets = classification.differs;
200
+ if (files !== null) {
201
+ const wanted = files.split(',').map((s) => s.trim()).filter(Boolean);
202
+ targets = [];
203
+ for (const rel of wanted) {
204
+ if (differsSet.has(rel)) targets.push(rel);
205
+ else result.errors.push({ path: rel, message: 'not classified as differs — only differs files can be merged' });
206
+ }
207
+ }
208
+
209
+ const { baseline: resolvedBaseline } = resolveBaseline({ root: absRoot, payload: absPayload, baseline });
210
+ for (const rel of targets) {
211
+ const local = join(absRoot, rel);
212
+ const template = join(absPayload, rel);
213
+ const base = join(baseDir, rel);
214
+ if (!existsSync(local) || !existsSync(template)) {
215
+ result.errors.push({ path: rel, message: 'local or template copy missing' });
216
+ continue;
217
+ }
218
+ if (!existsSync(base)) {
219
+ result.noBase.push(rel);
220
+ continue;
221
+ }
222
+ const baselineHash = resolvedBaseline ? resolvedBaseline.files[rel] : undefined;
223
+ if (baselineHash !== undefined && hashBytes(readFileSync(base)) !== baselineHash) {
224
+ result.noBase.push(rel);
225
+ continue;
226
+ }
227
+ const merge = mergeTrio(local, base, template);
228
+ if (merge.error) {
229
+ result.errors.push({ path: rel, message: merge.error });
230
+ continue;
231
+ }
232
+ const outFile = join(result.out, rel);
233
+ mkdirSync(dirname(outFile), { recursive: true });
234
+ writeFileSync(outFile, merge.bytes);
235
+ const entry = { path: rel, conflicts: merge.conflicts, out: outFile };
236
+ if (merge.conflicts === 0) result.merged.push(entry);
237
+ else result.conflicted.push(entry);
238
+ }
239
+ return result;
240
+ }
241
+
242
+ function main() {
243
+ const args = parseArgs(process.argv);
244
+ const result = mergeTemplateFiles(args);
245
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
246
+ }
247
+
248
+ if (isMainModule(import.meta.url)) {
249
+ try {
250
+ main();
251
+ } catch (err) {
252
+ process.stderr.write(`template-merge: ${err.message}\n`);
253
+ process.exit(1);
254
+ }
255
+ }