Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion scripts/v18-to-v19/V18MigrationApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { runV18ToV19Migration, type V18MigrationCommandReport } from './V18Migra
import type V18MigrationExecutionMode from './V18MigrationExecutionMode.ts';
import type V18MigrationGraph from './V18MigrationGraph.ts';
import type { V18MigrationProgress } from './V18MigrationProgress.ts';
import V18MigrationProgressCoalescer from './V18MigrationProgressCoalescer.ts';
import type { V18MigrationPreflight } from './V18MigrationPreflight.ts';
import {
renderV18MigrationApp,
Expand Down Expand Up @@ -50,19 +51,24 @@ export async function runV18MigrationApp(
): Promise<V18MigrationAppResult> {
let result: V18MigrationAppResult = Object.freeze({ status: 'cancelled' });
const execute: Cmd<V18MigrationAppMsg> = async (emit) => {
const progress = new V18MigrationProgressCoalescer((update) => {
emit(Object.freeze({ progress: update, type: 'progress' }));
});
try {
const report = await runV18ToV19Migration({
graph: options.graph.name,
mode: options.mode,
progress: (progress) => emit(Object.freeze({ progress, type: 'progress' })),
progress: (update) => progress.report(update),
repositoryPath: options.repositoryPath,
...(options.passphrase === undefined ? {} : { passphrase: options.passphrase }),
...(options.recoveryId === undefined ? {} : { recoveryId: options.recoveryId }),
...(options.scratchRoot === undefined ? {} : { scratchRoot: options.scratchRoot }),
});
progress.flushBestEffort();
result = Object.freeze({ report, status: 'completed' });
return Object.freeze({ report, type: 'succeeded' });
} catch (error: unknown) {
progress.flushBestEffort();
result = Object.freeze({ error, status: 'failed' });
return Object.freeze({
error,
Expand Down
7 changes: 6 additions & 1 deletion scripts/v18-to-v19/V18MigrationAppSurface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,16 @@ function migrationLines(
if (model.phase === 'succeeded' && model.report !== undefined) {
const report = model.report;
lines.push(
{ text: `Migration ${report.status}.`, token: success },
{ text: 'Migration completed successfully.', token: success },
{ text: `Status: ${report.status}`, token: success },
{ text: `Writers: ${String(report.plan.writers.length)}`, token: body },
{
text: `Scratch verified: ${report.scratchVerified ? 'yes' : 'no'}`,
token: body,
},
{
text: `Authoritative refs changed: ${report.finalization === null ? 'no' : 'yes'}`,
token: body,
}
);
if (report.finalization !== null) {
Expand Down
17 changes: 7 additions & 10 deletions scripts/v18-to-v19/V18MigrationPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
import { V18MigrationGitObjectReader } from './V18MigrationGitObjectReader.ts';
import {
reportV18MigrationProgress,
shouldReportV18CommitProgress,
type V18MigrationProgressReporter,
} from './V18MigrationProgress.ts';

Expand Down Expand Up @@ -192,15 +191,13 @@ async function planWriter(
}
previous = sha;
completed += 1;
if (shouldReportV18CommitProgress(completed, commits.length)) {
reportV18MigrationProgress(options.progress, {
completed,
message: 'validating writer chain',
phase: 'inventory',
total: commits.length,
writer,
});
}
reportV18MigrationProgress(options.progress, {
completed,
message: 'validating writer chain',
phase: 'inventory',
total: commits.length,
writer,
});
}
if (previous !== head) {
throw new Error(`writer chain ${options.refName} did not inventory to its head`);
Expand Down
6 changes: 0 additions & 6 deletions scripts/v18-to-v19/V18MigrationProgress.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
const COMMIT_PROGRESS_INTERVAL = 250;

export type V18MigrationPhase = 'finalize' | 'inventory' | 'rewrite' | 'scratch' | 'verify';

export type V18MigrationProgress = Readonly<{
Expand All @@ -19,10 +17,6 @@ export function reportV18MigrationProgress(
reporter?.(Object.freeze({ ...progress }));
}

export function shouldReportV18CommitProgress(completed: number, total: number): boolean {
return completed === total || completed % COMMIT_PROGRESS_INTERVAL === 0;
}

/** Convert bounded work counts into the percentage expected by Bijou. */
export function v18MigrationProgressPercent(completed: number, total: number): number {
return total === 0 ? 100 : Math.min(100, Math.max(0, (completed / total) * 100));
Expand Down
99 changes: 99 additions & 0 deletions scripts/v18-to-v19/V18MigrationProgressCoalescer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { V18MigrationProgress, V18MigrationProgressReporter } from './V18MigrationProgress.ts';

export const V18_MIGRATION_PROGRESS_RENDER_INTERVAL_MS = 100;

/**
* Preserves per-item progress truth while bounding host rendering work.
*
* The first event is delivered immediately. During each render window, the
* newest event replaces older pending events. Phase, writer, and message
* transitions flush the prior stream so named migration steps are never
* hidden. Flush delivers the final pending event synchronously and cancels
* scheduled work before a terminal result.
*/
export default class V18MigrationProgressCoalescer {
readonly #intervalMs: number;
readonly #reporter: V18MigrationProgressReporter;
#hasStream = false;
#message: string | null = null;
#phase: V18MigrationProgress['phase'] | null = null;
#pending: V18MigrationProgress | null = null;
#timer: ReturnType<typeof setTimeout> | null = null;
#writer: string | undefined;

constructor(
reporter: V18MigrationProgressReporter,
intervalMs: number = V18_MIGRATION_PROGRESS_RENDER_INTERVAL_MS
) {
if (!Number.isSafeInteger(intervalMs) || intervalMs <= 0) {
throw new RangeError('migration progress render interval must be a positive integer');
}
this.#intervalMs = intervalMs;
this.#reporter = reporter;
}

report(progress: V18MigrationProgress): void {
if (this.#changesStream(progress)) {
this.#finishWindow();
}
this.#hasStream = true;
this.#message = progress.message;
this.#phase = progress.phase;
this.#writer = progress.writer;
this.#pending = progress;
if (this.#timer === null) {
this.#deliverPending();
this.#scheduleWindow();
}
}

flush(): void {
this.#finishWindow();
}

flushBestEffort(): void {
try {
this.flush();
} catch {
// Terminal progress rendering must not replace the migration outcome.
}
}

#changesStream(progress: V18MigrationProgress): boolean {
return (
this.#hasStream
&& (
progress.message !== this.#message
|| progress.phase !== this.#phase
|| progress.writer !== this.#writer
)
);
}

#finishWindow(): void {
if (this.#timer !== null) {
clearTimeout(this.#timer);
this.#timer = null;
}
this.#deliverPending();
}

#deliverPending(): void {
const progress = this.#pending;
if (progress === null) {
return;
}
this.#pending = null;
this.#reporter(progress);
}

#scheduleWindow(): void {
this.#timer = setTimeout(() => {
this.#timer = null;
if (this.#pending !== null) {
this.#deliverPending();
this.#scheduleWindow();
}
}, this.#intervalMs);
}
}
21 changes: 21 additions & 0 deletions scripts/v18-to-v19/V18MigrationReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { V18MigrationCommandReport } from './V18MigrationCommand.ts';

/** Format the durable operator report printed after terminal UI teardown. */
export function formatV18MigrationReport(report: V18MigrationCommandReport): string {
const lines = [
'git-warp v18-to-v19 migration completed successfully',
`status: ${report.status}`,
`repository: ${report.plan.repositoryPath}`,
`graph: ${report.plan.graph}`,
`writers: ${String(report.plan.writers.length)}`,
`scratch verified: ${report.scratchVerified ? 'yes' : 'no'}`,
`authoritative refs changed: ${report.finalization === null ? 'no' : 'yes'}`,
];
if (report.finalization !== null) {
lines.push(`recovery refs: ${report.finalization.recoveryPrefix}`);
}
if (report.status === 'verified-dry-run') {
lines.push('no authoritative refs changed; a later promotion reruns the migration');
}
return `${lines.join('\n')}\n`;
}
87 changes: 34 additions & 53 deletions scripts/v18-to-v19/V18WriterChainRewriter.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import type { V18CommitIdentity, V18PatchCommit } from './V18PatchCommit.ts';
import { readV18PatchCommit } from './V18PatchCommit.ts';
import {
readV18MigrationRef,
v18MigrationGitText,
} from './V18MigrationGit.ts';
import { readV18MigrationRef, v18MigrationGitText } from './V18MigrationGit.ts';
import type { V18MigrationGitCommitWriter } from './V18MigrationGitCommitWriter.ts';
import type { V18MigrationGitObjectReader } from './V18MigrationGitObjectReader.ts';
import V18PatchTranslator from './V18PatchTranslator.ts';
import {
reportV18MigrationProgress,
shouldReportV18CommitProgress,
type V18MigrationProgressReporter,
} from './V18MigrationProgress.ts';

Expand All @@ -23,17 +19,19 @@ export type V18WriterChainRewrite = Readonly<{
}>;

/** Recreates one writer chain in order, translating only legacy patch payloads. */
export async function rewriteV18WriterChain(options: Readonly<{
commitWriter?: V18MigrationGitCommitWriter;
commitMap?: Map<string, string>;
graph: string;
objectReader?: V18MigrationGitObjectReader;
progress?: V18MigrationProgressReporter;
refName: string;
repositoryPath: string;
translator: V18PatchTranslator;
writer: string;
}>): Promise<V18WriterChainRewrite> {
export async function rewriteV18WriterChain(
options: Readonly<{
commitWriter?: V18MigrationGitCommitWriter;
commitMap?: Map<string, string>;
graph: string;
objectReader?: V18MigrationGitObjectReader;
progress?: V18MigrationProgressReporter;
refName: string;
repositoryPath: string;
translator: V18PatchTranslator;
writer: string;
}>
): Promise<V18WriterChainRewrite> {
const oldHead = await readV18MigrationRef(options.repositoryPath, options.refName);
if (oldHead === null) {
throw new Error(`writer ref disappeared: ${options.refName}`);
Expand All @@ -51,16 +49,13 @@ export async function rewriteV18WriterChain(options: Readonly<{
writer: options.writer,
});
for (const sha of commits) {
const patch = await readV18PatchCommit(
options.repositoryPath,
sha,
options.objectReader,
);
const patch = await readV18PatchCommit(options.repositoryPath, sha, options.objectReader);
requirePatchIdentity(patch, options.graph, options.writer);
requireLinearParent(patch, previousOld);
const translated = patch.storage.kind === 'current'
? { message: patch.commit.message, tree: patch.commit.tree }
: await options.translator.translate(patch);
const translated =
patch.storage.kind === 'current'
? { message: patch.commit.message, tree: patch.commit.tree }
: await options.translator.translate(patch);
if (patch.storage.kind !== 'current') {
translatedCount += 1;
}
Expand All @@ -70,7 +65,7 @@ export async function rewriteV18WriterChain(options: Readonly<{
translated.tree,
translated.message,
previousNew,
options.commitWriter,
options.commitWriter
);
if (patch.storage.kind === 'current' && previousNew === previousOld && newSha !== sha) {
throw new Error(`current commit ${sha} was not recreated byte-identically`);
Expand All @@ -79,15 +74,13 @@ export async function rewriteV18WriterChain(options: Readonly<{
previousNew = newSha;
options.commitMap?.set(sha, newSha);
completed += 1;
if (shouldReportV18CommitProgress(completed, commits.length)) {
reportV18MigrationProgress(options.progress, {
completed,
message: 'translating writer chain',
phase: 'rewrite',
total: commits.length,
writer: options.writer,
});
}
reportV18MigrationProgress(options.progress, {
completed,
message: 'translating writer chain',
phase: 'rewrite',
total: commits.length,
writer: options.writer,
});
}
if (previousNew === null) {
throw new Error(`writer ref has no commits: ${options.refName}`);
Expand All @@ -110,34 +103,22 @@ export async function rewriteV18WriterChain(options: Readonly<{

async function listWriterCommits(
repositoryPath: string,
refName: string,
refName: string
): Promise<readonly string[]> {
const output = await v18MigrationGitText(repositoryPath, [
'rev-list',
'--reverse',
refName,
]);
const output = await v18MigrationGitText(repositoryPath, ['rev-list', '--reverse', refName]);
return Object.freeze(output === '' ? [] : output.split('\n').filter(Boolean));
}

function requirePatchIdentity(
patch: V18PatchCommit,
graph: string,
writer: string,
): void {
function requirePatchIdentity(patch: V18PatchCommit, graph: string, writer: string): void {
if (patch.graph !== graph || patch.writer !== writer) {
throw new Error(
`commit ${patch.commit.sha} identity does not match ${graph}/${writer}`,
);
throw new Error(`commit ${patch.commit.sha} identity does not match ${graph}/${writer}`);
}
}

function requireLinearParent(patch: V18PatchCommit, previousOld: string | null): void {
const parent = patch.commit.parents[0] ?? null;
if (parent !== previousOld) {
throw new Error(
`writer commit ${patch.commit.sha} does not form one complete linear chain`,
);
throw new Error(`writer commit ${patch.commit.sha} does not form one complete linear chain`);
}
}

Expand All @@ -147,7 +128,7 @@ async function createCommit(
tree: string,
message: string,
parent: string | null,
commitWriter?: V18MigrationGitCommitWriter,
commitWriter?: V18MigrationGitCommitWriter
): Promise<string> {
if (commitWriter !== undefined) {
return await commitWriter.writeCommit({
Expand All @@ -170,7 +151,7 @@ async function createCommit(

function commitEnvironment(
author: V18CommitIdentity,
committer: V18CommitIdentity,
committer: V18CommitIdentity
): Readonly<Record<string, string>> {
return Object.freeze({
GIT_AUTHOR_NAME: author.name,
Expand Down
Loading
Loading