Skip to content

Commit c089a3f

Browse files
feat(ocm-cli): add granular push progress with phase reporting and upload tracking
1 parent b268a1d commit c089a3f

4 files changed

Lines changed: 152 additions & 10 deletions

File tree

ocm-cli/src/manager-api.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ export interface MirrorBundleResult {
5252
created: false
5353
}
5454

55+
function createByteCounter(onProgress: (bytesSent: number) => void): TransformStream<Uint8Array, Uint8Array> {
56+
let bytesSent = 0
57+
return new TransformStream<Uint8Array, Uint8Array>({
58+
transform(chunk, controller) {
59+
bytesSent += chunk.byteLength
60+
onProgress(bytesSent)
61+
controller.enqueue(chunk)
62+
},
63+
})
64+
}
65+
5566
export class ManagerApiError extends Error {
5667
constructor(
5768
message: string,
@@ -161,11 +172,16 @@ export class ManagerApi {
161172
return (await res.json()) as MirrorPatchResult
162173
}
163174

164-
async mirrorUploadBundle(repoId: number, bundlePath: string, opts: { branch: string | null; force?: boolean }): Promise<MirrorBundleResult> {
175+
async mirrorUploadBundle(
176+
repoId: number,
177+
bundlePath: string,
178+
opts: { branch: string | null; force?: boolean; onProgress?: (bytesSent: number) => void },
179+
): Promise<MirrorBundleResult> {
165180
const query = opts.force === true ? '?force=1' : ''
166181
const headers: Record<string, string> = { ...this.headers(), 'Content-Type': 'application/octet-stream' }
167182
if (opts.branch) headers['X-OCM-Branch'] = opts.branch
168-
const body = Readable.toWeb(createReadStream(bundlePath)) as unknown as ReadableStream<Uint8Array>
183+
const fileStream = Readable.toWeb(createReadStream(bundlePath)) as unknown as ReadableStream<Uint8Array>
184+
const body = opts.onProgress ? fileStream.pipeThrough(createByteCounter(opts.onProgress)) : fileStream
169185
const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle${query}`, {
170186
method: 'POST',
171187
headers,

ocm-cli/src/mirror.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,17 @@ function applyPatch(repoRoot: string, patch: string): void {
379379

380380
async function createLocalBundle(repoRoot: string): Promise<string> {
381381
const bundlePath = join(tmpdir(), `ocm-bundle-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`)
382-
runGit(repoRoot, ['bundle', 'create', bundlePath, '--all'])
382+
await new Promise<void>((resolve, reject) => {
383+
const child = spawn('git', ['bundle', 'create', bundlePath, '--all'], { cwd: repoRoot })
384+
const stderrChunks: Buffer[] = []
385+
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
386+
child.on('error', reject)
387+
child.on('close', (code) => {
388+
if (code === 0) return resolve()
389+
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim()
390+
reject(new Error(`git bundle create failed${stderr ? `: ${stderr}` : ''}`))
391+
})
392+
})
383393
return bundlePath
384394
}
385395

@@ -428,14 +438,34 @@ async function writeBundleStream(repoId: number, api: ManagerApi): Promise<strin
428438
return bundlePath
429439
}
430440

441+
export type MirrorUpFastPhase =
442+
| { kind: 'bundling' }
443+
| { kind: 'uploading'; bytesSent: number; totalBytes: number }
444+
| { kind: 'processing' }
445+
| { kind: 'patching' }
446+
431447
export async function mirrorUpFast(
432448
plan: MirrorPlan,
433-
opts: Pick<MirrorUpOpts, 'api' | 'force'>,
449+
opts: Pick<MirrorUpOpts, 'api' | 'force'> & { onPhase?: (phase: MirrorUpFastPhase) => void },
434450
): Promise<{ repoId: number; branch: string | null; head: string | null; created: false }> {
435451
const repoId = plan.matched[0]!.repoId
452+
const onPhase = opts.onPhase
453+
onPhase?.({ kind: 'bundling' })
436454
const bundlePath = await createLocalBundle(plan.repoRoot)
437455
try {
438-
await opts.api.mirrorUploadBundle(repoId, bundlePath, { branch: getBranchName(plan.repoRoot), force: opts.force })
456+
const { size } = await fsp.stat(bundlePath)
457+
onPhase?.({ kind: 'uploading', bytesSent: 0, totalBytes: size })
458+
await opts.api.mirrorUploadBundle(repoId, bundlePath, {
459+
branch: getBranchName(plan.repoRoot),
460+
force: opts.force,
461+
onProgress: onPhase
462+
? (bytesSent) => {
463+
onPhase({ kind: 'uploading', bytesSent, totalBytes: size })
464+
if (bytesSent >= size) onPhase({ kind: 'processing' })
465+
}
466+
: undefined,
467+
})
468+
onPhase?.({ kind: 'patching' })
439469
const patchResult = await mirrorUpPatch(plan, opts)
440470
return { repoId: patchResult.repoId, branch: patchResult.branch, head: patchResult.head, created: false }
441471
} finally {

ocm-cli/src/tui-plugin.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { getToken } from './keychain.js'
44
import { fetchRepos, toRemoteRepoSummaries } from './manager-repos.js'
55
import { ManagerApi, ManagerApiError } from './manager-api.js'
66
import { prepareMirror, checkPushDivergence, mirrorUpFast } from './mirror.js'
7-
import type { MirrorPlan } from './mirror.js'
7+
import type { MirrorPlan, MirrorUpFastPhase } from './mirror.js'
8+
import { formatBytes } from './progress.js'
89
import { transferSession, moveReminderText } from './session-move.js'
910
import { createManagerReplay, createManagerPromptAsync } from './remote-replay.js'
1011
import { readSessionEvents } from './local-history.js'
@@ -43,6 +44,31 @@ function showInstallNotice(api: TuiPluginApi): void {
4344
})
4445
}
4546

47+
function pushPhaseMessage(phase: MirrorUpFastPhase): string {
48+
switch (phase.kind) {
49+
case 'bundling':
50+
return 'Pushing repo state: creating git bundle…'
51+
case 'uploading':
52+
return `Pushing repo state: uploading ${formatBytes(phase.bytesSent)} / ${formatBytes(phase.totalBytes)}…`
53+
case 'processing':
54+
return 'Pushing repo state: waiting for server to import bundle…'
55+
case 'patching':
56+
return 'Pushing repo state: applying local changes…'
57+
}
58+
}
59+
60+
function createPushPhaseToaster(api: TuiPluginApi): (phase: MirrorUpFastPhase) => void {
61+
let lastUploadToastAt = 0
62+
return (phase) => {
63+
if (phase.kind === 'uploading') {
64+
const now = Date.now()
65+
if (now - lastUploadToastAt < 1000) return
66+
lastUploadToastAt = now
67+
}
68+
api.ui.toast({ message: pushPhaseMessage(phase) })
69+
}
70+
}
71+
4672
async function runSessionMove(api: TuiPluginApi): Promise<void> {
4773
try {
4874
const current = api.route.current
@@ -103,9 +129,12 @@ async function runSessionMove(api: TuiPluginApi): Promise<void> {
103129
if (!(error instanceof ManagerApiError && error.status === 404)) throw error
104130
}
105131

106-
api.ui.toast({ message: 'Pushing repo state…' })
107132
const selectedPlan: MirrorPlan = { ...plan, matched: [matched] }
108-
await mirrorUpFast(selectedPlan, { api: managerApi, force: false })
133+
await mirrorUpFast(selectedPlan, {
134+
api: managerApi,
135+
force: false,
136+
onPhase: createPushPhaseToaster(api),
137+
})
109138

110139
const result = await transferSession(
111140
{ sessionID, localRoot: plan.repoRoot, remoteDirectory },

ocm-cli/test/mirror.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, readFileSync } from 'fs'
2+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, readFileSync, statSync } from 'fs'
33
import { join } from 'path'
44
import { tmpdir } from 'os'
55
import { randomBytes } from 'crypto'
66
import { spawnSync, execSync } from 'child_process'
7-
import { prepareMirror, MirrorAbort, mirrorDown, mirrorUp, mirrorUpPatch, mirrorUpFast, checkPushDivergence, checkPullDivergence } from '../src/mirror'
7+
import { prepareMirror, MirrorAbort, mirrorDown, mirrorUp, mirrorUpPatch, mirrorUpFast, checkPushDivergence, checkPullDivergence, type MirrorUpFastPhase } from '../src/mirror'
88
import { getBranchName } from '../src/local-repo'
99
import { gitRemoteProjectId } from '@opencode-manager/shared/project-id'
1010

@@ -818,6 +818,73 @@ describe('mirrorUpFast targets the selected repo', () => {
818818
expect(result.repoId).toBe(99)
819819
})
820820

821+
it('reports bundling, uploading, and patching phases in order', async () => {
822+
const repoRoot = join(tmpDir, 'repo-phases')
823+
mkdirSync(repoRoot)
824+
spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' })
825+
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' })
826+
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' })
827+
writeFileSync(join(repoRoot, 'a.txt'), 'a\n')
828+
spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' })
829+
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoRoot, stdio: 'ignore' })
830+
831+
const api = {
832+
mirrorUploadBundle: vi.fn().mockResolvedValue(undefined),
833+
mirrorPatch: vi.fn().mockResolvedValue({ repoId: 1, fullPath: '/tmp/x', branch: 'main', head: 'abc', created: false, applied: true }),
834+
}
835+
836+
const plan = {
837+
repoRoot,
838+
localProjectId: 'proj',
839+
matched: [{ repoId: 1, name: 'repo-A', projectId: 'proj', branch: 'main' }],
840+
}
841+
842+
const phases: MirrorUpFastPhase[] = []
843+
await mirrorUpFast(plan, { api: api as any, force: false, onPhase: (p) => phases.push(p) })
844+
845+
expect(phases.map((p) => p.kind)).toEqual(['bundling', 'uploading', 'patching'])
846+
const uploading = phases[1] as Extract<MirrorUpFastPhase, { kind: 'uploading' }>
847+
expect(uploading.bytesSent).toBe(0)
848+
expect(uploading.totalBytes).toBeGreaterThan(0)
849+
})
850+
851+
it('emits cumulative upload progress and a processing phase when the api reports sent bytes', async () => {
852+
const repoRoot = join(tmpDir, 'repo-upload-progress')
853+
mkdirSync(repoRoot)
854+
spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' })
855+
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' })
856+
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' })
857+
writeFileSync(join(repoRoot, 'a.txt'), 'a\n')
858+
spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' })
859+
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoRoot, stdio: 'ignore' })
860+
861+
const api = {
862+
mirrorUploadBundle: vi.fn().mockImplementation(
863+
async (_repoId: number, bundlePath: string, opts: { onProgress?: (bytesSent: number) => void }) => {
864+
const { size } = statSync(bundlePath)
865+
opts.onProgress?.(Math.floor(size / 2))
866+
opts.onProgress?.(size)
867+
},
868+
),
869+
mirrorPatch: vi.fn().mockResolvedValue({ repoId: 1, fullPath: '/tmp/x', branch: 'main', head: 'abc', created: false, applied: true }),
870+
}
871+
872+
const plan = {
873+
repoRoot,
874+
localProjectId: 'proj',
875+
matched: [{ repoId: 1, name: 'repo-A', projectId: 'proj', branch: 'main' }],
876+
}
877+
878+
const phases: MirrorUpFastPhase[] = []
879+
await mirrorUpFast(plan, { api: api as any, force: false, onPhase: (p) => phases.push(p) })
880+
881+
expect(phases.map((p) => p.kind)).toEqual(['bundling', 'uploading', 'uploading', 'uploading', 'processing', 'patching'])
882+
const sent = phases.filter((p): p is Extract<MirrorUpFastPhase, { kind: 'uploading' }> => p.kind === 'uploading').map((p) => p.bytesSent)
883+
expect(sent[0]).toBe(0)
884+
expect(sent[1]!).toBeGreaterThan(0)
885+
expect(sent[2]!).toBeGreaterThan(sent[1]!)
886+
})
887+
821888
it('narrows a multi-match plan so bundle goes to the chosen repo', async () => {
822889
const repoRoot = join(tmpDir, 'repo-narrow')
823890
mkdirSync(repoRoot)

0 commit comments

Comments
 (0)