kanna-code 0.23.1 → 0.24.0
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/dist/client/assets/index-0UyEIBeu.js +2532 -0
- package/dist/client/assets/index-CYUEw41P.css +32 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/diff-store.test.ts +214 -1
- package/src/server/diff-store.ts +802 -2
- package/src/server/ws-router.test.ts +4 -2
- package/src/server/ws-router.ts +83 -2
- package/src/shared/protocol.ts +20 -0
- package/src/shared/types.ts +102 -0
- package/dist/client/assets/index-DADxOnBF.css +0 -32
- package/dist/client/assets/index-y8BVCflz.js +0 -2506
package/src/server/diff-store.ts
CHANGED
|
@@ -1,23 +1,47 @@
|
|
|
1
1
|
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
|
|
2
2
|
import { tmpdir } from "node:os"
|
|
3
3
|
import path from "node:path"
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ChatBranchHistoryEntry,
|
|
6
|
+
ChatBranchHistorySnapshot,
|
|
7
|
+
ChatBranchListEntry,
|
|
8
|
+
ChatBranchListResult,
|
|
9
|
+
ChatCheckoutBranchResult,
|
|
10
|
+
ChatCreateBranchResult,
|
|
11
|
+
ChatDiffFile,
|
|
12
|
+
ChatDiffSnapshot,
|
|
13
|
+
ChatSyncResult,
|
|
14
|
+
DiffCommitMode,
|
|
15
|
+
DiffCommitResult,
|
|
16
|
+
} from "../shared/types"
|
|
5
17
|
import { generateCommitMessageDetailed } from "./generate-commit-message"
|
|
6
18
|
import { inferProjectFileContentType } from "./uploads"
|
|
7
19
|
|
|
8
20
|
interface StoredChatDiffState {
|
|
9
21
|
status: ChatDiffSnapshot["status"]
|
|
10
22
|
branchName?: string
|
|
23
|
+
defaultBranchName?: string
|
|
24
|
+
originRepoSlug?: string
|
|
11
25
|
hasUpstream?: boolean
|
|
26
|
+
aheadCount?: number
|
|
27
|
+
behindCount?: number
|
|
28
|
+
lastFetchedAt?: string
|
|
12
29
|
files: ChatDiffFile[]
|
|
30
|
+
branchHistory: ChatBranchHistorySnapshot
|
|
13
31
|
}
|
|
14
32
|
|
|
15
33
|
function createEmptyState(): StoredChatDiffState {
|
|
16
34
|
return {
|
|
17
35
|
status: "unknown",
|
|
18
36
|
branchName: undefined,
|
|
37
|
+
defaultBranchName: undefined,
|
|
38
|
+
originRepoSlug: undefined,
|
|
19
39
|
hasUpstream: undefined,
|
|
40
|
+
aheadCount: undefined,
|
|
41
|
+
behindCount: undefined,
|
|
42
|
+
lastFetchedAt: undefined,
|
|
20
43
|
files: [],
|
|
44
|
+
branchHistory: { entries: [] },
|
|
21
45
|
}
|
|
22
46
|
}
|
|
23
47
|
|
|
@@ -27,8 +51,27 @@ function snapshotsEqual(left: StoredChatDiffState | undefined, right: StoredChat
|
|
|
27
51
|
}
|
|
28
52
|
if (left.status !== right.status) return false
|
|
29
53
|
if (left.branchName !== right.branchName) return false
|
|
54
|
+
if (left.defaultBranchName !== right.defaultBranchName) return false
|
|
55
|
+
if (left.originRepoSlug !== right.originRepoSlug) return false
|
|
30
56
|
if (left.hasUpstream !== right.hasUpstream) return false
|
|
57
|
+
if (left.aheadCount !== right.aheadCount) return false
|
|
58
|
+
if (left.behindCount !== right.behindCount) return false
|
|
59
|
+
if (left.lastFetchedAt !== right.lastFetchedAt) return false
|
|
31
60
|
if (left.files.length !== right.files.length) return false
|
|
61
|
+
if (left.branchHistory.entries.length !== right.branchHistory.entries.length) return false
|
|
62
|
+
const sameHistory = left.branchHistory.entries.every((entry, index) => {
|
|
63
|
+
const other = right.branchHistory.entries[index]
|
|
64
|
+
return Boolean(other)
|
|
65
|
+
&& entry.sha === other.sha
|
|
66
|
+
&& entry.summary === other.summary
|
|
67
|
+
&& entry.description === other.description
|
|
68
|
+
&& entry.authorName === other.authorName
|
|
69
|
+
&& entry.authoredAt === other.authoredAt
|
|
70
|
+
&& entry.githubUrl === other.githubUrl
|
|
71
|
+
&& entry.tags.length === other.tags.length
|
|
72
|
+
&& entry.tags.every((tag, tagIndex) => tag === other.tags[tagIndex])
|
|
73
|
+
})
|
|
74
|
+
if (!sameHistory) return false
|
|
32
75
|
return left.files.every((file, index) => {
|
|
33
76
|
const other = right.files[index]
|
|
34
77
|
return Boolean(other)
|
|
@@ -72,6 +115,24 @@ async function runGit(args: string[], cwd: string) {
|
|
|
72
115
|
}
|
|
73
116
|
}
|
|
74
117
|
|
|
118
|
+
async function runCommand(args: string[]) {
|
|
119
|
+
const process = Bun.spawn(args, {
|
|
120
|
+
stdout: "pipe",
|
|
121
|
+
stderr: "pipe",
|
|
122
|
+
})
|
|
123
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
124
|
+
new Response(process.stdout).text(),
|
|
125
|
+
new Response(process.stderr).text(),
|
|
126
|
+
process.exited,
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
stdout,
|
|
131
|
+
stderr,
|
|
132
|
+
exitCode,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
75
136
|
function formatGitFailure(result: Awaited<ReturnType<typeof runGit>>) {
|
|
76
137
|
return [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n")
|
|
77
138
|
}
|
|
@@ -160,6 +221,312 @@ async function hasUpstreamBranch(repoRoot: string) {
|
|
|
160
221
|
return upstream.exitCode === 0 && upstream.stdout.trim().length > 0
|
|
161
222
|
}
|
|
162
223
|
|
|
224
|
+
async function getLastFetchedAt(repoRoot: string) {
|
|
225
|
+
const gitDirResult = await runGit(["rev-parse", "--git-dir"], repoRoot)
|
|
226
|
+
if (gitDirResult.exitCode !== 0) {
|
|
227
|
+
return undefined
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const gitDir = gitDirResult.stdout.trim()
|
|
231
|
+
const fetchHeadPath = path.resolve(repoRoot, gitDir, "FETCH_HEAD")
|
|
232
|
+
try {
|
|
233
|
+
const fetchHeadStat = await stat(fetchHeadPath)
|
|
234
|
+
return fetchHeadStat.mtime.toISOString()
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function getUpstreamStatusCounts(repoRoot: string) {
|
|
241
|
+
const result = await runGit(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], repoRoot)
|
|
242
|
+
if (result.exitCode !== 0) {
|
|
243
|
+
return { aheadCount: undefined, behindCount: undefined }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const [aheadRaw, behindRaw] = result.stdout.trim().split(/\s+/u)
|
|
247
|
+
const aheadCount = Number.parseInt(aheadRaw ?? "", 10)
|
|
248
|
+
const behindCount = Number.parseInt(behindRaw ?? "", 10)
|
|
249
|
+
return {
|
|
250
|
+
aheadCount: Number.isFinite(aheadCount) ? aheadCount : undefined,
|
|
251
|
+
behindCount: Number.isFinite(behindCount) ? behindCount : undefined,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function getOriginRemoteUrl(repoRoot: string) {
|
|
256
|
+
const result = await runGit(["remote", "get-url", "origin"], repoRoot)
|
|
257
|
+
if (result.exitCode !== 0) {
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
const remoteUrl = result.stdout.trim()
|
|
261
|
+
return remoteUrl.length > 0 ? remoteUrl : null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function getGitHubRemoteSlugs(repoRoot: string) {
|
|
265
|
+
const remotesResult = await runGit(["remote"], repoRoot)
|
|
266
|
+
if (remotesResult.exitCode !== 0) {
|
|
267
|
+
return new Map<string, string>()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const remoteNames = remotesResult.stdout
|
|
271
|
+
.split(/\r?\n/u)
|
|
272
|
+
.map((line) => line.trim())
|
|
273
|
+
.filter(Boolean)
|
|
274
|
+
|
|
275
|
+
const remoteSlugEntries = await Promise.all(remoteNames.map(async (remoteName) => {
|
|
276
|
+
const remoteUrlResult = await runGit(["remote", "get-url", remoteName], repoRoot)
|
|
277
|
+
if (remoteUrlResult.exitCode !== 0) {
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
const repoSlug = extractGitHubRepoSlug(remoteUrlResult.stdout.trim())
|
|
281
|
+
return repoSlug ? [remoteName, repoSlug.toLowerCase()] as const : null
|
|
282
|
+
}))
|
|
283
|
+
|
|
284
|
+
return new Map(remoteSlugEntries.filter((entry): entry is readonly [string, string] => Boolean(entry)))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function getLocalBranchNames(repoRoot: string) {
|
|
288
|
+
const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], repoRoot)
|
|
289
|
+
if (result.exitCode !== 0) {
|
|
290
|
+
throw new Error(result.stderr.trim() || "Failed to list local branches")
|
|
291
|
+
}
|
|
292
|
+
return result.stdout
|
|
293
|
+
.split(/\r?\n/u)
|
|
294
|
+
.map((line) => line.trim())
|
|
295
|
+
.filter(Boolean)
|
|
296
|
+
.sort((left, right) => left.localeCompare(right))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function getRemoteBranchNames(repoRoot: string) {
|
|
300
|
+
const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/remotes"], repoRoot)
|
|
301
|
+
if (result.exitCode !== 0) {
|
|
302
|
+
throw new Error(result.stderr.trim() || "Failed to list remote branches")
|
|
303
|
+
}
|
|
304
|
+
return result.stdout
|
|
305
|
+
.split(/\r?\n/u)
|
|
306
|
+
.map((line) => line.trim())
|
|
307
|
+
.filter((line) => line.length > 0 && !line.endsWith("/HEAD"))
|
|
308
|
+
.sort((left, right) => left.localeCompare(right))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function getBranchUpdatedAtMap(repoRoot: string, refPrefix: "refs/heads" | "refs/remotes") {
|
|
312
|
+
const result = await runGit(
|
|
313
|
+
["for-each-ref", "--format=%(refname:short)\t%(committerdate:iso-strict)", refPrefix],
|
|
314
|
+
repoRoot
|
|
315
|
+
)
|
|
316
|
+
if (result.exitCode !== 0) {
|
|
317
|
+
throw new Error(result.stderr.trim() || "Failed to read branch update times")
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const entries = new Map<string, string>()
|
|
321
|
+
for (const line of result.stdout.split(/\r?\n/u)) {
|
|
322
|
+
const trimmed = line.trim()
|
|
323
|
+
if (!trimmed) continue
|
|
324
|
+
const [name, updatedAt] = trimmed.split("\t")
|
|
325
|
+
if (!name || !updatedAt || (refPrefix === "refs/remotes" && name.endsWith("/HEAD"))) {
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
entries.set(name, updatedAt)
|
|
329
|
+
}
|
|
330
|
+
return entries
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function resolveDefaultBranchName(repoRoot: string) {
|
|
334
|
+
const originHead = await runGit(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], repoRoot)
|
|
335
|
+
if (originHead.exitCode === 0) {
|
|
336
|
+
const ref = originHead.stdout.trim()
|
|
337
|
+
if (ref.startsWith("origin/")) {
|
|
338
|
+
return ref.slice("origin/".length)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const localBranches = await getLocalBranchNames(repoRoot)
|
|
343
|
+
if (localBranches.includes("main")) return "main"
|
|
344
|
+
if (localBranches.includes("master")) return "master"
|
|
345
|
+
return (await getBranchName(repoRoot)) ?? localBranches[0] ?? undefined
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function getRecentBranchNames(repoRoot: string) {
|
|
349
|
+
const result = await runGit(["reflog", "--format=%gs", "--max-count=100", "HEAD"], repoRoot)
|
|
350
|
+
if (result.exitCode !== 0) {
|
|
351
|
+
return []
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const recent: string[] = []
|
|
355
|
+
const seen = new Set<string>()
|
|
356
|
+
for (const line of result.stdout.split(/\r?\n/u)) {
|
|
357
|
+
const match = /checkout: moving from .* to (?<branch>.+)$/u.exec(line.trim())
|
|
358
|
+
const branch = match?.groups?.branch?.trim()
|
|
359
|
+
if (!branch || branch === "HEAD" || branch.startsWith("refs/")) {
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
if (seen.has(branch)) continue
|
|
363
|
+
seen.add(branch)
|
|
364
|
+
recent.push(branch)
|
|
365
|
+
}
|
|
366
|
+
return recent
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function extractGitHubRepoSlug(remoteUrl: string | null | undefined) {
|
|
370
|
+
if (!remoteUrl) return null
|
|
371
|
+
|
|
372
|
+
const sshMatch = /^git@github\.com:(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
|
|
373
|
+
if (sshMatch?.groups?.owner && sshMatch.groups.repo) {
|
|
374
|
+
return `${sshMatch.groups.owner}/${sshMatch.groups.repo}`
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const sshProtocolMatch = /^ssh:\/\/git@github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
|
|
378
|
+
if (sshProtocolMatch?.groups?.owner && sshProtocolMatch.groups.repo) {
|
|
379
|
+
return `${sshProtocolMatch.groups.owner}/${sshProtocolMatch.groups.repo}`
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const httpsMatch = /^https?:\/\/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
|
|
383
|
+
if (httpsMatch?.groups?.owner && httpsMatch.groups.repo) {
|
|
384
|
+
return `${httpsMatch.groups.owner}/${httpsMatch.groups.repo}`
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return null
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
interface GitHubPullRequestResponseItem {
|
|
391
|
+
number: number
|
|
392
|
+
title: string
|
|
393
|
+
head?: {
|
|
394
|
+
ref?: string
|
|
395
|
+
label?: string
|
|
396
|
+
repo?: {
|
|
397
|
+
clone_url?: string
|
|
398
|
+
full_name?: string
|
|
399
|
+
} | null
|
|
400
|
+
}
|
|
401
|
+
base?: {
|
|
402
|
+
ref?: string
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
|
|
407
|
+
|
|
408
|
+
type GitHubCliApiLike = (path: string) => Promise<GitHubPullRequestResponseItem[] | null>
|
|
409
|
+
|
|
410
|
+
interface FetchGitHubPullRequestsDeps {
|
|
411
|
+
fetchImpl?: FetchLike
|
|
412
|
+
ghApiImpl?: GitHubCliApiLike
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function fetchGitHubPullRequestsViaGh(path: string): Promise<GitHubPullRequestResponseItem[] | null> {
|
|
416
|
+
const result = await runCommand([
|
|
417
|
+
"gh",
|
|
418
|
+
"api",
|
|
419
|
+
"-H",
|
|
420
|
+
"Accept: application/vnd.github+json",
|
|
421
|
+
path,
|
|
422
|
+
])
|
|
423
|
+
if (result.exitCode !== 0) {
|
|
424
|
+
return null
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const json = JSON.parse(result.stdout)
|
|
428
|
+
return Array.isArray(json) ? json as GitHubPullRequestResponseItem[] : []
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function fetchGitHubPullRequests(
|
|
432
|
+
repoSlug: string,
|
|
433
|
+
deps: FetchLike | FetchGitHubPullRequestsDeps = fetch
|
|
434
|
+
): Promise<GitHubPullRequestResponseItem[]> {
|
|
435
|
+
const fetchImpl = typeof deps === "function" ? deps : (deps.fetchImpl ?? fetch)
|
|
436
|
+
const ghApiImpl = typeof deps === "function" ? fetchGitHubPullRequestsViaGh : (deps.ghApiImpl ?? fetchGitHubPullRequestsViaGh)
|
|
437
|
+
const ghPath = `repos/${repoSlug}/pulls?state=open&per_page=50`
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const ghPulls = await ghApiImpl(ghPath)
|
|
441
|
+
if (ghPulls) {
|
|
442
|
+
return ghPulls
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// Fall back to an unauthenticated HTTP request when `gh` is unavailable.
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const response = await fetchImpl(`https://api.github.com/repos/${repoSlug}/pulls?state=open&per_page=50`, {
|
|
449
|
+
headers: {
|
|
450
|
+
Accept: "application/vnd.github+json",
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
if (!response.ok) {
|
|
455
|
+
throw new Error(`GitHub pull requests request failed with status ${response.status}`)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const json = await response.json()
|
|
459
|
+
return Array.isArray(json) ? json as GitHubPullRequestResponseItem[] : []
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function buildGitHubCommitUrl(remoteUrl: string | null, sha: string) {
|
|
463
|
+
const slug = extractGitHubRepoSlug(remoteUrl)
|
|
464
|
+
return slug ? `https://github.com/${slug}/commit/${sha}` : undefined
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function listCommitTags(repoRoot: string, sha: string) {
|
|
468
|
+
const result = await runGit(["tag", "--points-at", sha], repoRoot)
|
|
469
|
+
if (result.exitCode !== 0) {
|
|
470
|
+
return []
|
|
471
|
+
}
|
|
472
|
+
return result.stdout
|
|
473
|
+
.split(/\r?\n/u)
|
|
474
|
+
.map((line) => line.trim())
|
|
475
|
+
.filter(Boolean)
|
|
476
|
+
.sort((left, right) => left.localeCompare(right))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function getBranchHistory(args: {
|
|
480
|
+
repoRoot: string
|
|
481
|
+
ref: string
|
|
482
|
+
limit: number
|
|
483
|
+
}): Promise<ChatBranchHistorySnapshot> {
|
|
484
|
+
const logResult = await runGit(
|
|
485
|
+
[
|
|
486
|
+
"log",
|
|
487
|
+
"--max-count",
|
|
488
|
+
String(args.limit),
|
|
489
|
+
"--pretty=format:%H%x1f%s%x1f%b%x1f%an%x1f%aI%x1e",
|
|
490
|
+
args.ref,
|
|
491
|
+
],
|
|
492
|
+
args.repoRoot
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if (logResult.exitCode !== 0) {
|
|
496
|
+
throw new Error(logResult.stderr.trim() || "Failed to read git log")
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const remoteUrl = await getOriginRemoteUrl(args.repoRoot)
|
|
500
|
+
const entries: ChatBranchHistoryEntry[] = []
|
|
501
|
+
|
|
502
|
+
for (const record of logResult.stdout.split("\u001e")) {
|
|
503
|
+
const trimmed = record.trim()
|
|
504
|
+
if (!trimmed) continue
|
|
505
|
+
const [sha, summary, description, authorName, authoredAt] = trimmed.split("\u001f")
|
|
506
|
+
if (!sha || !summary || !authoredAt) continue
|
|
507
|
+
entries.push({
|
|
508
|
+
sha,
|
|
509
|
+
summary,
|
|
510
|
+
description: (description ?? "").trim(),
|
|
511
|
+
authorName: authorName?.trim() || undefined,
|
|
512
|
+
authoredAt,
|
|
513
|
+
tags: await listCommitTags(args.repoRoot, sha),
|
|
514
|
+
githubUrl: buildGitHubCommitUrl(remoteUrl, sha),
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return { entries }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function createBranchActionFailure(title: string, detail: string, fallback: string) {
|
|
522
|
+
return {
|
|
523
|
+
ok: false,
|
|
524
|
+
title,
|
|
525
|
+
message: summarizeGitFailure(detail, fallback),
|
|
526
|
+
detail,
|
|
527
|
+
} as const
|
|
528
|
+
}
|
|
529
|
+
|
|
163
530
|
function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
164
531
|
const entries: DirtyPathEntry[] = []
|
|
165
532
|
for (const rawLine of output.split(/\r?\n/u)) {
|
|
@@ -381,8 +748,19 @@ export class DiffStore {
|
|
|
381
748
|
return {
|
|
382
749
|
status: state.status,
|
|
383
750
|
branchName: state.branchName,
|
|
751
|
+
defaultBranchName: state.defaultBranchName,
|
|
752
|
+
originRepoSlug: state.originRepoSlug,
|
|
384
753
|
hasUpstream: state.hasUpstream,
|
|
754
|
+
aheadCount: state.aheadCount,
|
|
755
|
+
behindCount: state.behindCount,
|
|
756
|
+
lastFetchedAt: state.lastFetchedAt,
|
|
385
757
|
files: [...state.files],
|
|
758
|
+
branchHistory: {
|
|
759
|
+
entries: state.branchHistory.entries.map((entry) => ({
|
|
760
|
+
...entry,
|
|
761
|
+
tags: [...entry.tags],
|
|
762
|
+
})),
|
|
763
|
+
},
|
|
386
764
|
}
|
|
387
765
|
}
|
|
388
766
|
|
|
@@ -392,8 +770,14 @@ export class DiffStore {
|
|
|
392
770
|
const nextState = {
|
|
393
771
|
status: "no_repo",
|
|
394
772
|
branchName: undefined,
|
|
773
|
+
defaultBranchName: undefined,
|
|
774
|
+
originRepoSlug: undefined,
|
|
395
775
|
hasUpstream: undefined,
|
|
776
|
+
aheadCount: undefined,
|
|
777
|
+
behindCount: undefined,
|
|
778
|
+
lastFetchedAt: undefined,
|
|
396
779
|
files: [],
|
|
780
|
+
branchHistory: { entries: [] },
|
|
397
781
|
} satisfies StoredChatDiffState
|
|
398
782
|
const changed = !snapshotsEqual(this.states.get(chatId), nextState)
|
|
399
783
|
this.states.set(chatId, nextState)
|
|
@@ -402,18 +786,432 @@ export class DiffStore {
|
|
|
402
786
|
|
|
403
787
|
const files = await computeCurrentFiles(repo.repoRoot, repo.baseCommit)
|
|
404
788
|
const branchName = await getBranchName(repo.repoRoot)
|
|
789
|
+
const defaultBranchName = await resolveDefaultBranchName(repo.repoRoot)
|
|
790
|
+
const originRemoteUrl = await getOriginRemoteUrl(repo.repoRoot)
|
|
791
|
+
const originRepoSlug = extractGitHubRepoSlug(originRemoteUrl) ?? undefined
|
|
405
792
|
const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
793
|
+
const { aheadCount, behindCount } = hasUpstream
|
|
794
|
+
? await getUpstreamStatusCounts(repo.repoRoot)
|
|
795
|
+
: { aheadCount: undefined, behindCount: undefined }
|
|
796
|
+
const lastFetchedAt = await getLastFetchedAt(repo.repoRoot)
|
|
797
|
+
const branchHistory = repo.baseCommit
|
|
798
|
+
? await getBranchHistory({
|
|
799
|
+
repoRoot: repo.repoRoot,
|
|
800
|
+
ref: branchName ?? "HEAD",
|
|
801
|
+
limit: 20,
|
|
802
|
+
})
|
|
803
|
+
: { entries: [] }
|
|
406
804
|
const nextState = {
|
|
407
805
|
status: "ready",
|
|
408
806
|
branchName,
|
|
807
|
+
defaultBranchName,
|
|
808
|
+
originRepoSlug,
|
|
409
809
|
hasUpstream,
|
|
810
|
+
aheadCount,
|
|
811
|
+
behindCount,
|
|
812
|
+
lastFetchedAt,
|
|
410
813
|
files,
|
|
814
|
+
branchHistory,
|
|
411
815
|
} satisfies StoredChatDiffState
|
|
412
816
|
const changed = !snapshotsEqual(this.states.get(chatId), nextState)
|
|
413
817
|
this.states.set(chatId, nextState)
|
|
414
818
|
return changed
|
|
415
819
|
}
|
|
416
820
|
|
|
821
|
+
async listBranches(args: {
|
|
822
|
+
projectPath: string
|
|
823
|
+
}): Promise<ChatBranchListResult> {
|
|
824
|
+
const repo = await resolveRepo(args.projectPath)
|
|
825
|
+
if (!repo) {
|
|
826
|
+
throw new Error("Project is not in a git repository")
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const [currentBranchName, defaultBranchName, localBranchNames, remoteBranchNames, recentBranchNames, localUpdatedAtMap, remoteUpdatedAtMap] = await Promise.all([
|
|
830
|
+
getBranchName(repo.repoRoot),
|
|
831
|
+
resolveDefaultBranchName(repo.repoRoot),
|
|
832
|
+
getLocalBranchNames(repo.repoRoot),
|
|
833
|
+
getRemoteBranchNames(repo.repoRoot),
|
|
834
|
+
getRecentBranchNames(repo.repoRoot),
|
|
835
|
+
getBranchUpdatedAtMap(repo.repoRoot, "refs/heads"),
|
|
836
|
+
getBranchUpdatedAtMap(repo.repoRoot, "refs/remotes"),
|
|
837
|
+
])
|
|
838
|
+
|
|
839
|
+
const local = localBranchNames.map((name) => ({
|
|
840
|
+
id: `local:${name}`,
|
|
841
|
+
kind: "local",
|
|
842
|
+
name,
|
|
843
|
+
displayName: name,
|
|
844
|
+
updatedAt: localUpdatedAtMap.get(name),
|
|
845
|
+
} satisfies ChatBranchListEntry))
|
|
846
|
+
|
|
847
|
+
const remote = remoteBranchNames.map((remoteRef) => ({
|
|
848
|
+
id: `remote:${remoteRef}`,
|
|
849
|
+
kind: "remote",
|
|
850
|
+
name: remoteRef.replace(/^[^/]+\//u, ""),
|
|
851
|
+
displayName: remoteRef,
|
|
852
|
+
updatedAt: remoteUpdatedAtMap.get(remoteRef),
|
|
853
|
+
remoteRef,
|
|
854
|
+
} satisfies ChatBranchListEntry))
|
|
855
|
+
|
|
856
|
+
const localBranchSet = new Set(localBranchNames)
|
|
857
|
+
const remoteByName = new Map(remote.map((entry) => [entry.name, entry]))
|
|
858
|
+
const remoteEntriesByName = new Map<string, ChatBranchListEntry[]>()
|
|
859
|
+
for (const entry of remote) {
|
|
860
|
+
const entries = remoteEntriesByName.get(entry.name) ?? []
|
|
861
|
+
entries.push(entry)
|
|
862
|
+
remoteEntriesByName.set(entry.name, entries)
|
|
863
|
+
}
|
|
864
|
+
const recent: ChatBranchListEntry[] = recentBranchNames.flatMap<ChatBranchListEntry>((branchName) => {
|
|
865
|
+
if (localBranchSet.has(branchName)) {
|
|
866
|
+
return {
|
|
867
|
+
id: `recent:local:${branchName}`,
|
|
868
|
+
kind: "local",
|
|
869
|
+
name: branchName,
|
|
870
|
+
displayName: branchName,
|
|
871
|
+
updatedAt: localUpdatedAtMap.get(branchName),
|
|
872
|
+
} satisfies ChatBranchListEntry
|
|
873
|
+
}
|
|
874
|
+
const remoteEntry = remoteByName.get(branchName)
|
|
875
|
+
return remoteEntry
|
|
876
|
+
? {
|
|
877
|
+
...remoteEntry,
|
|
878
|
+
id: `recent:${remoteEntry.id}`,
|
|
879
|
+
} satisfies ChatBranchListEntry
|
|
880
|
+
: []
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
const [remoteUrl, githubRemoteSlugs] = await Promise.all([
|
|
884
|
+
getOriginRemoteUrl(repo.repoRoot),
|
|
885
|
+
getGitHubRemoteSlugs(repo.repoRoot),
|
|
886
|
+
])
|
|
887
|
+
const repoSlug = extractGitHubRepoSlug(remoteUrl)
|
|
888
|
+
let pullRequests: ChatBranchListEntry[] = []
|
|
889
|
+
const pullRequestRemoteRefs = new Set<string>()
|
|
890
|
+
const pullRequestHeadNames = new Set<string>()
|
|
891
|
+
let pullRequestsStatus: ChatBranchListResult["pullRequestsStatus"] = "unavailable"
|
|
892
|
+
let pullRequestsError: string | undefined
|
|
893
|
+
|
|
894
|
+
if (repoSlug) {
|
|
895
|
+
try {
|
|
896
|
+
const prs = await fetchGitHubPullRequests(repoSlug)
|
|
897
|
+
pullRequests = prs.flatMap<ChatBranchListEntry>((pr) => {
|
|
898
|
+
const headRefName = pr.head?.ref?.trim()
|
|
899
|
+
if (!headRefName) return []
|
|
900
|
+
pullRequestHeadNames.add(headRefName)
|
|
901
|
+
const cloneUrl = pr.head?.repo?.clone_url?.trim() || undefined
|
|
902
|
+
const fullName = pr.head?.repo?.full_name?.trim() || undefined
|
|
903
|
+
const headRepoSlug = fullName?.toLowerCase()
|
|
904
|
+
const matchingRemoteEntries = (remoteEntriesByName.get(headRefName) ?? []).filter((entry) => {
|
|
905
|
+
const remoteName = entry.remoteRef?.split("/")[0]
|
|
906
|
+
if (!remoteName) return false
|
|
907
|
+
const remoteSlug = githubRemoteSlugs.get(remoteName)
|
|
908
|
+
if (!remoteSlug) return false
|
|
909
|
+
if (headRepoSlug) {
|
|
910
|
+
return remoteSlug === headRepoSlug
|
|
911
|
+
}
|
|
912
|
+
return remoteName === "origin"
|
|
913
|
+
})
|
|
914
|
+
for (const entry of matchingRemoteEntries) {
|
|
915
|
+
if (entry.remoteRef) {
|
|
916
|
+
pullRequestRemoteRefs.add(entry.remoteRef)
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const preferredRemoteEntry = matchingRemoteEntries[0] ?? remoteByName.get(headRefName)
|
|
920
|
+
const remoteRef = preferredRemoteEntry?.remoteRef ?? `origin/${headRefName}`
|
|
921
|
+
return {
|
|
922
|
+
id: `pr:${pr.number}`,
|
|
923
|
+
kind: "pull_request",
|
|
924
|
+
name: headRefName,
|
|
925
|
+
displayName: `PR #${pr.number}`,
|
|
926
|
+
updatedAt: (remoteRef ? remoteUpdatedAtMap.get(remoteRef) : undefined) ?? localUpdatedAtMap.get(headRefName),
|
|
927
|
+
description: pr.title,
|
|
928
|
+
remoteRef,
|
|
929
|
+
prNumber: pr.number,
|
|
930
|
+
prTitle: pr.title,
|
|
931
|
+
headRefName,
|
|
932
|
+
headLabel: pr.head?.label?.trim() || fullName,
|
|
933
|
+
headRepoCloneUrl: cloneUrl,
|
|
934
|
+
isCrossRepository: Boolean(fullName && fullName.toLowerCase() !== repoSlug.toLowerCase()),
|
|
935
|
+
} satisfies ChatBranchListEntry
|
|
936
|
+
})
|
|
937
|
+
pullRequestsStatus = "available"
|
|
938
|
+
} catch (error) {
|
|
939
|
+
pullRequestsStatus = "error"
|
|
940
|
+
pullRequestsError = error instanceof Error ? error.message : String(error)
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const visibleRemote = remote.filter((entry) => {
|
|
945
|
+
if (pullRequestHeadNames.has(entry.name)) {
|
|
946
|
+
return false
|
|
947
|
+
}
|
|
948
|
+
return !entry.remoteRef || !pullRequestRemoteRefs.has(entry.remoteRef)
|
|
949
|
+
})
|
|
950
|
+
const visibleRemoteByName = new Map(visibleRemote.map((entry) => [entry.name, entry]))
|
|
951
|
+
const visibleRecent = recent.filter((entry) => entry.kind !== "remote" || !entry.remoteRef || visibleRemoteByName.has(entry.name))
|
|
952
|
+
|
|
953
|
+
return {
|
|
954
|
+
currentBranchName,
|
|
955
|
+
defaultBranchName,
|
|
956
|
+
recent: visibleRecent,
|
|
957
|
+
local,
|
|
958
|
+
remote: visibleRemote,
|
|
959
|
+
pullRequests,
|
|
960
|
+
pullRequestsStatus,
|
|
961
|
+
pullRequestsError,
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async checkoutBranch(args: {
|
|
966
|
+
chatId: string
|
|
967
|
+
projectPath: string
|
|
968
|
+
branch:
|
|
969
|
+
| { kind: "local"; name: string }
|
|
970
|
+
| { kind: "remote"; name: string; remoteRef: string }
|
|
971
|
+
| {
|
|
972
|
+
kind: "pull_request"
|
|
973
|
+
name: string
|
|
974
|
+
prNumber: number
|
|
975
|
+
headRefName: string
|
|
976
|
+
headRepoCloneUrl?: string
|
|
977
|
+
isCrossRepository?: boolean
|
|
978
|
+
remoteRef?: string
|
|
979
|
+
}
|
|
980
|
+
bringChanges?: boolean
|
|
981
|
+
}): Promise<ChatCheckoutBranchResult> {
|
|
982
|
+
const repo = await resolveRepo(args.projectPath)
|
|
983
|
+
if (!repo) {
|
|
984
|
+
throw new Error("Project is not in a git repository")
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const currentDirtyPaths = await listDirtyPaths(repo.repoRoot)
|
|
988
|
+
if (currentDirtyPaths.length > 0 && !args.bringChanges) {
|
|
989
|
+
return {
|
|
990
|
+
ok: false,
|
|
991
|
+
cancelled: true,
|
|
992
|
+
title: "Branch switch cancelled",
|
|
993
|
+
message: "Your current changes were kept on the current branch.",
|
|
994
|
+
snapshotChanged: false,
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
let switchResult: Awaited<ReturnType<typeof runGit>>
|
|
999
|
+
if (args.branch.kind === "local") {
|
|
1000
|
+
switchResult = await runGit(["switch", args.branch.name], repo.repoRoot)
|
|
1001
|
+
} else if (args.branch.kind === "remote") {
|
|
1002
|
+
const localBranchNames = await getLocalBranchNames(repo.repoRoot)
|
|
1003
|
+
if (localBranchNames.includes(args.branch.name)) {
|
|
1004
|
+
switchResult = await runGit(["switch", args.branch.name], repo.repoRoot)
|
|
1005
|
+
} else {
|
|
1006
|
+
switchResult = await runGit(["switch", "--track", "--no-guess", args.branch.remoteRef], repo.repoRoot)
|
|
1007
|
+
}
|
|
1008
|
+
} else {
|
|
1009
|
+
const localBranchNames = await getLocalBranchNames(repo.repoRoot)
|
|
1010
|
+
let localBranchName = args.branch.name
|
|
1011
|
+
|
|
1012
|
+
if (localBranchNames.includes(localBranchName) && args.branch.isCrossRepository) {
|
|
1013
|
+
localBranchName = `${args.branch.name}-pr-${args.branch.prNumber}`
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (localBranchNames.includes(localBranchName)) {
|
|
1017
|
+
switchResult = await runGit(["switch", localBranchName], repo.repoRoot)
|
|
1018
|
+
} else if (args.branch.isCrossRepository && args.branch.headRepoCloneUrl) {
|
|
1019
|
+
const fetchResult = await runGit(
|
|
1020
|
+
[
|
|
1021
|
+
"fetch",
|
|
1022
|
+
"--no-tags",
|
|
1023
|
+
args.branch.headRepoCloneUrl,
|
|
1024
|
+
`refs/heads/${args.branch.headRefName}:refs/heads/${localBranchName}`,
|
|
1025
|
+
],
|
|
1026
|
+
repo.repoRoot
|
|
1027
|
+
)
|
|
1028
|
+
if (fetchResult.exitCode !== 0) {
|
|
1029
|
+
return createBranchActionFailure("Checkout failed", formatGitFailure(fetchResult), "Git could not fetch the pull request branch.")
|
|
1030
|
+
}
|
|
1031
|
+
switchResult = await runGit(["switch", localBranchName], repo.repoRoot)
|
|
1032
|
+
} else {
|
|
1033
|
+
const remoteRef = args.branch.remoteRef ?? `origin/${args.branch.headRefName}`
|
|
1034
|
+
const remoteBranchNames = await getRemoteBranchNames(repo.repoRoot)
|
|
1035
|
+
if (!remoteBranchNames.includes(remoteRef)) {
|
|
1036
|
+
const fetchResult = await runGit(
|
|
1037
|
+
["fetch", "--no-tags", "origin", `refs/heads/${args.branch.headRefName}:refs/remotes/${remoteRef}`],
|
|
1038
|
+
repo.repoRoot
|
|
1039
|
+
)
|
|
1040
|
+
if (fetchResult.exitCode !== 0) {
|
|
1041
|
+
return createBranchActionFailure("Checkout failed", formatGitFailure(fetchResult), "Git could not fetch the pull request branch.")
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
switchResult = await runGit(["switch", "--track", "--no-guess", remoteRef], repo.repoRoot)
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if (switchResult.exitCode !== 0) {
|
|
1049
|
+
return createBranchActionFailure("Checkout failed", formatGitFailure(switchResult), "Git could not switch branches.")
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
|
|
1053
|
+
return {
|
|
1054
|
+
ok: true,
|
|
1055
|
+
branchName: await getBranchName(repo.repoRoot),
|
|
1056
|
+
snapshotChanged,
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async createBranch(args: {
|
|
1061
|
+
chatId: string
|
|
1062
|
+
projectPath: string
|
|
1063
|
+
name: string
|
|
1064
|
+
baseBranchName?: string
|
|
1065
|
+
}): Promise<ChatCreateBranchResult> {
|
|
1066
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1067
|
+
if (!repo) {
|
|
1068
|
+
throw new Error("Project is not in a git repository")
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const branchName = args.name.trim()
|
|
1072
|
+
if (!branchName) {
|
|
1073
|
+
throw new Error("Branch name is required")
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const refValidation = await runGit(["check-ref-format", "--branch", branchName], repo.repoRoot)
|
|
1077
|
+
if (refValidation.exitCode !== 0) {
|
|
1078
|
+
return createBranchActionFailure("Create branch failed", formatGitFailure(refValidation), "Branch name is not valid.")
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const localBranchNames = await getLocalBranchNames(repo.repoRoot)
|
|
1082
|
+
if (localBranchNames.includes(branchName)) {
|
|
1083
|
+
return {
|
|
1084
|
+
ok: false,
|
|
1085
|
+
title: "Create branch failed",
|
|
1086
|
+
message: `A local branch named "${branchName}" already exists.`,
|
|
1087
|
+
snapshotChanged: false,
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const baseBranchName = args.baseBranchName?.trim() || await resolveDefaultBranchName(repo.repoRoot) || await getBranchName(repo.repoRoot)
|
|
1092
|
+
if (!baseBranchName) {
|
|
1093
|
+
throw new Error("Could not determine a base branch")
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const switchResult = await runGit(["switch", "-c", branchName, baseBranchName], repo.repoRoot)
|
|
1097
|
+
if (switchResult.exitCode !== 0) {
|
|
1098
|
+
return createBranchActionFailure("Create branch failed", formatGitFailure(switchResult), "Git could not create the branch.")
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
|
|
1102
|
+
return {
|
|
1103
|
+
ok: true,
|
|
1104
|
+
branchName,
|
|
1105
|
+
snapshotChanged,
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async syncBranch(args: {
|
|
1110
|
+
chatId: string
|
|
1111
|
+
projectPath: string
|
|
1112
|
+
action: "fetch" | "pull" | "publish"
|
|
1113
|
+
}): Promise<ChatSyncResult> {
|
|
1114
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1115
|
+
if (!repo) {
|
|
1116
|
+
throw new Error("Project is not in a git repository")
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1120
|
+
if (args.action === "publish") {
|
|
1121
|
+
const publishResult = await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
|
|
1122
|
+
if (publishResult.exitCode !== 0) {
|
|
1123
|
+
const detail = formatGitFailure(publishResult)
|
|
1124
|
+
const normalized = detail.toLowerCase()
|
|
1125
|
+
let title = "Publish branch failed"
|
|
1126
|
+
let message = summarizeGitFailure(detail, "Git could not publish this branch.")
|
|
1127
|
+
|
|
1128
|
+
if (normalized.includes("could not read from remote repository") || normalized.includes("authentication failed") || normalized.includes("permission denied")) {
|
|
1129
|
+
title = "Remote authentication failed"
|
|
1130
|
+
message = "Git could not authenticate with the remote repository."
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
return {
|
|
1134
|
+
ok: false,
|
|
1135
|
+
action: args.action,
|
|
1136
|
+
title,
|
|
1137
|
+
message,
|
|
1138
|
+
detail,
|
|
1139
|
+
snapshotChanged: false,
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
|
|
1144
|
+
const branchName = await getBranchName(repo.repoRoot)
|
|
1145
|
+
const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1146
|
+
const { aheadCount, behindCount } = nextHasUpstream
|
|
1147
|
+
? await getUpstreamStatusCounts(repo.repoRoot)
|
|
1148
|
+
: { aheadCount: undefined, behindCount: undefined }
|
|
1149
|
+
|
|
1150
|
+
return {
|
|
1151
|
+
ok: true,
|
|
1152
|
+
action: args.action,
|
|
1153
|
+
branchName,
|
|
1154
|
+
aheadCount,
|
|
1155
|
+
behindCount,
|
|
1156
|
+
snapshotChanged,
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (args.action === "pull" && !hasUpstream) {
|
|
1161
|
+
return {
|
|
1162
|
+
ok: false,
|
|
1163
|
+
action: args.action,
|
|
1164
|
+
title: "Pull failed",
|
|
1165
|
+
message: "This branch does not have an upstream remote branch configured yet.",
|
|
1166
|
+
snapshotChanged: false,
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const syncResult = args.action === "pull"
|
|
1171
|
+
? await runGit(["pull", "--ff-only"], repo.repoRoot)
|
|
1172
|
+
: await runGit(["fetch", "--all", "--prune"], repo.repoRoot)
|
|
1173
|
+
|
|
1174
|
+
if (syncResult.exitCode !== 0) {
|
|
1175
|
+
const detail = formatGitFailure(syncResult)
|
|
1176
|
+
const normalized = detail.toLowerCase()
|
|
1177
|
+
let title = args.action === "pull" ? "Pull failed" : "Fetch failed"
|
|
1178
|
+
let message = summarizeGitFailure(detail, args.action === "pull" ? "Git could not pull the latest changes." : "Git could not fetch the latest changes.")
|
|
1179
|
+
|
|
1180
|
+
if (args.action === "pull" && normalized.includes("not possible to fast-forward")) {
|
|
1181
|
+
title = "Pull requires merge or rebase"
|
|
1182
|
+
message = "Your branch cannot be fast-forwarded. Rebase or merge manually, then try again."
|
|
1183
|
+
} else if (normalized.includes("could not read from remote repository") || normalized.includes("authentication failed") || normalized.includes("permission denied")) {
|
|
1184
|
+
title = "Remote authentication failed"
|
|
1185
|
+
message = "Git could not authenticate with the remote repository."
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
return {
|
|
1189
|
+
ok: false,
|
|
1190
|
+
action: args.action,
|
|
1191
|
+
title,
|
|
1192
|
+
message,
|
|
1193
|
+
detail,
|
|
1194
|
+
snapshotChanged: false,
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
|
|
1199
|
+
const branchName = await getBranchName(repo.repoRoot)
|
|
1200
|
+
const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1201
|
+
const { aheadCount, behindCount } = nextHasUpstream
|
|
1202
|
+
? await getUpstreamStatusCounts(repo.repoRoot)
|
|
1203
|
+
: { aheadCount: undefined, behindCount: undefined }
|
|
1204
|
+
|
|
1205
|
+
return {
|
|
1206
|
+
ok: true,
|
|
1207
|
+
action: args.action,
|
|
1208
|
+
branchName,
|
|
1209
|
+
aheadCount,
|
|
1210
|
+
behindCount,
|
|
1211
|
+
snapshotChanged,
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
417
1215
|
async generateCommitMessage(args: {
|
|
418
1216
|
projectPath: string
|
|
419
1217
|
paths: string[]
|
|
@@ -509,12 +1307,14 @@ export class DiffStore {
|
|
|
509
1307
|
return createPushFailure(args.mode, formatGitFailure(pushResult), snapshotChanged)
|
|
510
1308
|
}
|
|
511
1309
|
|
|
1310
|
+
const postPushSnapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
|
|
1311
|
+
|
|
512
1312
|
return {
|
|
513
1313
|
ok: true,
|
|
514
1314
|
mode: args.mode,
|
|
515
1315
|
branchName,
|
|
516
1316
|
pushed: true,
|
|
517
|
-
snapshotChanged,
|
|
1317
|
+
snapshotChanged: snapshotChanged || postPushSnapshotChanged,
|
|
518
1318
|
} satisfies DiffCommitResult
|
|
519
1319
|
}
|
|
520
1320
|
|