edsger 0.21.5 → 0.21.7

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.
@@ -6,7 +6,7 @@ import { fetchCodeImplementationContext, formatContextForPrompt, } from './conte
6
6
  import { logFeaturePhaseEvent } from '../../services/audit-logs.js';
7
7
  import { buildImplementationResult, buildVerificationFailureResult, buildNoResultsError, } from './outcome.js';
8
8
  import { performVerificationCycle } from '../code-implementation-verification/index.js';
9
- import { prepareCustomBranchGitEnvironment, syncFeatBranchWithMain, } from '../../utils/git-branch-manager.js';
9
+ import { prepareCustomBranchGitEnvironmentAsync, syncFeatBranchWithMain, } from '../../utils/git-branch-manager.js';
10
10
  import { getCurrentBranch, updateBranch, getBranches, createBranches, getBaseBranchInfo, } from '../../services/branches.js';
11
11
  import { createBranchPullRequest, } from './branch-pr-creator.js';
12
12
  import { getGitHubConfig } from '../../api/github.js';
@@ -114,7 +114,7 @@ export const implementFeatureCode = async (options, config, checklistContext) =>
114
114
  // Determine the actual base branch for branch chaining
115
115
  // If the current feature branch depends on another branch, use that as the base
116
116
  let actualBaseBranch = baseBranch;
117
- let needsRebase = false;
117
+ let originalBaseBranch;
118
118
  if (currentBranch && currentBranch.base_branch_id) {
119
119
  try {
120
120
  const allBranches = await getBranches({
@@ -123,20 +123,20 @@ export const implementFeatureCode = async (options, config, checklistContext) =>
123
123
  });
124
124
  const baseBranchInfo = await getBaseBranchInfo(currentBranch, allBranches, baseBranch);
125
125
  actualBaseBranch = baseBranchInfo.baseBranch;
126
- needsRebase = baseBranchInfo.needsRebase;
126
+ originalBaseBranch = baseBranchInfo.originalBaseBranch;
127
127
  if (verbose) {
128
128
  logInfo(`🔗 Branch chaining detected:`);
129
129
  logInfo(` Current branch: ${currentBranch.name}`);
130
130
  logInfo(` Base branch: ${actualBaseBranch}`);
131
+ if (originalBaseBranch) {
132
+ logInfo(` Original base branch: ${originalBaseBranch} (for --onto rebase)`);
133
+ }
131
134
  if (baseBranchInfo.baseBranchMerged) {
132
- logInfo(` Parent branch merged: yes (using main)`);
135
+ logInfo(` Parent branch merged: yes`);
133
136
  }
134
137
  else {
135
138
  logInfo(` Parent branch merged: no (using parent branch)`);
136
139
  }
137
- if (needsRebase) {
138
- logInfo(` Rebase needed: yes`);
139
- }
140
140
  }
141
141
  }
142
142
  catch (error) {
@@ -178,29 +178,15 @@ export const implementFeatureCode = async (options, config, checklistContext) =>
178
178
  }
179
179
  }
180
180
  }
