@hncode/hncode 0.3.2 → 0.3.4
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/README.md +43 -0
- package/package.json +1 -1
- package/src/agent-task.js +78 -0
- package/src/agent.js +150 -45
- package/src/cache.js +130 -0
- package/src/ci.js +244 -0
- package/src/colors.js +18 -0
- package/src/config.js +13 -0
- package/src/git.js +501 -0
- package/src/hooks.js +247 -0
- package/src/index.js +131 -17
- package/src/llm.js +14 -2
- package/src/mcp.js +384 -0
- package/src/plan.js +127 -0
- package/src/skills.js +150 -0
- package/src/subagent-types.js +1 -1
- package/src/tools/edit.js +17 -5
- package/src/tools/git.js +155 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +1256 -121
- package/src/update.js +35 -14
package/src/git.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// Git workflow helpers — structured access to the operations a coding agent
|
|
2
|
+
// performs constantly, so the model (and the /git, /commit, /branch commands)
|
|
3
|
+
// do not have to hand-write shell pipelines for them.
|
|
4
|
+
//
|
|
5
|
+
// Everything here shells out to the user's own `git`. Nothing is reimplemented:
|
|
6
|
+
// the point is a typed, testable wrapper that reports clean errors and never
|
|
7
|
+
// runs a destructive command implicitly. `isRepo()` gates every other call so a
|
|
8
|
+
// non-repo directory yields a clear message instead of a raw git error.
|
|
9
|
+
//
|
|
10
|
+
// Destructive operations (reset --hard, checkout --, branch -D) are NOT exposed
|
|
11
|
+
// here. The agent can still run them through Bash if the user asks; a helper
|
|
12
|
+
// module should not make them easy to reach by accident.
|
|
13
|
+
|
|
14
|
+
import cp from 'node:child_process';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
|
|
18
|
+
// Force UTF-8 for git's OWN output regardless of the console codepage, and stop
|
|
19
|
+
// it octal-escaping non-ASCII filenames. Without these, on a Windows console
|
|
20
|
+
// using a non-UTF-8 codepage (GBK/CP936, etc.) git prints paths and messages in
|
|
21
|
+
// that codepage and Node's utf8 decode mangles them — and even in UTF-8 locales
|
|
22
|
+
// `git status` quotes any non-ASCII path as `"\350\257\264\346\230\216.md"`,
|
|
23
|
+
// which the model then cannot match to a real file.
|
|
24
|
+
// i18n.logOutputEncoding=utf-8 : commit messages on stdout
|
|
25
|
+
// core.quotepath=false : raw (unquoted) non-ASCII paths
|
|
26
|
+
// i18n.commitEncoding=utf-8 : how we write messages back
|
|
27
|
+
// Passed with -c so nothing is written to the user's git config.
|
|
28
|
+
const GIT_ENV = [
|
|
29
|
+
'-c', 'core.quotepath=false',
|
|
30
|
+
'-c', 'i18n.logOutputEncoding=utf-8',
|
|
31
|
+
'-c', 'i18n.commitEncoding=utf-8',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// Run git with args in `cwd`. Returns { ok, stdout, stderr, code, error }.
|
|
35
|
+
// Never throws: a missing git binary or a non-repo is data, not an exception.
|
|
36
|
+
export function runGit(args, cwd, opts = {}) {
|
|
37
|
+
const timeout = opts.timeoutMs || 30_000;
|
|
38
|
+
try {
|
|
39
|
+
const r = cp.spawnSync('git', [...GIT_ENV, ...args], {
|
|
40
|
+
cwd: cwd || process.cwd(),
|
|
41
|
+
// Decode git's bytes as UTF-8 explicitly. `encoding: 'utf8'` is what we
|
|
42
|
+
// want, but the environment is also pinned below so a console codepage
|
|
43
|
+
// cannot override the byte stream before it reaches us.
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
46
|
+
timeout,
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
env: { ...process.env, LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' },
|
|
49
|
+
});
|
|
50
|
+
if (r.error) return { ok: false, stdout: '', stderr: '', code: null, error: r.error.message };
|
|
51
|
+
return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '', code: r.status, error: null };
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return { ok: false, stdout: '', stderr: '', code: null, error: e.message };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isRepo(cwd) {
|
|
58
|
+
const r = runGit(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
59
|
+
return r.ok && r.stdout.trim() === 'true';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// "{branch}" or a detached-HEAD short sha. Empty when not a repo.
|
|
63
|
+
export function currentBranch(cwd) {
|
|
64
|
+
const r = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
65
|
+
if (!r.ok) return '';
|
|
66
|
+
const b = r.stdout.trim();
|
|
67
|
+
if (b && b !== 'HEAD') return b;
|
|
68
|
+
// Detached: report the short sha so the status line is still meaningful.
|
|
69
|
+
const s = runGit(['rev-parse', '--short', 'HEAD'], cwd);
|
|
70
|
+
return s.ok ? `(detached ${s.stdout.trim()})` : '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function headSha(cwd, short = true) {
|
|
74
|
+
const r = runGit(short ? ['rev-parse', '--short', 'HEAD'] : ['rev-parse', 'HEAD'], cwd);
|
|
75
|
+
return r.ok ? r.stdout.trim() : '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Parsed `git status --porcelain=v1`. Each entry:
|
|
79
|
+
// { x, y, path, origPath } — x = index status, y = worktree status
|
|
80
|
+
// `??` is untracked, `!!` is ignored (not reported by default here).
|
|
81
|
+
export function status(cwd) {
|
|
82
|
+
const r = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all'], cwd);
|
|
83
|
+
if (!r.ok) return { ok: false, error: r.error || r.stderr.trim() || 'git status failed', entries: [] };
|
|
84
|
+
const parts = r.stdout.split('\0');
|
|
85
|
+
const entries = [];
|
|
86
|
+
for (let i = 0; i < parts.length; i++) {
|
|
87
|
+
const e = parts[i];
|
|
88
|
+
if (!e) continue;
|
|
89
|
+
const x = e[0], y = e[1];
|
|
90
|
+
const p = e.slice(3);
|
|
91
|
+
// A rename/copy is followed by the ORIGINAL path in the next NUL field.
|
|
92
|
+
if (x === 'R' || x === 'C') {
|
|
93
|
+
const orig = parts[++i] || '';
|
|
94
|
+
entries.push({ x, y, path: p, origPath: orig });
|
|
95
|
+
} else {
|
|
96
|
+
entries.push({ x, y, path: p });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, entries, error: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A compact human summary: "M file.js", "?? new.txt", …
|
|
103
|
+
export function statusLines(cwd) {
|
|
104
|
+
const s = status(cwd);
|
|
105
|
+
if (!s.ok) return [`git status failed: ${s.error}`];
|
|
106
|
+
if (!s.entries.length) return ['Working tree clean.'];
|
|
107
|
+
return s.entries.map((e) => `${(e.x + e.y).trim() || '??'} ${e.path}${e.origPath ? ` (from ${e.origPath})` : ''}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Stage paths (default: everything, `git add -A`). Returns { ok, error }.
|
|
111
|
+
export function stage(paths, cwd) {
|
|
112
|
+
// `-A` is a FLAG, not a pathspec: it must not follow `--`. With explicit
|
|
113
|
+
// paths, `--` separates them from a path that looks like a flag.
|
|
114
|
+
const real = (paths && paths.length) ? ['add', '--', ...paths] : ['add', '-A'];
|
|
115
|
+
const r = runGit(real, cwd);
|
|
116
|
+
return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error || 'git add failed') };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function unstage(paths, cwd) {
|
|
120
|
+
const real = (paths && paths.length) ? ['reset', 'HEAD', '--', ...paths] : ['reset', 'HEAD'];
|
|
121
|
+
const r = runGit(real, cwd);
|
|
122
|
+
return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error || 'git reset failed') };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Files changed in the index (staged), for a commit preview.
|
|
126
|
+
export function stagedFiles(cwd) {
|
|
127
|
+
const r = runGit(['diff', '--cached', '--name-only'], cwd);
|
|
128
|
+
return r.ok ? r.stdout.split('\n').filter(Boolean) : [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Is the index empty (nothing staged)?
|
|
132
|
+
export function hasStagedChanges(cwd) {
|
|
133
|
+
const r = runGit(['diff', '--cached', '--quiet'], cwd);
|
|
134
|
+
// exit 1 means there ARE differences.
|
|
135
|
+
return r.code === 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Commit the staged index. `message` may be multi-line; it is passed with -m
|
|
139
|
+
// repeated so newlines survive every platform's arg handling.
|
|
140
|
+
export function commit(message, cwd, opts = {}) {
|
|
141
|
+
const msg = String(message == null ? '' : message).trim();
|
|
142
|
+
if (!msg) return { ok: false, error: 'commit message must not be empty' };
|
|
143
|
+
const args = ['commit'];
|
|
144
|
+
if (opts.amend) args.push('--amend');
|
|
145
|
+
if (opts.noVerify) args.push('--no-verify');
|
|
146
|
+
for (const line of msg.split('\n')) args.push('-m', line);
|
|
147
|
+
const r = runGit(args, cwd, { timeoutMs: 120_000 });
|
|
148
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git commit failed', stdout: r.stdout };
|
|
149
|
+
const sha = headSha(cwd);
|
|
150
|
+
return { ok: true, sha, stdout: r.stdout.trim() };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function listBranches(cwd) {
|
|
154
|
+
const r = runGit(['branch', '--format=%(refname:short)'], cwd);
|
|
155
|
+
return r.ok ? r.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Create and switch to a branch. `from` optionally bases it on another ref.
|
|
159
|
+
export function createBranch(name, cwd, from) {
|
|
160
|
+
const n = String(name || '').trim();
|
|
161
|
+
if (!n) return { ok: false, error: 'branch name must not be empty' };
|
|
162
|
+
const args = ['switch', '-c', n];
|
|
163
|
+
if (from) args.push(from);
|
|
164
|
+
let r = runGit(args, cwd);
|
|
165
|
+
// Older git (< 2.23) has no `switch`.
|
|
166
|
+
if (!r.ok && /switch/.test(r.stderr) && /unknown|not a git command/i.test(r.stderr)) {
|
|
167
|
+
r = runGit(from ? ['checkout', '-b', n, from] : ['checkout', '-b', n], cwd);
|
|
168
|
+
}
|
|
169
|
+
return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error || 'git switch failed') };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function switchBranch(name, cwd) {
|
|
173
|
+
const n = String(name || '').trim();
|
|
174
|
+
if (!n) return { ok: false, error: 'branch name must not be empty' };
|
|
175
|
+
let r = runGit(['switch', n], cwd);
|
|
176
|
+
if (!r.ok && /unknown|not a git command/i.test(r.stderr)) r = runGit(['checkout', n], cwd);
|
|
177
|
+
return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error || 'git switch failed') };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function remoteUrl(cwd) {
|
|
181
|
+
const r = runGit(['remote', 'get-url', 'origin'], cwd);
|
|
182
|
+
return r.ok ? r.stdout.trim() : '';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Parse a git remote URL into { host, owner, repo }. Handles https and ssh
|
|
186
|
+
// (scp-like) forms. Returns null when it does not look like one.
|
|
187
|
+
export function parseRemote(url) {
|
|
188
|
+
const s = String(url || '').trim();
|
|
189
|
+
if (!s) return null;
|
|
190
|
+
let m = /^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(s);
|
|
191
|
+
if (m) {
|
|
192
|
+
const parts = m[2].split('/').filter(Boolean);
|
|
193
|
+
if (parts.length >= 2) return { host: m[1], owner: parts[0], repo: parts[parts.length - 1] };
|
|
194
|
+
}
|
|
195
|
+
m = /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?\/?$/.exec(s);
|
|
196
|
+
if (m) {
|
|
197
|
+
const parts = m[2].split('/').filter(Boolean);
|
|
198
|
+
if (parts.length >= 2) return { host: m[1], owner: parts[0], repo: parts[parts.length - 1] };
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A "create a PR" URL for the current branch, or '' when the remote is unknown.
|
|
204
|
+
// This does NOT open a browser or call an API: it hands the user a link, which
|
|
205
|
+
// is the portable behaviour across hosts (GitHub/GitLab/Bitbucket all accept a
|
|
206
|
+
// compare URL for the common hosts; unknown hosts get the repo page).
|
|
207
|
+
export function prUrl(cwd, base = 'main') {
|
|
208
|
+
const info = parseRemote(remoteUrl(cwd));
|
|
209
|
+
if (!info) return '';
|
|
210
|
+
const branch = currentBranch(cwd);
|
|
211
|
+
if (!branch || branch.startsWith('(')) return '';
|
|
212
|
+
const { host, owner, repo } = info;
|
|
213
|
+
if (host.includes('github.com')) {
|
|
214
|
+
return `https://${host}/${owner}/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`;
|
|
215
|
+
}
|
|
216
|
+
if (host.includes('gitlab')) {
|
|
217
|
+
return `https://${host}/${owner}/${repo}/-/merge_requests/new?merge_request[source_branch]=${encodeURIComponent(branch)}`;
|
|
218
|
+
}
|
|
219
|
+
if (host.includes('bitbucket')) {
|
|
220
|
+
return `https://${host}/${owner}/${repo}/pull-requests/new?source=${encodeURIComponent(branch)}`;
|
|
221
|
+
}
|
|
222
|
+
return `https://${host}/${owner}/${repo}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- worktree support (parallel sessions that cannot collide) ----------------
|
|
226
|
+
|
|
227
|
+
export function listWorktrees(cwd) {
|
|
228
|
+
const r = runGit(['worktree', 'list', '--porcelain'], cwd);
|
|
229
|
+
if (!r.ok) return [];
|
|
230
|
+
const out = [];
|
|
231
|
+
let cur = null;
|
|
232
|
+
for (const line of r.stdout.split('\n')) {
|
|
233
|
+
if (line.startsWith('worktree ')) { if (cur) out.push(cur); cur = { path: line.slice(9).trim() }; }
|
|
234
|
+
else if (!cur) continue;
|
|
235
|
+
else if (line.startsWith('branch ')) cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '');
|
|
236
|
+
else if (line.startsWith('HEAD ')) cur.head = line.slice(5).trim();
|
|
237
|
+
else if (line === 'bare') cur.bare = true;
|
|
238
|
+
else if (line === 'detached') cur.detached = true;
|
|
239
|
+
}
|
|
240
|
+
if (cur) out.push(cur);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Normalize a path for comparison. Two problems make a raw string compare fail:
|
|
245
|
+
// * git reports worktree paths with FORWARD slashes even on Windows, while
|
|
246
|
+
// path.resolve() yields backslashes;
|
|
247
|
+
// * on Windows a path can be spelled with an 8.3 short name (`ADMINI~1`) or
|
|
248
|
+
// the long name (`Administrator`) — they are the same directory.
|
|
249
|
+
// realpathSync resolves both; it also lower-cases via lowercasing after resolve.
|
|
250
|
+
function samePath(a, b) {
|
|
251
|
+
const norm = (p) => {
|
|
252
|
+
let r;
|
|
253
|
+
const abs = path.resolve(String(p || ''));
|
|
254
|
+
try { r = fs.realpathSync.native ? fs.realpathSync.native(abs) : fs.realpathSync(abs); }
|
|
255
|
+
catch { r = abs; } // may not exist yet: fall back
|
|
256
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
257
|
+
};
|
|
258
|
+
return norm(a) === norm(b);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Create (or reuse) a worktree for `branch` at `dir`.
|
|
262
|
+
export function addWorktree(dir, branch, cwd, opts = {}) {
|
|
263
|
+
const target = path.resolve(dir);
|
|
264
|
+
if (fs.existsSync(target)) {
|
|
265
|
+
const existing = listWorktrees(cwd).find((w) => samePath(w.path, target));
|
|
266
|
+
if (existing) return { ok: true, path: target, reused: true, branch: existing.branch };
|
|
267
|
+
return { ok: false, error: `path already exists and is not a worktree: ${target}` };
|
|
268
|
+
}
|
|
269
|
+
const branches = listBranches(cwd);
|
|
270
|
+
const args = ['worktree', 'add'];
|
|
271
|
+
if (opts.detach) args.push('--detach');
|
|
272
|
+
args.push(target);
|
|
273
|
+
if (branch) args.push(branches.includes(branch) ? branch : '-b', ...(branches.includes(branch) ? [] : [branch]));
|
|
274
|
+
const r = runGit(args, cwd);
|
|
275
|
+
return r.ok ? { ok: true, path: target, reused: false, branch } : { ok: false, error: r.stderr.trim() || r.error };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function removeWorktree(dir, cwd, opts = {}) {
|
|
279
|
+
const args = ['worktree', 'remove', path.resolve(dir)];
|
|
280
|
+
if (opts.force) args.push('--force');
|
|
281
|
+
const r = runGit(args, cwd);
|
|
282
|
+
return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error) };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A short context block for the system prompt / status line: branch, dirty
|
|
286
|
+
// count, and the remote. Empty when not a repo.
|
|
287
|
+
export function summary(cwd) {
|
|
288
|
+
if (!isRepo(cwd)) return '';
|
|
289
|
+
const branch = currentBranch(cwd);
|
|
290
|
+
const s = status(cwd);
|
|
291
|
+
const dirty = s.ok ? s.entries.length : 0;
|
|
292
|
+
const info = parseRemote(remoteUrl(cwd));
|
|
293
|
+
const remote = info ? `${info.owner}/${info.repo}` : '';
|
|
294
|
+
return [
|
|
295
|
+
`branch: ${branch || '(unknown)'}`,
|
|
296
|
+
`changes: ${dirty} file(s)${dirty ? ' — uncommitted' : ' — clean'}`,
|
|
297
|
+
remote ? `remote: ${remote}` : '',
|
|
298
|
+
].filter(Boolean).join('\n');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Compact statusline info: the branch plus the working-tree diff totals.
|
|
302
|
+
// Returns null when not a repo (the caller then renders nothing).
|
|
303
|
+
//
|
|
304
|
+
// The counts come from `git diff --shortstat` run twice (unstaged + staged) so
|
|
305
|
+
// the numbers describe the UNCOMMITTED work — which is what a "+12 -3" badge on
|
|
306
|
+
// the status line is for.
|
|
307
|
+
export function statuslineInfo(cwd) {
|
|
308
|
+
if (!isRepo(cwd)) return null;
|
|
309
|
+
const branch = currentBranch(cwd);
|
|
310
|
+
const stat = (args) => {
|
|
311
|
+
const r = runGit(['diff', ...args, '--shortstat'], cwd);
|
|
312
|
+
if (!r.ok) return { files: 0, insertions: 0, deletions: 0 };
|
|
313
|
+
// e.g. " 3 files changed, 12 insertions(+), 4 deletions(-)"
|
|
314
|
+
const s = r.stdout.trim();
|
|
315
|
+
const files = /(\d+) files? changed/.exec(s);
|
|
316
|
+
const ins = /(\d+) insertions?\(\+\)/.exec(s);
|
|
317
|
+
const del = /(\d+) deletions?\(-\)/.exec(s);
|
|
318
|
+
return {
|
|
319
|
+
files: files ? Number(files[1]) : 0,
|
|
320
|
+
insertions: ins ? Number(ins[1]) : 0,
|
|
321
|
+
deletions: del ? Number(del[1]) : 0,
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
const unstaged = stat([]);
|
|
325
|
+
const staged = stat(['--cached']);
|
|
326
|
+
return {
|
|
327
|
+
branch: branch || '',
|
|
328
|
+
insertions: unstaged.insertions + staged.insertions,
|
|
329
|
+
deletions: unstaged.deletions + staged.deletions,
|
|
330
|
+
changed: unstaged.files + staged.files,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// ---- diff / log / push / stash / rebase -------------------------------------
|
|
334
|
+
// Thin wrappers over `git` for the TUI's slash commands. Each returns
|
|
335
|
+
// { ok, ... } and never throws, so a command handler can just print the result.
|
|
336
|
+
|
|
337
|
+
// Working-tree diff. `staged` shows the index, `path` limits to one file.
|
|
338
|
+
// `stat` returns only the summary line (cheap); otherwise the patch, bounded so a
|
|
339
|
+
// huge change cannot flood the transcript.
|
|
340
|
+
export function diffText(cwd, opts = {}) {
|
|
341
|
+
const args = ['diff'];
|
|
342
|
+
if (opts.staged) args.push('--cached');
|
|
343
|
+
const paths = opts.path ? [opts.path] : [];
|
|
344
|
+
if (opts.stat) args.push('--stat');
|
|
345
|
+
const r = runGit(paths.length ? args.concat(['--', ...paths]) : args, cwd);
|
|
346
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git diff failed', text: '' };
|
|
347
|
+
let text = r.stdout;
|
|
348
|
+
if (text.length > 200 * 1024) text = text.slice(0, 200 * 1024) + '\n... [diff truncated]';
|
|
349
|
+
return { ok: true, text, empty: !text.trim(), error: null };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Commit history, one commit per line: `<short-sha> <date> <subject>`.
|
|
353
|
+
// `count` defaults to 15; `path` limits to a file's history.
|
|
354
|
+
export function logText(cwd, opts = {}) {
|
|
355
|
+
const n = Math.max(1, Math.min(500, Number(opts.count) || 15));
|
|
356
|
+
const args = ['log', `-${n}`, '--pretty=format:%h %ad %s', '--date=short'];
|
|
357
|
+
if (opts.path) args.push('--', opts.path);
|
|
358
|
+
const r = runGit(args, cwd);
|
|
359
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git log failed', text: '' };
|
|
360
|
+
return { ok: true, text: r.stdout.trim(), empty: !r.stdout.trim(), error: null };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Push the current (or a named) branch. This is an OUTBOUND operation, so the
|
|
364
|
+
// caller must have confirmed first. `setUpstream` adds -u when the branch has no
|
|
365
|
+
// upstream yet. `force` is opt-in only.
|
|
366
|
+
export function push(cwd, opts = {}) {
|
|
367
|
+
const branch = String(opts.branch || '').trim();
|
|
368
|
+
const remote = String(opts.remote || 'origin').trim();
|
|
369
|
+
const args = ['push'];
|
|
370
|
+
if (opts.setUpstream) args.push('-u');
|
|
371
|
+
if (opts.force) args.push('--force-with-lease'); // safer than --force
|
|
372
|
+
args.push(remote);
|
|
373
|
+
if (branch) args.push(branch);
|
|
374
|
+
const r = runGit(args, cwd, { timeoutMs: 120_000 });
|
|
375
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git push failed', output: r.stdout.trim() };
|
|
376
|
+
return { ok: true, output: (r.stdout + r.stderr).trim(), error: null };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Does the branch have an upstream? Used to decide whether push needs -u.
|
|
380
|
+
export function hasUpstream(cwd, branch) {
|
|
381
|
+
const b = branch || currentBranch(cwd);
|
|
382
|
+
if (!b) return false;
|
|
383
|
+
const r = runGit(['rev-parse', '--abbrev-ref', `${b}@{upstream}`], cwd);
|
|
384
|
+
return r.ok;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Commits the local branch is ahead/behind its upstream, e.g. "ahead 2, behind 0".
|
|
388
|
+
export function aheadBehind(cwd, branch) {
|
|
389
|
+
const b = branch || currentBranch(cwd);
|
|
390
|
+
if (!b) return '';
|
|
391
|
+
const r = runGit(['rev-list', '--left-right', '--count', `${b}...${b}@{upstream}`], cwd);
|
|
392
|
+
if (!r.ok) return '';
|
|
393
|
+
const [ahead, behind] = r.stdout.trim().split(/\s+/);
|
|
394
|
+
const parts = [];
|
|
395
|
+
if (Number(ahead)) parts.push(`ahead ${ahead}`);
|
|
396
|
+
if (Number(behind)) parts.push(`behind ${behind}`);
|
|
397
|
+
return parts.join(', ');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---- stash ------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
export function stashList(cwd) {
|
|
403
|
+
const r = runGit(['stash', 'list'], cwd);
|
|
404
|
+
return r.ok ? r.stdout.split('\n').filter(Boolean) : [];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function stashPush(cwd, message) {
|
|
408
|
+
const args = ['stash', 'push'];
|
|
409
|
+
if (message) args.push('-m', String(message));
|
|
410
|
+
const r = runGit(args, cwd);
|
|
411
|
+
return r.ok ? { ok: true, output: r.stdout.trim(), error: null }
|
|
412
|
+
: { ok: false, error: r.stderr.trim() || r.error || 'git stash failed' };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function stashPop(cwd) {
|
|
416
|
+
const r = runGit(['stash', 'pop'], cwd);
|
|
417
|
+
return r.ok ? { ok: true, output: r.stdout.trim(), error: null }
|
|
418
|
+
: { ok: false, error: r.stderr.trim() || r.error || 'git stash pop failed' };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- rebase -----------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
// Rebase the current branch onto `onto`. Returns the git output; on conflict the
|
|
424
|
+
// caller shows git's own message (the user resolves it outside, or with a prompt).
|
|
425
|
+
export function rebase(cwd, onto) {
|
|
426
|
+
const target = String(onto || '').trim();
|
|
427
|
+
if (!target) return { ok: false, error: 'a target branch is required' };
|
|
428
|
+
const r = runGit(['rebase', target], cwd, { timeoutMs: 120_000 });
|
|
429
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'git rebase failed' };
|
|
430
|
+
return { ok: true, output: r.stdout.trim(), error: null };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- async variants (never block the event loop) ----------------------------
|
|
434
|
+
// `runGit` uses spawnSync: on the STARTUP path that freezes the TUI before its
|
|
435
|
+
// first paint, and a cold `git` was measured at ~80-100ms per call (×4 calls).
|
|
436
|
+
// The async form below runs the same command off the main thread, so the frame
|
|
437
|
+
// draws immediately and the badge fills in when the data arrives.
|
|
438
|
+
|
|
439
|
+
export function runGitAsync(args, cwd, opts = {}) {
|
|
440
|
+
return new Promise((resolve) => {
|
|
441
|
+
let child;
|
|
442
|
+
const timeout = opts.timeoutMs || 30_000;
|
|
443
|
+
try {
|
|
444
|
+
child = cp.spawn('git', [...GIT_ENV, ...args], {
|
|
445
|
+
cwd: cwd || process.cwd(),
|
|
446
|
+
windowsHide: true,
|
|
447
|
+
env: { ...process.env, LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' },
|
|
448
|
+
});
|
|
449
|
+
} catch (e) {
|
|
450
|
+
resolve({ ok: false, stdout: '', stderr: '', code: null, error: e.message });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
let stdout = '';
|
|
454
|
+
let stderr = '';
|
|
455
|
+
let done = false;
|
|
456
|
+
const finish = (r) => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
|
|
457
|
+
const timer = setTimeout(() => {
|
|
458
|
+
try { child.kill(); } catch {}
|
|
459
|
+
finish({ ok: false, stdout: '', stderr: '', code: null, error: 'git timed out' });
|
|
460
|
+
}, timeout);
|
|
461
|
+
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
|
|
462
|
+
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
|
|
463
|
+
child.on('error', (e) => finish({ ok: false, stdout: '', stderr: '', code: null, error: e.message }));
|
|
464
|
+
child.on('close', (code) => finish({ ok: code === 0, stdout, stderr, code, error: null }));
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Async twin of statuslineInfo(). Same shape, same parser — only the four git
|
|
469
|
+
// invocations are concurrent instead of blocking. Each sub-call fails soft, so a
|
|
470
|
+
// non-repo (or a missing git) yields null exactly like the sync version.
|
|
471
|
+
export async function statuslineInfoAsync(cwd) {
|
|
472
|
+
const inside = await runGitAsync(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
473
|
+
if (!inside.ok) return null;
|
|
474
|
+
|
|
475
|
+
const stat = async (args) => {
|
|
476
|
+
const r = await runGitAsync(['diff', ...args, '--shortstat'], cwd);
|
|
477
|
+
if (!r.ok) return { files: 0, insertions: 0, deletions: 0 };
|
|
478
|
+
const s = r.stdout.trim();
|
|
479
|
+
const files = /(\d+) files? changed/.exec(s);
|
|
480
|
+
const ins = /(\d+) insertions?\(\+\)/.exec(s);
|
|
481
|
+
const del = /(\d+) deletions?\(-\)/.exec(s);
|
|
482
|
+
return {
|
|
483
|
+
files: files ? Number(files[1]) : 0,
|
|
484
|
+
insertions: ins ? Number(ins[1]) : 0,
|
|
485
|
+
deletions: del ? Number(del[1]) : 0,
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const [branchRes, unstaged, staged] = await Promise.all([
|
|
490
|
+
runGitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
|
|
491
|
+
stat([]),
|
|
492
|
+
stat(['--cached']),
|
|
493
|
+
]);
|
|
494
|
+
const branch = branchRes.ok ? branchRes.stdout.trim() : '';
|
|
495
|
+
return {
|
|
496
|
+
branch: branch || '',
|
|
497
|
+
insertions: unstaged.insertions + staged.insertions,
|
|
498
|
+
deletions: unstaged.deletions + staged.deletions,
|
|
499
|
+
changed: unstaged.files + staged.files,
|
|
500
|
+
};
|
|
501
|
+
}
|