@g-abhishek/gitx 0.1.1 → 0.1.4

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.
Files changed (161) hide show
  1. package/README.md +374 -3
  2. package/dist/ai/claudeAi.d.ts +35 -0
  3. package/dist/ai/claudeAi.d.ts.map +1 -0
  4. package/dist/ai/claudeAi.js +396 -0
  5. package/dist/ai/claudeAi.js.map +1 -0
  6. package/dist/ai/claudeCliAi.d.ts +27 -0
  7. package/dist/ai/claudeCliAi.d.ts.map +1 -0
  8. package/dist/ai/claudeCliAi.js +312 -0
  9. package/dist/ai/claudeCliAi.js.map +1 -0
  10. package/dist/ai/localClaudeAi.d.ts +2 -0
  11. package/dist/ai/localClaudeAi.d.ts.map +1 -0
  12. package/dist/ai/localClaudeAi.js +4 -0
  13. package/dist/ai/localClaudeAi.js.map +1 -0
  14. package/dist/ai/mockAi.d.ts +8 -1
  15. package/dist/ai/mockAi.d.ts.map +1 -1
  16. package/dist/ai/mockAi.js +57 -0
  17. package/dist/ai/mockAi.js.map +1 -1
  18. package/dist/ai/openAiAi.d.ts +33 -0
  19. package/dist/ai/openAiAi.d.ts.map +1 -0
  20. package/dist/ai/openAiAi.js +388 -0
  21. package/dist/ai/openAiAi.js.map +1 -0
  22. package/dist/ai/reviewHelpers.d.ts +66 -0
  23. package/dist/ai/reviewHelpers.d.ts.map +1 -0
  24. package/dist/ai/reviewHelpers.js +559 -0
  25. package/dist/ai/reviewHelpers.js.map +1 -0
  26. package/dist/ai/types.d.ts +247 -0
  27. package/dist/ai/types.d.ts.map +1 -1
  28. package/dist/ai/types.js.map +1 -1
  29. package/dist/cli/commands/ask.d.ts +27 -0
  30. package/dist/cli/commands/ask.d.ts.map +1 -0
  31. package/dist/cli/commands/ask.js +230 -0
  32. package/dist/cli/commands/ask.js.map +1 -0
  33. package/dist/cli/commands/commit.d.ts +16 -0
  34. package/dist/cli/commands/commit.d.ts.map +1 -0
  35. package/dist/cli/commands/commit.js +163 -0
  36. package/dist/cli/commands/commit.js.map +1 -0
  37. package/dist/cli/commands/config.d.ts +4 -0
  38. package/dist/cli/commands/config.d.ts.map +1 -0
  39. package/dist/cli/commands/config.js +666 -0
  40. package/dist/cli/commands/config.js.map +1 -0
  41. package/dist/cli/commands/implement.d.ts.map +1 -1
  42. package/dist/cli/commands/implement.js +149 -28
  43. package/dist/cli/commands/implement.js.map +1 -1
  44. package/dist/cli/commands/init.d.ts +4 -0
  45. package/dist/cli/commands/init.d.ts.map +1 -1
  46. package/dist/cli/commands/init.js +7 -54
  47. package/dist/cli/commands/init.js.map +1 -1
  48. package/dist/cli/commands/port.d.ts +32 -0
  49. package/dist/cli/commands/port.d.ts.map +1 -0
  50. package/dist/cli/commands/port.js +554 -0
  51. package/dist/cli/commands/port.js.map +1 -0
  52. package/dist/cli/commands/pr/close.d.ts +15 -0
  53. package/dist/cli/commands/pr/close.d.ts.map +1 -0
  54. package/dist/cli/commands/pr/close.js +71 -0
  55. package/dist/cli/commands/pr/close.js.map +1 -0
  56. package/dist/cli/commands/pr/create.d.ts +17 -0
  57. package/dist/cli/commands/pr/create.d.ts.map +1 -1
  58. package/dist/cli/commands/pr/create.js +209 -5
  59. package/dist/cli/commands/pr/create.js.map +1 -1
  60. package/dist/cli/commands/pr/fixComments.d.ts.map +1 -1
  61. package/dist/cli/commands/pr/fixComments.js +77 -5
  62. package/dist/cli/commands/pr/fixComments.js.map +1 -1
  63. package/dist/cli/commands/pr/index.d.ts.map +1 -1
  64. package/dist/cli/commands/pr/index.js +4 -0
  65. package/dist/cli/commands/pr/index.js.map +1 -1
  66. package/dist/cli/commands/pr/list.d.ts.map +1 -1
  67. package/dist/cli/commands/pr/list.js +26 -3
  68. package/dist/cli/commands/pr/list.js.map +1 -1
  69. package/dist/cli/commands/pr/merge.d.ts +23 -0
  70. package/dist/cli/commands/pr/merge.d.ts.map +1 -0
  71. package/dist/cli/commands/pr/merge.js +191 -0
  72. package/dist/cli/commands/pr/merge.js.map +1 -0
  73. package/dist/cli/commands/pr/review.d.ts.map +1 -1
  74. package/dist/cli/commands/pr/review.js +123 -5
  75. package/dist/cli/commands/pr/review.js.map +1 -1
  76. package/dist/cli/commands/push.d.ts +16 -0
  77. package/dist/cli/commands/push.d.ts.map +1 -0
  78. package/dist/cli/commands/push.js +166 -0
  79. package/dist/cli/commands/push.js.map +1 -0
  80. package/dist/cli/commands/sync.d.ts +24 -0
  81. package/dist/cli/commands/sync.d.ts.map +1 -0
  82. package/dist/cli/commands/sync.js +414 -0
  83. package/dist/cli/commands/sync.js.map +1 -0
  84. package/dist/cli/index.d.ts.map +1 -1
  85. package/dist/cli/index.js +34 -6
  86. package/dist/cli/index.js.map +1 -1
  87. package/dist/config/config.d.ts +20 -3
  88. package/dist/config/config.d.ts.map +1 -1
  89. package/dist/config/config.js +103 -24
  90. package/dist/config/config.js.map +1 -1
  91. package/dist/config/schema.d.ts.map +1 -1
  92. package/dist/config/schema.js +70 -9
  93. package/dist/config/schema.js.map +1 -1
  94. package/dist/core/context.d.ts +13 -0
  95. package/dist/core/context.d.ts.map +1 -0
  96. package/dist/core/context.js +2 -0
  97. package/dist/core/context.js.map +1 -0
  98. package/dist/core/gitx.d.ts +47 -0
  99. package/dist/core/gitx.d.ts.map +1 -1
  100. package/dist/core/gitx.js +204 -9
  101. package/dist/core/gitx.js.map +1 -1
  102. package/dist/index.d.ts +1 -5
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.js +4 -1
  105. package/dist/index.js.map +1 -1
  106. package/dist/providers/azure.d.ts +26 -0
  107. package/dist/providers/azure.d.ts.map +1 -0
  108. package/dist/providers/azure.js +256 -0
  109. package/dist/providers/azure.js.map +1 -0
  110. package/dist/providers/base.d.ts +104 -0
  111. package/dist/providers/base.d.ts.map +1 -0
  112. package/dist/providers/base.js +5 -0
  113. package/dist/providers/base.js.map +1 -0
  114. package/dist/providers/factory.d.ts +8 -0
  115. package/dist/providers/factory.d.ts.map +1 -0
  116. package/dist/providers/factory.js +25 -0
  117. package/dist/providers/factory.js.map +1 -0
  118. package/dist/providers/github.d.ts +19 -0
  119. package/dist/providers/github.d.ts.map +1 -0
  120. package/dist/providers/github.js +291 -0
  121. package/dist/providers/github.js.map +1 -0
  122. package/dist/providers/gitlab.d.ts +19 -0
  123. package/dist/providers/gitlab.d.ts.map +1 -0
  124. package/dist/providers/gitlab.js +186 -0
  125. package/dist/providers/gitlab.js.map +1 -0
  126. package/dist/types/config.d.ts +53 -9
  127. package/dist/types/config.d.ts.map +1 -1
  128. package/dist/types/config.js.map +1 -1
  129. package/dist/utils/azureAuth.d.ts +51 -0
  130. package/dist/utils/azureAuth.d.ts.map +1 -0
  131. package/dist/utils/azureAuth.js +172 -0
  132. package/dist/utils/azureAuth.js.map +1 -0
  133. package/dist/utils/git.d.ts +22 -0
  134. package/dist/utils/git.d.ts.map +1 -1
  135. package/dist/utils/git.js +63 -7
  136. package/dist/utils/git.js.map +1 -1
  137. package/dist/utils/gitOps.d.ts +118 -0
  138. package/dist/utils/gitOps.d.ts.map +1 -0
  139. package/dist/utils/gitOps.js +380 -0
  140. package/dist/utils/gitOps.js.map +1 -0
  141. package/dist/utils/lockFile.d.ts +13 -0
  142. package/dist/utils/lockFile.d.ts.map +1 -0
  143. package/dist/utils/lockFile.js +54 -0
  144. package/dist/utils/lockFile.js.map +1 -0
  145. package/dist/utils/retry.d.ts +10 -0
  146. package/dist/utils/retry.d.ts.map +1 -0
  147. package/dist/utils/retry.js +31 -0
  148. package/dist/utils/retry.js.map +1 -0
  149. package/dist/workflows/implement.d.ts +41 -0
  150. package/dist/workflows/implement.d.ts.map +1 -0
  151. package/dist/workflows/implement.js +219 -0
  152. package/dist/workflows/implement.js.map +1 -0
  153. package/dist/workflows/pr.d.ts +41 -0
  154. package/dist/workflows/pr.d.ts.map +1 -0
  155. package/dist/workflows/pr.js +285 -0
  156. package/dist/workflows/pr.js.map +1 -0
  157. package/dist/workflows/prAddress.d.ts +55 -0
  158. package/dist/workflows/prAddress.d.ts.map +1 -0
  159. package/dist/workflows/prAddress.js +349 -0
  160. package/dist/workflows/prAddress.js.map +1 -0
  161. package/package.json +1 -1