181
- const cleanupGit = prepareCustomBranchGitEnvironment(devBranchName, actualBaseBranch, verbose);
182
- // Force push after rebase to trigger GitHub to recalculate PR diff
183
- if (featSyncedToMain) {
184
- try {
185
- const { execSync } = await import('child_process');
186
- if (verbose) {
187
- logInfo(`📤 Force pushing ${devBranchName} to trigger PR diff recalculation...`);
188
- }
189
- execSync(`git push --force-with-lease origin ${devBranchName}`, {
190
- encoding: 'utf-8',
191
- stdio: verbose ? 'inherit' : 'pipe',
192
- });
193
- if (verbose) {
194
- logInfo(`✅ Successfully force pushed ${devBranchName}`);
195
- }
196
- }
197
- catch (pushError) {
198
- if (verbose) {
199
- logInfo(`⚠️ Could not force push ${devBranchName}: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
200
- }
201
- // Don't fail the whole process if push fails
202
- }
203
- }
181
+ // Use async version to support --onto rebase for branch chaining
182
+ // This correctly handles cases where base branch was squash-merged
183
+ const { cleanup: cleanupGit } = await prepareCustomBranchGitEnvironmentAsync({
184
+ featureBranch: devBranchName,
185
+ baseBranch: actualBaseBranch,
186
+ originalBaseBranch, // For --onto rebase when base branch was squash-merged
187
+ verbose,
188
+ forcePushAfterRebase: featSyncedToMain, // Trigger GitHub to recalculate PR diff
189
+ });
204
190
  try {
205
191
  // Fetch all required context information via MCP endpoints
206
192
  if (verbose) {
@@ -62,12 +62,44 @@ const discardUncommittedChanges = (verbose) => {
62
62
  };
63
63
  /**
64
64
  * Switch to an existing branch
65
+ * By default, checks remote if branch doesn't exist locally (handles multi-clone scenarios)
66
+ * @param branch - The branch to switch to
67
+ * @param verbose - Whether to log verbose output
65
68
  */
66
69
  const switchToBranch = (branch, verbose) => {
67
70
  try {
68
- // First check if branch exists
71
+ // First check if branch exists locally
69
72
  if (!branchExists(branch)) {
70
- throw new Error(`Branch '${branch}' does not exist. The branch should have been created during code implementation phase.`);
73
+ // Branch doesn't exist locally, try to fetch and create tracking branch from remote
74
+ if (verbose) {
75
+ console.log(`🔍 Branch '${branch}' not found locally, checking remote...`);
76
+ }
77
+ try {
78
+ // Fetch to get latest remote refs
79
+ execSync('git fetch origin', { encoding: 'utf-8', stdio: 'pipe' });
80
+ // Check if remote branch exists
81
+ execSync(`git rev-parse --verify origin/${branch}`, {
82
+ encoding: 'utf-8',
83
+ stdio: 'pipe',
84
+ });
85
+ // Remote branch exists, create local tracking branch
86
+ if (verbose) {
87
+ console.log(`📥 Found branch '${branch}' on remote, creating local tracking branch...`);
88
+ }
89
+ // Discard any uncommitted changes before creating branch
90
+ discardUncommittedChanges(verbose);
91
+ execSync(`git checkout -b ${branch} --track origin/${branch}`, {
92
+ encoding: 'utf-8',
93
+ });
94
+ if (verbose) {
95
+ console.log(`✅ Created and switched to tracking branch: ${branch}`);
96
+ }
97
+ return;
98
+ }
99
+ catch (e) {
100
+ // Remote branch doesn't exist either
101
+ throw new Error(`Branch '${branch}' does not exist locally or on remote. The branch should have been created during code implementation phase.`);
102
+ }
71
103
  }
72
104
  if (verbose) {
73
105
  console.log(`🔀 Switching to branch: ${branch}`);
@@ -224,6 +256,7 @@ export async function createPullRequest(config, feature) {
224
256
  console.log(`🔍 Current branch: ${currentBranch}`);
225
257
  }
226
258
  // If we're on the base branch, switch to dev branch (should already exist)
259
+ // Default behavior now checks remote if not found locally (multi-clone scenario)
227
260
  if (currentBranch === baseBranch) {
228
261
  const devBranch = `dev/${feature.id}`;
229
262
  if (verbose) {
@@ -61,19 +61,15 @@ export declare function getNextPendingBranch(options: PipelinePhaseOptions): Pro
61
61
  export declare function allBranchesCompleted(options: PipelinePhaseOptions): Promise<boolean>;
62
62
  /**
63
63
  * Get the base branch information for a branch.
64
- * Returns:
65
- * - If base_branch_id is null: use main (first branch in chain)
66
- * - If base_branch_id is set and that branch is completed: use main
67
- * (completed means feat merged to main)
68
- * - If base_branch_id is set and that branch is merged: use base branch's feat branch
69
- * (merged means dev merged to feat, not to main)
70
- * - If base_branch_id is set and that branch is not merged: use base branch's dev branch
71
64
  *
72
- * When base branch is merged/completed (squash merge), we need to use `git rebase --onto` to
73
- * correctly rebase only the new commits. This function returns both the new base
74
- * and the original base for this purpose:
75
- * - completed: baseBranch=main, originalBaseBranch=feat/xxx
76
- * - merged: baseBranch=feat/xxx, originalBaseBranch=dev/xxx
65
+ * Rebase strategies based on base branch status:
66
+ * - no base_branch_id: git rebase main (first branch in chain)
67
+ * - completed (feat merged to main): git rebase main
68
+ * - merged (dev merged to feat): git rebase --onto main feat/xxx
69
+ * - in_progress/pending: git rebase dev/xxx (base on parent dev branch)
70
+ *
71
+ * For 'merged' status, we use --onto to rebase only new commits (after feat branch)
72
+ * onto main, avoiding conflicts from re-applying squashed commits.
77
73
  */
78
74
  export declare function getBaseBranchInfo(branch: Branch, allBranches: Branch[], mainBranch?: string): Promise<{
79
75
  baseBranch: string;
@@ -160,19 +160,15 @@ export async function allBranchesCompleted(options) {
160
160
  }
161
161
  /**
162
162
  * Get the base branch information for a branch.
163
- * Returns:
164
- * - If base_branch_id is null: use main (first branch in chain)
165
- * - If base_branch_id is set and that branch is completed: use main
166
- * (completed means feat merged to main)
167
- * - If base_branch_id is set and that branch is merged: use base branch's feat branch
168
- * (merged means dev merged to feat, not to main)
169
- * - If base_branch_id is set and that branch is not merged: use base branch's dev branch
170
163
  *
171
- * When base branch is merged/completed (squash merge), we need to use `git rebase --onto` to
172
- * correctly rebase only the new commits. This function returns both the new base
173
- * and the original base for this purpose:
174
- * - completed: baseBranch=main, originalBaseBranch=feat/xxx
175
- * - merged: baseBranch=feat/xxx, originalBaseBranch=dev/xxx
164
+ * Rebase strategies based on base branch status:
165
+ * - no base_branch_id: git rebase main (first branch in chain)
166
+ * - completed (feat merged to main): git rebase main
167
+ * - merged (dev merged to feat): git rebase --onto main feat/xxx
168
+ * - in_progress/pending: git rebase dev/xxx (base on parent dev branch)
169
+ *
170
+ * For 'merged' status, we use --onto to rebase only new commits (after feat branch)
171
+ * onto main, avoiding conflicts from re-applying squashed commits.
176
172
  */
177
173
  export async function getBaseBranchInfo(branch, allBranches, mainBranch = 'main') {
178
174
  // No base branch - start from main (first branch in chain)
@@ -195,27 +191,21 @@ export async function getBaseBranchInfo(branch, allBranches, mainBranch = 'main'
195
191
  }
196
192
  // Check if base branch is completed (feat merged to main)
197
193
  if (baseBranch.status === 'completed') {
198
- // Base branch's feat is merged to main - rebase directly from main
199
- // Use feat branch as originalBaseBranch for --onto
200
- if (!baseBranch.branch_name) {
201
- return {
202
- baseBranch: mainBranch,
203
- needsRebase: false,
204
- baseBranchMerged: true,
205
- };
206
- }
207
- const featBranchName = baseBranch.branch_name.replace(/^dev\//, 'feat/');
194
+ // Base branch's feat is already squash-merged to main
195
+ // Just rebase directly to main - no need for --onto
196
+ // Main already contains all the changes, so a normal rebase will work correctly
208
197
  return {
209
198
  baseBranch: mainBranch,
210
- originalBaseBranch: featBranchName, // Use feat branch for --onto
211
- needsRebase: true,
199
+ // Don't set originalBaseBranch - we want a normal rebase, not --onto
200
+ needsRebase: false,
212
201
  baseBranchMerged: true,
213
202
  };
214
203
  }
215
- // Check if base branch is merged (dev merged to feat)
204
+ // Check if base branch is merged (dev merged to feat, but feat not yet merged to main)
216
205
  if (baseBranch.status === 'merged') {
217
- // Base branch's dev is merged to feat - rebase from base branch's feat branch
218
- // Convert dev/xxx/1-name to feat/xxx/1-name
206
+ // Base branch's dev is squash-merged to feat
207
+ // Use --onto to rebase only new commits (after feat branch) onto main
208
+ // This correctly handles the case where feat contains squashed commits
219
209
  if (!baseBranch.branch_name) {
220
210
  return {
221
211
  baseBranch: mainBranch,
@@ -225,8 +215,8 @@ export async function getBaseBranchInfo(branch, allBranches, mainBranch = 'main'
225
215
  }
226
216
  const featBranchName = baseBranch.branch_name.replace(/^dev\//, 'feat/');
227
217
  return {
228
- baseBranch: featBranchName,
229
- originalBaseBranch: baseBranch.branch_name, // Return original dev branch for --onto
218
+ baseBranch: mainBranch, // Rebase onto main
219
+ originalBaseBranch: featBranchName, // Use feat branch for --onto
230
220
  needsRebase: true,
231
221
  baseBranchMerged: true,
232
222
  };
@@ -68,10 +68,34 @@ export declare function branchExists(branch: string): boolean;
68
68
  * Check if a remote branch exists
69
69
  */
70
70
  export declare function remoteBranchExists(branch: string): boolean;
71
+ /**
72
+ * Options for switchToBranch
73
+ */
74
+ export interface SwitchToBranchOptions {
75
+ /**
76
+ * Skip checking remote for branch existence.
77
+ * By default, switchToBranch will check remote if branch doesn't exist locally,
78
+ * and create a tracking branch if found on remote.
79
+ * Set this to true to skip the remote check (useful for well-known local branches like 'main').
80
+ */
81
+ skipRemoteCheck?: boolean;
82
+ }
71
83
  /**
72
84
  * Switch to a specific Git branch, creating it if it doesn't exist
85
+ *
86
+ * By default, if the branch doesn't exist locally, it will:
87
+ * 1. Fetch from origin to check if branch exists on remote
88
+ * 2. If found on remote, create a local tracking branch from it
89
+ * 3. If not found on remote, create a new branch from current HEAD
90
+ *
91
+ * This handles multi-clone scenarios where a branch may have been created
92
+ * and pushed from another local clone.
93
+ *
94
+ * @param branch - The branch name to switch to
95
+ * @param verbose - Whether to log verbose output
96
+ * @param options - Additional options for branch switching
73
97
  */
74
- export declare function switchToBranch(branch: string, verbose?: boolean): void;
98
+ export declare function switchToBranch(branch: string, verbose?: boolean, options?: SwitchToBranchOptions): void;
75
99
  /**
76
100
  * Pull latest changes from remote for a specific branch
77
101
  */
@@ -240,20 +240,32 @@ export function remoteBranchExists(branch) {
240
240
  }
241
241
  /**
242
242
  * Switch to a specific Git branch, creating it if it doesn't exist
243
+ *
244
+ * By default, if the branch doesn't exist locally, it will:
245
+ * 1. Fetch from origin to check if branch exists on remote
246
+ * 2. If found on remote, create a local tracking branch from it
247
+ * 3. If not found on remote, create a new branch from current HEAD
248
+ *
249
+ * This handles multi-clone scenarios where a branch may have been created
250
+ * and pushed from another local clone.
251
+ *
252
+ * @param branch - The branch name to switch to
253
+ * @param verbose - Whether to log verbose output
254
+ * @param options - Additional options for branch switching
243
255
  */
244
- export function switchToBranch(branch, verbose) {
256
+ export function switchToBranch(branch, verbose, options) {
245
257
  try {
246
258
  if (verbose) {
247
259
  logInfo(`🔄 Switching to branch ${branch}...`);
248
260
  }
249
261
  // Check if branch exists locally
250
- const exists = branchExists(branch);
251
- if (exists) {
252
- // Branch exists, just switch to it
262
+ const localExists = branchExists(branch);
263
+ if (localExists) {
264
+ // Branch exists locally, just switch to it
253
265
  execSync(`git checkout ${branch}`, { encoding: 'utf-8', stdio: 'pipe' });
254
266
  }
255
- else {
256
- // Branch doesn't exist, create it from current HEAD
267
+ else if (options?.skipRemoteCheck) {
268
+ // Skip remote check, just create from current HEAD
257
269
  if (verbose) {
258
270
  logInfo(` Branch ${branch} doesn't exist, creating it...`);
259
271
  }
@@ -262,6 +274,37 @@ export function switchToBranch(branch, verbose) {
262
274
  stdio: 'pipe',
263
275
  });
264
276
  }
277
+ else {
278
+ // Default: Check if branch exists on remote before creating locally
279
+ // This handles multi-clone scenarios where branch was pushed from another clone
280
+ try {
281
+ // Fetch to get latest remote refs
282
+ execSync('git fetch origin', { encoding: 'utf-8', stdio: 'pipe' });
283
+ // Check if remote branch exists
284
+ execSync(`git rev-parse --verify origin/${branch}`, {
285
+ encoding: 'utf-8',
286
+ stdio: 'pipe',
287
+ });
288
+ // Remote branch exists, create local tracking branch from it
289
+ if (verbose) {
290
+ logInfo(` Branch ${branch} found on remote, creating local tracking branch...`);
291
+ }
292
+ execSync(`git checkout -b ${branch} --track origin/${branch}`, {
293
+ encoding: 'utf-8',
294
+ stdio: 'pipe',
295
+ });
296
+ }
297
+ catch (e) {
298
+ // Remote branch doesn't exist, create new branch from current HEAD
299
+ if (verbose) {
300
+ logInfo(` Branch ${branch} doesn't exist locally or remotely, creating new branch...`);
301
+ }
302
+ execSync(`git checkout -b ${branch}`, {
303
+ encoding: 'utf-8',
304
+ stdio: 'pipe',
305
+ });
306
+ }
307
+ }
265
308
  if (verbose) {
266
309
  logInfo(`✅ Switched to ${branch} branch`);
267
310
  }
@@ -338,11 +381,12 @@ export function switchToFeatureBranchAndRebase(featureBranch, baseBranch = 'main
338
381
  }
339
382
  }
