kanna-code 0.23.1 → 0.25.0

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