@saadnvd1/agent-os 0.3.465 → 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.
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "child_process";
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 = execFileSync(
65
- "git",
66
- [
67
- "log",
68
- `--format=${format}`,
69
- "-n",
70
- String(Math.trunc(limit) || 30),
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 = execFileSync(
174
- "git",
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
- // Get file stats using numstat
187
- const statOutput = execFileSync(
188
- "git",
189
- ["show", "--numstat", "--format=", commitHash, "--"],
190
- { cwd, encoding: "utf-8" }
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
- const diff = execFileSync(
298
- "git",
299
- ["show", "-m", "--first-parent", commitHash, "--", filePath],
300
- { cwd, encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 }
301
- );
302
- return diff;
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 "";
package/lib/git-status.ts CHANGED
@@ -1,33 +1,20 @@
1
- import { execFile, execFileSync } from "child_process";
2
- import { promisify } from "util";
3
1
  import { realpathSync, unlinkSync } from "fs";
4
2
  import { basename, dirname, join, resolve, sep } from "path";
5
3
  import { homedir } from "os";
6
- import { remoteDefaultBranch } from "./pr";
7
-
8
- const execFileAsync = promisify(execFile);
9
-
10
- // The reads the git panel polls run off the event loop: a slow repo
11
- // shouldn't stall every terminal and status push while git works.
12
- async function git(cwd: string, args: string[]): Promise<string> {
13
- // Paths are names, never globs or pathspec magic: "[draft].md" or "*"
14
- // must not reach other files.
15
- const { stdout } = await execFileAsync(
16
- "git",
17
- ["--literal-pathspecs", ...args],
18
- {
19
- cwd,
20
- encoding: "utf-8",
21
- maxBuffer: 10 * 1024 * 1024,
22
- // A hung git (a stalled mount, a stuck hook) fails the request instead.
23
- timeout: 30_000,
24
- killSignal: "SIGKILL",
25
- // Status reads never take the index lock a commit or rebase needs.
26
- env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
27
- }
28
- );
29
- return stdout;
30
- }
4
+ import {
5
+ currentBranch as readBranch,
6
+ git as runGit,
7
+ gitOk,
8
+ gitPush,
9
+ gitResult,
10
+ type GitOptions,
11
+ } from "./git-exec";
12
+
13
+ // The reads the git panel polls, and its stage/unstage/discard: paths are
14
+ // names, never globs or pathspec magic, so "[draft].md" or "*" can't reach
15
+ // other files. A hung git fails the request at 30s.
16
+ const READ: GitOptions = { literalPathspecs: true, timeout: 30_000 };
17
+ const git = (cwd: string, args: string[]) => runGit(cwd, args, READ);
31
18
 
32
19
  // For commands whose exit code isn't failure (a diff with changes exits 1):
33
20
  // whatever they printed, or nothing.
@@ -40,6 +27,9 @@ async function gitOutput(cwd: string, args: string[]): Promise<string> {
40
27
  }
41
28
  }
42
29
 
30
+ // A commit or push can wait on a hook or a remote.
31
+ const WRITE: GitOptions = { timeout: 120_000 };
32
+
43
33
  /**
44
34
  * Expand ~ to home directory in paths
45
35
  */
@@ -282,51 +272,25 @@ export async function discardChanges(
282
272
  unlinkSync(inside);
283
273
  }
284
274
 
285
- /**
286
- * Check if directory is a git repository
287
- */
288
- export async function isGitRepo(workingDir: string): Promise<boolean> {
289
- try {
290
- await git(workingDir, ["rev-parse", "--git-dir"]);
291
- return true;
292
- } catch {
293
- return false;
294
- }
295
- }
296
-
297
- // Writes and the few sync reads: argv only, never a shell, so a branch name,
298
- // a commit message or a path is always data.
299
- function gitSync(cwd: string, args: string[], input?: string): string {
300
- return execFileSync("git", args, {
301
- cwd,
302
- encoding: "utf-8",
303
- input,
304
- stdio: ["pipe", "pipe", "pipe"],
305
- maxBuffer: 10 * 1024 * 1024,
306
- });
307
- }
275
+ export { isGitRepo } from "./git";
308
276
 
309
277
  /**
310
278
  * Get the root of the git repository
311
279
  */
312
- export function getGitRoot(workingDir: string): string {
280
+ export async function getGitRoot(workingDir: string): Promise<string> {
313
281
  try {
314
- return gitSync(workingDir, ["rev-parse", "--show-toplevel"]).trim();
282
+ return (await runGit(workingDir, ["rev-parse", "--show-toplevel"])).trim();
315
283
  } catch {
316
284
  return workingDir;
317
285
  }
318
286
  }
319
287
 
320
- function currentBranch(workingDir: string): string {
321
- return gitSync(workingDir, ["branch", "--show-current"]).trim();
322
- }
323
-
324
288
  /**
325
289
  * Check if on main/master branch
326
290
  */
327
- export function isMainBranch(workingDir: string): boolean {
291
+ export async function isMainBranch(workingDir: string): Promise<boolean> {
328
292
  try {
329
- const branch = currentBranch(workingDir);
293
+ const branch = await readBranch(workingDir);
330
294
  return branch === "main" || branch === "master";
331
295
  } catch {
332
296
  return false;
@@ -337,59 +301,25 @@ export function isMainBranch(workingDir: string): boolean {
337
301
  * A name `git checkout -b` takes as a branch and nothing else: no option
338
302
  * ("-f", "--orphan=x"), nothing git itself refuses as a ref.
339
303
  */
340
- export function isValidBranchName(workingDir: string, name: string): boolean {
304
+ export async function isValidBranchName(
305
+ workingDir: string,
306
+ name: string
307
+ ): Promise<boolean> {
341
308
  if (!name || name.startsWith("-")) return false;
342
- try {
343
- gitSync(workingDir, ["check-ref-format", "--branch", name]);
344
- return true;
345
- } catch {
346
- return false;
347
- }
309
+ return gitOk(workingDir, ["check-ref-format", "--branch", name]);
348
310
  }
349
311
 
350
312
  /**
351
313
  * Create a new branch and switch to it
352
314
  */
353
- export function createBranch(workingDir: string, branchName: string): void {
354
- if (!isValidBranchName(workingDir, branchName)) {
315
+ export async function createBranch(
316
+ workingDir: string,
317
+ branchName: string
318
+ ): Promise<void> {
319
+ if (!(await isValidBranchName(workingDir, branchName))) {
355
320
  throw new Error(`Invalid branch name: ${branchName}`);
356
321
  }
357
- gitSync(workingDir, ["checkout", "-b", branchName, "--"]);
358
- }
359
-
360
- // A commit or push can wait on a hook or a remote: off the event loop, with a
361
- // ceiling (a hung ssh is killed at it), and no git credential prompt.
362
- function gitWrite(
363
- cwd: string,
364
- args: string[],
365
- input?: string
366
- ): Promise<{ stdout: string; stderr: string }> {
367
- return new Promise((resolve, reject) => {
368
- const child = execFile(
369
- "git",
370
- args,
371
- {
372
- cwd,
373
- encoding: "utf-8",
374
- maxBuffer: 10 * 1024 * 1024,
375
- timeout: 120_000,
376
- killSignal: "SIGKILL",
377
- env: {
378
- ...process.env,
379
- GIT_TERMINAL_PROMPT: "0",
380
- },
381
- },
382
- (error, stdout, stderr) => {
383
- if (!error) return resolve({ stdout, stderr });
384
- // "nothing to commit" is on stdout.
385
- const reason = stderr.trim() || stdout.trim() || error.message;
386
- reject(new Error(`git ${args[0]} failed: ${reason}`));
387
- }
388
- );
389
- // git exiting before it read the message is its failure, not a crash.
390
- child.stdin?.on("error", () => {});
391
- child.stdin?.end(input ?? "");
392
- });
322
+ await runGit(workingDir, ["checkout", "-b", branchName, "--"]);
393
323
  }
394
324
 
395
325
  /**
@@ -400,7 +330,11 @@ export async function commit(
400
330
  workingDir: string,
401
331
  message: string
402
332
  ): Promise<string> {
403
- return (await gitWrite(workingDir, ["commit", "-F", "-"], message)).stdout;
333
+ const { stdout } = await gitResult(workingDir, ["commit", "-F", "-"], {
334
+ ...WRITE,
335
+ input: message,
336
+ });
337
+ return stdout;
404
338
  }
405
339
 
406
340
  /**
@@ -412,43 +346,30 @@ export async function push(
412
346
  ): Promise<string> {
413
347
  const args = ["push"];
414
348
  if (setUpstream) {
415
- const branch = currentBranch(workingDir);
416
- if (!isValidBranchName(workingDir, branch)) {
349
+ const branch = await readBranch(workingDir);
350
+ if (!(await isValidBranchName(workingDir, branch))) {
417
351
  throw new Error(`Can't push from "${branch || "a detached HEAD"}"`);
418
352
  }
419
353
  args.push("-u", "origin", `refs/heads/${branch}:refs/heads/${branch}`);
420
354
  }
421
355
  // git push reports progress and the remote's messages on stderr.
422
- const { stdout, stderr } = await gitWrite(workingDir, args);
423
- return `${stdout}${stderr}`;
356
+ return gitPush(workingDir, args.slice(1), WRITE);
424
357
  }
425
358
 
426
359
  /**
427
360
  * Check if branch has upstream
428
361
  */
429
- export function hasUpstream(workingDir: string): boolean {
430
- try {
431
- gitSync(workingDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
432
- return true;
433
- } catch {
434
- return false;
435
- }
362
+ export function hasUpstream(workingDir: string): Promise<boolean> {
363
+ return gitOk(workingDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
436
364
  }
437
365
 
438
366
  /**
439
367
  * Get remote URL
440
368
  */
441
- export function getRemoteUrl(workingDir: string): string | null {
369
+ export async function getRemoteUrl(workingDir: string): Promise<string | null> {
442
370
  try {
443
- return gitSync(workingDir, ["remote", "get-url", "origin"]).trim();
371
+ return (await runGit(workingDir, ["remote", "get-url", "origin"])).trim();
444
372
  } catch {
445
373
  return null;
446
374
  }
447
375
  }
448
-
449
- /**
450
- * Get the default branch name (main or master)
451
- */
452
- export function getDefaultBranch(workingDir: string): string {
453
- return remoteDefaultBranch(workingDir);
454
- }
package/lib/git.ts CHANGED
@@ -1,98 +1,61 @@
1
1
  /**
2
- * Git utilities for worktree management
2
+ * Repository questions AgentOS asks: is this a repo, which branch is the
3
+ * default, which branches exist. Every git call goes through lib/git-exec.
3
4
  */
4
5
 
5
- import { execFile } from "child_process";
6
- import { promisify } from "util";
6
+ import * as os from "os";
7
7
  import * as path from "path";
8
+ import { currentBranch, git, gitOk, resolveRef } from "./git-exec";
8
9
 
9
- const execFileAsync = promisify(execFile);
10
+ const expandHome = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
10
11
 
11
12
  /**
12
- * Check if a directory is a git repository
13
+ * Check if a directory is a git repository. A slow mount gets the git
14
+ * panel's 30s before it reads as "not a repository".
13
15
  */
14
16
  export async function isGitRepo(dirPath: string): Promise<boolean> {
15
- try {
16
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
17
- await execFileAsync("git", ["-C", resolvedPath, "rev-parse", "--git-dir"], {
18
- timeout: 5000,
19
- });
20
- return true;
21
- } catch {
22
- return false;
23
- }
17
+ return gitOk(expandHome(dirPath), ["rev-parse", "--git-dir"], {
18
+ timeout: 30_000,
19
+ });
24
20
  }
25
21
 
26
22
  /**
27
- * Get the current branch name
23
+ * The checked-out branch, "HEAD" when detached.
28
24
  */
29
25
  export async function getCurrentBranch(dirPath: string): Promise<string> {
30
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
31
- const { stdout } = await execFileAsync(
32
- "git",
33
- ["-C", resolvedPath, "rev-parse", "--abbrev-ref", "HEAD"],
34
- { timeout: 5000 }
26
+ return (
27
+ (await currentBranch(expandHome(dirPath), { timeout: 5000 })) || "HEAD"
35
28
  );
36
- return stdout.trim();
37
29
  }
38
30
 
39
31
  /**
40
- * Get the default branch (main or master)
32
+ * The default branch: the remote's, else main, else master, else the
33
+ * current one.
41
34
  */
42
35
  export async function getDefaultBranch(dirPath: string): Promise<string> {
43
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
44
- try {
45
- // Try to get the default branch from remote
46
- const { stdout } = await execFileAsync(
47
- "git",
48
- ["-C", resolvedPath, "symbolic-ref", "refs/remotes/origin/HEAD"],
49
- { timeout: 5000 }
50
- );
51
- const name = stdout.trim().replace(/^refs\/remotes\/origin\//, "");
52
- // The remote picks this name: anything that isn't a plain branch name
53
- // (a "$(...)" a hostile repo could set) is ignored.
54
- if (name && isBranchName(name)) {
55
- return name;
56
- }
57
- } catch {
58
- // Ignore
59
- }
60
-
61
- // Fallback: check if main or master exists
62
- try {
63
- await execFileAsync(
64
- "git",
65
- ["-C", resolvedPath, "rev-parse", "--verify", "main"],
66
- {
67
- timeout: 5000,
68
- }
69
- );
70
- return "main";
71
- } catch {
72
- try {
73
- await execFileAsync(
74
- "git",
75
- ["-C", resolvedPath, "rev-parse", "--verify", "master"],
76
- {
77
- timeout: 5000,
78
- }
79
- );
80
- return "master";
81
- } catch {
82
- // Return current branch as fallback
83
- return getCurrentBranch(resolvedPath);
84
- }
85
- }
36
+ const dir = expandHome(dirPath);
37
+ const opts = { timeout: 5000 };
38
+ const remote = await git(
39
+ dir,
40
+ ["symbolic-ref", "refs/remotes/origin/HEAD"],
41
+ opts
42
+ ).catch(() => "");
43
+ const name = remote.trim().replace(/^refs\/remotes\/origin\//, "");
44
+ // The remote picks this name: anything that isn't a plain branch name
45
+ // (a "$(...)" a hostile repo could set) is ignored.
46
+ if (name && isBranchName(name)) return name;
47
+ if (await resolveRef(dir, "main", opts)) return "main";
48
+ if (await resolveRef(dir, "master", opts)) return "master";
49
+ return getCurrentBranch(dir);
86
50
  }
87
51
 
88
52
  /**
89
53
  * Get list of local branches
90
54
  */
91
55
  export async function getBranches(dirPath: string): Promise<string[]> {
92
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
93
- const { stdout } = await execFileAsync(
94
- "git",
95
- ["-C", resolvedPath, "branch", "--format=%(refname:short)"],
56
+ const stdout = await git(
57
+ expandHome(dirPath),
58
+ ["branch", "--format=%(refname:short)"],
96
59
  { timeout: 5000 }
97
60
  );
98
61
  return stdout
@@ -108,32 +71,18 @@ export async function branchExists(
108
71
  dirPath: string,
109
72
  branchName: string
110
73
  ): Promise<boolean> {
111
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
112
- try {
113
- await execFileAsync(
114
- "git",
115
- [
116
- "-C",
117
- resolvedPath,
118
- "rev-parse",
119
- "--verify",
120
- "--end-of-options",
121
- branchName,
122
- ],
123
- { timeout: 5000 }
124
- );
125
- return true;
126
- } catch {
127
- return false;
128
- }
74
+ return gitOk(
75
+ expandHome(dirPath),
76
+ ["rev-parse", "--verify", "--end-of-options", branchName],
77
+ { timeout: 5000 }
78
+ );
129
79
  }
130
80
 
131
81
  /**
132
82
  * Get the repository name from path
133
83
  */
134
84
  export function getRepoName(dirPath: string): string {
135
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
136
- return path.basename(resolvedPath);
85
+ return path.basename(expandHome(dirPath));
137
86
  }
138
87
 
139
88
  /**
@@ -169,11 +118,10 @@ export async function remoteBranchExists(
169
118
  dirPath: string,
170
119
  branchName: string
171
120
  ): Promise<boolean> {
172
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
173
121
  try {
174
- const { stdout } = await execFileAsync(
175
- "git",
176
- ["-C", resolvedPath, "ls-remote", "--heads", "origin", "--", branchName],
122
+ const stdout = await git(
123
+ expandHome(dirPath),
124
+ ["ls-remote", "--heads", "origin", "--", branchName],
177
125
  { timeout: 10000 }
178
126
  );
179
127
  return stdout.trim().length > 0;
@@ -194,72 +142,9 @@ export async function renameBranch(
194
142
  ): Promise<void> {
195
143
  if (await remoteBranchExists(dirPath, oldBranchName))
196
144
  throw new Error(`${oldBranchName} is on the remote; keeping its name`);
197
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
198
- await execFileAsync(
199
- "git",
200
- ["-C", resolvedPath, "branch", "-m", "--", oldBranchName, newBranchName],
145
+ await git(
146
+ expandHome(dirPath),
147
+ ["branch", "-m", "--", oldBranchName, newBranchName],
201
148
  { timeout: 10000 }
202
149
  );
203
150
  }
204
-
205
- /**
206
- * Get git status summary (files changed, ahead/behind)
207
- */
208
- export async function getGitStatus(dirPath: string): Promise<{
209
- staged: number;
210
- unstaged: number;
211
- untracked: number;
212
- ahead: number;
213
- behind: number;
214
- }> {
215
- const resolvedPath = dirPath.replace(/^~/, process.env.HOME || "");
216
-
217
- // Get file counts
218
- const { stdout: statusOutput } = await execFileAsync(
219
- "git",
220
- ["-C", resolvedPath, "status", "--porcelain"],
221
- { timeout: 5000 }
222
- );
223
-
224
- const lines = statusOutput.trim().split("\n").filter(Boolean);
225
- let staged = 0;
226
- let unstaged = 0;
227
- let untracked = 0;
228
-
229
- for (const line of lines) {
230
- const index = line[0];
231
- const worktree = line[1];
232
- if (index === "?" && worktree === "?") {
233
- untracked++;
234
- } else {
235
- if (index !== " " && index !== "?") staged++;
236
- if (worktree !== " " && worktree !== "?") unstaged++;
237
- }
238
- }
239
-
240
- // Get ahead/behind counts
241
- let ahead = 0;
242
- let behind = 0;
243
- try {
244
- // No upstream throws, and counts as level.
245
- const { stdout: aheadBehind } = await execFileAsync(
246
- "git",
247
- [
248
- "-C",
249
- resolvedPath,
250
- "rev-list",
251
- "--left-right",
252
- "--count",
253
- "HEAD...@{upstream}",
254
- ],
255
- { timeout: 5000 }
256
- );
257
- const [a, b] = aheadBehind.trim().split(/\s+/);
258
- ahead = parseInt(a, 10) || 0;
259
- behind = parseInt(b, 10) || 0;
260
- } catch {
261
- // No upstream, ignore
262
- }
263
-
264
- return { staged, unstaged, untracked, ahead, behind };
265
- }
@@ -13,7 +13,7 @@ import {
13
13
  type Session,
14
14
  } from "../db";
15
15
  import { getProject } from "../projects";
16
- import { run } from "../tasks/gh";
16
+ import { git as runGit } from "../git-exec";
17
17
  import { LumifyHubError } from "./client";
18
18
  import { requireClient } from "./connection";
19
19
  import { linkedWorkspaceFor } from "./links";
@@ -33,7 +33,7 @@ function localProject(projectId: string): Project {
33
33
  }
34
34
 
35
35
  const git = (dir: string, ...args: string[]) =>
36
- run("git", args, dir).then((out) => out.trim());
36
+ runGit(dir, args).then((out) => out.trim());
37
37
 
38
38
  async function commonDir(dir: string): Promise<string | null> {
39
39
  return git(dir, "rev-parse", "--path-format=absolute", "--git-common-dir")