@openvcs/git-plugin 0.3.2-nightly.20260603.124 → 0.3.2-nightly.20260613.146
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/bin/git.js +153 -6
- package/bin/plugin-request-handler.js +19 -1
- package/package.json +3 -3
- package/src/git.ts +156 -6
- package/src/plugin-request-handler.ts +23 -1
package/bin/git.js
CHANGED
|
@@ -88,7 +88,7 @@ export class GitCommand {
|
|
|
88
88
|
const baseLine = isCurrent ? line.slice(0, -1) : line;
|
|
89
89
|
const name = baseLine.trim();
|
|
90
90
|
if (name) {
|
|
91
|
-
branches.push({ name, current: name === current
|
|
91
|
+
branches.push({ name, current: name === current });
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
return { current, branches };
|
|
@@ -302,7 +302,7 @@ export class GitCommand {
|
|
|
302
302
|
return false;
|
|
303
303
|
}
|
|
304
304
|
return lines.some((line) => {
|
|
305
|
-
const candidate = String(line
|
|
305
|
+
const candidate = String(line);
|
|
306
306
|
return /^binary files /i.test(candidate)
|
|
307
307
|
|| /^git binary patch/i.test(candidate)
|
|
308
308
|
|| /^literal /i.test(candidate);
|
|
@@ -359,7 +359,7 @@ export class GitCommand {
|
|
|
359
359
|
const line = rawLine.trim();
|
|
360
360
|
if (!line)
|
|
361
361
|
continue;
|
|
362
|
-
const marker = line[0]
|
|
362
|
+
const marker = line[0];
|
|
363
363
|
const rest = line.slice(1).trim();
|
|
364
364
|
const [commit = '', path = ''] = rest.split(/\s+/);
|
|
365
365
|
if (!path)
|
|
@@ -409,7 +409,7 @@ export class GitCommand {
|
|
|
409
409
|
this.runChecked(['submodule', 'deinit', '-f', '--', path], 'git-submodule-remove-failed');
|
|
410
410
|
this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
|
|
411
411
|
const modulesPath = join(this.cwd, '.git', 'modules', path);
|
|
412
|
-
/* c8 ignore next
|
|
412
|
+
/* c8 ignore next 5 */
|
|
413
413
|
try {
|
|
414
414
|
rmSync(modulesPath, { recursive: true, force: true });
|
|
415
415
|
}
|
|
@@ -517,6 +517,140 @@ export class GitCommand {
|
|
|
517
517
|
stdin: patch,
|
|
518
518
|
});
|
|
519
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Stages structured hunk/line selections (VCS-agnostic).
|
|
522
|
+
*
|
|
523
|
+
* For each file, fetches the worktree diff, extracts the selected hunks/lines,
|
|
524
|
+
* builds a combined sub-patch, and applies it to the index atomically.
|
|
525
|
+
*/
|
|
526
|
+
stageSelections(selections) {
|
|
527
|
+
const filePatches = [];
|
|
528
|
+
for (const sel of selections) {
|
|
529
|
+
// Fetch only the worktree diff (not --cached) — cached changes are
|
|
530
|
+
// already staged and must not be re-applied.
|
|
531
|
+
const raw = this.runChecked(['diff', '--no-ext-diff', '--no-color', '--', sel.path], 'git-diff-failed').stdout;
|
|
532
|
+
if (!raw.trim())
|
|
533
|
+
continue;
|
|
534
|
+
const lines = this.splitDiffLines(raw);
|
|
535
|
+
/* c8 ignore next 3 */
|
|
536
|
+
if (lines.length === 0)
|
|
537
|
+
continue;
|
|
538
|
+
const normPath = sel.path.replace(/\\/g, '/');
|
|
539
|
+
// Find the first @@ line to separate prelude from hunks
|
|
540
|
+
const firstHunk = lines.findIndex(l => l.startsWith('@@'));
|
|
541
|
+
if (firstHunk < 0)
|
|
542
|
+
continue;
|
|
543
|
+
const prelude = lines.slice(0, firstHunk);
|
|
544
|
+
const rest = lines.slice(firstHunk);
|
|
545
|
+
// Build headerExtras: metadata lines that aren't diff --git, ---, or +++
|
|
546
|
+
const headerExtras = prelude.filter(l => !!l &&
|
|
547
|
+
!l.startsWith('diff --git') &&
|
|
548
|
+
!l.startsWith('--- ') &&
|
|
549
|
+
!l.startsWith('+++ '));
|
|
550
|
+
// Locate all hunk @@ positions within rest
|
|
551
|
+
const starts = [];
|
|
552
|
+
for (let i = 0; i < rest.length; i++) {
|
|
553
|
+
if (rest[i].startsWith('@@'))
|
|
554
|
+
starts.push(i);
|
|
555
|
+
}
|
|
556
|
+
/* c8 ignore next 3 */
|
|
557
|
+
if (starts.length === 0)
|
|
558
|
+
continue;
|
|
559
|
+
starts.push(rest.length);
|
|
560
|
+
const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
|
|
561
|
+
const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
|
|
562
|
+
const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
|
|
563
|
+
let out = `diff --git a/${normPath} b/${normPath}\n`;
|
|
564
|
+
if (headerExtras.length)
|
|
565
|
+
out += headerExtras.join('\n') + '\n';
|
|
566
|
+
if (isAdd)
|
|
567
|
+
out += `--- /dev/null\n+++ b/${normPath}\n`;
|
|
568
|
+
else if (isDel)
|
|
569
|
+
out += `--- a/${normPath}\n+++ /dev/null\n`;
|
|
570
|
+
else
|
|
571
|
+
out += `--- a/${normPath}\n+++ b/${normPath}\n`;
|
|
572
|
+
for (let h = 0; h < starts.length - 1; h++) {
|
|
573
|
+
const s = starts[h];
|
|
574
|
+
const e = starts[h + 1];
|
|
575
|
+
const block = rest.slice(s, e);
|
|
576
|
+
/* c8 ignore next 7 */
|
|
577
|
+
const header = block[0] || '';
|
|
578
|
+
const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
|
|
579
|
+
if (!m)
|
|
580
|
+
continue;
|
|
581
|
+
const aStart = parseInt(m[1] || '0', 10) || 0;
|
|
582
|
+
const cStart = parseInt(m[3] || '0', 10) || 0;
|
|
583
|
+
const content = block.slice(1);
|
|
584
|
+
if (wantWhole.has(h)) {
|
|
585
|
+
out += header + '\n' + content.join('\n') + '\n';
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
|
|
589
|
+
? sel.partial_hunks[h]
|
|
590
|
+
: (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
|
|
591
|
+
const picksAdj = Array.isArray(picksRaw)
|
|
592
|
+
? picksRaw.map((i) => i - 1).filter((i) => i >= 0 && i < content.length)
|
|
593
|
+
: [];
|
|
594
|
+
const pickSet = new Set(picksAdj);
|
|
595
|
+
if (pickSet.size === 0)
|
|
596
|
+
continue;
|
|
597
|
+
// prefix counts for old/new positions
|
|
598
|
+
const prefOld = new Array(content.length + 1).fill(0);
|
|
599
|
+
const prefNew = new Array(content.length + 1).fill(0);
|
|
600
|
+
for (let i = 0; i < content.length; i++) {
|
|
601
|
+
const ch = (content[i] || '')[0] || ' ';
|
|
602
|
+
const isMeta = ch === '\\';
|
|
603
|
+
prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
|
|
604
|
+
prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
|
|
605
|
+
}
|
|
606
|
+
// Group consecutive selected lines into mini-hunks
|
|
607
|
+
const sorted = Array.from(pickSet).sort((x, y) => x - y);
|
|
608
|
+
let group = [];
|
|
609
|
+
const flush = () => {
|
|
610
|
+
/* c8 ignore next 3 */
|
|
611
|
+
if (group.length === 0)
|
|
612
|
+
return;
|
|
613
|
+
const i0 = group[0];
|
|
614
|
+
const old_start = aStart + prefOld[i0];
|
|
615
|
+
const new_start = cStart + prefNew[i0];
|
|
616
|
+
const slice = group.map(i => content[i]);
|
|
617
|
+
const contentLines = slice.filter(l => (l || '')[0] !== '\\');
|
|
618
|
+
const metaLines = slice.filter(l => (l || '')[0] === '\\');
|
|
619
|
+
const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
|
|
620
|
+
const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
|
|
621
|
+
if (old_count === 0 && new_count === 0) {
|
|
622
|
+
group = [];
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
|
|
626
|
+
out += contentLines.join('\n') + '\n';
|
|
627
|
+
if (metaLines.length)
|
|
628
|
+
out += metaLines.join('\n') + '\n';
|
|
629
|
+
group = [];
|
|
630
|
+
};
|
|
631
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
632
|
+
if (group.length === 0) {
|
|
633
|
+
group.push(sorted[i]);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (sorted[i] === group[group.length - 1] + 1)
|
|
637
|
+
group.push(sorted[i]);
|
|
638
|
+
else {
|
|
639
|
+
flush();
|
|
640
|
+
group.push(sorted[i]);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
flush();
|
|
644
|
+
}
|
|
645
|
+
filePatches.push(out.trimEnd());
|
|
646
|
+
}
|
|
647
|
+
if (filePatches.length === 0)
|
|
648
|
+
return;
|
|
649
|
+
const combinedPatch = filePatches.join('\n') + '\n';
|
|
650
|
+
this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
|
|
651
|
+
stdin: combinedPatch,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
520
654
|
applyReversePatch(patch) {
|
|
521
655
|
this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
|
|
522
656
|
stdin: patch,
|
|
@@ -545,8 +679,21 @@ export class GitCommand {
|
|
|
545
679
|
this.runChecked(['config', '--local', 'user.name', name], 'git-identity-set-failed');
|
|
546
680
|
this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
|
|
547
681
|
}
|
|
548
|
-
mergeIntoCurrent(branch) {
|
|
549
|
-
|
|
682
|
+
mergeIntoCurrent(branch, strategy, message) {
|
|
683
|
+
switch (strategy) {
|
|
684
|
+
case 'squash': {
|
|
685
|
+
this.runChecked(['merge', '--squash', branch], 'git-merge-failed');
|
|
686
|
+
const commitArgs = ['commit', '-m', message ?? `Merge branch '${branch}'`];
|
|
687
|
+
this.runChecked(commitArgs, 'git-merge-failed');
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
case 'rebase':
|
|
691
|
+
this.runChecked(['merge', '--rebase', branch], 'git-merge-failed');
|
|
692
|
+
break;
|
|
693
|
+
default:
|
|
694
|
+
this.runChecked(['merge', branch], 'git-merge-failed');
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
550
697
|
}
|
|
551
698
|
mergeAbort() {
|
|
552
699
|
this.runChecked(['merge', '--abort'], 'git-merge-abort-failed');
|
|
@@ -15,6 +15,8 @@ function splitDiffLines(output) {
|
|
|
15
15
|
/** Reduces a file status string to the primary status code needed for discard routing. */
|
|
16
16
|
function getPrimaryDiscardStatus(status) {
|
|
17
17
|
const normalized = asTrimmedString(status);
|
|
18
|
+
/* c8 ignore next 3 */
|
|
19
|
+
// Unreachable: parseStatusOutput always produces non-empty status (falls back to 'M')
|
|
18
20
|
if (!normalized) {
|
|
19
21
|
return 'M';
|
|
20
22
|
}
|
|
@@ -23,6 +25,8 @@ function getPrimaryDiscardStatus(status) {
|
|
|
23
25
|
return candidate;
|
|
24
26
|
}
|
|
25
27
|
}
|
|
28
|
+
/* c8 ignore next 3 */
|
|
29
|
+
// Unreachable: normalized[0] is always defined on non-empty string
|
|
26
30
|
return normalized[0] ?? 'M';
|
|
27
31
|
}
|
|
28
32
|
/** Builds a discard plan that handles tracked, added, copied, and renamed paths. */
|
|
@@ -86,6 +90,7 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
86
90
|
staging: true,
|
|
87
91
|
push_pull: true,
|
|
88
92
|
fast_forward: true,
|
|
93
|
+
merge_strategies: ['merge', 'squash', 'rebase'],
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
open(params, _context) {
|
|
@@ -142,6 +147,9 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
142
147
|
.map((line) => {
|
|
143
148
|
const [name = '', fullRef = '', headMark = ''] = line.split('\t');
|
|
144
149
|
const isRemote = fullRef.startsWith('refs/remotes/');
|
|
150
|
+
// isRemote is true → name = 'origin/main'. split('/')[0] = 'origin' (always defined).
|
|
151
|
+
// ?? null is unreachable, kept for type-safety.
|
|
152
|
+
/* c8 ignore next */
|
|
145
153
|
const remote = isRemote ? name.split('/')[0] ?? null : null;
|
|
146
154
|
const kind = isRemote
|
|
147
155
|
? { type: 'Remote', remote }
|
|
@@ -295,6 +303,11 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
295
303
|
git.stagePatch(asString(params.patch));
|
|
296
304
|
return null;
|
|
297
305
|
}
|
|
306
|
+
stageSelections(params, _context) {
|
|
307
|
+
const git = this.requireGit(params.session_id);
|
|
308
|
+
git.stageSelections(params.selections);
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
298
311
|
stagePaths(params, _context) {
|
|
299
312
|
const git = this.requireGit(params.session_id);
|
|
300
313
|
git.stagePaths(asStringArray(params.paths));
|
|
@@ -350,7 +363,12 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
350
363
|
}
|
|
351
364
|
mergeIntoCurrent(params, _context) {
|
|
352
365
|
const git = this.requireGit(params.session_id);
|
|
353
|
-
|
|
366
|
+
const rawStrategy = asTrimmedString(params.strategy);
|
|
367
|
+
const strategy = rawStrategy && !['merge', 'squash', 'rebase'].includes(rawStrategy)
|
|
368
|
+
? (() => { throw pluginError('vcs-merge-invalid-strategy', `Unknown merge strategy '${rawStrategy}'. Must be 'merge', 'squash', or 'rebase'.`); })()
|
|
369
|
+
: (rawStrategy || undefined);
|
|
370
|
+
const message = asTrimmedString(params.message) || undefined;
|
|
371
|
+
git.mergeIntoCurrent(asTrimmedString(params.name), strategy, message);
|
|
354
372
|
return null;
|
|
355
373
|
}
|
|
356
374
|
mergeAbort(params, _context) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openvcs/git-plugin",
|
|
3
|
-
"version": "0.3.2-nightly.
|
|
3
|
+
"version": "0.3.2-nightly.20260613.146",
|
|
4
4
|
"description": "Git VCS backend plugin for OpenVCS",
|
|
5
5
|
"author": "OpenVCS Contributors",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
}
|
|
36
36
|
]
|
|
37
37
|
},
|
|
38
|
-
"version": "0.3.2-nightly.
|
|
38
|
+
"version": "0.3.2-nightly.20260613.146"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
42
42
|
"test": "tsx --test test/*.test.ts",
|
|
43
|
-
"coverage": "c8 --include src --exclude 'src/plugin-types.ts' --reporter text --reporter lcov --all --check-coverage --lines
|
|
43
|
+
"coverage": "c8 --include src --exclude 'src/plugin-types.ts' --reporter text --reporter lcov --all --check-coverage --lines 100 --functions 100 --branches 100 --statements 100 --per-file tsx --test test/*.test.ts",
|
|
44
44
|
"prepack": "npm run build",
|
|
45
45
|
"build:plugin": "tsc -p tsconfig.json",
|
|
46
46
|
"build": "node ./node_modules/@openvcs/sdk/bin/openvcs.js build"
|
package/src/git.ts
CHANGED
|
@@ -180,7 +180,7 @@ export class GitCommand {
|
|
|
180
180
|
const baseLine = isCurrent ? line.slice(0, -1) : line;
|
|
181
181
|
const name = baseLine.trim();
|
|
182
182
|
if (name) {
|
|
183
|
-
branches.push({ name, current: name === current
|
|
183
|
+
branches.push({ name, current: name === current });
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
@@ -441,7 +441,7 @@ export class GitCommand {
|
|
|
441
441
|
}
|
|
442
442
|
|
|
443
443
|
return lines.some((line) => {
|
|
444
|
-
const candidate = String(line
|
|
444
|
+
const candidate = String(line);
|
|
445
445
|
return /^binary files /i.test(candidate)
|
|
446
446
|
|| /^git binary patch/i.test(candidate)
|
|
447
447
|
|| /^literal /i.test(candidate);
|
|
@@ -504,7 +504,7 @@ export class GitCommand {
|
|
|
504
504
|
const line = rawLine.trim();
|
|
505
505
|
if (!line) continue;
|
|
506
506
|
|
|
507
|
-
const marker = line[0]
|
|
507
|
+
const marker = line[0];
|
|
508
508
|
const rest = line.slice(1).trim();
|
|
509
509
|
const [commit = '', path = ''] = rest.split(/\s+/);
|
|
510
510
|
if (!path) continue;
|
|
@@ -562,7 +562,7 @@ export class GitCommand {
|
|
|
562
562
|
this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
|
|
563
563
|
|
|
564
564
|
const modulesPath = join(this.cwd, '.git', 'modules', path);
|
|
565
|
-
/* c8 ignore next
|
|
565
|
+
/* c8 ignore next 5 */
|
|
566
566
|
try {
|
|
567
567
|
rmSync(modulesPath, { recursive: true, force: true });
|
|
568
568
|
} catch {
|
|
@@ -698,6 +698,143 @@ export class GitCommand {
|
|
|
698
698
|
});
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Stages structured hunk/line selections (VCS-agnostic).
|
|
703
|
+
*
|
|
704
|
+
* For each file, fetches the worktree diff, extracts the selected hunks/lines,
|
|
705
|
+
* builds a combined sub-patch, and applies it to the index atomically.
|
|
706
|
+
*/
|
|
707
|
+
stageSelections(
|
|
708
|
+
selections: Array<{ path: string; whole_hunks: number[]; partial_hunks: Record<number, number[]> }>,
|
|
709
|
+
): void {
|
|
710
|
+
const filePatches: string[] = [];
|
|
711
|
+
|
|
712
|
+
for (const sel of selections) {
|
|
713
|
+
// Fetch only the worktree diff (not --cached) — cached changes are
|
|
714
|
+
// already staged and must not be re-applied.
|
|
715
|
+
const raw = this.runChecked(
|
|
716
|
+
['diff', '--no-ext-diff', '--no-color', '--', sel.path],
|
|
717
|
+
'git-diff-failed',
|
|
718
|
+
).stdout;
|
|
719
|
+
if (!raw.trim()) continue;
|
|
720
|
+
|
|
721
|
+
const lines = this.splitDiffLines(raw);
|
|
722
|
+
/* c8 ignore next 3 */
|
|
723
|
+
if (lines.length === 0) continue;
|
|
724
|
+
|
|
725
|
+
const normPath = sel.path.replace(/\\/g, '/');
|
|
726
|
+
|
|
727
|
+
// Find the first @@ line to separate prelude from hunks
|
|
728
|
+
const firstHunk = lines.findIndex(l => l.startsWith('@@'));
|
|
729
|
+
if (firstHunk < 0) continue;
|
|
730
|
+
|
|
731
|
+
const prelude = lines.slice(0, firstHunk);
|
|
732
|
+
const rest = lines.slice(firstHunk);
|
|
733
|
+
|
|
734
|
+
// Build headerExtras: metadata lines that aren't diff --git, ---, or +++
|
|
735
|
+
const headerExtras = prelude.filter(l =>
|
|
736
|
+
!!l &&
|
|
737
|
+
!l.startsWith('diff --git') &&
|
|
738
|
+
!l.startsWith('--- ') &&
|
|
739
|
+
!l.startsWith('+++ ')
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
// Locate all hunk @@ positions within rest
|
|
743
|
+
const starts: number[] = [];
|
|
744
|
+
for (let i = 0; i < rest.length; i++) {
|
|
745
|
+
if (rest[i].startsWith('@@')) starts.push(i);
|
|
746
|
+
}
|
|
747
|
+
/* c8 ignore next 3 */
|
|
748
|
+
if (starts.length === 0) continue;
|
|
749
|
+
starts.push(rest.length);
|
|
750
|
+
|
|
751
|
+
const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
|
|
752
|
+
const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
|
|
753
|
+
const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
|
|
754
|
+
|
|
755
|
+
let out = `diff --git a/${normPath} b/${normPath}\n`;
|
|
756
|
+
if (headerExtras.length) out += headerExtras.join('\n') + '\n';
|
|
757
|
+
if (isAdd) out += `--- /dev/null\n+++ b/${normPath}\n`;
|
|
758
|
+
else if (isDel) out += `--- a/${normPath}\n+++ /dev/null\n`;
|
|
759
|
+
else out += `--- a/${normPath}\n+++ b/${normPath}\n`;
|
|
760
|
+
|
|
761
|
+
for (let h = 0; h < starts.length - 1; h++) {
|
|
762
|
+
const s = starts[h];
|
|
763
|
+
const e = starts[h + 1];
|
|
764
|
+
const block = rest.slice(s, e);
|
|
765
|
+
/* c8 ignore next 7 */
|
|
766
|
+
const header = block[0] || '';
|
|
767
|
+
const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
|
|
768
|
+
if (!m) continue;
|
|
769
|
+
const aStart = parseInt(m[1] || '0', 10) || 0;
|
|
770
|
+
const cStart = parseInt(m[3] || '0', 10) || 0;
|
|
771
|
+
const content = block.slice(1);
|
|
772
|
+
|
|
773
|
+
if (wantWhole.has(h)) {
|
|
774
|
+
out += header + '\n' + content.join('\n') + '\n';
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
|
|
779
|
+
? sel.partial_hunks[h]
|
|
780
|
+
: (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
|
|
781
|
+
const picksAdj = Array.isArray(picksRaw)
|
|
782
|
+
? picksRaw.map((i: number) => i - 1).filter((i: number) => i >= 0 && i < content.length)
|
|
783
|
+
: [];
|
|
784
|
+
const pickSet = new Set<number>(picksAdj);
|
|
785
|
+
if (pickSet.size === 0) continue;
|
|
786
|
+
|
|
787
|
+
// prefix counts for old/new positions
|
|
788
|
+
const prefOld: number[] = new Array(content.length + 1).fill(0);
|
|
789
|
+
const prefNew: number[] = new Array(content.length + 1).fill(0);
|
|
790
|
+
for (let i = 0; i < content.length; i++) {
|
|
791
|
+
const ch = (content[i] || '')[0] || ' ';
|
|
792
|
+
const isMeta = ch === '\\';
|
|
793
|
+
prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
|
|
794
|
+
prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Group consecutive selected lines into mini-hunks
|
|
798
|
+
const sorted = Array.from(pickSet).sort((x, y) => x - y);
|
|
799
|
+
let group: number[] = [];
|
|
800
|
+
|
|
801
|
+
const flush = (): void => {
|
|
802
|
+
/* c8 ignore next 3 */
|
|
803
|
+
if (group.length === 0) return;
|
|
804
|
+
const i0 = group[0];
|
|
805
|
+
const old_start = aStart + prefOld[i0];
|
|
806
|
+
const new_start = cStart + prefNew[i0];
|
|
807
|
+
const slice = group.map(i => content[i]);
|
|
808
|
+
const contentLines = slice.filter(l => (l || '')[0] !== '\\');
|
|
809
|
+
const metaLines = slice.filter(l => (l || '')[0] === '\\');
|
|
810
|
+
const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
|
|
811
|
+
const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
|
|
812
|
+
if (old_count === 0 && new_count === 0) { group = []; return; }
|
|
813
|
+
out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
|
|
814
|
+
out += contentLines.join('\n') + '\n';
|
|
815
|
+
if (metaLines.length) out += metaLines.join('\n') + '\n';
|
|
816
|
+
group = [];
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
820
|
+
if (group.length === 0) { group.push(sorted[i]); continue; }
|
|
821
|
+
if (sorted[i] === group[group.length - 1] + 1) group.push(sorted[i]);
|
|
822
|
+
else { flush(); group.push(sorted[i]); }
|
|
823
|
+
}
|
|
824
|
+
flush();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
filePatches.push(out.trimEnd());
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (filePatches.length === 0) return;
|
|
831
|
+
|
|
832
|
+
const combinedPatch = filePatches.join('\n') + '\n';
|
|
833
|
+
this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
|
|
834
|
+
stdin: combinedPatch,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
701
838
|
applyReversePatch(patch: string): void {
|
|
702
839
|
this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
|
|
703
840
|
stdin: patch,
|
|
@@ -733,8 +870,21 @@ export class GitCommand {
|
|
|
733
870
|
this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
|
|
734
871
|
}
|
|
735
872
|
|
|
736
|
-
mergeIntoCurrent(branch: string): void {
|
|
737
|
-
|
|
873
|
+
mergeIntoCurrent(branch: string, strategy?: string, message?: string): void {
|
|
874
|
+
switch (strategy) {
|
|
875
|
+
case 'squash': {
|
|
876
|
+
this.runChecked(['merge', '--squash', branch], 'git-merge-failed');
|
|
877
|
+
const commitArgs = ['commit', '-m', message ?? `Merge branch '${branch}'`];
|
|
878
|
+
this.runChecked(commitArgs, 'git-merge-failed');
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
case 'rebase':
|
|
882
|
+
this.runChecked(['merge', '--rebase', branch], 'git-merge-failed');
|
|
883
|
+
break;
|
|
884
|
+
default:
|
|
885
|
+
this.runChecked(['merge', branch], 'git-merge-failed');
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
738
888
|
}
|
|
739
889
|
|
|
740
890
|
mergeAbort(): void {
|
|
@@ -44,6 +44,8 @@ function splitDiffLines(output: string): string[] {
|
|
|
44
44
|
/** Reduces a file status string to the primary status code needed for discard routing. */
|
|
45
45
|
function getPrimaryDiscardStatus(status: string): string {
|
|
46
46
|
const normalized = asTrimmedString(status);
|
|
47
|
+
/* c8 ignore next 3 */
|
|
48
|
+
// Unreachable: parseStatusOutput always produces non-empty status (falls back to 'M')
|
|
47
49
|
if (!normalized) {
|
|
48
50
|
return 'M';
|
|
49
51
|
}
|
|
@@ -54,6 +56,8 @@ function getPrimaryDiscardStatus(status: string): string {
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/* c8 ignore next 3 */
|
|
60
|
+
// Unreachable: normalized[0] is always defined on non-empty string
|
|
57
61
|
return normalized[0] ?? 'M';
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -143,6 +147,7 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
143
147
|
staging: true,
|
|
144
148
|
push_pull: true,
|
|
145
149
|
fast_forward: true,
|
|
150
|
+
merge_strategies: ['merge', 'squash', 'rebase'],
|
|
146
151
|
};
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -232,6 +237,9 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
232
237
|
.map((line): OpenVcs.VcsBranchEntry => {
|
|
233
238
|
const [name = '', fullRef = '', headMark = ''] = line.split('\t');
|
|
234
239
|
const isRemote = fullRef.startsWith('refs/remotes/');
|
|
240
|
+
// isRemote is true → name = 'origin/main'. split('/')[0] = 'origin' (always defined).
|
|
241
|
+
// ?? null is unreachable, kept for type-safety.
|
|
242
|
+
/* c8 ignore next */
|
|
235
243
|
const remote = isRemote ? name.split('/')[0] ?? null : null;
|
|
236
244
|
const kind: OpenVcs.VcsBranchKind = isRemote
|
|
237
245
|
? { type: 'Remote', remote }
|
|
@@ -484,6 +492,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
484
492
|
return null;
|
|
485
493
|
}
|
|
486
494
|
|
|
495
|
+
override stageSelections(
|
|
496
|
+
params: OpenVcs.VcsStageSelectionsParams,
|
|
497
|
+
_context: PluginRuntimeContext,
|
|
498
|
+
): null {
|
|
499
|
+
const git = this.requireGit(params.session_id);
|
|
500
|
+
git.stageSelections(params.selections);
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
487
504
|
override stagePaths(
|
|
488
505
|
params: OpenVcs.VcsStagePathsParams,
|
|
489
506
|
_context: PluginRuntimeContext,
|
|
@@ -576,7 +593,12 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
576
593
|
_context: PluginRuntimeContext,
|
|
577
594
|
): null {
|
|
578
595
|
const git = this.requireGit(params.session_id);
|
|
579
|
-
|
|
596
|
+
const rawStrategy = asTrimmedString(params.strategy);
|
|
597
|
+
const strategy = rawStrategy && !['merge', 'squash', 'rebase'].includes(rawStrategy)
|
|
598
|
+
? (() => { throw pluginError('vcs-merge-invalid-strategy', `Unknown merge strategy '${rawStrategy}'. Must be 'merge', 'squash', or 'rebase'.`); })()
|
|
599
|
+
: (rawStrategy || undefined);
|
|
600
|
+
const message = asTrimmedString(params.message) || undefined;
|
|
601
|
+
git.mergeIntoCurrent(asTrimmedString(params.name), strategy, message);
|
|
580
602
|
return null;
|
|
581
603
|
}
|
|
582
604
|
|