Skip to content

Commit 910d4d5

Browse files
authored
Merge pull request #708 from campfirein/feat/add-migration-hint
feat: add VC-sync hint after successful migrate
2 parents 30bc4f0 + be740ea commit 910d4d5

2 files changed

Lines changed: 163 additions & 17 deletions

File tree

src/oclif/commands/migrate.ts

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,49 @@ public static flags = {
6060
}),
6161
}
6262

63+
// Visibility: `protected` so unit tests can exercise the gate
64+
// combinations without spinning up the daemon transport.
65+
protected displayForwardResult(report: MigrateRunReport, format: string, dryRun: boolean): void {
66+
if (format === 'json') {
67+
this.log(JSON.stringify(report, null, 2))
68+
return
69+
}
70+
71+
this.log(summarizeReportLine(report))
72+
if (report.summary.failed > 0) {
73+
this.warn(
74+
`${report.summary.failed} file(s) failed — sources moved to the archive at ${report.archiveRoot ?? '(none)'}`,
75+
)
76+
for (const f of report.files) {
77+
if (f.outcome === 'failed') {
78+
this.warn(` - ${f.sourceRelPath}: ${f.reason ?? '(no reason)'}`)
79+
}
80+
}
81+
}
82+
83+
// VC-sync hint: text mode only (JSON consumers parse the envelope),
84+
// skip when nothing actually changed on disk (dry-run, or `migrated=0`
85+
// which means no .md topics were eligible). Also skip on partial
86+
// failure (`failed > 0`) — the run exits 2 below, so "successfully
87+
// migrated" wording would contradict the exit code and might nudge
88+
// users to push a tree the command itself reports as failed.
89+
if (!dryRun && report.summary.migrated > 0 && report.summary.failed === 0) {
90+
this.logVcSyncHint()
91+
}
92+
}
93+
94+
// Stderr (not stdout) so the hint stays out of pipes like
95+
// `brv migrate | grep migrated`; logToStderr (not `this.warn`)
96+
// because it's a tip, not a warning.
97+
protected logVcSyncHint(): void {
98+
this.logToStderr('')
99+
this.logToStderr('Tip: the context tree was successfully migrated. Sync the new HTML topics to ByteRover cloud:')
100+
this.logToStderr(' brv vc status')
101+
this.logToStderr(' brv vc add . && brv vc commit -m "Migrate context tree to HTML"')
102+
this.logToStderr(' brv vc push')
103+
this.logToStderr('(Run `brv vc remote add origin <url>` first if no remote is configured.)')
104+
}
105+
63106
public async run(): Promise<void> {
64107
const {flags} = await this.parse(Migrate)
65108
// Project resolution mirrors `status` / `vc`:
@@ -163,29 +206,14 @@ public static flags = {
163206
this.emitError(format, formatConnectionError(error))
164207
}
165208

166-
const {report} = response
167-
if (format === 'json') {
168-
this.log(JSON.stringify(report, null, 2))
169-
} else {
170-
this.log(summarizeReportLine(report))
171-
if (report.summary.failed > 0) {
172-
this.warn(
173-
`${report.summary.failed} file(s) failed — sources moved to the archive at ${report.archiveRoot ?? '(none)'}`,
174-
)
175-
for (const f of report.files) {
176-
if (f.outcome === 'failed') {
177-
this.warn(` - ${f.sourceRelPath}: ${f.reason ?? '(no reason)'}`)
178-
}
179-
}
180-
}
181-
}
209+
this.displayForwardResult(response.report, format, dryRun)
182210

183211
// Force termination with exit 2 on failure. The daemon-client tail
184212
// (Socket.IO reconnect timers) keeps the event loop alive, so
185213
// letting Node exit naturally with `process.exitCode = 2` doesn't
186214
// work — the loop never drains. Match the Python oracle's
187215
// behavior (lines 2021-2031): hard-exit with the failure code.
188-
if (report.summary.failed > 0) {
216+
if (response.report.summary.failed > 0) {
189217
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
190218
process.exit(2)
191219
}

test/commands/migrate.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type {Config} from '@oclif/core'
2+
3+
import {Config as OclifConfig} from '@oclif/core'
4+
import {expect} from 'chai'
5+
import {restore, stub} from 'sinon'
6+
7+
import type {MigrateRunReport} from '../../src/shared/transport/events/migrate-events.js'
8+
9+
import Migrate from '../../src/oclif/commands/migrate.js'
10+
11+
// Tests the gate logic in displayForwardResult that controls when the
12+
// VC-sync hint fires. Daemon transport is bypassed by calling the
13+
// `protected` display method directly with a synthetic report — the
14+
// gate decisions are pure functions of (format, dryRun, summary) and
15+
// don't need a live daemon round-trip.
16+
17+
class TestableMigrate extends Migrate {
18+
public exerciseDisplay(report: MigrateRunReport, format: string, dryRun: boolean): void {
19+
return this.displayForwardResult(report, format, dryRun)
20+
}
21+
}
22+
23+
function makeReport(overrides: {failed?: number; migrated?: number;} = {}): MigrateRunReport {
24+
return {
25+
archiveRoot: '/tmp/archive',
26+
completedAt: '2026-05-26T00:00:00.000Z',
27+
dryRun: false,
28+
files: [],
29+
projectRoot: '/tmp/proj',
30+
startedAt: '2026-05-26T00:00:00.000Z',
31+
summary: {
32+
archived: 0,
33+
failed: overrides.failed ?? 0,
34+
migrated: overrides.migrated ?? 0,
35+
skipped: 0,
36+
},
37+
}
38+
}
39+
40+
describe('brv migrate — VC-sync hint gate', () => {
41+
let config: Config
42+
let stdout: string[]
43+
let stderr: string[]
44+
let warnings: string[]
45+
46+
before(async () => {
47+
config = await OclifConfig.load(import.meta.url)
48+
})
49+
50+
beforeEach(() => {
51+
stdout = []
52+
stderr = []
53+
warnings = []
54+
})
55+
56+
afterEach(() => {
57+
restore()
58+
})
59+
60+
function buildCommand(): TestableMigrate {
61+
const cmd = new TestableMigrate([], config)
62+
stub(cmd, 'log').callsFake((msg?: string) => {
63+
if (msg !== undefined) stdout.push(msg)
64+
})
65+
stub(cmd, 'logToStderr').callsFake((msg?: string) => {
66+
if (msg !== undefined) stderr.push(msg)
67+
})
68+
stub(cmd, 'warn').callsFake((msg: Error | string) => {
69+
warnings.push(typeof msg === 'string' ? msg : msg.message)
70+
return msg
71+
})
72+
return cmd
73+
}
74+
75+
it('text + real + migrated>0 + failed=0: prints the hint on stderr', () => {
76+
const cmd = buildCommand()
77+
cmd.exerciseDisplay(makeReport({failed: 0, migrated: 5}), 'text', false)
78+
79+
const stderrJoined = stderr.join('\n')
80+
expect(stderrJoined).to.include('Tip: the context tree was successfully migrated')
81+
expect(stderrJoined).to.include('brv vc status')
82+
expect(stderrJoined).to.include('brv vc add')
83+
expect(stderrJoined).to.include('brv vc push')
84+
expect(stderrJoined).to.include('brv vc remote add origin')
85+
})
86+
87+
it('text + dry-run + migrated>0: does NOT print the hint', () => {
88+
const cmd = buildCommand()
89+
cmd.exerciseDisplay(makeReport({failed: 0, migrated: 5}), 'text', true)
90+
91+
expect(stderr.join('\n')).to.not.include('Tip:')
92+
})
93+
94+
it('text + real + migrated=0: does NOT print the hint', () => {
95+
const cmd = buildCommand()
96+
cmd.exerciseDisplay(makeReport({failed: 0, migrated: 0}), 'text', false)
97+
98+
expect(stderr.join('\n')).to.not.include('Tip:')
99+
})
100+
101+
it('text + real + migrated>0 + failed>0: does NOT print the hint (exit-code contradiction guard)', () => {
102+
const cmd = buildCommand()
103+
cmd.exerciseDisplay(makeReport({failed: 2, migrated: 5}), 'text', false)
104+
105+
// Warnings about failures still fire — only the success hint is suppressed.
106+
expect(warnings.join('\n')).to.include('2 file(s) failed')
107+
expect(stderr.join('\n')).to.not.include('Tip:')
108+
})
109+
110+
it('json + real + migrated>0: emits the JSON envelope on stdout, nothing on stderr', () => {
111+
const cmd = buildCommand()
112+
cmd.exerciseDisplay(makeReport({failed: 0, migrated: 5}), 'json', false)
113+
114+
expect(stdout).to.have.lengthOf(1)
115+
expect(() => JSON.parse(stdout[0])).to.not.throw()
116+
expect(stderr).to.have.lengthOf(0)
117+
})
118+
})

0 commit comments

Comments
 (0)