kanna-code 0.23.0 → 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 +346 -2
- package/src/server/diff-store.ts +938 -3
- package/src/server/generate-commit-message.test.ts +2 -0
- package/src/server/generate-commit-message.ts +2 -2
- package/src/server/ws-router.test.ts +198 -14
- package/src/server/ws-router.ts +125 -2
- package/src/shared/protocol.ts +22 -0
- package/src/shared/types.ts +103 -0
- package/dist/client/assets/index-BGQxheCc.js +0 -2511
- package/dist/client/assets/index-DKD9EePK.css +0 -32
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)
|
|
@@ -42,6 +85,7 @@ interface DirtyPathEntry {
|
|
|
42
85
|
path: string
|
|
43
86
|
previousPath?: string
|
|
44
87
|
changeType: ChatDiffFile["changeType"]
|
|
88
|
+
isUntracked: boolean
|
|
45
89
|
}
|
|
46
90
|
|
|
47
91
|
async function fileExists(filePath: string) {
|
|
@@ -71,6 +115,24 @@ async function runGit(args: string[], cwd: string) {
|
|
|
71
115
|
}
|
|
72
116
|
}
|
|
73
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
|
+
|
|
74
136
|
function formatGitFailure(result: Awaited<ReturnType<typeof runGit>>) {
|
|
75
137
|
return [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n")
|
|
76
138
|
}
|
|
@@ -159,6 +221,312 @@ async function hasUpstreamBranch(repoRoot: string) {
|
|
|
159
221
|
return upstream.exitCode === 0 && upstream.stdout.trim().length > 0
|
|
160
222
|
}
|
|
161
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
|
+
|
|
162
530
|
function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
163
531
|
const entries: DirtyPathEntry[] = []
|
|
164
532
|
for (const rawLine of output.split(/\r?\n/u)) {
|
|
@@ -167,9 +535,10 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
|
167
535
|
const statusCode = line.slice(0, 2)
|
|
168
536
|
const value = line.slice(3)
|
|
169
537
|
if (!value) continue
|
|
538
|
+
const isUntracked = statusCode === "??"
|
|
170
539
|
const isRename = statusCode.includes("R")
|
|
171
540
|
const isDelete = statusCode.includes("D")
|
|
172
|
-
const isAdd = statusCode.includes("A") ||
|
|
541
|
+
const isAdd = statusCode.includes("A") || isUntracked
|
|
173
542
|
const changeType: ChatDiffFile["changeType"] = isRename
|
|
174
543
|
? "renamed"
|
|
175
544
|
: isDelete
|
|
@@ -185,6 +554,7 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
|
185
554
|
path: nextPath,
|
|
186
555
|
previousPath: previousPath || undefined,
|
|
187
556
|
changeType,
|
|
557
|
+
isUntracked,
|
|
188
558
|
})
|
|
189
559
|
}
|
|
190
560
|
continue
|
|
@@ -193,6 +563,7 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
|
193
563
|
entries.push({
|
|
194
564
|
path: value,
|
|
195
565
|
changeType,
|
|
566
|
+
isUntracked,
|
|
196
567
|
})
|
|
197
568
|
}
|
|
198
569
|
return entries.sort((left, right) => left.path.localeCompare(right.path))
|
|
@@ -289,6 +660,7 @@ async function computeCurrentFiles(repoRoot: string, baseCommit: string | null):
|
|
|
289
660
|
files.push({
|
|
290
661
|
path: relativePath,
|
|
291
662
|
changeType: entry.changeType,
|
|
663
|
+
isUntracked: entry.isUntracked,
|
|
292
664
|
patch,
|
|
293
665
|
mimeType,
|
|
294
666
|
size,
|
|
@@ -306,6 +678,64 @@ function normalizeRepoRelativePath(inputPath: string) {
|
|
|
306
678
|
return normalized
|
|
307
679
|
}
|
|
308
680
|
|
|
681
|
+
async function findDirtyPath(repoRoot: string, relativePath: string) {
|
|
682
|
+
const dirtyPaths = await listDirtyPaths(repoRoot)
|
|
683
|
+
return dirtyPaths.find((entry) => entry.path === relativePath)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function discardAddedPath(repoRoot: string, repoHasHead: boolean, relativePath: string) {
|
|
687
|
+
if (repoHasHead) {
|
|
688
|
+
const resetResult = await runGit(["reset", "--quiet", "HEAD", "--", relativePath], repoRoot)
|
|
689
|
+
if (resetResult.exitCode !== 0) {
|
|
690
|
+
throw new Error(formatGitFailure(resetResult) || "Failed to unstage added file")
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
const rmCachedResult = await runGit(["rm", "--cached", "--force", "--", relativePath], repoRoot)
|
|
694
|
+
if (rmCachedResult.exitCode !== 0) {
|
|
695
|
+
throw new Error(formatGitFailure(rmCachedResult) || "Failed to unstage added file")
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function discardRenamedPath(repoRoot: string, entry: DirtyPathEntry) {
|
|
701
|
+
if (!entry.previousPath) {
|
|
702
|
+
throw new Error(`Missing previous path for renamed file: ${entry.path}`)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const resetResult = await runGit(["reset", "--quiet", "HEAD", "--", entry.path], repoRoot)
|
|
706
|
+
if (resetResult.exitCode !== 0) {
|
|
707
|
+
throw new Error(formatGitFailure(resetResult) || "Failed to unstage renamed file")
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.previousPath], repoRoot)
|
|
711
|
+
if (restoreResult.exitCode !== 0) {
|
|
712
|
+
throw new Error(formatGitFailure(restoreResult) || "Failed to restore renamed file")
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
await rm(path.join(repoRoot, entry.path), { recursive: true, force: true })
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function appendGitIgnoreEntry(currentContents: string | null, entry: string) {
|
|
719
|
+
const normalizedContents = currentContents ?? ""
|
|
720
|
+
const existingEntries = normalizedContents
|
|
721
|
+
.split(/\r?\n/u)
|
|
722
|
+
.map((line) => line.trim())
|
|
723
|
+
.filter(Boolean)
|
|
724
|
+
|
|
725
|
+
if (existingEntries.includes(entry)) {
|
|
726
|
+
return normalizedContents.length > 0 && !normalizedContents.endsWith("\n")
|
|
727
|
+
? `${normalizedContents}\n`
|
|
728
|
+
: normalizedContents
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const prefix = normalizedContents.length === 0
|
|
732
|
+
? ""
|
|
733
|
+
: normalizedContents.endsWith("\n")
|
|
734
|
+
? normalizedContents
|
|
735
|
+
: `${normalizedContents}\n`
|
|
736
|
+
return `${prefix}${entry}\n`
|
|
737
|
+
}
|
|
738
|
+
|
|
309
739
|
export class DiffStore {
|
|
310
740
|
private readonly states = new Map<string, StoredChatDiffState>()
|
|
311
741
|
|
|
@@ -318,8 +748,19 @@ export class DiffStore {
|
|
|
318
748
|
return {
|
|
319
749
|
status: state.status,
|
|
320
750
|
branchName: state.branchName,
|
|
751
|
+
defaultBranchName: state.defaultBranchName,
|
|
752
|
+
originRepoSlug: state.originRepoSlug,
|
|
321
753
|
hasUpstream: state.hasUpstream,
|
|
754
|
+
aheadCount: state.aheadCount,
|
|
755
|
+
behindCount: state.behindCount,
|
|
756
|
+
lastFetchedAt: state.lastFetchedAt,
|
|
322
757
|
files: [...state.files],
|
|
758
|
+
branchHistory: {
|
|
759
|
+
entries: state.branchHistory.entries.map((entry) => ({
|
|
760
|
+
...entry,
|
|
761
|
+
tags: [...entry.tags],
|
|
762
|
+
})),
|
|
763
|
+
},
|
|
323
764
|
}
|
|
324
765
|
}
|
|
325
766
|
|
|
@@ -329,8 +770,14 @@ export class DiffStore {
|
|
|
329
770
|
const nextState = {
|
|
330
771
|
status: "no_repo",
|
|
331
772
|
branchName: undefined,
|
|
773
|
+
defaultBranchName: undefined,
|
|
774
|
+
originRepoSlug: undefined,
|
|
332
775
|
hasUpstream: undefined,
|
|
776
|
+
aheadCount: undefined,
|
|
777
|
+
behindCount: undefined,
|
|
778
|
+
lastFetchedAt: undefined,
|
|
333
779
|
files: [],
|
|
780
|
+
branchHistory: { entries: [] },
|
|
334
781
|
} satisfies StoredChatDiffState
|
|
335
782
|
const changed = !snapshotsEqual(this.states.get(chatId), nextState)
|
|
336
783
|
this.states.set(chatId, nextState)
|
|
@@ -339,18 +786,432 @@ export class DiffStore {
|
|
|
339
786
|
|
|
340
787
|
const files = await computeCurrentFiles(repo.repoRoot, repo.baseCommit)
|
|
341
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
|
|
342
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: [] }
|
|
343
804
|
const nextState = {
|
|
344
805
|
status: "ready",
|
|
345
806
|
branchName,
|
|
807
|
+
defaultBranchName,
|
|
808
|
+
originRepoSlug,
|
|
346
809
|
hasUpstream,
|
|
810
|
+
aheadCount,
|
|
811
|
+
behindCount,
|
|
812
|
+
lastFetchedAt,
|
|
347
813
|
files,
|
|
814
|
+
branchHistory,
|
|
348
815
|
} satisfies StoredChatDiffState
|
|
349
816
|
const changed = !snapshotsEqual(this.states.get(chatId), nextState)
|
|
350
817
|
this.states.set(chatId, nextState)
|
|
351
818
|
return changed
|
|
352
819
|
}
|
|
353
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
|
+
|
|
354
1215
|
async generateCommitMessage(args: {
|
|
355
1216
|
projectPath: string
|
|
356
1217
|
paths: string[]
|
|
@@ -446,12 +1307,86 @@ export class DiffStore {
|
|
|
446
1307
|
return createPushFailure(args.mode, formatGitFailure(pushResult), snapshotChanged)
|
|
447
1308
|
}
|
|
448
1309
|
|
|
1310
|
+
const postPushSnapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
|
|
1311
|
+
|
|
449
1312
|
return {
|
|
450
1313
|
ok: true,
|
|
451
1314
|
mode: args.mode,
|
|
452
1315
|
branchName,
|
|
453
1316
|
pushed: true,
|
|
454
|
-
snapshotChanged,
|
|
1317
|
+
snapshotChanged: snapshotChanged || postPushSnapshotChanged,
|
|
455
1318
|
} satisfies DiffCommitResult
|
|
456
1319
|
}
|
|
1320
|
+
|
|
1321
|
+
async discardFile(args: {
|
|
1322
|
+
chatId: string
|
|
1323
|
+
projectPath: string
|
|
1324
|
+
path: string
|
|
1325
|
+
}) {
|
|
1326
|
+
const relativePath = normalizeRepoRelativePath(args.path)
|
|
1327
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1328
|
+
if (!repo) {
|
|
1329
|
+
throw new Error("Project is not in a git repository")
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const entry = await findDirtyPath(repo.repoRoot, relativePath)
|
|
1333
|
+
if (!entry) {
|
|
1334
|
+
throw new Error(`File is no longer changed: ${relativePath}`)
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (entry.isUntracked) {
|
|
1338
|
+
await rm(path.join(repo.repoRoot, entry.path), { recursive: true, force: true })
|
|
1339
|
+
} else if (entry.changeType === "added") {
|
|
1340
|
+
await discardAddedPath(repo.repoRoot, repo.baseCommit !== null, entry.path)
|
|
1341
|
+
await rm(path.join(repo.repoRoot, entry.path), { recursive: true, force: true })
|
|
1342
|
+
} else if (entry.changeType === "renamed") {
|
|
1343
|
+
if (!repo.baseCommit) {
|
|
1344
|
+
throw new Error("Cannot discard a rename before the repository has an initial commit")
|
|
1345
|
+
}
|
|
1346
|
+
await discardRenamedPath(repo.repoRoot, entry)
|
|
1347
|
+
} else {
|
|
1348
|
+
if (!repo.baseCommit) {
|
|
1349
|
+
throw new Error("Cannot discard tracked changes before the repository has an initial commit")
|
|
1350
|
+
}
|
|
1351
|
+
const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.path], repo.repoRoot)
|
|
1352
|
+
if (restoreResult.exitCode !== 0) {
|
|
1353
|
+
throw new Error(formatGitFailure(restoreResult) || "Failed to discard file changes")
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
return {
|
|
1358
|
+
snapshotChanged: await this.refreshSnapshot(args.chatId, args.projectPath),
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
async ignoreFile(args: {
|
|
1363
|
+
chatId: string
|
|
1364
|
+
projectPath: string
|
|
1365
|
+
path: string
|
|
1366
|
+
}) {
|
|
1367
|
+
const relativePath = normalizeRepoRelativePath(args.path)
|
|
1368
|
+
const repo = await resolveRepo(args.projectPath)
|
|
1369
|
+
if (!repo) {
|
|
1370
|
+
throw new Error("Project is not in a git repository")
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const entry = await findDirtyPath(repo.repoRoot, relativePath)
|
|
1374
|
+
if (!entry) {
|
|
1375
|
+
throw new Error(`File is no longer changed: ${relativePath}`)
|
|
1376
|
+
}
|
|
1377
|
+
if (!entry.isUntracked) {
|
|
1378
|
+
throw new Error("Only untracked files can be ignored from the diff viewer")
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const gitignorePath = path.join(repo.repoRoot, ".gitignore")
|
|
1382
|
+
const currentContents = await readFile(gitignorePath, "utf8").catch(() => null)
|
|
1383
|
+
const nextContents = appendGitIgnoreEntry(currentContents, relativePath)
|
|
1384
|
+
if (nextContents !== currentContents) {
|
|
1385
|
+
await writeFile(gitignorePath, nextContents, "utf8")
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
return {
|
|
1389
|
+
snapshotChanged: await this.refreshSnapshot(args.chatId, args.projectPath),
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
457
1392
|
}
|