kanna-code 0.23.1 → 0.24.0

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