kanna-code 0.23.1 → 0.25.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/README.md +6 -2
- package/dist/client/assets/index-Hy4LEj8j.css +32 -0
- package/dist/client/assets/index-Tg96DNSK.js +2554 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/diff-store.test.ts +382 -27
- package/src/server/diff-store.ts +1293 -30
- package/src/server/generate-commit-message.test.ts +0 -2
- package/src/server/generate-commit-message.ts +9 -4
- package/src/server/keybindings.test.ts +12 -0
- package/src/server/keybindings.ts +3 -0
- package/src/server/read-models.test.ts +1 -2
- package/src/server/read-models.ts +1 -4
- package/src/server/ws-router.test.ts +277 -8
- package/src/server/ws-router.ts +124 -50
- package/src/shared/protocol.ts +56 -0
- package/src/shared/types.ts +119 -13
- 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,40 +1,97 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
1
2
|
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
|
|
2
3
|
import { tmpdir } from "node:os"
|
|
3
4
|
import path from "node:path"
|
|
4
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
BranchMetadata,
|
|
7
|
+
ChatBranchHistoryEntry,
|
|
8
|
+
ChatBranchHistorySnapshot,
|
|
9
|
+
ChatBranchListEntry,
|
|
10
|
+
ChatBranchListResult,
|
|
11
|
+
ChatCheckoutBranchResult,
|
|
12
|
+
ChatCreateBranchResult,
|
|
13
|
+
ChatDiffFile,
|
|
14
|
+
ChatDiffSnapshot,
|
|
15
|
+
ChatMergeBranchResult,
|
|
16
|
+
ChatMergePreviewResult,
|
|
17
|
+
ChatSyncResult,
|
|
18
|
+
DiffCommitMode,
|
|
19
|
+
DiffCommitResult,
|
|
20
|
+
UpstreamStatus,
|
|
21
|
+
} from "../shared/types"
|
|
5
22
|
import { generateCommitMessageDetailed } from "./generate-commit-message"
|
|
6
23
|
import { inferProjectFileContentType } from "./uploads"
|
|
7
24
|
|
|
8
|
-
interface StoredChatDiffState {
|
|
25
|
+
interface StoredChatDiffState extends BranchMetadata, UpstreamStatus {
|
|
9
26
|
status: ChatDiffSnapshot["status"]
|
|
10
|
-
branchName?: string
|
|
11
|
-
hasUpstream?: boolean
|
|
12
27
|
files: ChatDiffFile[]
|
|
28
|
+
branchHistory: ChatBranchHistorySnapshot
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
function createEmptyState(): StoredChatDiffState {
|
|
16
32
|
return {
|
|
17
33
|
status: "unknown",
|
|
18
34
|
branchName: undefined,
|
|
35
|
+
defaultBranchName: undefined,
|
|
36
|
+
originRepoSlug: undefined,
|
|
19
37
|
hasUpstream: undefined,
|
|
38
|
+
aheadCount: undefined,
|
|
39
|
+
behindCount: undefined,
|
|
40
|
+
lastFetchedAt: undefined,
|
|
20
41
|
files: [],
|
|
42
|
+
branchHistory: { entries: [] },
|
|
21
43
|
}
|
|
22
44
|
}
|
|
23
45
|
|
|
46
|
+
function branchMetadataEqual(left: BranchMetadata, right: BranchMetadata) {
|
|
47
|
+
return left.branchName === right.branchName
|
|
48
|
+
&& left.defaultBranchName === right.defaultBranchName
|
|
49
|
+
&& left.originRepoSlug === right.originRepoSlug
|
|
50
|
+
&& left.hasUpstream === right.hasUpstream
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function upstreamStatusEqual(left: UpstreamStatus, right: UpstreamStatus) {
|
|
54
|
+
return left.aheadCount === right.aheadCount
|
|
55
|
+
&& left.behindCount === right.behindCount
|
|
56
|
+
&& left.lastFetchedAt === right.lastFetchedAt
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function branchHistoryEqual(left: ChatBranchHistorySnapshot, right: ChatBranchHistorySnapshot) {
|
|
60
|
+
if (left.entries.length !== right.entries.length) return false
|
|
61
|
+
return left.entries.every((entry, index) => {
|
|
62
|
+
const other = right.entries[index]
|
|
63
|
+
return Boolean(other)
|
|
64
|
+
&& entry.sha === other.sha
|
|
65
|
+
&& entry.summary === other.summary
|
|
66
|
+
&& entry.description === other.description
|
|
67
|
+
&& entry.authorName === other.authorName
|
|
68
|
+
&& entry.authoredAt === other.authoredAt
|
|
69
|
+
&& entry.githubUrl === other.githubUrl
|
|
70
|
+
&& entry.tags.length === other.tags.length
|
|
71
|
+
&& entry.tags.every((tag, tagIndex) => tag === other.tags[tagIndex])
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
24
75
|
function snapshotsEqual(left: StoredChatDiffState | undefined, right: StoredChatDiffState) {
|
|
25
76
|
if (!left) {
|
|
26
77
|
return right.status === "unknown" && right.files.length === 0
|
|
27
78
|
}
|
|
28
79
|
if (left.status !== right.status) return false
|
|
29
|
-
if (left
|
|
30
|
-
if (left
|
|
80
|
+
if (!branchMetadataEqual(left, right)) return false
|
|
81
|
+
if (!upstreamStatusEqual(left, right)) return false
|
|
31
82
|
if (left.files.length !== right.files.length) return false
|
|
83
|
+
if (!branchHistoryEqual(left.branchHistory, right.branchHistory)) return false
|
|
32
84
|
return left.files.every((file, index) => {
|
|
33
85
|
const other = right.files[index]
|
|
34
86
|
return Boolean(other)
|
|
35
87
|
&& file.path === other.path
|
|
36
88
|
&& file.changeType === other.changeType
|
|
37
|
-
&& file.
|
|
89
|
+
&& file.isUntracked === other.isUntracked
|
|
90
|
+
&& file.additions === other.additions
|
|
91
|
+
&& file.deletions === other.deletions
|
|
92
|
+
&& file.patchDigest === other.patchDigest
|
|
93
|
+
&& file.mimeType === other.mimeType
|
|
94
|
+
&& file.size === other.size
|
|
38
95
|
})
|
|
39
96
|
}
|
|
40
97
|
|
|
@@ -45,6 +102,19 @@ interface DirtyPathEntry {
|
|
|
45
102
|
isUntracked: boolean
|
|
46
103
|
}
|
|
47
104
|
|
|
105
|
+
type SelectedBranch =
|
|
106
|
+
| { kind: "local"; name: string }
|
|
107
|
+
| { kind: "remote"; name: string; remoteRef: string }
|
|
108
|
+
| {
|
|
109
|
+
kind: "pull_request"
|
|
110
|
+
name: string
|
|
111
|
+
prNumber: number
|
|
112
|
+
headRefName: string
|
|
113
|
+
headRepoCloneUrl?: string
|
|
114
|
+
isCrossRepository?: boolean
|
|
115
|
+
remoteRef?: string
|
|
116
|
+
}
|
|
117
|
+
|
|
48
118
|
async function fileExists(filePath: string) {
|
|
49
119
|
try {
|
|
50
120
|
await stat(filePath)
|
|
@@ -72,6 +142,24 @@ async function runGit(args: string[], cwd: string) {
|
|
|
72
142
|
}
|
|
73
143
|
}
|
|
74
144
|
|
|
145
|
+
async function runCommand(args: string[]) {
|
|
146
|
+
const process = Bun.spawn(args, {
|
|
147
|
+
stdout: "pipe",
|
|
148
|
+
stderr: "pipe",
|
|
149
|
+
})
|
|
150
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
151
|
+
new Response(process.stdout).text(),
|
|
152
|
+
new Response(process.stderr).text(),
|
|
153
|
+
process.exited,
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
stdout,
|
|
158
|
+
stderr,
|
|
159
|
+
exitCode,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
75
163
|
function formatGitFailure(result: Awaited<ReturnType<typeof runGit>>) {
|
|
76
164
|
return [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n")
|
|
77
165
|
}
|
|
@@ -127,6 +215,35 @@ function createPushFailure(mode: DiffCommitMode, detail: string, snapshotChanged
|
|
|
127
215
|
}
|
|
128
216
|
}
|
|
129
217
|
|
|
218
|
+
function createSyncPushFailure(detail: string, snapshotChanged: boolean): ChatSyncResult {
|
|
219
|
+
const normalized = detail.toLowerCase()
|
|
220
|
+
let title = "Push failed"
|
|
221
|
+
let message = summarizeGitFailure(detail, "Git could not push this branch.")
|
|
222
|
+
|
|
223
|
+
if (normalized.includes("non-fast-forward") || normalized.includes("fetch first")) {
|
|
224
|
+
title = "Branch is not up to date"
|
|
225
|
+
message = "Your branch is behind its remote. Pull or rebase, then try pushing again."
|
|
226
|
+
} else if (normalized.includes("has no upstream branch") || normalized.includes("set-upstream")) {
|
|
227
|
+
title = "No upstream branch configured"
|
|
228
|
+
message = "This branch does not have an upstream remote branch configured yet."
|
|
229
|
+
} else if (normalized.includes("merge conflict") || normalized.includes("resolve conflicts")) {
|
|
230
|
+
title = "Merge conflicts need resolution"
|
|
231
|
+
message = "Git reported conflicts while preparing the push. Resolve them, then try again."
|
|
232
|
+
} else if (normalized.includes("permission denied") || normalized.includes("authentication failed") || normalized.includes("could not read from remote repository")) {
|
|
233
|
+
title = "Remote authentication failed"
|
|
234
|
+
message = "Git could not authenticate with the remote repository."
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
action: "push",
|
|
240
|
+
title,
|
|
241
|
+
message,
|
|
242
|
+
detail,
|
|
243
|
+
snapshotChanged,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
130
247
|
async function resolveRepo(projectPath: string): Promise<{ repoRoot: string; baseCommit: string | null } | null> {
|
|
131
248
|
const topLevel = await runGit(["rev-parse", "--show-toplevel"], projectPath)
|
|
132
249
|
if (topLevel.exitCode !== 0) {
|
|
@@ -160,6 +277,440 @@ async function hasUpstreamBranch(repoRoot: string) {
|
|
|
160
277
|
return upstream.exitCode === 0 && upstream.stdout.trim().length > 0
|
|
161
278
|
}
|
|
162
279
|
|
|
280
|
+
async function getLastFetchedAt(repoRoot: string) {
|
|
281
|
+
const gitDirResult = await runGit(["rev-parse", "--git-dir"], repoRoot)
|
|
282
|
+
if (gitDirResult.exitCode !== 0) {
|
|
283
|
+
return undefined
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const gitDir = gitDirResult.stdout.trim()
|
|
287
|
+
const fetchHeadPath = path.resolve(repoRoot, gitDir, "FETCH_HEAD")
|
|
288
|
+
try {
|
|
289
|
+
const fetchHeadStat = await stat(fetchHeadPath)
|
|
290
|
+
return fetchHeadStat.mtime.toISOString()
|
|
291
|
+
} catch {
|
|
292
|
+
return undefined
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function getUpstreamStatusCounts(repoRoot: string) {
|
|
297
|
+
const result = await runGit(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], repoRoot)
|
|
298
|
+
if (result.exitCode !== 0) {
|
|
299
|
+
return { aheadCount: undefined, behindCount: undefined }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const [aheadRaw, behindRaw] = result.stdout.trim().split(/\s+/u)
|
|
303
|
+
const aheadCount = Number.parseInt(aheadRaw ?? "", 10)
|
|
304
|
+
const behindCount = Number.parseInt(behindRaw ?? "", 10)
|
|
305
|
+
return {
|
|
306
|
+
aheadCount: Number.isFinite(aheadCount) ? aheadCount : undefined,
|
|
307
|
+
behindCount: Number.isFinite(behindCount) ? behindCount : undefined,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function getOriginRemoteUrl(repoRoot: string) {
|
|
312
|
+
const result = await runGit(["remote", "get-url", "origin"], repoRoot)
|
|
313
|
+
if (result.exitCode !== 0) {
|
|
314
|
+
return null
|
|
315
|
+
}
|
|
316
|
+
const remoteUrl = result.stdout.trim()
|
|
317
|
+
return remoteUrl.length > 0 ? remoteUrl : null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function getGitHubRemoteSlugs(repoRoot: string) {
|
|
321
|
+
const remotesResult = await runGit(["remote"], repoRoot)
|
|
322
|
+
if (remotesResult.exitCode !== 0) {
|
|
323
|
+
return new Map<string, string>()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const remoteNames = remotesResult.stdout
|
|
327
|
+
.split(/\r?\n/u)
|
|
328
|
+
.map((line) => line.trim())
|
|
329
|
+
.filter(Boolean)
|
|
330
|
+
|
|
331
|
+
const remoteSlugEntries = await Promise.all(remoteNames.map(async (remoteName) => {
|
|
332
|
+
const remoteUrlResult = await runGit(["remote", "get-url", remoteName], repoRoot)
|
|
333
|
+
if (remoteUrlResult.exitCode !== 0) {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
const repoSlug = extractGitHubRepoSlug(remoteUrlResult.stdout.trim())
|
|
337
|
+
return repoSlug ? [remoteName, repoSlug.toLowerCase()] as const : null
|
|
338
|
+
}))
|
|
339
|
+
|
|
340
|
+
return new Map(remoteSlugEntries.filter((entry): entry is readonly [string, string] => Boolean(entry)))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function getLocalBranchNames(repoRoot: string) {
|
|
344
|
+
const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], repoRoot)
|
|
345
|
+
if (result.exitCode !== 0) {
|
|
346
|
+
throw new Error(result.stderr.trim() || "Failed to list local branches")
|
|
347
|
+
}
|
|
348
|
+
return result.stdout
|
|
349
|
+
.split(/\r?\n/u)
|
|
350
|
+
.map((line) => line.trim())
|
|
351
|
+
.filter(Boolean)
|
|
352
|
+
.sort((left, right) => left.localeCompare(right))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function getRemoteBranchNames(repoRoot: string) {
|
|
356
|
+
const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/remotes"], repoRoot)
|
|
357
|
+
if (result.exitCode !== 0) {
|
|
358
|
+
throw new Error(result.stderr.trim() || "Failed to list remote branches")
|
|
359
|
+
}
|
|
360
|
+
return result.stdout
|
|
361
|
+
.split(/\r?\n/u)
|
|
362
|
+
.map((line) => line.trim())
|
|
363
|
+
.filter((line) => line.length > 0 && !line.endsWith("/HEAD"))
|
|
364
|
+
.sort((left, right) => left.localeCompare(right))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function getBranchUpdatedAtMap(repoRoot: string, refPrefix: "refs/heads" | "refs/remotes") {
|
|
368
|
+
const result = await runGit(
|
|
369
|
+
["for-each-ref", "--format=%(refname:short)\t%(committerdate:iso-strict)", refPrefix],
|
|
370
|
+
repoRoot
|
|
371
|
+
)
|
|
372
|
+
if (result.exitCode !== 0) {
|
|
373
|
+
throw new Error(result.stderr.trim() || "Failed to read branch update times")
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const entries = new Map<string, string>()
|
|
377
|
+
for (const line of result.stdout.split(/\r?\n/u)) {
|
|
378
|
+
const trimmed = line.trim()
|
|
379
|
+
if (!trimmed) continue
|
|
380
|
+
const [name, updatedAt] = trimmed.split("\t")
|
|
381
|
+
if (!name || !updatedAt || (refPrefix === "refs/remotes" && name.endsWith("/HEAD"))) {
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
entries.set(name, updatedAt)
|
|
385
|
+
}
|
|
386
|
+
return entries
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function resolveDefaultBranchName(repoRoot: string) {
|
|
390
|
+
const originHead = await runGit(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], repoRoot)
|
|
391
|
+
if (originHead.exitCode === 0) {
|
|
392
|
+
const ref = originHead.stdout.trim()
|
|
393
|
+
if (ref.startsWith("origin/")) {
|
|
394
|
+
return ref.slice("origin/".length)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const localBranches = await getLocalBranchNames(repoRoot)
|
|
399
|
+
if (localBranches.includes("main")) return "main"
|
|
400
|
+
if (localBranches.includes("master")) return "master"
|
|
401
|
+
return (await getBranchName(repoRoot)) ?? localBranches[0] ?? undefined
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function getRecentBranchNames(repoRoot: string) {
|
|
405
|
+
const result = await runGit(["reflog", "--format=%gs", "--max-count=100", "HEAD"], repoRoot)
|
|
406
|
+
if (result.exitCode !== 0) {
|
|
407
|
+
return []
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const recent: string[] = []
|
|
411
|
+
const seen = new Set<string>()
|
|
412
|
+
for (const line of result.stdout.split(/\r?\n/u)) {
|
|
413
|
+
const match = /checkout: moving from .* to (?<branch>.+)$/u.exec(line.trim())
|
|
414
|
+
const branch = match?.groups?.branch?.trim()
|
|
415
|
+
if (!branch || branch === "HEAD" || branch.startsWith("refs/")) {
|
|
416
|
+
continue
|
|
417
|
+
}
|
|
418
|
+
if (seen.has(branch)) continue
|
|
419
|
+
seen.add(branch)
|
|
420
|
+
recent.push(branch)
|
|
421
|
+
}
|
|
422
|
+
return recent
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function resolveSelectedBranchRef(repoRoot: string, branch: SelectedBranch) {
|
|
426
|
+
if (branch.kind === "local") {
|
|
427
|
+
const localBranchNames = await getLocalBranchNames(repoRoot)
|
|
428
|
+
if (!localBranchNames.includes(branch.name)) {
|
|
429
|
+
throw new Error(`Local branch not found: ${branch.name}`)
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ref: branch.name,
|
|
433
|
+
displayName: branch.name,
|
|
434
|
+
branchName: branch.name,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (branch.kind === "remote") {
|
|
439
|
+
const remoteRef = branch.remoteRef.trim()
|
|
440
|
+
const remoteBranchNames = await getRemoteBranchNames(repoRoot)
|
|
441
|
+
if (!remoteBranchNames.includes(remoteRef)) {
|
|
442
|
+
throw new Error(`Remote branch not found: ${remoteRef}`)
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
ref: remoteRef,
|
|
446
|
+
displayName: remoteRef,
|
|
447
|
+
branchName: branch.name,
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const localBranchNames = await getLocalBranchNames(repoRoot)
|
|
452
|
+
if (localBranchNames.includes(branch.name)) {
|
|
453
|
+
return {
|
|
454
|
+
ref: branch.name,
|
|
455
|
+
displayName: `PR #${branch.prNumber}`,
|
|
456
|
+
branchName: branch.name,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const remoteRef = branch.remoteRef?.trim()
|
|
461
|
+
if (remoteRef) {
|
|
462
|
+
const remoteBranchNames = await getRemoteBranchNames(repoRoot)
|
|
463
|
+
if (remoteBranchNames.includes(remoteRef)) {
|
|
464
|
+
return {
|
|
465
|
+
ref: remoteRef,
|
|
466
|
+
displayName: `PR #${branch.prNumber}`,
|
|
467
|
+
branchName: branch.headRefName || branch.name,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (branch.isCrossRepository) {
|
|
473
|
+
throw new Error("This pull request branch is not available locally yet. Check it out first before merging.")
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
throw new Error(`Pull request branch not found: ${branch.headRefName || branch.name}`)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function getMergeCommitCount(repoRoot: string, sourceRef: string) {
|
|
480
|
+
const result = await runGit(["rev-list", "--count", `HEAD..${sourceRef}`], repoRoot)
|
|
481
|
+
if (result.exitCode !== 0) {
|
|
482
|
+
throw new Error(result.stderr.trim() || "Failed to calculate merge commit count")
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const commitCount = Number.parseInt(result.stdout.trim(), 10)
|
|
486
|
+
return Number.isFinite(commitCount) ? commitCount : 0
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function predictMergeConflicts(repoRoot: string, sourceRef: string) {
|
|
490
|
+
const result = await runGit(["merge-tree", "--write-tree", "--messages", "HEAD", sourceRef], repoRoot)
|
|
491
|
+
const output = `${result.stdout}\n${result.stderr}`.trim()
|
|
492
|
+
|
|
493
|
+
if (result.exitCode === 0) {
|
|
494
|
+
return { hasConflicts: false }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const normalizedOutput = output.toLowerCase()
|
|
498
|
+
if (result.exitCode === 1 || normalizedOutput.includes("conflict")) {
|
|
499
|
+
return {
|
|
500
|
+
hasConflicts: true,
|
|
501
|
+
detail: output || "Git reported merge conflicts for this branch pair.",
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
throw new Error(output || "Failed to analyze merge conflicts")
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function extractGitHubRepoSlug(remoteUrl: string | null | undefined) {
|
|
509
|
+
if (!remoteUrl) return null
|
|
510
|
+
|
|
511
|
+
const sshMatch = /^git@github\.com:(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
|
|
512
|
+
if (sshMatch?.groups?.owner && sshMatch.groups.repo) {
|
|
513
|
+
return `${sshMatch.groups.owner}/${sshMatch.groups.repo}`
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const sshProtocolMatch = /^ssh:\/\/git@github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
|
|
517
|
+
if (sshProtocolMatch?.groups?.owner && sshProtocolMatch.groups.repo) {
|
|
518
|
+
return `${sshProtocolMatch.groups.owner}/${sshProtocolMatch.groups.repo}`
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const httpsMatch = /^https?:\/\/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
|
|
522
|
+
if (httpsMatch?.groups?.owner && httpsMatch.groups.repo) {
|
|
523
|
+
return `${httpsMatch.groups.owner}/${httpsMatch.groups.repo}`
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return null
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
interface GitHubPullRequestResponseItem {
|
|
530
|
+
number: number
|
|
531
|
+
title: string
|
|
532
|
+
head?: {
|
|
533
|
+
ref?: string
|
|
534
|
+
label?: string
|
|
535
|
+
repo?: {
|
|
536
|
+
clone_url?: string
|
|
537
|
+
full_name?: string
|
|
538
|
+
} | null
|
|
539
|
+
}
|
|
540
|
+
base?: {
|
|
541
|
+
ref?: string
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
|
|
546
|
+
|
|
547
|
+
type GitHubCliApiLike = (path: string) => Promise<GitHubPullRequestResponseItem[] | null>
|
|
548
|
+
|
|
549
|
+
interface FetchGitHubPullRequestsDeps {
|
|
550
|
+
fetchImpl?: FetchLike
|
|
551
|
+
ghApiImpl?: GitHubCliApiLike
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function fetchGitHubPullRequestsViaGh(path: string): Promise<GitHubPullRequestResponseItem[] | null> {
|
|
555
|
+
const result = await runCommand([
|
|
556
|
+
"gh",
|
|
557
|
+
"api",
|
|
558
|
+
"-H",
|
|
559
|
+
"Accept: application/vnd.github+json",
|
|
560
|
+
path,
|
|
561
|
+
])
|
|
562
|
+
if (result.exitCode !== 0) {
|
|
563
|
+
return null
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const json = JSON.parse(result.stdout)
|
|
567
|
+
return Array.isArray(json) ? json as GitHubPullRequestResponseItem[] : []
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export async function fetchGitHubPullRequests(
|
|
571
|
+
repoSlug: string,
|
|
572
|
+
deps: FetchLike | FetchGitHubPullRequestsDeps = fetch
|
|
573
|
+
): Promise<GitHubPullRequestResponseItem[]> {
|
|
574
|
+
const fetchImpl = typeof deps === "function" ? deps : (deps.fetchImpl ?? fetch)
|
|
575
|
+
const ghApiImpl = typeof deps === "function" ? fetchGitHubPullRequestsViaGh : (deps.ghApiImpl ?? fetchGitHubPullRequestsViaGh)
|
|
576
|
+
const ghPath = `repos/${repoSlug}/pulls?state=open&per_page=50`
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
const ghPulls = await ghApiImpl(ghPath)
|
|
580
|
+
if (ghPulls) {
|
|
581
|
+
return ghPulls
|
|
582
|
+
}
|
|
583
|
+
} catch {
|
|
584
|
+
// Fall back to an unauthenticated HTTP request when `gh` is unavailable.
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const response = await fetchImpl(`https://api.github.com/repos/${repoSlug}/pulls?state=open&per_page=50`, {
|
|
588
|
+
headers: {
|
|
589
|
+
Accept: "application/vnd.github+json",
|
|
590
|
+
},
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
if (!response.ok) {
|
|
594
|
+
throw new Error(`GitHub pull requests request failed with status ${response.status}`)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const json = await response.json()
|
|
598
|
+
return Array.isArray(json) ? json as GitHubPullRequestResponseItem[] : []
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function buildGitHubCommitUrl(remoteUrl: string | null, sha: string) {
|
|
602
|
+
const slug = extractGitHubRepoSlug(remoteUrl)
|
|
603
|
+
return slug ? `https://github.com/${slug}/commit/${sha}` : undefined
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function getTagsByCommit(repoRoot: string, shas: string[]): Promise<Map<string, string[]>> {
|
|
607
|
+
const tagMap = new Map<string, string[]>()
|
|
608
|
+
if (shas.length === 0) return tagMap
|
|
609
|
+
|
|
610
|
+
for (const sha of shas) {
|
|
611
|
+
tagMap.set(sha, [])
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const result = await runGit(
|
|
615
|
+
["log", "--max-count", String(shas.length), "--decorate-refs=refs/tags", "--format=%H %D", shas[0]!],
|
|
616
|
+
repoRoot
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
if (result.exitCode !== 0) return tagMap
|
|
620
|
+
|
|
621
|
+
for (const line of result.stdout.split(/\r?\n/u)) {
|
|
622
|
+
const trimmed = line.trim()
|
|
623
|
+
if (!trimmed) continue
|
|
624
|
+
const spaceIndex = trimmed.indexOf(" ")
|
|
625
|
+
if (spaceIndex < 0) continue
|
|
626
|
+
const sha = trimmed.slice(0, spaceIndex)
|
|
627
|
+
const decorations = trimmed.slice(spaceIndex + 1)
|
|
628
|
+
if (!tagMap.has(sha) || !decorations) continue
|
|
629
|
+
const tags = decorations
|
|
630
|
+
.split(",")
|
|
631
|
+
.map((decoration) => decoration.trim())
|
|
632
|
+
.filter((decoration) => decoration.startsWith("tag: "))
|
|
633
|
+
.map((decoration) => decoration.slice(5))
|
|
634
|
+
.filter(Boolean)
|
|
635
|
+
.sort((left, right) => left.localeCompare(right))
|
|
636
|
+
tagMap.set(sha, tags)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return tagMap
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function getBranchHistory(args: {
|
|
643
|
+
repoRoot: string
|
|
644
|
+
ref: string
|
|
645
|
+
limit: number
|
|
646
|
+
}): Promise<ChatBranchHistorySnapshot> {
|
|
647
|
+
const logResult = await runGit(
|
|
648
|
+
[
|
|
649
|
+
"log",
|
|
650
|
+
"--max-count",
|
|
651
|
+
String(args.limit),
|
|
652
|
+
"--pretty=format:%H%x1f%s%x1f%b%x1f%an%x1f%aI%x1e",
|
|
653
|
+
args.ref,
|
|
654
|
+
],
|
|
655
|
+
args.repoRoot
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
if (logResult.exitCode !== 0) {
|
|
659
|
+
throw new Error(logResult.stderr.trim() || "Failed to read git log")
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const remoteUrl = await getOriginRemoteUrl(args.repoRoot)
|
|
663
|
+
const parsedRecords: Array<{ sha: string; summary: string; description: string; authorName?: string; authoredAt: string }> = []
|
|
664
|
+
|
|
665
|
+
for (const record of logResult.stdout.split("\u001e")) {
|
|
666
|
+
const trimmed = record.trim()
|
|
667
|
+
if (!trimmed) continue
|
|
668
|
+
const [sha, summary, description, authorName, authoredAt] = trimmed.split("\u001f")
|
|
669
|
+
if (!sha || !summary || !authoredAt) continue
|
|
670
|
+
parsedRecords.push({
|
|
671
|
+
sha,
|
|
672
|
+
summary,
|
|
673
|
+
description: (description ?? "").trim(),
|
|
674
|
+
authorName: authorName?.trim() || undefined,
|
|
675
|
+
authoredAt,
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const tagMap = await getTagsByCommit(args.repoRoot, parsedRecords.map((record) => record.sha))
|
|
680
|
+
|
|
681
|
+
const entries: ChatBranchHistoryEntry[] = parsedRecords.map((record) => ({
|
|
682
|
+
...record,
|
|
683
|
+
tags: tagMap.get(record.sha) ?? [],
|
|
684
|
+
githubUrl: buildGitHubCommitUrl(remoteUrl, record.sha),
|
|
685
|
+
}))
|
|
686
|
+
|
|
687
|
+
return { entries }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function createBranchActionFailure(title: string, detail: string, fallback: string) {
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
title,
|
|
694
|
+
message: summarizeGitFailure(detail, fallback),
|
|
695
|
+
detail,
|
|
696
|
+
} as const
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function createMergeActionFailure(args: {
|
|
700
|
+
title: string
|
|
701
|
+
detail: string
|
|
702
|
+
fallback: string
|
|
703
|
+
snapshotChanged: boolean
|
|
704
|
+
}) {
|
|
705
|
+
return {
|
|
706
|
+
ok: false,
|
|
707
|
+
title: args.title,
|
|
708
|
+
message: summarizeGitFailure(args.detail, args.fallback),
|
|
709
|
+
detail: args.detail,
|
|
710
|
+
snapshotChanged: args.snapshotChanged,
|
|
711
|
+
} as const
|
|
712
|
+
}
|
|
713
|
+
|
|
163
714
|
function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
164
715
|
const entries: DirtyPathEntry[] = []
|
|
165
716
|
for (const rawLine of output.split(/\r?\n/u)) {
|
|
@@ -270,8 +821,83 @@ async function createPatch(beforePathLabel: string, afterPathLabel: string, befo
|
|
|
270
821
|
}
|
|
271
822
|
}
|
|
272
823
|
|
|
824
|
+
function getContentDigest(args: {
|
|
825
|
+
changeType: ChatDiffFile["changeType"]
|
|
826
|
+
beforePath: string
|
|
827
|
+
afterPath: string
|
|
828
|
+
beforeText: string | null
|
|
829
|
+
afterText: string | null
|
|
830
|
+
}) {
|
|
831
|
+
return createHash("sha1")
|
|
832
|
+
.update(args.changeType)
|
|
833
|
+
.update("\u0000")
|
|
834
|
+
.update(args.beforePath)
|
|
835
|
+
.update("\u0000")
|
|
836
|
+
.update(args.afterPath)
|
|
837
|
+
.update("\u0000")
|
|
838
|
+
.update(args.beforeText ?? "")
|
|
839
|
+
.update("\u0000")
|
|
840
|
+
.update(args.afterText ?? "")
|
|
841
|
+
.digest("hex")
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function parseNumstatValue(value: string) {
|
|
845
|
+
if (value === "-" || value.trim() === "") return 0
|
|
846
|
+
const parsed = Number.parseInt(value, 10)
|
|
847
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function countTextLines(text: string | null) {
|
|
851
|
+
if (!text) return 0
|
|
852
|
+
const lines = text.split(/\r?\n/u)
|
|
853
|
+
if (lines.at(-1) === "") {
|
|
854
|
+
lines.pop()
|
|
855
|
+
}
|
|
856
|
+
return lines.length
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function getTrackedDiffStats(repoRoot: string, baseCommit: string | null) {
|
|
860
|
+
const statsByPath = new Map<string, { additions: number; deletions: number }>()
|
|
861
|
+
if (!baseCommit) {
|
|
862
|
+
return statsByPath
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const result = await runGit(["diff", "--numstat", "-z", "-M", baseCommit], repoRoot)
|
|
866
|
+
if (result.exitCode !== 0) {
|
|
867
|
+
throw new Error(result.stderr.trim() || "Failed to read git diff stats")
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const tokens = result.stdout.split("\u0000")
|
|
871
|
+
for (let index = 0; index < tokens.length;) {
|
|
872
|
+
const header = tokens[index++] ?? ""
|
|
873
|
+
if (!header) continue
|
|
874
|
+
|
|
875
|
+
const [additionsValue, deletionsValue, pathValue = ""] = header.split("\t")
|
|
876
|
+
if (typeof additionsValue !== "string" || typeof deletionsValue !== "string") continue
|
|
877
|
+
|
|
878
|
+
if (pathValue) {
|
|
879
|
+
statsByPath.set(pathValue, {
|
|
880
|
+
additions: parseNumstatValue(additionsValue),
|
|
881
|
+
deletions: parseNumstatValue(deletionsValue),
|
|
882
|
+
})
|
|
883
|
+
continue
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
index += 1
|
|
887
|
+
const nextPath = tokens[index++] ?? ""
|
|
888
|
+
if (!nextPath) continue
|
|
889
|
+
statsByPath.set(nextPath, {
|
|
890
|
+
additions: parseNumstatValue(additionsValue),
|
|
891
|
+
deletions: parseNumstatValue(deletionsValue),
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return statsByPath
|
|
896
|
+
}
|
|
897
|
+
|
|
273
898
|
async function computeCurrentFiles(repoRoot: string, baseCommit: string | null): Promise<ChatDiffFile[]> {
|
|
274
899
|
const currentDirtyPaths = await listDirtyPaths(repoRoot)
|
|
900
|
+
const trackedStatsByPath = await getTrackedDiffStats(repoRoot, baseCommit)
|
|
275
901
|
const files: ChatDiffFile[] = []
|
|
276
902
|
|
|
277
903
|
for (const entry of currentDirtyPaths) {
|
|
@@ -289,12 +915,22 @@ async function computeCurrentFiles(repoRoot: string, baseCommit: string | null):
|
|
|
289
915
|
continue
|
|
290
916
|
}
|
|
291
917
|
|
|
292
|
-
const
|
|
918
|
+
const trackedStats = trackedStatsByPath.get(relativePath)
|
|
919
|
+
const additions = trackedStats?.additions ?? countTextLines(afterText)
|
|
920
|
+
const deletions = trackedStats?.deletions ?? 0
|
|
293
921
|
files.push({
|
|
294
922
|
path: relativePath,
|
|
295
923
|
changeType: entry.changeType,
|
|
296
924
|
isUntracked: entry.isUntracked,
|
|
297
|
-
|
|
925
|
+
additions,
|
|
926
|
+
deletions,
|
|
927
|
+
patchDigest: getContentDigest({
|
|
928
|
+
changeType: entry.changeType,
|
|
929
|
+
beforePath,
|
|
930
|
+
afterPath: relativePath,
|
|
931
|
+
beforeText,
|
|
932
|
+
afterText,
|
|
933
|
+
}),
|
|
298
934
|
mimeType,
|
|
299
935
|
size,
|
|
300
936
|
})
|
|
@@ -376,44 +1012,656 @@ export class DiffStore {
|
|
|
376
1012
|
|
|
377
1013
|
async initialize() {}
|
|
378
1014
|
|
|
379
|
-
|
|
380
|
-
|
|
1015
|
+
async readPatch(args: {
|
|
1016
|
+
projectPath: string
|
|
1017
|
+
path: string
|
|
1018
|
+
}) {
|
|
1019
|
+
const relativePath = normalizeRepoRelativePath(args.path)
|
|
1020
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1021
|
+
if (!repo) {
|
|
1022
|
+
throw new Error("Project is not in a git repository")
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const entry = await findDirtyPath(repo.repoRoot, relativePath)
|
|
1026
|
+
if (!entry) {
|
|
1027
|
+
throw new Error(`File is no longer changed: ${relativePath}`)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const beforePath = entry.previousPath ?? relativePath
|
|
1031
|
+
const beforeText = await readBaseFile(repo.repoRoot, repo.baseCommit, beforePath)
|
|
1032
|
+
const afterText = await readWorktreeFile(repo.repoRoot, relativePath)
|
|
1033
|
+
const patch = await createPatch(beforePath, relativePath, beforeText, afterText)
|
|
1034
|
+
|
|
1035
|
+
return { patch }
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
getProjectSnapshot(projectId: string): ChatDiffSnapshot {
|
|
1039
|
+
const state = this.states.get(projectId) ?? createEmptyState()
|
|
381
1040
|
return {
|
|
382
1041
|
status: state.status,
|
|
383
1042
|
branchName: state.branchName,
|
|
1043
|
+
defaultBranchName: state.defaultBranchName,
|
|
1044
|
+
originRepoSlug: state.originRepoSlug,
|
|
384
1045
|
hasUpstream: state.hasUpstream,
|
|
1046
|
+
aheadCount: state.aheadCount,
|
|
1047
|
+
behindCount: state.behindCount,
|
|
1048
|
+
lastFetchedAt: state.lastFetchedAt,
|
|
385
1049
|
files: [...state.files],
|
|
1050
|
+
branchHistory: {
|
|
1051
|
+
entries: state.branchHistory.entries.map((entry) => ({
|
|
1052
|
+
...entry,
|
|
1053
|
+
tags: [...entry.tags],
|
|
1054
|
+
})),
|
|
1055
|
+
},
|
|
386
1056
|
}
|
|
387
1057
|
}
|
|
388
1058
|
|
|
389
|
-
async refreshSnapshot(
|
|
1059
|
+
async refreshSnapshot(projectId: string, projectPath: string) {
|
|
390
1060
|
const repo = await resolveRepo(projectPath)
|
|
391
1061
|
if (!repo) {
|
|
392
1062
|
const nextState = {
|
|
393
1063
|
status: "no_repo",
|
|
394
1064
|
branchName: undefined,
|
|
1065
|
+
defaultBranchName: undefined,
|
|
1066
|
+
originRepoSlug: undefined,
|
|
395
1067
|
hasUpstream: undefined,
|
|
1068
|
+
aheadCount: undefined,
|
|
1069
|
+
behindCount: undefined,
|
|
1070
|
+
lastFetchedAt: undefined,
|
|
396
1071
|
files: [],
|
|
1072
|
+
branchHistory: { entries: [] },
|
|
397
1073
|
} satisfies StoredChatDiffState
|
|
398
|
-
const changed = !snapshotsEqual(this.states.get(
|
|
399
|
-
this.states.set(
|
|
1074
|
+
const changed = !snapshotsEqual(this.states.get(projectId), nextState)
|
|
1075
|
+
this.states.set(projectId, nextState)
|
|
400
1076
|
return changed
|
|
401
1077
|
}
|
|
402
1078
|
|
|
403
1079
|
const files = await computeCurrentFiles(repo.repoRoot, repo.baseCommit)
|
|
404
1080
|
const branchName = await getBranchName(repo.repoRoot)
|
|
1081
|
+
const defaultBranchName = await resolveDefaultBranchName(repo.repoRoot)
|
|
1082
|
+
const originRemoteUrl = await getOriginRemoteUrl(repo.repoRoot)
|
|
1083
|
+
const originRepoSlug = extractGitHubRepoSlug(originRemoteUrl) ?? undefined
|
|
405
1084
|
const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1085
|
+
const { aheadCount, behindCount } = hasUpstream
|
|
1086
|
+
? await getUpstreamStatusCounts(repo.repoRoot)
|
|
1087
|
+
: { aheadCount: undefined, behindCount: undefined }
|
|
1088
|
+
const lastFetchedAt = await getLastFetchedAt(repo.repoRoot)
|
|
1089
|
+
const branchHistory = repo.baseCommit
|
|
1090
|
+
? await getBranchHistory({
|
|
1091
|
+
repoRoot: repo.repoRoot,
|
|
1092
|
+
ref: branchName ?? "HEAD",
|
|
1093
|
+
limit: 20,
|
|
1094
|
+
})
|
|
1095
|
+
: { entries: [] }
|
|
406
1096
|
const nextState = {
|
|
407
1097
|
status: "ready",
|
|
408
1098
|
branchName,
|
|
1099
|
+
defaultBranchName,
|
|
1100
|
+
originRepoSlug,
|
|
409
1101
|
hasUpstream,
|
|
1102
|
+
aheadCount,
|
|
1103
|
+
behindCount,
|
|
1104
|
+
lastFetchedAt,
|
|
410
1105
|
files,
|
|
1106
|
+
branchHistory,
|
|
411
1107
|
} satisfies StoredChatDiffState
|
|
412
|
-
const changed = !snapshotsEqual(this.states.get(
|
|
413
|
-
this.states.set(
|
|
1108
|
+
const changed = !snapshotsEqual(this.states.get(projectId), nextState)
|
|
1109
|
+
this.states.set(projectId, nextState)
|
|
414
1110
|
return changed
|
|
415
1111
|
}
|
|
416
1112
|
|
|
1113
|
+
async listBranches(args: {
|
|
1114
|
+
projectPath: string
|
|
1115
|
+
}): Promise<ChatBranchListResult> {
|
|
1116
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1117
|
+
if (!repo) {
|
|
1118
|
+
throw new Error("Project is not in a git repository")
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const [currentBranchName, defaultBranchName, localBranchNames, remoteBranchNames, recentBranchNames, localUpdatedAtMap, remoteUpdatedAtMap] = await Promise.all([
|
|
1122
|
+
getBranchName(repo.repoRoot),
|
|
1123
|
+
resolveDefaultBranchName(repo.repoRoot),
|
|
1124
|
+
getLocalBranchNames(repo.repoRoot),
|
|
1125
|
+
getRemoteBranchNames(repo.repoRoot),
|
|
1126
|
+
getRecentBranchNames(repo.repoRoot),
|
|
1127
|
+
getBranchUpdatedAtMap(repo.repoRoot, "refs/heads"),
|
|
1128
|
+
getBranchUpdatedAtMap(repo.repoRoot, "refs/remotes"),
|
|
1129
|
+
])
|
|
1130
|
+
|
|
1131
|
+
const local = localBranchNames.map((name) => ({
|
|
1132
|
+
id: `local:${name}`,
|
|
1133
|
+
kind: "local",
|
|
1134
|
+
name,
|
|
1135
|
+
displayName: name,
|
|
1136
|
+
updatedAt: localUpdatedAtMap.get(name),
|
|
1137
|
+
} satisfies ChatBranchListEntry))
|
|
1138
|
+
|
|
1139
|
+
const remote = remoteBranchNames.map((remoteRef) => ({
|
|
1140
|
+
id: `remote:${remoteRef}`,
|
|
1141
|
+
kind: "remote",
|
|
1142
|
+
name: remoteRef.replace(/^[^/]+\//u, ""),
|
|
1143
|
+
displayName: remoteRef,
|
|
1144
|
+
updatedAt: remoteUpdatedAtMap.get(remoteRef),
|
|
1145
|
+
remoteRef,
|
|
1146
|
+
} satisfies ChatBranchListEntry))
|
|
1147
|
+
|
|
1148
|
+
const localBranchSet = new Set(localBranchNames)
|
|
1149
|
+
const remoteByName = new Map(remote.map((entry) => [entry.name, entry]))
|
|
1150
|
+
const remoteEntriesByName = new Map<string, ChatBranchListEntry[]>()
|
|
1151
|
+
for (const entry of remote) {
|
|
1152
|
+
const entries = remoteEntriesByName.get(entry.name) ?? []
|
|
1153
|
+
entries.push(entry)
|
|
1154
|
+
remoteEntriesByName.set(entry.name, entries)
|
|
1155
|
+
}
|
|
1156
|
+
const recent: ChatBranchListEntry[] = recentBranchNames.flatMap<ChatBranchListEntry>((branchName) => {
|
|
1157
|
+
if (localBranchSet.has(branchName)) {
|
|
1158
|
+
return {
|
|
1159
|
+
id: `recent:local:${branchName}`,
|
|
1160
|
+
kind: "local",
|
|
1161
|
+
name: branchName,
|
|
1162
|
+
displayName: branchName,
|
|
1163
|
+
updatedAt: localUpdatedAtMap.get(branchName),
|
|
1164
|
+
} satisfies ChatBranchListEntry
|
|
1165
|
+
}
|
|
1166
|
+
const remoteEntry = remoteByName.get(branchName)
|
|
1167
|
+
return remoteEntry
|
|
1168
|
+
? {
|
|
1169
|
+
...remoteEntry,
|
|
1170
|
+
id: `recent:${remoteEntry.id}`,
|
|
1171
|
+
} satisfies ChatBranchListEntry
|
|
1172
|
+
: []
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
const [remoteUrl, githubRemoteSlugs] = await Promise.all([
|
|
1176
|
+
getOriginRemoteUrl(repo.repoRoot),
|
|
1177
|
+
getGitHubRemoteSlugs(repo.repoRoot),
|
|
1178
|
+
])
|
|
1179
|
+
const repoSlug = extractGitHubRepoSlug(remoteUrl)
|
|
1180
|
+
let pullRequests: ChatBranchListEntry[] = []
|
|
1181
|
+
const pullRequestRemoteRefs = new Set<string>()
|
|
1182
|
+
const pullRequestHeadNames = new Set<string>()
|
|
1183
|
+
let pullRequestsStatus: ChatBranchListResult["pullRequestsStatus"] = "unavailable"
|
|
1184
|
+
let pullRequestsError: string | undefined
|
|
1185
|
+
|
|
1186
|
+
if (repoSlug) {
|
|
1187
|
+
try {
|
|
1188
|
+
const prs = await fetchGitHubPullRequests(repoSlug)
|
|
1189
|
+
pullRequests = prs.flatMap<ChatBranchListEntry>((pr) => {
|
|
1190
|
+
const headRefName = pr.head?.ref?.trim()
|
|
1191
|
+
if (!headRefName) return []
|
|
1192
|
+
pullRequestHeadNames.add(headRefName)
|
|
1193
|
+
const cloneUrl = pr.head?.repo?.clone_url?.trim() || undefined
|
|
1194
|
+
const fullName = pr.head?.repo?.full_name?.trim() || undefined
|
|
1195
|
+
const headRepoSlug = fullName?.toLowerCase()
|
|
1196
|
+
const matchingRemoteEntries = (remoteEntriesByName.get(headRefName) ?? []).filter((entry) => {
|
|
1197
|
+
const remoteName = entry.remoteRef?.split("/")[0]
|
|
1198
|
+
if (!remoteName) return false
|
|
1199
|
+
const remoteSlug = githubRemoteSlugs.get(remoteName)
|
|
1200
|
+
if (!remoteSlug) return false
|
|
1201
|
+
if (headRepoSlug) {
|
|
1202
|
+
return remoteSlug === headRepoSlug
|
|
1203
|
+
}
|
|
1204
|
+
return remoteName === "origin"
|
|
1205
|
+
})
|
|
1206
|
+
for (const entry of matchingRemoteEntries) {
|
|
1207
|
+
if (entry.remoteRef) {
|
|
1208
|
+
pullRequestRemoteRefs.add(entry.remoteRef)
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const preferredRemoteEntry = matchingRemoteEntries[0] ?? remoteByName.get(headRefName)
|
|
1212
|
+
const remoteRef = preferredRemoteEntry?.remoteRef ?? `origin/${headRefName}`
|
|
1213
|
+
return {
|
|
1214
|
+
id: `pr:${pr.number}`,
|
|
1215
|
+
kind: "pull_request",
|
|
1216
|
+
name: headRefName,
|
|
1217
|
+
displayName: `PR #${pr.number}`,
|
|
1218
|
+
updatedAt: (remoteRef ? remoteUpdatedAtMap.get(remoteRef) : undefined) ?? localUpdatedAtMap.get(headRefName),
|
|
1219
|
+
description: pr.title,
|
|
1220
|
+
remoteRef,
|
|
1221
|
+
prNumber: pr.number,
|
|
1222
|
+
prTitle: pr.title,
|
|
1223
|
+
headRefName,
|
|
1224
|
+
headLabel: pr.head?.label?.trim() || fullName,
|
|
1225
|
+
headRepoCloneUrl: cloneUrl,
|
|
1226
|
+
isCrossRepository: Boolean(fullName && fullName.toLowerCase() !== repoSlug.toLowerCase()),
|
|
1227
|
+
} satisfies ChatBranchListEntry
|
|
1228
|
+
})
|
|
1229
|
+
pullRequestsStatus = "available"
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
pullRequestsStatus = "error"
|
|
1232
|
+
pullRequestsError = error instanceof Error ? error.message : String(error)
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const visibleRemote = remote.filter((entry) => {
|
|
1237
|
+
if (pullRequestHeadNames.has(entry.name)) {
|
|
1238
|
+
return false
|
|
1239
|
+
}
|
|
1240
|
+
return !entry.remoteRef || !pullRequestRemoteRefs.has(entry.remoteRef)
|
|
1241
|
+
})
|
|
1242
|
+
const visibleRemoteByName = new Map(visibleRemote.map((entry) => [entry.name, entry]))
|
|
1243
|
+
const visibleRecent = recent.filter((entry) => entry.kind !== "remote" || !entry.remoteRef || visibleRemoteByName.has(entry.name))
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
currentBranchName,
|
|
1247
|
+
defaultBranchName,
|
|
1248
|
+
recent: visibleRecent,
|
|
1249
|
+
local,
|
|
1250
|
+
remote: visibleRemote,
|
|
1251
|
+
pullRequests,
|
|
1252
|
+
pullRequestsStatus,
|
|
1253
|
+
pullRequestsError,
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async previewMergeBranch(args: {
|
|
1258
|
+
projectPath: string
|
|
1259
|
+
branch: SelectedBranch
|
|
1260
|
+
}): Promise<ChatMergePreviewResult> {
|
|
1261
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1262
|
+
if (!repo) {
|
|
1263
|
+
throw new Error("Project is not in a git repository")
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const currentBranchName = await getBranchName(repo.repoRoot)
|
|
1267
|
+
const resolvedBranch = await resolveSelectedBranchRef(repo.repoRoot, args.branch)
|
|
1268
|
+
|
|
1269
|
+
if (currentBranchName && resolvedBranch.branchName === currentBranchName) {
|
|
1270
|
+
return {
|
|
1271
|
+
currentBranchName,
|
|
1272
|
+
targetBranchName: resolvedBranch.branchName,
|
|
1273
|
+
targetDisplayName: resolvedBranch.displayName,
|
|
1274
|
+
status: "up_to_date",
|
|
1275
|
+
commitCount: 0,
|
|
1276
|
+
hasConflicts: false,
|
|
1277
|
+
message: `${currentBranchName} is already up to date with ${resolvedBranch.displayName}.`,
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
try {
|
|
1282
|
+
const commitCount = await getMergeCommitCount(repo.repoRoot, resolvedBranch.ref)
|
|
1283
|
+
if (commitCount === 0) {
|
|
1284
|
+
return {
|
|
1285
|
+
currentBranchName,
|
|
1286
|
+
targetBranchName: resolvedBranch.branchName,
|
|
1287
|
+
targetDisplayName: resolvedBranch.displayName,
|
|
1288
|
+
status: "up_to_date",
|
|
1289
|
+
commitCount,
|
|
1290
|
+
hasConflicts: false,
|
|
1291
|
+
message: `${currentBranchName ?? "Current branch"} is already up to date with ${resolvedBranch.displayName}.`,
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const conflictPrediction = await predictMergeConflicts(repo.repoRoot, resolvedBranch.ref)
|
|
1296
|
+
if (conflictPrediction.hasConflicts) {
|
|
1297
|
+
return {
|
|
1298
|
+
currentBranchName,
|
|
1299
|
+
targetBranchName: resolvedBranch.branchName,
|
|
1300
|
+
targetDisplayName: resolvedBranch.displayName,
|
|
1301
|
+
status: "conflicts",
|
|
1302
|
+
commitCount,
|
|
1303
|
+
hasConflicts: true,
|
|
1304
|
+
message: `${commitCount} ${commitCount === 1 ? "commit" : "commits"} from ${resolvedBranch.displayName} would merge into ${currentBranchName ?? "the current branch"}, but conflicts are expected.`,
|
|
1305
|
+
detail: conflictPrediction.detail,
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
return {
|
|
1310
|
+
currentBranchName,
|
|
1311
|
+
targetBranchName: resolvedBranch.branchName,
|
|
1312
|
+
targetDisplayName: resolvedBranch.displayName,
|
|
1313
|
+
status: "mergeable",
|
|
1314
|
+
commitCount,
|
|
1315
|
+
hasConflicts: false,
|
|
1316
|
+
message: `${commitCount} ${commitCount === 1 ? "commit" : "commits"} from ${resolvedBranch.displayName} will merge into ${currentBranchName ?? "the current branch"}.`,
|
|
1317
|
+
}
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1320
|
+
return {
|
|
1321
|
+
currentBranchName,
|
|
1322
|
+
targetBranchName: resolvedBranch.branchName,
|
|
1323
|
+
targetDisplayName: resolvedBranch.displayName,
|
|
1324
|
+
status: "error",
|
|
1325
|
+
commitCount: 0,
|
|
1326
|
+
hasConflicts: false,
|
|
1327
|
+
message: "Could not preview this merge.",
|
|
1328
|
+
detail: message,
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
async mergeBranch(args: {
|
|
1334
|
+
projectId: string
|
|
1335
|
+
projectPath: string
|
|
1336
|
+
branch: SelectedBranch
|
|
1337
|
+
}): Promise<ChatMergeBranchResult> {
|
|
1338
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1339
|
+
if (!repo) {
|
|
1340
|
+
throw new Error("Project is not in a git repository")
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const currentDirtyPaths = await listDirtyPaths(repo.repoRoot)
|
|
1344
|
+
if (currentDirtyPaths.length > 0) {
|
|
1345
|
+
return {
|
|
1346
|
+
ok: false,
|
|
1347
|
+
title: "Merge blocked",
|
|
1348
|
+
message: "Commit, discard, or stash your local changes before merging.",
|
|
1349
|
+
snapshotChanged: false,
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const resolvedBranch = await resolveSelectedBranchRef(repo.repoRoot, args.branch)
|
|
1354
|
+
const commitCount = await getMergeCommitCount(repo.repoRoot, resolvedBranch.ref)
|
|
1355
|
+
if (commitCount === 0) {
|
|
1356
|
+
return {
|
|
1357
|
+
ok: false,
|
|
1358
|
+
title: "Already up to date",
|
|
1359
|
+
message: `${resolvedBranch.displayName} is already merged into ${await getBranchName(repo.repoRoot) ?? "the current branch"}.`,
|
|
1360
|
+
snapshotChanged: false,
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const mergeResult = await runGit(["merge", "--no-edit", resolvedBranch.ref], repo.repoRoot)
|
|
1365
|
+
const detail = formatGitFailure(mergeResult)
|
|
1366
|
+
|
|
1367
|
+
if (mergeResult.exitCode !== 0) {
|
|
1368
|
+
const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
1369
|
+
const normalized = detail.toLowerCase()
|
|
1370
|
+
const title = normalized.includes("conflict")
|
|
1371
|
+
? "Merge conflicts need resolution"
|
|
1372
|
+
: "Merge failed"
|
|
1373
|
+
const fallback = normalized.includes("conflict")
|
|
1374
|
+
? "Git reported merge conflicts while merging this branch."
|
|
1375
|
+
: "Git could not merge this branch."
|
|
1376
|
+
return createMergeActionFailure({
|
|
1377
|
+
title,
|
|
1378
|
+
detail,
|
|
1379
|
+
fallback,
|
|
1380
|
+
snapshotChanged,
|
|
1381
|
+
})
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
1385
|
+
return {
|
|
1386
|
+
ok: true,
|
|
1387
|
+
branchName: await getBranchName(repo.repoRoot),
|
|
1388
|
+
snapshotChanged,
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
async checkoutBranch(args: {
|
|
1393
|
+
projectId: string
|
|
1394
|
+
projectPath: string
|
|
1395
|
+
branch: SelectedBranch
|
|
1396
|
+
bringChanges?: boolean
|
|
1397
|
+
}): Promise<ChatCheckoutBranchResult> {
|
|
1398
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1399
|
+
if (!repo) {
|
|
1400
|
+
throw new Error("Project is not in a git repository")
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const currentDirtyPaths = await listDirtyPaths(repo.repoRoot)
|
|
1404
|
+
if (currentDirtyPaths.length > 0 && !args.bringChanges) {
|
|
1405
|
+
return {
|
|
1406
|
+
ok: false,
|
|
1407
|
+
cancelled: true,
|
|
1408
|
+
title: "Branch switch cancelled",
|
|
1409
|
+
message: "Your current changes were kept on the current branch.",
|
|
1410
|
+
snapshotChanged: false,
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
let switchResult: Awaited<ReturnType<typeof runGit>>
|
|
1415
|
+
if (args.branch.kind === "local") {
|
|
1416
|
+
switchResult = await runGit(["switch", args.branch.name], repo.repoRoot)
|
|
1417
|
+
} else if (args.branch.kind === "remote") {
|
|
1418
|
+
const localBranchNames = await getLocalBranchNames(repo.repoRoot)
|
|
1419
|
+
if (localBranchNames.includes(args.branch.name)) {
|
|
1420
|
+
switchResult = await runGit(["switch", args.branch.name], repo.repoRoot)
|
|
1421
|
+
} else {
|
|
1422
|
+
switchResult = await runGit(["switch", "--track", "--no-guess", args.branch.remoteRef], repo.repoRoot)
|
|
1423
|
+
}
|
|
1424
|
+
} else {
|
|
1425
|
+
const localBranchNames = await getLocalBranchNames(repo.repoRoot)
|
|
1426
|
+
let localBranchName = args.branch.name
|
|
1427
|
+
|
|
1428
|
+
if (localBranchNames.includes(localBranchName) && args.branch.isCrossRepository) {
|
|
1429
|
+
localBranchName = `${args.branch.name}-pr-${args.branch.prNumber}`
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (localBranchNames.includes(localBranchName)) {
|
|
1433
|
+
switchResult = await runGit(["switch", localBranchName], repo.repoRoot)
|
|
1434
|
+
} else if (args.branch.isCrossRepository && args.branch.headRepoCloneUrl) {
|
|
1435
|
+
const fetchResult = await runGit(
|
|
1436
|
+
[
|
|
1437
|
+
"fetch",
|
|
1438
|
+
"--no-tags",
|
|
1439
|
+
args.branch.headRepoCloneUrl,
|
|
1440
|
+
`refs/heads/${args.branch.headRefName}:refs/heads/${localBranchName}`,
|
|
1441
|
+
],
|
|
1442
|
+
repo.repoRoot
|
|
1443
|
+
)
|
|
1444
|
+
if (fetchResult.exitCode !== 0) {
|
|
1445
|
+
return createBranchActionFailure("Checkout failed", formatGitFailure(fetchResult), "Git could not fetch the pull request branch.")
|
|
1446
|
+
}
|
|
1447
|
+
switchResult = await runGit(["switch", localBranchName], repo.repoRoot)
|
|
1448
|
+
} else {
|
|
1449
|
+
const remoteRef = args.branch.remoteRef ?? `origin/${args.branch.headRefName}`
|
|
1450
|
+
const remoteBranchNames = await getRemoteBranchNames(repo.repoRoot)
|
|
1451
|
+
if (!remoteBranchNames.includes(remoteRef)) {
|
|
1452
|
+
const fetchResult = await runGit(
|
|
1453
|
+
["fetch", "--no-tags", "origin", `refs/heads/${args.branch.headRefName}:refs/remotes/${remoteRef}`],
|
|
1454
|
+
repo.repoRoot
|
|
1455
|
+
)
|
|
1456
|
+
if (fetchResult.exitCode !== 0) {
|
|
1457
|
+
return createBranchActionFailure("Checkout failed", formatGitFailure(fetchResult), "Git could not fetch the pull request branch.")
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
switchResult = await runGit(["switch", "--track", "--no-guess", remoteRef], repo.repoRoot)
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (switchResult.exitCode !== 0) {
|
|
1465
|
+
return createBranchActionFailure("Checkout failed", formatGitFailure(switchResult), "Git could not switch branches.")
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
1469
|
+
return {
|
|
1470
|
+
ok: true,
|
|
1471
|
+
branchName: await getBranchName(repo.repoRoot),
|
|
1472
|
+
snapshotChanged,
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async createBranch(args: {
|
|
1477
|
+
projectId: string
|
|
1478
|
+
projectPath: string
|
|
1479
|
+
name: string
|
|
1480
|
+
baseBranchName?: string
|
|
1481
|
+
}): Promise<ChatCreateBranchResult> {
|
|
1482
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1483
|
+
if (!repo) {
|
|
1484
|
+
throw new Error("Project is not in a git repository")
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const branchName = args.name.trim()
|
|
1488
|
+
if (!branchName) {
|
|
1489
|
+
throw new Error("Branch name is required")
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const refValidation = await runGit(["check-ref-format", "--branch", branchName], repo.repoRoot)
|
|
1493
|
+
if (refValidation.exitCode !== 0) {
|
|
1494
|
+
return createBranchActionFailure("Create branch failed", formatGitFailure(refValidation), "Branch name is not valid.")
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const localBranchNames = await getLocalBranchNames(repo.repoRoot)
|
|
1498
|
+
if (localBranchNames.includes(branchName)) {
|
|
1499
|
+
return {
|
|
1500
|
+
ok: false,
|
|
1501
|
+
title: "Create branch failed",
|
|
1502
|
+
message: `A local branch named "${branchName}" already exists.`,
|
|
1503
|
+
snapshotChanged: false,
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const baseBranchName = args.baseBranchName?.trim() || await resolveDefaultBranchName(repo.repoRoot) || await getBranchName(repo.repoRoot)
|
|
1508
|
+
if (!baseBranchName) {
|
|
1509
|
+
throw new Error("Could not determine a base branch")
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const switchResult = await runGit(["switch", "-c", branchName, baseBranchName], repo.repoRoot)
|
|
1513
|
+
if (switchResult.exitCode !== 0) {
|
|
1514
|
+
return createBranchActionFailure("Create branch failed", formatGitFailure(switchResult), "Git could not create the branch.")
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
1518
|
+
return {
|
|
1519
|
+
ok: true,
|
|
1520
|
+
branchName,
|
|
1521
|
+
snapshotChanged,
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
async syncBranch(args: {
|
|
1526
|
+
projectId: string
|
|
1527
|
+
projectPath: string
|
|
1528
|
+
action: "fetch" | "pull" | "push" | "publish"
|
|
1529
|
+
}): Promise<ChatSyncResult> {
|
|
1530
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1531
|
+
if (!repo) {
|
|
1532
|
+
throw new Error("Project is not in a git repository")
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1536
|
+
if (args.action === "publish") {
|
|
1537
|
+
const publishResult = await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
|
|
1538
|
+
if (publishResult.exitCode !== 0) {
|
|
1539
|
+
const detail = formatGitFailure(publishResult)
|
|
1540
|
+
const normalized = detail.toLowerCase()
|
|
1541
|
+
let title = "Publish branch failed"
|
|
1542
|
+
let message = summarizeGitFailure(detail, "Git could not publish this branch.")
|
|
1543
|
+
|
|
1544
|
+
if (normalized.includes("could not read from remote repository") || normalized.includes("authentication failed") || normalized.includes("permission denied")) {
|
|
1545
|
+
title = "Remote authentication failed"
|
|
1546
|
+
message = "Git could not authenticate with the remote repository."
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
return {
|
|
1550
|
+
ok: false,
|
|
1551
|
+
action: args.action,
|
|
1552
|
+
title,
|
|
1553
|
+
message,
|
|
1554
|
+
detail,
|
|
1555
|
+
snapshotChanged: false,
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
1560
|
+
const branchName = await getBranchName(repo.repoRoot)
|
|
1561
|
+
const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1562
|
+
const { aheadCount, behindCount } = nextHasUpstream
|
|
1563
|
+
? await getUpstreamStatusCounts(repo.repoRoot)
|
|
1564
|
+
: { aheadCount: undefined, behindCount: undefined }
|
|
1565
|
+
|
|
1566
|
+
return {
|
|
1567
|
+
ok: true,
|
|
1568
|
+
action: args.action,
|
|
1569
|
+
branchName,
|
|
1570
|
+
aheadCount,
|
|
1571
|
+
behindCount,
|
|
1572
|
+
snapshotChanged,
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (args.action === "push") {
|
|
1577
|
+
if (!hasUpstream) {
|
|
1578
|
+
return {
|
|
1579
|
+
ok: false,
|
|
1580
|
+
action: args.action,
|
|
1581
|
+
title: "Push failed",
|
|
1582
|
+
message: "This branch does not have an upstream remote branch configured yet.",
|
|
1583
|
+
snapshotChanged: false,
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
const pushResult = await runGit(["push"], repo.repoRoot)
|
|
1588
|
+
if (pushResult.exitCode !== 0) {
|
|
1589
|
+
const detail = formatGitFailure(pushResult)
|
|
1590
|
+
return createSyncPushFailure(detail, false)
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
1594
|
+
const branchName = await getBranchName(repo.repoRoot)
|
|
1595
|
+
const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1596
|
+
const { aheadCount, behindCount } = nextHasUpstream
|
|
1597
|
+
? await getUpstreamStatusCounts(repo.repoRoot)
|
|
1598
|
+
: { aheadCount: undefined, behindCount: undefined }
|
|
1599
|
+
|
|
1600
|
+
return {
|
|
1601
|
+
ok: true,
|
|
1602
|
+
action: args.action,
|
|
1603
|
+
branchName,
|
|
1604
|
+
aheadCount,
|
|
1605
|
+
behindCount,
|
|
1606
|
+
snapshotChanged,
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
if (args.action === "pull" && !hasUpstream) {
|
|
1611
|
+
return {
|
|
1612
|
+
ok: false,
|
|
1613
|
+
action: args.action,
|
|
1614
|
+
title: "Pull failed",
|
|
1615
|
+
message: "This branch does not have an upstream remote branch configured yet.",
|
|
1616
|
+
snapshotChanged: false,
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
const syncResult = args.action === "pull"
|
|
1621
|
+
? await runGit(["pull", "--ff-only"], repo.repoRoot)
|
|
1622
|
+
: await runGit(["fetch", "--all", "--prune"], repo.repoRoot)
|
|
1623
|
+
|
|
1624
|
+
if (syncResult.exitCode !== 0) {
|
|
1625
|
+
const detail = formatGitFailure(syncResult)
|
|
1626
|
+
const normalized = detail.toLowerCase()
|
|
1627
|
+
let title = args.action === "pull" ? "Pull failed" : "Fetch failed"
|
|
1628
|
+
let message = summarizeGitFailure(detail, args.action === "pull" ? "Git could not pull the latest changes." : "Git could not fetch the latest changes.")
|
|
1629
|
+
|
|
1630
|
+
if (args.action === "pull" && normalized.includes("not possible to fast-forward")) {
|
|
1631
|
+
title = "Pull requires merge or rebase"
|
|
1632
|
+
message = "Your branch cannot be fast-forwarded. Rebase or merge manually, then try again."
|
|
1633
|
+
} else if (normalized.includes("could not read from remote repository") || normalized.includes("authentication failed") || normalized.includes("permission denied")) {
|
|
1634
|
+
title = "Remote authentication failed"
|
|
1635
|
+
message = "Git could not authenticate with the remote repository."
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
return {
|
|
1639
|
+
ok: false,
|
|
1640
|
+
action: args.action,
|
|
1641
|
+
title,
|
|
1642
|
+
message,
|
|
1643
|
+
detail,
|
|
1644
|
+
snapshotChanged: false,
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
1649
|
+
const branchName = await getBranchName(repo.repoRoot)
|
|
1650
|
+
const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1651
|
+
const { aheadCount, behindCount } = nextHasUpstream
|
|
1652
|
+
? await getUpstreamStatusCounts(repo.repoRoot)
|
|
1653
|
+
: { aheadCount: undefined, behindCount: undefined }
|
|
1654
|
+
|
|
1655
|
+
return {
|
|
1656
|
+
ok: true,
|
|
1657
|
+
action: args.action,
|
|
1658
|
+
branchName,
|
|
1659
|
+
aheadCount,
|
|
1660
|
+
behindCount,
|
|
1661
|
+
snapshotChanged,
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
417
1665
|
async generateCommitMessage(args: {
|
|
418
1666
|
projectPath: string
|
|
419
1667
|
paths: string[]
|
|
@@ -428,14 +1676,24 @@ export class DiffStore {
|
|
|
428
1676
|
throw new Error("Project is not in a git repository")
|
|
429
1677
|
}
|
|
430
1678
|
|
|
431
|
-
const
|
|
432
|
-
const selectedFiles = normalizedPaths.map((selectedPath) => {
|
|
433
|
-
const
|
|
434
|
-
if (!
|
|
1679
|
+
const currentDirtyPaths = await listDirtyPaths(repo.repoRoot)
|
|
1680
|
+
const selectedFiles = await Promise.all(normalizedPaths.map(async (selectedPath) => {
|
|
1681
|
+
const entry = currentDirtyPaths.find((candidate) => candidate.path === selectedPath)
|
|
1682
|
+
if (!entry) {
|
|
435
1683
|
throw new Error(`File is no longer changed: ${selectedPath}`)
|
|
436
1684
|
}
|
|
437
|
-
|
|
438
|
-
|
|
1685
|
+
|
|
1686
|
+
const beforePath = entry.previousPath ?? selectedPath
|
|
1687
|
+
const beforeText = await readBaseFile(repo.repoRoot, repo.baseCommit, beforePath)
|
|
1688
|
+
const afterText = await readWorktreeFile(repo.repoRoot, selectedPath)
|
|
1689
|
+
const patch = await createPatch(beforePath, selectedPath, beforeText, afterText)
|
|
1690
|
+
|
|
1691
|
+
return {
|
|
1692
|
+
path: selectedPath,
|
|
1693
|
+
changeType: entry.changeType,
|
|
1694
|
+
patch,
|
|
1695
|
+
}
|
|
1696
|
+
}))
|
|
439
1697
|
|
|
440
1698
|
const branchName = await getBranchName(repo.repoRoot)
|
|
441
1699
|
return await generateCommitMessageDetailed({
|
|
@@ -446,7 +1704,7 @@ export class DiffStore {
|
|
|
446
1704
|
}
|
|
447
1705
|
|
|
448
1706
|
async commitFiles(args: {
|
|
449
|
-
|
|
1707
|
+
projectId: string
|
|
450
1708
|
projectPath: string
|
|
451
1709
|
paths: string[]
|
|
452
1710
|
summary: string
|
|
@@ -468,6 +1726,7 @@ export class DiffStore {
|
|
|
468
1726
|
if (!repo) {
|
|
469
1727
|
throw new Error("Project is not in a git repository")
|
|
470
1728
|
}
|
|
1729
|
+
const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
471
1730
|
|
|
472
1731
|
const currentDirtyPaths = new Set((await listDirtyPaths(repo.repoRoot)).map((entry) => entry.path))
|
|
473
1732
|
const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPaths.has(relativePath))
|
|
@@ -491,7 +1750,7 @@ export class DiffStore {
|
|
|
491
1750
|
return createCommitFailure(args.mode, formatGitFailure(commitResult))
|
|
492
1751
|
}
|
|
493
1752
|
|
|
494
|
-
const snapshotChanged = await this.refreshSnapshot(args.
|
|
1753
|
+
const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
495
1754
|
const branchName = await getBranchName(repo.repoRoot)
|
|
496
1755
|
|
|
497
1756
|
if (args.mode === "commit_only") {
|
|
@@ -504,22 +1763,26 @@ export class DiffStore {
|
|
|
504
1763
|
} satisfies DiffCommitResult
|
|
505
1764
|
}
|
|
506
1765
|
|
|
507
|
-
const pushResult =
|
|
1766
|
+
const pushResult = hasUpstream
|
|
1767
|
+
? await runGit(["push"], repo.repoRoot)
|
|
1768
|
+
: await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
|
|
508
1769
|
if (pushResult.exitCode !== 0) {
|
|
509
1770
|
return createPushFailure(args.mode, formatGitFailure(pushResult), snapshotChanged)
|
|
510
1771
|
}
|
|
511
1772
|
|
|
1773
|
+
const postPushSnapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
|
|
1774
|
+
|
|
512
1775
|
return {
|
|
513
1776
|
ok: true,
|
|
514
1777
|
mode: args.mode,
|
|
515
1778
|
branchName,
|
|
516
1779
|
pushed: true,
|
|
517
|
-
snapshotChanged,
|
|
1780
|
+
snapshotChanged: snapshotChanged || postPushSnapshotChanged,
|
|
518
1781
|
} satisfies DiffCommitResult
|
|
519
1782
|
}
|
|
520
1783
|
|
|
521
1784
|
async discardFile(args: {
|
|
522
|
-
|
|
1785
|
+
projectId: string
|
|
523
1786
|
projectPath: string
|
|
524
1787
|
path: string
|
|
525
1788
|
}) {
|
|
@@ -555,12 +1818,12 @@ export class DiffStore {
|
|
|
555
1818
|
}
|
|
556
1819
|
|
|
557
1820
|
return {
|
|
558
|
-
snapshotChanged: await this.refreshSnapshot(args.
|
|
1821
|
+
snapshotChanged: await this.refreshSnapshot(args.projectId, args.projectPath),
|
|
559
1822
|
}
|
|
560
1823
|
}
|
|
561
1824
|
|
|
562
1825
|
async ignoreFile(args: {
|
|
563
|
-
|
|
1826
|
+
projectId: string
|
|
564
1827
|
projectPath: string
|
|
565
1828
|
path: string
|
|
566
1829
|
}) {
|
|
@@ -586,7 +1849,7 @@ export class DiffStore {
|
|
|
586
1849
|
}
|
|
587
1850
|
|
|
588
1851
|
return {
|
|
589
|
-
snapshotChanged: await this.refreshSnapshot(args.
|
|
1852
|
+
snapshotChanged: await this.refreshSnapshot(args.projectId, args.projectPath),
|
|
590
1853
|
}
|
|
591
1854
|
}
|
|
592
1855
|
}
|