Skip to content
2 changes: 2 additions & 0 deletions .agents/skills/mops-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ When `[canisters.<name>.migrations]` is configured, `mops check`, `mops build`,

`chain` and `next` must live in the same parent directory. Migration files can import from sibling folders via relative paths (e.g. shared types in `src/backend/types/`); mops stages the active chain into `<parent-of-chain>/.migrations-<canister>/`, preserving depth so relative imports resolve identically. The staged dir self-stamps a `.gitignore`; `mops init` also adds `.migrations-*/` to the project `.gitignore`.

`moc` diagnostics may print paths under `.migrations-<canister>/` — a staging dir that mops removes when the command finishes. The real file has the same name under `chain/` or `next/`.

Typical workflow: make a breaking change → `mops check` fails with a hint → `mops migrate new Name` → edit migration → `mops check` passes → `mops build` → deploy → `mops migrate freeze`.

### `mops remove <package>`
Expand Down
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Mops CLI Changelog

## Next
- `mops check`/`build`/`check-stable` skip migration staging when only the pending `next` migration is needed, so `moc` diagnostics reference the real `next-migration/<file>` path.

## 2.12.0
- Migration staging directory moved from `.mops/.migrations/<canister>/` to `<parent-of-chain>/.migrations-<canister>/`, so migration files can import shared modules from sibling folders (e.g. a `types/` folder next to `migrations/`) — relative imports now resolve to the same target whether moc reads the original chain dir or the staged one. The staged dir self-stamps a `.gitignore` so it doesn't pollute `git status`; `mops init` now also adds `.migrations-*/` to the project `.gitignore`
Expand Down
10 changes: 10 additions & 0 deletions cli/helpers/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ export async function prepareMigrationArgs(
};
}

// Shortcut: when only the pending next migration is needed (empty chain or
// trimmed to 1), point moc at next-migration/ so diagnostics use the real path.
if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) {
const migrationArgs = [`--enhanced-migration=${nextDir}`];
if (isTrimming) {
migrationArgs.push("-A=M0254");
}
return { migrationArgs, cleanup: async () => {} };
}

const tempDir = stagedMigrationsDir(chainDir, canisterName);
await rm(tempDir, { recursive: true, force: true });
mkdirSync(tempDir, { recursive: true });
Expand Down
36 changes: 36 additions & 0 deletions cli/tests/__snapshots__/migrate.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,42 @@ check-stable Comparing deployed.most ↔ .mops/.check-stable/new.most
}
`;

exports[`migrate check check-limit=1 with pending next reports real next-migration path on error 1`] = `
{
"exitCode": 1,
"stderr": "next-migration/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050], literal of type
Text
does not have expected type
Nat
✗ Check failed for canister backend (exit code: 1)",
"stdout": "",
}
`;

exports[`migrate check empty chain with pending next reports real next-migration path on error 1`] = `
{
"exitCode": 1,
"stderr": "next-migration/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050], literal of type
Text
does not have expected type
Nat
✗ Check failed for canister backend (exit code: 1)",
"stdout": "",
}
`;

exports[`migrate check error inside a chain migration reports its file location 1`] = `
{
"exitCode": 1,
"stderr": ".migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050], literal of type
Nat
does not have expected type
Text
✗ Check failed for canister backend (exit code: 1)",
"stdout": "",
}
`;

exports[`migrate migrate freeze moves the file from next to chain 1`] = `
{
"exitCode": 0,
Expand Down
45 changes: 45 additions & 0 deletions cli/tests/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,51 @@ describe("migrate", () => {
await patchMigrations(cwd, "check-limit = 2");
await cliSnapshot(["check", "--verbose"], { cwd }, 0);
});

test("error inside a chain migration reports its file location", async () => {
const cwd = await makeTempFixture("with-next");
await writeFile(
path.join(cwd, "migrations", "20250301_000000_AddEmail.mo"),
'import State "../types/State";\n' +
"module {\n" +
" public func migration(old : State.V2) : State.V3 {\n" +
" { old with email = 42 };\n" +
" };\n" +
"};\n",
);
await cliSnapshot(["check"], { cwd }, 1);
});

async function corruptNextMigration(cwd: string): Promise<void> {
const nextDir = path.join(cwd, "next-migration");
const nextFile = readdirSync(nextDir).find((f) => f.endsWith(".mo"))!;
await writeFile(
path.join(nextDir, nextFile),
'import State "../types/State";\n' +
"module {\n" +
" public func migration(old : State.V3) : State.V4 {\n" +
' { id = "wrong"; name = old.name; email = old.email };\n' +
" };\n" +
"};\n",
);
}

test("check-limit=1 with pending next reports real next-migration path on error", async () => {
const cwd = await makeTempFixture("with-next");
await patchMigrations(cwd, "check-limit = 1");
await corruptNextMigration(cwd);
await cliSnapshot(["check"], { cwd }, 1);
});

test("empty chain with pending next reports real next-migration path on error", async () => {
const cwd = await makeTempFixture("with-next");
const chainDir = path.join(cwd, "migrations");
for (const f of readdirSync(chainDir).filter((f) => f.endsWith(".mo"))) {
await rm(path.join(chainDir, f));
}
await corruptNextMigration(cwd);
await cliSnapshot(["check"], { cwd }, 1);
});
});

describe("build", () => {
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/09-mops.toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ When `[migrations]` is configured, do not add `--enhanced-migration` to `[canist

:::note
When a `next` migration exists or chain trimming is active, mops stages the active chain into `<parent-of-chain>/.migrations-<canister>/` for compilation. This keeps the staged files at the same depth as the originals so relative imports (e.g. a shared `types/` folder next to `chain` and `next`) resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`.

`moc` diagnostics may point to a staged path under `.migrations-<canister>/`, which mops removes when the command finishes.
:::

Shorthand — when only the entrypoint is needed:
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/cli/4-dev/08-mops-migrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ build-limit = 100

`chain` and `next` must live in the same parent directory. Migration files can import from sibling folders (e.g. a shared `types/` folder) using relative paths — mops stages the active chain into `<parent-of-chain>/.migrations-<canister>/` for compilation, preserving the depth of the originals so relative imports resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`.

`moc` diagnostics may point to a staged path under `.migrations-<canister>/`, which mops removes when the command finishes.

See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields.

## Typical workflow
Expand Down
Loading