340
383
  // Switch to feature branch (will create if doesn't exist)
384
+ // Default behavior now checks remote first (handles multi-clone scenarios)
341
385
  if (getCurrentBranch() !== featureBranch) {
342
386
  switchToBranch(featureBranch, verbose);
343
387
  }
344
- // Sync with remote feature branch if it exists
345
- // This handles the case where the branch was pushed from another machine
388
+ // Sync with remote feature branch if it exists and local branch needs updating
389
+ // This handles the case where local branch exists but is behind remote
346
390
  try {
347
391
  // Check for uncommitted changes and reset if found before any git operations
348
392
  if (hasUncommittedChanges()) {
@@ -351,7 +395,7 @@ export function switchToFeatureBranchAndRebase(featureBranch, baseBranch = 'main
351
395
  }
352
396
  resetUncommittedChanges(verbose);
353
397
  }
354
- // Fetch to get latest remote state
398
+ // Fetch to get latest remote state (may have been fetched in switchToBranch, but ensures fresh data)
355
399
  execSync('git fetch origin', { encoding: 'utf-8', stdio: 'pipe' });
356
400
  // Check if remote feature branch exists
357
401
  try {
@@ -359,18 +403,35 @@ export function switchToFeatureBranchAndRebase(featureBranch, baseBranch = 'main
359
403
  encoding: 'utf-8',
360
404
  stdio: 'pipe',
361
405
  });
362
- // Remote branch exists, sync with it
363
- if (verbose) {
364
- logInfo(`📥 Syncing with remote feature branch origin/${featureBranch}...`);
365
- }
366
- // Reset local branch to match remote branch
367
- // This ensures we have all commits from the remote (e.g., from another machine)
368
- execSync(`git reset --hard origin/${featureBranch}`, {
406
+ // Remote branch exists, check if we need to sync
407
+ // Get local and remote commit SHAs
408
+ const localSha = execSync(`git rev-parse ${featureBranch}`, {
369
409
  encoding: 'utf-8',
370
410
  stdio: 'pipe',
371
- });
372
- if (verbose) {
373
- logInfo(`✅ Synced local branch with origin/${featureBranch}`);
411
+ }).trim();
412
+ const remoteSha = execSync(`git rev-parse origin/${featureBranch}`, {
413
+ encoding: 'utf-8',
414
+ stdio: 'pipe',
415
+ }).trim();
416
+ if (localSha !== remoteSha) {
417
+ // Local and remote differ, reset to remote
418
+ if (verbose) {
419
+ logInfo(`📥 Syncing with remote feature branch origin/${featureBranch}...`);
420
+ logInfo(` Local: ${localSha.substring(0, 7)}`);
421
+ logInfo(` Remote: ${remoteSha.substring(0, 7)}`);
422
+ }
423
+ // Reset local branch to match remote branch
424
+ // This ensures we have all commits from the remote (e.g., from another machine)
425
+ execSync(`git reset --hard origin/${featureBranch}`, {
426
+ encoding: 'utf-8',
427
+ stdio: 'pipe',
428
+ });
429
+ if (verbose) {
430
+ logInfo(`✅ Synced local branch with origin/${featureBranch}`);
431
+ }
432
+ }
433
+ else if (verbose) {
434
+ logInfo(`✅ Local branch is up to date with origin/${featureBranch}`);
374
435
  }
375
436
  }
376
437
  catch (e) {
@@ -660,10 +721,12 @@ export async function switchToFeatureBranchAndRebaseAsync(options) {
660
721
  }
661
722
  }
662
723
  // Switch to feature branch (will create if doesn't exist)
724
+ // Default behavior now checks remote first (handles multi-clone scenarios)
663
725
  if (getCurrentBranch() !== featureBranch) {
664
726
  switchToBranch(featureBranch, verbose);
665
727
  }
666
- // Sync with remote feature branch if it exists
728
+ // Sync with remote feature branch if it exists and local branch needs updating
729
+ // This handles the case where local branch exists but is behind remote
667
730
  try {
668
731
  if (hasUncommittedChanges()) {
669
732
  if (verbose) {
@@ -671,21 +734,40 @@ export async function switchToFeatureBranchAndRebaseAsync(options) {
671
734
  }
672
735
  resetUncommittedChanges(verbose);
673
736
  }
737
+ // Fetch to get latest remote state (may have been fetched in switchToBranch, but ensures fresh data)
674
738
  execSync('git fetch origin', { encoding: 'utf-8', stdio: 'pipe' });
675
739
  try {
676
740
  execSync(`git rev-parse --verify origin/${featureBranch}`, {
677
741
  encoding: 'utf-8',
678
742
  stdio: 'pipe',
679
743
  });
680
- if (verbose) {
681
- logInfo(`📥 Syncing with remote feature branch origin/${featureBranch}...`);
682
- }
683
- execSync(`git reset --hard origin/${featureBranch}`, {
744
+ // Remote branch exists, check if we need to sync
745
+ // Get local and remote commit SHAs
746
+ const localSha = execSync(`git rev-parse ${featureBranch}`, {
684
747
  encoding: 'utf-8',
685
748
  stdio: 'pipe',
686
- });
687
- if (verbose) {
688
- logInfo(`✅ Synced local branch with origin/${featureBranch}`);
749
+ }).trim();
750
+ const remoteSha = execSync(`git rev-parse origin/${featureBranch}`, {
751
+ encoding: 'utf-8',
752
+ stdio: 'pipe',
753
+ }).trim();
754
+ if (localSha !== remoteSha) {
755
+ // Local and remote differ, reset to remote
756
+ if (verbose) {
757
+ logInfo(`📥 Syncing with remote feature branch origin/${featureBranch}...`);
758
+ logInfo(` Local: ${localSha.substring(0, 7)}`);
759
+ logInfo(` Remote: ${remoteSha.substring(0, 7)}`);
760
+ }
761
+ execSync(`git reset --hard origin/${featureBranch}`, {
762
+ encoding: 'utf-8',
763
+ stdio: 'pipe',
764
+ });
765
+ if (verbose) {
766
+ logInfo(`✅ Synced local branch with origin/${featureBranch}`);
767
+ }
768
+ }
769
+ else if (verbose) {
770
+ logInfo(`✅ Local branch is up to date with origin/${featureBranch}`);
689
771
  }
690
772
  }
691
773
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.21.5",
3
+ "version": "0.21.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"