dsh-ide-git 0.14.5 → 0.16.0
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/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +2277 -151
- package/src/index.js +198 -17
package/src/index.js
CHANGED
|
@@ -84,6 +84,21 @@ export const Config = Schema === null ? undefined : railConfigSchema(Schema)
|
|
|
84
84
|
|
|
85
85
|
const ROUTE_PREFIX = '/dsh-ide-git/api'
|
|
86
86
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
87
|
+
|
|
88
|
+
/* Ring buffer of the most recent git runs — the panel's "Git output" view
|
|
89
|
+
(VS Code's scm showOutput equivalent). Bounded on both axes: entries and
|
|
90
|
+
per-entry text, so a hostile `log -p` cannot grow it without limit. */
|
|
91
|
+
const GIT_LOG_MAX_ENTRIES = 200
|
|
92
|
+
const GIT_LOG_TEXT_MAX = 4000
|
|
93
|
+
const gitLog = []
|
|
94
|
+
function pushGitLog(entry) {
|
|
95
|
+
gitLog.push(entry)
|
|
96
|
+
if (gitLog.length > GIT_LOG_MAX_ENTRIES) gitLog.splice(0, gitLog.length - GIT_LOG_MAX_ENTRIES)
|
|
97
|
+
}
|
|
98
|
+
function clipGitLogText(text) {
|
|
99
|
+
if (typeof text !== 'string' || text.length <= GIT_LOG_TEXT_MAX) return text
|
|
100
|
+
return text.slice(0, GIT_LOG_TEXT_MAX / 2) + '\n…[' + String(text.length - GIT_LOG_TEXT_MAX) + ' more chars]…\n' + text.slice(-GIT_LOG_TEXT_MAX / 2)
|
|
101
|
+
}
|
|
87
102
|
const MAX_DIFF_CHARS = 400 * 1024
|
|
88
103
|
const DEFAULT_TIMEOUT_MS = 30_000
|
|
89
104
|
const NETWORK_TIMEOUT_MS = 180_000
|
|
@@ -118,6 +133,7 @@ function gitBinary() {
|
|
|
118
133
|
*/
|
|
119
134
|
function runGit(cwd, args, options = {}) {
|
|
120
135
|
const timeoutMs = options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs
|
|
136
|
+
const startedAt = Date.now()
|
|
121
137
|
return new Promise((resolve, reject) => {
|
|
122
138
|
let child
|
|
123
139
|
try {
|
|
@@ -135,6 +151,7 @@ function runGit(cwd, args, options = {}) {
|
|
|
135
151
|
if (settled) return
|
|
136
152
|
settled = true
|
|
137
153
|
child.kill('SIGKILL')
|
|
154
|
+
pushGitLog({ at: new Date(startedAt).toISOString(), argv: args.join(' '), code: 'timeout', ms: timeoutMs, out: '', err: 'timed out after ' + String(timeoutMs) + 'ms' })
|
|
138
155
|
reject(new PanelError('git-timeout', 'git ' + args[0] + ' timed out after ' + timeoutMs + 'ms', 504))
|
|
139
156
|
}, timeoutMs)
|
|
140
157
|
child.stdout.on('data', (chunk) => {
|
|
@@ -155,6 +172,14 @@ function runGit(cwd, args, options = {}) {
|
|
|
155
172
|
if (settled) return
|
|
156
173
|
settled = true
|
|
157
174
|
clearTimeout(timer)
|
|
175
|
+
pushGitLog({
|
|
176
|
+
at: new Date(startedAt).toISOString(),
|
|
177
|
+
argv: args.join(' '),
|
|
178
|
+
code: code === null ? -1 : code,
|
|
179
|
+
ms: Date.now() - startedAt,
|
|
180
|
+
out: clipGitLogText(Buffer.concat(out).toString('utf8')),
|
|
181
|
+
err: clipGitLogText(Buffer.concat(err).toString('utf8')),
|
|
182
|
+
})
|
|
158
183
|
resolve({
|
|
159
184
|
code: code === null ? -1 : code,
|
|
160
185
|
signal: signal === null ? undefined : signal,
|
|
@@ -436,7 +461,7 @@ function withRepoLock(key, work) {
|
|
|
436
461
|
const WRITE_METHODS = new Set([
|
|
437
462
|
'stage', 'unstage', 'discard', 'commit', 'checkout', 'branchCreate', 'branchRename',
|
|
438
463
|
'branchDelete', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
|
|
439
|
-
'push', 'stashPush', 'stashApply', 'stashDrop', 'tagCreate', 'tagDelete', 'undoApply',
|
|
464
|
+
'push', 'stashPush', 'stashApply', 'stashDrop', 'stashClear', 'tagCreate', 'tagDelete', 'tagDeleteRemote', 'pushTags', 'clone', 'remoteAdd', 'remoteRemove', 'branchDeleteRemote', 'undoApply',
|
|
440
465
|
])
|
|
441
466
|
|
|
442
467
|
function lockKeyOf(payload) {
|
|
@@ -574,23 +599,42 @@ async function operationOf(root) {
|
|
|
574
599
|
async function summary(payload) {
|
|
575
600
|
const cwd = cwdOf(payload)
|
|
576
601
|
const root = await repoRootOf(cwd, payload)
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
602
|
+
/* Two-phase changes (issue #11): with `stats: false` the two `diff
|
|
603
|
+
--numstat` passes — the most expensive step once a tree holds thousands
|
|
604
|
+
of changes — are skipped, `statsDeferred: true` announces that the +/-
|
|
605
|
+
numbers arrive through the `stats` method instead. The rows themselves
|
|
606
|
+
are complete without them; nothing is truncated. */
|
|
607
|
+
const deferStats = payload.stats === false
|
|
608
|
+
/* The branch chain (branch → upstream → ahead/behind) is sequential by
|
|
609
|
+
necessity; everything else is independent and runs concurrently, so a
|
|
610
|
+
large `status` scan no longer serialises behind five cheap calls
|
|
611
|
+
(issue #11: the sum of ~ten sequential commands became the max of two
|
|
612
|
+
chains). */
|
|
613
|
+
const [branchInfo, statusOut, unstagedStat, stagedStat, ignored, stashCount, worktrees, operation] = await Promise.all([
|
|
614
|
+
(async () => {
|
|
615
|
+
const branch = await currentBranchOf(root)
|
|
616
|
+
const detached = branch === 'HEAD'
|
|
617
|
+
const upstream = detached ? null : await upstreamOf(root, branch)
|
|
618
|
+
const tracking = upstream === null ? { ahead: 0, behind: 0 } : await aheadBehindOf(root, upstream, branch)
|
|
619
|
+
return { branch, detached, upstream, tracking }
|
|
620
|
+
})(),
|
|
621
|
+
git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
|
|
622
|
+
deferStats ? Promise.resolve(new Map()) : numstatOf(root, []),
|
|
623
|
+
deferStats ? Promise.resolve(new Map()) : numstatOf(root, ['--cached']),
|
|
624
|
+
payload.ignored === true ? ignoredEntriesOf(root) : { entries: [], truncated: false },
|
|
625
|
+
stashCountOf(root),
|
|
626
|
+
worktreesOf(root),
|
|
627
|
+
operationOf(root),
|
|
628
|
+
])
|
|
582
629
|
const groups = classify(parsePorcelain(statusOut))
|
|
583
|
-
const ignored = payload.ignored === true ? await ignoredEntriesOf(root) : { entries: [], truncated: false }
|
|
584
|
-
const unstagedStat = await numstatOf(root, [])
|
|
585
|
-
const stagedStat = await numstatOf(root, ['--cached'])
|
|
586
630
|
return {
|
|
587
631
|
repoRoot: root,
|
|
588
632
|
cwd,
|
|
589
|
-
branch,
|
|
590
|
-
detached,
|
|
591
|
-
upstream,
|
|
592
|
-
ahead: tracking.ahead,
|
|
593
|
-
behind: tracking.behind,
|
|
633
|
+
branch: branchInfo.branch,
|
|
634
|
+
detached: branchInfo.detached,
|
|
635
|
+
upstream: branchInfo.upstream,
|
|
636
|
+
ahead: branchInfo.tracking.ahead,
|
|
637
|
+
behind: branchInfo.tracking.behind,
|
|
594
638
|
changes: {
|
|
595
639
|
staged: decorate(groups.staged, stagedStat),
|
|
596
640
|
unstaged: decorate(groups.unstaged, unstagedStat),
|
|
@@ -599,12 +643,31 @@ async function summary(payload) {
|
|
|
599
643
|
ignored: ignored.entries,
|
|
600
644
|
ignoredTruncated: ignored.truncated,
|
|
601
645
|
},
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
646
|
+
statsDeferred: deferStats === true,
|
|
647
|
+
stashCount: stashCount,
|
|
648
|
+
worktrees: worktrees,
|
|
649
|
+
operation: operation,
|
|
605
650
|
}
|
|
606
651
|
}
|
|
607
652
|
|
|
653
|
+
/* The second phase of the two-phase changes (issue #11): the two numstat
|
|
654
|
+
passes, concurrent, packed as [path, additions, deletions, binary] rows so
|
|
655
|
+
they survive JSON. Paths are post-rename paths — exactly what `summary`
|
|
656
|
+
decorates its entries with. Read-only: deliberately not in WRITE_METHODS. */
|
|
657
|
+
async function stats(payload) {
|
|
658
|
+
const root = await repoRootOf(cwdOf(payload), payload)
|
|
659
|
+
const [unstaged, staged] = await Promise.all([
|
|
660
|
+
numstatOf(root, []),
|
|
661
|
+
numstatOf(root, ['--cached']),
|
|
662
|
+
])
|
|
663
|
+
const pack = (map) => {
|
|
664
|
+
const rows = []
|
|
665
|
+
for (const [path, stat] of map) rows.push([path, stat.additions, stat.deletions, stat.binary === true])
|
|
666
|
+
return rows
|
|
667
|
+
}
|
|
668
|
+
return { unstaged: pack(unstaged), staged: pack(staged) }
|
|
669
|
+
}
|
|
670
|
+
|
|
608
671
|
async function branches(payload) {
|
|
609
672
|
const cwd = cwdOf(payload)
|
|
610
673
|
const root = await repoRootOf(cwd, payload)
|
|
@@ -916,6 +979,85 @@ async function rebase(payload) {
|
|
|
916
979
|
return { onto, conflicted, output: combined }
|
|
917
980
|
}
|
|
918
981
|
|
|
982
|
+
/* VS Code's "Abort Rebase" (SCM ▸ Commit ▸ Abort Rebase). Only meaningful
|
|
983
|
+
while a rebase is in progress — `operationOf` already surfaces that state,
|
|
984
|
+
so the menu greys the entry out; the route still refuses honestly when
|
|
985
|
+
there is nothing to abort. */
|
|
986
|
+
async function rebaseAbort(payload) {
|
|
987
|
+
const cwd = cwdOf(payload)
|
|
988
|
+
const root = await repoRootOf(cwd, payload)
|
|
989
|
+
const result = await runGit(root, ['rebase', '--abort'])
|
|
990
|
+
if (result.code !== 0) {
|
|
991
|
+
const detail = (result.stdout + '\n' + result.stderr).trim()
|
|
992
|
+
throw new PanelError('git-failed', detail === '' ? 'no rebase to abort' : detail, 409)
|
|
993
|
+
}
|
|
994
|
+
return { output: (result.stdout + '\n' + result.stderr).trim() }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/* VS Code's SCM ▸ Remote ▸ Add Remote.... The name is a ref, the URL the same
|
|
998
|
+
grammar `clone` already accepts. */
|
|
999
|
+
async function remoteAdd(payload) {
|
|
1000
|
+
const cwd = cwdOf(payload)
|
|
1001
|
+
const root = await repoRootOf(cwd, payload)
|
|
1002
|
+
const name = requireRef(payload.name, 'name')
|
|
1003
|
+
const url = requireUrl(payload.url, 'url')
|
|
1004
|
+
await git(root, ['remote', 'add', name, url])
|
|
1005
|
+
return { name, url }
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function remoteRemove(payload) {
|
|
1009
|
+
const cwd = cwdOf(payload)
|
|
1010
|
+
const root = await repoRootOf(cwd, payload)
|
|
1011
|
+
const name = requireRef(payload.name, 'name')
|
|
1012
|
+
if (typeof payload.confirm !== 'boolean' || payload.confirm !== true) throw badRequest('removing a remote requires confirm: true')
|
|
1013
|
+
await git(root, ['remote', 'remove', name])
|
|
1014
|
+
return { name }
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/* SCM ▸ Branch ▸ Delete Remote Branch...: `git push <remote> --delete <branch>`.
|
|
1018
|
+
Genuinely destructive on the OTHER side, so it keeps the typed-confirm
|
|
1019
|
+
posture of a local protected-branch delete: confirm is mandatory. */
|
|
1020
|
+
async function branchDeleteRemote(payload) {
|
|
1021
|
+
const cwd = cwdOf(payload)
|
|
1022
|
+
const root = await repoRootOf(cwd, payload)
|
|
1023
|
+
const remote = requireRef(payload.remote, 'remote')
|
|
1024
|
+
const branch = requireRef(payload.branch, 'branch')
|
|
1025
|
+
if (payload.confirm !== true) throw badRequest('deleting a remote branch requires confirm: true')
|
|
1026
|
+
const out = await git(root, ['push', remote, '--delete', branch], { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
1027
|
+
return { remote, branch, output: out.trim() }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/* SCM ▸ Tags ▸ Delete Remote Tag... / Push Tags. */
|
|
1031
|
+
async function tagDeleteRemote(payload) {
|
|
1032
|
+
const cwd = cwdOf(payload)
|
|
1033
|
+
const root = await repoRootOf(cwd, payload)
|
|
1034
|
+
const remote = requireRef(payload.remote, 'remote')
|
|
1035
|
+
const tag = requireRef(payload.name, 'name')
|
|
1036
|
+
if (payload.confirm !== true) throw badRequest('deleting a remote tag requires confirm: true')
|
|
1037
|
+
const out = await git(root, ['push', remote, '--delete', tag], { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
1038
|
+
return { remote, tag, output: out.trim() }
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function pushTags(payload) {
|
|
1042
|
+
const cwd = cwdOf(payload)
|
|
1043
|
+
const root = await repoRootOf(cwd, payload)
|
|
1044
|
+
if (payload.confirm !== true) throw badRequest('pushing tags requires confirm: true')
|
|
1045
|
+
const remote = typeof payload.remote === 'string' && payload.remote.trim() !== '' ? requireRef(payload.remote, 'remote') : 'origin'
|
|
1046
|
+
const out = await git(root, ['push', remote, '--tags'], { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
1047
|
+
return { output: out.trim() }
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/* SCM ▸ Stash ▸ Drop All Stashes...: one `git stash clear`. No undo handle is
|
|
1051
|
+
possible (the stash reflog is gone with the clear), hence the mandatory
|
|
1052
|
+
confirm on top of the dialog. */
|
|
1053
|
+
async function stashClear(payload) {
|
|
1054
|
+
const cwd = cwdOf(payload)
|
|
1055
|
+
const root = await repoRootOf(cwd, payload)
|
|
1056
|
+
if (payload.confirm !== true) throw badRequest('dropping all stashes requires confirm: true')
|
|
1057
|
+
await git(root, ['stash', 'clear'])
|
|
1058
|
+
return { cleared: true }
|
|
1059
|
+
}
|
|
1060
|
+
|
|
919
1061
|
async function cherryPick(payload) {
|
|
920
1062
|
const cwd = cwdOf(payload)
|
|
921
1063
|
const root = await repoRootOf(cwd, payload)
|
|
@@ -953,6 +1095,7 @@ async function fetch(payload) {
|
|
|
953
1095
|
const root = await repoRootOf(cwd, payload)
|
|
954
1096
|
const args = ['fetch']
|
|
955
1097
|
if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
|
|
1098
|
+
if (payload.all === true) args.push('--all')
|
|
956
1099
|
if (payload.prune !== false) args.push('--prune')
|
|
957
1100
|
args.push('--tags')
|
|
958
1101
|
const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
@@ -979,6 +1122,7 @@ async function push(payload) {
|
|
|
979
1122
|
if (payload.confirm !== true) throw badRequest('push requires confirm: true')
|
|
980
1123
|
const args = ['push']
|
|
981
1124
|
if (payload.setUpstream === true) args.push('--set-upstream')
|
|
1125
|
+
if (payload.force === true) args.push('--force-with-lease')
|
|
982
1126
|
if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
|
|
983
1127
|
if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'branch'))
|
|
984
1128
|
const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
@@ -1004,6 +1148,7 @@ async function stashPush(payload) {
|
|
|
1004
1148
|
const root = await repoRootOf(cwd, payload)
|
|
1005
1149
|
const args = ['stash', 'push']
|
|
1006
1150
|
if (payload.includeUntracked === true) args.push('--include-untracked')
|
|
1151
|
+
if (payload.staged === true) args.push('--staged')
|
|
1007
1152
|
if (typeof payload.message === 'string' && payload.message.trim() !== '') args.push('-m', payload.message)
|
|
1008
1153
|
const out = await git(root, args)
|
|
1009
1154
|
return { output: out.trim() }
|
|
@@ -1052,6 +1197,32 @@ async function tagDelete(payload) {
|
|
|
1052
1197
|
return { tag }
|
|
1053
1198
|
}
|
|
1054
1199
|
|
|
1200
|
+
/* VS Code's SCM title "Clone..." — clone a URL into an absolute local path.
|
|
1201
|
+
The PARENT of the target must exist; git creates the last component itself
|
|
1202
|
+
and refuses a non-empty one, which is the safety net for typos. It writes a
|
|
1203
|
+
brand-new tree (never touches an existing repository), but it does write the
|
|
1204
|
+
filesystem, so it rides the write queue like everything else that mutates. */
|
|
1205
|
+
const CLONE_TIMEOUT_MS = 10 * 60 * 1000
|
|
1206
|
+
function requireUrl(value, label) {
|
|
1207
|
+
if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
|
|
1208
|
+
const text = value.trim()
|
|
1209
|
+
if (text.startsWith('-') === true || /[\s\u0000-\u001f]/.test(text) === true || text.length > 2048) {
|
|
1210
|
+
throw badRequest(label + ' is not a valid repository URL')
|
|
1211
|
+
}
|
|
1212
|
+
return text
|
|
1213
|
+
}
|
|
1214
|
+
async function clone(payload) {
|
|
1215
|
+
const url = requireUrl(payload.url, 'url')
|
|
1216
|
+
const dir = requireAbsolute(payload.dir, 'dir')
|
|
1217
|
+
await git(path.dirname(dir), ['clone', url, dir], { timeoutMs: CLONE_TIMEOUT_MS })
|
|
1218
|
+
return { dir }
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/* The tail of the git run ring buffer (see runGit). Read-only, global. */
|
|
1222
|
+
async function gitLogs() {
|
|
1223
|
+
return { entries: gitLog.slice(-GIT_LOG_MAX_ENTRIES) }
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1055
1226
|
/** What this repository can still undo right now (newest last). */
|
|
1056
1227
|
async function undoList(payload) {
|
|
1057
1228
|
const cwd = cwdOf(payload)
|
|
@@ -1666,6 +1837,7 @@ const METHODS = {
|
|
|
1666
1837
|
'commit-efforts': commitEfforts,
|
|
1667
1838
|
repos,
|
|
1668
1839
|
summary,
|
|
1840
|
+
stats,
|
|
1669
1841
|
branches,
|
|
1670
1842
|
log,
|
|
1671
1843
|
commitDetail,
|
|
@@ -1693,6 +1865,15 @@ const METHODS = {
|
|
|
1693
1865
|
stashDrop,
|
|
1694
1866
|
tagCreate,
|
|
1695
1867
|
tagDelete,
|
|
1868
|
+
tagDeleteRemote,
|
|
1869
|
+
pushTags,
|
|
1870
|
+
clone,
|
|
1871
|
+
gitLogs,
|
|
1872
|
+
remoteAdd,
|
|
1873
|
+
remoteRemove,
|
|
1874
|
+
branchDeleteRemote,
|
|
1875
|
+
rebaseAbort,
|
|
1876
|
+
stashClear,
|
|
1696
1877
|
undoList,
|
|
1697
1878
|
undoApply,
|
|
1698
1879
|
}
|