feat: add WSL support for repos on WSL filesystems (#375)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-04-07 21:02:36 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent beb62ff580
commit f6ec69c969
36 changed files with 1380 additions and 351 deletions
+12 -24
View File
@@ -2,7 +2,7 @@
concurrency acquire/release pattern and error handling consistent across operations. */
import type { PRInfo, PRMergeableState, PRCheckDetail, PRComment } from '../../shared/types'
import { getPRConflictSummary } from './conflict-summary'
import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
import { execFileAsync, ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
export { _resetOwnerRepoCache } from './gh-utils'
export { getIssue, listIssues } from './issues'
import {
@@ -88,8 +88,7 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
} | null = null
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
[
'pr',
'list',
@@ -104,16 +103,12 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{
cwd: repoPath,
encoding: 'utf-8'
}
{ cwd: repoPath }
)
const list = JSON.parse(stdout) as NonNullable<typeof data>[]
data = list[0] ?? null
} else {
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
@@ -121,10 +116,7 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{
cwd: repoPath,
encoding: 'utf-8'
}
{ cwd: repoPath }
)
data = JSON.parse(stdout)
}
@@ -175,14 +167,13 @@ export async function getPRChecks(
// user explicitly clicks refresh we must skip it so gh fetches fresh data.
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
try {
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
[
'api',
...cacheArgs,
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodeURIComponent(headSha)}/check-runs?per_page=100`
],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as {
check_runs: {
@@ -207,10 +198,9 @@ export async function getPRChecks(
}
}
// Fallback: no branch provided or non-GitHub remote
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
['pr', 'checks', String(prNumber), '--json', 'name,state,link'],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
return data.map((d) => ({
@@ -490,9 +480,8 @@ export async function mergePR(
// Don't use --delete-branch: it tries to delete the local branch which
// fails when the user's worktree is checked out on it. Branch cleanup
// is handled by worktree deletion (local) and GitHub's auto-delete setting (remote).
await execFileAsync('gh', ['pr', 'merge', String(prNumber), `--${method}`], {
await ghExecFileAsync(['pr', 'merge', String(prNumber), `--${method}`], {
cwd: repoPath,
encoding: 'utf-8',
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
@@ -515,9 +504,8 @@ export async function updatePRTitle(
): Promise<boolean> {
await acquire()
try {
await execFileAsync('gh', ['pr', 'edit', String(prNumber), '--title', title], {
cwd: repoPath,
encoding: 'utf-8'
await ghExecFileAsync(['pr', 'edit', String(prNumber), '--title', title], {
cwd: repoPath
})
return true
} catch (err) {
+10 -21
View File
@@ -1,8 +1,5 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import type { PRConflictSummary } from '../../shared/types'
const execFileAsync = promisify(execFile)
import { gitExecFileAsync } from '../git/runner'
export async function getPRConflictSummary(
repoPath: string,
@@ -47,9 +44,8 @@ async function resolveLatestBaseOid(
try {
// Why: cap the fetch at 10 s so slow or unreachable remotes don't block
// the conflict-summary derivation indefinitely.
await execFileAsync('git', ['fetch', '--quiet', remoteName, baseRefName], {
await gitExecFileAsync(['fetch', '--quiet', remoteName, baseRefName], {
cwd: repoPath,
encoding: 'utf-8',
timeout: 10_000
})
} catch {
@@ -60,9 +56,8 @@ async function resolveLatestBaseOid(
for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) {
try {
const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', ref], {
cwd: repoPath,
encoding: 'utf-8'
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], {
cwd: repoPath
})
const oid = stdout.trim()
if (oid) {
@@ -81,17 +76,15 @@ async function resolveMergeBase(
headOid: string,
baseOid: string
): Promise<string> {
const { stdout } = await execFileAsync('git', ['merge-base', headOid, baseOid], {
cwd: repoPath,
encoding: 'utf-8'
const { stdout } = await gitExecFileAsync(['merge-base', headOid, baseOid], {
cwd: repoPath
})
return stdout.trim()
}
async function countCommits(repoPath: string, range: string): Promise<number> {
const { stdout } = await execFileAsync('git', ['rev-list', '--count', range], {
cwd: repoPath,
encoding: 'utf-8'
const { stdout } = await gitExecFileAsync(['rev-list', '--count', range], {
cwd: repoPath
})
return Number.parseInt(stdout.trim(), 10) || 0
}
@@ -104,8 +97,7 @@ async function loadConflictingFiles(
): Promise<string[]> {
let stdout = ''
try {
const result = await execFileAsync(
'git',
const result = await gitExecFileAsync(
[
'merge-tree',
'--write-tree',
@@ -117,10 +109,7 @@ async function loadConflictingFiles(
headOid,
baseOid
],
{
cwd: repoPath,
encoding: 'utf-8'
}
{ cwd: repoPath }
)
stdout = result.stdout
} catch (error) {
+7 -3
View File
@@ -1,7 +1,12 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { gitExecFileAsync, ghExecFileAsync } from '../git/runner'
// Why: legacy generic execFile wrapper — only used by callers that don't need
// WSL-aware routing (e.g. non-repo-scoped gh commands). Repo-scoped callers
// should use ghExecFileAsync or gitExecFileAsync from the runner instead.
export const execFileAsync = promisify(execFile)
export { ghExecFileAsync, gitExecFileAsync }
// Concurrency limiter - max 4 parallel gh processes
const MAX_CONCURRENT = 4
@@ -44,9 +49,8 @@ export async function getOwnerRepo(
return ownerRepoCache.get(repoPath)!
}
try {
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
cwd: repoPath,
encoding: 'utf-8'
const { stdout } = await gitExecFileAsync(['remote', 'get-url', 'origin'], {
cwd: repoPath
})
const match = stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
if (match) {
+9 -13
View File
@@ -1,6 +1,6 @@
import type { IssueInfo } from '../../shared/types'
import { mapIssueInfo } from './mappers'
import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
import { ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
/**
* Get a single issue by number.
@@ -11,24 +11,22 @@ export async function getIssue(repoPath: string, issueNumber: number): Promise<I
await acquire()
try {
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'300s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`
],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout)
return mapIssueInfo(data)
}
// Fallback for non-GitHub remotes
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout)
return mapIssueInfo(data)
@@ -48,24 +46,22 @@ export async function listIssues(repoPath: string, limit = 20): Promise<IssueInf
await acquire()
try {
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'120s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as unknown[]
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
}
// Fallback for non-GitHub remotes
const { stdout } = await execFileAsync(
'gh',
const { stdout } = await ghExecFileAsync(
['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)],
{ cwd: repoPath, encoding: 'utf-8' }
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as unknown[]
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))