@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.
- package/dist/execution/worktree/git-sync-cli.d.ts +11 -0
- package/dist/execution/worktree/git-sync-cli.d.ts.map +1 -1
- package/dist/execution/worktree/git-sync-cli.js +52 -1
- package/dist/execution/worktree/git-sync-cli.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -29
- package/dist/index.js.map +1 -1
- package/dist/public/assets/index-DV9Tbujb.css +1 -0
- package/dist/public/assets/index-DcDX9-Ad.js +740 -0
- package/dist/public/assets/index-DcDX9-Ad.js.map +1 -0
- package/dist/public/index.html +2 -2
- package/dist/routes/executions.d.ts.map +1 -1
- package/dist/routes/executions.js +327 -24
- package/dist/routes/executions.js.map +1 -1
- package/dist/routes/repo-info.d.ts.map +1 -1
- package/dist/routes/repo-info.js +77 -0
- package/dist/routes/repo-info.js.map +1 -1
- package/dist/routes/version.d.ts +3 -0
- package/dist/routes/version.d.ts.map +1 -0
- package/dist/routes/version.js +25 -0
- package/dist/routes/version.js.map +1 -0
- package/dist/services/execution-changes-service.d.ts +18 -0
- package/dist/services/execution-changes-service.d.ts.map +1 -1
- package/dist/services/execution-changes-service.js +155 -1
- package/dist/services/execution-changes-service.js.map +1 -1
- package/dist/services/execution-service.d.ts +32 -1
- package/dist/services/execution-service.d.ts.map +1 -1
- package/dist/services/execution-service.js +81 -0
- package/dist/services/execution-service.js.map +1 -1
- package/dist/services/version-service.d.ts +14 -0
- package/dist/services/version-service.d.ts.map +1 -0
- package/dist/services/version-service.js +57 -0
- package/dist/services/version-service.js.map +1 -0
- package/dist/services/worktree-sync-service.d.ts +111 -13
- package/dist/services/worktree-sync-service.d.ts.map +1 -1
- package/dist/services/worktree-sync-service.js +610 -82
- package/dist/services/worktree-sync-service.js.map +1 -1
- package/package.json +3 -3
- package/dist/public/assets/index-Bb_W5bUr.css +0 -1
- package/dist/public/assets/index-CFKL113G.js +0 -710
- 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
|
-
|
|
73
|
-
|
|
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: [
|
|
88
|
+
warnings: [criticalPreconditionError],
|
|
92
89
|
};
|
|
93
90
|
}
|
|
94
|
-
// 3.
|
|
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
|
-
//
|
|
97
|
+
// 6. Get diff summary (use main repo to see all changes)
|
|
99
98
|
const diff = this.gitSync.getDiff(mergeBase, execution.branch_name);
|
|
100
|
-
//
|
|
101
|
-
const conflicts =
|
|
102
|
-
// 7. Check for uncommitted
|
|
103
|
-
const
|
|
104
|
-
|
|
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
|
-
//
|
|
120
|
-
|
|
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
|
|
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
|
|
263
|
+
* @returns Array of all uncommitted file paths
|
|
220
264
|
*/
|
|
221
|
-
|
|
265
|
+
_getUncommittedFiles(worktreePath) {
|
|
222
266
|
const gitSyncWorktree = new GitSyncCli(worktreePath);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
414
|
-
.split("\n")
|
|
415
|
-
.filter((line) => line.trim().length > 0).length;
|
|
416
|
-
return { filesChanged };
|
|
604
|
+
filesCopied++;
|
|
417
605
|
}
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
493
|
-
*
|
|
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
|
|
772
|
+
// 3. Preview sync to get info (we'll proceed even with conflicts)
|
|
506
773
|
const preview = await this.previewSync(executionId);
|
|
507
|
-
// 4.
|
|
508
|
-
if (preview.
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
//
|
|
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.
|
|
528
|
-
// 8.
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|