kanna-code 0.24.0 → 0.25.1

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,7 +1,9 @@
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
5
  import type {
6
+ BranchMetadata,
5
7
  ChatBranchHistoryEntry,
6
8
  ChatBranchHistorySnapshot,
7
9
  ChatBranchListEntry,
@@ -10,22 +12,18 @@ import type {
10
12
  ChatCreateBranchResult,
11
13
  ChatDiffFile,
12
14
  ChatDiffSnapshot,
15
+ ChatMergeBranchResult,
16
+ ChatMergePreviewResult,
13
17
  ChatSyncResult,
14
18
  DiffCommitMode,
15
19
  DiffCommitResult,
20
+ UpstreamStatus,
16
21
  } from "../shared/types"
17
22
  import { generateCommitMessageDetailed } from "./generate-commit-message"
18
23
  import { inferProjectFileContentType } from "./uploads"
19
24
 
20
- interface StoredChatDiffState {
25
+ interface StoredChatDiffState extends BranchMetadata, UpstreamStatus {
21
26
  status: ChatDiffSnapshot["status"]
22
- branchName?: string
23
- defaultBranchName?: string
24
- originRepoSlug?: string
25
- hasUpstream?: boolean
26
- aheadCount?: number
27
- behindCount?: number
28
- lastFetchedAt?: string
29
27
  files: ChatDiffFile[]
30
28
  branchHistory: ChatBranchHistorySnapshot
31
29
  }
@@ -45,22 +43,23 @@ function createEmptyState(): StoredChatDiffState {
45
43
  }
46
44
  }
47
45
 
48
- function snapshotsEqual(left: StoredChatDiffState | undefined, right: StoredChatDiffState) {
49
- if (!left) {
50
- return right.status === "unknown" && right.files.length === 0
51
- }
52
- if (left.status !== right.status) return false
53
- if (left.branchName !== right.branchName) return false
54
- if (left.defaultBranchName !== right.defaultBranchName) return false
55
- if (left.originRepoSlug !== right.originRepoSlug) return false
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
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]
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]
64
63
  return Boolean(other)
65
64
  && entry.sha === other.sha
66
65
  && entry.summary === other.summary
@@ -71,13 +70,28 @@ function snapshotsEqual(left: StoredChatDiffState | undefined, right: StoredChat
71
70
  && entry.tags.length === other.tags.length
72
71
  && entry.tags.every((tag, tagIndex) => tag === other.tags[tagIndex])
73
72
  })
74
- if (!sameHistory) return false
73
+ }
74
+
75
+ function snapshotsEqual(left: StoredChatDiffState | undefined, right: StoredChatDiffState) {
76
+ if (!left) {
77
+ return right.status === "unknown" && right.files.length === 0
78
+ }
79
+ if (left.status !== right.status) return false
80
+ if (!branchMetadataEqual(left, right)) return false
81
+ if (!upstreamStatusEqual(left, right)) return false
82
+ if (left.files.length !== right.files.length) return false
83
+ if (!branchHistoryEqual(left.branchHistory, right.branchHistory)) return false
75
84
  return left.files.every((file, index) => {
76
85
  const other = right.files[index]
77
86
  return Boolean(other)
78
87
  && file.path === other.path
79
88
  && file.changeType === other.changeType
80
- && 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
81
95
  })
82
96
  }
83
97
 
@@ -88,6 +102,19 @@ interface DirtyPathEntry {
88
102
  isUntracked: boolean
89
103
  }
