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
2 changes: 2 additions & 0 deletions .agents/skills/mops-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ mops migrate freeze backend # specify canister explicitly

When `[canisters.<name>.migrations]` is configured, `mops check`, `mops build`, and `mops check-stable` automatically inject `--enhanced-migration`. Do not add `--enhanced-migration` to `[canisters.<name>].args` when using managed migrations — mops will error.

`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`.

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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ target/
dist/
.dfx/
.mops/
.migrations-*/
mops.lock

.DS_Store
2 changes: 2 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Mops CLI Changelog

## Next
- 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`
- `[canisters.<name>.migrations]` now requires `chain` and `next` to share the same parent directory (any layout where the parents differed is rejected with a clear error). The default layout `chain = "migrations"` + `next = "next-migration"` already satisfies this. For per-canister setups, use sibling subdirectories, e.g. `chain = "src/backend/migrations"` + `next = "src/backend/next-migration"`

## 2.11.0
- Add `mops migrate new <Name>` and `mops migrate freeze` commands for managing enhanced migration chains
Expand Down
21 changes: 17 additions & 4 deletions cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,16 +282,29 @@ async function applyInit({
await template("github-workflow:mops-test");
}

// add .mops to .gitignore
// add mops-managed paths to .gitignore
{
let gitignore = path.join(process.cwd(), ".gitignore");
let gitignoreData = existsSync(gitignore)
? readFileSync(gitignore).toString()
: "";
let lf = gitignoreData.endsWith("\n") ? "\n" : "";
const additions: string[] = [];
if (!gitignoreData.includes(".mops")) {
writeFileSync(gitignore, `${gitignoreData}\n.mops${lf}`.trimStart());
console.log(chalk.green("Added"), ".mops to .gitignore");
additions.push(".mops");
}
if (!gitignoreData.includes(".migrations-")) {
additions.push(".migrations-*/");
}
if (additions.length > 0) {
let lf = gitignoreData.endsWith("\n") ? "\n" : "";
writeFileSync(
gitignore,
`${gitignoreData}\n${additions.join("\n")}${lf}`.trimStart(),
);
console.log(
chalk.green("Added"),
`${additions.join(", ")} to .gitignore`,
);
}
}

Expand Down
34 changes: 29 additions & 5 deletions cli/helpers/migrations.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { existsSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
import { join, resolve } from "node:path";
import {
existsSync,
mkdirSync,
readdirSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { rm } from "node:fs/promises";
import chalk from "chalk";
import { cliError } from "../error.js";
import { resolveConfigPath } from "../mops.js";
import { getRootDir, resolveConfigPath } from "../mops.js";
import { MigrationsConfig } from "../types.js";

const MIGRATIONS_TEMP_DIR = ".mops/.migrations";
function stagedMigrationsDir(chainDir: string, canisterName: string): string {
return join(dirname(chainDir), `.migrations-${canisterName}`);
}

export interface MigrationArgsResult {
migrationArgs: string[];
Expand Down Expand Up @@ -70,6 +78,21 @@ export function validateMigrationsConfig(
);
}
}
if (migrations.next) {
const parentOf = (p: string) => dirname(resolve(getRootDir(), p));
const chainParent = parentOf(migrations.chain);
const nextParent = parentOf(migrations.next);
if (chainParent !== nextParent) {
cliError(
`[canisters.${canisterName}.migrations] "chain" and "next" must live in the same parent directory.\n` +
` chain = "${migrations.chain}" (parent: ${chainParent})\n` +
` next = "${migrations.next}" (parent: ${nextParent})\n` +
"Place them in the same parent directory, e.g.:\n" +
' chain = "migrations"\n' +
' next = "next-migration"',
);
}
}
}

export async function prepareMigrationArgs(
Expand Down Expand Up @@ -130,9 +153,10 @@ export async function prepareMigrationArgs(
};
}

const tempDir = join(MIGRATIONS_TEMP_DIR, canisterName);
const tempDir = stagedMigrationsDir(chainDir, canisterName);
await rm(tempDir, { recursive: true, force: true });
mkdirSync(tempDir, { recursive: true });
writeFileSync(join(tempDir, ".gitignore"), "*\n");

const filesToInclude = isTrimming
? allMigrations.slice(-limit)
Expand Down
14 changes: 7 additions & 7 deletions cli/tests/__snapshots__/migrate.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ actor {

exports[`migrate build build-limit counts next migration as part of the chain 1`] = `
"// Version: 4.0.0
type V2__933402648 = {a : Nat; name : Text};
type V3__40989327 = {a : Nat; email : Text; name : Text};
type V4__364034734 = {email : Text; id : Nat; name : Text};
{
"20250301_000000_AddEmail" :
(old : {a : Nat; name : Text}) -> {a : Nat; email : Text; name : Text};
"20250401_000000_RenameId" :
(old : {a : Nat; email : Text; name : Text}) ->
{email : Text; id : Nat; name : Text}
"20250301_000000_AddEmail" : (old : V2__933402648) -> V3__40989327;
"20250401_000000_RenameId" : (old : V3__40989327) -> V4__364034734
}
actor {
stable email : Text;
Expand Down Expand Up @@ -75,10 +75,10 @@ exports[`migrate check check with trimming shows reduced chain 1`] = `
"stdout": "check Using --all-libs for richer diagnostics
migrations Prepared 2 migration(s) for backend (trimmed from 3)
check Checking canister backend:
<CACHE>moc-wrapper ["src/main.mo","--check","--all-libs","--default-persistent-actors","--enhanced-migration=.mops/.migrations/backend","-A=M0254"]
<CACHE>moc-wrapper ["src/main.mo","--check","--all-libs","--default-persistent-actors","--enhanced-migration=.migrations-backend","-A=M0254"]
✓ backend
check-stable Generating stable types for src/main.mo
<CACHE>moc-wrapper ["--stable-types","-o",".mops/.check-stable/new.wasm","src/main.mo","--default-persistent-actors","--enhanced-migration=.mops/.migrations/backend","-A=M0254"]
<CACHE>moc-wrapper ["--stable-types","-o",".mops/.check-stable/new.wasm","src/main.mo","--default-persistent-actors","--enhanced-migration=.migrations-backend","-A=M0254"]
check-stable Comparing deployed.most ↔ .mops/.check-stable/new.most
<CACHE>moc-wrapper ["--stable-compatible","deployed.most",".mops/.check-stable/new.most"]
✓ Stable compatibility check passed for canister 'backend'",
Expand Down
27 changes: 27 additions & 0 deletions cli/tests/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,33 @@ describe("migrate", () => {
});
});

describe("sibling validation", () => {
async function patchNextDir(cwd: string, nextValue: string): Promise<void> {
const tomlPath = path.join(cwd, "mops.toml");
const toml = readFileSync(tomlPath, "utf-8");
await writeFile(
tomlPath,
toml.replace('next = "next-migration"', `next = "${nextValue}"`),
);
}

test("errors from `mops check` when chain and next have different parents", async () => {
const cwd = await makeTempFixture("with-next");
await patchNextDir(cwd, "other/next-migration");
const result = await cli(["check"], { cwd });
expect(result.exitCode).toBe(1);
expect(result.stderr).toMatch(/same parent directory/i);
});

test("errors from `mops migrate new` too — validation runs in every entry point", async () => {
const cwd = await makeTempFixture("basic");
await patchNextDir(cwd, "other/next-migration");
const result = await cli(["migrate", "new", "Test"], { cwd });
expect(result.exitCode).toBe(1);
expect(result.stderr).toMatch(/same parent directory/i);
});
});

describe("conflict detection", () => {
test("errors when both [migrations] and --enhanced-migration in args", async () => {
const cwd = await makeTempFixture("basic");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import State "../types/State";

module {
public func migration(_ : {}) : { a : Nat } {
public func migration(_ : State.V0) : State.V1 {
{ a = 0 };
};
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import State "../types/State";

module {
public func migration(old : { a : Nat }) : { a : Nat; name : Text } {
public func migration(old : State.V1) : State.V2 {
{ old with name = "" };
};
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import State "../types/State";

module {
public func migration(old : { a : Nat; name : Text }) : {
a : Nat;
name : Text;
email : Text;
} {
public func migration(old : State.V2) : State.V3 {
{ old with email = "" };
};
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import State "../types/State";

module {
public func migration(old : { a : Nat; name : Text; email : Text }) : {
id : Nat;
name : Text;
email : Text;
} {
public func migration(old : State.V3) : State.V4 {
{ id = old.a; name = old.name; email = old.email };
};
};
7 changes: 7 additions & 0 deletions cli/tests/migrate/with-next/types/State.mo
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module {
public type V0 = {};
public type V1 = { a : Nat };
public type V2 = { a : Nat; name : Text };
public type V3 = { a : Nat; name : Text; email : Text };
public type V4 = { id : Nat; name : Text; email : Text };
};
10 changes: 7 additions & 3 deletions docs/docs/09-mops.toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ Multi-canister example with per-canister flags:
main = "src/backend/main.mo"

[canisters.backend.migrations]
chain = "migrations/backend"
next = "next-migration/backend"
chain = "migrations/backend/chain"
next = "migrations/backend/next"

[canisters.frontend]
main = "src/frontend/main.mo"
Expand Down Expand Up @@ -146,7 +146,7 @@ Configure managed enhanced migration chains for a canister. When set, `mops chec
| Field | Description |
| ----------- | --------------------------------------------------------------- |
| chain | Path to the directory containing frozen migration files (required) |
| next | Path to the directory for the next pending migration (optional). Required for `mops migrate new/freeze`. Must contain 0 or 1 `.mo` files |
| next | Path to the directory for the next pending migration (optional). Required for `mops migrate new/freeze`. Must contain 0 or 1 `.mo` files. Must share the same parent directory as `chain` |
| check-limit | Max number of migrations to pass to `moc` during `mops check` and `mops check-stable` (optional). Counts the full chain including any pending next migration |
| build-limit | Max number of migrations to pass to `moc` during `mops build` (optional). Counts the full chain including any pending next migration |

Expand All @@ -165,6 +165,10 @@ Migration files must be named so they sort lexicographically in the correct orde
When `[migrations]` is configured, do not add `--enhanced-migration` to `[canisters.<name>].args` — mops manages this flag automatically.
:::

:::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`.
:::

Shorthand — when only the entrypoint is needed:
```toml
[canisters]
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/cli/00-mops-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ When accepted, adds `.github/workflows/mops-test.yml` that runs `mops test` on p
5. **`LICENSE`** (and `NOTICE` for Apache-2.0) — package only, filled with the current year and copyright owner.
6. **`README.md`** — package only, with placeholders replaced by the package name.
7. **`.github/workflows/mops-test.yml`** — when the workflow prompt was accepted.
8. **`.mops`** appended to `.gitignore` (created if missing).
8. **`.mops`** and **`.migrations-*/`** appended to `.gitignore` (created if missing).

Existing `LICENSE`, `README.md`, and workflow files are not overwritten.

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 @@ -59,6 +59,8 @@ check-limit = 1
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`.

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

## Typical workflow
Expand Down
Loading