@sudocode-ai/local-server 0.1.8 → 0.1.9

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 (41) hide show
  1. package/dist/execution/worktree/git-sync-cli.d.ts +11 -0
  2. package/dist/execution/worktree/git-sync-cli.d.ts.map +1 -1
  3. package/dist/execution/worktree/git-sync-cli.js +52 -1
  4. package/dist/execution/worktree/git-sync-cli.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -29
  7. package/dist/index.js.map +1 -1
  8. package/dist/public/assets/index-DV9Tbujb.css +1 -0
  9. package/dist/public/assets/index-DcDX9-Ad.js +740 -0
  10. package/dist/public/assets/index-DcDX9-Ad.js.map +1 -0
  11. package/dist/public/index.html +2 -2
  12. package/dist/routes/executions.d.ts.map +1 -1
  13. package/dist/routes/executions.js +327 -24
  14. package/dist/routes/executions.js.map +1 -1
  15. package/dist/routes/repo-info.d.ts.map +1 -1
  16. package/dist/routes/repo-info.js +77 -0
  17. package/dist/routes/repo-info.js.map +1 -1
  18. package/dist/routes/version.d.ts +3 -0
  19. package/dist/routes/version.d.ts.map +1 -0
  20. package/dist/routes/version.js +25 -0
  21. package/dist/routes/version.js.map +1 -0
  22. package/dist/services/execution-changes-service.d.ts +18 -0
  23. package/dist/services/execution-changes-service.d.ts.map +1 -1
  24. package/dist/services/execution-changes-service.js +155 -1
  25. package/dist/services/execution-changes-service.js.map +1 -1
  26. package/dist/services/execution-service.d.ts +32 -1
  27. package/dist/services/execution-service.d.ts.map +1 -1
  28. package/dist/services/execution-service.js +81 -0
  29. package/dist/services/execution-service.js.map +1 -1
  30. package/dist/services/version-service.d.ts +14 -0
  31. package/dist/services/version-service.d.ts.map +1 -0
  32. package/dist/services/version-service.js +57 -0
  33. package/dist/services/version-service.js.map +1 -0
  34. package/dist/services/worktree-sync-service.d.ts +111 -13
  35. package/dist/services/worktree-sync-service.d.ts.map +1 -1
  36. package/dist/services/worktree-sync-service.js +610 -82
  37. package/dist/services/worktree-sync-service.js.map +1 -1
  38. package/package.json +3 -3
  39. package/dist/public/assets/index-Bb_W5bUr.css +0 -1
  40. package/dist/public/assets/index-CFKL113G.js +0 -710
  41. package/dist/public/assets/index-CFKL113G.js.map +0 -1
@@ -9,9 +9,9 @@
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";
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
15
  import { writeJSONL } from "@sudocode-ai/cli/dist/jsonl.js";