90
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
+
91
118
  async function fileExists(filePath: string) {
92
119
  try {
93
120
  await stat(filePath)
@@ -188,6 +215,35 @@ function createPushFailure(mode: DiffCommitMode, detail: string, snapshotChanged
188
215
  }
189
216
  }
190
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
+
191
247
  async function resolveRepo(projectPath: string): Promise<{ repoRoot: string; baseCommit: string | null } | null> {
192
248
  const topLevel = await runGit(["rev-parse", "--show-toplevel"], projectPath)
193
249
  if (topLevel.exitCode !== 0) {
@@ -366,6 +422,89 @@ async function getRecentBranchNames(repoRoot: string) {
366
422
  return recent
367
423
  }
368
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
+
369
508
  export function extractGitHubRepoSlug(remoteUrl: string | null | undefined) {
370
509
  if (!remoteUrl) return null
371
510
 
@@ -464,16 +603,40 @@ function buildGitHubCommitUrl(remoteUrl: string | null, sha: string) {
464
603
  return slug ? `https://github.com/${slug}/commit/${sha}` : undefined
465
604
  }
466
605
 
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 []
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, [])
471
612
  }
472
- return result.stdout
473
- .split(/\r?\n/u)
474
- .map((line) => line.trim())
475
- .filter(Boolean)
476
- .sort((left, right) => left.localeCompare(right))
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
477
640
  }
478
641
 
479
642
  async function getBranchHistory(args: {
@@ -497,24 +660,30 @@ async function getBranchHistory(args: {
497
660
  }
498
661
 
499
662
  const remoteUrl = await getOriginRemoteUrl(args.repoRoot)
500
- const entries: ChatBranchHistoryEntry[] = []
663
+ const parsedRecords: Array<{ sha: string; summary: string; description: string; authorName?: string; authoredAt: string }> = []
501
664
 
502
665
  for (const record of logResult.stdout.split("\u001e")) {
503
666
  const trimmed = record.trim()
504
667
  if (!trimmed) continue
505
668
  const [sha, summary, description, authorName, authoredAt] = trimmed.split("\u001f")
506
669
  if (!sha || !summary || !authoredAt) continue
507
- entries.push({
670
+ parsedRecords.push({
508
671
  sha,
509
672
  summary,
510
673
  description: (description ?? "").trim(),
511
674
  authorName: authorName?.trim() || undefined,
512
675
  authoredAt,
513
- tags: await listCommitTags(args.repoRoot, sha),
514
- githubUrl: buildGitHubCommitUrl(remoteUrl, sha),
515
676
  })
516
677
  }
517
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
+
518
687
  return { entries }
519
688
  }
520
689
 
@@ -527,6 +696,21 @@ function createBranchActionFailure(title: string, detail: string, fallback: stri
527
696
  } as const
528
697
  }
529
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
+
530
714
  function parseStatusPaths(output: string): DirtyPathEntry[] {
531
715
  const entries: DirtyPathEntry[] = []
532
716
  for (const rawLine of output.split(/\r?\n/u)) {
@@ -637,8 +821,83 @@ async function createPatch(beforePathLabel: string, afterPathLabel: string, befo
637
821
  }
638
822
  }
639
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
+
640
898
  async function computeCurrentFiles(repoRoot: string, baseCommit: string | null): Promise<ChatDiffFile[]> {
641
899
  const currentDirtyPaths = await listDirtyPaths(repoRoot)
900
+ const trackedStatsByPath = await getTrackedDiffStats(repoRoot, baseCommit)
642
901
  const files: ChatDiffFile[] = []
643
902
 
644
903
  for (const entry of currentDirtyPaths) {
@@ -656,12 +915,22 @@ async function computeCurrentFiles(repoRoot: string, baseCommit: string | null):
656
915
  continue
657
916
  }
658
917
 
659
- 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
660
921
  files.push({
661
922
  path: relativePath,
662
923
  changeType: entry.changeType,
663
924
  isUntracked: entry.isUntracked,
664
- patch,
925
+ additions,
926
+ deletions,
927
+ patchDigest: getContentDigest({
928
+ changeType: entry.changeType,
929
+ beforePath,
930
+ afterPath: relativePath,
931
+ beforeText,
932
+ afterText,
933
+ }),
665
934
  mimeType,
666
935
  size,
667
936
  })
@@ -743,8 +1012,31 @@ export class DiffStore {
743
1012
 
744
1013
  async initialize() {}
745
1014
 
746
- getSnapshot(chatId: string): ChatDiffSnapshot {
747
- 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()
748
1040
  return {
749
1041
  status: state.status,
750
1042
  branchName: state.branchName,
@@ -764,7 +1056,7 @@ export class DiffStore {
764
1056
  }
765
1057
  }
766
1058
 
767
- async refreshSnapshot(chatId: string, projectPath: string) {
1059
+ async refreshSnapshot(projectId: string, projectPath: string) {
768
1060
  const repo = await resolveRepo(projectPath)
769
1061
  if (!repo) {
770
1062
  const nextState = {
@@ -779,8 +1071,8 @@ export class DiffStore {
779
1071
  files: [],
780
1072
  branchHistory: { entries: [] },
781
1073
  } satisfies StoredChatDiffState
782
- const changed = !snapshotsEqual(this.states.get(chatId), nextState)
783
- this.states.set(chatId, nextState)
1074
+ const changed = !snapshotsEqual(this.states.get(projectId), nextState)
1075
+ this.states.set(projectId, nextState)
784
1076
  return changed
785
1077
  }
786
1078
 
@@ -813,8 +1105,8 @@ export class DiffStore {
813
1105
  files,
814
1106
  branchHistory,
815
1107
  } satisfies StoredChatDiffState
816
- const changed = !snapshotsEqual(this.states.get(chatId), nextState)
817
- this.states.set(chatId, nextState)
1108
+ const changed = !snapshotsEqual(this.states.get(projectId), nextState)
1109
+ this.states.set(projectId, nextState)
818
1110
  return changed
819
1111
  }
820
1112
 
@@ -962,21 +1254,145 @@ export class DiffStore {
962
1254
  }
963
1255
  }
964
1256
 
965
- async checkoutBranch(args: {
966
- chatId: string
1257
+ async previewMergeBranch(args: {
967
1258
  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
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}.`,
979
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
980
1396
  bringChanges?: boolean
981
1397
  }): Promise<ChatCheckoutBranchResult> {
982
1398
  const repo = await resolveRepo(args.projectPath)
@@ -1049,7 +1465,7 @@ export class DiffStore {
1049
1465
  return createBranchActionFailure("Checkout failed", formatGitFailure(switchResult), "Git could not switch branches.")
1050
1466
  }
1051
1467
 
1052
- const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
1468
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1053
1469
  return {
1054
1470
  ok: true,
1055
1471
  branchName: await getBranchName(repo.repoRoot),
@@ -1058,7 +1474,7 @@ export class DiffStore {
1058
1474
  }
1059
1475
 
1060
1476
  async createBranch(args: {
1061
- chatId: string
1477
+ projectId: string
1062
1478
  projectPath: string
1063
1479
  name: string
1064
1480
  baseBranchName?: string
@@ -1098,7 +1514,7 @@ export class DiffStore {
1098
1514
  return createBranchActionFailure("Create branch failed", formatGitFailure(switchResult), "Git could not create the branch.")
1099
1515
  }
1100
1516
 
1101
- const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
1517
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1102
1518
  return {
1103
1519
  ok: true,
1104
1520
  branchName,
@@ -1107,9 +1523,9 @@ export class DiffStore {
1107
1523
  }
1108
1524
 
1109
1525
  async syncBranch(args: {
1110
- chatId: string
1526
+ projectId: string
1111
1527
  projectPath: string
1112
- action: "fetch" | "pull" | "publish"
1528
+ action: "fetch" | "pull" | "push" | "publish"
1113
1529
  }): Promise<ChatSyncResult> {
1114
1530
  const repo = await resolveRepo(args.projectPath)
1115
1531
  if (!repo) {
@@ -1140,7 +1556,41 @@ export class DiffStore {
1140
1556
  }
1141
1557
  }
1142
1558
 
1143
- const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
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)
1144
1594
  const branchName = await getBranchName(repo.repoRoot)
1145
1595
  const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
1146
1596
  const { aheadCount, behindCount } = nextHasUpstream
@@ -1195,7 +1645,7 @@ export class DiffStore {
1195
1645
  }
1196
1646
  }
1197
1647
 
1198
- const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
1648
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1199
1649
  const branchName = await getBranchName(repo.repoRoot)
1200
1650
  const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
1201
1651
  const { aheadCount, behindCount } = nextHasUpstream
@@ -1226,14 +1676,24 @@ export class DiffStore {
1226
1676
  throw new Error("Project is not in a git repository")
1227
1677
  }
1228
1678
 
1229
- const files = await computeCurrentFiles(repo.repoRoot, repo.baseCommit)
1230
- const selectedFiles = normalizedPaths.map((selectedPath) => {
1231
- const file = files.find((candidate) => candidate.path === selectedPath)
1232
- 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) {
1233
1683
  throw new Error(`File is no longer changed: ${selectedPath}`)
1234
1684
  }
1235
- return file
1236
- })
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
+ }))
1237
1697
 
1238
1698
  const branchName = await getBranchName(repo.repoRoot)
1239
1699
  return await generateCommitMessageDetailed({
@@ -1244,7 +1704,7 @@ export class DiffStore {
1244
1704
  }
1245
1705
 
1246
1706
  async commitFiles(args: {
1247
- chatId: string
1707
+ projectId: string
1248
1708
  projectPath: string
1249
1709
  paths: string[]
1250
1710
  summary: string
@@ -1266,6 +1726,7 @@ export class DiffStore {
1266
1726
  if (!repo) {
1267
1727
  throw new Error("Project is not in a git repository")
1268
1728
  }
1729
+ const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
1269
1730
 
1270
1731
  const currentDirtyPaths = new Set((await listDirtyPaths(repo.repoRoot)).map((entry) => entry.path))
1271
1732
  const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPaths.has(relativePath))
@@ -1289,7 +1750,7 @@ export class DiffStore {
1289
1750
  return createCommitFailure(args.mode, formatGitFailure(commitResult))
1290
1751
  }
1291
1752
 
1292
- const snapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
1753
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1293
1754
  const branchName = await getBranchName(repo.repoRoot)
1294
1755
 
1295
1756
  if (args.mode === "commit_only") {
@@ -1302,12 +1763,14 @@ export class DiffStore {
1302
1763
  } satisfies DiffCommitResult
1303
1764
  }
1304
1765
 
1305
- 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)
1306
1769
  if (pushResult.exitCode !== 0) {
1307
1770
  return createPushFailure(args.mode, formatGitFailure(pushResult), snapshotChanged)
1308
1771
  }
1309
1772
 
1310
- const postPushSnapshotChanged = await this.refreshSnapshot(args.chatId, args.projectPath)
1773
+ const postPushSnapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1311
1774
 
1312
1775
  return {
1313
1776
  ok: true,
@@ -1319,7 +1782,7 @@ export class DiffStore {
1319
1782
  }
1320
1783
 
1321
1784
  async discardFile(args: {
1322
- chatId: string
1785
+ projectId: string
1323
1786
  projectPath: string
1324
1787
  path: string
1325
1788
  }) {
@@ -1355,12 +1818,12 @@ export class DiffStore {
1355
1818
  }
1356
1819
 
1357
1820
  return {
1358
- snapshotChanged: await this.refreshSnapshot(args.chatId, args.projectPath),
1821
+ snapshotChanged: await this.refreshSnapshot(args.projectId, args.projectPath),
1359
1822
  }
1360
1823
  }
1361
1824
 
1362
1825
  async ignoreFile(args: {
1363
- chatId: string
1826
+ projectId: string
1364
1827
  projectPath: string
1365
1828
  path: string
1366
1829
  }) {
@@ -1386,7 +1849,7 @@ export class DiffStore {
1386
1849
  }
1387
1850
 
1388
1851
  return {
1389
- snapshotChanged: await this.refreshSnapshot(args.chatId, args.projectPath),
1852
+ snapshotChanged: await this.refreshSnapshot(args.projectId, args.projectPath),
1390
1853
  }
1391
1854
  }
1392
1855
  }