@abhaydesu/git-pilot 1.3.1 → 2.0.1

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.
@@ -0,0 +1,498 @@
1
+ import { execa } from "execa";
2
+ import process from "node:process";
3
+
4
+ export const SERVICE_NAME = "git-pilot";
5
+ const SAFE_IDENTIFIER = /^[a-zA-Z0-9._-]+$/;
6
+
7
+ const WINDOWS_CREDENTIAL_MANAGER_SOURCE = `
8
+ using System;
9
+ using System.ComponentModel;
10
+ using System.Runtime.InteropServices;
11
+
12
+ public static class GitPilotCredentialManager {
13
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
14
+ private struct NativeCredential {
15
+ public UInt32 Flags;
16
+ public UInt32 Type;
17
+ public string TargetName;
18
+ public string Comment;
19
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
20
+ public UInt32 CredentialBlobSize;
21
+ public IntPtr CredentialBlob;
22
+ public UInt32 Persist;
23
+ public UInt32 AttributeCount;
24
+ public IntPtr Attributes;
25
+ public string TargetAlias;
26
+ public string UserName;
27
+ }
28
+
29
+ [DllImport("Advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
30
+ private static extern bool CredWrite(ref NativeCredential credential, UInt32 flags);
31
+ [DllImport("Advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
32
+ private static extern bool CredRead(string target, UInt32 type, UInt32 flags, out IntPtr credential);
33
+ [DllImport("Advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
34
+ private static extern bool CredDelete(string target, UInt32 type, UInt32 flags);
35
+ [DllImport("Advapi32.dll", SetLastError = false)]
36
+ private static extern void CredFree(IntPtr credential);
37
+
38
+ public static bool Probe() { return true; }
39
+
40
+ public static void Write(string target, string username, byte[] secret) {
41
+ IntPtr blob = Marshal.AllocHGlobal(secret.Length);
42
+ try {
43
+ Marshal.Copy(secret, 0, blob, secret.Length);
44
+ var credential = new NativeCredential {
45
+ Type = 1, TargetName = target, CredentialBlobSize = (UInt32)secret.Length,
46
+ CredentialBlob = blob, Persist = 2, UserName = username
47
+ };
48
+ if (!CredWrite(ref credential, 0)) throw new Win32Exception(Marshal.GetLastWin32Error());
49
+ } finally { Marshal.FreeHGlobal(blob); }
50
+ }
51
+
52
+ public static byte[] Read(string target, out int error) {
53
+ IntPtr pointer;
54
+ if (!CredRead(target, 1, 0, out pointer)) { error = Marshal.GetLastWin32Error(); return null; }
55
+ try {
56
+ var credential = (NativeCredential)Marshal.PtrToStructure(pointer, typeof(NativeCredential));
57
+ var secret = new byte[credential.CredentialBlobSize];
58
+ if (secret.Length > 0) Marshal.Copy(credential.CredentialBlob, secret, 0, secret.Length);
59
+ error = 0;
60
+ return secret;
61
+ } finally { CredFree(pointer); }
62
+ }
63
+
64
+ public static bool Delete(string target, out int error) {
65
+ if (CredDelete(target, 1, 0)) { error = 0; return true; }
66
+ error = Marshal.GetLastWin32Error();
67
+ return false;
68
+ }
69
+ }
70
+ `;
71
+
72
+ function windowsCredentialScript(action, service, account) {
73
+ const target = `${service}/${account}`;
74
+ const prelude = `$ErrorActionPreference = 'Stop';\nAdd-Type -TypeDefinition @'\n${WINDOWS_CREDENTIAL_MANAGER_SOURCE}\n'@;\n`;
75
+
76
+ if (action === "probe") {
77
+ return `${prelude}[GitPilotCredentialManager]::Probe() | Out-Null; 'OK'`;
78
+ }
79
+ if (action === "get") {
80
+ return `${prelude}$errorCode = 0; $secret = [GitPilotCredentialManager]::Read('${target}', [ref]$errorCode); if ($null -eq $secret) { if ($errorCode -eq 1168) { exit 44 }; throw [ComponentModel.Win32Exception]::new($errorCode) }; [Console]::Out.Write([Convert]::ToBase64String($secret))`;
81
+ }
82
+ if (action === "set") {
83
+ return `${prelude}$encoded = [Console]::In.ReadToEnd().Trim(); $secret = [Convert]::FromBase64String($encoded); [GitPilotCredentialManager]::Write('${target}', '${account}', $secret)`;
84
+ }
85
+ return `${prelude}$errorCode = 0; if ([GitPilotCredentialManager]::Delete('${target}', [ref]$errorCode)) { exit 0 }; if ($errorCode -eq 1168) { exit 44 }; throw [ComponentModel.Win32Exception]::new($errorCode)`;
86
+ }
87
+
88
+ export class CredentialStoreUnavailableError extends Error {
89
+ constructor(message, platform) {
90
+ super(message);
91
+ this.name = "CredentialStoreUnavailableError";
92
+ this.platform = platform;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Redacts any occurrence of a secret (and its hex representation) or key-shaped token from text.
98
+ */
99
+ export function redactSecretText(text, secrets = []) {
100
+ if (typeof text !== "string" || !text) return "";
101
+ let out = text;
102
+ const envKey = process.env.GEMINI_API_KEY?.trim();
103
+ const allSecrets = [...secrets, envKey].filter((s) => typeof s === "string" && s.length > 0);
104
+
105
+ for (const s of allSecrets) {
106
+ out = out.replaceAll(s, "[REDACTED]");
107
+ const hex = Buffer.from(s, "utf8").toString("hex");
108
+ if (hex.length >= 8) {
109
+ out = out.replaceAll(hex, "[REDACTED]");
110
+ }
111
+ }
112
+
113
+ // Also redact Google API key patterns
114
+ out = out.replace(/\bAIza[0-9A-Za-z-_]{20,}\b/g, "[REDACTED]");
115
+ return out;
116
+ }
117
+
118
+ function assertSafeIdentifier(value, label) {
119
+ if (typeof value !== "string" || !SAFE_IDENTIFIER.test(value)) {
120
+ throw new Error(
121
+ `Invalid ${label}: must contain only alphanumeric, '.', '_', or '-' characters.`
122
+ );
123
+ }
124
+ }
125
+
126
+ /**
127
+ * MemoryCredentialStore provides an in-memory implementation for testing.
128
+ */
129
+ export class MemoryCredentialStore {
130
+ constructor() {
131
+ this.store = new Map();
132
+ this.platform = "memory";
133
+ }
134
+
135
+ async isAvailable() {
136
+ return { available: true };
137
+ }
138
+
139
+ async getPassword(service, account) {
140
+ return this.store.get(`${service}:${account}`) ?? null;
141
+ }
142
+
143
+ async setPassword(service, account, password) {
144
+ this.store.set(`${service}:${account}`, password);
145
+ }
146
+
147
+ async deletePassword(service, account) {
148
+ return this.store.delete(`${service}:${account}`);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * OsCredentialStore delegates to native OS credential tools without a shell.
154
+ * Secrets are passed exclusively via stdin, never in process argv.
155
+ */
156
+ export class OsCredentialStore {
157
+ constructor(optionsOrPlatform = process.platform) {
158
+ if (typeof optionsOrPlatform === "string") {
159
+ this.platform = optionsOrPlatform;
160
+ this.execFn = execa;
161
+ this.keychainPath = null;
162
+ } else {
163
+ this.platform = optionsOrPlatform?.platform || process.platform;
164
+ this.execFn = optionsOrPlatform?.execFn || execa;
165
+ this.keychainPath = optionsOrPlatform?.keychainPath || null;
166
+ }
167
+ }
168
+
169
+ async isAvailable() {
170
+ if (this.platform === "darwin") {
171
+ try {
172
+ await this.execFn("/usr/bin/security", ["default-keychain"]);
173
+ return { available: true };
174
+ } catch (err) {
175
+ return {
176
+ available: false,
177
+ reason: `macOS Keychain is inaccessible: ${redactSecretText(err.stderr || err.message)}`,
178
+ };
179
+ }
180
+ }
181
+
182
+ if (this.platform === "linux") {
183
+ try {
184
+ await this.execFn("which", ["secret-tool"]);
185
+ return { available: true };
186
+ } catch {
187
+ return {
188
+ available: false,
189
+ reason:
190
+ "Linux Secret Service utility 'secret-tool' was not found. " +
191
+ "Please install libsecret-tools (e.g. `sudo apt install libsecret-tools` or `pacman -S libsecret`) " +
192
+ "and ensure a Secret Service daemon (such as GNOME Keyring or KeePassXC) is running.",
193
+ };
194
+ }
195
+ }
196
+
197
+ if (this.platform === "win32") {
198
+ try {
199
+ await this.execFn("powershell.exe", [
200
+ "-NoProfile",
201
+ "-NonInteractive",
202
+ "-Command",
203
+ windowsCredentialScript("probe", "git-pilot", "availability"),
204
+ ]);
205
+ return { available: true };
206
+ } catch {
207
+ return {
208
+ available: false,
209
+ reason: "Windows Credential Manager is unavailable.",
210
+ };
211
+ }
212
+ }
213
+
214
+ return {
215
+ available: false,
216
+ reason: `Unsupported platform for OS credential storage: ${this.platform}`,
217
+ };
218
+ }
219
+
220
+ async getPassword(service, account) {
221
+ assertSafeIdentifier(service, "service");
222
+ assertSafeIdentifier(account, "account");
223
+
224
+ const status = await this.isAvailable();
225
+ if (!status.available) {
226
+ throw new CredentialStoreUnavailableError(status.reason, this.platform);
227
+ }
228
+
229
+ if (this.platform === "darwin") {
230
+ try {
231
+ const args = ["find-generic-password", "-s", service, "-a", account, "-w"];
232
+ if (this.keychainPath) args.push(this.keychainPath);
233
+ const { stdout } = await this.execFn("/usr/bin/security", args);
234
+ return stdout.trim();
235
+ } catch (err) {
236
+ const msg = err.stderr || err.message || "";
237
+ // Exit code 44 on macOS security means item not found
238
+ if (err.exitCode === 44 || msg.includes("could not be found")) {
239
+ return null;
240
+ }
241
+ throw new CredentialStoreUnavailableError(
242
+ `macOS Keychain lookup failed: ${redactSecretText(msg)}`,
243
+ this.platform
244
+ );
245
+ }
246
+ }
247
+
248
+ if (this.platform === "linux") {
249
+ try {
250
+ const { stdout } = await this.execFn("secret-tool", [
251
+ "lookup",
252
+ "service",
253
+ service,
254
+ "account",
255
+ account,
256
+ ]);
257
+ return stdout.trim() || null;
258
+ } catch (err) {
259
+ const msg = err.stderr || err.message || "";
260
+ if (
261
+ err.exitCode === 1 &&
262
+ !msg.toLowerCase().includes("dbus") &&
263
+ !msg.toLowerCase().includes("secret service") &&
264
+ !msg.toLowerCase().includes("cannot autolaunch")
265
+ ) {
266
+ return null;
267
+ }
268
+ throw new CredentialStoreUnavailableError(
269
+ `Linux Secret Service lookup failed: ${redactSecretText(msg || "Secret Service daemon unavailable")}`,
270
+ this.platform
271
+ );
272
+ }
273
+ }
274
+
275
+ if (this.platform === "win32") {
276
+ try {
277
+ const { stdout } = await this.execFn("powershell.exe", [
278
+ "-NoProfile",
279
+ "-NonInteractive",
280
+ "-Command",
281
+ windowsCredentialScript("get", service, account),
282
+ ]);
283
+ const encoded = stdout.trim();
284
+ return encoded ? Buffer.from(encoded, "base64").toString("utf8") : null;
285
+ } catch (err) {
286
+ if (err.exitCode === 44) return null;
287
+ throw new CredentialStoreUnavailableError(
288
+ `Windows Credential Manager lookup failed: ${redactSecretText(err.stderr || err.message)}`,
289
+ this.platform
290
+ );
291
+ }
292
+ }
293
+
294
+ return null;
295
+ }
296
+
297
+ async setPassword(service, account, password) {
298
+ assertSafeIdentifier(service, "service");
299
+ assertSafeIdentifier(account, "account");
300
+
301
+ const status = await this.isAvailable();
302
+ if (!status.available) {
303
+ throw new CredentialStoreUnavailableError(status.reason, this.platform);
304
+ }
305
+
306
+ try {
307
+ if (this.platform === "darwin") {
308
+ // Use `security -i` (interactive stdin mode) with `-X <hex>` so the secret
309
+ // never appears in process argv (`ps`) and avoids shell/quote parsing issues.
310
+ const hexPassword = Buffer.from(password, "utf8").toString("hex");
311
+ const keychainArg = this.keychainPath
312
+ ? ` "${this.keychainPath.replace(/(["\\])/g, "\\$1")}"`
313
+ : "";
314
+ const commandLine = `add-generic-password -U -s ${service} -a ${account} -X ${hexPassword}${keychainArg}\n`;
315
+ await this.execFn("/usr/bin/security", ["-i"], {
316
+ input: commandLine,
317
+ });
318
+ return;
319
+ }
320
+
321
+ if (this.platform === "linux") {
322
+ // Pass password over stdin to avoid revealing in process arguments
323
+ await this.execFn(
324
+ "secret-tool",
325
+ ["store", `--label=Git Pilot (${account})`, "service", service, "account", account],
326
+ { input: password }
327
+ );
328
+ return;
329
+ }
330
+
331
+ if (this.platform === "win32") {
332
+ await this.execFn(
333
+ "powershell.exe",
334
+ [
335
+ "-NoProfile",
336
+ "-NonInteractive",
337
+ "-Command",
338
+ windowsCredentialScript("set", service, account),
339
+ ],
340
+ { input: Buffer.from(password, "utf8").toString("base64") }
341
+ );
342
+ }
343
+ } catch (err) {
344
+ const sanitized = redactSecretText(
345
+ err.stderr || err.shortMessage || err.message || "Failed to write to OS credential store.",
346
+ [password]
347
+ );
348
+ throw new CredentialStoreUnavailableError(
349
+ `Could not store credential in OS credential store: ${sanitized}`,
350
+ this.platform
351
+ );
352
+ }
353
+ }
354
+
355
+ async deletePassword(service, account) {
356
+ assertSafeIdentifier(service, "service");
357
+ assertSafeIdentifier(account, "account");
358
+
359
+ const status = await this.isAvailable();
360
+ if (!status.available) {
361
+ throw new CredentialStoreUnavailableError(status.reason, this.platform);
362
+ }
363
+
364
+ if (this.platform === "darwin") {
365
+ try {
366
+ const args = ["delete-generic-password", "-s", service, "-a", account];
367
+ if (this.keychainPath) args.push(this.keychainPath);
368
+ await this.execFn("/usr/bin/security", args);
369
+ return true;
370
+ } catch (err) {
371
+ const msg = err.stderr || err.message || "";
372
+ if (err.exitCode === 44 || msg.includes("could not be found")) {
373
+ return false;
374
+ }
375
+ throw new CredentialStoreUnavailableError(
376
+ `macOS Keychain delete failed: ${redactSecretText(msg)}`,
377
+ this.platform
378
+ );
379
+ }
380
+ }
381
+
382
+ if (this.platform === "linux") {
383
+ try {
384
+ await this.execFn("secret-tool", ["clear", "service", service, "account", account]);
385
+ return true;
386
+ } catch (err) {
387
+ if (err.exitCode === 1) return false;
388
+ throw new CredentialStoreUnavailableError(
389
+ `Linux Secret Service delete failed: ${redactSecretText(err.stderr || err.message)}`,
390
+ this.platform
391
+ );
392
+ }
393
+ }
394
+
395
+ if (this.platform === "win32") {
396
+ try {
397
+ await this.execFn("powershell.exe", [
398
+ "-NoProfile",
399
+ "-NonInteractive",
400
+ "-Command",
401
+ windowsCredentialScript("delete", service, account),
402
+ ]);
403
+ return true;
404
+ } catch (err) {
405
+ if (err.exitCode === 44) return false;
406
+ throw new CredentialStoreUnavailableError(
407
+ `Windows Credential Manager delete failed: ${redactSecretText(err.stderr || err.message)}`,
408
+ this.platform
409
+ );
410
+ }
411
+ }
412
+
413
+ return false;
414
+ }
415
+ }
416
+
417
+ let activeBackend = new OsCredentialStore();
418
+
419
+ /** Override active backend (useful for testing) */
420
+ export function setCredentialBackend(backend) {
421
+ activeBackend = backend;
422
+ }
423
+
424
+ export function getCredentialBackend() {
425
+ return activeBackend;
426
+ }
427
+
428
+ export function getEnvVarName(provider = "gemini") {
429
+ return provider === "gemini" ? "GEMINI_API_KEY" : `${provider.toUpperCase()}_API_KEY`;
430
+ }
431
+
432
+ /**
433
+ * Returns the environment-provided API key if set, without touching the OS credential store.
434
+ */
435
+ export function getEnvApiKey(provider = "gemini") {
436
+ const envVarName = getEnvVarName(provider);
437
+ const envVal = process.env[envVarName]?.trim();
438
+ if (envVal) {
439
+ return { key: envVal, source: "env", envVarName };
440
+ }
441
+ return null;
442
+ }
443
+
444
+ /**
445
+ * Returns the API key stored in the OS credential store, ignoring environment variables.
446
+ */
447
+ export async function getStoredApiKey(provider = "gemini") {
448
+ const stored = await activeBackend.getPassword(SERVICE_NAME, provider);
449
+ if (stored) {
450
+ return { key: stored, source: "keychain" };
451
+ }
452
+ return null;
453
+ }
454
+
455
+ /**
456
+ * Returns the effective API key for `provider`.
457
+ * Checks GEMINI_API_KEY env var first for CI/ephemeral use,
458
+ * then checks OS credential store.
459
+ * Returns: { key: string, source: "env" | "keychain" } or null if not set.
460
+ */
461
+ export async function getApiKey(provider = "gemini") {
462
+ const envKey = getEnvApiKey(provider);
463
+ if (envKey) {
464
+ return { key: envKey.key, source: "env" };
465
+ }
466
+ return getStoredApiKey(provider);
467
+ }
468
+
469
+ /**
470
+ * Stores the API key in the OS credential store.
471
+ * Does NOT persist environment variables.
472
+ */
473
+ export async function setApiKey(provider = "gemini", apiKey) {
474
+ if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") {
475
+ throw new Error("API key must be a non-empty string.");
476
+ }
477
+ await activeBackend.setPassword(SERVICE_NAME, provider, apiKey.trim());
478
+ }
479
+
480
+ /**
481
+ * Deletes the stored API key from the OS credential store.
482
+ */
483
+ export async function deleteApiKey(provider = "gemini") {
484
+ return activeBackend.deletePassword(SERVICE_NAME, provider);
485
+ }
486
+
487
+ /**
488
+ * Masks an API key for safe display in logs and status commands.
489
+ * Examples: AIzaSy...1234 -> AIza...1234
490
+ */
491
+ export function maskApiKey(key) {
492
+ if (!key || typeof key !== "string") return "****";
493
+ const trimmed = key.trim();
494
+ if (trimmed.length <= 8) return "****";
495
+ const prefix = trimmed.slice(0, 4);
496
+ const suffix = trimmed.slice(-4);
497
+ return `${prefix}...${suffix}`;
498
+ }
package/src/lib/exec.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parse } from "shell-quote";
2
2
  import { execa } from "execa";
3
+ import { getSanitizedGitEnv } from "./git.js";
3
4
 
4
5
  export class UnsafeCommandError extends Error {}
5
6
 
@@ -32,7 +33,8 @@ const ALLOWED_SUBCOMMANDS = new Set([
32
33
  ]);
33
34
 
34
35
  // Options that make git run another program or write to an arbitrary path.
35
- const FORBIDDEN_OPTION = /^--(upload-pack|receive-pack|exec|exec-path|output)(=|$)/;
36
+ const FORBIDDEN_OPTION =
37
+ /^--(upload-pack|receive-pack|exec|exec-path|output|ext-diff|textconv)(=|$)/;
36
38
 
37
39
  function assertSafeArgs(args) {
38
40
  const [subcommand, ...rest] = args;
@@ -81,5 +83,10 @@ export function parseGitCommand(command) {
81
83
  return args;
82
84
  }
83
85
 
84
- /** Runs `git <args>` with the terminal attached. */
85
- export const runGit = (args) => execa("git", args, { stdio: "inherit" });
86
+ /** Runs `git <args>` with the terminal attached and provider secrets stripped from env. */
87
+ export const runGit = (args) =>
88
+ execa("git", args, {
89
+ stdio: "inherit",
90
+ env: getSanitizedGitEnv(),
91
+ extendEnv: false,
92
+ });
package/src/lib/git.js CHANGED
@@ -1,13 +1,113 @@
1
1
  import { execa } from "execa";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import process from "node:process";
5
+ import { sanitizeTerminalText } from "./sanitizer.js";
6
+ import { redactSecretText } from "./credentials.js";
4
7
 
5
- const git = async (args) => (await execa("git", args)).stdout;
8
+ const SECRET_ENV_PATTERN =
9
+ /^(GEMINI|GOOGLE|OPENAI|ANTHROPIC|CLAUDE|COHERE|MISTRAL|GIT_PILOT)_.*(KEY|TOKEN|SECRET)|_API_KEY$/i;
10
+
11
+ /**
12
+ * Builds a clean environment for Git subprocesses and hooks.
13
+ * Strips GEMINI_API_KEY and other provider credentials so Git hooks or helpers
14
+ * never inherit API keys, while preserving standard Git and system variables.
15
+ */
16
+ export function getSanitizedGitEnv(baseEnv = process.env) {
17
+ const clean = {};
18
+ for (const [key, value] of Object.entries(baseEnv)) {
19
+ if (value === undefined) continue;
20
+ if (SECRET_ENV_PATTERN.test(key)) continue;
21
+ if (key === "GIT_EXTERNAL_DIFF") continue;
22
+ clean[key] = value;
23
+ }
24
+ clean.GIT_PAGER = "cat";
25
+ return clean;
26
+ }
27
+
28
+ const git = async (args, opts = {}) =>
29
+ (
30
+ await execa("git", args, {
31
+ env: getSanitizedGitEnv(),
32
+ extendEnv: false,
33
+ ...opts,
34
+ })
35
+ ).stdout;
36
+
37
+ export class GitCommitError extends Error {
38
+ constructor(message, originalError, commitMessage) {
39
+ super(message);
40
+ this.name = "GitCommitError";
41
+ this.originalError = originalError;
42
+ this.commitMessage = commitMessage;
43
+ this.exitCode = originalError?.exitCode;
44
+ }
45
+ }
46
+
47
+ export async function isInsideGitRepo() {
48
+ try {
49
+ const res = await git(["rev-parse", "--is-inside-work-tree"]);
50
+ return res.trim() === "true";
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ export const getStagedDiff = () => git(["diff", "--staged", "--no-ext-diff", "--no-textconv"]);
57
+
58
+ export const getStagedSummary = () =>
59
+ git(["diff", "--staged", "--name-status", "--no-ext-diff", "--no-textconv"]);
60
+
61
+ export function parseStagedFileList(output) {
62
+ return output.split("\0").filter((file) => file.length > 0);
63
+ }
64
+
65
+ /**
66
+ * Returns staged file paths using NUL-delimited (`-z`) output so filenames
67
+ * containing leading/trailing spaces, special characters, or newlines remain intact.
68
+ */
69
+ export async function getStagedFiles() {
70
+ const output = await git(
71
+ ["diff", "--staged", "--name-only", "-z", "--no-ext-diff", "--no-textconv"],
72
+ { stripFinalNewline: false }
73
+ );
74
+ return parseStagedFileList(output);
75
+ }
76
+
77
+ /**
78
+ * Returns the current staged index tree hash (`git write-tree`).
79
+ * Used for best-effort detection of concurrent index modifications before `git commit`.
80
+ */
81
+ export async function getStagedTreeHash() {
82
+ try {
83
+ return (await git(["write-tree"])).trim();
84
+ } catch {
85
+ // Fallback if write-tree cannot run (e.g. unmerged paths)
86
+ return (await getStagedDiff()).trim();
87
+ }
88
+ }
6
89
 
7
- export const getStagedDiff = () => git(["diff", "--staged"]);
8
- export const getStagedSummary = () => git(["diff", "--staged", "--name-status"]);
9
90
  export const getReflog = () => git(["reflog", "-n", "15"]);
10
- export const commit = (message) => git(["commit", "-m", message]);
91
+
92
+ export async function hasOrigHead() {
93
+ try {
94
+ await git(["rev-parse", "--verify", "--quiet", "ORIG_HEAD^{commit}"]);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ export async function commit(message) {
102
+ try {
103
+ return await git(["commit", "-m", message]);
104
+ } catch (err) {
105
+ const rawDetails = (err.stderr || err.stdout || err.message || "").trim();
106
+ const details = sanitizeTerminalText(redactSecretText(rawDetails));
107
+ throw new GitCommitError(`Git commit failed (hooks or git error): ${details}`, err, message);
108
+ }
109
+ }
110
+
11
111
  export const createBranch = (name) => git(["switch", "-c", name]);
12
112
 
13
113
  const exists = (p) =>
@@ -16,12 +116,34 @@ const exists = (p) =>
16
116
  () => false
17
117
  );
18
118
 
119
+ export async function getGitRepoState() {
120
+ try {
121
+ const gitDir = path.resolve(await git(["rev-parse", "--git-dir"]));
122
+ const states = [];
123
+ if (
124
+ (await exists(path.join(gitDir, "rebase-apply"))) ||
125
+ (await exists(path.join(gitDir, "rebase-merge")))
126
+ ) {
127
+ states.push("rebase");
128
+ }
129
+ if (await exists(path.join(gitDir, "MERGE_HEAD"))) {
130
+ states.push("merge");
131
+ }
132
+ if (await exists(path.join(gitDir, "CHERRY_PICK_HEAD"))) {
133
+ states.push("cherry-pick");
134
+ }
135
+ if (await exists(path.join(gitDir, "REVERT_HEAD"))) {
136
+ states.push("revert");
137
+ }
138
+ return states;
139
+ } catch {
140
+ return [];
141
+ }
142
+ }
143
+
19
144
  export async function isRebaseInProgress() {
20
- const gitDir = path.resolve(await git(["rev-parse", "--git-dir"]));
21
- return (
22
- (await exists(path.join(gitDir, "rebase-apply"))) ||
23
- (await exists(path.join(gitDir, "rebase-merge")))
24
- );
145
+ const states = await getGitRepoState();
146
+ return states.includes("rebase");
25
147
  }
26
148
 
27
149
  /** Lowercase, hyphenated, git-safe branch name; "" if nothing usable is left. */