@@ -0,0 +1,554 @@
1
+ /**
2
+ * gitx port <target1> [target2...]
3
+ *
4
+ * Cherry-pick all commits from the current branch onto one or more target
5
+ * branches, with AI-assisted conflict resolution, then push and open PRs.
6
+ *
7
+ * Smart incremental detection via `git cherry`:
8
+ * - First run → creates port/<source>-to-<target>, ports all commits
9
+ * - Re-run → detects which commits are NEW since the last port, ports only those
10
+ * - Up to date → tells you nothing to do
11
+ *
12
+ * Flow (per target branch):
13
+ * 1. Detect base branch → collect commits on current branch (base..HEAD)
14
+ * 2. If port branch exists → run `git cherry` to find unported commits only
15
+ * 3. Create (or checkout) port/<source>-to-<target> from origin/<target>
16
+ * 4. Cherry-pick commits oldest→newest with -x flag (records source SHA)
17
+ * 5. On conflict → AI resolves → stage → cherry-pick --continue
18
+ * 6. On unresolvable → pause, print manual instructions, `gitx port --continue`
19
+ * 7. Push → create PR
20
+ *
21
+ * Usage:
22
+ * gitx port release/v2 # port to one branch
23
+ * gitx port release/v2 hotfix/v1 # port to multiple branches
24
+ * gitx port release/v2 --base develop # override base branch
25
+ * gitx port release/v2 --no-pr # skip PR creation
26
+ * gitx port release/v2 --draft # create draft PRs
27
+ * gitx port --continue # after manually resolving conflicts
28
+ * gitx port --abort # abort a stuck cherry-pick
29
+ */
30
+ import ora from "ora";
31
+ import { execFile } from "node:child_process";
32
+ import { promisify } from "node:util";
33
+ import { readFile, writeFile, writeFile as fsWriteFile } from "node:fs/promises";
34
+ import { resolve as resolvePath, join as pathJoin } from "node:path";
35
+ import { existsSync } from "node:fs";
36
+ import { confirm } from "@inquirer/prompts";
37
+ import { logger } from "../../logger/logger.js";
38
+ import { isInsideGitRepo } from "../../utils/git.js";
39
+ import { getCurrentBranch, detectBaseBranch, getBranchCommits, getBranchDiff, getBranchStat } from "../../utils/gitOps.js";
40
+ import { GitxError } from "../../utils/errors.js";
41
+ import { Gitx } from "../../core/gitx.js";
42
+ import { createProvider } from "../../providers/factory.js";
43
+ const execFileAsync = promisify(execFile);
44
+ // ─── Git helper ───────────────────────────────────────────────────────────────
45
+ async function git(args, cwd) {
46
+ try {
47
+ const result = await execFileAsync("git", args, { cwd });
48
+ return { stdout: result.stdout.trim(), stderr: "", exitCode: 0 };
49
+ }
50
+ catch (err) {
51
+ const e = err;
52
+ return {
53
+ stdout: e.stdout?.trim() ?? "",
54
+ stderr: e.stderr?.trim() ?? "",
55
+ exitCode: e.code ?? 1,
56
+ };
57
+ }
58
+ }
59
+ async function loadPortState(cwd) {
60
+ const { stdout: gitDir } = await git(["rev-parse", "--git-dir"], cwd);
61
+ const statePath = pathJoin(cwd, gitDir || ".git", "GITX_PORT");
62
+ try {
63
+ const raw = await readFile(statePath, "utf8");
64
+ return JSON.parse(raw);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ async function savePortState(cwd, state) {
71
+ const { stdout: gitDir } = await git(["rev-parse", "--git-dir"], cwd);
72
+ const statePath = pathJoin(cwd, gitDir || ".git", "GITX_PORT");
73
+ await fsWriteFile(statePath, JSON.stringify(state, null, 2), "utf8");
74
+ }
75
+ async function clearPortState(cwd) {
76
+ const { stdout: gitDir } = await git(["rev-parse", "--git-dir"], cwd);
77
+ const statePath = pathJoin(cwd, gitDir || ".git", "GITX_PORT");
78
+ try {
79
+ const { unlink } = await import("node:fs/promises");
80
+ await unlink(statePath);
81
+ }
82
+ catch { /* already gone */ }
83
+ }
84
+ // ─── Conflict detection ───────────────────────────────────────────────────────
85
+ async function getConflictingFiles(cwd) {
86
+ const { stdout } = await git(["diff", "--name-only", "--diff-filter=U"], cwd);
87
+ return stdout.split("\n").map((l) => l.trim()).filter(Boolean);
88
+ }
89
+ async function isCherryPickInProgress(cwd) {
90
+ const { stdout: gitDir } = await git(["rev-parse", "--git-dir"], cwd);
91
+ const cherryPickHead = pathJoin(cwd, gitDir || ".git", "CHERRY_PICK_HEAD");
92
+ return existsSync(cherryPickHead);
93
+ }
94
+ // ─── Incremental commit detection ────────────────────────────────────────────
95
+ /**
96
+ * Use `git cherry <upstreamBranch> <headBranch>` to find commits in headBranch
97
+ * that are NOT yet present in upstreamBranch (by patch-id comparison).
98
+ *
99
+ * Returns SHAs of unported commits, oldest-first.
100
+ * Lines prefixed with '+' are not yet ported; '-' are already present.
101
+ */
102
+ async function getUnportedCommits(cwd, portBranch, sourceBranch) {
103
+ // git cherry compares patch IDs — works even when SHAs differ after cherry-pick
104
+ const { stdout } = await git(["cherry", portBranch, sourceBranch], cwd);
105
+ const lines = stdout.split("\n").filter(Boolean);
106
+ // '+' = not on portBranch (needs porting), '-' = already there
107
+ const unported = lines
108
+ .filter((l) => l.startsWith("+ "))
109
+ .map((l) => l.slice(2).trim());
110
+ // git cherry returns newest-first; reverse to get oldest-first for cherry-pick
111
+ return unported.reverse();
112
+ }
113
+ /**
114
+ * Get ALL commits on the current branch (base..HEAD), oldest-first.
115
+ * These are the SHAs we'll cherry-pick on a first run.
116
+ */
117
+ async function getAllBranchCommitShas(cwd, baseBranch) {
118
+ const { stdout } = await git(["log", "--format=%H", "--no-decorate", `${baseBranch}..HEAD`], cwd);
119
+ const shas = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
120
+ // git log returns newest-first; reverse to oldest-first
121
+ return shas.reverse();
122
+ }
123
+ // ─── AI conflict resolution ───────────────────────────────────────────────────
124
+ async function resolveConflictsWithAi(conflictFiles, cwd, gitx) {
125
+ const resolved = [];
126
+ const needsManual = [];
127
+ for (const filePath of conflictFiles) {
128
+ const absPath = resolvePath(cwd, filePath);
129
+ let content;
130
+ try {
131
+ content = await readFile(absPath, "utf8");
132
+ }
133
+ catch {
134
+ needsManual.push(filePath);
135
+ continue;
136
+ }
137
+ if (!content.includes("<<<<<<<")) {
138
+ needsManual.push(filePath);
139
+ continue;
140
+ }
141
+ const spinner = ora(` 🤖 AI resolving: ${filePath}`).start();
142
+ try {
143
+ const result = await gitx.ai.resolveConflict(filePath, content);
144
+ if (result.confidence === "high") {
145
+ await writeFile(absPath, result.resolved, "utf8");
146
+ spinner.succeed(` ✅ Auto-resolved: ${filePath} — ${result.explanation}`);
147
+ resolved.push(filePath);
148
+ }
149
+ else {
150
+ spinner.warn(` ⚠️ Low confidence: ${filePath} — ${result.explanation}`);
151
+ const preview = result.resolved.split("\n").slice(0, 30).join("\n");
152
+ logger.info(`\n${preview}\n`);
153
+ let apply = false;
154
+ try {
155
+ apply = await confirm({ message: `Apply AI resolution for ${filePath}?`, default: true });
156
+ }
157
+ catch {
158
+ apply = false;
159
+ }
160
+ if (apply) {
161
+ await writeFile(absPath, result.resolved, "utf8");
162
+ logger.success(` ✅ Applied: ${filePath}`);
163
+ resolved.push(filePath);
164
+ }
165
+ else {
166
+ needsManual.push(filePath);
167
+ }
168
+ }
169
+ }
170
+ catch {
171
+ spinner.fail(` ❌ AI resolution failed: ${filePath} — resolve manually`);
172
+ needsManual.push(filePath);
173
+ }
174
+ }
175
+ return { resolved, needsManual };
176
+ }
177
+ async function cherryPickCommits(commits, cwd, gitx) {
178
+ for (let i = 0; i < commits.length; i++) {
179
+ const sha = commits[i];
180
+ const shortSha = sha.slice(0, 7);
181
+ // Get the commit subject for display
182
+ const { stdout: subject } = await git(["log", "--format=%s", "-1", sha], cwd);
183
+ logger.info(`\n 🍒 [${i + 1}/${commits.length}] ${shortSha} — ${subject}`);
184
+ // -x appends "(cherry picked from commit <sha>)" to the commit message
185
+ const result = await git(["cherry-pick", "-x", sha], cwd);
186
+ if (result.exitCode === 0) {
187
+ logger.success(` ✓ Applied cleanly`);
188
+ continue;
189
+ }
190
+ // Cherry-pick failed — check for conflicts
191
+ const conflictFiles = await getConflictingFiles(cwd);
192
+ if (conflictFiles.length === 0) {
193
+ // Some other error (e.g. empty commit)
194
+ logger.warn(` ⚠️ Skipping (empty or already applied): ${shortSha}`);
195
+ await git(["cherry-pick", "--skip"], cwd);
196
+ continue;
197
+ }
198
+ logger.warn(`\n ⚡ Conflicts in ${conflictFiles.length} file(s):`);
199
+ conflictFiles.forEach((f) => logger.info(` • ${f}`));
200
+ const { resolved, needsManual } = await resolveConflictsWithAi(conflictFiles, cwd, gitx);
201
+ if (needsManual.length > 0) {
202
+ // Can't auto-resolve everything — pause and let user fix manually
203
+ logger.warn(`\n ⛔ ${needsManual.length} file(s) need manual resolution:`);
204
+ needsManual.forEach((f) => logger.warn(` • ${f}`));
205
+ // Stage the auto-resolved files so user only needs to fix the rest
206
+ if (resolved.length > 0) {
207
+ await git(["add", ...resolved], cwd);
208
+ logger.info(`\n ✅ Auto-resolved files have been staged.`);
209
+ }
210
+ return {
211
+ status: "paused",
212
+ remainingCommits: commits.slice(i), // include current commit (still in progress)
213
+ };
214
+ }
215
+ // All conflicts resolved — stage and continue
216
+ await git(["add", ...resolved], cwd);
217
+ const continueResult = await git(["cherry-pick", "--continue", "--no-edit"], cwd);
218
+ if (continueResult.exitCode !== 0) {
219
+ // Still failing — pause
220
+ return {
221
+ status: "paused",
222
+ remainingCommits: commits.slice(i),
223
+ };
224
+ }
225
+ logger.success(` ✅ Conflict resolved and applied`);
226
+ }
227
+ return { status: "success", remainingCommits: [] };
228
+ }
229
+ // ─── Port a single target branch ─────────────────────────────────────────────
230
+ async function portToTarget(opts) {
231
+ const { sourceBranch, targetBranch, baseBranch, cwd, gitx, noPr, draft } = opts;
232
+ const portBranch = `port/${sourceBranch.replace(/\//g, "-")}-to-${targetBranch.replace(/\//g, "-")}`;
233
+ logger.info(`\n${"─".repeat(60)}`);
234
+ logger.info(`🎯 Target: ${targetBranch}`);
235
+ logger.info(` Port branch: ${portBranch}`);
236
+ // ── 1. Fetch origin ────────────────────────────────────────────────────────
237
+ const fetchSpinner = ora("Fetching origin…").start();
238
+ const fetchResult = await git(["fetch", "origin"], cwd);
239
+ if (fetchResult.exitCode !== 0) {
240
+ fetchSpinner.fail(`fetch failed: ${fetchResult.stderr}`);
241
+ return;
242
+ }
243
+ fetchSpinner.succeed("Fetched origin");
244
+ // ── 2. Check if target branch exists on origin ────────────────────────────
245
+ const { stdout: remoteRefs } = await git(["ls-remote", "--heads", "origin", targetBranch], cwd);
246
+ if (!remoteRefs.trim()) {
247
+ logger.error(` ❌ Branch "${targetBranch}" does not exist on origin. Skipping.`);
248
+ return;
249
+ }
250
+ // ── 3. Determine which commits to port ────────────────────────────────────
251
+ const portBranchExistsRemote = (await git(["ls-remote", "--heads", "origin", portBranch], cwd)).stdout.trim().length > 0;
252
+ const portBranchExistsLocal = (await git(["rev-parse", "--verify", portBranch], cwd)).exitCode === 0;
253
+ const portBranchExists = portBranchExistsLocal || portBranchExistsRemote;
254
+ let commitShas;
255
+ if (portBranchExists) {
256
+ // Incremental run — use git cherry to find only NEW commits
257
+ const localRef = portBranchExistsLocal
258
+ ? portBranch
259
+ : `origin/${portBranch}`;
260
+ const unported = await getUnportedCommits(cwd, localRef, sourceBranch);
261
+ if (unported.length === 0) {
262
+ logger.success(` ✅ Already up to date — nothing new to port to ${targetBranch}`);
263
+ return;
264
+ }
265
+ logger.info(` 📋 ${unported.length} new commit(s) to port (incremental):`);
266
+ for (const sha of unported) {
267
+ const { stdout: subject } = await git(["log", "--format=%s", "-1", sha], cwd);
268
+ logger.info(` + ${sha.slice(0, 7)} — ${subject}`);
269
+ }
270
+ commitShas = unported;
271
+ }
272
+ else {
273
+ // First run — port all commits on this branch
274
+ commitShas = await getAllBranchCommitShas(cwd, baseBranch);
275
+ if (commitShas.length === 0) {
276
+ logger.warn(` ⚠️ No commits found on "${sourceBranch}" since "${baseBranch}". Nothing to port.`);
277
+ return;
278
+ }
279
+ logger.info(` 📋 ${commitShas.length} commit(s) to port:`);
280
+ for (const sha of commitShas) {
281
+ const { stdout: subject } = await git(["log", "--format=%s", "-1", sha], cwd);
282
+ logger.info(` + ${sha.slice(0, 7)} — ${subject}`);
283
+ }
284
+ }
285
+ const proceed = await confirm({
286
+ message: `Port ${commitShas.length} commit(s) to ${targetBranch}?`,
287
+ default: true,
288
+ });
289
+ if (!proceed) {
290
+ logger.info(" Skipped.");
291
+ return;
292
+ }
293
+ // ── 4. Create or checkout the port branch ─────────────────────────────────
294
+ if (portBranchExists) {
295
+ // Checkout and update from origin if it exists remotely
296
+ if (portBranchExistsLocal) {
297
+ await git(["checkout", portBranch], cwd);
298
+ }
299
+ else {
300
+ await git(["checkout", "-b", portBranch, `origin/${portBranch}`], cwd);
301
+ }
302
+ // Pull latest from origin if it's there
303
+ if (portBranchExistsRemote) {
304
+ await git(["pull", "--ff-only", "origin", portBranch], cwd);
305
+ }
306
+ }
307
+ else {
308
+ // Create fresh from origin/<target>
309
+ const checkoutResult = await git(["checkout", "-b", portBranch, `origin/${targetBranch}`], cwd);
310
+ if (checkoutResult.exitCode !== 0) {
311
+ logger.error(` ❌ Could not create port branch: ${checkoutResult.stderr}`);
312
+ return;
313
+ }
314
+ }
315
+ // ── 5. Cherry-pick ─────────────────────────────────────────────────────────
316
+ const pickResult = await cherryPickCommits(commitShas, cwd, gitx);
317
+ if (pickResult.status === "paused") {
318
+ // Save state and bail — user needs to fix conflicts manually
319
+ await savePortState(cwd, {
320
+ sourceBranch,
321
+ portBranch,
322
+ targetBranch,
323
+ remainingCommits: pickResult.remainingCommits,
324
+ noPr,
325
+ draft,
326
+ });
327
+ logger.warn(`\n ⛔ Port paused — manual conflict resolution needed.`);
328
+ logger.info(`\n Fix the conflicts above, then run:`);
329
+ logger.info(` git add <resolved-files>`);
330
+ logger.info(` gitx port --continue`);
331
+ logger.info(`\n Or to abandon this port:`);
332
+ logger.info(` gitx port --abort`);
333
+ return;
334
+ }
335
+ // ── 6. Push port branch ────────────────────────────────────────────────────
336
+ const pushSpinner = ora(`Pushing ${portBranch}…`).start();
337
+ const pushResult = await git(["push", "--force-with-lease", "--set-upstream", "origin", portBranch], cwd);
338
+ if (pushResult.exitCode !== 0) {
339
+ pushSpinner.fail(`Push failed: ${pushResult.stderr}`);
340
+ return;
341
+ }
342
+ pushSpinner.succeed(`Pushed ${portBranch}`);
343
+ // ── 7. Create PR ───────────────────────────────────────────────────────────
344
+ if (noPr) {
345
+ logger.success(`\n ✅ Port branch pushed: ${portBranch}`);
346
+ logger.info(` Create PR manually: ${portBranch} → ${targetBranch}`);
347
+ return;
348
+ }
349
+ let ctx;
350
+ try {
351
+ ctx = await gitx.getRepoContext();
352
+ }
353
+ catch {
354
+ logger.warn(` ⚠️ Could not get repo context for PR creation — create PR manually.`);
355
+ logger.success(` ✅ Port branch pushed: ${portBranch}`);
356
+ return;
357
+ }
358
+ const provider = createProvider(ctx);
359
+ // Check if a PR already exists for this port branch
360
+ let existingPrUrl;
361
+ try {
362
+ const allPrs = await provider.listPRs(ctx.repoSlug);
363
+ const existing = allPrs.find((pr) => pr.head === portBranch && pr.base === targetBranch && pr.state === "open");
364
+ if (existing) {
365
+ existingPrUrl = existing.url;
366
+ }
367
+ }
368
+ catch { /* non-fatal */ }
369
+ if (existingPrUrl) {
370
+ logger.success(` ✅ PR already open — updated with new commits: ${existingPrUrl}`);
371
+ return;
372
+ }
373
+ // Generate PR content with AI
374
+ const prSpinner = ora("Generating PR description…").start();
375
+ let prTitle = `[Port → ${targetBranch}] ${sourceBranch}`;
376
+ let prBody = `Ported from \`${sourceBranch}\` → \`${targetBranch}\`.\n\n`;
377
+ prBody += `Cherry-picked ${commitShas.length} commit(s):\n`;
378
+ for (const sha of commitShas) {
379
+ const { stdout: subject } = await git(["log", "--format=%s", "-1", sha], cwd);
380
+ prBody += `- ${sha.slice(0, 7)} ${subject}\n`;
381
+ }
382
+ try {
383
+ const commits = await getBranchCommits(cwd, targetBranch);
384
+ const diff = await getBranchDiff(cwd, `origin/${targetBranch}`);
385
+ const stat = await getBranchStat(cwd, `origin/${targetBranch}`);
386
+ const aiContent = await gitx.ai.generatePrContent(commits, diff, stat || undefined);
387
+ // Prepend port context to AI-generated body
388
+ prTitle = `[Port → ${targetBranch}] ${aiContent.title}`;
389
+ prBody = `> 🍒 Ported from \`${sourceBranch}\` → \`${targetBranch}\` (${commitShas.length} commit(s))\n\n${aiContent.body}`;
390
+ prSpinner.succeed("PR description generated");
391
+ }
392
+ catch {
393
+ prSpinner.warn("Could not generate AI description — using commit list");
394
+ }
395
+ try {
396
+ const createdPr = await provider.createPR(ctx.repoSlug, {
397
+ title: prTitle,
398
+ body: prBody,
399
+ head: portBranch,
400
+ base: targetBranch,
401
+ draft,
402
+ });
403
+ logger.success(`\n ✅ PR created: ${createdPr.url}`);
404
+ logger.info(` #${createdPr.number} — ${createdPr.title}`);
405
+ }
406
+ catch (err) {
407
+ const msg = err instanceof Error ? err.message : String(err);
408
+ logger.warn(` ⚠️ PR creation failed: ${msg}`);
409
+ logger.info(` Create manually: ${portBranch} → ${targetBranch}`);
410
+ }
411
+ }
412
+ // ─── Register command ─────────────────────────────────────────────────────────
413
+ export function registerPortCommand(program) {
414
+ program
415
+ .command("port")
416
+ .description("🍒 Port commits from the current branch to one or more other branches\n" +
417
+ " Smart incremental: only ports NEW commits on re-runs\n" +
418
+ " Example: gitx port release/v2 hotfix/v1")
419
+ .argument("[targets...]", "Target branch(es) to port commits onto")
420
+ .option("--base <branch>", "Base branch to calculate commits from (auto-detected if omitted)")
421
+ .option("--no-pr", "Push the port branch but skip PR creation")
422
+ .option("--draft", "Create PRs as drafts")
423
+ .option("--continue", "Continue after manually resolving cherry-pick conflicts")
424
+ .option("--abort", "Abort a paused port and clean up")
425
+ .action(async (targets, opts) => {
426
+ const cwd = process.cwd();
427
+ if (!(await isInsideGitRepo(cwd))) {
428
+ throw new GitxError("Not inside a git repository. cd into your project folder first.", { exitCode: 2 });
429
+ }
430
+ const gitx = await Gitx.fromCwd(cwd);
431
+ // ── --abort ─────────────────────────────────────────────────────────────
432
+ if (opts.abort) {
433
+ const inProgress = await isCherryPickInProgress(cwd);
434
+ if (inProgress) {
435
+ await git(["cherry-pick", "--abort"], cwd);
436
+ }
437
+ const state = await loadPortState(cwd);
438
+ if (state) {
439
+ // Return to source branch
440
+ await git(["checkout", state.sourceBranch], cwd);
441
+ await clearPortState(cwd);
442
+ logger.success(`✅ Port aborted. Back on ${state.sourceBranch}.`);
443
+ }
444
+ else {
445
+ logger.info("No port in progress to abort.");
446
+ }
447
+ return;
448
+ }
449
+ // ── --continue ──────────────────────────────────────────────────────────
450
+ if (opts.continue) {
451
+ const state = await loadPortState(cwd);
452
+ if (!state) {
453
+ throw new GitxError("No port in progress. Run `gitx port <target>` to start one.", { exitCode: 2 });
454
+ }
455
+ // Make sure cherry-pick is no longer paused (user staged their fixes)
456
+ const cherryInProgress = await isCherryPickInProgress(cwd);
457
+ if (cherryInProgress) {
458
+ // Complete the current cherry-pick
459
+ const continueResult = await git(["cherry-pick", "--continue", "--no-edit"], cwd);
460
+ if (continueResult.exitCode !== 0) {
461
+ const remaining = await getConflictingFiles(cwd);
462
+ if (remaining.length > 0) {
463
+ logger.warn("Still has conflicts — resolve them and stage before running --continue again.");
464
+ remaining.forEach((f) => logger.warn(` • ${f}`));
465
+ return;
466
+ }
467
+ }
468
+ }
469
+ // Resume remaining commits (skip the first — it was the one in conflict)
470
+ const toResume = state.remainingCommits.slice(1);
471
+ if (toResume.length > 0) {
472
+ logger.info(`\n▶ Resuming port — ${toResume.length} commit(s) remaining…`);
473
+ const pickResult = await cherryPickCommits(toResume, cwd, gitx);
474
+ if (pickResult.status === "paused") {
475
+ await savePortState(cwd, { ...state, remainingCommits: pickResult.remainingCommits });
476
+ logger.warn("Port paused again — fix conflicts and run `gitx port --continue`.");
477
+ return;
478
+ }
479
+ }
480
+ // All done — push and create PR
481
+ await clearPortState(cwd);
482
+ const pushSpinner = ora(`Pushing ${state.portBranch}…`).start();
483
+ const pushResult = await git(["push", "--force-with-lease", "--set-upstream", "origin", state.portBranch], cwd);
484
+ if (pushResult.exitCode !== 0) {
485
+ pushSpinner.fail(`Push failed: ${pushResult.stderr}`);
486
+ return;
487
+ }
488
+ pushSpinner.succeed(`Pushed ${state.portBranch}`);
489
+ if (!state.noPr) {
490
+ let ctx;
491
+ try {
492
+ ctx = await gitx.getRepoContext();
493
+ const provider = createProvider(ctx);
494
+ const createdPr = await provider.createPR(ctx.repoSlug, {
495
+ title: `[Port → ${state.targetBranch}] ${state.sourceBranch}`,
496
+ body: `Ported from \`${state.sourceBranch}\` → \`${state.targetBranch}\` (manual conflict resolution).`,
497
+ head: state.portBranch,
498
+ base: state.targetBranch,
499
+ draft: state.draft,
500
+ });
501
+ logger.success(`✅ PR created: ${createdPr.url}`);
502
+ }
503
+ catch (err) {
504
+ const msg = err instanceof Error ? err.message : String(err);
505
+ logger.warn(`PR creation failed: ${msg} — create manually.`);
506
+ }
507
+ }
508
+ else {
509
+ logger.success(`✅ Port complete. Branch: ${state.portBranch}`);
510
+ }
511
+ // Return to source branch
512
+ await git(["checkout", state.sourceBranch], cwd);
513
+ return;
514
+ }
515
+ // ── Normal port run ─────────────────────────────────────────────────────
516
+ if (targets.length === 0) {
517
+ throw new GitxError("Specify at least one target branch.\n Example: gitx port release/v2 hotfix/v1", { exitCode: 2 });
518
+ }
519
+ const sourceBranch = await getCurrentBranch(cwd);
520
+ if (!sourceBranch) {
521
+ throw new GitxError("Could not determine current branch.", { exitCode: 2 });
522
+ }
523
+ // Prevent porting to the same branch
524
+ const invalidTargets = targets.filter((t) => t === sourceBranch);
525
+ if (invalidTargets.length > 0) {
526
+ throw new GitxError(`Cannot port a branch onto itself: ${invalidTargets.join(", ")}`, { exitCode: 2 });
527
+ }
528
+ const baseBranch = opts.base ?? (await detectBaseBranch(cwd));
529
+ logger.info(`\n🍒 gitx port`);
530
+ logger.info(` Source: ${sourceBranch}`);
531
+ logger.info(` Base: ${baseBranch}`);
532
+ logger.info(` Targets: ${targets.join(", ")}`);
533
+ const originalBranch = sourceBranch;
534
+ for (const targetBranch of targets) {
535
+ await portToTarget({
536
+ sourceBranch,
537
+ targetBranch,
538
+ baseBranch,
539
+ cwd,
540
+ gitx,
541
+ noPr: opts.pr === false,
542
+ draft: opts.draft,
543
+ });
544
+ // Return to source branch between targets
545
+ const { stdout: currentBranch } = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
546
+ if (currentBranch !== originalBranch) {
547
+ await git(["checkout", originalBranch], cwd);
548
+ }
549
+ }
550
+ logger.info(`\n${"─".repeat(60)}`);
551
+ logger.success(`\n✅ gitx port complete.`);
552
+ });
553
+ }
554
+ //# sourceMappingURL=port.js.map