diff --git a/docs/rust.md b/docs/rust.md index 3aefbcd2..66d75e2c 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -9,6 +9,7 @@ See [configuration.md](./configuration.md) for the full config reference. ## What's supported - `Cargo.toml` version bumping driven by conventional commits +- `Cargo.lock` sync on every bump: the crate's own lock, and every other committed lock whose workspace reaches the crate's directory through path dependencies (`workspace = true` included), is updated in the same release commit, without running cargo. Gitignored locks are left alone, and so are other crates of the same name (every `tauri init` app is `app`) - Per-crate git tags (via `version.packageSpecificTags`) - Publishing to crates.io with `cargo publish` - Workspace dependency ordering via topological sort (Kahn's algorithm) so path-dependent crates are published in the correct sequence @@ -22,6 +23,7 @@ See [configuration.md](./configuration.md) for the full config reference. - OIDC / trusted publishing for crates.io — token-based auth only via `CARGO_REGISTRY_TOKEN` - Automatic workspace root discovery — crate directories must be listed in `version.cargo.paths` or inferred from `version.packages` +- `[patch]`, in `Cargo.toml` or `.cargo/config.toml`, when syncing `Cargo.lock` — a lock whose workspace reaches a bumped crate only through a patch keeps the old version, without an error, and `cargo build --locked` there fails until you run `cargo update --workspace` in that workspace and commit the lock - Comment and formatting preservation in `Cargo.toml` — the file is rewritten on version bump using `smol-toml`'s serialiser; whitespace, comments, and key order may change --- @@ -207,6 +209,15 @@ jobs: ## Edge cases and troubleshooting +**`Failed to sync Cargo.lock: …`** + +releasekit edits each lock's text the way cargo would, and fails the version step (`CARGO_LOCK_SYNC_ERROR`: exit code 8 from `release` and `standing-pr`, 1 from `releasekit version`) rather than leave a lock behind. The message names the lock and the reason: + +- `it also has from another source`: the lock also lists the crate from a registry or git source at the old or the new version, so cargo would have to change how it tells the two apart. Sync that lock with cargo yourself (`cargo update` with the new version in `Cargo.toml`) and commit it, or drop the second source of the crate. +- `it has path entries named `: the lock holds several local crates of that name, so releasekit can't tell which one is the bumped crate. Sync that lock with cargo yourself. +- `its format (version = ) is newer than releasekit can edit`: releasekit edits lock formats up to v4. Sync that lock with cargo yourself. +- A file-system error such as `EACCES`: the lock couldn't be read or written. + **`error: failed to verify package tarball`** `cargo publish` runs a pre-publish verification step that builds the crate from the packaged tarball. This can fail in some environments (missing system libraries, conflicting feature flags, etc.). As a workaround, set `publish.cargo.noVerify: true` to pass `--no-verify` — but note that this skips all pre-publish checks, so it is best treated as a temporary measure rather than a permanent setting. diff --git a/packages/core/package.json b/packages/core/package.json index 5ec6c41d..ff8a3766 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,7 +27,8 @@ "dependencies": { "@manypkg/get-packages": "catalog:", "chalk": "catalog:", - "minimatch": "catalog:" + "minimatch": "catalog:", + "smol-toml": "catalog:" }, "devDependencies": { "@biomejs/biome": "catalog:", diff --git a/packages/core/src/cargo.ts b/packages/core/src/cargo.ts index e7f5e95f..3ac042d7 100644 --- a/packages/core/src/cargo.ts +++ b/packages/core/src/cargo.ts @@ -1,49 +1,394 @@ import fs from 'node:fs'; import path from 'node:path'; +import * as TOML from 'smol-toml'; -/** A `Cargo.toml` declares a workspace when it has a `[workspace]` (or `[workspace.*]`) table. */ -function isWorkspaceManifest(cargoTomlPath: string): boolean { +interface CargoWorkspace { + members?: unknown; + exclude?: unknown; +} + +type Table = Record; +type ReadManifest = (cargoTomlPath: string) => Table | undefined; + +function readManifest(cargoTomlPath: string): Table | undefined { try { - return /^\s*\[workspace[\].]/m.test(fs.readFileSync(cargoTomlPath, 'utf8')); + return TOML.parse(fs.readFileSync(cargoTomlPath, 'utf8')); } catch { - return false; + return undefined; + } +} + +const asTable = (value: unknown): Table => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Table) : {}; + +// Mirrors cargo's `is_excluded`, which matches `exclude` and `members` as literal path prefixes (no globs). +function isExcluded(workspace: CargoWorkspace, rootDir: string, manifestPath: string): boolean { + const covers = (entries: unknown): boolean => + Array.isArray(entries) && + entries.some((entry) => { + const prefix = typeof entry === 'string' ? path.resolve(rootDir, entry) : undefined; + return prefix !== undefined && (manifestPath === prefix || manifestPath.startsWith(prefix + path.sep)); + }); + return covers(workspace.exclude) && !covers(workspace.members); +} + +/** + * The crate's workspace root, by cargo's rule: its `package.workspace` pointer if set, else the crate + * itself if its manifest has a `[workspace]`, else the nearest ancestor `[workspace]` that doesn't exclude + * it, else the crate. + */ +function findCargoWorkspaceRoot(crateDir: string, read: ReadManifest = readManifest): string { + const manifestPath = path.join(path.resolve(crateDir), 'Cargo.toml'); + const manifest = read(manifestPath); + const pointer = asTable(manifest?.package).workspace; + if (typeof pointer === 'string') { + return path.resolve(path.dirname(manifestPath), pointer); + } + let root = path.dirname(manifestPath); + if (!manifest?.workspace) { + for (let dir = root; path.dirname(dir) !== dir; ) { + dir = path.dirname(dir); + const workspace = read(path.join(dir, 'Cargo.toml'))?.workspace as CargoWorkspace | undefined; + if (workspace && !isExcluded(workspace, dir, manifestPath)) { + root = dir; + break; + } + } + } + return root; +} + +/** + * The Cargo.lock at the crate's workspace root. Undefined when the root has no lock: a bump syncs a + * committed lock but never creates one. + */ +export function findCargoLockfile(crateDir: string): string | undefined { + const lockPath = path.join(findCargoWorkspaceRoot(crateDir), 'Cargo.lock'); + return fs.existsSync(lockPath) ? lockPath : undefined; +} + +const DEPENDENCY_KINDS = ['dependencies', 'dev-dependencies', 'build-dependencies']; + +/** + * Directories of the path crates a lock at `lockRoot` resolves: path dependencies followed transitively, + * across workspaces, from the manifests whose workspace root is `lockRoot`. + */ +function findPathCrates(lockRoot: string, manifestDirs: string[], read: ReadManifest): Set { + const found = new Set(); + const queue = manifestDirs.filter((dir) => findCargoWorkspaceRoot(dir, read) === lockRoot); + for (let dir = queue.pop(); dir !== undefined; dir = queue.pop()) { + if (found.has(dir)) { + continue; + } + found.add(dir); + const manifest = asTable(read(path.join(dir, 'Cargo.toml'))); + const root = findCargoWorkspaceRoot(dir, read); + const inherited = asTable(asTable(asTable(read(path.join(root, 'Cargo.toml'))).workspace).dependencies); + // Cargo resolves dev-dependencies for the lock's own workspace members only. + const kinds = root === lockRoot ? DEPENDENCY_KINDS : DEPENDENCY_KINDS.filter((kind) => kind !== 'dev-dependencies'); + const tables = [manifest, ...Object.values(asTable(manifest.target)).map(asTable)].flatMap((scope) => + kinds.map((kind) => asTable(scope[kind])), + ); + for (const table of tables) { + for (const [key, value] of Object.entries(table)) { + const dependency = asTable(value); + const [base, source] = dependency.workspace === true ? [root, asTable(inherited[key])] : [dir, dependency]; + if (typeof source.path === 'string') { + queue.push(path.resolve(base, source.path)); + } + } + } + } + return found; +} + +interface CargoLockEntry { + name: string; + version: string; + start: number; + /** Last non-blank line. */ + end: number; + versionLine: number; + /** Registry and git packages carry a `source`; path and workspace packages don't. */ + hasSource: boolean; +} + +function readCargoLockEntries(lines: string[]): CargoLockEntry[] { + const entries: CargoLockEntry[] = []; + let current: (Partial & { start: number; end: number; hasSource: boolean }) | undefined; + const closeEntry = (): void => { + if (current?.name !== undefined && current.version !== undefined && current.versionLine !== undefined) { + entries.push({ + name: current.name, + version: current.version, + start: current.start, + end: current.end, + versionLine: current.versionLine, + hasSource: current.hasSource, + }); + } + }; + + for (const [index, line] of lines.entries()) { + if (line.startsWith('[')) { + closeEntry(); + current = /^\[\[package\]\]\s*$/.test(line) ? { start: index, end: index, hasSource: false } : undefined; + continue; + } + if (!current) { + continue; + } + if (line.trim() !== '') { + current.end = index; + } + const field = /^(name|version|source) = "([^"]*)"\s*$/.exec(line); + if (field?.[1] === 'name') { + current.name = field[2]; + } else if (field?.[1] === 'version') { + current.version = field[2]; + current.versionLine = index; + } else if (field?.[1] === 'source') { + current.hasSource = true; + } + } + closeEntry(); + return entries; +} + +/** The top-level `version = N` header; v1 and v2 locks have none. */ +function readCargoLockFormat(lines: string[]): number | undefined { + for (const line of lines) { + if (line.startsWith('[')) { + return undefined; + } + const header = /^version = (\d+)\s*$/.exec(line); + if (header) { + return Number(header[1]); + } + } + return undefined; +} + +function parseVersion(version: string): { core: number[]; pre: string[]; build: string } { + const plus = version.indexOf('+'); + const withoutBuild = plus === -1 ? version : version.slice(0, plus); + const dash = withoutBuild.indexOf('-'); + return { + core: (dash === -1 ? withoutBuild : withoutBuild.slice(0, dash)).split('.').map(Number), + pre: dash === -1 ? [] : withoutBuild.slice(dash + 1).split('.'), + build: plus === -1 ? '' : version.slice(plus + 1), + }; +} + +const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** Semver precedence: the order cargo keeps same-name `[[package]]` entries in. */ +function compareVersions(a: string, b: string): number { + const left = parseVersion(a); + const right = parseVersion(b); + for (let i = 0; i < 3; i++) { + if (left.core[i] !== right.core[i]) { + return left.core[i] - right.core[i]; + } + } + if (left.pre.length === 0 || right.pre.length === 0) { + return right.pre.length - left.pre.length || compareStrings(left.build, right.build); + } + for (let i = 0; i < Math.min(left.pre.length, right.pre.length); i++) { + const [x, y] = [left.pre[i], right.pre[i]]; + const [xNumeric, yNumeric] = [/^\d+$/.test(x), /^\d+$/.test(y)]; + if (xNumeric && yNumeric && x !== y) { + return x.length - y.length || compareStrings(x, y); + } + if (xNumeric !== yNumeric) { + return xNumeric ? -1 : 1; + } + if (x !== y) { + return compareStrings(x, y); + } + } + return left.pre.length - right.pre.length || compareStrings(left.build, right.build); +} + +/** Re-sort adjacent same-name entries by version, keeping blank lines in place; non-adjacent ones are left. */ +function sortSameNameEntries(lines: string[], group: Array<{ entry: CargoLockEntry; version: string }>): void { + const gaps = group.slice(1).map(({ entry }, i) => lines.slice(group[i].entry.end + 1, entry.start)); + if (gaps.some((gap) => gap.some((line) => line.trim() !== ''))) { + return; + } + const blocks = group.map(({ entry }) => lines.slice(entry.start, entry.end + 1)); + const order = group.map((_, i) => i).sort((a, b) => compareVersions(group[a].version, group[b].version)); + const region = order.flatMap((blockIndex, slot) => [...(slot === 0 ? [] : gaps[slot - 1]), ...blocks[blockIndex]]); + const first = group[0].entry.start; + lines.splice(first, group[group.length - 1].entry.end + 1 - first, ...region); +} + +/** Re-sort each run of `name` references; cargo orders them by version as a string (`10.0.0` before `2.0.0`). */ +function sortReferenceRuns(lines: string[], name: string, version: string): void { + const referenceVersion = (line: string): string | undefined => { + const reference = /^\s*"([^" ]+) ([^" ]+)[^"]*",?\s*$/.exec(line); + return reference?.[1] === name ? reference[2] : undefined; + }; + for (let i = 0; i < lines.length; i++) { + if (referenceVersion(lines[i]) !== version) { + continue; + } + let first = i; + while (first > 0 && referenceVersion(lines[first - 1]) !== undefined) { + first--; + } + let last = i; + while (last + 1 < lines.length && referenceVersion(lines[last + 1]) !== undefined) { + last++; + } + const run = lines.slice(first, last + 1); + const sorted = [...run].sort((a, b) => compareStrings(referenceVersion(a) ?? '', referenceVersion(b) ?? '')); + lines.splice(first, run.length, ...sorted); + i = last; + } +} + +/** + * Set a path crate's version in Cargo.lock content the way cargo would: its `version` line and any + * `" "` references (cargo writes the version into a reference only when the lock + * holds several versions of the name), re-sorting same-name entries if their order changes. + * + * Undefined when the lock has no path entry for the crate. Throws when the name is ambiguous (several + * path entries, or another source's entry at the old or new version) or the lock format is newer than v4. + */ +export function setCargoLockVersion(lockContent: string, crateName: string, version: string): string | undefined { + const lines = lockContent.split('\n'); + const format = readCargoLockFormat(lines); + if (format !== undefined && format > 4) { + throw new Error(`its format (version = ${format}) is newer than releasekit can edit`); + } + const sameName = readCargoLockEntries(lines).filter((entry) => entry.name === crateName); + const own = sameName.filter((entry) => !entry.hasSource); + if (own.length === 0) { + return undefined; + } + if (own.length > 1) { + throw new Error(`it has ${own.length} path entries named ${crateName}`); + } + + const [entry] = own; + if (entry.version === version) { + return lockContent; } + const clash = sameName.find((other) => other !== entry && [entry.version, version].includes(other.version)); + if (clash) { + throw new Error(`it also has ${crateName} ${clash.version} from another source`); + } + + lines[entry.versionLine] = lines[entry.versionLine].replace(`"${entry.version}"`, `"${version}"`); + if (sameName.length > 1) { + sortSameNameEntries( + lines, + sameName.map((other) => ({ entry: other, version: other === entry ? version : other.version })), + ); + } + const updated = lines + .join('\n') + .replaceAll(`"${crateName} ${entry.version}"`, `"${crateName} ${version}"`) + .split('\n'); + if (sameName.length > 1) { + sortReferenceRuns(updated, crateName, version); + } + return updated.join('\n'); +} + +/** The slice of `@releasekit/git`'s `Git` this needs; core doesn't depend on that package. */ +export interface CargoLockGit { + isRepository(cwd?: string): Promise; + lsFiles(opts?: { paths?: string[]; cwd?: string }): Promise; + isIgnored(file: string, cwd?: string): Promise; +} + +export interface CargoLockBump { + cargoTomlPath: string; + name: string; + version: string; +} + +export interface CargoLockUpdate { + lockPath: string; + content: string; + changed: boolean; } /** - * Find the `Cargo.lock` that governs a crate at `startDir` — the one `cargo update --workspace` - * rewrites — by walking up from `startDir`: + * Plan the content of every Cargo.lock that records a bumped crate, by text edit (no cargo, toolchain or + * network). Candidates are every tracked lock plus each crate's own lock unless git ignores it. * - * - If an ancestor `Cargo.toml` declares a `[workspace]`, that's the workspace root and its - * `Cargo.lock` is authoritative. A workspace member's own (stray) lock is ignored by cargo, so we - * must not target it — only the root lock actually moves. - * - Otherwise (a standalone crate, no workspace) the nearest committed `Cargo.lock` governs. + * A crate's own lock always takes its bump. Names repeat across workspaces (every `tauri init` app is + * `app`), so another lock takes it only if the lock's workspace reaches the crate's directory by path. * - * Returns `undefined` when no committed lock governs the crate. That is deliberate: libraries - * commonly don't commit a lock, and a version bump must never *create* one as a side effect — only - * sync an existing committed lock so it doesn't drift. + * Writes nothing; throws, naming the lock, when any lock can't be edited unambiguously. */ -export function findCargoLockfile(startDir: string): string | undefined { - let dir = path.resolve(startDir); - let nearestLock: string | undefined; - while (true) { - const lockPath = path.join(dir, 'Cargo.lock'); - const hasLock = fs.existsSync(lockPath); - if (hasLock && !nearestLock) { - nearestLock = lockPath; +export async function planCargoLockUpdates( + git: CargoLockGit, + cwd: string, + bumps: CargoLockBump[], +): Promise { + const lockPaths = new Set(); + const manifestDirs: string[] = []; + if (await git.isRepository(cwd)) { + for (const file of await git.lsFiles({ paths: [':(top,glob)**/Cargo.lock', ':(top,glob)**/Cargo.toml'], cwd })) { + if (path.basename(file) === 'Cargo.lock') { + lockPaths.add(path.resolve(cwd, file)); + } else { + manifestDirs.push(path.resolve(cwd, path.dirname(file))); + } } - - const cargoToml = path.join(dir, 'Cargo.toml'); - if (fs.existsSync(cargoToml) && isWorkspaceManifest(cargoToml)) { - // Workspace root: its lock is the one cargo updates (or none, if no lock is committed). - return hasLock ? lockPath : undefined; + } + const owners = new Map(); + for (const bump of bumps) { + const ownLock = findCargoLockfile(path.dirname(bump.cargoTomlPath)); + if (ownLock && !(await git.isIgnored(ownLock, cwd))) { + owners.set(ownLock, [...(owners.get(ownLock) ?? []), bump]); + lockPaths.add(ownLock); + } + } + const manifests = new Map(); + const read: ReadManifest = (cargoTomlPath) => { + if (!manifests.has(cargoTomlPath)) { + manifests.set(cargoTomlPath, readManifest(cargoTomlPath)); } + return manifests.get(cargoTomlPath); + }; - const parent = path.dirname(dir); - if (parent === dir) { - break; // reached the filesystem root + const updates: CargoLockUpdate[] = []; + for (const lockPath of lockPaths) { + // Tracked but deleted in the work tree: nothing to sync. + if (!fs.existsSync(lockPath)) { + continue; + } + const original = fs.readFileSync(lockPath, 'utf8'); + let pathCrates: Set | undefined; + let content = original; + let listed = false; + for (const bump of bumps) { + if (!owners.get(lockPath)?.includes(bump)) { + pathCrates ??= findPathCrates(path.dirname(lockPath), manifestDirs, read); + if (!pathCrates.has(path.resolve(path.dirname(bump.cargoTomlPath)))) { + continue; + } + } + let next: string | undefined; + try { + next = setCargoLockVersion(content, bump.name, bump.version); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`can't set ${bump.name} to ${bump.version} in ${lockPath}: ${detail}`); + } + if (next !== undefined) { + listed = true; + content = next; + } + } + if (listed) { + updates.push({ lockPath, content, changed: content !== original }); } - dir = parent; } - return nearestLock; + return updates; } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 2e1cb23c..73b61973 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -73,6 +73,7 @@ const CODE_TO_EXIT: Record = { GIT_ERROR: EXIT_CODES.GIT_ERROR, VERSION_ERROR: EXIT_CODES.VERSION_ERROR, PUBLISH_ERROR: EXIT_CODES.PUBLISH_ERROR, + CARGO_LOCK_SYNC_ERROR: EXIT_CODES.VERSION_ERROR, }; /** Map any thrown value to its process exit code, defaulting to GENERAL_ERROR. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a3b89e88..2d33c309 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,10 @@ -export { findCargoLockfile } from './cargo.js'; +export { + type CargoLockBump, + type CargoLockGit, + type CargoLockUpdate, + findCargoLockfile, + planCargoLockUpdates, +} from './cargo.js'; export { type ChangelogRefsMode, escapeChangelogMentions, diff --git a/packages/core/test/unit/cargo.spec.ts b/packages/core/test/unit/cargo.spec.ts index 1769e6ec..77235d8a 100644 --- a/packages/core/test/unit/cargo.spec.ts +++ b/packages/core/test/unit/cargo.spec.ts @@ -1,8 +1,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { findCargoLockfile } from '../../src/cargo.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { findCargoLockfile, planCargoLockUpdates, setCargoLockVersion } from '../../src/cargo.js'; describe('findCargoLockfile', () => { const tmpDirs: string[] = []; @@ -27,8 +27,9 @@ describe('findCargoLockfile', () => { expect(findCargoLockfile(dir)).toBe(lock); }); - it('should walk up to the nearest ancestor lock for a workspace member', () => { + it('should walk up to the workspace root’s lock for a member', () => { const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/*"]\n'); const lock = path.join(root, 'Cargo.lock'); fs.writeFileSync(lock, ''); const member = path.join(root, 'crates', 'foo'); @@ -36,16 +37,72 @@ describe('findCargoLockfile', () => { expect(findCargoLockfile(member)).toBe(lock); }); - it('should return the nearest lock for a standalone (non-workspace) crate when both a member and an ancestor have one', () => { - // No [workspace] manifest anywhere → the standalone fallback (nearest lock wins). With a - // workspace root present, the root lock would win instead — see the workspace-root test below. + it('should ignore a lock in an ancestor directory that is not a workspace root', () => { const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[package]\nname = "outer"\nversion = "1.0.0"\n'); fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); - const member = path.join(root, 'crates', 'foo'); - fs.mkdirSync(member, { recursive: true }); - const memberLock = path.join(member, 'Cargo.lock'); - fs.writeFileSync(memberLock, ''); - expect(findCargoLockfile(member)).toBe(memberLock); + const crate = path.join(root, 'crates', 'foo'); + fs.mkdirSync(crate, { recursive: true }); + expect(findCargoLockfile(crate)).toBeUndefined(); + fs.writeFileSync(path.join(crate, 'Cargo.lock'), ''); + expect(findCargoLockfile(crate)).toBe(path.join(crate, 'Cargo.lock')); + }); + + it('should follow a member’s package.workspace pointer to a root outside its directory', () => { + const root = tmp(); + const workspace = path.join(root, 'workspace'); + const member = path.join(root, 'member'); + fs.mkdirSync(workspace); + fs.mkdirSync(member); + fs.writeFileSync(path.join(workspace, 'Cargo.toml'), '[workspace]\nmembers = ["../member"]\n'); + fs.writeFileSync(path.join(workspace, 'Cargo.lock'), ''); + fs.writeFileSync( + path.join(member, 'Cargo.toml'), + '[package]\nname = "member"\nversion = "0.1.0"\nworkspace = "../workspace"\n', + ); + + expect(findCargoLockfile(member)).toBe(path.join(workspace, 'Cargo.lock')); + }); + + it.each([ + ['an ancestor workspace excludes it', '[workspace]\nmembers = ["crates/*"]\nexclude = ["apps/x"]\n', 'own'], + ['an ancestor workspace excludes a directory above it', '[workspace]\nexclude = ["./apps/"]\n', 'own'], + ['a literal member entry overrides an exclude', '[workspace]\nmembers = ["apps/x"]\nexclude = ["apps"]\n', 'root'], + ['an exclude is a glob, which cargo doesn’t expand', '[workspace]\nexclude = ["apps/*"]\n', 'root'], + ['an exclude only shares a prefix with its directory name', '[workspace]\nexclude = ["app"]\n', 'root'], + ])('should follow cargo’s workspace-root rule when %s', (_label, rootManifest, expected) => { + const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), rootManifest); + fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); + const crate = path.join(root, 'apps', 'x'); + fs.mkdirSync(crate, { recursive: true }); + fs.writeFileSync(path.join(crate, 'Cargo.toml'), '[package]\nname = "x"\nversion = "0.1.0"\n'); + fs.writeFileSync(path.join(crate, 'Cargo.lock'), ''); + + expect(findCargoLockfile(crate)).toBe(path.join(expected === 'own' ? crate : root, 'Cargo.lock')); + }); + + it('should skip a workspace that excludes the crate for an outer one that has it as a member', () => { + const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["inner/app"]\n'); + fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); + fs.mkdirSync(path.join(root, 'inner', 'app'), { recursive: true }); + fs.writeFileSync(path.join(root, 'inner', 'Cargo.toml'), '[workspace]\nexclude = ["app"]\n'); + fs.writeFileSync(path.join(root, 'inner', 'Cargo.lock'), ''); + + expect(findCargoLockfile(path.join(root, 'inner', 'app'))).toBe(path.join(root, 'Cargo.lock')); + }); + + it('should treat a crate whose manifest declares a workspace as its own root', () => { + const root = tmp(); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/*"]\n'); + fs.writeFileSync(path.join(root, 'Cargo.lock'), ''); + const app = path.join(root, 'fixtures', 'app'); + fs.mkdirSync(app, { recursive: true }); + fs.writeFileSync(path.join(app, 'Cargo.toml'), '[package]\nname = "app"\nversion = "0.1.0"\n\n[workspace]\n'); + fs.writeFileSync(path.join(app, 'Cargo.lock'), ''); + + expect(findCargoLockfile(app)).toBe(path.join(app, 'Cargo.lock')); }); it('should return undefined when no lock exists above the start dir', () => { @@ -79,3 +136,470 @@ describe('findCargoLockfile', () => { expect(findCargoLockfile(member)).toBeUndefined(); }); }); + +const REGISTRY = 'registry+https://github.com/rust-lang/crates.io-index'; +const GIT = 'git+https://github.com/example/dupe#9a1327a6d3f8bc80a0f02fe01325c7828d587668'; + +/** Join lock lines the way cargo writes them: LF endings and a trailing newline. */ +const lock = (...lines: string[]): string => `${lines.join('\n')}\n`; + +describe('setCargoLockVersion', () => { + it.each([3, 4])('should bump only the crate’s own entry in a v%i lock', (format) => { + const before = lock( + '# This file is automatically @generated by Cargo.', + '# It is not intended for manual editing.', + `version = ${format}`, + '', + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "core",', + ']', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + ); + + expect(setCargoLockVersion(before, 'core', '1.5.0')).toBe(before.replace('version = "1.4.0"', 'version = "1.5.0"')); + }); + + it('should rewrite versioned references when the lock holds two versions of the name', () => { + // Byte-for-byte what `cargo update --workspace` writes after bumping the path crate `dupe`. + const before = lock( + 'version = 4', + '', + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "dupe 0.9.0",', + ' "dupe 1.0.0",', + ']', + '', + '[[package]]', + 'name = "dupe"', + 'version = "0.9.0"', + `source = "${GIT}"`, + '', + '[[package]]', + 'name = "dupe"', + 'version = "1.0.0"', + ); + const after = lock( + 'version = 4', + '', + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "dupe 0.9.0",', + ' "dupe 1.1.0",', + ']', + '', + '[[package]]', + 'name = "dupe"', + 'version = "0.9.0"', + `source = "${GIT}"`, + '', + '[[package]]', + 'name = "dupe"', + 'version = "1.1.0"', + ); + + expect(setCargoLockVersion(before, 'dupe', '1.1.0')).toBe(after); + }); + + it('should keep cargo’s order when the new version passes same-named entries from other sources', () => { + // Byte-for-byte what `cargo update --workspace` writes after bumping `dupe` from 1.0.0 to 3.0.0. + const source = (repo: string) => + `source = "git+https://example.com/${repo}#9a1327a6d3f8bc80a0f02fe01325c7828d587668"`; + const entry = (version: string, ...rest: string[]) => [ + '', + '[[package]]', + 'name = "dupe"', + `version = "${version}"`, + ...rest, + ]; + const app = (...references: string[]) => [ + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ...references.map((reference) => ` "dupe ${reference}",`), + ']', + ]; + const before = lock( + 'version = 4', + '', + ...app('1.0.0', '1.0.0-beta.1', '10.0.0', '2.0.0'), + ...entry('1.0.0-beta.1', source('pre')), + ...entry('1.0.0'), + ...entry('2.0.0', source('two')), + ...entry('10.0.0', source('ten')), + ); + const after = lock( + 'version = 4', + '', + ...app('1.0.0-beta.1', '10.0.0', '2.0.0', '3.0.0'), + ...entry('1.0.0-beta.1', source('pre')), + ...entry('2.0.0', source('two')), + ...entry('3.0.0'), + ...entry('10.0.0', source('ten')), + ); + + expect(setCargoLockVersion(before, 'dupe', '3.0.0')).toBe(after); + }); + + it('should order pre-release identifiers numerically when the new version passes other entries', () => { + // Byte-for-byte what `cargo update --workspace` writes after bumping `dupe` from 1.0.0-beta.2 to 1.0.0-beta.11. + const entry = (version: string, ...rest: string[]) => [ + '', + '[[package]]', + 'name = "dupe"', + `version = "${version}"`, + ...rest, + ]; + const app = (...versions: string[]) => [ + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ...versions.map((version) => ` "dupe 1.0.0-beta.${version}",`), + ']', + ]; + const before = lock( + 'version = 4', + '', + ...app('10', '2', '9'), + ...entry('1.0.0-beta.2'), + ...entry('1.0.0-beta.9', `source = "${GIT}"`), + ...entry('1.0.0-beta.10', `source = "${GIT}"`), + ); + const after = lock( + 'version = 4', + '', + ...app('10', '11', '9'), + ...entry('1.0.0-beta.9', `source = "${GIT}"`), + ...entry('1.0.0-beta.10', `source = "${GIT}"`), + ...entry('1.0.0-beta.11'), + ); + + expect(setCargoLockVersion(before, 'dupe', '1.0.0-beta.11')).toBe(after); + }); + + it('should rewrite path references in a v1 lock without touching registry references or checksums', () => { + // v1 locks always spell out the version, and add the source for registry packages. + const before = lock( + '[[package]]', + 'name = "app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "core 1.4.0",', + ` "serde 1.0.200 (${REGISTRY})",`, + ']', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.200"', + `source = "${REGISTRY}"`, + '', + '[metadata]', + `"checksum serde 1.0.200 (${REGISTRY})" = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"`, + ); + + expect(setCargoLockVersion(before, 'core', '2.0.0')).toBe( + before.replace('"core 1.4.0"', '"core 2.0.0"').replace('version = "1.4.0"', 'version = "2.0.0"'), + ); + }); + + it('should return undefined when the crate is only in the lock as a registry package', () => { + const before = lock( + 'version = 3', + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.200"', + `source = "${REGISTRY}"`, + ); + + expect(setCargoLockVersion(before, 'serde', '1.0.201')).toBeUndefined(); + }); + + it('should return the content unchanged when the entry is already at the version', () => { + const before = lock('version = 3', '', '[[package]]', 'name = "core"', 'version = "1.5.0"'); + + expect(setCargoLockVersion(before, 'core', '1.5.0')).toBe(before); + }); + + it('should keep CRLF line endings', () => { + const before = 'version = 3\r\n\r\n[[package]]\r\nname = "core"\r\nversion = "1.4.0"\r\n'; + + expect(setCargoLockVersion(before, 'core', '1.5.0')).toBe(before.replace('1.4.0', '1.5.0')); + }); + + it.each([ + ['the new version', '2.0.0'], + ['the old version', '1.4.0'], + ])('should throw when another source holds the name at %s', (_label, otherVersion) => { + const before = lock( + 'version = 4', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + '', + '[[package]]', + 'name = "core"', + `version = "${otherVersion}"`, + `source = "${REGISTRY}"`, + ); + + expect(() => setCargoLockVersion(before, 'core', '2.0.0')).toThrow(/from another source/); + }); + + it('should throw when the lock has several path entries of the name', () => { + const before = lock( + 'version = 4', + '', + '[[package]]', + 'name = "core"', + 'version = "1.4.0"', + '', + '[[package]]', + 'name = "core"', + 'version = "3.0.0"', + ); + + expect(() => setCargoLockVersion(before, 'core', '2.0.0')).toThrow('it has 2 path entries named core'); + }); + + it('should throw on a lock format newer than v4', () => { + const before = lock('version = 5', '', '[[package]]', 'name = "core"', 'version = "1.4.0"'); + + expect(() => setCargoLockVersion(before, 'core', '1.5.0')).toThrow( + 'its format (version = 5) is newer than releasekit can edit', + ); + }); +}); + +describe('planCargoLockUpdates', () => { + const tmpDirs: string[] = []; + + function fakeGit(tracked: string[], ignored: string[] = []) { + return { + isRepository: vi.fn(async () => true), + lsFiles: vi.fn(async () => tracked), + isIgnored: vi.fn(async (file: string) => ignored.includes(file)), + }; + } + + function write(file: string, content: string): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + return file; + } + + /** A lock whose root package `root` depends on `dependency` by path. */ + const lockWithDependency = (root: string, dependency: string, version: string): string => + lock( + 'version = 4', + '', + '[[package]]', + `name = "${dependency}"`, + `version = "${version}"`, + '', + '[[package]]', + `name = "${root}"`, + 'version = "0.1.0"', + 'dependencies = [', + ` "${dependency}",`, + ']', + ); + + /** A workspace whose root lock lists `core`, plus a separate app whose own lock uses `core` by path. */ + function repoWithLocks(): { root: string; manifest: string; ownLock: string; appLock: string } { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-plan-'))); + tmpDirs.push(root); + write(path.join(root, 'Cargo.toml'), '[workspace]\nmembers = ["crates/*"]\n'); + const manifest = write( + path.join(root, 'crates', 'core', 'Cargo.toml'), + '[package]\nname = "core"\nversion = "1.5.0"\n', + ); + const ownLock = write( + path.join(root, 'Cargo.lock'), + lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.4.0"'), + ); + const appManifest = write( + path.join(root, 'app', 'Cargo.toml'), + '[package]\nname = "app"\nversion = "0.1.0"\n\n[workspace]\n\n[dependencies]\ncore = { path = "../crates/core" }\n', + ); + return { + root, + manifest, + ownLock, + appManifest, + appLock: write(path.join(root, 'app', 'Cargo.lock'), lockWithDependency('app', 'core', '1.4.0')), + }; + } + + afterEach(() => { + for (const dir of tmpDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tmpDirs.length = 0; + }); + + it('should plan the crate’s own lock and every tracked lock that uses it, writing nothing', async () => { + const { root, manifest, ownLock, appLock } = repoWithLocks(); + write(path.join(root, 'other', 'Cargo.lock'), lockWithDependency('other', 'serde', '1.0.0')); + const git = fakeGit(['../../app/Cargo.lock', '../../app/Cargo.toml', '../../other/Cargo.lock']); + const bump = { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }; + + const updates = await planCargoLockUpdates(git, path.dirname(manifest), [bump]); + + expect(updates).toEqual([ + { lockPath: appLock, content: fs.readFileSync(appLock, 'utf8').replace('1.4.0', '1.5.0'), changed: true }, + { lockPath: ownLock, content: fs.readFileSync(ownLock, 'utf8').replace('1.4.0', '1.5.0'), changed: true }, + ]); + expect(git.lsFiles).toHaveBeenCalledWith({ + paths: [':(top,glob)**/Cargo.lock', ':(top,glob)**/Cargo.toml'], + cwd: path.dirname(manifest), + }); + expect(fs.readFileSync(ownLock, 'utf8')).toContain('version = "1.4.0"'); + }); + + it('should plan only the crate’s own lock outside a git repository', async () => { + const { manifest, ownLock } = repoWithLocks(); + const git = { ...fakeGit([]), isRepository: vi.fn(async () => false) }; + + const updates = await planCargoLockUpdates(git, path.dirname(manifest), [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, + ]); + + expect(updates.map((update) => update.lockPath)).toEqual([ownLock]); + expect(git.lsFiles).not.toHaveBeenCalled(); + }); + + it('should not plan an own lock git ignores', async () => { + const { root, manifest, ownLock } = repoWithLocks(); + + const updates = await planCargoLockUpdates(fakeGit([], [ownLock]), root, [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, + ]); + + expect(updates).toEqual([]); + }); + + it('should apply every bump a shared lock lists in one update', async () => { + const { root, manifest, appManifest, appLock } = repoWithLocks(); + + const updates = await planCargoLockUpdates(fakeGit([appLock, appManifest]), root, [ + { cargoTomlPath: appManifest, name: 'app', version: '0.2.0' }, + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, + ]); + + expect(updates.find((update) => update.lockPath === appLock)?.content).toBe( + fs.readFileSync(appLock, 'utf8').replace('"0.1.0"', '"0.2.0"').replace('"1.4.0"', '"1.5.0"'), + ); + }); + + it('should report a lock already at the new version as unchanged, and skip a tracked lock missing from disk', async () => { + const { root, manifest, ownLock } = repoWithLocks(); + fs.writeFileSync(ownLock, lock('version = 4', '', '[[package]]', 'name = "core"', 'version = "1.5.0"')); + + const updates = await planCargoLockUpdates(fakeGit([path.join(root, 'deleted', 'Cargo.lock')]), root, [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, + ]); + + expect(updates).toEqual([{ lockPath: ownLock, content: fs.readFileSync(ownLock, 'utf8'), changed: false }]); + }); + + /** A `plugins` workspace whose `core` 1.0.0 is bumped, and an app workspace with a `core` 1.0.0 of its own. */ + function pluginsAndApp(appDependencies: string, apiDependencies: string) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-reach-'))); + tmpDirs.push(root); + const crate = (dir: string, name: string, version: string, extra = ''): string => + write(path.join(root, dir, 'Cargo.toml'), `[package]\nname = "${name}"\nversion = "${version}"\n\n${extra}\n`); + const manifests = [ + write( + path.join(root, 'plugins', 'Cargo.toml'), + '[workspace]\nmembers = ["api", "core"]\n\n[workspace.dependencies]\ncore = { path = "core" }\n', + ), + crate('plugins/core', 'core', '1.0.0'), + crate('plugins/api', 'api', '0.1.0', apiDependencies), + crate('app', 'app', '0.1.0', `[workspace]\n\n${appDependencies}`), + crate('app/core', 'core', '1.0.0'), + ]; + const pluginsLock = write(path.join(root, 'plugins', 'Cargo.lock'), lockWithDependency('api', 'core', '1.0.0')); + const appLock = write(path.join(root, 'app', 'Cargo.lock'), lockWithDependency('app', 'core', '1.0.0')); + return { + root, + pluginsLock, + appLock, + git: fakeGit([...manifests, pluginsLock, appLock]), + bump: { cargoTomlPath: manifests[1], name: 'core', version: '1.1.0' }, + }; + } + + it.each([ + ['depends on the bumped crate by path', '[dependencies]\ncore = { path = "../plugins/core" }', ''], + [ + 'has it as a target-specific dev-dependency', + `[target.'cfg(unix)'.dev-dependencies]\ncore = { path = "../plugins/core" }`, + '', + ], + [ + 'inherits it from its workspace', + '[workspace.dependencies]\ncore = { path = "../plugins/core" }\n\n[dependencies]\ncore = { workspace = true }', + '', + ], + [ + 'depends on a crate that inherits it from another workspace', + '[dependencies]\napi = { path = "../plugins/api" }', + '[dependencies]\ncore = { workspace = true }', + ], + ])('should sync an app’s lock when the app %s', async (_label, appDependencies, apiDependencies) => { + const { root, appLock, git, bump } = pluginsAndApp(appDependencies, apiDependencies); + + const updates = await planCargoLockUpdates(git, root, [bump]); + + expect(updates.find((update) => update.lockPath === appLock)).toEqual({ + lockPath: appLock, + content: lockWithDependency('app', 'core', '1.1.0'), + changed: true, + }); + }); + + it.each([ + ['depends on its own crate of the same name and version', '[dependencies]\ncore = { path = "core" }', ''], + [ + 'depends on a crate that has the bumped crate as a dev-dependency', + '[dependencies]\ncore = { path = "core" }\napi = { path = "../plugins/api" }', + '[dev-dependencies]\ncore = { path = "../core" }', + ], + ['patches it in', '[patch.crates-io]\ncore = { path = "../plugins/core" }', ''], + ])('should leave an app’s lock alone when the app %s', async (_label, appDependencies, apiDependencies) => { + const { root, pluginsLock, git, bump } = pluginsAndApp(appDependencies, apiDependencies); + + const updates = await planCargoLockUpdates(git, root, [bump]); + + expect(updates.map((update) => update.lockPath)).toEqual([pluginsLock]); + }); + + it('should name the lock when an edit cannot be made unambiguously', async () => { + const { root, manifest, appManifest, appLock } = repoWithLocks(); + fs.appendFileSync(appLock, lock('', '[[package]]', 'name = "core"', 'version = "1.5.0"', `source = "${REGISTRY}"`)); + + await expect( + planCargoLockUpdates(fakeGit([appLock, appManifest]), root, [ + { cargoTomlPath: manifest, name: 'core', version: '1.5.0' }, + ]), + ).rejects.toThrow(`can't set core to 1.5.0 in ${appLock}: it also has core 1.5.0 from another source`); + }); +}); diff --git a/packages/git/src/fake.ts b/packages/git/src/fake.ts index fd28d568..07abc4e8 100644 --- a/packages/git/src/fake.ts +++ b/packages/git/src/fake.ts @@ -7,6 +7,7 @@ import type { GitForEachRefOptions, GitListTagsOptions, GitLogOptions, + GitLsFilesOptions, GitPushOptions, GitStatusOptions, GitTagOptions, @@ -190,6 +191,14 @@ export class FakeGit implements Git { return [...(this.diffNames[commitSha] ?? [])]; } + async lsFiles(_opts: GitLsFilesOptions = {}): Promise { + return []; + } + + async isIgnored(_file: string, _cwd?: string): Promise { + return false; + } + async forEachRef(_opts: GitForEachRefOptions): Promise { return [...this.refLines]; } diff --git a/packages/git/src/git.ts b/packages/git/src/git.ts index 37866d50..03d287a4 100644 --- a/packages/git/src/git.ts +++ b/packages/git/src/git.ts @@ -10,6 +10,7 @@ import type { GitForEachRefOptions, GitListTagsOptions, GitLogOptions, + GitLsFilesOptions, GitPushOptions, GitStatusOptions, GitTagOptions, @@ -241,6 +242,19 @@ export class GitCli implements Git { return splitLines(stdout); } + async lsFiles(opts: GitLsFilesOptions = {}): Promise { + const args = ['ls-files', '-z']; + if (opts.paths && opts.paths.length > 0) { + args.push('--', ...opts.paths); + } + const { stdout } = await this.exec(args, { cwd: this.cwd(opts.cwd) }); + return stdout.split('\0').filter((file) => file.length > 0); + } + + async isIgnored(file: string, cwd?: string): Promise { + return this.execSoft(['check-ignore', '-q', '--', file], { cwd: this.cwd(cwd), ignoreOutput: true }); + } + async forEachRef(opts: GitForEachRefOptions): Promise { // `format`/`sort` are flag values by design; `pattern` is a positional ref, so guard it like // every other user-controlled positional (a leading-`-` pattern would be read as an option). diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index da3454c2..cca06dd8 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -18,6 +18,7 @@ export type { GitForEachRefOptions, GitListTagsOptions, GitLogOptions, + GitLsFilesOptions, GitPushOptions, GitStatusOptions, GitTagOptions, diff --git a/packages/git/src/types.ts b/packages/git/src/types.ts index bfbf9f00..208951e2 100644 --- a/packages/git/src/types.ts +++ b/packages/git/src/types.ts @@ -98,6 +98,13 @@ export interface GitForEachRefOptions { cwd?: string; } +/** Options for {@link Git.lsFiles}. */ +export interface GitLsFilesOptions { + /** Pathspecs to match (passed after `--`); magic such as `:(top,glob)` is allowed. */ + paths?: string[]; + cwd?: string; +} + /** Options for {@link Git.status}. */ export interface GitStatusOptions { /** Use `--porcelain` machine-readable output. */ @@ -131,6 +138,10 @@ export interface Git { log(opts: GitLogOptions): Promise; /** File paths changed by `commitSha` (`diff-tree --no-commit-id --name-only -r`). */ diffTreeNames(commitSha: string, cwd?: string): Promise; + /** Tracked file paths matching `paths` (`ls-files -z`), relative to `cwd` as git prints them. */ + lsFiles(opts?: GitLsFilesOptions): Promise; + /** Whether git ignores `file` (`check-ignore -q`); false for a tracked file, and on failure (e.g. outside a repository). */ + isIgnored(file: string, cwd?: string): Promise; /** One formatted line per matching ref (`for-each-ref`), empties dropped. */ forEachRef(opts: GitForEachRefOptions): Promise; /** Whether `branch` exists on `remote` (`ls-remote --exit-code --heads`). */ diff --git a/packages/git/test/unit/git.spec.ts b/packages/git/test/unit/git.spec.ts index 3c98864c..c44da4dd 100644 --- a/packages/git/test/unit/git.spec.ts +++ b/packages/git/test/unit/git.spec.ts @@ -165,6 +165,25 @@ describe('GitCli queries', () => { expect(calls[0].args).toEqual(['diff-tree', '--no-commit-id', '--name-only', '-r', 'sha1']); }); + it('should list tracked files NUL-separated, passing pathspecs after --', async () => { + const { run, calls } = makeRunner({ stdout: '../Cargo.lock\0app/Cargo.lock\0' }); + expect(await new GitCli(run).lsFiles({ paths: [':(top,glob)**/Cargo.lock'], cwd: '/r/crates' })).toEqual([ + '../Cargo.lock', + 'app/Cargo.lock', + ]); + expect(calls[0].args).toEqual(['ls-files', '-z', '--', ':(top,glob)**/Cargo.lock']); + expect(calls[0].options.cwd).toBe('/r/crates'); + }); + + it('should answer isIgnored via check-ignore -q exit code, passing the file after --', async () => { + const ignored = makeRunner(); + expect(await new GitCli(ignored.run).isIgnored('/r/Cargo.lock', '/r')).toBe(true); + expect(ignored.calls[0].args).toEqual(['check-ignore', '-q', '--', '/r/Cargo.lock']); + expect(ignored.calls[0].options).toEqual({ cwd: '/r', ignoreOutput: true }); + const notIgnored = makeRunner({ reject: exitError(1) }); + expect(await new GitCli(notIgnored.run).isIgnored('Cargo.lock')).toBe(false); + }); + it('should run for-each-ref with format, and append sort/pattern when given', async () => { const { run, calls } = makeRunner({ stdout: 'v1 sha1\nv2 sha2\n' }); expect( diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index f784ef1d..616bf2e1 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -1,9 +1,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { debug, info, isPathWithinRoot } from '@releasekit/core'; +import { type CargoLockBump, debug, info, isPathWithinRoot } from '@releasekit/core'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; import type { PipelineContext } from '../types.js'; -import { syncCargoLockfile, updateCargoVersion } from '../utils/cargo.js'; +import { syncCargoLockfiles, updateCargoVersion } from '../utils/cargo.js'; export async function runPrepareStage(ctx: PipelineContext): Promise { const { input, config, cliOptions, cwd } = ctx; @@ -57,12 +57,9 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { // Update Cargo.toml versions for cargo packages if (config.cargo.enabled) { - // Cargo.lock files to stage alongside the manifests, deduped — crates in one workspace - // share a single workspace-root lock. We re-sync the lock here (rather than trust the version - // step) so the direct-commit flow — git-commit stages an explicit file list, not `git add -A` — - // is self-sufficient and correct even run in isolation. The standing-PR flow stages the lock via - // its own `git add -A`, with git-commit skipped, so this re-sync is a harmless no-op there. - const lockfiles = new Set(); + // Re-synced here so the direct-commit flow, which stages an explicit file list, commits every lock + // that records a bump. A no-op in the standing-PR flow, which stages with `git add -A`. + const bumps: Array> = []; for (const update of input.updates) { const pkgDir = path.dirname(path.resolve(cwd, update.filePath)); const cargoPath = path.join(pkgDir, 'Cargo.toml'); @@ -78,19 +75,12 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { updateCargoVersion(cargoPath, update.newVersion); debug(`Updated ${cargoPath} to version ${update.newVersion}`); - - const lockPath = await syncCargoLockfile(pkgDir); - if (lockPath) { - lockfiles.add(lockPath); - } + bumps.push({ cargoTomlPath: cargoPath, version: update.newVersion }); } - if (lockfiles.size > 0) { - const existing = new Set(ctx.additionalFiles ?? []); - for (const lock of lockfiles) { - existing.add(lock); - } - ctx.additionalFiles = [...existing]; + const lockfiles = await syncCargoLockfiles(cwd, bumps); + if (lockfiles.length > 0) { + ctx.additionalFiles = [...new Set([...(ctx.additionalFiles ?? []), ...lockfiles])]; } } } diff --git a/packages/publish/src/utils/cargo.ts b/packages/publish/src/utils/cargo.ts index 303c26e0..4c93ad50 100644 --- a/packages/publish/src/utils/cargo.ts +++ b/packages/publish/src/utils/cargo.ts @@ -1,50 +1,41 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; import type { CargoManifest } from '@releasekit/config'; import { parseCargoToml } from '@releasekit/config'; -import { findCargoLockfile, warn } from '@releasekit/core'; +import { type CargoLockBump, planCargoLockUpdates, warn } from '@releasekit/core'; +import { createGitCli } from '@releasekit/git'; import * as TOML from 'smol-toml'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; -import { execCommand } from './exec.js'; export type { CargoManifest }; export { parseCargoToml }; /** - * Refresh a crate's `Cargo.lock` self-version entry after its `Cargo.toml` was bumped, so the - * committed lock doesn't drift. `cargo update --workspace --offline` moves only workspace - * members' own lock entries to match their manifests — registry dependencies are never re-resolved. + * Sync every Cargo.lock that records a crate this publish bumped (core's `planCargoLockUpdates`). Returns + * every such lock, changed or not, so the caller stages them all. * - * Returns the lock path (so the caller can stage it) or `undefined` when there's no committed lock, - * or the refresh fails. A failure is logged, never thrown: a lockfile fix-up must not abort a publish - * that would otherwise succeed. This mirrors the version step's sync (which covers the standing-PR - * flow's `git add -A`); doing it here too keeps the direct-commit flow self-sufficient rather than - * depending on the version step having run and succeeded first. + * Logs rather than throws: in the standing-PR flow this runs after the merge, and a lock fix-up mustn't + * strand a release already on the base branch. The version step fails on an unsyncable lock instead. */ -export async function syncCargoLockfile(crateDir: string): Promise { - const lockPath = findCargoLockfile(crateDir); - if (!lockPath) { - return undefined; +export async function syncCargoLockfiles(cwd: string, bumps: Array>): Promise { + if (bumps.length === 0) { + return []; } - try { - await execCommand('cargo', ['update', '--workspace', '--offline'], { - cwd: crateDir, - label: `cargo update --workspace --offline (${path.basename(crateDir)})`, + const lockBumps = bumps.flatMap((bump) => { + const name = parseCargoToml(bump.cargoTomlPath).package?.name; + return name ? [{ ...bump, name }] : []; }); - return lockPath; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - warn( - `cargo not found on PATH — Cargo.lock at ${lockPath} may drift from the bumped version. ` + - 'Install the Rust toolchain in the release environment, or disable cargo handling.', - ); - } else { - const stderr = (error as { stderr?: string }).stderr; - const detail = stderr?.trim() || (error instanceof Error ? error.message : String(error)); - warn(`Failed to refresh Cargo.lock at ${lockPath}: ${detail}. The committed lock may drift from the bump.`); + const updates = await planCargoLockUpdates(createGitCli(), cwd, lockBumps); + for (const { lockPath, content, changed } of updates) { + if (changed) { + fs.writeFileSync(lockPath, content); + } } - return undefined; + return updates.map(({ lockPath }) => lockPath); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + warn(`Failed to sync Cargo.lock files: ${detail}. Committed locks may drift from the bump.`); + return []; } } diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index e0529064..a861e545 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -6,11 +7,14 @@ import { getDefaultConfig } from '../../../src/config.js'; import { runPrepareStage } from '../../../src/stages/prepare.js'; import type { PipelineContext } from '../../../src/types.js'; -// Don't shell out to a real cargo for the lockfile sync — assert the staging behaviour, not cargo. -vi.mock('../../../src/utils/exec.js', async () => { - const actual = await vi.importActual('../../../src/utils/exec.js'); - return { ...actual, execCommand: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) }; -}); +/** A committed lock listing the path crate `foo` at `version`. */ +const fooLock = (version: string): string => `version = 4\n\n[[package]]\nname = "foo"\nversion = "${version}"\n`; + +/** A fixture app's manifest and committed lock: the app uses `foo` (at `version`) by path. */ +const appManifestUsingFoo = + '[package]\nname = "app"\nversion = "0.1.0"\n\n[workspace]\n\n[dependencies]\nfoo = { path = "../../crates/foo" }\n'; +const appLockUsingFoo = (version: string): string => + `${fooLock(version)}\n[[package]]\nname = "app"\nversion = "0.1.0"\ndependencies = [\n "foo",\n]\n`; function createContext(overrides?: Partial): PipelineContext { return { @@ -144,13 +148,14 @@ describe('prepare stage', () => { expect(fs.existsSync(path.join(pkgDir, 'LICENSE'))).toBe(false); }); - it('should stage the workspace Cargo.lock alongside a bumped crate', async () => { + it('should sync and stage the workspace Cargo.lock alongside a bumped crate', async () => { const dir = createTmpDir(); + fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[workspace]\nmembers = ["crates/foo"]\n'); const crateDir = path.join(dir, 'crates', 'foo'); fs.mkdirSync(crateDir, { recursive: true }); fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); const lockPath = path.join(dir, 'Cargo.lock'); - fs.writeFileSync(lockPath, ''); + fs.writeFileSync(lockPath, fooLock('1.0.0')); const config = getDefaultConfig(); config.cargo.enabled = true; @@ -167,15 +172,143 @@ describe('prepare stage', () => { await runPrepareStage(ctx); + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(fooLock('1.1.0')); expect(ctx.additionalFiles).toContain(lockPath); }); + it('should sync and stage a committed fixture-app lock that uses the crate by path', async () => { + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); + const ownLock = path.join(crateDir, 'Cargo.lock'); + fs.writeFileSync(ownLock, fooLock('1.0.0')); + const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); + fs.mkdirSync(path.dirname(appLock), { recursive: true }); + fs.writeFileSync(path.join(path.dirname(appLock), 'Cargo.toml'), appManifestUsingFoo); + fs.writeFileSync(appLock, appLockUsingFoo('1.0.0')); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(ownLock, 'utf-8')).toBe(fooLock('1.1.0')); + expect(fs.readFileSync(appLock, 'utf-8')).toBe(appLockUsingFoo('1.1.0')); + expect(ctx.additionalFiles).toEqual(expect.arrayContaining([ownLock, appLock])); + }); + + it('should not fail the publish when a lock cannot be synced', async () => { + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); + const lockPath = path.join(crateDir, 'Cargo.lock'); + const ambiguous = `${fooLock('1.0.0')}\n[[package]]\nname = "foo"\nversion = "1.1.0"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\n`; + fs.writeFileSync(lockPath, ambiguous); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(path.join(crateDir, 'Cargo.toml'), 'utf-8')).toContain('version = "1.1.0"'); + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(ambiguous); + expect(ctx.additionalFiles ?? []).not.toContain(lockPath); + }); + + it('should still stage the Cargo.lock files the version step already synced', async () => { + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.1.0"\n'); + const lockPath = path.join(crateDir, 'Cargo.lock'); + fs.writeFileSync(lockPath, fooLock('1.1.0')); + const appLock = path.join(dir, 'fixtures', 'app', 'Cargo.lock'); + fs.mkdirSync(path.dirname(appLock), { recursive: true }); + fs.writeFileSync(path.join(path.dirname(appLock), 'Cargo.toml'), appManifestUsingFoo); + fs.writeFileSync(appLock, appLockUsingFoo('1.1.0')); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(ctx.additionalFiles).toEqual(expect.arrayContaining([lockPath, appLock])); + }); + + it('should neither sync nor stage a crate’s own Cargo.lock that git ignores', async () => { + // `git add` refuses an ignored path, which would fail the direct-commit release. + const dir = createTmpDir(); + const crateDir = path.join(dir, 'crates', 'foo'); + fs.mkdirSync(crateDir, { recursive: true }); + fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); + const lockPath = path.join(crateDir, 'Cargo.lock'); + fs.writeFileSync(lockPath, fooLock('1.0.0')); + fs.writeFileSync(path.join(dir, '.gitignore'), 'Cargo.lock\n'); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'crates/foo/Cargo.toml' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(lockPath, 'utf-8')).toBe(fooLock('1.0.0')); + expect(ctx.additionalFiles ?? []).not.toContain(lockPath); + }); + it('should not stage a Cargo.lock in dry-run mode', async () => { const dir = createTmpDir(); const crateDir = path.join(dir, 'crates', 'foo'); fs.mkdirSync(crateDir, { recursive: true }); fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "foo"\nversion = "1.0.0"\n'); - fs.writeFileSync(path.join(dir, 'Cargo.lock'), ''); + fs.writeFileSync(path.join(dir, 'Cargo.toml'), '[workspace]\nmembers = ["crates/foo"]\n'); + fs.writeFileSync(path.join(dir, 'Cargo.lock'), fooLock('1.0.0')); const config = getDefaultConfig(); config.cargo.enabled = true; @@ -203,6 +336,7 @@ describe('prepare stage', () => { await runPrepareStage(ctx); + expect(fs.readFileSync(path.join(dir, 'Cargo.lock'), 'utf-8')).toBe(fooLock('1.0.0')); expect(ctx.additionalFiles ?? []).not.toContain(path.join(dir, 'Cargo.lock')); }); diff --git a/packages/version/src/cargo/cargoHandler.ts b/packages/version/src/cargo/cargoHandler.ts index 4d802bb0..39a0de78 100644 --- a/packages/version/src/cargo/cargoHandler.ts +++ b/packages/version/src/cargo/cargoHandler.ts @@ -9,6 +9,7 @@ import { isCargoToml, parseCargoToml } from '@releasekit/config'; import * as TOML from 'smol-toml'; import { addPackageUpdate, recordPendingWrite } from '../utils/jsonOutput.js'; import { log } from '../utils/logging.js'; +import { recordCargoBump } from './cargoLock.js'; export { isCargoToml }; @@ -58,6 +59,9 @@ export function getCargoInfo(cargoPath: string): CargoInfo { /** * Update a Cargo.toml file with a new version * Preserves comments and formatting as much as possible + * + * Also records the bump for the end-of-run Cargo.lock sync. Every version-step bump goes through here, + * so no strategy can skip the sync. */ export function updateCargoVersion(cargoPath: string, version: string, dryRun = false): void { try { @@ -86,6 +90,7 @@ export function updateCargoVersion(cargoPath: string, version: string, dryRun = // Track update for JSON output addPackageUpdate(packageName, version, cargoPath); + recordCargoBump({ cargoTomlPath: cargoPath, name: packageName, version }); log(`${dryRun ? '[DRY RUN] Would update' : 'Updated'} Cargo.toml at ${cargoPath} to version ${version}`, 'success'); } catch (error) { diff --git a/packages/version/src/cargo/cargoLock.ts b/packages/version/src/cargo/cargoLock.ts index ee200155..d71f7130 100644 --- a/packages/version/src/cargo/cargoLock.ts +++ b/packages/version/src/cargo/cargoLock.ts @@ -1,53 +1,49 @@ -import { execFileSync } from 'node:child_process'; -import path from 'node:path'; -import { findCargoLockfile } from '@releasekit/core'; +import fs from 'node:fs'; +import { type CargoLockBump, planCargoLockUpdates } from '@releasekit/core'; +import { createGitCli } from '@releasekit/git'; +import { CargoLockSyncError } from '../errors/cargoLockSyncError.js'; +import { recordPendingWrite } from '../utils/jsonOutput.js'; import { log } from '../utils/logging.js'; +const recordedBumps: CargoLockBump[] = []; + +export function recordCargoBump(bump: CargoLockBump): void { + recordedBumps.push(bump); +} + +/** Drop bumps recorded outside a version run, so a run never syncs another run's bumps. */ +export function clearCargoBumps(): void { + recordedBumps.length = 0; +} + /** - * Sync a crate's `Cargo.lock` self-version entry after its `Cargo.toml` version was bumped, so a - * committed lock doesn't drift from the manifest (which breaks `cargo build --locked`/`--frozen` and - * leaks a spurious lock diff into later builds). The bump only rewrites `Cargo.toml`, so without this - * the lock's own `[[package]]` entry stays at the old version. + * Sync every Cargo.lock that records a crate bumped since the last sync (core's `planCargoLockUpdates`). + * Under dry-run the edits are still planned, so an unsyncable lock fails the dry run too, and they're + * recorded as pending writes instead of written. * - * The refresh is surgical and offline: `cargo update --workspace --offline` moves only the workspace - * members' own lock entries to match their manifests, never re-resolving registry dependencies, so it - * can't reintroduce a dependency-cascade. We never create a lock that wasn't already committed. - * - * Returns the lock path when it was refreshed (so the caller can stage it), or `undefined` when there - * is no committed lock, under dry-run, or when cargo is unavailable / the refresh fails. A failure is - * logged but never thrown: the version bump itself already succeeded and must not be blocked by a - * lockfile fix-up — surfacing the drift loudly is enough. + * Throws {@link CargoLockSyncError} on a lock it can't read, edit or write; every edit is planned before + * the first write, so a failure leaves all locks untouched. */ -export function syncCargoLockfile(cargoTomlPath: string, dryRun = false): string | undefined { - const crateDir = path.dirname(cargoTomlPath); - const lockPath = findCargoLockfile(crateDir); - if (!lockPath) { - log(`No Cargo.lock found above ${crateDir}; nothing to sync`, 'debug'); - return undefined; - } - - if (dryRun) { - log(`[DRY RUN] Would refresh Cargo.lock at ${lockPath}`, 'info'); - return undefined; +export async function syncCargoLockfiles(cwd: string, dryRun: boolean): Promise { + const bumps = recordedBumps.splice(0); + if (bumps.length === 0) { + return; } try { - execFileSync('cargo', ['update', '--workspace', '--offline'], { cwd: crateDir, stdio: 'pipe' }); - log(`Synced Cargo.lock at ${lockPath}`, 'success'); - return lockPath; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - log( - `cargo not found on PATH — Cargo.lock at ${lockPath} may drift from the bumped version. ` + - 'Install the Rust toolchain in the release environment, or set version.cargo.enabled to false.', - 'warning', - ); - } else { - const stderr = (error as { stderr?: Buffer | string }).stderr; - const detail = stderr ? stderr.toString().trim() : error instanceof Error ? error.message : String(error); - log(`Failed to refresh Cargo.lock at ${lockPath}: ${detail}`, 'warning'); + for (const { lockPath, content, changed } of await planCargoLockUpdates(createGitCli(), cwd, bumps)) { + if (!changed) { + continue; + } + if (dryRun) { + recordPendingWrite(lockPath, content); + log(`[DRY RUN] Would sync Cargo.lock at ${lockPath}`, 'info'); + } else { + fs.writeFileSync(lockPath, content); + log(`Synced Cargo.lock at ${lockPath}`, 'success'); + } } - return undefined; + } catch (error) { + throw new CargoLockSyncError(error instanceof Error ? error.message : String(error)); } } diff --git a/packages/version/src/core/versionEngine.ts b/packages/version/src/core/versionEngine.ts index 4bfe58d8..c567e04d 100644 --- a/packages/version/src/core/versionEngine.ts +++ b/packages/version/src/core/versionEngine.ts @@ -7,6 +7,7 @@ import { filterPackagesByConfig, isCratePublishable, parseCargoToml, parsePubspe import { isPrivatePackageJson, shouldMatchPackageTargets } from '@releasekit/core'; import { minimatch } from 'minimatch'; import * as yaml from 'yaml'; +import { clearCargoBumps, syncCargoLockfiles } from '../cargo/cargoLock.js'; import { GitError } from '../errors/gitError.js'; import { createVersionError, VersionError, VersionErrorCode } from '../errors/versionError.js'; import { getCommitsLength, getLatestTag, getLatestTagForPackage } from '../git/tagsAndBranches.js'; @@ -634,8 +635,10 @@ export class VersionEngine { */ public async run(packages: PackagesWithRoot, targets: string[] = []): Promise { try { + clearCargoBumps(); // Execute the strategy function await this.currentStrategy(packages, targets); + await syncCargoLockfiles(packages.root ?? cwd(), this.config.dryRun ?? false); // Once the strategy has produced the updates, tag each one's role: explicit (group-expanded) // targets keep the override; derived prerequisites carry their own bump and record the // target(s) they were pulled in for. Only when prerequisites were actually derived. diff --git a/packages/version/src/core/versionStrategies.ts b/packages/version/src/core/versionStrategies.ts index 59d90f4c..0bf6526a 100644 --- a/packages/version/src/core/versionStrategies.ts +++ b/packages/version/src/core/versionStrategies.ts @@ -7,7 +7,6 @@ import * as path from 'node:path'; import type { Package } from '@manypkg/get-packages'; import type { VersionChangelogEntry } from '@releasekit/core'; import { shouldMatchPackageTargets, shouldProcessPackage as shouldProcessPackageUtil } from '@releasekit/core'; -import { syncCargoLockfile } from '../cargo/cargoLock.js'; import { extractChangelogEntriesFromCommits } from '../changelog/commitParser.js'; import { BaseVersionError } from '../errors/baseError.js'; import { StrictReachableError } from '../errors/strictReachableError.js'; @@ -88,31 +87,21 @@ function updateCargoFiles( const cargoPaths = cargoConfig?.paths; - // After rewriting a Cargo.toml version, sync its Cargo.lock self-entry so the committed lock - // doesn't drift. The returned lock path is staged alongside the manifest; deduped because - // crates in one workspace share a single workspace-root lock. - const stageCargo = (cargoTomlPath: string): void => { - updatePackageVersion(cargoTomlPath, version, dryRun); - updatedFiles.push(cargoTomlPath); - const lockPath = syncCargoLockfile(cargoTomlPath, dryRun); - if (lockPath && !updatedFiles.includes(lockPath)) { - updatedFiles.push(lockPath); - } - }; - if (cargoPaths && cargoPaths.length > 0) { // If paths are specified, only include those Cargo.toml files for (const cargoPath of cargoPaths) { const resolvedCargoPath = resolveConfinedManifestPath(repoRoot, packageDir, cargoPath, 'Cargo.toml'); if (fs.existsSync(resolvedCargoPath)) { - stageCargo(resolvedCargoPath); + updatePackageVersion(resolvedCargoPath, version, dryRun); + updatedFiles.push(resolvedCargoPath); } } } else { // Default behaviour: check for Cargo.toml in the root package directory const cargoTomlPath = path.join(packageDir, 'Cargo.toml'); if (fs.existsSync(cargoTomlPath)) { - stageCargo(cargoTomlPath); + updatePackageVersion(cargoTomlPath, version, dryRun); + updatedFiles.push(cargoTomlPath); } } diff --git a/packages/version/src/errors/cargoLockSyncError.ts b/packages/version/src/errors/cargoLockSyncError.ts new file mode 100644 index 00000000..5b9c464e --- /dev/null +++ b/packages/version/src/errors/cargoLockSyncError.ts @@ -0,0 +1,10 @@ +import { VersionError } from './versionError.js'; + +/** Fatal on purpose: a lock left at the old version breaks every later `cargo --locked` run. */ +export class CargoLockSyncError extends VersionError { + constructor(details: string) { + super(`Failed to sync Cargo.lock: ${details}`, 'CARGO_LOCK_SYNC_ERROR', [ + 'If the lock also lists the crate from a registry or git source, sync it with cargo (`cargo update` with the new version in Cargo.toml) and commit it, or drop the second source', + ]); + } +} diff --git a/packages/version/src/index.ts b/packages/version/src/index.ts index 89bcac23..7600fa09 100644 --- a/packages/version/src/index.ts +++ b/packages/version/src/index.ts @@ -1,7 +1,6 @@ // Re-export public API export type { VersionOutput } from '@releasekit/core'; -export { syncCargoLockfile } from './cargo/cargoLock.js'; export { extractChangelogEntriesFromCommits } from './changelog/commitParser.js'; export { createVersionCommand } from './command.js'; export { loadConfig } from './config.js'; diff --git a/packages/version/test/unit/cargo/cargoHandler.spec.ts b/packages/version/test/unit/cargo/cargoHandler.spec.ts index d367120e..b9b1932d 100644 --- a/packages/version/test/unit/cargo/cargoHandler.spec.ts +++ b/packages/version/test/unit/cargo/cargoHandler.spec.ts @@ -3,11 +3,13 @@ import path from 'node:path'; import * as TOML from 'smol-toml'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getCargoInfo, isCargoToml, updateCargoVersion } from '../../../src/cargo/cargoHandler.js'; +import { recordCargoBump } from '../../../src/cargo/cargoLock.js'; import * as jsonOutput from '../../../src/utils/jsonOutput.js'; import * as logging from '../../../src/utils/logging.js'; // Mock dependencies vi.mock('node:fs'); +vi.mock('../../../src/cargo/cargoLock.js'); vi.mock('../../../src/utils/jsonOutput.js'); vi.mock('../../../src/utils/logging.js'); vi.mock('smol-toml', () => ({ @@ -150,6 +152,11 @@ describe('Cargo Handler', () => { // Should still track the update and log expect(jsonOutput.addPackageUpdate).toHaveBeenCalledWith('test-package', '2.0.0', mockCargoPath); expect(logging.log).toHaveBeenCalledWith(expect.stringContaining('[DRY RUN] Would update'), 'success'); + expect(recordCargoBump).toHaveBeenCalledWith({ + cargoTomlPath: mockCargoPath, + name: 'test-package', + version: '2.0.0', + }); }); it('should write to disk when dryRun is false', () => { @@ -160,6 +167,11 @@ describe('Cargo Handler', () => { expect(TOML.stringify).toHaveBeenCalled(); expect(fs.writeFileSync).toHaveBeenCalledWith(mockCargoPath, 'mocked stringified TOML'); expect(jsonOutput.addPackageUpdate).toHaveBeenCalledWith('test-package', '2.0.0', mockCargoPath); + expect(recordCargoBump).toHaveBeenCalledWith({ + cargoTomlPath: mockCargoPath, + name: 'test-package', + version: '2.0.0', + }); }); it('should handle errors when updating Cargo.toml', () => { @@ -170,6 +182,7 @@ describe('Cargo Handler', () => { expect(() => updateCargoVersion(mockCargoPath, '2.0.0')).toThrow(); expect(logging.log).toHaveBeenCalledWith(expect.stringContaining('Failed to update Cargo.toml at'), 'error'); + expect(recordCargoBump).not.toHaveBeenCalled(); }); it('should throw an error if package name is not found', () => { diff --git a/packages/version/test/unit/cargo/cargoLock.spec.ts b/packages/version/test/unit/cargo/cargoLock.spec.ts index 49b3f5ed..0fb59c14 100644 --- a/packages/version/test/unit/cargo/cargoLock.spec.ts +++ b/packages/version/test/unit/cargo/cargoLock.spec.ts @@ -1,72 +1,427 @@ +/** + * Cargo.lock sync on every path that bumps a Cargo.toml. Each test runs a real strategy through + * `VersionEngine.run` over a git work tree on disk (only tag lookups, commit parsing and the version + * calculation are mocked) and checks the crates' own lock and a fixture app's lock. + */ import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; -import { findCargoLockfile } from '@releasekit/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { syncCargoLockfile } from '../../../src/cargo/cargoLock.js'; -import { log } from '../../../src/utils/logging.js'; +import type { Package, Tool } from '@manypkg/get-packages'; +import { EXIT_CODES, exitCodeForError } from '@releasekit/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as commitParser from '../../../src/changelog/commitParser.js'; +import * as calculator from '../../../src/core/versionCalculator.js'; +import { type PackagesWithRoot, VersionEngine } from '../../../src/core/versionEngine.js'; +import type { StrategyType } from '../../../src/core/versionStrategies.js'; +import * as gitTags from '../../../src/git/tagsAndBranches.js'; +import * as tagVerification from '../../../src/git/tagVerification.js'; +import type { Config } from '../../../src/types.js'; +import { enableJsonOutput, flushPendingWrites } from '../../../src/utils/jsonOutput.js'; -vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })); -vi.mock('@releasekit/core', () => ({ findCargoLockfile: vi.fn() })); -vi.mock('../../../src/utils/logging.js', () => ({ log: vi.fn() })); +vi.mock('../../../src/git/tagsAndBranches.js'); +vi.mock('../../../src/git/tagVerification.js'); +vi.mock('../../../src/core/versionCalculator.js'); +vi.mock('../../../src/changelog/commitParser.js'); +vi.mock('../../../src/utils/logging.js'); -const crateDir = path.join('/repo', 'crates', 'foo'); -const cargoToml = path.join(crateDir, 'Cargo.toml'); -const lockPath = path.join('/repo', 'Cargo.lock'); +const REGISTRY = 'registry+https://github.com/rust-lang/crates.io-index'; +const CHECKSUM = 'checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"'; -describe('syncCargoLockfile', () => { - afterEach(() => vi.clearAllMocks()); +type Versions = { alpha: string; beta: string }; - it('should run an offline workspace-scoped cargo update and return the lock path', () => { - vi.mocked(findCargoLockfile).mockReturnValue(lockPath); +/** The workspace lock as cargo writes it: alpha depends on beta (path) and serde (registry). */ +function workspaceLock(versions: Versions): string { + return [ + '# This file is automatically @generated by Cargo.', + '# It is not intended for manual editing.', + 'version = 3', + '', + '[[package]]', + 'name = "alpha"', + `version = "${versions.alpha}"`, + 'dependencies = [', + ' "beta",', + ' "serde",', + ']', + '', + '[[package]]', + 'name = "beta"', + `version = "${versions.beta}"`, + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.200"', + `source = "${REGISTRY}"`, + CHECKSUM, + '', + ].join('\n'); +} - const result = syncCargoLockfile(cargoToml); +/** + * The fixture app's lock: it uses alpha by path and also the published alpha 0.9.0, so cargo spells + * out versions in references to alpha, and the registry entry must never move. + */ +function appLock(versions: Versions): string { + return [ + 'version = 4', + '', + '[[package]]', + 'name = "alpha"', + 'version = "0.9.0"', + `source = "${REGISTRY}"`, + CHECKSUM, + '', + '[[package]]', + 'name = "alpha"', + `version = "${versions.alpha}"`, + 'dependencies = [', + ' "beta",', + ' "serde",', + ']', + '', + '[[package]]', + 'name = "beta"', + `version = "${versions.beta}"`, + '', + '[[package]]', + 'name = "fixture-app"', + 'version = "0.1.0"', + 'dependencies = [', + ' "alpha 0.9.0",', + ` "alpha ${versions.alpha}",`, + ']', + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.200"', + `source = "${REGISTRY}"`, + CHECKSUM, + '', + ].join('\n'); +} - expect(result).toBe(lockPath); - expect(execFileSync).toHaveBeenCalledWith('cargo', ['update', '--workspace', '--offline'], { - cwd: crateDir, - stdio: 'pipe', - }); +function writeCrate(dir: string, name: string, version: string, extra = ''): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'Cargo.toml'), + `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\n${extra}`, + ); +} + +function crate(root: string, name: string): Package { + return { + dir: path.join(root, 'crates', name), + relativeDir: `crates/${name}`, + packageJson: { name, version: '1.0.0' }, + } as unknown as Package; +} + +function workspace(root: string, packages: Package[]): PackagesWithRoot { + return { root, rootDir: root, tool: 'cargo' as unknown as Tool, packages } as unknown as PackagesWithRoot; +} + +const baseConfig = (overrides: Partial = {}): Config => + ({ + sync: false, + preset: 'conventional', + packages: [], + tagTemplate: '${prefix}${version}', + versionPrefix: 'v', + ...overrides, + }) as Config; + +/** Make `calculateVersion` bump exactly the named packages. */ +function bump(next: Record): void { + vi.mocked(calculator.calculateVersion).mockImplementation(async (_config, options) => next[options.name ?? ''] ?? ''); +} + +async function runVersion(strategy: StrategyType, config: Partial, packages: PackagesWithRoot): Promise { + const engine = new VersionEngine(baseConfig(config)); + engine.setStrategy(strategy); + await engine.run(packages); +} + +const git = (cwd: string, ...args: string[]): string => execFileSync('git', args, { cwd, encoding: 'utf8' }); + +describe('Cargo.lock sync on Cargo.toml bumps', () => { + let root: string; + let ownLock: string; + let fixtureLock: string; + + const read = (file: string): string => fs.readFileSync(file, 'utf8'); + const both = (): Package[] => [crate(root, 'alpha'), crate(root, 'beta')]; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(gitTags.getLatestTag).mockResolvedValue(''); + vi.mocked(gitTags.getLatestTagForPackage).mockResolvedValue(''); + vi.mocked(tagVerification.verifyTag, { partial: true }).mockResolvedValue({ exists: true, reachable: true }); + vi.mocked(commitParser.extractChangelogEntriesFromCommits).mockResolvedValue([]); + vi.mocked(commitParser.extractRepoLevelChangelogEntries).mockResolvedValue([]); + + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-paths-'))); + fs.mkdirSync(path.join(root, 'crates')); + fs.writeFileSync(path.join(root, 'crates', 'Cargo.toml'), '[workspace]\nmembers = ["alpha", "beta"]\n'); + writeCrate( + path.join(root, 'crates', 'alpha'), + 'alpha', + '1.0.0', + '\n[dependencies]\nbeta = { path = "../beta" }\nserde = "1"\n', + ); + writeCrate(path.join(root, 'crates', 'beta'), 'beta', '1.0.0'); + ownLock = path.join(root, 'crates', 'Cargo.lock'); + fs.writeFileSync(ownLock, workspaceLock({ alpha: '1.0.0', beta: '1.0.0' })); + + writeCrate( + path.join(root, 'fixtures', 'app'), + 'fixture-app', + '0.1.0', + 'publish = false\n\n[workspace]\n\n[dependencies]\nalpha = { path = "../../crates/alpha" }\nalpha-old = { package = "alpha", version = "0.9" }\n', + ); + fixtureLock = path.join(root, 'fixtures', 'app', 'Cargo.lock'); + fs.writeFileSync(fixtureLock, appLock({ alpha: '1.0.0', beta: '1.0.0' })); + + git(root, 'init', '-q'); + git(root, 'add', '-A'); }); - it('should skip the refresh and not run cargo under dry-run', () => { - vi.mocked(findCargoLockfile).mockReturnValue(lockPath); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each<[string, StrategyType, Partial, 'alpha' | 'both', Versions]>([ + [ + 'a version group', + 'group', + { groups: { rust: { packages: ['alpha', 'beta'], sync: 'fixed' } } }, + 'both', + { alpha: '1.1.0', beta: '1.1.0' }, + ], + [ + 'the ungrouped path in group mode', + 'group', + { groups: { other: { packages: ['beta'], sync: 'fixed' } } }, + 'both', + { alpha: '1.1.0', beta: '1.0.0' }, + ], + ['the per-package processor', 'async', {}, 'both', { alpha: '1.1.0', beta: '1.0.0' }], + ['updateCargoFiles in a sync release', 'sync', { sync: true }, 'both', { alpha: '1.1.0', beta: '1.1.0' }], + ['updateCargoFiles in a single-package release', 'single', {}, 'alpha', { alpha: '1.1.0', beta: '1.0.0' }], + ])( + 'should sync every lock that records a crate bumped through %s', + async (_label, strategy, config, scope, expected) => { + bump({ alpha: '1.1.0' }); + const packages = scope === 'both' ? both() : [crate(root, 'alpha')]; + + await runVersion(strategy, config, workspace(root, packages)); - const result = syncCargoLockfile(cargoToml, true); + expect(read(ownLock)).toBe(workspaceLock(expected)); + expect(read(fixtureLock)).toBe(appLock(expected)); + expect(git(root, 'diff', '--name-only', '--', '*.lock')).toBe('crates/Cargo.lock\nfixtures/app/Cargo.lock\n'); + }, + ); - expect(result).toBeUndefined(); - expect(execFileSync).not.toHaveBeenCalled(); + it('should sync the lock for a Cargo.toml found through cargo.paths (Tauri layout)', async () => { + // A JS package whose crate lives in src-tauri/ with its own committed lock. + const appDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cargo-lock-tauri-'))); + const crateLock = path.join(appDir, 'src-tauri', 'Cargo.lock'); + fs.writeFileSync(path.join(appDir, 'package.json'), '{ "name": "desktop", "version": "0.4.0" }\n'); + writeCrate(path.join(appDir, 'src-tauri'), 'desktop', '0.4.0'); + fs.writeFileSync(crateLock, 'version = 4\n\n[[package]]\nname = "desktop"\nversion = "0.4.0"\n'); + const app = { dir: appDir, relativeDir: '.', packageJson: { name: 'desktop', version: '0.4.0' } } as unknown; + bump({ desktop: '0.5.0' }); + + try { + await runVersion( + 'async', + { cargo: { enabled: true, paths: ['src-tauri'] } }, + workspace(appDir, [app as Package]), + ); + expect(read(crateLock)).toBe('version = 4\n\n[[package]]\nname = "desktop"\nversion = "0.5.0"\n'); + } finally { + fs.rmSync(appDir, { recursive: true, force: true }); + } }); - it('should do nothing when there is no committed lock above the crate', () => { - vi.mocked(findCargoLockfile).mockReturnValue(undefined); + const tauriLock = (version: string): string => `version = 4\n\n[[package]]\nname = "app"\nversion = "${version}"\n`; + const tauriLockOf = (name: string): string => path.join(root, 'apps', name, 'src-tauri', 'Cargo.lock'); + + /** A JS package `apps/` whose src-tauri crate has the `tauri init` name, `app`. */ + function tauriApp(name: string, version: string, withLock = true): Package { + const dir = path.join(root, 'apps', name); + writeCrate(path.join(dir, 'src-tauri'), 'app', version); + if (withLock) { + fs.writeFileSync(tauriLockOf(name), tauriLock(version)); + } + fs.writeFileSync(path.join(dir, 'package.json'), `{ "name": "${name}", "version": "${version}" }\n`); + return { dir, relativeDir: `apps/${name}`, packageJson: { name, version } } as unknown as Package; + } + + it.each([ + ['both apps', { one: '0.2.0', two: '0.1.1' }], + ['one app', { one: '0.2.0' }], + ])( + 'should sync each Tauri app’s lock to its own crate when the crates share a name (%s bumped)', + async (_label, next: Record) => { + const apps = [tauriApp('one', '0.1.0'), tauriApp('two', '0.1.0')]; + git(root, 'add', '-A'); + bump(next); - const result = syncCargoLockfile(cargoToml); + await runVersion('async', { cargo: { enabled: true, paths: ['src-tauri'] } }, workspace(root, apps)); - expect(result).toBeUndefined(); - expect(execFileSync).not.toHaveBeenCalled(); + for (const name of ['one', 'two']) { + expect(read(tauriLockOf(name))).toBe(tauriLock(next[name] ?? '0.1.0')); + } + }, + ); + + it('should sync excluded apps’ own locks, not the ancestor workspace’s, when the crates share a name', async () => { + fs.writeFileSync( + path.join(root, 'Cargo.toml'), + '[workspace]\nmembers = ["shared"]\nexclude = ["apps/one/src-tauri", "apps/two/src-tauri"]\n', + ); + const rootLock = 'version = 4\n\n[[package]]\nname = "shared"\nversion = "1.0.0"\n'; + fs.writeFileSync(path.join(root, 'Cargo.lock'), rootLock); + const apps = [tauriApp('one', '0.1.0'), tauriApp('two', '0.3.0')]; + git(root, 'add', '-A'); + bump({ one: '0.2.0', two: '0.4.0' }); + + await runVersion('async', { cargo: { enabled: true, paths: ['src-tauri'] } }, workspace(root, apps)); + + expect(read(tauriLockOf('one'))).toBe(tauriLock('0.2.0')); + expect(read(tauriLockOf('two'))).toBe(tauriLock('0.4.0')); + expect(read(path.join(root, 'Cargo.lock'))).toBe(rootLock); }); - it('should warn and return undefined when cargo is not on PATH', () => { - vi.mocked(findCargoLockfile).mockReturnValue(lockPath); - vi.mocked(execFileSync).mockImplementation(() => { - throw Object.assign(new Error('spawn cargo ENOENT'), { code: 'ENOENT' }); - }); + it('should leave an ancestor workspace’s member alone when an excluded crate of the same name is bumped', async () => { + fs.writeFileSync( + path.join(root, 'Cargo.toml'), + '[workspace]\nmembers = ["apps/one/src-tauri"]\nexclude = ["apps/two/src-tauri"]\n', + ); + fs.writeFileSync(path.join(root, 'Cargo.lock'), tauriLock('0.1.0')); + const apps = [tauriApp('one', '0.1.0', false), tauriApp('two', '0.3.0')]; + git(root, 'add', '-A'); + bump({ two: '0.4.0' }); + + await runVersion('async', { cargo: { enabled: true, paths: ['src-tauri'] } }, workspace(root, apps)); + + expect(read(tauriLockOf('two'))).toBe(tauriLock('0.4.0')); + expect(read(path.join(root, 'Cargo.lock'))).toBe(tauriLock('0.1.0')); + }); + + it('should sync the lock of a crate that an ancestor Cargo workspace excludes', async () => { + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nexclude = ["apps/desk/src-tauri"]\n'); + const appDir = path.join(root, 'apps', 'desk'); + const appLock = path.join(appDir, 'src-tauri', 'Cargo.lock'); + writeCrate(path.join(appDir, 'src-tauri'), 'desk', '0.1.0'); + fs.writeFileSync(appLock, 'version = 4\n\n[[package]]\nname = "desk"\nversion = "0.1.0"\n'); + fs.writeFileSync(path.join(appDir, 'package.json'), '{ "name": "desk", "version": "0.1.0" }\n'); + git(root, 'add', '-A'); + bump({ desk: '0.2.0' }); + const app = { dir: appDir, relativeDir: 'apps/desk', packageJson: { name: 'desk', version: '0.1.0' } }; - const result = syncCargoLockfile(cargoToml); + await runVersion( + 'async', + { cargo: { enabled: true, paths: ['src-tauri'] } }, + workspace(root, [app as unknown as Package]), + ); - expect(result).toBeUndefined(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('cargo not found on PATH'), 'warning'); + expect(read(appLock)).toBe('version = 4\n\n[[package]]\nname = "desk"\nversion = "0.2.0"\n'); }); - it('should warn with the cargo error and return undefined when the refresh fails', () => { - vi.mocked(findCargoLockfile).mockReturnValue(lockPath); - vi.mocked(execFileSync).mockImplementation(() => { - throw Object.assign(new Error('exit 101'), { stderr: Buffer.from('error: version conflict') }); + it('should sync the locks without a Rust toolchain or a crates.io index', async () => { + // A fresh CI runner: git on PATH but no cargo, and an empty CARGO_HOME. Nothing may shell out to cargo. + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'rk-no-cargo-')); + const gitDir = (process.env.PATH ?? '').split(path.delimiter).find((dir) => fs.existsSync(path.join(dir, 'git'))); + fs.symlinkSync(path.join(gitDir as string, 'git'), path.join(bin, 'git')); + vi.stubEnv('PATH', bin); + vi.stubEnv('CARGO_HOME', bin); + bump({ alpha: '1.1.0' }); + + try { + await runVersion('group', { groups: { rust: { packages: ['alpha'], sync: 'fixed' } } }, workspace(root, both())); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(bin, { recursive: true, force: true }); + } + + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.1.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.1.0', beta: '1.0.0' })); + }); + + it('should leave the locks on disk under dry-run and hand the edits to the release flush', async () => { + // `release` runs the version step as a dry run with JSON output, then flushes the pending writes. + enableJsonOutput(true); + bump({ alpha: '1.1.0' }); + + await runVersion('async', { dryRun: true }, workspace(root, both())); + + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.0.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.0.0', beta: '1.0.0' })); + flushPendingWrites(); + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.1.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.1.0', beta: '1.0.0' })); + }); + + it('should fail the run, writing no lock, when a committed lock cannot be synced', async () => { + // A registry copy of beta at the target version: cargo would have to add a source to beta's + // references, so the sync refuses to guess. + const ambiguous = `${appLock({ alpha: '1.0.0', beta: '1.0.0' }).replace(' "beta",', ' "beta 1.0.0",')}\n[[package]]\nname = "beta"\nversion = "2.0.0"\nsource = "${REGISTRY}"\n`; + fs.writeFileSync(fixtureLock, ambiguous); + bump({ beta: '2.0.0' }); + + const run = runVersion('async', {}, workspace(root, both())); + + await expect(run).rejects.toMatchObject({ code: 'CARGO_LOCK_SYNC_ERROR' }); + expect(exitCodeForError(await run.catch((error: unknown) => error))).toBe(EXIT_CODES.VERSION_ERROR); + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.0.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(ambiguous); + }); + + it('should fail a dry run on a lock that cannot be synced, before anything is written', async () => { + fs.writeFileSync( + fixtureLock, + `${read(fixtureLock)}\n[[package]]\nname = "beta"\nversion = "2.0.0"\nsource = "${REGISTRY}"\n`, + ); + bump({ beta: '2.0.0' }); + + await expect(runVersion('async', { dryRun: true }, workspace(root, both()))).rejects.toMatchObject({ + code: 'CARGO_LOCK_SYNC_ERROR', }); + }); + + it('should sync a crate’s own lock even when untracked, but leave other untracked locks alone', async () => { + git(root, 'rm', '-q', '--cached', 'crates/Cargo.lock', 'fixtures/app/Cargo.lock'); + bump({ alpha: '1.1.0' }); + + await runVersion('async', {}, workspace(root, both())); + + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.1.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.0.0', beta: '1.0.0' })); + }); + + it('should not sync a crate’s own lock that git ignores', async () => { + // A library that ignores its lock, left on disk by an earlier `cargo test`: it can't be staged. + git(root, 'rm', '-q', '--cached', 'crates/Cargo.lock'); + fs.writeFileSync(path.join(root, '.gitignore'), 'Cargo.lock\n'); + bump({ alpha: '1.1.0' }); + + await runVersion('async', {}, workspace(root, both())); + + expect(read(ownLock)).toBe(workspaceLock({ alpha: '1.0.0', beta: '1.0.0' })); + expect(read(fixtureLock)).toBe(appLock({ alpha: '1.1.0', beta: '1.0.0' })); + }); + + it('should not create a lock when none is committed', async () => { + fs.rmSync(ownLock); + fs.rmSync(fixtureLock); + bump({ alpha: '1.1.0' }); - const result = syncCargoLockfile(cargoToml); + await runVersion('async', {}, workspace(root, both())); - expect(result).toBeUndefined(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('error: version conflict'), 'warning'); + expect(fs.existsSync(ownLock)).toBe(false); + expect(fs.existsSync(fixtureLock)).toBe(false); + expect(read(path.join(root, 'crates', 'alpha', 'Cargo.toml'))).toContain('version = "1.1.0"'); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44ee6ec1..70683f06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,6 +317,9 @@ importers: minimatch: specifier: 'catalog:' version: 10.2.6 + smol-toml: + specifier: 'catalog:' + version: 1.9.1 devDependencies: '@biomejs/biome': specifier: 'catalog:' diff --git a/test/integration/cargo-lock-sync.spec.ts b/test/integration/cargo-lock-sync.spec.ts index b1970a17..7b93d0d3 100644 --- a/test/integration/cargo-lock-sync.spec.ts +++ b/test/integration/cargo-lock-sync.spec.ts @@ -1,18 +1,17 @@ /** - * Integration test: Cargo.lock self-version sync (#496) against REAL cargo. - * - * Unit tests mock cargo, so they can't prove the load-bearing claim — that - * `cargo update --workspace --offline` syncs a workspace member's self-version in the lock WITHOUT - * re-resolving other members/dependencies. This runs real cargo on a real workspace to verify exactly - * that. Skipped automatically where cargo isn't on PATH (CI installs a Rust toolchain for this job). + * Cargo.lock sync checked against real cargo: after the built `releasekit version` runs without cargo, + * cargo must accept every synced lock under `--locked` unchanged, and the edit must match + * `cargo update --workspace` byte for byte. Skipped where cargo isn't installed. */ -import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { syncCargoLockfile } from '@releasekit/version'; +import { delimiter, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +const VERSION_CLI = fileURLToPath(new URL('../../packages/version/dist/cli.js', import.meta.url)); + function cargoAvailable(): boolean { try { execFileSync('cargo', ['--version'], { stdio: 'ignore' }); @@ -22,58 +21,267 @@ function cargoAvailable(): boolean { } } -/** The version on a crate's `[[package]]` entry in a Cargo.lock. */ -function lockVersion(lockContent: string, crate: string): string | undefined { - const block = lockContent.split('[[package]]').find((b) => new RegExp(`^\\s*name = "${crate}"\\s*$`, 'm').test(b)); - return block?.match(/^\s*version = "([^"]+)"/m)?.[1]; +function writeCrate(dir: string, name: string, version: string, extra = ''): void { + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync( + join(dir, 'Cargo.toml'), + `[package]\nname = "${name}"\nversion = "${version}"\nedition = "2021"\n${extra}`, + ); + writeFileSync(join(dir, 'src', 'lib.rs'), '// test crate\n'); +} + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', ['-c', 'user.name=test', '-c', 'user.email=test@example.com', ...args], { cwd, stdio: 'ignore' }); +} + +/** Versions of the sourceless (path or workspace) `[[package]]` entries named `name` in a lock. */ +function pathEntryVersions(lockContent: string, name: string): string[] { + return lockContent + .split('[[package]]') + .filter((entry) => entry.includes(`name = "${name}"\n`) && !/^source = /m.test(entry)) + .map((entry) => /^version = "([^"]+)"/m.exec(entry)?.[1] ?? ''); } -describe.skipIf(!cargoAvailable())('syncCargoLockfile (real cargo)', () => { +function crateVersion(manifest: string): string { + return /^version = "([^"]+)"/m.exec(readFileSync(manifest, 'utf-8'))?.[1] ?? ''; +} + +describe.skipIf(!cargoAvailable())('Cargo.lock sync (real cargo)', () => { let dir: string; + let emptyCargoHome: string; + let warmCargoHome: string; + + const cargo = (cwd: string, cargoHome: string, ...args: string[]): void => { + execFileSync('cargo', args, { cwd, stdio: 'pipe', env: { ...process.env, CARGO_HOME: cargoHome } }); + }; + + /** Commit the repo, tag every crate at its current version, then land a `feat:` change in `changedCrate`. */ + function commitWithChange(repo: string, crates: string[], changedCrate: string): void { + // Workspace discovery starts from a root package.json, as in a JS monorepo that ships crates. + writeFileSync(join(repo, 'package.json'), '{ "name": "rk-fixture", "private": true }\n'); + git(repo, 'init', '-q'); + git(repo, 'add', '-A'); + git(repo, 'commit', '-qm', 'chore: init'); + for (const name of crates) { + git(repo, 'tag', `${name}@v0.1.0`); + } + writeFileSync(join(changedCrate, 'src', 'lib.rs'), '// a new feature\n'); + git(repo, 'commit', '-qam', 'feat: add a feature'); + } + + /** Run `releasekit version` the way a fresh CI runner would: git on PATH, no cargo, an empty CARGO_HOME. */ + function releasekitVersion(repo: string, versionConfig: Record): string { + writeFileSync( + join(repo, 'releasekit.config.json'), + JSON.stringify({ + version: { + preset: 'angular', + sync: false, + versionPrefix: 'v', + tagTemplate: '${packageName}@v${version}', + packageSpecificTags: true, + ...versionConfig, + }, + }), + ); + const bin = join(dir, 'bin'); + if (!existsSync(bin)) { + mkdirSync(bin); + const gitDir = (process.env.PATH ?? '').split(delimiter).find((entry) => existsSync(join(entry, 'git'))); + symlinkSync(join(gitDir as string, 'git'), join(bin, 'git')); + } + const result = spawnSync(process.execPath, [VERSION_CLI], { + cwd: repo, + encoding: 'utf-8', + env: { ...process.env, PATH: bin, CARGO_HOME: emptyCargoHome }, + }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + return result.stdout; + } beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'rk-cargo-lock-int-')); - // Two-member workspace: crate-a depends on crate-b by path. Bumping crate-a must move only its - // own lock entry — crate-b and the a→b dependency edge stay put (no dependency re-resolution). - writeFileSync(join(dir, 'Cargo.toml'), '[workspace]\nmembers = ["crate-a", "crate-b"]\nresolver = "2"\n'); - for (const [name, deps] of [ - ['crate-a', '\n[dependencies]\ncrate-b = { path = "../crate-b", version = "0.1.0" }\n'], - ['crate-b', ''], - ] as const) { - const crateDir = join(dir, name); - mkdirSync(join(crateDir, 'src'), { recursive: true }); - writeFileSync( - join(crateDir, 'Cargo.toml'), - `[package]\nname = "${name}"\nversion = "0.1.0"\nedition = "2021"\n${deps}`, - ); - writeFileSync(join(crateDir, 'src', 'lib.rs'), '// test crate\n'); - } - // Generate a real, valid committed lock at the stale versions (offline — only path deps). - execFileSync('cargo', ['generate-lockfile', '--offline'], { cwd: dir, stdio: 'ignore' }); + emptyCargoHome = join(dir, 'empty-cargo-home'); + mkdirSync(emptyCargoHome); + warmCargoHome = join(dir, 'warm-cargo-home'); }); afterEach(() => rmSync(dir, { recursive: true, force: true })); - it('should sync the bumped crate’s self-version in the workspace-root lock, leaving others untouched', () => { - // Bump crate-a's manifest (what the version step does before calling the sync). - writeFileSync( - join(dir, 'crate-a', 'Cargo.toml'), - '[package]\nname = "crate-a"\nversion = "0.2.0"\nedition = "2021"\n\n[dependencies]\ncrate-b = { path = "../crate-b", version = "0.1.0" }\n', + it.each([ + [ + 'version.groups', + { groups: { rust: { packages: ['rk-core', 'rk-cli'], sync: 'fixed' } } }, + 'Using version-groups strategy', + ], + ['the per-package processor', {}, 'Using async versioning strategy'], + ['updateCargoFiles', { sync: true }, 'Using sync versioning strategy'], + ])( + 'should leave every lock that records a crate bumped through %s accepted by cargo --locked', + (_path, config, strategyLog) => { + // A Cargo workspace with its own lock, plus a fixture app (its own workspace and lock) that uses + // the workspace's crates by path, as webdriverio/desktop-mobile's Tauri and Dioxus apps do. + const repo = join(dir, 'repo'); + mkdirSync(join(repo, 'crates'), { recursive: true }); + writeFileSync(join(repo, 'crates', 'Cargo.toml'), '[workspace]\nmembers = ["core", "cli"]\nresolver = "2"\n'); + writeCrate(join(repo, 'crates', 'core'), 'rk-core', '0.1.0'); + writeCrate(join(repo, 'crates', 'cli'), 'rk-cli', '0.1.0', '\n[dependencies]\nrk-core = { path = "../core" }\n'); + writeCrate( + join(repo, 'fixtures', 'app'), + 'fixture-app', + '0.1.0', + 'publish = false\n\n[workspace]\n\n[dependencies]\nrk-cli = { path = "../../crates/cli" }\n', + ); + const manifests = [join(repo, 'crates', 'Cargo.toml'), join(repo, 'fixtures', 'app', 'Cargo.toml')]; + for (const manifest of manifests) { + cargo(repo, emptyCargoHome, 'generate-lockfile', '--offline', '--manifest-path', manifest); + } + commitWithChange(repo, ['rk-core', 'rk-cli'], join(repo, 'crates', 'core')); + + expect(releasekitVersion(repo, config)).toContain(strategyLog); + + const bumped = ['core', 'cli'] + .map((crate) => ({ name: `rk-${crate}`, version: crateVersion(join(repo, 'crates', crate, 'Cargo.toml')) })) + .filter(({ version }) => version !== '0.1.0'); + expect(bumped.map(({ name }) => name)).toContain('rk-core'); + for (const manifest of manifests) { + const lockPath = join(manifest, '..', 'Cargo.lock'); + const lock = readFileSync(lockPath, 'utf-8'); + for (const { name, version } of bumped) { + expect(pathEntryVersions(lock, name), `${name} in ${lockPath}`).toEqual([version]); + } + cargo( + repo, + emptyCargoHome, + 'metadata', + '--locked', + '--offline', + '--format-version', + '1', + '--manifest-path', + manifest, + ); + expect(readFileSync(lockPath, 'utf-8')).toBe(lock); + } + }, + ); + + it('should leave a same-named crate’s lock alone when another Tauri app is bumped', () => { + // Two Tauri apps whose src-tauri crates both have the `tauri init` name and version, `app` 0.1.0. + const repo = join(dir, 'repo'); + const manifest = (app: string): string => join(repo, 'apps', app, 'src-tauri', 'Cargo.toml'); + const lockOf = (app: string): string => join(repo, 'apps', app, 'src-tauri', 'Cargo.lock'); + for (const app of ['one', 'two']) { + writeCrate(join(repo, 'apps', app, 'src-tauri'), 'app', '0.1.0'); + writeFileSync(join(repo, 'apps', app, 'package.json'), `{ "name": "${app}", "version": "0.1.0" }\n`); + cargo(repo, emptyCargoHome, 'generate-lockfile', '--offline', '--manifest-path', manifest(app)); + } + writeFileSync(join(repo, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n'); + commitWithChange(repo, ['one', 'two'], join(repo, 'apps', 'one', 'src-tauri')); + const untouched = readFileSync(lockOf('two'), 'utf-8'); + + releasekitVersion(repo, { cargo: { enabled: true, paths: ['src-tauri'] } }); + + expect(crateVersion(manifest('one'))).toBe('0.2.0'); + expect(pathEntryVersions(readFileSync(lockOf('one'), 'utf-8'), 'app')).toEqual(['0.2.0']); + expect(readFileSync(lockOf('two'), 'utf-8')).toBe(untouched); + for (const app of ['one', 'two']) { + const lock = readFileSync(lockOf(app), 'utf-8'); + cargo( + repo, + emptyCargoHome, + 'metadata', + '--locked', + '--offline', + '--format-version', + '1', + '--manifest-path', + manifest(app), + ); + expect(readFileSync(lockOf(app), 'utf-8')).toBe(lock); + } + }); + + it('should leave another workspace’s lock alone when it uses its own crate of the same name and version', () => { + const repo = join(dir, 'repo'); + mkdirSync(join(repo, 'crates'), { recursive: true }); + writeFileSync(join(repo, 'crates', 'Cargo.toml'), '[workspace]\nmembers = ["utils"]\nresolver = "2"\n'); + writeCrate(join(repo, 'crates', 'utils'), 'utils', '0.1.0'); + writeCrate( + join(repo, 'fixtures', 'app'), + 'fixture-app', + '0.1.0', + 'publish = false\n\n[workspace]\n\n[dependencies]\nutils = { path = "utils" }\n', ); + writeCrate(join(repo, 'fixtures', 'app', 'utils'), 'utils', '0.1.0', 'publish = false\n'); + const manifests = [join(repo, 'crates', 'Cargo.toml'), join(repo, 'fixtures', 'app', 'Cargo.toml')]; + for (const manifest of manifests) { + cargo(repo, emptyCargoHome, 'generate-lockfile', '--offline', '--manifest-path', manifest); + } + commitWithChange(repo, ['utils'], join(repo, 'crates', 'utils')); + const fixtureLock = join(repo, 'fixtures', 'app', 'Cargo.lock'); + const untouched = readFileSync(fixtureLock, 'utf-8'); - const lockPath = syncCargoLockfile(join(dir, 'crate-a', 'Cargo.toml')); + releasekitVersion(repo, {}); - expect(lockPath).toBe(join(dir, 'Cargo.lock')); - const lock = readFileSync(join(dir, 'Cargo.lock'), 'utf-8'); - expect(lockVersion(lock, 'crate-a')).toBe('0.2.0'); // self-version synced - expect(lockVersion(lock, 'crate-b')).toBe('0.1.0'); // other member untouched - expect(lock).toContain('crate-b'); // a→b dependency edge preserved (no re-resolution) + expect(crateVersion(join(repo, 'crates', 'utils', 'Cargo.toml'))).toBe('0.2.0'); + expect(pathEntryVersions(readFileSync(join(repo, 'crates', 'Cargo.lock'), 'utf-8'), 'utils')).toEqual(['0.2.0']); + expect(readFileSync(fixtureLock, 'utf-8')).toBe(untouched); + for (const manifest of manifests) { + const lockPath = join(manifest, '..', 'Cargo.lock'); + const lock = readFileSync(lockPath, 'utf-8'); + cargo( + repo, + emptyCargoHome, + 'metadata', + '--locked', + '--offline', + '--format-version', + '1', + '--manifest-path', + manifest, + ); + expect(readFileSync(lockPath, 'utf-8')).toBe(lock); + } }); - it('should not create a lock when none is committed', () => { - rmSync(join(dir, 'Cargo.lock')); - const lockPath = syncCargoLockfile(join(dir, 'crate-a', 'Cargo.toml')); - expect(lockPath).toBeUndefined(); - expect(() => readFileSync(join(dir, 'Cargo.lock'))).toThrow(); + it('should write the same lock cargo writes, also when the bump passes other versions of the crate', () => { + // Git crates also named `crate-b` make cargo spell out versions in its references. Bumping 0.1.0 to + // 0.2.0 passes 0.1.5 in entry order (semver) and 0.10.0 in reference order (string), so both re-sort. + const gitDeps = ['0.0.9', '0.1.5', '0.10.0'].map((version) => { + const gitCrate = join(dir, `git-crate-b-${version}`); + writeCrate(gitCrate, 'crate-b', version); + git(gitCrate, 'init', '-q'); + git(gitCrate, 'add', '-A'); + git(gitCrate, 'commit', '-qm', 'init'); + return `b-${version.replaceAll('.', '-')} = { package = "crate-b", git = "file://${gitCrate}" }\n`; + }); + + const ws = join(dir, 'ws'); + mkdirSync(ws); + writeFileSync(join(ws, 'Cargo.toml'), '[workspace]\nmembers = ["crate-a", "crate-b"]\nresolver = "2"\n'); + writeCrate( + join(ws, 'crate-a'), + 'crate-a', + '0.1.0', + `\n[dependencies]\ncrate-b = { path = "../crate-b" }\n${gitDeps.join('')}`, + ); + writeCrate(join(ws, 'crate-b'), 'crate-b', '0.1.0'); + cargo(ws, warmCargoHome, 'generate-lockfile'); + commitWithChange(ws, ['crate-a', 'crate-b'], join(ws, 'crate-b')); + const expected = join(dir, 'expected'); + cpSync(ws, expected, { recursive: true }); + + releasekitVersion(ws, {}); + + expect(crateVersion(join(ws, 'crate-b', 'Cargo.toml'))).toBe('0.2.0'); + // Give cargo the same manifest versions releasekit wrote (it may bump dependents too). + for (const member of ['crate-a', 'crate-b']) { + const manifest = join(expected, member, 'Cargo.toml'); + const version = crateVersion(join(ws, member, 'Cargo.toml')); + writeFileSync(manifest, readFileSync(manifest, 'utf-8').replace('version = "0.1.0"', `version = "${version}"`)); + } + cargo(expected, warmCargoHome, 'update', '--workspace', '--offline'); + expect(readFileSync(join(ws, 'Cargo.lock'), 'utf-8')).toBe(readFileSync(join(expected, 'Cargo.lock'), 'utf-8')); }); });