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,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
+ };
@@ -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 };
@@ -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: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...extra.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 countUntrackedAdded = async (top) => {
187
- try {
188
- const result = await runGit(untrackedArgs([]), top);
189
- if (result.status !== 0) return 0;
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 toplevel = async (cwd, signal) => {
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 branchName = (result) => {
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 BRANCH_ARGS = ['rev-parse', '--abbrev-ref', 'HEAD'];
269
- const REMOTE_ARGS = ['config', '--get', 'remote.origin.url'];
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 names = [
307
- shortRev(top, commit),
308
- currentBranch(top),
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 commands = [...worktreeCommands(paths), BRANCH_ARGS, REMOTE_ARGS];
335
- const running = commands.map((args) => runGit(args, top, { signal }));
336
- const results = await Promise.all(running);
337
- const repoUrl = repositoryUrl(results.pop());
338
- const branch = branchName(results.pop());
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,