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.
Files changed (44) hide show
  1. package/README.en.md +13 -10
  2. package/README.md +14 -9
  3. package/cordis.patch.yml +10 -5
  4. package/lib/client.js +3319 -1093
  5. package/lib/index.js +211 -9
  6. package/lib/types/client/api.d.ts +12 -0
  7. package/lib/types/client/composer.d.ts +5 -1
  8. package/lib/types/client/exec-output.d.ts +58 -0
  9. package/lib/types/client/i18n.d.ts +1 -1
  10. package/lib/types/client/scm/CommitPane.d.ts +73 -0
  11. package/lib/types/client/scm/ExecPanel.d.ts +34 -0
  12. package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
  13. package/lib/types/client/scm/actions.d.ts +50 -3
  14. package/lib/types/client/scm/commit-model.d.ts +119 -0
  15. package/lib/types/client/store.d.ts +9 -3
  16. package/lib/types/client/vsc/kit.d.ts +16 -0
  17. package/lib/types/git-format.d.ts +5 -0
  18. package/lib/types/git-ops.d.ts +57 -2
  19. package/lib/types/index.d.ts +5 -0
  20. package/lib/types/pool.d.ts +9 -0
  21. package/lib/types/protocol.d.ts +23 -0
  22. package/lib/types/timeouts.d.ts +17 -2
  23. package/package.json +37 -37
  24. package/src/client/SettingsHosts.tsx +2 -1
  25. package/src/client/api.ts +61 -0
  26. package/src/client/composer.ts +11 -2
  27. package/src/client/exec-output.ts +129 -0
  28. package/src/client/i18n.ts +133 -4
  29. package/src/client/scm/CommitPane.tsx +726 -0
  30. package/src/client/scm/ExecPanel.tsx +124 -0
  31. package/src/client/scm/HistoryPanel.tsx +483 -104
  32. package/src/client/scm/ScmPanel.tsx +247 -9
  33. package/src/client/scm/actions.ts +93 -10
  34. package/src/client/scm/commit-model.ts +317 -0
  35. package/src/client/store.tsx +13 -6
  36. package/src/client/vsc/kit.tsx +71 -0
  37. package/src/client/vsc/vsc.css +647 -0
  38. package/src/git-format.ts +7 -3
  39. package/src/git-ops.ts +83 -7
  40. package/src/index.ts +10 -2
  41. package/src/pool.ts +84 -4
  42. package/src/protocol.ts +14 -0
  43. package/src/routes.ts +135 -1
  44. 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
- if (kind === 'push' || kind === 'sync') {
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: kind === 'push' ? t('scm.pushTitle') : t('scm.syncTitle'),
399
- text: kind === 'push' ? t('scm.pushText') : t('scm.syncText'),
400
- okText: kind === 'push' ? t('scm.push') : t('scm.sync'),
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
- await guard(kind, async () => {
405
- const output = await syncRepo(alias, repoRoot, kind)
406
- toast(`${t('scm.syncDone')} ${output.split('\n').slice(-1)[0] ?? ''}`.trim())
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 { DEFAULT_GIT_TIMEOUT_MS, MAX_TIMEOUT_MS, timeoutSeconds } from '../../timeouts.ts'
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 double
80
- * that (capped by the Host route's ceiling), because fetch/pull/push on a big
81
- * tree legitimately outlast a status read.
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
- const longTimeoutMs = (): number => Math.min(MAX_TIMEOUT_MS, Math.max(DEFAULT_GIT_TIMEOUT_MS, gitTimeoutMs() * 2))
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
- /** Diff of one path inside a commit (used by the history file drill-down). */
275
- export async function loadCommitFileDiff(alias: string, root: string, hash: string, path: string): Promise<DiffPayload> {
276
- const out = await run(alias, diffCommand(root, { ref: `${hash}^`, path }), 90_000)
277
- const oldText = await tryRun(alias, fileAtRevCommand(root, `${hash}^`, path))
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,