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/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +894 -93
- package/src/index.js +152 -21
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
|
-
|
|
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)
|
|
@@ -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(
|
|
1552
|
-
const stagedPatch = sums.changes.staged.length > 0 ? (await diff({
|
|
1553
|
-
const input = commitInputOf(stagedPatch, (await diff(
|
|
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
|
}
|