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,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { oneLine } = require('../common/utilities.js');
|
|
7
|
+
const { REVIEW_DIR, LOG_DIR } = require('../common/files.js');
|
|
8
|
+
const { runGit, requireOk } = require('./worktree.js');
|
|
9
|
+
|
|
10
|
+
const HEADS = 'refs/heads/';
|
|
11
|
+
|
|
12
|
+
const FIELDS = {
|
|
13
|
+
worktree: (entry, value) => ({ path: path.resolve(value) }),
|
|
14
|
+
HEAD: (entry, value) => ({ head: value }),
|
|
15
|
+
branch: (entry, value) => ({ branch: value.replace(HEADS, '') }),
|
|
16
|
+
detached: () => ({ detached: true }),
|
|
17
|
+
bare: () => ({ bare: true }),
|
|
18
|
+
prunable: () => ({ prunable: true }),
|
|
19
|
+
locked: (entry, value) => ({ locked: true, lockReason: value }),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const parseEntry = (block) => {
|
|
23
|
+
let entry = {
|
|
24
|
+
path: '',
|
|
25
|
+
head: '',
|
|
26
|
+
branch: '',
|
|
27
|
+
detached: false,
|
|
28
|
+
bare: false,
|
|
29
|
+
locked: false,
|
|
30
|
+
lockReason: '',
|
|
31
|
+
prunable: false,
|
|
32
|
+
};
|
|
33
|
+
for (const line of block.split('\n')) {
|
|
34
|
+
const space = line.indexOf(' ');
|
|
35
|
+
const key = space < 0 ? line : line.slice(0, space);
|
|
36
|
+
const value = space < 0 ? '' : line.slice(space + 1).trim();
|
|
37
|
+
const field = Object.hasOwn(FIELDS, key) ? FIELDS[key] : null;
|
|
38
|
+
if (field) entry = { ...entry, ...field(entry, value) };
|
|
39
|
+
}
|
|
40
|
+
return entry;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// The first entry is the main worktree, the others are linked to it
|
|
44
|
+
const parseWorktrees = (text) =>
|
|
45
|
+
`${text ?? ''}`
|
|
46
|
+
.split(/\r?\n\r?\n/)
|
|
47
|
+
.filter((block) => block.trim())
|
|
48
|
+
.map(parseEntry)
|
|
49
|
+
.filter((entry) => entry.path)
|
|
50
|
+
.map((entry, index) => ({ ...entry, main: index === 0 }));
|
|
51
|
+
|
|
52
|
+
const listWorktrees = async (dir) => {
|
|
53
|
+
const args = ['worktree', 'list', '--porcelain'];
|
|
54
|
+
const result = await runGit(args, dir);
|
|
55
|
+
if (result.status !== 0) return [];
|
|
56
|
+
return parseWorktrees(result.stdout);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Git reports real paths, the others can go through links
|
|
60
|
+
const dirKey = (dir) => {
|
|
61
|
+
try {
|
|
62
|
+
return fs.realpathSync.native(dir);
|
|
63
|
+
} catch {
|
|
64
|
+
return path.resolve(dir);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const sameDir = (left, right) => dirKey(left) === dirKey(right);
|
|
69
|
+
|
|
70
|
+
const findWorktree = (list, dir) => list.find((it) => sameDir(it.path, dir));
|
|
71
|
+
|
|
72
|
+
const isOwnFile = (rel) => {
|
|
73
|
+
const head = rel.split(/[/\\]/)[0];
|
|
74
|
+
return head === REVIEW_DIR || head === LOG_DIR;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// The plan and the logs of reslop are not the work of the user
|
|
78
|
+
const changedFiles = async (dir) => {
|
|
79
|
+
const args = ['status', '--porcelain', '-z', '--untracked-files=all'];
|
|
80
|
+
const result = await runGit([...args, '--no-renames'], dir);
|
|
81
|
+
requireOk(result, 'git status failed');
|
|
82
|
+
const names = result.stdout.split('\0').filter(Boolean);
|
|
83
|
+
return names.map((line) => line.slice(3)).filter((rel) => !isOwnFile(rel));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const isDirty = async (dir) => (await changedFiles(dir)).length > 0;
|
|
87
|
+
|
|
88
|
+
const branchExists = async (top, branch) => {
|
|
89
|
+
const args = ['show-ref', '--verify', '--quiet', `${HEADS}${branch}`];
|
|
90
|
+
return (await runGit(args, top)).status === 0;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const branchError = (branch) => {
|
|
94
|
+
const name = `${branch ?? ''}`.trim();
|
|
95
|
+
if (!name) return 'empty branch name';
|
|
96
|
+
if (name.startsWith('-')) return `bad branch name ${name}`;
|
|
97
|
+
return '';
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Git tells what happened after a line of progress: the last complaint
|
|
101
|
+
const requireDone = (result, fallback) => {
|
|
102
|
+
if (result.status === 0) return result;
|
|
103
|
+
const text = `${result.stderr}\n${result.stdout}`;
|
|
104
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim());
|
|
105
|
+
const filled = lines.filter(Boolean);
|
|
106
|
+
const named = filled.filter((line) => /^(fatal|error):/.test(line));
|
|
107
|
+
const line = named.at(-1) ?? filled.at(-1) ?? fallback;
|
|
108
|
+
throw new Error(oneLine(line.replace(/^(fatal|error):\s*/, ''), fallback));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Git itself refuses a branch that is checked out in another worktree
|
|
112
|
+
const addWorktree = async (top, target, branch) => {
|
|
113
|
+
const failed = branchError(branch);
|
|
114
|
+
if (failed) throw new Error(failed);
|
|
115
|
+
const name = branch.trim();
|
|
116
|
+
const dir = path.resolve(top, target);
|
|
117
|
+
const exists = await branchExists(top, name);
|
|
118
|
+
const args = exists
|
|
119
|
+
? ['worktree', 'add', '--', dir, name]
|
|
120
|
+
: ['worktree', 'add', '-b', name, '--', dir];
|
|
121
|
+
requireDone(await runGit(args, top), 'git worktree add failed');
|
|
122
|
+
return dir;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Never forced: uncommitted work or a lock keeps the worktree
|
|
126
|
+
const removeWorktree = async (top, target) => {
|
|
127
|
+
const list = await listWorktrees(top);
|
|
128
|
+
const found = findWorktree(list, target);
|
|
129
|
+
if (!found) throw new Error('not a worktree of this repository');
|
|
130
|
+
if (found.main) throw new Error('the main worktree cannot be removed');
|
|
131
|
+
if (found.locked) {
|
|
132
|
+
const why = found.lockReason ? `: ${found.lockReason}` : '';
|
|
133
|
+
throw new Error(oneLine(`worktree is locked${why}`, 'worktree is locked'));
|
|
134
|
+
}
|
|
135
|
+
if (await isDirty(found.path)) {
|
|
136
|
+
throw new Error('worktree has uncommitted changes');
|
|
137
|
+
}
|
|
138
|
+
const args = ['worktree', 'remove', '--', found.path];
|
|
139
|
+
requireDone(await runGit(args, top), 'git worktree remove failed');
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
parseWorktrees,
|
|
144
|
+
listWorktrees,
|
|
145
|
+
findWorktree,
|
|
146
|
+
dirKey,
|
|
147
|
+
sameDir,
|
|
148
|
+
changedFiles,
|
|
149
|
+
isDirty,
|
|
150
|
+
addWorktree,
|
|
151
|
+
removeWorktree,
|
|
152
|
+
};
|
package/lib/git/local.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const zlib = require('node:zlib');
|
|
6
|
+
|
|
7
|
+
const { resolveGitDirs } = require('./dir.js');
|
|
8
|
+
|
|
9
|
+
const HEAD_REF = 'ref: refs/heads/';
|
|
10
|
+
const SHA = /^[0-9a-f]{40}$/i;
|
|
11
|
+
|
|
12
|
+
const readText = (file) => {
|
|
13
|
+
try {
|
|
14
|
+
return fs.readFileSync(file, 'utf8');
|
|
15
|
+
} catch {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const readFirstLine = (file) => readText(file).split('\n')[0] ?? '';
|
|
21
|
+
|
|
22
|
+
const isWorktreeRoot = (dir) => {
|
|
23
|
+
const dirs = resolveGitDirs(dir);
|
|
24
|
+
if (!dirs) return false;
|
|
25
|
+
try {
|
|
26
|
+
const head = path.join(dirs.gitDir, 'HEAD');
|
|
27
|
+
const objects = path.join(dirs.commonDir, 'objects');
|
|
28
|
+
const refs = path.join(dirs.commonDir, 'refs');
|
|
29
|
+
return (
|
|
30
|
+
fs.statSync(head).isFile() &&
|
|
31
|
+
fs.statSync(objects).isDirectory() &&
|
|
32
|
+
fs.statSync(refs).isDirectory()
|
|
33
|
+
);
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// The worktree that contains `start`, without asking git. A linked checkout
|
|
40
|
+
// is the directory whose `.git` file points at the common repository.
|
|
41
|
+
const worktreeRoot = (start) => {
|
|
42
|
+
let dir = path.resolve(start);
|
|
43
|
+
let stat;
|
|
44
|
+
try {
|
|
45
|
+
stat = fs.statSync(dir);
|
|
46
|
+
} catch {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
if (!stat.isDirectory()) dir = path.dirname(dir);
|
|
50
|
+
for (;;) {
|
|
51
|
+
if (isWorktreeRoot(dir)) return dir;
|
|
52
|
+
const parent = path.dirname(dir);
|
|
53
|
+
if (parent === dir) return '';
|
|
54
|
+
dir = parent;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const headLine = (top) => {
|
|
59
|
+
const dirs = resolveGitDirs(top);
|
|
60
|
+
if (!dirs) return '';
|
|
61
|
+
return readFirstLine(path.join(dirs.gitDir, 'HEAD')).trim();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Empty when HEAD is detached or the checkout has no branch yet
|
|
65
|
+
const branchOf = (top) => {
|
|
66
|
+
const line = headLine(top);
|
|
67
|
+
if (!line.startsWith(HEAD_REF)) return '';
|
|
68
|
+
return line.slice(HEAD_REF.length);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const packedSha = (commonDir, ref) => {
|
|
72
|
+
const text = readText(path.join(commonDir, 'packed-refs'));
|
|
73
|
+
for (const line of text.split('\n')) {
|
|
74
|
+
if (!line || line.startsWith('#') || line.startsWith('^')) continue;
|
|
75
|
+
const space = line.indexOf(' ');
|
|
76
|
+
if (space < 0) continue;
|
|
77
|
+
if (line.slice(space + 1).trim() === ref) return line.slice(0, space);
|
|
78
|
+
}
|
|
79
|
+
return '';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const refSha = (commonDir, ref) => {
|
|
83
|
+
const direct = readFirstLine(path.join(commonDir, ref)).trim();
|
|
84
|
+
if (SHA.test(direct)) return direct;
|
|
85
|
+
return packedSha(commonDir, ref);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const headSha = (dirs) => {
|
|
89
|
+
const line = readFirstLine(path.join(dirs.gitDir, 'HEAD')).trim();
|
|
90
|
+
if (SHA.test(line)) return line;
|
|
91
|
+
if (!line.startsWith('ref: ')) return '';
|
|
92
|
+
return refSha(dirs.commonDir, line.slice('ref: '.length).trim());
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const looseCommit = (commonDir, sha) => {
|
|
96
|
+
if (!SHA.test(sha)) return null;
|
|
97
|
+
const file = path.join(commonDir, 'objects', sha.slice(0, 2), sha.slice(2));
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = zlib.inflateSync(fs.readFileSync(file));
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const text = raw.toString('utf8');
|
|
105
|
+
const nul = text.indexOf('\0');
|
|
106
|
+
if (nul < 0 || !text.slice(0, nul).startsWith('commit ')) return null;
|
|
107
|
+
return text.slice(nul + 1);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const parentsOf = (body) => {
|
|
111
|
+
const parents = [];
|
|
112
|
+
for (const line of body.split('\n')) {
|
|
113
|
+
if (line.startsWith('parent ')) parents.push(line.slice('parent '.length));
|
|
114
|
+
else if (line === '') break;
|
|
115
|
+
}
|
|
116
|
+
return parents;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Null when an object lives in a pack: the caller asks git then
|
|
120
|
+
const countLoose = (commonDir, sha) => {
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
const queue = [sha];
|
|
123
|
+
while (queue.length) {
|
|
124
|
+
const id = queue.pop();
|
|
125
|
+
if (!id || seen.has(id)) continue;
|
|
126
|
+
const body = looseCommit(commonDir, id);
|
|
127
|
+
if (body === null) return null;
|
|
128
|
+
seen.add(id);
|
|
129
|
+
for (const parent of parentsOf(body)) queue.push(parent);
|
|
130
|
+
}
|
|
131
|
+
return seen.size;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// '' when the history is not a set of loose objects
|
|
135
|
+
const commitCount = (top) => {
|
|
136
|
+
const dirs = resolveGitDirs(top);
|
|
137
|
+
if (!dirs) return '';
|
|
138
|
+
const sha = headSha(dirs);
|
|
139
|
+
if (!sha) return '0';
|
|
140
|
+
const count = countLoose(dirs.commonDir, sha);
|
|
141
|
+
if (count === null) return '';
|
|
142
|
+
return `${count}`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const originUrl = (text) => {
|
|
146
|
+
let section = '';
|
|
147
|
+
for (const line of text.split('\n')) {
|
|
148
|
+
const trimmed = line.trim();
|
|
149
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
150
|
+
section = trimmed.slice(1, -1).trim();
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (section !== 'remote "origin"') continue;
|
|
154
|
+
const eq = trimmed.indexOf('=');
|
|
155
|
+
if (eq < 0) continue;
|
|
156
|
+
if (trimmed.slice(0, eq).trim() !== 'url') continue;
|
|
157
|
+
return trimmed.slice(eq + 1).trim();
|
|
158
|
+
}
|
|
159
|
+
return '';
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const remoteOrigin = (top) => {
|
|
163
|
+
const dirs = resolveGitDirs(top);
|
|
164
|
+
if (!dirs) return '';
|
|
165
|
+
return originUrl(readText(path.join(dirs.commonDir, 'config')));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const gitDirPath = (top) => {
|
|
169
|
+
const dirs = resolveGitDirs(top);
|
|
170
|
+
return dirs ? dirs.gitDir : '';
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
worktreeRoot,
|
|
175
|
+
branchOf,
|
|
176
|
+
commitCount,
|
|
177
|
+
remoteOrigin,
|
|
178
|
+
gitDirPath,
|
|
179
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const STAGED_TITLE = 'Changes to be committed:\n';
|
|
4
|
+
const WORK_TITLE = 'Changes not staged for commit:\n';
|
|
5
|
+
const UNTRACKED_TITLE = 'Untracked files:\n';
|
|
6
|
+
const PREFIX = {
|
|
7
|
+
staged: { left: 'c/', right: 'i/' },
|
|
8
|
+
work: { left: 'i/', right: 'w/' },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DIFF_HEADS = [
|
|
12
|
+
'diff --git ',
|
|
13
|
+
'index ',
|
|
14
|
+
'@@ ',
|
|
15
|
+
'new file ',
|
|
16
|
+
'deleted file ',
|
|
17
|
+
'old mode ',
|
|
18
|
+
'new mode ',
|
|
19
|
+
'similarity ',
|
|
20
|
+
'rename ',
|
|
21
|
+
'Binary files ',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const isDiffHead = (line) => DIFF_HEADS.some((head) => line.startsWith(head));
|
|
25
|
+
|
|
26
|
+
const isDiffLine = (line) => {
|
|
27
|
+
if (/^-{10,}$/.test(line)) return false;
|
|
28
|
+
if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (line.startsWith('\\')) return true;
|
|
32
|
+
return isDiffHead(line);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const sectionDiff = (text, title) => {
|
|
36
|
+
const at = text.lastIndexOf(title);
|
|
37
|
+
if (at < 0) return '';
|
|
38
|
+
const rest = text.slice(at + title.length);
|
|
39
|
+
if (!rest.startsWith('diff --git ')) return '';
|
|
40
|
+
const lines = [];
|
|
41
|
+
for (const line of rest.split('\n')) {
|
|
42
|
+
if (lines.length && !isDiffLine(line)) break;
|
|
43
|
+
lines.push(line);
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n').replace(/\s+$/, '');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const untrackedOf = (text) => {
|
|
49
|
+
const at = text.indexOf(UNTRACKED_TITLE);
|
|
50
|
+
if (at < 0) return [];
|
|
51
|
+
const rest = text.slice(at + UNTRACKED_TITLE.length);
|
|
52
|
+
const stop = rest.indexOf('\ndiff --git ');
|
|
53
|
+
const block = stop < 0 ? rest : rest.slice(0, stop);
|
|
54
|
+
const names = [];
|
|
55
|
+
for (const line of block.split('\n')) {
|
|
56
|
+
if (line.startsWith('\t')) names.push(line.slice(1));
|
|
57
|
+
}
|
|
58
|
+
return names;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const rewritePath = (value, from, to) => {
|
|
62
|
+
if (value === '/dev/null') return value;
|
|
63
|
+
if (!value.startsWith(from)) return '';
|
|
64
|
+
return `${to}${value.slice(from.length)}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const rewriteGit = (line, left, right) => {
|
|
68
|
+
const rest = line.slice('diff --git '.length);
|
|
69
|
+
const mid = ` ${right}`;
|
|
70
|
+
const at = rest.indexOf(mid);
|
|
71
|
+
if (!rest.startsWith(left) || at < 0) return '';
|
|
72
|
+
const oldPath = rest.slice(left.length, at);
|
|
73
|
+
const newPath = rest.slice(at + mid.length);
|
|
74
|
+
return `diff --git a/${oldPath} b/${newPath}`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const rewriteBinary = (line, left, right) => {
|
|
78
|
+
const match = /^Binary files (.+) and (.+) differ$/.exec(line);
|
|
79
|
+
if (!match) return line;
|
|
80
|
+
const oldPath = rewritePath(match[1], left, 'a/');
|
|
81
|
+
const newPath = rewritePath(match[2], right, 'b/');
|
|
82
|
+
if (!oldPath || !newPath) return '';
|
|
83
|
+
return `Binary files ${oldPath} and ${newPath} differ`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const rewriteMarker = (line, mark, from, to) => {
|
|
87
|
+
if (!line.startsWith(mark)) return line;
|
|
88
|
+
const value = line.slice(mark.length);
|
|
89
|
+
if (value === '/dev/null') return line;
|
|
90
|
+
const next = rewritePath(value, from, to);
|
|
91
|
+
if (!next) return '';
|
|
92
|
+
return `${mark}${next}`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const rewritten = (line, left, right) => {
|
|
96
|
+
if (line.startsWith('diff --git ')) return rewriteGit(line, left, right);
|
|
97
|
+
if (line.startsWith('Binary files ')) return rewriteBinary(line, left, right);
|
|
98
|
+
if (line.startsWith('--- ')) return rewriteMarker(line, '--- ', left, 'a/');
|
|
99
|
+
if (line.startsWith('+++ ')) return rewriteMarker(line, '+++ ', right, 'b/');
|
|
100
|
+
return line;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// git status -vv labels the sides c/ (HEAD), i/ (index) and w/ (worktree).
|
|
104
|
+
// The diff parser wants the a/ and b/ labels of git diff.
|
|
105
|
+
const rewriteDiff = (text, side) => {
|
|
106
|
+
if (!text) return '';
|
|
107
|
+
const { left, right } = PREFIX[side];
|
|
108
|
+
const lines = [];
|
|
109
|
+
for (const line of text.split('\n')) {
|
|
110
|
+
const next = rewritten(line, left, right);
|
|
111
|
+
if (!next) return null;
|
|
112
|
+
lines.push(next);
|
|
113
|
+
}
|
|
114
|
+
return `${lines.join('\n')}\n`;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Null when this git prints diffs in a shape we do not rewrite
|
|
118
|
+
const parseStatus = (text) => {
|
|
119
|
+
const raw = `${text ?? ''}`.replaceAll('\r\n', '\n');
|
|
120
|
+
const staged = rewriteDiff(sectionDiff(raw, STAGED_TITLE), 'staged');
|
|
121
|
+
const work = rewriteDiff(sectionDiff(raw, WORK_TITLE), 'work');
|
|
122
|
+
if (staged === null || work === null) return null;
|
|
123
|
+
return { staged, work, untracked: untrackedOf(raw) };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
module.exports = { parseStatus };
|
package/lib/git/worktree.js
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
-
const { runProc, isAbort } = require('../common/process.js');
|
|
6
|
+
const { runProc, isAbort, abortError } = require('../common/process.js');
|
|
7
7
|
const { oneLine } = require('../common/utilities.js');
|
|
8
8
|
const diff = require('../diff/diff.js');
|
|
9
9
|
const { parseDiff, synthesizeNewFile, itemsFromFiles } = diff;
|
|
10
10
|
const files = require('../common/files.js');
|
|
11
11
|
const { groupByPath } = files;
|
|
12
12
|
const { itemPath, isReadOnlyOrigin, lineDelta, REVIEW_DIR } = files;
|
|
13
|
+
const local = require('./local.js');
|
|
14
|
+
const { worktreeRoot, branchOf, remoteOrigin } = local;
|
|
15
|
+
const { parseStatus } = require('./status.js');
|
|
13
16
|
const { replaceBlockAdds } = require('../diff/patch.js');
|
|
14
17
|
|
|
15
18
|
const GIT_CONFIG = [
|
|
@@ -20,10 +23,30 @@ const GIT_CONFIG = [
|
|
|
20
23
|
];
|
|
21
24
|
const DIFF_OPTS = ['--no-color', '--no-ext-diff', '--no-renames', '-U3'];
|
|
22
25
|
const BINARY_SIZE = 1000000;
|
|
26
|
+
const STATUS_ARGS = [
|
|
27
|
+
'-c',
|
|
28
|
+
'core.quotepath=false',
|
|
29
|
+
'-c',
|
|
30
|
+
'color.ui=false',
|
|
31
|
+
'-c',
|
|
32
|
+
'diff.renames=false',
|
|
33
|
+
'status',
|
|
34
|
+
'-vv',
|
|
35
|
+
'--no-renames',
|
|
36
|
+
'--no-ahead-behind',
|
|
37
|
+
'-uall',
|
|
38
|
+
];
|
|
39
|
+
const STATUS_ENV = { LC_MESSAGES: 'C', LANGUAGE: 'C' };
|
|
23
40
|
|
|
24
41
|
const gitOptions = (cwd, extra) => ({
|
|
25
42
|
cwd,
|
|
26
|
-
env: {
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
46
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
47
|
+
GCM_INTERACTIVE: 'Never',
|
|
48
|
+
...extra.env,
|
|
49
|
+
},
|
|
27
50
|
input: extra.input,
|
|
28
51
|
signal: extra.signal,
|
|
29
52
|
});
|
|
@@ -183,17 +206,12 @@ const untrackedItems = async (top, names) => {
|
|
|
183
206
|
return itemsFromFiles(files, 'untracked');
|
|
184
207
|
};
|
|
185
208
|
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const items = untrackedItemsFrom(top, namesFromLs(result.stdout));
|
|
191
|
-
let added = 0;
|
|
192
|
-
for (const item of items) added += lineDelta(item).added;
|
|
193
|
-
return added;
|
|
194
|
-
} catch {
|
|
195
|
-
return 0;
|
|
209
|
+
const addedOf = (names, top) => {
|
|
210
|
+
let added = 0;
|
|
211
|
+
for (const item of untrackedItemsFrom(top, names)) {
|
|
212
|
+
added += lineDelta(item).added;
|
|
196
213
|
}
|
|
214
|
+
return added;
|
|
197
215
|
};
|
|
198
216
|
|
|
199
217
|
const gitText = async (top, spec) => {
|
|
@@ -240,13 +258,23 @@ const readDepSides = async (top, rev, origin, rel) => {
|
|
|
240
258
|
return { oldText, newText };
|
|
241
259
|
};
|
|
242
260
|
|
|
243
|
-
const
|
|
261
|
+
const toplevelGit = async (cwd, signal) => {
|
|
244
262
|
const args = ['rev-parse', '--show-toplevel'];
|
|
245
263
|
const result = await runGit(args, cwd, { signal });
|
|
246
264
|
if (result.status !== 0) throw new Error('not a git repository');
|
|
247
265
|
return result.stdout.trim();
|
|
248
266
|
};
|
|
249
267
|
|
|
268
|
+
const toplevel = async (cwd, signal) => {
|
|
269
|
+
if (signal && signal.aborted) throw abortError();
|
|
270
|
+
if (process.env.GIT_DIR || process.env.GIT_WORK_TREE) {
|
|
271
|
+
return toplevelGit(cwd, signal);
|
|
272
|
+
}
|
|
273
|
+
const found = worktreeRoot(cwd);
|
|
274
|
+
if (found) return found;
|
|
275
|
+
return toplevelGit(cwd, signal);
|
|
276
|
+
};
|
|
277
|
+
|
|
250
278
|
const resolveRev = async (cwd, spec) => {
|
|
251
279
|
if (!spec) return null;
|
|
252
280
|
const args = ['rev-parse', '--verify', `${spec}^{commit}`];
|
|
@@ -259,20 +287,19 @@ const shortRev = async (top, commit) => {
|
|
|
259
287
|
return result.status === 0 ? result.stdout.trim() : commit.slice(0, 7);
|
|
260
288
|
};
|
|
261
289
|
|
|
262
|
-
const
|
|
290
|
+
const currentBranch = async (top) => {
|
|
291
|
+
const root = worktreeRoot(top);
|
|
292
|
+
if (root) return branchOf(root);
|
|
293
|
+
const args = ['rev-parse', '--abbrev-ref', 'HEAD'];
|
|
294
|
+
const result = await runGit(args, top);
|
|
263
295
|
if (result.status !== 0) return '';
|
|
264
296
|
const name = result.stdout.trim();
|
|
265
297
|
return name === 'HEAD' ? '' : name;
|
|
266
298
|
};
|
|
267
299
|
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const currentBranch = async (top) => branchName(await runGit(BRANCH_ARGS, top));
|
|
272
|
-
|
|
273
|
-
const repositoryUrl = (result) => {
|
|
274
|
-
if (result.status !== 0) return '';
|
|
275
|
-
let value = result.stdout.trim();
|
|
300
|
+
const repositoryUrl = (raw) => {
|
|
301
|
+
let value = `${raw ?? ''}`.trim();
|
|
302
|
+
if (!value) return '';
|
|
276
303
|
const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.exec(value);
|
|
277
304
|
if (scp && !value.includes('://')) value = `https://${scp[1]}/${scp[2]}`;
|
|
278
305
|
else if (value.startsWith('git+')) value = value.slice(4);
|
|
@@ -303,12 +330,9 @@ const worktreeSnapshot = (top, outputs, untracked, branch, repoUrl) => {
|
|
|
303
330
|
|
|
304
331
|
const snapshotCommit = async (top, commit, files) => {
|
|
305
332
|
const parsed = dropReviewItems(itemsFromFiles(files, 'commit'));
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
runGit(REMOTE_ARGS, top).then(repositoryUrl),
|
|
310
|
-
];
|
|
311
|
-
const [revShort, branch, repoUrl] = await Promise.all(names);
|
|
333
|
+
const revShort = await shortRev(top, commit);
|
|
334
|
+
const branch = await currentBranch(top);
|
|
335
|
+
const repoUrl = repositoryUrl(remoteOrigin(top));
|
|
312
336
|
return { top, parsed, rev: commit, revShort, branch, repositoryUrl: repoUrl };
|
|
313
337
|
};
|
|
314
338
|
|
|
@@ -324,6 +348,38 @@ const worktreeCommands = (paths) => [
|
|
|
324
348
|
untrackedArgs(paths),
|
|
325
349
|
];
|
|
326
350
|
|
|
351
|
+
const gitView = async (top, paths, signal) => {
|
|
352
|
+
const commands = worktreeCommands(paths);
|
|
353
|
+
const running = commands.map((args) => runGit(args, top, { signal }));
|
|
354
|
+
const results = await Promise.all(running);
|
|
355
|
+
const outputs = results.map((result) => requireOk(result).stdout);
|
|
356
|
+
return {
|
|
357
|
+
staged: outputs[0],
|
|
358
|
+
work: outputs[1],
|
|
359
|
+
untracked: namesFromLs(outputs[2]),
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const worktreeView = async (top, paths, signal) => {
|
|
364
|
+
// status -vv prints every diff and ignores the pathspec
|
|
365
|
+
if (paths.length) return gitView(top, paths, signal);
|
|
366
|
+
const extra = { signal, env: STATUS_ENV };
|
|
367
|
+
const result = await runGit(STATUS_ARGS, top, extra);
|
|
368
|
+
if (result.status !== 0) return gitView(top, paths, signal);
|
|
369
|
+
const parsed = parseStatus(result.stdout);
|
|
370
|
+
if (!parsed) return gitView(top, paths, signal);
|
|
371
|
+
return parsed;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const countUntrackedAdded = async (top) => {
|
|
375
|
+
try {
|
|
376
|
+
const view = await worktreeView(top, []);
|
|
377
|
+
return addedOf(view.untracked, top);
|
|
378
|
+
} catch {
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
327
383
|
const readSnapshot = async (cwd, paths, options) => {
|
|
328
384
|
const { commit: rev, signal } = options;
|
|
329
385
|
const top = await toplevel(cwd, signal);
|
|
@@ -331,14 +387,11 @@ const readSnapshot = async (cwd, paths, options) => {
|
|
|
331
387
|
const files = await runSteps(commitDiff(rev, paths), top, signal);
|
|
332
388
|
return snapshotCommit(top, rev, files);
|
|
333
389
|
}
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
const repoUrl = repositoryUrl(
|
|
338
|
-
const
|
|
339
|
-
const outputs = results.map((result) => requireOk(result).stdout);
|
|
340
|
-
const names = namesFromLs(outputs[2]);
|
|
341
|
-
const untracked = await untrackedItems(top, names);
|
|
390
|
+
const view = await worktreeView(top, paths, signal);
|
|
391
|
+
const untracked = await untrackedItems(top, view.untracked);
|
|
392
|
+
const branch = await currentBranch(top);
|
|
393
|
+
const repoUrl = repositoryUrl(remoteOrigin(top));
|
|
394
|
+
const outputs = [view.staged, view.work];
|
|
342
395
|
return worktreeSnapshot(top, outputs, untracked, branch, repoUrl);
|
|
343
396
|
};
|
|
344
397
|
|
|
@@ -475,6 +528,29 @@ const stagePath = async (top, rel) => {
|
|
|
475
528
|
if (rel) await gitOk(['add', '--', rel], top);
|
|
476
529
|
};
|
|
477
530
|
|
|
531
|
+
const sumDelta = (items) => {
|
|
532
|
+
let added = 0;
|
|
533
|
+
let removed = 0;
|
|
534
|
+
for (const item of items) {
|
|
535
|
+
const delta = lineDelta(item);
|
|
536
|
+
added += delta.added;
|
|
537
|
+
removed += delta.removed;
|
|
538
|
+
}
|
|
539
|
+
return { added, removed };
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const diffCounts = async (top) => {
|
|
543
|
+
const view = await worktreeView(top, []);
|
|
544
|
+
const staged = sumDelta(itemsFromFiles(parseDiff(view.staged), 'staged'));
|
|
545
|
+
const open = sumDelta(itemsFromFiles(parseDiff(view.work), 'unstaged'));
|
|
546
|
+
return {
|
|
547
|
+
stagedAdded: staged.added,
|
|
548
|
+
unstagedAdded: open.added + addedOf(view.untracked, top),
|
|
549
|
+
stagedRemoved: staged.removed,
|
|
550
|
+
unstagedRemoved: open.removed,
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
|
|
478
554
|
module.exports = {
|
|
479
555
|
requireOk,
|
|
480
556
|
gitOk,
|
|
@@ -492,6 +568,7 @@ module.exports = {
|
|
|
492
568
|
currentBranch,
|
|
493
569
|
fileText,
|
|
494
570
|
countUntrackedAdded,
|
|
571
|
+
diffCounts,
|
|
495
572
|
listScopeFiles,
|
|
496
573
|
readSnapshot,
|
|
497
574
|
editItem,
|