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/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
- const branch = await currentBranchOf(root)
578
- const detached = branch === 'HEAD'
579
- const upstream = detached ? null : await upstreamOf(root, branch)
580
- const tracking = upstream === null ? { ahead: 0, behind: 0 } : await aheadBehindOf(root, upstream, branch)
581
- const statusOut = await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
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
- stashCount: await stashCountOf(root),
603
- worktrees: await worktreesOf(root),
604
- operation: await operationOf(root),
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
  }