16
16
  /**
17
17
  * Worktree sync error codes
@@ -52,12 +52,10 @@ export class WorktreeSyncService {
52
52
  db;
53
53
  repoPath;
54
54
  gitSync;
55
- conflictDetector;
56
55
  constructor(db, repoPath) {
57
56
  this.db = db;
58
57
  this.repoPath = repoPath;
59
58
  this.gitSync = new GitSyncCli(repoPath);
60
- this.conflictDetector = new ConflictDetector(repoPath);
61
59
  }
62
60
  /**
63
61
  * Preview sync without making changes
@@ -68,12 +66,10 @@ export class WorktreeSyncService {
68
66
  async previewSync(executionId) {
69
67
  // 1. Load execution and validate
70
68
  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
69
+ // 2. Validate critical preconditions (ones that prevent us from getting any info)
70
+ // These are "hard" failures - we can't get diff/commits if these fail
71
+ const criticalPreconditionError = await this._validateCriticalPreconditions(execution);
72
+ if (criticalPreconditionError) {
77
73
  return {
78
74
  canSync: false,
79
75
  conflicts: {
@@ -87,37 +83,48 @@ export class WorktreeSyncService {
87
83
  commits: [],
88
84
  mergeBase: "",
89
85
  uncommittedJSONLChanges: [],
86
+ uncommittedChanges: { files: [], additions: 0, deletions: 0 },
90
87
  executionStatus: execution.status,
91
- warnings: [error.message],
88
+ warnings: [criticalPreconditionError],
92
89
  };
93
90
  }
94
- // 3. Find merge base
91
+ // 3. Create ConflictDetector instance for worktree context
92
+ const worktreeConflictDetector = new ConflictDetector(execution.worktree_path);
93
+ // 4. Find merge base (use main repo since it has both branches)
95
94
  const mergeBase = this.gitSync.getMergeBase(execution.branch_name, execution.target_branch);
96
95
  // 4. Get commit list
97
96
  const commits = this.gitSync.getCommitList(mergeBase, execution.branch_name);
98
- // 5. Get diff summary
97
+ // 6. Get diff summary (use main repo to see all changes)
99
98
  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
99
+ // 7. Detect conflicts (use worktree for conflict detection)
100
+ const conflicts = worktreeConflictDetector.detectConflicts(execution.branch_name, execution.target_branch);
101
+ // 7. Check for uncommitted changes in worktree (not included by default)
102
+ const uncommittedFiles = this._getUncommittedFiles(execution.worktree_path);
103
+ const uncommittedJSONL = uncommittedFiles.filter((file) => file.endsWith(".jsonl") &&
104
+ (file.includes(".sudocode/") || file.startsWith(".sudocode/")));
105
+ const uncommittedChanges = this._getUncommittedFileStats(execution.worktree_path);
106
+ // 8. Generate warnings and check "soft" preconditions
105
107
  const warnings = [];
108
+ let canSync = true;
109
+ // Check if local working tree is clean (soft precondition - we can still show preview)
110
+ if (!this.gitSync.isWorkingTreeClean()) {
111
+ warnings.push("Local working tree has uncommitted changes. Stash or commit them first.");
112
+ canSync = false;
113
+ }
106
114
  // Warn if execution is running/paused
107
- if (execution.status === "running" ||
108
- execution.status === "paused") {
115
+ if (execution.status === "running" || execution.status === "paused") {
109
116
  warnings.push("Execution is currently active. Synced state may not reflect final execution result.");
110
117
  }
111
118
  // Warn about code conflicts
112
119
  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.`);
120
+ warnings.push(`${conflicts.codeConflicts.length} code conflict(s) detected. Manual resolution may be required.`);
118
121
  }
119
- // 9. Determine if sync can proceed
120
- const canSync = conflicts.codeConflicts.length === 0;
122
+ // Note about uncommitted files (not included by default)
123
+ // if (uncommittedChanges && uncommittedChanges.files.length > 0) {
124
+ // warnings.push(
125
+ // `${uncommittedChanges.files.length} uncommitted file(s) in worktree will NOT be included (only committed changes are synced).`
126
+ // );
127
+ // }
121
128
  return {
122
129
  canSync,
123
130
  conflicts,
@@ -125,10 +132,48 @@ export class WorktreeSyncService {
125
132
  commits,
126
133
  mergeBase,
127
134
  uncommittedJSONLChanges: uncommittedJSONL,
135
+ uncommittedChanges,
128
136
  executionStatus: execution.status,
129
137
  warnings,
130
138
  };
131
139
  }
140
+ /**
141
+ * Validate critical preconditions that prevent us from getting any sync info
142
+ *
143
+ * These are "hard" failures - if these fail, we can't get diff/commits info.
144
+ * Returns an error message if validation fails, null if validation passes.
145
+ *
146
+ * @param execution - Execution to validate
147
+ * @returns Error message if validation fails, null if validation passes
148
+ */
149
+ async _validateCriticalPreconditions(execution) {
150
+ // 1. Check worktree path exists
151
+ if (!execution.worktree_path) {
152
+ return "No worktree path for execution";
153
+ }
154
+ // 2. Check worktree still exists on filesystem
155
+ if (!fs.existsSync(execution.worktree_path)) {
156
+ return "Worktree no longer exists";
157
+ }
158
+ // 3. Get list of branches
159
+ const branches = this._getBranches();
160
+ // 4. Check worktree branch exists
161
+ if (!branches.includes(execution.branch_name)) {
162
+ return `Worktree branch '${execution.branch_name}' not found`;
163
+ }
164
+ // 5. Check target branch exists
165
+ if (!branches.includes(execution.target_branch)) {
166
+ return `Target branch '${execution.target_branch}' not found`;
167
+ }
168
+ // 6. Verify branches have common base
169
+ try {
170
+ this.gitSync.getMergeBase(execution.branch_name, execution.target_branch);
171
+ }
172
+ catch (error) {
173
+ return "Worktree and target branch have diverged without common history";
174
+ }
175
+ return null;
176
+ }
132
177
  /**
133
178
  * Load execution from database and validate it exists
134
179
  *
@@ -161,7 +206,8 @@ export class WorktreeSyncService {
161
206
  * @param execution - Execution to validate
162
207
  * @throws WorktreeSyncError if any precondition fails
163
208
  */
