@saadnvd1/agent-os 0.3.464 → 0.3.466
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 +20 -0
- package/app/api/fresh-start/route.ts +5 -3
- package/app/api/git/commit/route.ts +4 -3
- package/app/api/git/file-content/route.ts +4 -15
- package/app/api/git/history/[hash]/diff/route.ts +1 -1
- package/app/api/git/history/[hash]/route.ts +1 -1
- package/app/api/git/history/route.ts +1 -1
- package/app/api/git/push/route.ts +2 -2
- package/app/api/sessions/[id]/pr/route.ts +2 -2
- package/components/Chat/ChatPanel.tsx +23 -1
- package/components/Chat/Messages.tsx +2 -3
- package/components/Chat/StaleChip.tsx +55 -0
- package/components/Workspaces/FreshStartForm.tsx +29 -6
- package/data/chat/useChat.ts +10 -2
- package/lib/away/deploy.ts +3 -7
- package/lib/away/run.ts +6 -5
- package/lib/build.ts +2 -6
- package/lib/bus/index.ts +3 -0
- package/lib/chat/client-send.ts +11 -3
- package/lib/chat/drivers/claude-mapper.ts +11 -1
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/events.ts +14 -1
- package/lib/chat/files.ts +31 -9
- package/lib/chat/refresh.ts +14 -2
- package/lib/chat/runner.ts +8 -1
- package/lib/chat/stale.ts +95 -0
- package/lib/chat/worker/host.ts +133 -4
- package/lib/chat/worker/protocol.ts +3 -0
- package/lib/done/remote.ts +5 -6
- package/lib/done/testing.ts +2 -7
- package/lib/done/worktree.ts +2 -2
- package/lib/git-exec.ts +334 -0
- package/lib/git-history.ts +29 -41
- package/lib/git-status.ts +45 -124
- package/lib/git.ts +44 -159
- package/lib/lumifyhub/publish.ts +2 -2
- package/lib/orchestrator/deliver.ts +9 -1
- package/lib/orchestrator/diff.ts +15 -13
- package/lib/orchestrator/rebase.ts +20 -24
- package/lib/orchestrator/repo-slug.ts +4 -7
- package/lib/orchestrator/repo.ts +24 -35
- package/lib/pr-generation.ts +2 -1
- package/lib/pr.ts +16 -57
- package/lib/schedules/start.ts +1 -0
- package/lib/sessions/worktree-setup.ts +17 -13
- package/lib/stacks/start.ts +7 -11
- package/lib/tasks/branch-worktree.ts +3 -2
- package/lib/tasks/finish.ts +5 -6
- package/lib/tasks/gh.ts +5 -0
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/tasks/move.ts +3 -2
- package/lib/tasks/project-ref.ts +12 -19
- package/lib/tasks/session.ts +5 -5
- package/lib/worktree-hooks.ts +3 -7
- package/lib/worktree-placed.ts +3 -10
- package/lib/worktrees.ts +4 -42
- package/package.json +1 -1
package/lib/git-exec.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way AgentOS runs git. Every git process starts here, so they all
|
|
3
|
+
* share the same hardening:
|
|
4
|
+
*
|
|
5
|
+
* - argv only, never a shell: a branch name, path or message is always data;
|
|
6
|
+
* - off the event loop, with a ceiling (a hung ssh, a stalled mount or a
|
|
7
|
+
* stuck hook is killed at it, with SIGKILL);
|
|
8
|
+
* - no prompt can ever wait for an answer (GIT_TERMINAL_PROMPT=0), and stdin
|
|
9
|
+
* is always closed;
|
|
10
|
+
* - no optional locks, so a read never takes the index lock a commit or
|
|
11
|
+
* rebase needs;
|
|
12
|
+
* - the server's own tmux (TMUX, TMUX_PANE) never reaches a hook;
|
|
13
|
+
* - a failure is a GitError carrying the exit code, stderr and stdout.
|
|
14
|
+
*
|
|
15
|
+
* A lint rule (eslint.config.mjs) refuses git spawned anywhere else.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFile, execFileSync } from "child_process";
|
|
19
|
+
|
|
20
|
+
export const GIT_TIMEOUT_MS = 60_000;
|
|
21
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
export interface GitOptions {
|
|
24
|
+
// Milliseconds before git is killed. Default GIT_TIMEOUT_MS.
|
|
25
|
+
timeout?: number;
|
|
26
|
+
// Written to stdin, which is closed either way.
|
|
27
|
+
input?: string;
|
|
28
|
+
// Added to the shared environment.
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
// Paths are names, never globs or pathspec magic: "[draft].md" or "*"
|
|
31
|
+
// must not reach other files.
|
|
32
|
+
literalPathspecs?: boolean;
|
|
33
|
+
// Bytes of output before git is killed. Default 16MB.
|
|
34
|
+
maxBuffer?: number;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class GitError extends Error {
|
|
39
|
+
override readonly name = "GitError";
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
readonly args: string[],
|
|
43
|
+
readonly cwd: string,
|
|
44
|
+
// git's exit status; null when it never exited on its own.
|
|
45
|
+
readonly exitCode: number | null,
|
|
46
|
+
readonly signal: NodeJS.Signals | null,
|
|
47
|
+
readonly stdout: string,
|
|
48
|
+
readonly stderr: string,
|
|
49
|
+
readonly timedOut: boolean,
|
|
50
|
+
options?: { cause?: unknown }
|
|
51
|
+
) {
|
|
52
|
+
super(message, options);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// What execFile's errors carry, for callers written against them.
|
|
56
|
+
get code(): number | string | null {
|
|
57
|
+
const cause = this.cause as { code?: unknown } | undefined;
|
|
58
|
+
if (this.exitCode !== null) return this.exitCode;
|
|
59
|
+
return typeof cause?.code === "string" ? cause.code : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get killed(): boolean {
|
|
63
|
+
return this.timedOut || this.signal !== null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const isGitError = (error: unknown): error is GitError =>
|
|
68
|
+
error instanceof GitError;
|
|
69
|
+
|
|
70
|
+
/** The environment every git runs with. */
|
|
71
|
+
export function gitEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
|
|
72
|
+
const env: NodeJS.ProcessEnv = {
|
|
73
|
+
...process.env,
|
|
74
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
75
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
76
|
+
...extra,
|
|
77
|
+
};
|
|
78
|
+
delete env.TMUX;
|
|
79
|
+
delete env.TMUX_PANE;
|
|
80
|
+
return env;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const argv = (args: string[], opts: GitOptions) =>
|
|
84
|
+
opts.literalPathspecs ? ["--literal-pathspecs", ...args] : args;
|
|
85
|
+
|
|
86
|
+
// The subcommand, for messages: "git worktree add", "git push".
|
|
87
|
+
const named = (args: string[]) => {
|
|
88
|
+
const words = args.filter((a) => !a.startsWith("-")).slice(0, 2);
|
|
89
|
+
return ["git", ...(words[0] === "worktree" ? words : words.slice(0, 1))]
|
|
90
|
+
.join(" ")
|
|
91
|
+
.trim();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function toGitError(
|
|
95
|
+
error: unknown,
|
|
96
|
+
args: string[],
|
|
97
|
+
cwd: string,
|
|
98
|
+
stdout: string,
|
|
99
|
+
stderr: string,
|
|
100
|
+
timeout: number
|
|
101
|
+
): GitError {
|
|
102
|
+
const e = error as {
|
|
103
|
+
code?: unknown;
|
|
104
|
+
status?: unknown;
|
|
105
|
+
killed?: boolean;
|
|
106
|
+
signal?: NodeJS.Signals | null;
|
|
107
|
+
name?: string;
|
|
108
|
+
message?: string;
|
|
109
|
+
};
|
|
110
|
+
// execFile reports the exit status as `code`, execFileSync as `status`.
|
|
111
|
+
const exitCode =
|
|
112
|
+
typeof e.code === "number"
|
|
113
|
+
? e.code
|
|
114
|
+
: typeof e.status === "number"
|
|
115
|
+
? e.status
|
|
116
|
+
: null;
|
|
117
|
+
const aborted = e.name === "AbortError" || e.code === "ABORT_ERR";
|
|
118
|
+
const overflow = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
119
|
+
const timedOut =
|
|
120
|
+
e.code === "ETIMEDOUT" ||
|
|
121
|
+
(exitCode === null && !aborted && !overflow && e.killed === true);
|
|
122
|
+
const what = named(args);
|
|
123
|
+
const reason =
|
|
124
|
+
stderr.trim() || stdout.trim() || e.message || String(error ?? "");
|
|
125
|
+
const message = timedOut
|
|
126
|
+
? `${what} timed out after ${Math.round(timeout / 1000)}s`
|
|
127
|
+
: `${what} failed: ${reason}`;
|
|
128
|
+
return new GitError(
|
|
129
|
+
message,
|
|
130
|
+
args,
|
|
131
|
+
cwd,
|
|
132
|
+
exitCode,
|
|
133
|
+
e.signal ?? null,
|
|
134
|
+
stdout,
|
|
135
|
+
stderr,
|
|
136
|
+
timedOut,
|
|
137
|
+
{ cause: error }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Runs git in `cwd`; resolves stdout and stderr, rejects with a GitError. */
|
|
142
|
+
export function gitResult(
|
|
143
|
+
cwd: string,
|
|
144
|
+
args: string[],
|
|
145
|
+
opts: GitOptions = {}
|
|
146
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
147
|
+
const full = argv(args, opts);
|
|
148
|
+
const timeout = opts.timeout ?? GIT_TIMEOUT_MS;
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const child = execFile(
|
|
151
|
+
"git",
|
|
152
|
+
full,
|
|
153
|
+
{
|
|
154
|
+
cwd,
|
|
155
|
+
encoding: "utf-8",
|
|
156
|
+
maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
|
|
157
|
+
timeout,
|
|
158
|
+
killSignal: "SIGKILL",
|
|
159
|
+
env: gitEnv(opts.env),
|
|
160
|
+
signal: opts.signal,
|
|
161
|
+
},
|
|
162
|
+
(error, stdout, stderr) => {
|
|
163
|
+
if (!error) return resolve({ stdout, stderr });
|
|
164
|
+
reject(toGitError(error, args, cwd, stdout, stderr, timeout));
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
// git exiting before it read its input is its failure, not a crash.
|
|
168
|
+
child.stdin?.on("error", () => {});
|
|
169
|
+
child.stdin?.end(opts.input ?? "");
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Runs git in `cwd` and resolves what it printed, untrimmed. */
|
|
174
|
+
export async function git(
|
|
175
|
+
cwd: string,
|
|
176
|
+
args: string[],
|
|
177
|
+
opts: GitOptions = {}
|
|
178
|
+
): Promise<string> {
|
|
179
|
+
return (await gitResult(cwd, args, opts)).stdout;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether git exits 0. Never throws. */
|
|
183
|
+
export function gitOk(
|
|
184
|
+
cwd: string,
|
|
185
|
+
args: string[],
|
|
186
|
+
opts: GitOptions = {}
|
|
187
|
+
): Promise<boolean> {
|
|
188
|
+
return gitResult(cwd, args, opts).then(
|
|
189
|
+
() => true,
|
|
190
|
+
() => false
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The blocking form, for what runs once outside a request (the build id at
|
|
196
|
+
* startup) and test setup. Never in a request handler.
|
|
197
|
+
*/
|
|
198
|
+
export function gitSync(
|
|
199
|
+
cwd: string,
|
|
200
|
+
args: string[],
|
|
201
|
+
opts: Omit<GitOptions, "signal"> = {}
|
|
202
|
+
): string {
|
|
203
|
+
const full = argv(args, opts);
|
|
204
|
+
const timeout = opts.timeout ?? GIT_TIMEOUT_MS;
|
|
205
|
+
try {
|
|
206
|
+
return execFileSync("git", full, {
|
|
207
|
+
cwd,
|
|
208
|
+
encoding: "utf-8",
|
|
209
|
+
input: opts.input ?? "",
|
|
210
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
211
|
+
maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
|
|
212
|
+
timeout,
|
|
213
|
+
killSignal: "SIGKILL",
|
|
214
|
+
env: gitEnv(opts.env),
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const e = error as { stdout?: unknown; stderr?: unknown };
|
|
218
|
+
const text = (v: unknown) =>
|
|
219
|
+
typeof v === "string" ? v : Buffer.isBuffer(v) ? v.toString() : "";
|
|
220
|
+
throw toGitError(error, args, cwd, text(e.stdout), text(e.stderr), timeout);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// The operations AgentOS actually runs. Names reach git as arguments after
|
|
226
|
+
// "--" or --end-of-options wherever git allows it, so none is read as an
|
|
227
|
+
// option.
|
|
228
|
+
|
|
229
|
+
const trimmed = (p: Promise<string>) => p.then((s) => s.trim());
|
|
230
|
+
|
|
231
|
+
/** `rev-parse <ref>`, trimmed. Throws when it doesn't resolve. */
|
|
232
|
+
export const revParse = (cwd: string, ref: string, opts?: GitOptions) =>
|
|
233
|
+
trimmed(git(cwd, ["rev-parse", ref], opts));
|
|
234
|
+
|
|
235
|
+
/** The object `ref` names, or null when it names none. */
|
|
236
|
+
export const resolveRef = (cwd: string, ref: string, opts?: GitOptions) =>
|
|
237
|
+
git(cwd, ["rev-parse", "--verify", "--quiet", ref], opts).then(
|
|
238
|
+
(out) => out.trim() || null,
|
|
239
|
+
() => null
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
/** The checked-out branch, "" when HEAD is detached. */
|
|
243
|
+
export const currentBranch = (cwd: string, opts?: GitOptions) =>
|
|
244
|
+
trimmed(git(cwd, ["branch", "--show-current"], opts));
|
|
245
|
+
|
|
246
|
+
/** `status --porcelain` as printed. */
|
|
247
|
+
export const statusPorcelain = (cwd: string, opts?: GitOptions) =>
|
|
248
|
+
git(cwd, ["status", "--porcelain"], opts);
|
|
249
|
+
|
|
250
|
+
/** Whether `ancestor` is reachable from `ref`; false when git can't say. */
|
|
251
|
+
export const isAncestor = (
|
|
252
|
+
cwd: string,
|
|
253
|
+
ancestor: string,
|
|
254
|
+
ref: string,
|
|
255
|
+
opts?: GitOptions
|
|
256
|
+
) => gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, ref], opts);
|
|
257
|
+
|
|
258
|
+
/** The best common ancestor of `a` and `b`. */
|
|
259
|
+
export const mergeBase = (
|
|
260
|
+
cwd: string,
|
|
261
|
+
a: string,
|
|
262
|
+
b: string,
|
|
263
|
+
opts?: GitOptions
|
|
264
|
+
) => trimmed(git(cwd, ["merge-base", "--end-of-options", a, b], opts));
|
|
265
|
+
|
|
266
|
+
export const gitFetch = (cwd: string, args: string[] = [], opts?: GitOptions) =>
|
|
267
|
+
git(cwd, ["fetch", ...args], opts);
|
|
268
|
+
|
|
269
|
+
/** Pushes; resolves stdout then stderr, where git reports what it did. */
|
|
270
|
+
export const gitPush = (cwd: string, args: string[], opts?: GitOptions) =>
|
|
271
|
+
gitResult(cwd, ["push", ...args], opts).then(
|
|
272
|
+
({ stdout, stderr }) => `${stdout}${stderr}`
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
export const gitRebase = (cwd: string, args: string[], opts?: GitOptions) =>
|
|
276
|
+
git(cwd, ["rebase", ...args], opts);
|
|
277
|
+
|
|
278
|
+
/** `diff --stat` of `args` (a range, paths), as printed. */
|
|
279
|
+
export const diffStat = (cwd: string, args: string[], opts?: GitOptions) =>
|
|
280
|
+
git(cwd, ["diff", "--stat", ...args], opts);
|
|
281
|
+
|
|
282
|
+
/** Deletes a local branch; `force` deletes it unmerged (-D). */
|
|
283
|
+
export const deleteBranch = (
|
|
284
|
+
cwd: string,
|
|
285
|
+
branch: string,
|
|
286
|
+
{ force = false, ...opts }: GitOptions & { force?: boolean } = {}
|
|
287
|
+
) => git(cwd, ["branch", force ? "-D" : "-d", "--", branch], opts);
|
|
288
|
+
|
|
289
|
+
export const worktreeAdd = (repo: string, args: string[], opts?: GitOptions) =>
|
|
290
|
+
git(repo, ["worktree", "add", ...args], opts);
|
|
291
|
+
|
|
292
|
+
export const worktreeRemove = (
|
|
293
|
+
repo: string,
|
|
294
|
+
dir: string,
|
|
295
|
+
{ force = 1, ...opts }: GitOptions & { force?: 0 | 1 | 2 } = {}
|
|
296
|
+
) =>
|
|
297
|
+
git(
|
|
298
|
+
repo,
|
|
299
|
+
["worktree", "remove", ...Array(force).fill("--force"), "--", dir],
|
|
300
|
+
opts
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
export interface WorktreeEntry {
|
|
304
|
+
path: string;
|
|
305
|
+
// Short name, "" when detached.
|
|
306
|
+
branch: string;
|
|
307
|
+
head: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** `worktree list --porcelain`, parsed. The main checkout is first. */
|
|
311
|
+
export async function worktreeList(
|
|
312
|
+
repo: string,
|
|
313
|
+
opts?: GitOptions
|
|
314
|
+
): Promise<WorktreeEntry[]> {
|
|
315
|
+
const out = await git(repo, ["worktree", "list", "--porcelain"], opts);
|
|
316
|
+
return parseWorktreeList(out);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function parseWorktreeList(out: string): WorktreeEntry[] {
|
|
320
|
+
const entries: WorktreeEntry[] = [];
|
|
321
|
+
for (const block of out.split("\n\n")) {
|
|
322
|
+
let path = "";
|
|
323
|
+
let branch = "";
|
|
324
|
+
let head = "";
|
|
325
|
+
for (const line of block.split("\n")) {
|
|
326
|
+
if (line.startsWith("worktree ")) path = line.slice(9);
|
|
327
|
+
else if (line.startsWith("branch "))
|
|
328
|
+
branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
329
|
+
else if (line.startsWith("HEAD ")) head = line.slice(5);
|
|
330
|
+
}
|
|
331
|
+
if (path) entries.push({ path, branch, head });
|
|
332
|
+
}
|
|
333
|
+
return entries;
|
|
334
|
+
}
|
package/lib/git-history.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { git } from "./git-exec";
|
|
2
2
|
import { expandPath } from "./git-status";
|
|
3
3
|
|
|
4
4
|
export interface CommitSummary {
|
|
@@ -51,27 +51,23 @@ function getRelativeTime(timestamp: number): string {
|
|
|
51
51
|
/**
|
|
52
52
|
* Get commit history
|
|
53
53
|
*/
|
|
54
|
-
export function getCommitHistory(
|
|
54
|
+
export async function getCommitHistory(
|
|
55
55
|
workingDir: string,
|
|
56
56
|
limit: number = 30
|
|
57
|
-
): CommitSummary[] {
|
|
57
|
+
): Promise<CommitSummary[]> {
|
|
58
58
|
const cwd = expandPath(workingDir);
|
|
59
59
|
|
|
60
60
|
try {
|
|
61
61
|
// Format: hash|shortHash|subject|body|author|email|timestamp
|
|
62
62
|
// Using %x00 as separator to handle commit messages with |
|
|
63
63
|
const format = "%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%at";
|
|
64
|
-
const output =
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"--shortstat",
|
|
72
|
-
],
|
|
73
|
-
{ cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
|
|
74
|
-
);
|
|
64
|
+
const output = await git(cwd, [
|
|
65
|
+
"log",
|
|
66
|
+
`--format=${format}`,
|
|
67
|
+
"-n",
|
|
68
|
+
String(Math.trunc(limit) || 30),
|
|
69
|
+
"--shortstat",
|
|
70
|
+
]);
|
|
75
71
|
|
|
76
72
|
const commits: CommitSummary[] = [];
|
|
77
73
|
const lines = output.split("\n");
|
|
@@ -160,20 +156,18 @@ export function getCommitHistory(
|
|
|
160
156
|
// A commit as git names it: 7 to 40 hex digits, nothing an option could be.
|
|
161
157
|
export const isCommitHash = (hash: string) => /^[0-9a-f]{7,40}$/.test(hash);
|
|
162
158
|
|
|
163
|
-
export function getCommitDetail(
|
|
159
|
+
export async function getCommitDetail(
|
|
164
160
|
workingDir: string,
|
|
165
161
|
commitHash: string
|
|
166
|
-
): CommitDetail | null {
|
|
162
|
+
): Promise<CommitDetail | null> {
|
|
167
163
|
if (!isCommitHash(commitHash)) return null;
|
|
168
164
|
const cwd = expandPath(workingDir);
|
|
169
165
|
|
|
170
166
|
try {
|
|
171
167
|
// Get commit info
|
|
172
168
|
const format = "%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%at";
|
|
173
|
-
const infoOutput =
|
|
174
|
-
|
|
175
|
-
["show", `--format=${format}`, "-s", commitHash, "--"],
|
|
176
|
-
{ cwd, encoding: "utf-8" }
|
|
169
|
+
const infoOutput = (
|
|
170
|
+
await git(cwd, ["show", `--format=${format}`, "-s", commitHash, "--"])
|
|
177
171
|
).trim();
|
|
178
172
|
|
|
179
173
|
const parts = infoOutput.split("\x00");
|
|
@@ -183,19 +177,11 @@ export function getCommitDetail(
|
|
|
183
177
|
parts;
|
|
184
178
|
const timestamp = parseInt(timestampStr, 10);
|
|
185
179
|
|
|
186
|
-
//
|
|
187
|
-
const statOutput =
|
|
188
|
-
|
|
189
|
-
["show", "--
|
|
190
|
-
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
// Get name-status for detecting renames
|
|
194
|
-
const nameStatusOutput = execFileSync(
|
|
195
|
-
"git",
|
|
196
|
-
["show", "--name-status", "--format=", commitHash, "--"],
|
|
197
|
-
{ cwd, encoding: "utf-8" }
|
|
198
|
-
);
|
|
180
|
+
// File stats from numstat; name-status for detecting renames
|
|
181
|
+
const [statOutput, nameStatusOutput] = await Promise.all([
|
|
182
|
+
git(cwd, ["show", "--numstat", "--format=", commitHash, "--"]),
|
|
183
|
+
git(cwd, ["show", "--name-status", "--format=", commitHash, "--"]),
|
|
184
|
+
]);
|
|
199
185
|
|
|
200
186
|
const files: CommitFile[] = [];
|
|
201
187
|
const statLines = statOutput.trim().split("\n").filter(Boolean);
|
|
@@ -283,23 +269,25 @@ export function getCommitDetail(
|
|
|
283
269
|
/**
|
|
284
270
|
* Get diff for a specific file in a commit
|
|
285
271
|
*/
|
|
286
|
-
export function getCommitFileDiff(
|
|
272
|
+
export async function getCommitFileDiff(
|
|
287
273
|
workingDir: string,
|
|
288
274
|
commitHash: string,
|
|
289
275
|
filePath: string
|
|
290
|
-
): string {
|
|
276
|
+
): Promise<string> {
|
|
291
277
|
if (!isCommitHash(commitHash)) return "";
|
|
292
278
|
const cwd = expandPath(workingDir);
|
|
293
279
|
|
|
294
280
|
try {
|
|
295
281
|
// Get diff for the specific file in this commit
|
|
296
282
|
// Use -m to handle merge commits (shows diff against first parent)
|
|
297
|
-
|
|
298
|
-
"
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
283
|
+
return await git(cwd, [
|
|
284
|
+
"show",
|
|
285
|
+
"-m",
|
|
286
|
+
"--first-parent",
|
|
287
|
+
commitHash,
|
|
288
|
+
"--",
|
|
289
|
+
filePath,
|
|
290
|
+
]);
|
|
303
291
|
} catch (error) {
|
|
304
292
|
console.error("Failed to get commit file diff:", error);
|
|
305
293
|
return "";
|