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.
@@ -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 { ChatDiffFile, ChatDiffSnapshot, DiffCommitMode, DiffCommitResult } from "../shared/types"
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") || statusCode === "??"
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
  }