Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
103 changes: 103 additions & 0 deletions scripts/v18-to-v19/V18MigrationProgressCoalescer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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) {
try {
this.#deliverPending();
} catch {
// Scheduled progress rendering must not terminate the migration.
}
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