164
- async _validateSyncPreconditions(execution) {
209
+ async _validateSyncPreconditions(execution, options) {
210
+ const { skipDirtyWorkingTreeCheck = false } = options || {};
165
211
  // 1. Check worktree path exists
166
212
  if (!execution.worktree_path) {
167
213
  throw new WorktreeSyncError("No worktree path for execution", WorktreeSyncErrorCode.NO_WORKTREE);
@@ -180,8 +226,8 @@ export class WorktreeSyncService {
180
226
  if (!branches.includes(execution.target_branch)) {
181
227
  throw new WorktreeSyncError(`Target branch '${execution.target_branch}' not found`, WorktreeSyncErrorCode.TARGET_BRANCH_MISSING);
182
228
  }
183
- // 6. Check local working tree is clean
184
- if (!this.gitSync.isWorkingTreeClean()) {
229
+ // 6. Check local working tree is clean (skip for stage mode since it doesn't commit)
230
+ if (!skipDirtyWorkingTreeCheck && !this.gitSync.isWorkingTreeClean()) {
185
231
  throw new WorktreeSyncError("Local working tree has uncommitted changes. Stash or commit them first.", WorktreeSyncErrorCode.DIRTY_WORKING_TREE);
186
232
  }
187
233
  // 7. Verify branches have common base
@@ -211,20 +257,85 @@ export class WorktreeSyncService {
211
257
  return tagName;
212
258
  }
213
259
  /**
214
- * Get uncommitted JSONL files from worktree
215
- *
216
- * Used by previewSync() and will be used in i-3wmx (JSONL conflict resolution)
260
+ * Get all uncommitted files from worktree
217
261
  *
218
262
  * @param worktreePath - Path to worktree
219
- * @returns Array of uncommitted JSONL file paths
263
+ * @returns Array of all uncommitted file paths
220
264
  */
221
- _getUncommittedJSONLFiles(worktreePath) {
265
+ _getUncommittedFiles(worktreePath) {
222
266
  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/")));
267
+ return gitSyncWorktree.getUncommittedFiles();
268
+ }
269
+ /**
270
+ * Get uncommitted file stats from worktree
271
+ *
272
+ * Returns list of files and aggregate additions/deletions stats
273
+ * for uncommitted changes in the worktree.
274
+ *
275
+ * @param worktreePath - Path to worktree
276
+ * @returns Uncommitted file stats
277
+ */
278
+ _getUncommittedFileStats(worktreePath) {
279
+ try {
280
+ // Get modified files
281
+ const modifiedOutput = execSync("git diff --numstat", {
282
+ cwd: worktreePath,
283
+ encoding: "utf8",
284
+ stdio: "pipe",
285
+ });
286
+ // Get untracked files
287
+ const untrackedFiles = execSync("git ls-files --others --exclude-standard", {
288
+ cwd: worktreePath,
289
+ encoding: "utf8",
290
+ stdio: "pipe",
291
+ })
292
+ .split("\n")
293
+ .filter((line) => line.trim().length > 0);
294
+ // Parse modified file stats
295
+ let additions = 0;
296
+ let deletions = 0;
297
+ const modifiedFiles = [];
298
+ for (const line of modifiedOutput.split("\n")) {
299
+ if (!line.trim())
300
+ continue;
301
+ const parts = line.split("\t");
302
+ if (parts.length >= 3) {
303
+ const add = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
304
+ const del = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
305
+ additions += add;
306
+ deletions += del;
307
+ modifiedFiles.push(parts[2]);
308
+ }
309
+ }
310
+ // Count lines in untracked files as additions
311
+ for (const filePath of untrackedFiles) {
312
+ try {
313
+ const fullPath = path.join(worktreePath, filePath);
314
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
315
+ const content = fs.readFileSync(fullPath, "utf-8");
316
+ additions += content.split("\n").length;
317
+ }
318
+ }
319
+ catch (e) {
320
+ // Skip files we can't read
321
+ }
322
+ }
323
+ // Combine all files
324
+ const allFiles = [...new Set([...modifiedFiles, ...untrackedFiles])];
325
+ return {
326
+ files: allFiles,
327
+ additions,
328
+ deletions,
329
+ };
330
+ }
331
+ catch (error) {
332
+ // Return empty stats on error
333
+ return {
334
+ files: [],
335
+ additions: 0,
336
+ deletions: 0,
337
+ };
338
+ }
228
339
  }
229
340
  /**
230
341
  * Check if local working tree is clean
@@ -340,7 +451,9 @@ export class WorktreeSyncService {
340
451
  stdio: "pipe",
341
452
  });
342
453
  // Parse JSONL content
343
- const lines = content.split("\n").filter((line) => line.trim().length > 0);
454
+ const lines = content
455
+ .split("\n")
456
+ .filter((line) => line.trim().length > 0);
344
457
  return lines.map((line) => JSON.parse(line));
345
458
  }
346
459
  catch (error) {
@@ -385,39 +498,192 @@ export class WorktreeSyncService {
385
498
  }
386
499
  }
387
500
  /**
388
- * Perform git merge --squash operation
501
+ * Perform git merge --squash operation, allowing conflicts
502
+ *
503
+ * This method doesn't throw on conflicts.
504
+ * Instead, it returns information about whether conflicts occurred.
389
505
  *
390
506
  * @param sourceBranch - Branch to merge from (worktree branch)
391
507
  * @param targetBranch - Branch to merge into
392
- * @returns Object with filesChanged count
393
- * @throws WorktreeSyncError if merge fails
508
+ * @returns Object with filesChanged count and hasConflicts flag
394
509
  */
395
- _performSquashMerge(sourceBranch, targetBranch) {
510
+ _performSquashMergeAllowConflicts(sourceBranch, targetBranch) {
511
+ // Checkout target branch
512
+ execSync(`git checkout ${this._escapeShellArg(targetBranch)}`, {
513
+ cwd: this.repoPath,
514
+ stdio: "pipe",
515
+ });
516
+ // Perform squash merge - may fail with conflicts
517
+ let hasConflicts = false;
396
518
  try {
397
- // Checkout target branch
398
- execSync(`git checkout ${this._escapeShellArg(targetBranch)}`, {
519
+ execSync(`git merge --squash ${this._escapeShellArg(sourceBranch)}`, {
399
520
  cwd: this.repoPath,
400
521
  stdio: "pipe",
401
522
  });
402
- // Perform squash merge
403
- execSync(`git merge --squash ${this._escapeShellArg(sourceBranch)}`, {
523
+ }
524
+ catch (error) {
525
+ // Check if this is a conflict situation (exit code 1) or a real error
526
+ // git merge --squash returns 1 on conflicts but stages what it can
527
+ hasConflicts = true;
528
+ }
529
+ // Count staged files (including conflicted ones)
530
+ const statusOutput = execSync("git diff --cached --name-only", {
531
+ cwd: this.repoPath,
532
+ encoding: "utf8",
533
+ stdio: "pipe",
534
+ });
535
+ const filesChanged = statusOutput
536
+ .split("\n")
537
+ .filter((line) => line.trim().length > 0).length;
538
+ // Check for actual conflicts in the working tree
539
+ try {
540
+ const conflictCheck = execSync("git diff --name-only --diff-filter=U", {
404
541
  cwd: this.repoPath,
542
+ encoding: "utf8",
405
543
  stdio: "pipe",
406
544
  });
407
- // Count staged files
408
- const statusOutput = execSync("git diff --cached --name-only", {
545
+ hasConflicts = conflictCheck.trim().length > 0;
546
+ }
547
+ catch (e) {
548
+ // If this fails, assume no conflicts
549
+ }
550
+ return { filesChanged, hasConflicts };
551
+ }
552
+ /**
553
+ * Copy uncommitted files from worktree to local repo
554
+ *
555
+ * Copies files that are modified or untracked in the worktree
556
+ * to the local repository working directory.
557
+ *
558
+ * @param worktreePath - Path to the worktree
559
+ * @returns Number of files copied
560
+ */
561
+ async _copyUncommittedFiles(worktreePath) {
562
+ // Get list of uncommitted/untracked files in worktree
563
+ const modifiedOutput = execSync("git diff --name-only", {
564
+ cwd: worktreePath,
565
+ encoding: "utf8",
566
+ stdio: "pipe",
567
+ });
568
+ const untrackedOutput = execSync("git ls-files --others --exclude-standard", {
569
+ cwd: worktreePath,
570
+ encoding: "utf8",
571
+ stdio: "pipe",
572
+ });
573
+ const modifiedFiles = modifiedOutput
574
+ .split("\n")
575
+ .filter((line) => line.trim().length > 0);
576
+ const untrackedFiles = untrackedOutput
577
+ .split("\n")
578
+ .filter((line) => line.trim().length > 0);
579
+ const allFiles = [...new Set([...modifiedFiles, ...untrackedFiles])];
580
+ if (allFiles.length === 0) {
581
+ return 0;
582
+ }
583
+ // Copy each file from worktree to local repo
584
+ let filesCopied = 0;
585
+ for (const filePath of allFiles) {
586
+ const srcPath = path.join(worktreePath, filePath);
587
+ const destPath = path.join(this.repoPath, filePath);
588
+ // Check if source file exists
589
+ if (!fs.existsSync(srcPath)) {
590
+ continue;
591
+ }
592
+ // Create destination directory if needed
593
+ const destDir = path.dirname(destPath);
594
+ if (!fs.existsSync(destDir)) {
595
+ fs.mkdirSync(destDir, { recursive: true });
596
+ }
597
+ // Copy file
598
+ fs.copyFileSync(srcPath, destPath);
599
+ // Stage the file
600
+ execSync(`git add ${this._escapeShellArg(filePath)}`, {
409
601
  cwd: this.repoPath,
410
- encoding: "utf8",
411
602
  stdio: "pipe",
412
603
  });
413
- const filesChanged = statusOutput
414
- .split("\n")
415
- .filter((line) => line.trim().length > 0).length;
416
- return { filesChanged };
604
+ filesCopied++;
417
605
  }
418
- catch (error) {
419
- throw new WorktreeSyncError(`Failed to perform squash merge: ${error.message}`, WorktreeSyncErrorCode.MERGE_FAILED, error);
606
+ return filesCopied;
607
+ }
608
+ /**
609
+ * Resolve JSONL merge conflicts in the local repository
610
+ *
611
+ * Checks for git conflict markers in issues.jsonl and specs.jsonl,
612
+ * and resolves them using the merge-resolver logic.
613
+ *
614
+ * @returns Number of files resolved
615
+ */
616
+ async _resolveJSONLConflicts() {
617
+ const sudocodePath = path.join(this.repoPath, ".sudocode");
618
+ const issuesPath = path.join(sudocodePath, "issues.jsonl");
619
+ const specsPath = path.join(sudocodePath, "specs.jsonl");
620
+ let filesResolved = 0;
621
+ // Check and resolve issues.jsonl
622
+ if (fs.existsSync(issuesPath) && hasGitConflictMarkers(issuesPath)) {
623
+ await this._resolveJSONLFile(issuesPath);
624
+ filesResolved++;
625
+ }
626
+ // Check and resolve specs.jsonl
627
+ if (fs.existsSync(specsPath) && hasGitConflictMarkers(specsPath)) {
628
+ await this._resolveJSONLFile(specsPath);
629
+ filesResolved++;
630
+ }
631
+ return filesResolved;
632
+ }
633
+ /**
634
+ * Resolve conflicts in a single JSONL file
635
+ *
636
+ * @param filePath - Path to the JSONL file with conflicts
637
+ */
638
+ async _resolveJSONLFile(filePath) {
639
+ // Read file with conflict markers
640
+ const content = fs.readFileSync(filePath, "utf8");
641
+ // Parse conflicts
642
+ const sections = parseMergeConflictFile(content);
643
+ // Extract all entities (from both clean and conflict sections)
644
+ const allEntities = [];
645
+ for (const section of sections) {
646
+ if (section.type === "clean") {
647
+ for (const line of section.lines) {
648
+ if (line.trim()) {
649
+ try {
650
+ allEntities.push(JSON.parse(line));
651
+ }
652
+ catch {
653
+ // Skip malformed lines
654
+ }
655
+ }
656
+ }
657
+ }
658
+ else {
659
+ // Conflict section - include both ours and theirs
660
+ for (const line of [
661
+ ...(section.ours || []),
662
+ ...(section.theirs || []),
663
+ ]) {
664
+ if (line.trim()) {
665
+ try {
666
+ allEntities.push(JSON.parse(line));
667
+ }
668
+ catch {
669
+ // Skip malformed lines
670
+ }
671
+ }
672
+ }
673
+ }
420
674
  }
675
+ // Resolve conflicts
676
+ const { entities: resolved } = resolveEntities(allEntities, {
677
+ verbose: false,
678
+ });
679
+ // Write back resolved entities
680
+ await writeJSONL(filePath, resolved);
681
+ // Stage the resolved file
682
+ const relativePath = path.relative(this.repoPath, filePath);
683
+ execSync(`git add ${this._escapeShellArg(relativePath)}`, {
684
+ cwd: this.repoPath,
685
+ stdio: "pipe",
686
+ });
421
687
  }
422
688
  /**
423
689
  * Generate commit message for squash sync
@@ -489,8 +755,9 @@ Synced changes from worktree execution.`;
489
755
  /**
490
756
  * Perform squash sync operation
491
757
  *
492
- * Squashes all worktree commits into a single commit on the target branch.
493
- * Auto-resolves JSONL conflicts but blocks on code conflicts.
758
+ * Squashes all committed worktree changes into a single commit on the target branch.
759
+ * Only includes committed changes - uncommitted changes are excluded.
760
+ * If merge conflicts occur, they are left for the user to resolve manually.
494
761
  *
495
762
  * @param executionId - Execution ID to sync
496
763
  * @param customCommitMessage - Optional custom commit message
@@ -502,34 +769,40 @@ Synced changes from worktree execution.`;
502
769
  const execution = await this._loadAndValidateExecution(executionId);
503
770
  // 2. Validate preconditions
504
771
  await this._validateSyncPreconditions(execution);
505
- // 3. Preview sync to check for conflicts
772
+ // 3. Preview sync to get info (we'll proceed even with conflicts)
506
773
  const preview = await this.previewSync(executionId);
507
- // 4. Block if code conflicts exist
508
- if (preview.conflicts.codeConflicts.length > 0) {
774
+ // 4. Check if there are any commits to merge
775
+ if (preview.commits.length === 0) {
509
776
  return {
510
777
  success: false,
511
778
  filesChanged: 0,
512
- conflictsResolved: 0,
513
- uncommittedJSONLIncluded: false,
514
- error: `Cannot sync: ${preview.conflicts.codeConflicts.length} code conflict(s) detected. Please resolve manually.`,
779
+ error: "No commits to merge. Only committed changes are included in sync.",
780
+ };
781
+ }
782
+ // 5. Check if worktree branch is already merged into target
783
+ if (this._isAncestor(execution.branch_name, execution.target_branch)) {
784
+ return {
785
+ success: false,
786
+ filesChanged: 0,
787
+ error: "Target branch is already up to date with worktree changes. Nothing to merge.",
515
788
  };
516
789
  }
517
790
  let safetyTag;
518
791
  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
792
+ // 6. Create safety snapshot (before any changes)
525
793
  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;
794
+ // 7. Perform git merge --squash (may have conflicts)
795
+ const mergeResult = this._performSquashMergeAllowConflicts(execution.branch_name, execution.target_branch);
796
+ // 8. Check if there are unresolved conflicts
797
+ if (mergeResult.hasConflicts) {
798
+ // Return with conflicts info - user must resolve manually
799
+ return {
800
+ success: false,
801
+ filesChanged: mergeResult.filesChanged,
802
+ hasConflicts: true,
803
+ error: "Merge conflicts detected. Please resolve them manually and commit.",
804
+ cleanupOffered: false,
805
+ };
533
806
  }
534
807
  // 9. Generate commit message
535
808
  const commitMessage = customCommitMessage ||
@@ -541,8 +814,6 @@ Synced changes from worktree execution.`;
541
814
  success: true,
542
815
  finalCommit,
543
816
  filesChanged: mergeResult.filesChanged,
544
- conflictsResolved,
545
- uncommittedJSONLIncluded: uncommittedJSONL.length > 0,
546
817
  cleanupOffered: true,
547
818
  };
548
819
  }
@@ -559,5 +830,262 @@ Synced changes from worktree execution.`;
559
830
  throw new WorktreeSyncError(`Squash sync failed: ${error.message}`, WorktreeSyncErrorCode.MERGE_FAILED, error);
560
831
  }
561
832
  }
833
+ /**
834
+ * Perform stage sync operation
835
+ *
836
+ * Applies committed worktree changes to the working directory without committing.
837
+ * Changes are left staged, ready for the user to commit manually.
838
+ * Only includes committed changes by default - uncommitted changes are excluded
839
+ * unless includeUncommitted is true.
840
+ *
841
+ * @param executionId - Execution ID to sync
842
+ * @param options - Optional settings
843
+ * @param options.includeUncommitted - If true, also copy uncommitted files from worktree
844
+ * @returns Sync result with details
845
+ * @throws WorktreeSyncError if sync fails
846
+ */
847
+ async stageSync(executionId, options) {
848
+ const { includeUncommitted = false } = options || {};
849
+ // 1. Load and validate execution
850
+ const execution = await this._loadAndValidateExecution(executionId);
851
+ // 2. Validate preconditions (skip dirty working tree check - stage mode doesn't commit)
852
+ await this._validateSyncPreconditions(execution, {
853
+ skipDirtyWorkingTreeCheck: true,
854
+ });
855
+ // 3. Preview sync to get info
856
+ const preview = await this.previewSync(executionId);
857
+ // 4. Check if there's anything to sync
858
+ const hasCommits = preview.commits.length > 0;
859
+ if (!hasCommits && !includeUncommitted) {
860
+ return {
861
+ success: false,
862
+ filesChanged: 0,
863
+ error: "No commits to merge. Only committed changes are included in sync.",
864
+ };
865
+ }
866
+ let safetyTag;
867
+ try {
868
+ // 5. Create safety snapshot (before any changes)
869
+ safetyTag = await this._createSafetySnapshot(executionId, execution.target_branch);
870
+ let filesChanged = 0;
871
+ let hasConflicts = false;
872
+ // 6. Perform git merge --squash for committed changes (if any)
873
+ if (hasCommits) {
874
+ const mergeResult = this._performSquashMergeAllowConflicts(execution.branch_name, execution.target_branch);
875
+ filesChanged = mergeResult.filesChanged;
876
+ hasConflicts = mergeResult.hasConflicts;
877
+ }
878
+ // 7. Copy uncommitted files from worktree if requested
879
+ let uncommittedFilesCopied = 0;
880
+ if (includeUncommitted && execution.worktree_path) {
881
+ uncommittedFilesCopied = await this._copyUncommittedFiles(execution.worktree_path);
882
+ filesChanged += uncommittedFilesCopied;
883
+ }
884
+ // 8. Auto-resolve JSONL conflicts if any
885
+ const jsonlFilesResolved = await this._resolveJSONLConflicts();
886
+ if (jsonlFilesResolved > 0) {
887
+ // Re-check for remaining conflicts after JSONL resolution
888
+ try {
889
+ const conflictCheck = execSync("git diff --name-only --diff-filter=U", {
890
+ cwd: this.repoPath,
891
+ encoding: "utf8",
892
+ stdio: "pipe",
893
+ });
894
+ hasConflicts = conflictCheck.trim().length > 0;
895
+ }
896
+ catch {
897
+ // If command fails, assume no conflicts
898
+ hasConflicts = false;
899
+ }
900
+ }
901
+ // 9. Check if there are unresolved (non-JSONL) conflicts
902
+ if (hasConflicts) {
903
+ return {
904
+ success: false,
905
+ filesChanged,
906
+ hasConflicts: true,
907
+ uncommittedFilesIncluded: uncommittedFilesCopied,
908
+ error: "Merge conflicts detected. Please resolve them manually.",
909
+ cleanupOffered: false,
910
+ };
911
+ }
912
+ // 10. Return success result WITHOUT creating a commit
913
+ // Changes remain staged for user to commit manually
914
+ return {
915
+ success: true,
916
+ filesChanged,
917
+ uncommittedFilesIncluded: uncommittedFilesCopied,
918
+ cleanupOffered: true,
919
+ };
920
+ }
921
+ catch (error) {
922
+ // Rollback to safety snapshot on failure
923
+ if (safetyTag) {
924
+ try {
925
+ await this._rollbackToSnapshot(execution.target_branch, safetyTag);
926
+ }
927
+ catch (rollbackError) {
928
+ console.error(`Failed to rollback to snapshot ${safetyTag}:`, rollbackError);
929
+ }
930
+ }
931
+ throw new WorktreeSyncError(`Stage sync failed: ${error.message}`, WorktreeSyncErrorCode.MERGE_FAILED, error);
932
+ }
933
+ }
934
+ /**
935
+ * Check if worktree branch is an ancestor of target branch
936
+ *
937
+ * If the worktree branch is an ancestor, it means target already has all
938
+ * the commits from the worktree (e.g., via a previous sync).
939
+ *
940
+ * @param worktreeBranch - Worktree branch name
941
+ * @param targetBranch - Target branch name
942
+ * @returns true if worktree branch is an ancestor of target branch
943
+ */
944
+ _isAncestor(worktreeBranch, targetBranch) {
945
+ try {
946
+ execSync(`git merge-base --is-ancestor ${this._escapeShellArg(worktreeBranch)} ${this._escapeShellArg(targetBranch)}`, {
947
+ cwd: this.repoPath,
948
+ stdio: "pipe",
949
+ });
950
+ // Exit code 0 means worktreeBranch IS an ancestor of targetBranch
951
+ return true;
952
+ }
953
+ catch {
954
+ // Exit code 1 means NOT an ancestor, which is what we want for merging
955
+ return false;
956
+ }
957
+ }
958
+ /**
959
+ * Perform preserve sync operation
960
+ *
961
+ * Merges all commits from worktree branch to target branch, preserving commit history.
962
+ * Only includes committed changes - uncommitted changes are excluded.
963
+ * If merge conflicts occur, they are left for the user to resolve manually.
964
+ *
965
+ * @param executionId - Execution ID to sync
966
+ * @returns Sync result with details
967
+ * @throws WorktreeSyncError if sync fails
968
+ */
969
+ async preserveSync(executionId) {
970
+ // 1. Load and validate execution
971
+ const execution = await this._loadAndValidateExecution(executionId);
972
+ // 2. Validate preconditions
973
+ await this._validateSyncPreconditions(execution);
974
+ // 3. Preview sync to get info
975
+ const preview = await this.previewSync(executionId);
976
+ // 4. Check if there are any commits to merge
977
+ if (preview.commits.length === 0) {
978
+ return {
979
+ success: false,
980
+ filesChanged: 0,
981
+ error: "No commits to merge. Only committed changes are included in sync.",
982
+ };
983
+ }
984
+ // 5. Check if worktree branch is already merged into target
985
+ // This happens if a previous sync (squash or preserve) already merged these commits
986
+ if (this._isAncestor(execution.branch_name, execution.target_branch)) {
987
+ return {
988
+ success: false,
989
+ filesChanged: 0,
990
+ error: "Target branch is already up to date with worktree changes. Nothing to merge.",
991
+ };
992
+ }
993
+ let safetyTag;
994
+ try {
995
+ // 6. Create safety snapshot (before any changes)
996
+ safetyTag = await this._createSafetySnapshot(executionId, execution.target_branch);
997
+ // 7. Checkout target branch
998
+ execSync(`git checkout ${this._escapeShellArg(execution.target_branch)}`, {
999
+ cwd: this.repoPath,
1000
+ stdio: "pipe",
1001
+ });
1002
+ // 8. Perform regular merge (preserves commit history)
1003
+ let hasConflicts = false;
1004
+ let filesChanged = 0;
1005
+ try {
1006
+ execSync(`git merge ${this._escapeShellArg(execution.branch_name)}`, {
1007
+ cwd: this.repoPath,
1008
+ stdio: "pipe",
1009
+ });
1010
+ }
1011
+ catch (error) {
1012
+ // Merge may have failed due to conflicts
1013
+ hasConflicts = true;
1014
+ }
1015
+ // 9. Count files changed
1016
+ try {
1017
+ const diffOutput = execSync(`git diff --name-only ${this._escapeShellArg(safetyTag)}..HEAD`, {
1018
+ cwd: this.repoPath,
1019
+ encoding: "utf8",
1020
+ stdio: "pipe",
1021
+ });
1022
+ filesChanged = diffOutput
1023
+ .split("\n")
1024
+ .filter((line) => line.trim().length > 0).length;
1025
+ }
1026
+ catch {
1027
+ // If merge is in progress, count staged/conflicted files
1028
+ const statusOutput = execSync("git diff --name-only --cached", {
1029
+ cwd: this.repoPath,
1030
+ encoding: "utf8",
1031
+ stdio: "pipe",
1032
+ });
1033
+ filesChanged = statusOutput
1034
+ .split("\n")
1035
+ .filter((line) => line.trim().length > 0).length;
1036
+ }
1037
+ // 10. Auto-resolve JSONL conflicts if any
1038
+ const jsonlFilesResolved = await this._resolveJSONLConflicts();
1039
+ if (jsonlFilesResolved > 0) {
1040
+ // Re-check for remaining conflicts
1041
+ try {
1042
+ const conflictCheck = execSync("git diff --name-only --diff-filter=U", {
1043
+ cwd: this.repoPath,
1044
+ encoding: "utf8",
1045
+ stdio: "pipe",
1046
+ });
1047
+ hasConflicts = conflictCheck.trim().length > 0;
1048
+ }
1049
+ catch {
1050
+ hasConflicts = false;
1051
+ }
1052
+ }
1053
+ // 11. Check if there are unresolved conflicts
1054
+ if (hasConflicts) {
1055
+ return {
1056
+ success: false,
1057
+ filesChanged,
1058
+ hasConflicts: true,
1059
+ error: "Merge conflicts detected. Please resolve them manually and commit.",
1060
+ cleanupOffered: false,
1061
+ };
1062
+ }
1063
+ // 12. Get the final commit SHA
1064
+ const finalCommit = execSync("git rev-parse HEAD", {
1065
+ cwd: this.repoPath,
1066
+ encoding: "utf8",
1067
+ stdio: "pipe",
1068
+ }).trim();
1069
+ // 13. Return success result
1070
+ return {
1071
+ success: true,
1072
+ finalCommit,
1073
+ filesChanged,
1074
+ cleanupOffered: true,
1075
+ };
1076
+ }
1077
+ catch (error) {
1078
+ // Rollback to safety snapshot on failure
1079
+ if (safetyTag) {
1080
+ try {
1081
+ await this._rollbackToSnapshot(execution.target_branch, safetyTag);
1082
+ }
1083
+ catch (rollbackError) {
1084
+ console.error(`Failed to rollback to snapshot ${safetyTag}:`, rollbackError);
1085
+ }
1086
+ }
1087
+ throw new WorktreeSyncError(`Preserve sync failed: ${error.message}`, WorktreeSyncErrorCode.MERGE_FAILED, error);
1088
+ }
1089
+ }
562
1090
  }
563
1091
  //# sourceMappingURL=worktree-sync-service.js.map