@sudocode-ai/local-server 0.1.8 → 0.1.10

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 (58) hide show
  1. package/dist/execution/worktree/config.js +1 -1
  2. package/dist/execution/worktree/config.js.map +1 -1
  3. package/dist/execution/worktree/git-sync-cli.d.ts +11 -0
  4. package/dist/execution/worktree/git-sync-cli.d.ts.map +1 -1
  5. package/dist/execution/worktree/git-sync-cli.js +52 -1
  6. package/dist/execution/worktree/git-sync-cli.js.map +1 -1
  7. package/dist/execution/worktree/types.d.ts.map +1 -1
  8. package/dist/execution/worktree/types.js.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -29
  11. package/dist/index.js.map +1 -1
  12. package/dist/public/assets/index-CQoCSnhl.css +1 -0
  13. package/dist/public/assets/index-iWE3gSYw.js +758 -0
  14. package/dist/public/assets/index-iWE3gSYw.js.map +1 -0
  15. package/dist/public/index.html +2 -2
  16. package/dist/routes/executions.d.ts.map +1 -1
  17. package/dist/routes/executions.js +329 -24
  18. package/dist/routes/executions.js.map +1 -1
  19. package/dist/routes/issues.d.ts.map +1 -1
  20. package/dist/routes/issues.js +13 -0
  21. package/dist/routes/issues.js.map +1 -1
  22. package/dist/routes/repo-info.d.ts.map +1 -1
  23. package/dist/routes/repo-info.js +77 -0
  24. package/dist/routes/repo-info.js.map +1 -1
  25. package/dist/routes/specs.d.ts.map +1 -1
  26. package/dist/routes/specs.js +14 -0
  27. package/dist/routes/specs.js.map +1 -1
  28. package/dist/routes/version.d.ts +3 -0
  29. package/dist/routes/version.d.ts.map +1 -0
  30. package/dist/routes/version.js +25 -0
  31. package/dist/routes/version.js.map +1 -0
  32. package/dist/services/execution-changes-service.d.ts +18 -0
  33. package/dist/services/execution-changes-service.d.ts.map +1 -1
  34. package/dist/services/execution-changes-service.js +155 -1
  35. package/dist/services/execution-changes-service.js.map +1 -1
  36. package/dist/services/execution-lifecycle.d.ts +2 -2
  37. package/dist/services/execution-lifecycle.d.ts.map +1 -1
  38. package/dist/services/execution-lifecycle.js +7 -23
  39. package/dist/services/execution-lifecycle.js.map +1 -1
  40. package/dist/services/execution-service.d.ts +32 -1
  41. package/dist/services/execution-service.d.ts.map +1 -1
  42. package/dist/services/execution-service.js +81 -0
  43. package/dist/services/execution-service.js.map +1 -1
  44. package/dist/services/project-manager.d.ts.map +1 -1
  45. package/dist/services/project-manager.js +18 -12
  46. package/dist/services/project-manager.js.map +1 -1
  47. package/dist/services/version-service.d.ts +14 -0
  48. package/dist/services/version-service.d.ts.map +1 -0
  49. package/dist/services/version-service.js +57 -0
  50. package/dist/services/version-service.js.map +1 -0
  51. package/dist/services/worktree-sync-service.d.ts +183 -13
  52. package/dist/services/worktree-sync-service.d.ts.map +1 -1
  53. package/dist/services/worktree-sync-service.js +851 -82
  54. package/dist/services/worktree-sync-service.js.map +1 -1
  55. package/package.json +3 -3
  56. package/dist/public/assets/index-Bb_W5bUr.css +0 -1
  57. package/dist/public/assets/index-CFKL113G.js +0 -710
  58. package/dist/public/assets/index-CFKL113G.js.map +0 -1
@@ -9,10 +9,11 @@
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
11
  import { execSync } from "child_process";
12
- import { GitSyncCli } from "../execution/worktree/git-sync-cli.js";
13
- import { ConflictDetector } from "../execution/worktree/conflict-detector.js";
14
- import { mergeThreeWay } from "@sudocode-ai/cli/dist/merge-resolver.js";
15
- import { writeJSONL } from "@sudocode-ai/cli/dist/jsonl.js";
12
+ import { GitSyncCli, } from "../execution/worktree/git-sync-cli.js";
13
+ import { ConflictDetector, } from "../execution/worktree/conflict-detector.js";
14
+ import { mergeThreeWay, hasGitConflictMarkers, parseMergeConflictFile, resolveEntities, } from "@sudocode-ai/cli/dist/merge-resolver.js";
15
+ import { writeJSONL, readJSONLSync, } from "@sudocode-ai/cli/dist/jsonl.js";
16
+ import * as os from "os";
16
17
  /**
17
18
  * Worktree sync error codes
18
19
  */
