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.
- package/CHANGELOG.md +16 -1
- package/README.md +21 -3
- package/lib/agents/acp.js +309 -0
- package/lib/agents/agents.js +163 -436
- package/lib/agents/allow.js +141 -52
- package/lib/agents/allow.json +16 -14
- package/lib/agents/models.js +250 -383
- package/lib/agents/registry.js +437 -0
- package/lib/agents/run.js +234 -0
- package/lib/agents/transcript.js +143 -0
- package/lib/cli.js +2 -1
- package/lib/common/process.js +26 -1
- package/lib/dashboard/git.js +11 -1
- package/lib/dashboard/reports.js +80 -0
- package/lib/dashboard/watch.js +43 -8
- package/lib/dashboard/workspace.js +19 -20
- package/lib/dashboard/worktrees.js +145 -0
- package/lib/git/dir.js +50 -0
- package/lib/git/linked.js +152 -0
- package/lib/git/local.js +179 -0
- package/lib/git/status.js +126 -0
- package/lib/git/worktree.js +113 -36
- package/lib/input/actions.js +83 -5
- package/lib/input/keys.js +30 -1
- package/lib/input/line-edit.js +5 -1
- package/lib/render/agents.js +50 -24
- package/lib/render/branches.js +55 -27
- package/lib/render/field.js +19 -5
- package/lib/render/footer.js +14 -6
- package/lib/render/frame.js +16 -2
- package/lib/render/menu.js +103 -6
- package/lib/render/name-edit.js +31 -0
- package/lib/render/repos.js +52 -8
- package/lib/render/status.js +22 -4
- package/lib/report/run.js +16 -23
- package/lib/review/agent-instructions.md +2 -2
- package/lib/runs/process.js +3 -3
- package/lib/runs/runs.js +27 -4
- package/lib/session/agents/agents.js +220 -205
- package/lib/session/agents/away.js +48 -0
- package/lib/session/agents/job.js +23 -86
- package/lib/session/agents/jobs.js +14 -27
- package/lib/session/agents/options.js +45 -101
- package/lib/session/agents/plans.js +53 -0
- package/lib/session/agents/view.js +34 -0
- package/lib/session/branches.js +18 -1
- package/lib/session/compose.js +13 -5
- package/lib/session/dispatch.js +43 -4
- package/lib/session/editor-pointer.js +98 -0
- package/lib/session/files-pane.js +3 -0
- package/lib/session/lifecycle.js +67 -10
- package/lib/session/navigation.js +5 -0
- package/lib/session/npm/history.js +2 -1
- package/lib/session/npm/npm.js +89 -12
- package/lib/session/ops.js +3 -0
- package/lib/session/pointer.js +304 -46
- package/lib/session/session.js +15 -2
- package/lib/session/terminal.js +2 -2
- package/lib/session/text-menu.js +50 -0
- package/lib/session/view.js +19 -2
- package/lib/session/workspace.js +179 -38
- package/lib/session/worktrees.js +280 -0
- package/lib/term/themes/dark.json +4 -0
- package/lib/term/themes/light.json +4 -0
- 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
|
}
|
package/lib/common/process.js
CHANGED
|
@@ -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 =
|
|
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,
|
package/lib/dashboard/git.js
CHANGED
|
@@ -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) =>
|
|
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
|
+
};
|
package/lib/dashboard/watch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
const readDiffStat = async (dir) => {
|
|
92
|
+
try {
|
|
93
|
+
return await diffCounts(dir);
|
|
94
|
+
} catch {
|
|
95
|
+
return emptyDiff();
|
|
96
|
+
}
|
|
95
97
|
};
|
|
96
98
|
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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 };
|