dsh-ide-git 0.14.4 → 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,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)
@@ -726,6 +789,38 @@ async function commitDetail(payload) {
726
789
  async function diff(payload) {
727
790
  const cwd = cwdOf(payload)
728
791
  const root = await repoRootOf(cwd, payload)
792
+ /* Untracked files have no index entry, so plain `git diff [--cached] -- path`
793
+ is legitimately empty for them — the panel's change list still shows the
794
+ file and the preview must not go blank. `--no-index /dev/null <file>`
795
+ produces the honest "new file" patch; its exit code is 1 when differences
796
+ exist, which is data, not failure (only >1 rejects). */
797
+ if (payload.untracked === true) {
798
+ const rel = requireRelativePath(payload.path)
799
+ const absolute = path.isAbsolute(rel) ? rel : path.join(root, rel)
800
+ /* --no-index reads the file for real, so unlike the plain `git diff --`
801
+ form an absolute path must NOT be allowed to escape the repository. */
802
+ if (absolute !== root && !absolute.startsWith(root + path.sep)) throw badRequest('path must stay inside the repository')
803
+ const result = await runGit(root, ['diff', '--no-color', '--no-ext-diff', '-U3', '--no-index', '--', '/dev/null', absolute])
804
+ if (result.code !== 0 && result.code !== 1) {
805
+ const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()
806
+ throw new PanelError('git-failed', 'git diff --no-index failed: ' + (detail === '' ? 'exit ' + result.code : detail), 409)
807
+ }
808
+ /* --no-index spells the b/ side with the absolute path we passed; rewrite
809
+ the header lines back to the repository-relative spelling so the preview
810
+ reads like every other patch (body lines are left untouched). */
811
+ let out = result.stdout
812
+ if (out.indexOf(absolute) >= 0 && out.indexOf('\n') !== -1) {
813
+ out = out.split('\n').map((line) => {
814
+ if (line.startsWith('diff --git ') && line.indexOf(absolute) >= 0) return 'diff --git a/' + rel + ' b/' + rel
815
+ /* the b/ side of --no-index carries the absolute path verbatim (its
816
+ leading slash merged into the separator): match '+++ b' + absolute. */
817
+ if (line.startsWith('+++ b' + absolute)) return '+++ b/' + rel
818
+ return line
819
+ }).join('\n')
820
+ }
821
+ const truncated = out.length > MAX_DIFF_CHARS
822
+ return { patch: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated }
823
+ }
729
824
  const args = ['diff', '--no-color', '--no-ext-diff', '-U3']
730
825
  if (payload.staged === true) args.push('--cached')
731
826
  if (typeof payload.hash === 'string' && payload.hash.trim() !== '') {
@@ -1020,6 +1115,32 @@ async function tagDelete(payload) {
1020
1115
  return { tag }
1021
1116
  }
1022
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
+
1023
1144
  /** What this repository can still undo right now (newest last). */
1024
1145
  async function undoList(payload) {
1025
1146
  const cwd = cwdOf(payload)
@@ -1547,10 +1668,17 @@ async function commitMessage(payload) {
1547
1668
  commitBusy = true
1548
1669
  try {
1549
1670
  const cwd = cwdOf(payload)
1671
+ /* The picked repository travels as `repoRoot`, and every other git call
1672
+ threads it through `repoRootOf`. Resolving the root from `cwd` alone made
1673
+ this the one route that answered not-a-repo in a workspace whose own root
1674
+ is not a repository — a workspace holding several sibling repos, which is
1675
+ exactly what the repo picker exists for. */
1676
+ const repoRoot = await repoRootOf(cwd, payload)
1677
+ const scope = { cwd: cwd, repoRoot: repoRoot }
1550
1678
  const route = await commitRouteOf(payload)
1551
- const sums = await summary({ cwd: cwd })
1552
- const stagedPatch = sums.changes.staged.length > 0 ? (await diff({ cwd: cwd, staged: true })).patch : ''
1553
- const input = commitInputOf(stagedPatch, (await diff({ cwd: cwd })).patch)
1679
+ const sums = await summary(scope)
1680
+ const stagedPatch = sums.changes.staged.length > 0 ? (await diff(Object.assign({}, scope, { staged: true }))).patch : ''
1681
+ const input = commitInputOf(stagedPatch, (await diff(scope)).patch)
1554
1682
  if (input.files.length === 0) {
1555
1683
  if (input.dropped > 0) {
1556
1684
  throw new PanelError('only-ignored-changes', 'the only changes are lock files or binary files, which are not sent to the model; stage a source change first')
@@ -1627,6 +1755,7 @@ const METHODS = {
1627
1755
  'commit-efforts': commitEfforts,
1628
1756
  repos,
1629
1757
  summary,
1758
+ stats,
1630
1759
  branches,
1631
1760
  log,
1632
1761
  commitDetail,
@@ -1654,6 +1783,8 @@ const METHODS = {
1654
1783
  stashDrop,
1655
1784
  tagCreate,
1656
1785
  tagDelete,
1786
+ clone,
1787
+ gitLogs,
1657
1788
  undoList,
1658
1789
  undoApply,
1659
1790
  }