dsh-remote-vs 0.9.3 → 0.11.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/README.en.md +13 -10
- package/README.md +14 -9
- package/cordis.patch.yml +10 -5
- package/lib/client.js +3319 -1093
- package/lib/index.js +211 -9
- package/lib/types/client/api.d.ts +12 -0
- package/lib/types/client/composer.d.ts +5 -1
- package/lib/types/client/exec-output.d.ts +58 -0
- package/lib/types/client/i18n.d.ts +1 -1
- package/lib/types/client/scm/CommitPane.d.ts +73 -0
- package/lib/types/client/scm/ExecPanel.d.ts +34 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
- package/lib/types/client/scm/actions.d.ts +50 -3
- package/lib/types/client/scm/commit-model.d.ts +119 -0
- package/lib/types/client/store.d.ts +9 -3
- package/lib/types/client/vsc/kit.d.ts +16 -0
- package/lib/types/git-format.d.ts +5 -0
- package/lib/types/git-ops.d.ts +57 -2
- package/lib/types/index.d.ts +5 -0
- package/lib/types/pool.d.ts +9 -0
- package/lib/types/protocol.d.ts +23 -0
- package/lib/types/timeouts.d.ts +17 -2
- package/package.json +37 -37
- package/src/client/SettingsHosts.tsx +2 -1
- package/src/client/api.ts +61 -0
- package/src/client/composer.ts +11 -2
- package/src/client/exec-output.ts +129 -0
- package/src/client/i18n.ts +133 -4
- package/src/client/scm/CommitPane.tsx +726 -0
- package/src/client/scm/ExecPanel.tsx +124 -0
- package/src/client/scm/HistoryPanel.tsx +483 -104
- package/src/client/scm/ScmPanel.tsx +247 -9
- package/src/client/scm/actions.ts +93 -10
- package/src/client/scm/commit-model.ts +317 -0
- package/src/client/store.tsx +13 -6
- package/src/client/vsc/kit.tsx +71 -0
- package/src/client/vsc/vsc.css +647 -0
- package/src/git-format.ts +7 -3
- package/src/git-ops.ts +83 -7
- package/src/index.ts +10 -2
- package/src/pool.ts +84 -4
- package/src/protocol.ts +14 -0
- package/src/routes.ts +135 -1
- package/src/timeouts.ts +18 -2
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* and the change list never disagree.
|
|
10
10
|
*/
|
|
11
11
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
12
|
-
import type { GitBranch, GitRemote, GitStash } from '../../protocol.ts'
|
|
12
|
+
import type { GitBranch, GitRemote, GitStash, RemoteExecResult } from '../../protocol.ts'
|
|
13
13
|
import { gitReadSettleMs, useExplorer } from '../store.tsx'
|
|
14
|
+
import { RemoteApiError, gitTimeoutMs } from '../api.ts'
|
|
14
15
|
import {
|
|
15
16
|
branchOp,
|
|
16
17
|
commitAll,
|
|
@@ -20,10 +21,14 @@ import {
|
|
|
20
21
|
loadRemotes,
|
|
21
22
|
loadStashes,
|
|
22
23
|
lastCommitMessage,
|
|
24
|
+
pushWithRefspec,
|
|
23
25
|
remoteOp,
|
|
24
26
|
stashOp,
|
|
27
|
+
streamNetworkVerb,
|
|
25
28
|
syncRepo,
|
|
26
29
|
} from './actions.ts'
|
|
30
|
+
import { appendExecOutput, formatElapsed, networkBudgetMs, type PushTarget } from '../exec-output.ts'
|
|
31
|
+
import { ExecPanel, type ExecPanelState } from './ExecPanel.tsx'
|
|
27
32
|
import type { ChangeEntry } from './status-map.ts'
|
|
28
33
|
import { rowVerb } from './status-map.ts'
|
|
29
34
|
import { ReadQueue } from './read-queue.ts'
|
|
@@ -56,6 +61,25 @@ import {
|
|
|
56
61
|
} from '../vsc/kit.tsx'
|
|
57
62
|
|
|
58
63
|
const MSG_KEY = 'dsh-remote-vs.commitMsg'
|
|
64
|
+
/** Remembered custom push refspec (Gerrit shape); absent = never customized. */
|
|
65
|
+
const PUSH_REFSPEC_KEY = 'dsh-remote-vs.pushRefspec'
|
|
66
|
+
|
|
67
|
+
function readPushRefspec(): string {
|
|
68
|
+
try {
|
|
69
|
+
return localStorage.getItem(PUSH_REFSPEC_KEY) ?? ''
|
|
70
|
+
} catch {
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writePushRefspec(value: string): void {
|
|
76
|
+
try {
|
|
77
|
+
if (value === '') localStorage.removeItem(PUSH_REFSPEC_KEY)
|
|
78
|
+
else localStorage.setItem(PUSH_REFSPEC_KEY, value)
|
|
79
|
+
} catch {
|
|
80
|
+
// Ignore: a blocked localStorage must not break the push flow.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
59
83
|
|
|
60
84
|
/**
|
|
61
85
|
* One extras read per repository at a time.
|
|
@@ -81,6 +105,16 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
|
|
|
81
105
|
const store = useExplorer()
|
|
82
106
|
const { t, lang, buckets, head, alias, repoRoot } = store
|
|
83
107
|
const [folded, setFolded] = useState<Record<string, boolean>>({})
|
|
108
|
+
/** Live log of the running network verb (undefined = panel closed). */
|
|
109
|
+
const [execPanel, setExecPanel] = useState<ExecPanelState | undefined>(undefined)
|
|
110
|
+
const execAbort = useRef<AbortController | undefined>(undefined)
|
|
111
|
+
const execLog = useRef('')
|
|
112
|
+
/** The live-log repaint interval (kept out of render state on purpose). */
|
|
113
|
+
const execTimer = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
|
|
114
|
+
// Leaving the panel mid-push must not leave a 250 ms setState loop running.
|
|
115
|
+
useEffect(() => () => {
|
|
116
|
+
if (execTimer.current !== undefined) clearInterval(execTimer.current)
|
|
117
|
+
}, [])
|
|
84
118
|
const [message, setMessage] = useState<string>(() => {
|
|
85
119
|
try {
|
|
86
120
|
return localStorage.getItem(MSG_KEY) ?? ''
|
|
@@ -391,20 +425,216 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
|
|
|
391
425
|
}, 'status')
|
|
392
426
|
}
|
|
393
427
|
|
|
428
|
+
/**
|
|
429
|
+
* The remote a push goes to: the current branch's upstream remote, else
|
|
430
|
+
* `origin`. A refspec names no remote, so the panel has to pick one.
|
|
431
|
+
*/
|
|
432
|
+
const pushRemote = (): string => {
|
|
433
|
+
const current = branches.find(item => item.head) ?? branches.find(item => item.name === head?.branch)
|
|
434
|
+
const upstream = current?.upstream ?? ''
|
|
435
|
+
const slash = upstream.indexOf('/')
|
|
436
|
+
return slash > 0 ? upstream.slice(0, slash) : 'origin'
|
|
437
|
+
}
|
|
438
|
+
|
|
394
439
|
const doSync = async (kind: 'fetch' | 'pull' | 'push' | 'sync'): Promise<void> => {
|
|
395
440
|
if (repoRoot === undefined) return
|
|
396
|
-
|
|
441
|
+
/** Empty = the plain `git push` (upstream) this action used to run. */
|
|
442
|
+
let refspec = ''
|
|
443
|
+
let remote = 'origin'
|
|
444
|
+
if (kind === 'push') {
|
|
445
|
+
// Gerrit-style review needs a destination refspec (`refs/for/<branch>`);
|
|
446
|
+
// a bare `git push` can never produce one. The prompt is the confirmation
|
|
447
|
+
// AND the destination field, with the last custom value remembered.
|
|
448
|
+
const current = branches.find(item => item.head) ?? branches.find(item => item.name === head?.branch)
|
|
449
|
+
const branchName = head?.detached === true || head === undefined
|
|
450
|
+
? (current?.name ?? 'master')
|
|
451
|
+
: head.branch
|
|
452
|
+
remote = pushRemote()
|
|
453
|
+
const remembered = readPushRefspec()
|
|
454
|
+
const answer = await promptDialog({
|
|
455
|
+
title: t('scm.pushTitle'),
|
|
456
|
+
text: t('scm.pushText'),
|
|
457
|
+
initial: remembered !== '' ? remembered : `HEAD:refs/for/${branchName}`,
|
|
458
|
+
placeholder: t('scm.pushRefspecPlaceholder'),
|
|
459
|
+
okText: t('scm.push'),
|
|
460
|
+
})
|
|
461
|
+
if (answer === undefined) return
|
|
462
|
+
refspec = answer.trim()
|
|
463
|
+
writePushRefspec(refspec)
|
|
464
|
+
} else if (kind === 'sync') {
|
|
397
465
|
const ok = await confirmDialog({
|
|
398
|
-
title:
|
|
399
|
-
text:
|
|
400
|
-
okText:
|
|
466
|
+
title: t('scm.syncTitle'),
|
|
467
|
+
text: t('scm.syncText'),
|
|
468
|
+
okText: t('scm.sync'),
|
|
401
469
|
})
|
|
402
470
|
if (!ok) return
|
|
403
471
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
472
|
+
const baseTitle = kind === 'push'
|
|
473
|
+
? t('scm.pushTitle')
|
|
474
|
+
: kind === 'sync'
|
|
475
|
+
? t('scm.syncTitle')
|
|
476
|
+
: kind === 'fetch'
|
|
477
|
+
? t('scm.fetch')
|
|
478
|
+
: t('scm.pull')
|
|
479
|
+
// The destination belongs in the panel title: "push" alone hides whether
|
|
480
|
+
// Gerrit review or a plain branch push is running.
|
|
481
|
+
const title = kind === 'push' ? `${baseTitle} · ${refspec === '' ? 'git push' : refspec}` : baseTitle
|
|
482
|
+
await runNetwork(kind, title, { refspec, remote })
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Stop the running network verb: confirm first.
|
|
487
|
+
*
|
|
488
|
+
* The stop button kills the remote process group, so `sync` can end with the
|
|
489
|
+
* pull applied and the push never attempted, and `pull` can leave a
|
|
490
|
+
* half-merged tree. One confirmation is the honest price of that.
|
|
491
|
+
*/
|
|
492
|
+
const cancelNetwork = (): void => {
|
|
493
|
+
void (async () => {
|
|
494
|
+
const ok = await confirmDialog({
|
|
495
|
+
title: t('exec.cancel'),
|
|
496
|
+
text: t('exec.cancelHint'),
|
|
497
|
+
danger: true,
|
|
498
|
+
okText: t('exec.cancel'),
|
|
499
|
+
})
|
|
500
|
+
if (ok) execAbort.current?.abort()
|
|
501
|
+
})()
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Run one network verb with its live output on screen.
|
|
506
|
+
*
|
|
507
|
+
* `streamNetworkVerb` speaks the SSE route; an older Host that has no such
|
|
508
|
+
* route falls back to the unary verb and shows the complete result in the same
|
|
509
|
+
* panel. The timer and the log are painted on their own 250 ms cadence — a
|
|
510
|
+
* per-chunk setState would re-render the whole SCM panel for every line git
|
|
511
|
+
* prints.
|
|
512
|
+
*/
|
|
513
|
+
const runNetwork = async (
|
|
514
|
+
kind: 'fetch' | 'pull' | 'push' | 'sync',
|
|
515
|
+
title: string,
|
|
516
|
+
target: PushTarget,
|
|
517
|
+
): Promise<void> => {
|
|
518
|
+
if (repoRoot === undefined) return
|
|
519
|
+
const startedAt = Date.now()
|
|
520
|
+
const controller = new AbortController()
|
|
521
|
+
execAbort.current = controller
|
|
522
|
+
execLog.current = ''
|
|
523
|
+
setExecPanel({ title, output: '', status: 'running', startedAt, elapsedMs: 0 })
|
|
524
|
+
setBusy(kind)
|
|
525
|
+
let painted = ''
|
|
526
|
+
const timer = setInterval(() => {
|
|
527
|
+
if (execLog.current !== painted) {
|
|
528
|
+
painted = execLog.current
|
|
529
|
+
setExecPanel(prev => (prev === undefined || prev.status !== 'running'
|
|
530
|
+
? prev
|
|
531
|
+
: { ...prev, output: painted, elapsedMs: Date.now() - startedAt }))
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
setExecPanel(prev => (prev === undefined || prev.status !== 'running'
|
|
535
|
+
? prev
|
|
536
|
+
: { ...prev, elapsedMs: Date.now() - startedAt }))
|
|
537
|
+
}, 250)
|
|
538
|
+
execTimer.current = timer
|
|
539
|
+
/** True when the unary fallback answered (no exit code was observed then). */
|
|
540
|
+
let fallbackUsed = false
|
|
541
|
+
/** Cancel: no refresh storm while the user is just stopping a run. */
|
|
542
|
+
let cancelledByUser = false
|
|
543
|
+
try {
|
|
544
|
+
let result: RemoteExecResult
|
|
545
|
+
let output = ''
|
|
546
|
+
try {
|
|
547
|
+
const streamed = await streamNetworkVerb(alias, repoRoot, kind, target, {
|
|
548
|
+
signal: controller.signal,
|
|
549
|
+
onData: (_stream, text) => {
|
|
550
|
+
execLog.current = appendExecOutput(execLog.current, text)
|
|
551
|
+
},
|
|
552
|
+
})
|
|
553
|
+
result = streamed.result
|
|
554
|
+
output = streamed.output
|
|
555
|
+
execLog.current = output
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (controller.signal.aborted) throw error
|
|
558
|
+
// Fall back ONLY when the route itself is absent (older Host: 404/405
|
|
559
|
+
// before any output). A stream that broke AFTER the push started must
|
|
560
|
+
// not be retried unary — that would run `git push` a second time.
|
|
561
|
+
const routeMissing = error instanceof RemoteApiError
|
|
562
|
+
&& (error.status === 404 || error.status === 405)
|
|
563
|
+
&& execLog.current === ''
|
|
564
|
+
if (!routeMissing) throw error
|
|
565
|
+
fallbackUsed = true
|
|
566
|
+
output = kind === 'push' && target.refspec !== ''
|
|
567
|
+
? await pushWithRefspec(alias, repoRoot, target.remote, target.refspec)
|
|
568
|
+
: await syncRepo(alias, repoRoot, kind)
|
|
569
|
+
execLog.current = appendExecOutput('', output)
|
|
570
|
+
result = {
|
|
571
|
+
success: true,
|
|
572
|
+
// No stream → no observed exit code. Claiming 0 in the footer would be
|
|
573
|
+
// a lie; the footer wording below drops the code for this path.
|
|
574
|
+
exitCode: null,
|
|
575
|
+
timedOut: false,
|
|
576
|
+
stdout: output,
|
|
577
|
+
stderr: '',
|
|
578
|
+
durationMs: Date.now() - startedAt,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const budgetMin = Math.round(networkBudgetMs(gitTimeoutMs()) / 60_000)
|
|
582
|
+
// Keep the verb-specific success wording in the panel footer (the toast
|
|
583
|
+
// that used to carry it would cover this very footer).
|
|
584
|
+
const doneLabel = result.success
|
|
585
|
+
? kind === 'sync'
|
|
586
|
+
? t('scm.syncDone')
|
|
587
|
+
: kind === 'push' && target.refspec !== ''
|
|
588
|
+
? t('scm.pushRefspecDone').replace('{refspec}', target.refspec)
|
|
589
|
+
: ''
|
|
590
|
+
: ''
|
|
591
|
+
const exitText = fallbackUsed
|
|
592
|
+
? `${t('exec.unaryResult')} · ${formatElapsed(result.durationMs)}`
|
|
593
|
+
: result.timedOut
|
|
594
|
+
? `${t('exec.timedOut')} · ${budgetMin} min`
|
|
595
|
+
: `${doneLabel === '' ? '' : `${doneLabel} · `}${t('exec.exitCode')} ${result.exitCode ?? '?'} · ${formatElapsed(result.durationMs)}${
|
|
596
|
+
result.error === undefined ? '' : ` · ${result.error}`
|
|
597
|
+
}`
|
|
598
|
+
setExecPanel(prev => (prev === undefined
|
|
599
|
+
? prev
|
|
600
|
+
: {
|
|
601
|
+
...prev,
|
|
602
|
+
output: execLog.current,
|
|
603
|
+
elapsedMs: Date.now() - startedAt,
|
|
604
|
+
status: result.success ? 'done' : 'error',
|
|
605
|
+
exitText,
|
|
606
|
+
}))
|
|
607
|
+
// The panel IS the feedback now: a toast would land exactly on the panel's
|
|
608
|
+
// exit-code footer (toast z-index 100001 vs panel 45) — only failures
|
|
609
|
+
// still raise one.
|
|
610
|
+
if (!result.success) toast(result.timedOut ? t('exec.timedOut') : t('exec.failed'), true)
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (controller.signal.aborted) {
|
|
613
|
+
cancelledByUser = true
|
|
614
|
+
setExecPanel(prev => (prev === undefined
|
|
615
|
+
? prev
|
|
616
|
+
: { ...prev, output: execLog.current, status: 'cancelled', elapsedMs: Date.now() - startedAt }))
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
setExecPanel(prev => (prev === undefined
|
|
620
|
+
? prev
|
|
621
|
+
: {
|
|
622
|
+
...prev,
|
|
623
|
+
output: execLog.current,
|
|
624
|
+
status: 'error',
|
|
625
|
+
elapsedMs: Date.now() - startedAt,
|
|
626
|
+
exitText: `${t('exec.unknownResult')} · ${messageOf(error)}`,
|
|
627
|
+
}))
|
|
628
|
+
toast(t('exec.unknownResult'), true)
|
|
629
|
+
} finally {
|
|
630
|
+
clearInterval(timer)
|
|
631
|
+
execTimer.current = undefined
|
|
632
|
+
execAbort.current = undefined
|
|
633
|
+
setBusy(undefined)
|
|
634
|
+
// A cancel already stopped the command; a full status+extras refresh would
|
|
635
|
+
// only pile three more remote git runs onto the user's intent.
|
|
636
|
+
if (!cancelledByUser) await refreshAll()
|
|
637
|
+
}
|
|
408
638
|
}
|
|
409
639
|
|
|
410
640
|
const saveStash = async (): Promise<void> => {
|
|
@@ -540,6 +770,14 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
|
|
|
540
770
|
|
|
541
771
|
return (
|
|
542
772
|
<div className="rvs-pane grow">
|
|
773
|
+
{execPanel !== undefined && (
|
|
774
|
+
<ExecPanel
|
|
775
|
+
state={execPanel}
|
|
776
|
+
onCancel={cancelNetwork}
|
|
777
|
+
onClose={() => { setExecPanel(undefined) }}
|
|
778
|
+
t={t}
|
|
779
|
+
/>
|
|
780
|
+
)}
|
|
543
781
|
<div className="rvs-header">
|
|
544
782
|
<span className="rvs-title">{t('scm.title')}</span>
|
|
545
783
|
{store.gitBusy && <MiniSpinner />}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { REMOTE_API } from '../../protocol.ts'
|
|
11
11
|
import type { GitBranch, GitCommitFile, GitRemote, GitStash, GitStatusDetail, RemoteExecResult, RemoteRead } from '../../protocol.ts'
|
|
12
|
-
import { assertRef, shQuote } from '../../git-format.ts'
|
|
12
|
+
import { assertRef, messageCommand, parseMessage, shQuote } from '../../git-format.ts'
|
|
13
13
|
import {
|
|
14
14
|
addRemoteCommand,
|
|
15
15
|
allBranchesCommand,
|
|
@@ -18,13 +18,16 @@ import {
|
|
|
18
18
|
checkoutCommand,
|
|
19
19
|
cleanUntrackedCommand,
|
|
20
20
|
commitCommand,
|
|
21
|
+
commitFileDiffCommand,
|
|
21
22
|
commitFilesCommand,
|
|
23
|
+
commitPatchCommand,
|
|
22
24
|
createBranchCommand,
|
|
23
25
|
deleteBranchCommand,
|
|
24
26
|
diffCommand,
|
|
25
27
|
fetchCommand,
|
|
26
28
|
fileAtRevCommand,
|
|
27
29
|
gitErrorHint,
|
|
30
|
+
GIT_CAP,
|
|
28
31
|
headMessageCommand,
|
|
29
32
|
mergeCommand,
|
|
30
33
|
parseAllBranches,
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
pullCommand,
|
|
38
41
|
publishBranchCommand,
|
|
39
42
|
pushCommand,
|
|
43
|
+
pushRefspecCommand,
|
|
40
44
|
remotesCommand,
|
|
41
45
|
removeRemoteCommand,
|
|
42
46
|
renameBranchCommand,
|
|
@@ -58,7 +62,8 @@ import {
|
|
|
58
62
|
unstageCommand,
|
|
59
63
|
} from '../../git-ops.ts'
|
|
60
64
|
import { api, gitTimeoutMs } from '../api.ts'
|
|
61
|
-
import {
|
|
65
|
+
import { appendExecOutput, networkBudgetMs, networkCommands, type PushTarget } from '../exec-output.ts'
|
|
66
|
+
import { timeoutSeconds } from '../../timeouts.ts'
|
|
62
67
|
|
|
63
68
|
/** Error carrying a message already fit for the UI. */
|
|
64
69
|
export class GitOpError extends Error {}
|
|
@@ -76,12 +81,15 @@ export function joinRemote(dir: string, rel: string): string {
|
|
|
76
81
|
* These were fixed (120 s normal / 300 s network) and were the reason a large
|
|
77
82
|
* remote repository reported "git timed out" while git was still working: the
|
|
78
83
|
* explorer's own client deadline cut in even earlier. Both now follow the
|
|
79
|
-
* configured `gitTimeoutMs` (default 300 s), and the network verbs get
|
|
80
|
-
*
|
|
81
|
-
*
|
|
84
|
+
* configured `gitTimeoutMs` (default 300 s), and the network verbs get at least
|
|
85
|
+
* `DEFAULT_NETWORK_TIMEOUT_MS` (20 min) — or double the configured budget when
|
|
86
|
+
* that is larger — capped by the Host route's 30-minute ceiling. A push waits
|
|
87
|
+
* for the far end's hooks and pack transfer, so the old 10-minute cap killed
|
|
88
|
+
* healthy Gerrit pushes.
|
|
82
89
|
*/
|
|
83
90
|
const normalTimeoutMs = (): number => gitTimeoutMs()
|
|
84
|
-
|
|
91
|
+
/** One formula for the network verbs, shared with the streamed path. */
|
|
92
|
+
const longTimeoutMs = (): number => networkBudgetMs(gitTimeoutMs())
|
|
85
93
|
|
|
86
94
|
/** The readable error for a failed exec result (`run` and the strict probe share it). */
|
|
87
95
|
function failureOf(result: RemoteExecResult, timeoutMs: number): GitOpError {
|
|
@@ -271,10 +279,18 @@ export async function loadCommitFiles(alias: string, root: string, hash: string)
|
|
|
271
279
|
return parseCommitFiles(out.stdout)
|
|
272
280
|
}
|
|
273
281
|
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
/**
|
|
283
|
+
* Diff of one path inside a commit (the editor drill-down).
|
|
284
|
+
*
|
|
285
|
+
* `origPath` is the rename source: `git show -M -- <old> <new>` needs BOTH
|
|
286
|
+
* paths or git cannot pair them and a 100% rename renders as a fully-added
|
|
287
|
+
* file. A root commit has no `^` revision — `git show <hash>` already diffs
|
|
288
|
+
* against the empty tree, and the failed old-side read degrades to ''.
|
|
289
|
+
*/
|
|
290
|
+
export async function loadCommitFileDiff(alias: string, root: string, hash: string, path: string, origPath?: string): Promise<DiffPayload> {
|
|
291
|
+
const paths = origPath !== undefined && origPath !== path ? [origPath, path] : [path]
|
|
292
|
+
const out = await run(alias, commitFileDiffCommand(root, hash, paths), 90_000)
|
|
293
|
+
const oldText = await tryRun(alias, fileAtRevCommand(root, `${hash}^`, origPath ?? path))
|
|
278
294
|
const newText = await tryRun(alias, fileAtRevCommand(root, hash, path))
|
|
279
295
|
return {
|
|
280
296
|
diff: out.stdout,
|
|
@@ -284,6 +300,28 @@ export async function loadCommitFileDiff(alias: string, root: string, hash: stri
|
|
|
284
300
|
}
|
|
285
301
|
}
|
|
286
302
|
|
|
303
|
+
/** Raw message of one commit, split into its subject line and the body. */
|
|
304
|
+
export async function loadCommitMessage(alias: string, root: string, hash: string): Promise<{ subject: string; body: string }> {
|
|
305
|
+
const out = await run(alias, messageCommand(root, hash), 60_000)
|
|
306
|
+
return parseMessage(out.stdout)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Whole patch of one commit (the commit header is stripped: starts at
|
|
311
|
+
* `diff --git`).
|
|
312
|
+
*
|
|
313
|
+
* `truncated` answers whether the read hit `commitPatchCommand`'s BYTE cap.
|
|
314
|
+
* The cap is applied with `head -c` (bytes) while the string length counts
|
|
315
|
+
* UTF-16 code units, so the comparison must measure BYTES too — a CJK-heavy
|
|
316
|
+
* patch capped at 2,000,000 bytes is only ~667k characters, and a char-length
|
|
317
|
+
* check silently reported "complete" for a cut patch.
|
|
318
|
+
*/
|
|
319
|
+
export async function loadCommitPatch(alias: string, root: string, hash: string): Promise<{ text: string; truncated: boolean }> {
|
|
320
|
+
const out = await run(alias, commitPatchCommand(root, hash), 90_000)
|
|
321
|
+
const bytes = typeof TextEncoder === 'undefined' ? out.stdout.length : new TextEncoder().encode(out.stdout).length
|
|
322
|
+
return { text: out.stdout, truncated: bytes >= GIT_CAP }
|
|
323
|
+
}
|
|
324
|
+
|
|
287
325
|
/** Local + remote-tracking branches. */
|
|
288
326
|
export async function loadBranches(alias: string, root: string, current: string): Promise<GitBranch[]> {
|
|
289
327
|
const out = await run(alias, allBranchesCommand(root), 60_000)
|
|
@@ -374,6 +412,51 @@ export async function syncRepo(alias: string, root: string, kind: 'fetch' | 'pul
|
|
|
374
412
|
return `${pulled}\n${pushed}`.trim()
|
|
375
413
|
}
|
|
376
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Push to an explicit remote with an explicit refspec (Gerrit review flow:
|
|
417
|
+
* `HEAD:refs/for/<branch>`). Same timeout budget as the other network verbs.
|
|
418
|
+
*/
|
|
419
|
+
export async function pushWithRefspec(alias: string, root: string, remote: string, refspec: string): Promise<string> {
|
|
420
|
+
const out = await run(alias, pushRefspecCommand(root, remote, refspec), longTimeoutMs())
|
|
421
|
+
return `${out.stdout}\n${out.stderr}`.trim()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface NetworkStreamHandlers {
|
|
425
|
+
onData?: (stream: 'out' | 'err', text: string) => void
|
|
426
|
+
signal?: AbortSignal
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Run one network verb over the STREAMING exec route and accumulate its log.
|
|
431
|
+
*
|
|
432
|
+
* Stops at the first failing step (a sync whose pull failed must not push a
|
|
433
|
+
* half-merged tree). The caller owns the live panel; this returns only once the
|
|
434
|
+
* whole verb has settled, with `output` capped to its tail.
|
|
435
|
+
*/
|
|
436
|
+
export async function streamNetworkVerb(
|
|
437
|
+
alias: string,
|
|
438
|
+
root: string,
|
|
439
|
+
kind: 'fetch' | 'pull' | 'push' | 'sync',
|
|
440
|
+
push: PushTarget,
|
|
441
|
+
handlers: NetworkStreamHandlers = {},
|
|
442
|
+
): Promise<{ result: RemoteExecResult; output: string }> {
|
|
443
|
+
const timeoutMs = networkBudgetMs(gitTimeoutMs())
|
|
444
|
+
let output = ''
|
|
445
|
+
let result: RemoteExecResult | undefined
|
|
446
|
+
for (const command of networkCommands(root, kind, push)) {
|
|
447
|
+
result = await api.execStream(alias, command, timeoutMs, {
|
|
448
|
+
...(handlers.signal === undefined ? {} : { signal: handlers.signal }),
|
|
449
|
+
onData: (stream, text) => {
|
|
450
|
+
output = appendExecOutput(output, text)
|
|
451
|
+
handlers.onData?.(stream, text)
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
if (!result.success) break
|
|
455
|
+
if (kind === 'sync') output = appendExecOutput(output, '\n')
|
|
456
|
+
}
|
|
457
|
+
return { result: result as RemoteExecResult, output }
|
|
458
|
+
}
|
|
459
|
+
|
|
377
460
|
/** Branch verbs. */
|
|
378
461
|
export async function branchOp(
|
|
379
462
|
alias: string,
|