dsh-ide-git 0.14.5 → 0.15.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
@@ -46,7 +46,7 @@ try {
46
46
  /** Rail action ids, in rail order. MUST mirror RAIL_SPECS in src/client.js. */
47
47
  const RAIL_CONFIG_IDS = [
48
48
  'refresh', 'tree', 'float', 'split', 'fullscreen', 'newBranch', 'checkout', 'delete',
49
- 'compare', 'diff', 'stash', 'tag', 'favorite', 'fetch', 'pull', 'push',
49
+ 'compare', 'diff', 'stash', 'tag', 'favorite', 'fetch', 'pull', 'push', 'more',
50
50
  ]
51
51
 
52
52
  /** Volatile refs (dsh >= 0.1.7 settings contract) or plain values on older hosts. */
@@ -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', 'tagCreate', 'tagDelete', 'undoApply', 'clone',
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,10 +643,29 @@ 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,
650
+ }
651
+ }
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
605
667
  }
668
+ return { unstaged: pack(unstaged), staged: pack(staged) }
606
669
  }
607
670
 
608
671
  async function branches(payload) {
@@ -1052,6 +1115,32 @@ async function tagDelete(payload) {
1052
1115
  return { tag }
1053
1116
  }
1054
1117
 
1118
+ /* VS Code's SCM title "Clone..." — clone a URL into an absolute local path.
1119
+ The PARENT of the target must exist; git creates the last component itself
1120
+ and refuses a non-empty one, which is the safety net for typos. It writes a
1121
+ brand-new tree (never touches an existing repository), but it does write the
1122
+ filesystem, so it rides the write queue like everything else that mutates. */
1123
+ const CLONE_TIMEOUT_MS = 10 * 60 * 1000
1124
+ function requireUrl(value, label) {
1125
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
1126
+ const text = value.trim()
1127
+ if (text.startsWith('-') === true || /[\s\u0000-\u001f]/.test(text) === true || text.length > 2048) {
1128
+ throw badRequest(label + ' is not a valid repository URL')
1129
+ }
1130
+ return text
1131
+ }
1132
+ async function clone(payload) {
1133
+ const url = requireUrl(payload.url, 'url')
1134
+ const dir = requireAbsolute(payload.dir, 'dir')
1135
+ await git(path.dirname(dir), ['clone', url, dir], { timeoutMs: CLONE_TIMEOUT_MS })
1136
+ return { dir }
1137
+ }
1138
+
1139
+ /* The tail of the git run ring buffer (see runGit). Read-only, global. */
1140
+ async function gitLogs() {
1141
+ return { entries: gitLog.slice(-GIT_LOG_MAX_ENTRIES) }
1142
+ }
1143
+
1055
1144
  /** What this repository can still undo right now (newest last). */
1056
1145
  async function undoList(payload) {
1057
1146
  const cwd = cwdOf(payload)
@@ -1666,6 +1755,7 @@ const METHODS = {
1666
1755
  'commit-efforts': commitEfforts,
1667
1756
  repos,
1668
1757
  summary,
1758
+ stats,
1669
1759
  branches,
1670
1760
  log,
1671
1761
  commitDetail,
@@ -1693,6 +1783,8 @@ const METHODS = {
1693
1783
  stashDrop,
1694
1784
  tagCreate,
1695
1785
  tagDelete,
1786
+ clone,
1787
+ gitLogs,
1696
1788
  undoList,
1697
1789
  undoApply,
1698
1790
  }