@@ -52,12 +53,10 @@ export class WorktreeSyncService {
52
53
  db;
53
54
  repoPath;
54
55
  gitSync;
55
- conflictDetector;
56
56
  constructor(db, repoPath) {
57
57
  this.db = db;
58
58
  this.repoPath = repoPath;
59
59
  this.gitSync = new GitSyncCli(repoPath);
60
- this.conflictDetector = new ConflictDetector(repoPath);
61
60
  }
62
61
  /**
63
62
  * Preview sync without making changes
@@ -68,12 +67,10 @@ export class WorktreeSyncService {
68
67
  async previewSync(executionId) {
69
68
  // 1. Load execution and validate
70
69
  const execution = await this._loadAndValidateExecution(executionId);
71
- // 2. Validate preconditions
72
- try {
73
- await this._validateSyncPreconditions(execution);
74
- }
75
- catch (error) {
76
- // Return preview with error details
70
+ // 2. Validate critical preconditions (ones that prevent us from getting any info)
71
+ // These are "hard" failures - we can't get diff/commits if these fail
72
+ const criticalPreconditionError = await this._validateCriticalPreconditions(execution);
73
+ if (criticalPreconditionError) {
77
74
  return {
78
75
  canSync: false,
79
76
  conflicts: {
@@ -87,37 +84,51 @@ export class WorktreeSyncService {
87
84
  commits: [],
88
85
  mergeBase: "",
89
86
  uncommittedJSONLChanges: [],
87
+ uncommittedChanges: { files: [], additions: 0, deletions: 0 },
90
88
  executionStatus: execution.status,
91
- warnings: [error.message],
89
+ warnings: [criticalPreconditionError],
92
90
  };
93
91
  }
94
- // 3. Find merge base
92
+ // 3. Create ConflictDetector instance for worktree context
93
+ const worktreeConflictDetector = new ConflictDetector(execution.worktree_path);
94
+ // 4. Find merge base (use main repo since it has both branches)
95
95
  const mergeBase = this.gitSync.getMergeBase(execution.branch_name, execution.target_branch);
96
96
  // 4. Get commit list
97
97
  const commits = this.gitSync.getCommitList(mergeBase, execution.branch_name);
98
- // 5. Get diff summary
98
+ // 6. Get diff summary (use main repo to see all changes)
99
99
  const diff = this.gitSync.getDiff(mergeBase, execution.branch_name);
100
- // 6. Detect conflicts
101
- const conflicts = this.conflictDetector.detectConflicts(execution.branch_name, execution.target_branch);
102
- // 7. Check for uncommitted JSONL changes
103
- const uncommittedJSONL = this._getUncommittedJSONLFiles(execution.worktree_path);
104
- // 8. Generate warnings
100
+ // 7. Detect conflicts (use worktree for conflict detection)
101
+ const conflicts = worktreeConflictDetector.detectConflicts(execution.branch_name, execution.target_branch);
102
+ // 7. Check for uncommitted changes in worktree (not included by default)
103
+ const uncommittedFiles = this._getUncommittedFiles(execution.worktree_path);
104
+ const uncommittedJSONL = uncommittedFiles.filter((file) => file.endsWith(".jsonl") &&
105
+ (file.includes(".sudocode/") || file.startsWith(".sudocode/")));
106
+ const uncommittedChanges = this._getUncommittedFileStats(execution.worktree_path);
107
+ // 8. Generate warnings and check "soft" preconditions
105
108
  const warnings = [];
109
+ let canSync = true;
110
+ // Check if local working tree is clean (soft precondition - we can still show preview)
111
+ if (!this.gitSync.isWorkingTreeClean()) {
112
+ warnings.push("Local working tree has uncommitted changes. Stash or commit them first.");
113
+ canSync = false;
114
+ }
106
115
  // Warn if execution is running/paused
107
- if (execution.status === "running" ||
108
- execution.status === "paused") {
116
+ if (execution.status === "running" || execution.status === "paused") {
109
117
  warnings.push("Execution is currently active. Synced state may not reflect final execution result.");
110
118
  }
111
119
  // Warn about code conflicts
112
120
  if (conflicts.codeConflicts.length > 0) {
113
- warnings.push(`${conflicts.codeConflicts.length} code conflict(s) detected. Manual resolution required.`);
114
- }
115
- // Warn about uncommitted JSONL
116
- if (uncommittedJSONL.length > 0) {
117
- warnings.push(`${uncommittedJSONL.length} uncommitted JSONL file(s) will be included in sync.`);
121
+ warnings.push(`${conflicts.codeConflicts.length} code conflict(s) detected. Manual resolution may be required.`);
118
122
  }
119
- // 9. Determine if sync can proceed
120
- const canSync = conflicts.codeConflicts.length === 0;
123
+ // Note about uncommitted files (not included by default)
124
+ // if (uncommittedChanges && uncommittedChanges.files.length > 0) {
125
+ // warnings.push(
126
+ // `${uncommittedChanges.files.length} uncommitted file(s) in worktree will NOT be included (only committed changes are synced).`
127
+ // );
128
+ // }
129
+ // 9. Detect potential local conflicts for uncommitted files
130
+ // These are files in worktree that also have local changes or are untracked locally
131
+ const potentialLocalConflicts = this._detectPotentialLocalConflicts(uncommittedChanges?.files || []);
121
132
  return {
122
133
  canSync,
123
134
  conflicts,
@@ -125,10 +136,49 @@ export class WorktreeSyncService {
125
136
  commits,
126
137
  mergeBase,
127
138
  uncommittedJSONLChanges: uncommittedJSONL,
139
+ uncommittedChanges,
140
+ potentialLocalConflicts,
128
141
  executionStatus: execution.status,
129
142
  warnings,
130
143
  };
131
144
  }
145
+ /**
146
+ * Validate critical preconditions that prevent us from getting any sync info
147
+ *
148
+ * These are "hard" failures - if these fail, we can't get diff/commits info.
149
+ * Returns an error message if validation fails, null if validation passes.
150
+ *
151
+ * @param execution - Execution to validate
152
+ * @returns Error message if validation fails, null if validation passes
153
+ */
154
+ async _validateCriticalPreconditions(execution) {
155
+ // 1. Check worktree path exists
156
+ if (!execution.worktree_path) {
157
+ return "No worktree path for execution";
158
+ }
159
+ // 2. Check worktree still exists on filesystem
160
+ if (!fs.existsSync(execution.worktree_path)) {
161
+ return "Worktree no longer exists";
162
+ }
163
+ // 3. Get list of branches
164
+ const branches = this._getBranches();
165
+ // 4. Check worktree branch exists
166
+ if (!branches.includes(execution.branch_name)) {
167
+ return `Worktree branch '${execution.branch_name}' not found`;
168
+ }
169
+ // 5. Check target branch exists
170
+ if (!branches.includes(execution.target_branch)) {
171
+ return `Target branch '${execution.target_branch}' not found`;
172
+ }
173
+ // 6. Verify branches have common base
174
+ try {
175
+ this.gitSync.getMergeBase(execution.branch_name, execution.target_branch);
176
+ }
177
+ catch (error) {
178
+ return "Worktree and target branch have diverged without common history";
179
+ }
180
+ return null;
181
+ }
132
182
  /**
133
183
  * Load execution from database and validate it exists
134
184
  *
@@ -161,7 +211,8 @@ export class WorktreeSyncService {
161
211
  * @param execution - Execution to validate
162
212
  * @throws WorktreeSyncError if any precondition fails
163
213
  */
164
- async _validateSyncPreconditions(execution) {
214
+ async _validateSyncPreconditions(execution, options) {
215
+ const { skipDirtyWorkingTreeCheck = false } = options || {};
165
216
  // 1. Check worktree path exists
166
217
  if (!execution.worktree_path) {
167
218
  throw new WorktreeSyncError("No worktree path for execution", WorktreeSyncErrorCode.NO_WORKTREE);
@@ -180,8 +231,8 @@ export class WorktreeSyncService {
180
231
  if (!branches.includes(execution.target_branch)) {
181
232
  throw new WorktreeSyncError(`Target branch '${execution.target_branch}' not found`, WorktreeSyncErrorCode.TARGET_BRANCH_MISSING);
182
233
  }
183
- // 6. Check local working tree is clean
184
- if (!this.gitSync.isWorkingTreeClean()) {
234
+ // 6. Check local working tree is clean (skip for stage mode since it doesn't commit)
235
+ if (!skipDirtyWorkingTreeCheck && !this.gitSync.isWorkingTreeClean()) {
185
236
  throw new WorktreeSyncError("Local working tree has uncommitted changes. Stash or commit them first.", WorktreeSyncErrorCode.DIRTY_WORKING_TREE);
186
237
  }
187
238
  // 7. Verify branches have common base
@@ -211,20 +262,85 @@ export class WorktreeSyncService {
211
262
  return tagName;
212
263
  }
213
264
  /**
214
- * Get uncommitted JSONL files from worktree
215
- *
216
- * Used by previewSync() and will be used in i-3wmx (JSONL conflict resolution)
265
+ * Get all uncommitted files from worktree
217
266
  *
218
267
  * @param worktreePath - Path to worktree
219
- * @returns Array of uncommitted JSONL file paths
268
+ * @returns Array of all uncommitted file paths
220
269
  */
221
- _getUncommittedJSONLFiles(worktreePath) {
270
+ _getUncommittedFiles(worktreePath) {
222
271
  const gitSyncWorktree = new GitSyncCli(worktreePath);
223
- // Get all uncommitted files
224
- const uncommitted = gitSyncWorktree.getUncommittedFiles();
225
- // Filter for JSONL files in .sudocode/
226
- return uncommitted.filter((file) => file.endsWith(".jsonl") &&
227
- (file.includes(".sudocode/") || file.startsWith(".sudocode/")));
272
+ return gitSyncWorktree.getUncommittedFiles();
273
+ }
274
+ /**
275
+ * Get uncommitted file stats from worktree
276
+ *
277
+ * Returns list of files and aggregate additions/deletions stats
278
+ * for uncommitted changes in the worktree.
279
+ *
280
+ * @param worktreePath - Path to worktree
281
+ * @returns Uncommitted file stats
282
+ */
283
+ _getUncommittedFileStats(worktreePath) {
284
+ try {
285
+ // Get modified files
286
+ const modifiedOutput = execSync("git diff --numstat", {
287
+ cwd: worktreePath,
288
+ encoding: "utf8",
289
+ stdio: "pipe",
290
+ });
291
+ // Get untracked files
292
+ const untrackedFiles = execSync("git ls-files --others --exclude-standard", {
293
+ cwd: worktreePath,
294
+ encoding: "utf8",
295
+ stdio: "pipe",
296
+ })
297
+ .split("\n")
298
+ .filter((line) => line.trim().length > 0);
299
+ // Parse modified file stats
300
+ let additions = 0;
301
+ let deletions = 0;
302
+ const modifiedFiles = [];
303
+ for (const line of modifiedOutput.split("\n")) {
304
+ if (!line.trim())
305
+ continue;
306
+ const parts = line.split("\t");
307
+ if (parts.length >= 3) {
308
+ const add = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
309
+ const del = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
310
+ additions += add;
311
+ deletions += del;
312
+ modifiedFiles.push(parts[2]);
313
+ }
314
+ }
315
+ // Count lines in untracked files as additions
316
+ for (const filePath of untrackedFiles) {
317
+ try {
318
+ const fullPath = path.join(worktreePath, filePath);
319
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
320
+ const content = fs.readFileSync(fullPath, "utf-8");
321
+ additions += content.split("\n").length;
322
+ }
323
+ }
324
+ catch (e) {
325
+ // Skip files we can't read
326
+ }
327
+ }
328
+ // Combine all files
329
+ const allFiles = [...new Set([...modifiedFiles, ...untrackedFiles])];
330
+ return {
331
+ files: allFiles,
332
+ additions,
333
+ deletions,
334
+ };
335
+ }
336
+ catch (error) {
337
+ // Return empty stats on error
338
+ return {
339
+ files: [],
340
+ additions: 0,
341
+ deletions: 0,
342
+ };
343
+ }
228
344
  }
229
345
  /**
230
346
  * Check if local working tree is clean
@@ -340,7 +456,9 @@ export class WorktreeSyncService {
340
456
  stdio: "pipe",
341
457
  });
342
458
  // Parse JSONL content
343
- const lines = content.split("\n").filter((line) => line.trim().length > 0);
459
+ const lines = content
460
+ .split("\n")
461
+ .filter((line) => line.trim().length > 0);
344
462
  return lines.map((line) => JSON.parse(line));
345
463
  }
346
464
  catch (error) {
@@ -385,39 +503,376 @@ export class WorktreeSyncService {
385
503
  }
386
504
  }
387
505
  /**
388
- * Perform git merge --squash operation
506
+ * Perform git merge --squash operation, allowing conflicts
507
+ *
508
+ * This method doesn't throw on conflicts.
509
+ * Instead, it returns information about whether conflicts occurred.
389
510
  *
390
511
  * @param sourceBranch - Branch to merge from (worktree branch)
391
512
  * @param targetBranch - Branch to merge into
392
- * @returns Object with filesChanged count
393
- * @throws WorktreeSyncError if merge fails
513
+ * @returns Object with filesChanged count and hasConflicts flag
394
514
  */
395
- _performSquashMerge(sourceBranch, targetBranch) {
515
+ _performSquashMergeAllowConflicts(sourceBranch, targetBranch) {
516
+ // Checkout target branch
517
+ execSync(`git checkout ${this._escapeShellArg(targetBranch)}`, {
518
+ cwd: this.repoPath,
519
+ stdio: "pipe",
520
+ });
521
+ // Perform squash merge - may fail with conflicts
522
+ let hasConflicts = false;
396
523
  try {
397
- // Checkout target branch
398
- execSync(`git checkout ${this._escapeShellArg(targetBranch)}`, {
524
+ execSync(`git merge --squash ${this._escapeShellArg(sourceBranch)}`, {
399
525
  cwd: this.repoPath,
400
526
  stdio: "pipe",
401
527
  });
402
- // Perform squash merge
403
- execSync(`git merge --squash ${this._escapeShellArg(sourceBranch)}`, {
528
+ }
529
+ catch (error) {
530
+ // Check if this is a conflict situation (exit code 1) or a real error
531
+ // git merge --squash returns 1 on conflicts but stages what it can
532
+ hasConflicts = true;
533
+ }
534
+ // Count staged files (including conflicted ones)
535
+ const statusOutput = execSync("git diff --cached --name-only", {
536
+ cwd: this.repoPath,
537
+ encoding: "utf8",
538
+ stdio: "pipe",
539
+ });
540
+ const filesChanged = statusOutput
541
+ .split("\n")
542
+ .filter((line) => line.trim().length > 0).length;
543
+ // Check for actual conflicts in the working tree
544
+ try {
545
+ const conflictCheck = execSync("git diff --name-only --diff-filter=U", {
546
+ cwd: this.repoPath,
547
+ encoding: "utf8",
548
+ stdio: "pipe",
549
+ });
550
+ hasConflicts = conflictCheck.trim().length > 0;
551
+ }
552
+ catch (e) {
553
+ // If this fails, assume no conflicts
554
+ }
555
+ return { filesChanged, hasConflicts };
556
+ }
557
+ /**
558
+ * Check if a file has local uncommitted changes compared to HEAD
559
+ *
560
+ * @param filePath - Relative path to the file
561
+ * @returns true if file has uncommitted changes, false otherwise
562
+ */
563
+ _hasLocalUncommittedChanges(filePath) {
564
+ try {
565
+ // git diff --quiet exits with 1 if there are changes, 0 if clean
566
+ execSync(`git diff --quiet HEAD -- ${this._escapeShellArg(filePath)}`, {
404
567
  cwd: this.repoPath,
405
568
  stdio: "pipe",
406
569
  });
407
- // Count staged files
408
- const statusOutput = execSync("git diff --cached --name-only", {
570
+ return false; // Exit 0 = no changes
571
+ }
572
+ catch {
573
+ return true; // Exit 1 = has changes
574
+ }
575
+ }
576
+ /**
577
+ * Detect potential local conflicts for uncommitted worktree files
578
+ *
579
+ * Checks which uncommitted worktree files also exist locally with changes
580
+ * or are untracked locally. These files may have merge conflicts when synced.
581
+ *
582
+ * @param worktreeFiles - List of uncommitted files in worktree
583
+ * @returns Info about files that may have conflicts
584
+ */
585
+ _detectPotentialLocalConflicts(worktreeFiles) {
586
+ const conflictFiles = [];
587
+ for (const filePath of worktreeFiles) {
588
+ const localPath = path.join(this.repoPath, filePath);
589
+ const localFileExists = fs.existsSync(localPath);
590
+ if (localFileExists) {
591
+ // Check if local file has uncommitted changes vs HEAD or is untracked
592
+ const hasChangesVsHead = this._hasLocalUncommittedChanges(filePath);
593
+ const isUntracked = this._isFileUntracked(filePath);
594
+ if (hasChangesVsHead || isUntracked) {
595
+ conflictFiles.push(filePath);
596
+ }
597
+ }
598
+ }
599
+ return {
600
+ count: conflictFiles.length,
601
+ files: conflictFiles,
602
+ };
603
+ }
604
+ /**
605
+ * Check if a file is untracked by git (not in the index)
606
+ *
607
+ * @param filePath - Relative path to the file
608
+ * @returns true if file is untracked, false if tracked
609
+ */
610
+ _isFileUntracked(filePath) {
611
+ try {
612
+ // git ls-files returns the file path if it's tracked, empty if not
613
+ const result = execSync(`git ls-files -- ${this._escapeShellArg(filePath)}`, {
409
614
  cwd: this.repoPath,
410
615
  encoding: "utf8",
411
616
  stdio: "pipe",
412
617
  });
413
- const filesChanged = statusOutput
414
- .split("\n")
415
- .filter((line) => line.trim().length > 0).length;
416
- return { filesChanged };
618
+ return result.trim().length === 0; // Empty = untracked
417
619
  }
418
- catch (error) {
419
- throw new WorktreeSyncError(`Failed to perform squash merge: ${error.message}`, WorktreeSyncErrorCode.MERGE_FAILED, error);
620
+ catch {
621
+ return true; // Assume untracked on error
622
+ }
623
+ }
624
+ /**
625
+ * Check if a file is a JSONL file in the .sudocode directory
626
+ *
627
+ * @param filePath - Relative path to the file
628
+ * @returns true if file is a .sudocode JSONL file
629
+ */
630
+ _isJSONLFile(filePath) {
631
+ return (filePath.endsWith(".jsonl") &&
632
+ (filePath.startsWith(".sudocode/") || filePath.includes("/.sudocode/")));
633
+ }
634
+ /**
635
+ * Perform three-way merge on a file using git merge-file
636
+ *
637
+ * Uses HEAD as base, local working copy as "ours", and worktree version as "theirs".
638
+ * Modifies the local file in place, inserting conflict markers if needed.
639
+ *
640
+ * @param filePath - Relative path to the file in local repo
641
+ * @param worktreeFilePath - Absolute path to the file in worktree
642
+ * @returns true if there are conflicts, false if merge was clean
643
+ */
644
+ _threeWayMergeFile(filePath, worktreeFilePath) {
645
+ const localFilePath = path.join(this.repoPath, filePath);
646
+ // Create temp file for base version from HEAD
647
+ const tempDir = os.tmpdir();
648
+ const baseTempFile = path.join(tempDir, `sudocode-merge-base-${Date.now()}-${path.basename(filePath)}`);
649
+ try {
650
+ // Get base version from HEAD
651
+ let baseContent = "";
652
+ try {
653
+ baseContent = execSync(`git show HEAD:${this._escapeShellArg(filePath)}`, {
654
+ cwd: this.repoPath,
655
+ encoding: "utf8",
656
+ stdio: "pipe",
657
+ });
658
+ }
659
+ catch {
660
+ // File might be new (not in HEAD), use empty base
661
+ baseContent = "";
662
+ }
663
+ fs.writeFileSync(baseTempFile, baseContent, "utf8");
664
+ // git merge-file modifies the first file in place
665
+ // Returns 0 if clean merge, >0 for number of conflicts, -1 for error
666
+ try {
667
+ execSync(`git merge-file -L "LOCAL" -L "BASE" -L "WORKTREE" ${this._escapeShellArg(localFilePath)} ${this._escapeShellArg(baseTempFile)} ${this._escapeShellArg(worktreeFilePath)}`, {
668
+ cwd: this.repoPath,
669
+ stdio: "pipe",
670
+ });
671
+ return false; // Exit 0 = clean merge, no conflicts
672
+ }
673
+ catch (error) {
674
+ // Exit code > 0 means conflicts (number of conflicts)
675
+ // Exit code < 0 means error
676
+ if (error.status > 0) {
677
+ return true; // Has conflicts
678
+ }
679
+ // Real error - rethrow
680
+ throw error;
681
+ }
682
+ }
683
+ finally {
684
+ // Clean up temp file
685
+ if (fs.existsSync(baseTempFile)) {
686
+ fs.unlinkSync(baseTempFile);
687
+ }
688
+ }
689
+ }
690
+ /**
691
+ * Merge two JSONL files using UUID-based resolution
692
+ *
693
+ * Reads both local and worktree versions, merges entities by UUID,
694
+ * and writes the result back to the local file.
695
+ *
696
+ * @param localFilePath - Absolute path to local JSONL file
697
+ * @param worktreeFilePath - Absolute path to worktree JSONL file
698
+ */
699
+ async _mergeJSONLFiles(localFilePath, worktreeFilePath) {
700
+ // Read both versions
701
+ const localEntities = readJSONLSync(localFilePath, { skipErrors: true });
702
+ const worktreeEntities = readJSONLSync(worktreeFilePath, {
703
+ skipErrors: true,
704
+ });
705
+ // Combine and resolve using UUID-based deduplication
706
+ const allEntities = [...localEntities, ...worktreeEntities];
707
+ const { entities: merged } = resolveEntities(allEntities);
708
+ // Write merged result back to local file
709
+ await writeJSONL(localFilePath, merged);
710
+ }
711
+ /**
712
+ * Copy uncommitted files from worktree to local repo with safe merging
713
+ *
714
+ * For files with local uncommitted changes:
715
+ * - JSONL files: Uses UUID-based merge resolution
716
+ * - Other files: Uses git merge-file for three-way merge with conflict markers
717
+ *
718
+ * Files without local changes are copied directly.
719
+ *
720
+ * @param worktreePath - Path to the worktree
721
+ * @param options - Optional settings
722
+ * @param options.overrideLocalChanges - If true, skip merge and overwrite local changes
723
+ * @returns Object with filesCopied count and list of files with conflicts
724
+ */
725
+ async _copyUncommittedFiles(worktreePath, options) {
726
+ const { overrideLocalChanges = false } = options || {};
727
+ // Get list of uncommitted/untracked files in worktree
728
+ const modifiedOutput = execSync("git diff --name-only", {
729
+ cwd: worktreePath,
730
+ encoding: "utf8",
731
+ stdio: "pipe",
732
+ });
733
+ const untrackedOutput = execSync("git ls-files --others --exclude-standard", {
734
+ cwd: worktreePath,
735
+ encoding: "utf8",
736
+ stdio: "pipe",
737
+ });
738
+ const modifiedFiles = modifiedOutput
739
+ .split("\n")
740
+ .filter((line) => line.trim().length > 0);
741
+ const untrackedFiles = untrackedOutput
742
+ .split("\n")
743
+ .filter((line) => line.trim().length > 0);
744
+ const allFiles = [...new Set([...modifiedFiles, ...untrackedFiles])];
745
+ if (allFiles.length === 0) {
746
+ return { filesCopied: 0, filesWithConflicts: [] };
747
+ }
748
+ // Process each file from worktree
749
+ let filesCopied = 0;
750
+ const filesWithConflicts = [];
751
+ for (const filePath of allFiles) {
752
+ const srcPath = path.join(worktreePath, filePath);
753
+ const destPath = path.join(this.repoPath, filePath);
754
+ // Check if source file exists
755
+ if (!fs.existsSync(srcPath)) {
756
+ continue;
757
+ }
758
+ // Create destination directory if needed
759
+ const destDir = path.dirname(destPath);
760
+ if (!fs.existsSync(destDir)) {
761
+ fs.mkdirSync(destDir, { recursive: true });
762
+ }
763
+ // Check if local file has uncommitted changes or is untracked
764
+ // We need to merge if: (1) file exists locally AND (2) either has changes vs HEAD or is untracked
765
+ const localFileExists = fs.existsSync(destPath);
766
+ const localHasChangesVsHead = localFileExists && this._hasLocalUncommittedChanges(filePath);
767
+ const localIsUntracked = localFileExists && this._isFileUntracked(filePath);
768
+ const needsMerge = !overrideLocalChanges && (localHasChangesVsHead || localIsUntracked);
769
+ let hasConflicts = false;
770
+ if (!needsMerge) {
771
+ // No local changes OR override mode - copy directly (overwrites local)
772
+ fs.copyFileSync(srcPath, destPath);
773
+ }
774
+ else if (this._isJSONLFile(filePath)) {
775
+ // JSONL file with local changes - use UUID-based merge
776
+ await this._mergeJSONLFiles(destPath, srcPath);
777
+ }
778
+ else {
779
+ // Other file with local changes - use three-way merge
780
+ hasConflicts = this._threeWayMergeFile(filePath, srcPath);
781
+ if (hasConflicts) {
782
+ filesWithConflicts.push(filePath);
783
+ }
784
+ }
785
+ // Stage the file ONLY if it doesn't have conflicts
786
+ // Files with conflict markers should remain unstaged so VS Code can detect them
787
+ if (!hasConflicts) {
788
+ execSync(`git add ${this._escapeShellArg(filePath)}`, {
789
+ cwd: this.repoPath,
790
+ stdio: "pipe",
791
+ });
792
+ }
793
+ filesCopied++;
420
794
  }
795
+ return { filesCopied, filesWithConflicts };
796
+ }
797
+ /**
798
+ * Resolve JSONL merge conflicts in the local repository
799
+ *
800
+ * Checks for git conflict markers in issues.jsonl and specs.jsonl,
801
+ * and resolves them using the merge-resolver logic.
802
+ *
803
+ * @returns Number of files resolved
804
+ */
805
+ async _resolveJSONLConflicts() {
806
+ const sudocodePath = path.join(this.repoPath, ".sudocode");
807
+ const issuesPath = path.join(sudocodePath, "issues.jsonl");
808
+ const specsPath = path.join(sudocodePath, "specs.jsonl");
809
+ let filesResolved = 0;
810
+ // Check and resolve issues.jsonl
811
+ if (fs.existsSync(issuesPath) && hasGitConflictMarkers(issuesPath)) {
812
+ await this._resolveJSONLFile(issuesPath);
813
+ filesResolved++;
814
+ }
815
+ // Check and resolve specs.jsonl
816
+ if (fs.existsSync(specsPath) && hasGitConflictMarkers(specsPath)) {
817
+ await this._resolveJSONLFile(specsPath);
818
+ filesResolved++;
819
+ }
820
+ return filesResolved;
821
+ }
822
+ /**
823
+ * Resolve conflicts in a single JSONL file
824
+ *
825
+ * @param filePath - Path to the JSONL file with conflicts
826
+ */
827
+ async _resolveJSONLFile(filePath) {
828
+ // Read file with conflict markers
829
+ const content = fs.readFileSync(filePath, "utf8");
830
+ // Parse conflicts
831
+ const sections = parseMergeConflictFile(content);
832
+ // Extract all entities (from both clean and conflict sections)
833
+ const allEntities = [];
834
+ for (const section of sections) {
835
+ if (section.type === "clean") {
836
+ for (const line of section.lines) {
837
+ if (line.trim()) {
838
+ try {
839
+ allEntities.push(JSON.parse(line));
840
+ }
841
+ catch {
842
+ // Skip malformed lines
843
+ }
844
+ }
845
+ }
846
+ }
847
+ else {
848
+ // Conflict section - include both ours and theirs
849
+ for (const line of [
850
+ ...(section.ours || []),
851
+ ...(section.theirs || []),
852
+ ]) {
853
+ if (line.trim()) {
854
+ try {
855
+ allEntities.push(JSON.parse(line));
856
+ }
857
+ catch {
858
+ // Skip malformed lines
859
+ }
860
+ }
861
+ }
862
+ }
863
+ }
864
+ // Resolve conflicts
865
+ const { entities: resolved } = resolveEntities(allEntities, {
866
+ verbose: false,
867
+ });
868
+ // Write back resolved entities
869
+ await writeJSONL(filePath, resolved);
870
+ // Stage the resolved file
871
+ const relativePath = path.relative(this.repoPath, filePath);
872
+ execSync(`git add ${this._escapeShellArg(relativePath)}`, {
873
+ cwd: this.repoPath,
874
+ stdio: "pipe",
875
+ });
421
876
  }
422
877
  /**
423
878
  * Generate commit message for squash sync
@@ -489,8 +944,9 @@ Synced changes from worktree execution.`;
489
944
  /**
490
945
  * Perform squash sync operation
491
946
  *
492
- * Squashes all worktree commits into a single commit on the target branch.
493
- * Auto-resolves JSONL conflicts but blocks on code conflicts.
947
+ * Squashes all committed worktree changes into a single commit on the target branch.
948
+ * Only includes committed changes - uncommitted changes are excluded.
949
+ * If merge conflicts occur, they are left for the user to resolve manually.
494
950
  *
495
951
  * @param executionId - Execution ID to sync
496
952
  * @param customCommitMessage - Optional custom commit message
@@ -502,34 +958,57 @@ Synced changes from worktree execution.`;
502
958
  const execution = await this._loadAndValidateExecution(executionId);
503
959
  // 2. Validate preconditions
504
960
  await this._validateSyncPreconditions(execution);
505
- // 3. Preview sync to check for conflicts
961
+ // 3. Preview sync to get info (we'll proceed even with conflicts)
506
962
  const preview = await this.previewSync(executionId);
507
- // 4. Block if code conflicts exist
508
- if (preview.conflicts.codeConflicts.length > 0) {
963
+ // 4. Check if there are any commits to merge
964
+ if (preview.commits.length === 0) {
509
965
  return {
510
966
  success: false,
511
967
  filesChanged: 0,
512
- conflictsResolved: 0,
513
- uncommittedJSONLIncluded: false,
514
- error: `Cannot sync: ${preview.conflicts.codeConflicts.length} code conflict(s) detected. Please resolve manually.`,
968
+ error: "No commits to merge. Only committed changes are included in sync.",
969
+ };
970
+ }
971
+ // 5. Check if worktree branch is already merged into target
972
+ if (this._isAncestor(execution.branch_name, execution.target_branch)) {
973
+ return {
974
+ success: false,
975
+ filesChanged: 0,
976
+ error: "Target branch is already up to date with worktree changes. Nothing to merge.",
515
977
  };
516
978
  }
517
979
  let safetyTag;
518
980
  try {
519
- // 5. Handle uncommitted JSONL files
520
- const uncommittedJSONL = preview.uncommittedJSONLChanges;
521
- if (uncommittedJSONL.length > 0) {
522
- await this.commitUncommittedJSONL(execution.worktree_path, uncommittedJSONL);
523
- }
524
- // 6. Create safety snapshot
981
+ // 6. Create safety snapshot (before any changes)
525
982
  safetyTag = await this._createSafetySnapshot(executionId, execution.target_branch);
526
- // 7. Perform git merge --squash
527
- const mergeResult = this._performSquashMerge(execution.branch_name, execution.target_branch);
528
- // 8. Resolve JSONL conflicts
529
- let conflictsResolved = 0;
530
- if (preview.conflicts.jsonlConflicts.length > 0) {
531
- await this.resolveJSONLConflicts(execution, preview.conflicts.jsonlConflicts);
532
- conflictsResolved = preview.conflicts.jsonlConflicts.length;
983
+ // 7. Perform git merge --squash (may have conflicts)
984
+ const mergeResult = this._performSquashMergeAllowConflicts(execution.branch_name, execution.target_branch);
985
+ // 8. Check if there are unresolved conflicts
986
+ if (mergeResult.hasConflicts) {
987
+ // Get list of files with conflicts
988
+ let filesWithConflicts = [];
989
+ try {
990
+ const conflictCheck = execSync("git diff --name-only --diff-filter=U", {
991
+ cwd: this.repoPath,
992
+ encoding: "utf8",
993
+ stdio: "pipe",
994
+ });
995
+ filesWithConflicts = conflictCheck
996
+ .trim()
997
+ .split("\n")
998
+ .filter((f) => f.length > 0);
999
+ }
1000
+ catch {
1001
+ // If command fails, leave empty
1002
+ }
1003
+ // Return with conflicts info - user must resolve manually
1004
+ return {
1005
+ success: false,
1006
+ filesChanged: mergeResult.filesChanged,
1007
+ hasConflicts: true,
1008
+ filesWithConflicts,
1009
+ error: "Merge conflicts detected. Please resolve them manually and commit.",
1010
+ cleanupOffered: false,
1011
+ };
533
1012
  }
534
1013
  // 9. Generate commit message
535
1014
  const commitMessage = customCommitMessage ||
@@ -541,8 +1020,6 @@ Synced changes from worktree execution.`;
541
1020
  success: true,
542
1021
  finalCommit,
543
1022
  filesChanged: mergeResult.filesChanged,
544
- conflictsResolved,
545
- uncommittedJSONLIncluded: uncommittedJSONL.length > 0,
546
1023
  cleanupOffered: true,
547
1024
  };
548
1025
  }
@@ -559,5 +1036,297 @@ Synced changes from worktree execution.`;
559
1036
  throw new WorktreeSyncError(`Squash sync failed: ${error.message}`, WorktreeSyncErrorCode.MERGE_FAILED, error);
560
1037
  }
561
1038
  }
1039
+ /**
1040
+ * Perform stage sync operation
1041
+ *
1042
+ * Applies committed worktree changes to the working directory without committing.
1043
+ * Changes are left staged, ready for the user to commit manually.
1044
+ * Only includes committed changes by default - uncommitted changes are excluded
1045
+ * unless includeUncommitted is true.
1046
+ *
1047
+ * @param executionId - Execution ID to sync
1048
+ * @param options - Optional settings
1049
+ * @param options.includeUncommitted - If true, also copy uncommitted files from worktree
1050
+ * @param options.overrideLocalChanges - If true, overwrite local changes instead of merging
1051
+ * @returns Sync result with details
1052
+ * @throws WorktreeSyncError if sync fails
1053
+ */
1054
+ async stageSync(executionId, options) {
1055
+ const { includeUncommitted = false, overrideLocalChanges = false } = options || {};
1056
+ // 1. Load and validate execution
1057
+ const execution = await this._loadAndValidateExecution(executionId);
1058
+ // 2. Validate preconditions (skip dirty working tree check - stage mode doesn't commit)
1059
+ await this._validateSyncPreconditions(execution, {
1060
+ skipDirtyWorkingTreeCheck: true,
1061
+ });
1062
+ // 3. Preview sync to get info
1063
+ const preview = await this.previewSync(executionId);
1064
+ // 4. Check if there's anything to sync
1065
+ const hasCommits = preview.commits.length > 0;
1066
+ if (!hasCommits && !includeUncommitted) {
1067
+ return {
1068
+ success: false,
1069
+ filesChanged: 0,
1070
+ error: "No commits to merge. Only committed changes are included in sync.",
1071
+ };
1072
+ }
1073
+ let safetyTag;
1074
+ try {
1075
+ // 5. Create safety snapshot (before any changes)
1076
+ safetyTag = await this._createSafetySnapshot(executionId, execution.target_branch);
1077
+ let filesChanged = 0;
1078
+ let hasConflicts = false;
1079
+ // 6. Perform git merge --squash for committed changes (if any)
1080
+ if (hasCommits) {
1081
+ const mergeResult = this._performSquashMergeAllowConflicts(execution.branch_name, execution.target_branch);
1082
+ filesChanged = mergeResult.filesChanged;
1083
+ hasConflicts = mergeResult.hasConflicts;
1084
+ }
1085
+ // 7. Copy uncommitted files from worktree if requested (with safe merging)
1086
+ let uncommittedFilesCopied = 0;
1087
+ let filesWithConflicts = [];
1088
+ if (includeUncommitted && execution.worktree_path) {
1089
+ const copyResult = await this._copyUncommittedFiles(execution.worktree_path, { overrideLocalChanges });
1090
+ uncommittedFilesCopied = copyResult.filesCopied;
1091
+ filesWithConflicts = copyResult.filesWithConflicts;
1092
+ filesChanged += uncommittedFilesCopied;
1093
+ // If we have conflicts from uncommitted files merge, mark hasConflicts
1094
+ if (filesWithConflicts.length > 0) {
1095
+ hasConflicts = true;
1096
+ }
1097
+ }
1098
+ // 8. Auto-resolve JSONL conflicts if any (from git merge --squash)
1099
+ const jsonlFilesResolved = await this._resolveJSONLConflicts();
1100
+ if (jsonlFilesResolved > 0) {
1101
+ // Re-check for remaining conflicts after JSONL resolution
1102
+ try {
1103
+ const conflictCheck = execSync("git diff --name-only --diff-filter=U", {
1104
+ cwd: this.repoPath,
1105
+ encoding: "utf8",
1106
+ stdio: "pipe",
1107
+ });
1108
+ const remainingConflictFiles = conflictCheck
1109
+ .trim()
1110
+ .split("\n")
1111
+ .filter((f) => f.length > 0);
1112
+ hasConflicts = remainingConflictFiles.length > 0;
1113
+ // Add any remaining conflict files not already tracked
1114
+ for (const file of remainingConflictFiles) {
1115
+ if (!filesWithConflicts.includes(file)) {
1116
+ filesWithConflicts.push(file);
1117
+ }
1118
+ }
1119
+ }
1120
+ catch {
1121
+ // If command fails, assume no additional conflicts
1122
+ }
1123
+ }
1124
+ // 9. Check if there are unresolved (non-JSONL) conflicts
1125
+ if (hasConflicts) {
1126
+ return {
1127
+ success: false,
1128
+ filesChanged,
1129
+ hasConflicts: true,
1130
+ filesWithConflicts,
1131
+ uncommittedFilesIncluded: uncommittedFilesCopied,
1132
+ error: "Merge conflicts detected. Please resolve them manually.",
1133
+ cleanupOffered: false,
1134
+ };
1135
+ }
1136
+ // 10. Return success result WITHOUT creating a commit
1137
+ // Changes remain staged for user to commit manually
1138
+ return {
1139
+ success: true,
1140
+ filesChanged,
1141
+ uncommittedFilesIncluded: uncommittedFilesCopied,
1142
+ cleanupOffered: true,
1143
+ };
1144
+ }
1145
+ catch (error) {
1146
+ // Rollback to safety snapshot on failure
1147
+ if (safetyTag) {
1148
+ try {
1149
+ await this._rollbackToSnapshot(execution.target_branch, safetyTag);
1150
+ }
1151
+ catch (rollbackError) {
1152
+ console.error(`Failed to rollback to snapshot ${safetyTag}:`, rollbackError);
1153
+ }
1154
+ }
1155
+ throw new WorktreeSyncError(`Stage sync failed: ${error.message}`, WorktreeSyncErrorCode.MERGE_FAILED, error);
1156
+ }
1157
+ }
1158
+ /**
1159
+ * Check if worktree branch is an ancestor of target branch
1160
+ *
1161
+ * If the worktree branch is an ancestor, it means target already has all
1162
+ * the commits from the worktree (e.g., via a previous sync).
1163
+ *
1164
+ * @param worktreeBranch - Worktree branch name
1165
+ * @param targetBranch - Target branch name
1166
+ * @returns true if worktree branch is an ancestor of target branch
1167
+ */
1168
+ _isAncestor(worktreeBranch, targetBranch) {
1169
+ try {
1170
+ execSync(`git merge-base --is-ancestor ${this._escapeShellArg(worktreeBranch)} ${this._escapeShellArg(targetBranch)}`, {
1171
+ cwd: this.repoPath,
1172
+ stdio: "pipe",
1173
+ });
1174
+ // Exit code 0 means worktreeBranch IS an ancestor of targetBranch
1175
+ return true;
1176
+ }
1177
+ catch {
1178
+ // Exit code 1 means NOT an ancestor, which is what we want for merging
1179
+ return false;
1180
+ }
1181
+ }
1182
+ /**
1183
+ * Perform preserve sync operation
1184
+ *
1185
+ * Merges all commits from worktree branch to target branch, preserving commit history.
1186
+ * Only includes committed changes - uncommitted changes are excluded.
1187
+ * If merge conflicts occur, they are left for the user to resolve manually.
1188
+ *
1189
+ * @param executionId - Execution ID to sync
1190
+ * @returns Sync result with details
1191
+ * @throws WorktreeSyncError if sync fails
1192
+ */
1193
+ async preserveSync(executionId) {
1194
+ // 1. Load and validate execution
1195
+ const execution = await this._loadAndValidateExecution(executionId);
1196
+ // 2. Validate preconditions
1197
+ await this._validateSyncPreconditions(execution);
1198
+ // 3. Preview sync to get info
1199
+ const preview = await this.previewSync(executionId);
1200
+ // 4. Check if there are any commits to merge
1201
+ if (preview.commits.length === 0) {
1202
+ return {
1203
+ success: false,
1204
+ filesChanged: 0,
1205
+ error: "No commits to merge. Only committed changes are included in sync.",
1206
+ };
1207
+ }
1208
+ // 5. Check if worktree branch is already merged into target
1209
+ // This happens if a previous sync (squash or preserve) already merged these commits
1210
+ if (this._isAncestor(execution.branch_name, execution.target_branch)) {
1211
+ return {
1212
+ success: false,
1213
+ filesChanged: 0,
1214
+ error: "Target branch is already up to date with worktree changes. Nothing to merge.",
1215
+ };
1216
+ }
1217
+ let safetyTag;
1218
+ try {
1219
+ // 6. Create safety snapshot (before any changes)
1220
+ safetyTag = await this._createSafetySnapshot(executionId, execution.target_branch);
1221
+ // 7. Checkout target branch
1222
+ execSync(`git checkout ${this._escapeShellArg(execution.target_branch)}`, {
1223
+ cwd: this.repoPath,
1224
+ stdio: "pipe",
1225
+ });
1226
+ // 8. Perform regular merge (preserves commit history)
1227
+ let hasConflicts = false;
1228
+ let filesChanged = 0;
1229
+ try {
1230
+ execSync(`git merge ${this._escapeShellArg(execution.branch_name)}`, {
1231
+ cwd: this.repoPath,
1232
+ stdio: "pipe",
1233
+ });
1234
+ }
1235
+ catch (error) {
1236
+ // Merge may have failed due to conflicts
1237
+ hasConflicts = true;
1238
+ }
1239
+ // 9. Count files changed
1240
+ try {
1241
+ const diffOutput = execSync(`git diff --name-only ${this._escapeShellArg(safetyTag)}..HEAD`, {
1242
+ cwd: this.repoPath,
1243
+ encoding: "utf8",
1244
+ stdio: "pipe",
1245
+ });
1246
+ filesChanged = diffOutput
1247
+ .split("\n")
1248
+ .filter((line) => line.trim().length > 0).length;
1249
+ }
1250
+ catch {
1251
+ // If merge is in progress, count staged/conflicted files
1252
+ const statusOutput = execSync("git diff --name-only --cached", {
1253
+ cwd: this.repoPath,
1254
+ encoding: "utf8",
1255
+ stdio: "pipe",
1256
+ });
1257
+ filesChanged = statusOutput
1258
+ .split("\n")
1259
+ .filter((line) => line.trim().length > 0).length;
1260
+ }
1261
+ // 10. Auto-resolve JSONL conflicts if any
1262
+ const jsonlFilesResolved = await this._resolveJSONLConflicts();
1263
+ if (jsonlFilesResolved > 0) {
1264
+ // Re-check for remaining conflicts
1265
+ try {
1266
+ const conflictCheck = execSync("git diff --name-only --diff-filter=U", {
1267
+ cwd: this.repoPath,
1268
+ encoding: "utf8",
1269
+ stdio: "pipe",
1270
+ });
1271
+ hasConflicts = conflictCheck.trim().length > 0;
1272
+ }
1273
+ catch {
1274
+ hasConflicts = false;
1275
+ }
1276
+ }
1277
+ // 11. Check if there are unresolved conflicts
1278
+ if (hasConflicts) {
1279
+ // Get list of files with conflicts
1280
+ let filesWithConflicts = [];
1281
+ try {
1282
+ const conflictCheck = execSync("git diff --name-only --diff-filter=U", {
1283
+ cwd: this.repoPath,
1284
+ encoding: "utf8",
1285
+ stdio: "pipe",
1286
+ });
1287
+ filesWithConflicts = conflictCheck
1288
+ .trim()
1289
+ .split("\n")
1290
+ .filter((f) => f.length > 0);
1291
+ }
1292
+ catch {
1293
+ // If command fails, leave empty
1294
+ }
1295
+ return {
1296
+ success: false,
1297
+ filesChanged,
1298
+ hasConflicts: true,
1299
+ filesWithConflicts,
1300
+ error: "Merge conflicts detected. Please resolve them manually and commit.",
1301
+ cleanupOffered: false,
1302
+ };
1303
+ }
1304
+ // 12. Get the final commit SHA
1305
+ const finalCommit = execSync("git rev-parse HEAD", {
1306
+ cwd: this.repoPath,
1307
+ encoding: "utf8",
1308
+ stdio: "pipe",
1309
+ }).trim();
1310
+ // 13. Return success result
1311
+ return {
1312
+ success: true,
1313
+ finalCommit,
1314
+ filesChanged,
1315
+ cleanupOffered: true,
1316
+ };
1317
+ }
1318
+ catch (error) {
1319
+ // Rollback to safety snapshot on failure
1320
+ if (safetyTag) {
1321
+ try {
1322
+ await this._rollbackToSnapshot(execution.target_branch, safetyTag);
1323
+ }
1324
+ catch (rollbackError) {
1325
+ console.error(`Failed to rollback to snapshot ${safetyTag}:`, rollbackError);
1326
+ }
1327
+ }
1328
+ throw new WorktreeSyncError(`Preserve sync failed: ${error.message}`, WorktreeSyncErrorCode.MERGE_FAILED, error);
1329
+ }
1330
+ }
562
1331
  }
563
1332
  //# sourceMappingURL=worktree-sync-service.js.map