reslop 0.3.1 → 0.3.2

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +21 -3
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +163 -436
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -14
  7. package/lib/agents/models.js +250 -383
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/transcript.js +143 -0
  11. package/lib/cli.js +2 -1
  12. package/lib/common/process.js +26 -1
  13. package/lib/dashboard/git.js +11 -1
  14. package/lib/dashboard/reports.js +80 -0
  15. package/lib/dashboard/watch.js +43 -8
  16. package/lib/dashboard/workspace.js +19 -20
  17. package/lib/dashboard/worktrees.js +145 -0
  18. package/lib/git/dir.js +50 -0
  19. package/lib/git/linked.js +152 -0
  20. package/lib/git/local.js +179 -0
  21. package/lib/git/status.js +126 -0
  22. package/lib/git/worktree.js +113 -36
  23. package/lib/input/actions.js +83 -5
  24. package/lib/input/keys.js +30 -1
  25. package/lib/input/line-edit.js +5 -1
  26. package/lib/render/agents.js +50 -24
  27. package/lib/render/branches.js +55 -27
  28. package/lib/render/field.js +19 -5
  29. package/lib/render/footer.js +14 -6
  30. package/lib/render/frame.js +16 -2
  31. package/lib/render/menu.js +103 -6
  32. package/lib/render/name-edit.js +31 -0
  33. package/lib/render/repos.js +52 -8
  34. package/lib/render/status.js +22 -4
  35. package/lib/report/run.js +16 -23
  36. package/lib/review/agent-instructions.md +2 -2
  37. package/lib/runs/process.js +3 -3
  38. package/lib/runs/runs.js +27 -4
  39. package/lib/session/agents/agents.js +220 -205
  40. package/lib/session/agents/away.js +48 -0
  41. package/lib/session/agents/job.js +23 -86
  42. package/lib/session/agents/jobs.js +14 -27
  43. package/lib/session/agents/options.js +45 -101
  44. package/lib/session/agents/plans.js +53 -0
  45. package/lib/session/agents/view.js +34 -0
  46. package/lib/session/branches.js +18 -1
  47. package/lib/session/compose.js +13 -5
  48. package/lib/session/dispatch.js +43 -4
  49. package/lib/session/editor-pointer.js +98 -0
  50. package/lib/session/files-pane.js +3 -0
  51. package/lib/session/lifecycle.js +67 -10
  52. package/lib/session/navigation.js +5 -0
  53. package/lib/session/npm/history.js +2 -1
  54. package/lib/session/npm/npm.js +89 -12
  55. package/lib/session/ops.js +3 -0
  56. package/lib/session/pointer.js +304 -46
  57. package/lib/session/session.js +15 -2
  58. package/lib/session/terminal.js +2 -2
  59. package/lib/session/text-menu.js +50 -0
  60. package/lib/session/view.js +19 -2
  61. package/lib/session/workspace.js +179 -38
  62. package/lib/session/worktrees.js +280 -0
  63. package/lib/term/themes/dark.json +4 -0
  64. package/lib/term/themes/light.json +4 -0
  65. package/package.json +2 -2
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const { isHashObject } = require('metautil');
4
+
5
+ const TITLE_MAX = 200;
6
+ const TOOL_MARK = { completed: '✔', failed: '✖' };
7
+ const RUNNING_MARK = '▸';
8
+ const THOUGHT_MARK = '│ ';
9
+ const WRITE_KINDS = ['edit', 'delete', 'move'];
10
+ const CHUNK_KIND = new Map([
11
+ ['agent_message_chunk', 'text'],
12
+ ['agent_thought_chunk', 'thought'],
13
+ ]);
14
+
15
+ const oneLine = (text) => {
16
+ const line = `${text ?? ''}`.replace(/\s+/g, ' ').trim();
17
+ return line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1)}…` : line;
18
+ };
19
+
20
+ const chunkText = (content) => {
21
+ if (!isHashObject(content)) return '';
22
+ return content.type === 'text' ? `${content.text ?? ''}` : '';
23
+ };
24
+
25
+ const quoted = (text) =>
26
+ text
27
+ .replace(/\n+$/, '')
28
+ .split('\n')
29
+ .map((line) => `${THOUGHT_MARK}${line}`)
30
+ .join('\n');
31
+
32
+ const rootPrefix = (root) => {
33
+ const slash = `${root ?? ''}`.replaceAll('\\', '/');
34
+ const trimmed = slash.replace(/\/+$/, '');
35
+ return trimmed ? `${trimmed}/` : '';
36
+ };
37
+
38
+ const FACE = {
39
+ text: (block) => block.text.replace(/\n+$/, ''),
40
+ thought: (block) => quoted(block.text),
41
+ note: (block) => `△ ${block.text}`,
42
+ tool: (block) => {
43
+ const title = block.title || block.tool || 'tool';
44
+ if (block.denied) return `${TOOL_MARK.failed} denied: ${title}`;
45
+ return `${TOOL_MARK[block.status] ?? RUNNING_MARK} ${title}`;
46
+ },
47
+ };
48
+
49
+ // The session updates of a run as the text its log shows
50
+ class Transcript {
51
+ constructor(root = '') {
52
+ // Agents name files by full path: inside the repository it is noise
53
+ this.prefix = rootPrefix(root);
54
+ this.blocks = [];
55
+ this.tools = new Map();
56
+ this.files = new Set();
57
+ this.tokens = null;
58
+ this.cache = null;
59
+ }
60
+
61
+ add(block) {
62
+ this.blocks.push(block);
63
+ this.cache = null;
64
+ return block;
65
+ }
66
+
67
+ note(text) {
68
+ const line = oneLine(text);
69
+ if (line) this.add({ kind: 'note', text: line });
70
+ }
71
+
72
+ fail(error) {
73
+ const text = `${(error && error.message) || error || 'failed'}`.trim();
74
+ this.add({ kind: 'text', text: `✖ ${text}` });
75
+ }
76
+
77
+ write(kind, text) {
78
+ if (!text) return;
79
+ const last = this.blocks.at(-1);
80
+ if (last && last.kind === kind) last.text += text;
81
+ else this.add({ kind, text });
82
+ this.cache = null;
83
+ }
84
+
85
+ title(text) {
86
+ const line = `${text ?? ''}`.replaceAll('\\', '/');
87
+ const stripped = this.prefix ? line.replaceAll(this.prefix, '') : line;
88
+ return oneLine(stripped);
89
+ }
90
+
91
+ deny(call) {
92
+ this.tool(call).denied = true;
93
+ }
94
+
95
+ tool(update) {
96
+ const id = `${update.toolCallId ?? ''}`;
97
+ const known = this.tools.get(id);
98
+ const block = known ?? this.add({ kind: 'tool', title: '', status: '' });
99
+ if (!known) this.tools.set(id, block);
100
+ const { title, status, kind } = update;
101
+ if (typeof title === 'string') block.title = this.title(title);
102
+ if (typeof status === 'string') block.status = status;
103
+ if (typeof kind === 'string') block.tool = kind;
104
+ this.cache = null;
105
+ if (!WRITE_KINDS.includes(block.tool)) return block;
106
+ const listed = Array.isArray(update.locations) ? update.locations : [];
107
+ for (const location of listed) {
108
+ const file = isHashObject(location) ? location.path : '';
109
+ if (file) this.files.add(file);
110
+ }
111
+ return block;
112
+ }
113
+
114
+ update(update) {
115
+ if (!isHashObject(update)) return;
116
+ const type = update.sessionUpdate;
117
+ const kind = CHUNK_KIND.get(type);
118
+ if (kind) return void this.write(kind, chunkText(update.content));
119
+ if (type === 'tool_call' || type === 'tool_call_update') {
120
+ return void this.tool(update);
121
+ }
122
+ if (type !== 'usage_update') return;
123
+ if (Number.isFinite(update.used)) this.tokens = update.used;
124
+ }
125
+
126
+ text() {
127
+ if (this.cache !== null) return this.cache;
128
+ let out = '';
129
+ let prev = '';
130
+ for (const block of this.blocks) {
131
+ const face = FACE[block.kind](block);
132
+ if (!face) continue;
133
+ const joined = prev === 'tool' && block.kind === 'tool';
134
+ if (out) out += joined ? '\n' : '\n\n';
135
+ out += face;
136
+ prev = block.kind;
137
+ }
138
+ this.cache = out ? `${out}\n` : '';
139
+ return this.cache;
140
+ }
141
+ }
142
+
143
+ module.exports = { Transcript };
package/lib/cli.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  } = require('./source/capabilities.js');
17
17
  const { runCapture } = require('./report/run.js');
18
18
  const { workspaceFromArgs } = require('./dashboard/workspace.js');
19
+ const { expandWorkspace } = require('./dashboard/worktrees.js');
19
20
 
20
21
  const HEX_REV = /^[0-9a-f]{4,40}$/i;
21
22
  const HEAD_REV = /^HEAD([~^].*)?$/i;
@@ -72,7 +73,7 @@ const localOptions = async (deps, args, selected, cwd) => {
72
73
  rev: null,
73
74
  capabilities,
74
75
  startPane: 'repos',
75
- workspace: found,
76
+ workspace: await expandWorkspace(found),
76
77
  repoName: path.basename(found.root),
77
78
  };
78
79
  }
@@ -6,6 +6,7 @@ const IS_WIN = process.platform === 'win32';
6
6
  const INSTALL_MS = 120000;
7
7
  const DEFAULT_ENCODING = 'utf8';
8
8
  const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
9
+ const CMD_SAFE = /^[\w./:=@+-]+$/;
9
10
 
10
11
  const npmBin = () => (IS_WIN ? 'npm.cmd' : 'npm');
11
12
 
@@ -14,6 +15,28 @@ const spawnBase = (extra = {}) => ({ windowsHide: true, ...extra });
14
15
  const npmOpts = (extra = {}, platform = process.platform) =>
15
16
  spawnBase({ shell: platform === 'win32', ...extra });
16
17
 
18
+ const quoteCmdArg = (arg) => {
19
+ const text = `${arg}`;
20
+ if (text && CMD_SAFE.test(text)) return text;
21
+ const escaped = text.replaceAll('"', '""').replaceAll('%', '"%"');
22
+ return `"${escaped}"`;
23
+ };
24
+
25
+ const winCommand = (program, args) => {
26
+ const list = args ?? [];
27
+ const parts = [program, ...list].map(quoteCmdArg);
28
+ return `"${parts.join(' ')}"`;
29
+ };
30
+
31
+ // cmd.exe quoting, not Node's shell:true join, which splits a quoted -e script
32
+ const spawnCmd = (cmd, args, options = {}) => {
33
+ if (!IS_WIN || options.shell !== true) return spawn(cmd, args, options);
34
+ const shell = process.env.comspec || 'cmd.exe';
35
+ const line = winCommand(cmd, args);
36
+ const opts = { ...options, windowsVerbatimArguments: true, shell: false };
37
+ return spawn(shell, ['/d', '/s', '/c', line], opts);
38
+ };
39
+
17
40
  const codeError = (message, code) =>
18
41
  Object.assign(new Error(message), { code });
19
42
 
@@ -98,7 +121,7 @@ const runProc = (cmd, args, options = {}) =>
98
121
  if (options.shell) spawnOpts.shell = true;
99
122
  let child = null;
100
123
  try {
101
- child = spawn(cmd, args, spawnBase(spawnOpts));
124
+ child = spawnCmd(cmd, args, spawnBase(spawnOpts));
102
125
  } catch (error) {
103
126
  resolve(procResult({ error }));
104
127
  }
@@ -112,6 +135,8 @@ module.exports = {
112
135
  npmBin,
113
136
  spawnBase,
114
137
  npmOpts,
138
+ winCommand,
139
+ spawnCmd,
115
140
  abortError,
116
141
  isAbort,
117
142
  runProc,
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const { readText } = require('../common/utilities.js');
6
6
 
7
7
  const { runGit } = require('../git/worktree.js');
8
+ const { commitCount, gitDirPath } = require('../git/local.js');
8
9
 
9
10
  const UNIT = '\x1f';
10
11
  const RECENT_COUNT = 8;
@@ -97,9 +98,18 @@ const rebaseState = (gitDir) => {
97
98
  return null;
98
99
  };
99
100
 
101
+ const probeText = (top, name) => {
102
+ if (name === 'dir') return gitDirPath(top);
103
+ if (name === 'total') {
104
+ const counted = commitCount(top);
105
+ if (counted !== '') return counted;
106
+ }
107
+ return gitText(top, PROBES[name]);
108
+ };
109
+
100
110
  const readGitSummary = async (top) => {
101
111
  const names = Object.keys(PROBES);
102
- const read = (name) => gitText(top, PROBES[name]);
112
+ const read = (name) => probeText(top, name);
103
113
  const texts = await Promise.all(names.map(read));
104
114
  const probes = {};
105
115
  for (let i = 0; i < names.length; i++) probes[names[i]] = texts[i];
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+
7
+ const { MANIFEST, LOCKFILE } = require('../deps/deps.js');
8
+ const { collectReport } = require('../git/deps.js');
9
+
10
+ const REPORT_TTL_MS = 10 * 60 * 1000;
11
+ const MODULES_LOCK = path.join('node_modules', '.package-lock.json');
12
+
13
+ const answers = new Map();
14
+
15
+ const hashOf = (file) => {
16
+ try {
17
+ const bytes = fs.readFileSync(file);
18
+ return crypto.createHash('sha1').update(bytes).digest('hex');
19
+ } catch {
20
+ return '';
21
+ }
22
+ };
23
+
24
+ const stampOf = (file) => {
25
+ try {
26
+ const stat = fs.statSync(file);
27
+ return `${stat.mtimeMs}:${stat.size}`;
28
+ } catch {
29
+ return '';
30
+ }
31
+ };
32
+
33
+ // `npm audit` reads the lockfile only, so equal lockfiles have equal answers
34
+ // in every worktree of a repository
35
+ const auditKey = (dir) => {
36
+ const hash = hashOf(path.join(dir, LOCKFILE));
37
+ return hash ? `audit:${hash}` : '';
38
+ };
39
+
40
+ // `npm outdated` reads what is installed, so it is kept for the directory
41
+ const outdatedKey = (dir) => {
42
+ const files = [MANIFEST, LOCKFILE, MODULES_LOCK];
43
+ const stamps = files.map((file) => stampOf(path.join(dir, file)));
44
+ return `outdated:${path.resolve(dir)}:${stamps.join(':')}`;
45
+ };
46
+
47
+ // The answer is asked of npm once while the files stay as they are, however
48
+ // many times the tiles are read: npm goes to the network
49
+ const remembered = (key, collect, now = Date.now()) => {
50
+ if (!key) return collect();
51
+ const hit = answers.get(key);
52
+ if (hit && now - hit.at < REPORT_TTL_MS) return hit.promise;
53
+ const promise = collect();
54
+ answers.set(key, { at: now, promise });
55
+ promise.catch(() => answers.delete(key));
56
+ return promise;
57
+ };
58
+
59
+ const sizeOf = async (collect, dir, kind) => {
60
+ const report = await collect(dir, kind);
61
+ return report ? report.size : 0;
62
+ };
63
+
64
+ const reportCounts = async (dir, collect = collectReport) => {
65
+ const [audit, outdated] = await Promise.all([
66
+ remembered(auditKey(dir), () => sizeOf(collect, dir, 'audit')),
67
+ remembered(outdatedKey(dir), () => sizeOf(collect, dir, 'outdated')),
68
+ ]);
69
+ return { audit, outdated };
70
+ };
71
+
72
+ const forgetReports = () => answers.clear();
73
+
74
+ module.exports = {
75
+ auditKey,
76
+ outdatedKey,
77
+ remembered,
78
+ reportCounts,
79
+ forgetReports,
80
+ };
@@ -6,6 +6,7 @@ const path = require('node:path');
6
6
  const runs = require('../runs/runs.js');
7
7
  const { isRunsRel } = runs;
8
8
  const { REVIEW_DIR, posixRel } = require('../common/files.js');
9
+ const { resolveGitDirs } = require('../git/dir.js');
9
10
 
10
11
  const DEBOUNCE_MS = 200;
11
12
  const UNKNOWN_PATH = '.';
@@ -42,7 +43,13 @@ const ignoredName = (name) => {
42
43
  return suffix || backup || store;
43
44
  };
44
45
 
45
- const ignoredGitRel = ([, name = '', sub]) => {
46
+ // Linked worktrees keep their HEAD and index in `.git/worktrees/<name>`
47
+ const ignoredLinkedRel = ([, , , leaf = '']) =>
48
+ leaf !== '' && !GIT_WATCH_NAMES.includes(leaf) && leaf !== 'logs';
49
+
50
+ const ignoredGitRel = (parts) => {
51
+ const [, name = '', sub] = parts;
52
+ if (name === 'worktrees') return ignoredLinkedRel(parts);
46
53
  if (name === 'logs' && sub === 'HEAD') return false;
47
54
  return name !== '' && !GIT_WATCH_NAMES.includes(name);
48
55
  };
@@ -53,7 +60,8 @@ const ignoredRel = (rel) => {
53
60
  if (norm === MODULES_STAMP) return false;
54
61
  const parts = norm.split('/');
55
62
  if (parts.some((part) => SKIP_DIRS.includes(part))) return true;
56
- if (parts[0] === '.git') return ignoredGitRel(parts);
63
+ const gitAt = parts.indexOf('.git');
64
+ if (gitAt >= 0) return ignoredGitRel(parts.slice(gitAt));
57
65
  return ignoredName(parts[parts.length - 1]);
58
66
  };
59
67
 
@@ -150,15 +158,35 @@ class DiskWatcher {
150
158
  this.closed = false;
151
159
  const scopes = options.paths?.length ? options.paths : ['.'];
152
160
  for (const spec of scopes) this.watchScope(spec);
153
- const gitDir = path.resolve(this.root, '.git');
154
- this.watchOne(gitDir);
155
- this.watchOne(path.join(gitDir, 'refs'));
156
- this.watchOne(path.join(gitDir, 'logs'));
161
+ this.watchGit();
157
162
  this.watchOne(path.resolve(this.root, runs.logDir('.')));
158
163
  this.watchOne(path.resolve(this.root, runs.runsDir('.')));
159
164
  this.watchOne(path.resolve(this.root, REVIEW_DIR));
160
165
  }
161
166
 
167
+ // `.git` of a linked worktree is a file, its state lives in other places
168
+ watchGit() {
169
+ const dirs = resolveGitDirs(this.root);
170
+ this.gitBases = dirs && dirs.linked ? [dirs.gitDir, dirs.commonDir] : [];
171
+ const { gitDir, commonDir } = dirs ?? {};
172
+ const dot = path.resolve(this.root, '.git');
173
+ for (const base of new Set([gitDir ?? dot, commonDir ?? dot])) {
174
+ this.watchOne(base);
175
+ this.watchOne(path.join(base, 'refs'));
176
+ this.watchOne(path.join(base, 'logs'));
177
+ }
178
+ }
179
+
180
+ // Events of the git directories are reported as paths of `.git`
181
+ relOf(full) {
182
+ for (const base of this.gitBases) {
183
+ const inside = path.relative(base, full);
184
+ if (inside.startsWith('..') || path.isAbsolute(inside)) continue;
185
+ return posixRel(path.join('.git', inside));
186
+ }
187
+ return posixRel(path.relative(this.root, full));
188
+ }
189
+
162
190
  schedule(key, fire) {
163
191
  if (this.closed || !fire) return;
164
192
  this.clearTimer(key);
@@ -212,11 +240,17 @@ class DiskWatcher {
212
240
 
213
241
  onEvent(dir, eventType, filename) {
214
242
  if (this.closed) return;
243
+ // Windows keeps reporting a directory after it was deleted, forever
244
+ if (!hasFile(dir)) {
245
+ const rel = this.relOf(dir) || UNKNOWN_PATH;
246
+ this.close();
247
+ if (this.onPaths) this.onPaths([rel]);
248
+ return;
249
+ }
215
250
  const name = filename ? `${filename}` : '';
216
251
  let rel = '';
217
252
  if (name) {
218
- const full = path.join(dir, name);
219
- rel = posixRel(path.relative(this.root, full));
253
+ rel = this.relOf(path.join(dir, name));
220
254
  }
221
255
  const wanted = this.files.has(rel);
222
256
  if (isRunsRel(rel)) {
@@ -253,6 +287,7 @@ class DiskWatcher {
253
287
  const watcher = fs.watch(target, options, (eventType, filename) => {
254
288
  this.onEvent(target, eventType, filename);
255
289
  });
290
+ watcher.unref();
256
291
  watcher.on('error', () => {});
257
292
  this.watched.add(target);
258
293
  this.watchers.push(watcher);
@@ -6,7 +6,8 @@ const path = require('node:path');
6
6
  const { REVIEW_DIR } = require('../common/files.js');
7
7
  const review = require('../review/review.js');
8
8
  const { listReviewNames, latestReviewName, loadReview, noteCounts } = review;
9
- const { countUntrackedAdded } = require('../git/worktree.js');
9
+ const { diffCounts } = require('../git/worktree.js');
10
+ const { branchOf, commitCount, worktreeRoot } = require('../git/local.js');
10
11
  const { gitText, countOf } = require('./git.js');
11
12
  const { runName } = require('./model.js');
12
13
  const { readRuns } = require('../runs/runs.js');
@@ -87,29 +88,26 @@ const sumNumstat = (text) => {
87
88
  return { added, removed };
88
89
  };
89
90
 
90
- const readNumstat = async (dir, cached) => {
91
- const stagedArgs = ['diff', '--cached', '--numstat'];
92
- const openArgs = ['diff', '--numstat'];
93
- const args = cached ? stagedArgs : openArgs;
94
- return sumNumstat(await gitText(dir, args));
91
+ const readDiffStat = async (dir) => {
92
+ try {
93
+ return await diffCounts(dir);
94
+ } catch {
95
+ return emptyDiff();
96
+ }
95
97
  };
96
98
 
97
- const readDiffStat = async (dir) => {
98
- const [staged, open, untracked] = await Promise.all([
99
- readNumstat(dir, true),
100
- readNumstat(dir, false),
101
- countUntrackedAdded(dir),
102
- ]);
103
- return {
104
- stagedAdded: staged.added,
105
- unstagedAdded: open.added + untracked,
106
- stagedRemoved: staged.removed,
107
- unstagedRemoved: open.removed,
108
- };
99
+ const countCommits = async (dir) => {
100
+ const counted = commitCount(dir);
101
+ if (counted !== '') return countOf(counted);
102
+ return countOf(await gitText(dir, ['rev-list', '--count', 'HEAD']));
109
103
  };
110
104
 
111
- const countCommits = async (dir) =>
112
- countOf(await gitText(dir, ['rev-list', '--count', 'HEAD']));
105
+ // Empty when HEAD is detached
106
+ const currentBranchName = async (dir) => {
107
+ const root = worktreeRoot(dir);
108
+ if (root) return branchOf(root);
109
+ return gitText(dir, ['symbolic-ref', '--short', '-q', 'HEAD']);
110
+ };
113
111
 
114
112
  const taskStats = (dir) => {
115
113
  const latest = latestReviewName(listReviewNames(dir));
@@ -146,6 +144,7 @@ module.exports = {
146
144
  sumNumstat,
147
145
  readDiffStat,
148
146
  countCommits,
147
+ currentBranchName,
149
148
  taskStats,
150
149
  pickRun,
151
150
  };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { posixRel } = require('../common/files.js');
7
+ const linked = require('../git/linked.js');
8
+ const { listWorktrees, dirKey } = linked;
9
+ const { resolveGitDirs } = require('../git/dir.js');
10
+ const { workspaceAt } = require('./workspace.js');
11
+
12
+ const HEAD_LENGTH = 7;
13
+ const UNSAFE_NAME = /[\\/:*?"<>|\s]+/g;
14
+
15
+ const isDir = (dir) => {
16
+ try {
17
+ return fs.statSync(dir).isDirectory();
18
+ } catch {
19
+ return false;
20
+ }
21
+ };
22
+
23
+ // Inside the folder a worktree is named by its directory, outside by its path
24
+ const nameOf = (root, dir, taken) => {
25
+ const rel = posixRel(path.relative(root, dir));
26
+ const inside = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
27
+ const plain = inside ? rel : path.basename(dir);
28
+ return taken.has(plain) ? dir : plain;
29
+ };
30
+
31
+ const plainRow = (repo) => ({
32
+ name: repo.name,
33
+ dir: repo.dir,
34
+ group: repo.name,
35
+ main: true,
36
+ linked: false,
37
+ branch: '',
38
+ head: '',
39
+ detached: false,
40
+ locked: false,
41
+ });
42
+
43
+ const entryRow = (root, entry, taken) => {
44
+ const name = nameOf(root, entry.path, taken);
45
+ return {
46
+ name,
47
+ dir: entry.path,
48
+ group: name,
49
+ main: entry.main,
50
+ linked: !entry.main,
51
+ branch: entry.branch,
52
+ head: entry.head.slice(0, HEAD_LENGTH),
53
+ detached: entry.detached,
54
+ locked: entry.locked,
55
+ };
56
+ };
57
+
58
+ const usable = (entry) => !entry.bare && !entry.prunable && isDir(entry.path);
59
+
60
+ // The main one first, the linked ones by their directory name
61
+ const inOrder = (entries) => {
62
+ const main = entries.filter((entry) => entry.main);
63
+ const rest = entries.filter((entry) => !entry.main);
64
+ const dirName = (entry) => path.basename(entry.path);
65
+ rest.sort((a, b) => dirName(a).localeCompare(dirName(b), 'en'));
66
+ return [...main, ...rest];
67
+ };
68
+
69
+ // The repositories of the folder with all their worktrees, one group each:
70
+ // the main worktree first and the linked ones after it
71
+ const expandWorkspace = async (info) => {
72
+ const seen = new Set();
73
+ const taken = new Set();
74
+ const repos = [];
75
+ const lists = await Promise.all(info.repos.map((r) => listWorktrees(r.dir)));
76
+ // Git reports real paths; a directory that was found keeps the path it
77
+ // was found by, which may go through a link
78
+ const found = new Map(info.repos.map((r) => [dirKey(r.dir), r.dir]));
79
+ for (const [index, repo] of info.repos.entries()) {
80
+ if (seen.has(dirKey(repo.dir))) continue;
81
+ const rows = inOrder(lists[index].filter(usable));
82
+ if (!rows.length) {
83
+ seen.add(dirKey(repo.dir));
84
+ taken.add(repo.name);
85
+ repos.push(plainRow(repo));
86
+ continue;
87
+ }
88
+ let group = '';
89
+ for (const entry of rows) {
90
+ seen.add(dirKey(entry.path));
91
+ const where = found.get(dirKey(entry.path)) ?? entry.path;
92
+ const row = entryRow(info.root, { ...entry, path: where }, taken);
93
+ group ||= row.name;
94
+ taken.add(row.name);
95
+ repos.push({ ...row, group });
96
+ }
97
+ }
98
+ return { ...info, repos };
99
+ };
100
+
101
+ // A folder of repositories, or the worktrees of one repository
102
+ // The worktrees of a repository are asked of any of them that is left
103
+ const readWorkspace = async (info, rows = []) => {
104
+ if (info.derived) {
105
+ const row = rows.find((it) => isDir(it.dir));
106
+ const seed = row ? [{ name: row.name, dir: row.dir }] : info.repos;
107
+ return expandWorkspace({ ...info, repos: seed });
108
+ }
109
+ const found = workspaceAt(info.root);
110
+ if (!found) return { ...info, repos: [] };
111
+ return expandWorkspace(found);
112
+ };
113
+
114
+ const worktreesOf = (top) => ({
115
+ root: top,
116
+ repos: [{ name: path.basename(top), dir: top }],
117
+ derived: true,
118
+ });
119
+
120
+ // What the watcher of the folder needs of a worktree besides the folder:
121
+ // its git directory and, outside of the folder, its files
122
+ const watchedBy = (root, row) => {
123
+ const key = dirKey(row.dir);
124
+ const rel = path.relative(root, key);
125
+ const inside = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
126
+ const paths = inside ? [] : [key];
127
+ const dirs = resolveGitDirs(key);
128
+ if (dirs) paths.push(dirKey(dirs.gitDir));
129
+ return paths;
130
+ };
131
+
132
+ // A new worktree is a sibling of the main one: `app` and `app-feature`
133
+ const worktreeTarget = (main, branch) => {
134
+ const slug = `${branch}`.trim().replace(UNSAFE_NAME, '-');
135
+ const base = path.basename(main.dir);
136
+ return path.join(path.dirname(main.dir), `${base}-${slug}`);
137
+ };
138
+
139
+ module.exports = {
140
+ expandWorkspace,
141
+ readWorkspace,
142
+ worktreesOf,
143
+ watchedBy,
144
+ worktreeTarget,
145
+ };
package/lib/git/dir.js ADDED
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const GITDIR_PREFIX = 'gitdir:';
7
+
8
+ const statOf = (file) => {
9
+ try {
10
+ return fs.statSync(file);
11
+ } catch {
12
+ return null;
13
+ }
14
+ };
15
+
16
+ const readFirstLine = (file) => {
17
+ try {
18
+ return fs.readFileSync(file, 'utf8').split('\n')[0] ?? '';
19
+ } catch {
20
+ return '';
21
+ }
22
+ };
23
+
24
+ // A linked worktree keeps `.git` as a file that points into the main one
25
+ const gitDirOf = (dot) => {
26
+ const line = readFirstLine(dot).trim();
27
+ if (!line.startsWith(GITDIR_PREFIX)) return '';
28
+ const raw = line.slice(GITDIR_PREFIX.length).trim();
29
+ if (!raw) return '';
30
+ return path.resolve(path.dirname(dot), raw);
31
+ };
32
+
33
+ const commonGitDir = (gitDir) => {
34
+ const raw = readFirstLine(path.join(gitDir, 'commondir')).trim();
35
+ if (!raw) return gitDir;
36
+ return path.resolve(gitDir, raw);
37
+ };
38
+
39
+ // The directory with HEAD and the index, and the one with refs and objects
40
+ const resolveGitDirs = (root) => {
41
+ const dot = path.join(path.resolve(root), '.git');
42
+ const stat = statOf(dot);
43
+ if (!stat) return null;
44
+ if (stat.isDirectory()) return { gitDir: dot, commonDir: dot, linked: false };
45
+ const gitDir = stat.isFile() ? gitDirOf(dot) : '';
46
+ if (!gitDir) return null;
47
+ return { gitDir, commonDir: commonGitDir(gitDir), linked: true };
48
+ };
49
+
50
+ module.exports = { resolveGitDirs };