also looking at this
fix(ecosystems): regenerate the lockfiles the adapters were written to update
#1426
2 files
+194
-1
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -1826,12 +1826,15 @@ export class Buddy { | ||
| 1826 | 1826 | } |
| 1827 | 1827 | |
| 1828 | 1828 | const changes: Array<{ path: string, content: string, type: 'update' }> = [] |
| 1829 | const ecosystems = new Set<string>() | |
| 1829 | 1830 | |
| 1830 | 1831 | for (const [file, fileUpdates] of byFile) { |
| 1831 | 1832 | const adapter = adapterNamed(fileUpdates[0].dependencyType) |
| 1832 | 1833 | if (!adapter) |
| 1833 | 1834 | continue |
| 1834 | 1835 | |
| 1836 | ecosystems.add(adapter.name) | |
| 1837 | ||
| 1835 | 1838 | try { |
| 1836 | 1839 | const path = join(this.projectPath, file) |
| 1837 | 1840 | const original = await Bun.file(path).text() |
| @@ -1860,7 +1863,72 @@ export class Buddy { | ||
| 1860 | 1863 | } |
| 1861 | 1864 | } |
| 1862 | 1865 | |
| 1863 | return changes | |
| 1866 | return [...changes, ...await this.regenerateAdapterLockfiles(changes, [...ecosystems])] | |
| 1867 | } | |
| 1868 | ||
| 1869 | /** | |
| 1870 | * Bring the adapter ecosystems' lockfiles back in step with their manifests. | |
| 1871 | * | |
| 1872 | * `regenerateLockfiles` and every adapter's `postWrite` were written, tested | |
| 1873 | * and exported, and nothing called them — so a Python, Rust, Go or Ruby | |
| 1874 | * manifest was updated and its lockfile left describing the old version. | |
| 1875 | * That is precisely the `lockfile-drift` failure `fix-ci` exists to | |
| 1876 | * diagnose, arriving on a pull request buddy opened itself. | |
| 1877 | * | |
| 1878 | * The manifests are written to disk first because `postWrite` runs the | |
| 1879 | * ecosystem's own tool against the working tree — it reads the file, not the | |
| 1880 | * change about to be committed. The tree is a CI checkout that is discarded | |
| 1881 | * after the run, which is why writing to it is acceptable here and would not | |
| 1882 | * be somewhere longer-lived. | |
| 1883 | * | |
| 1884 | * @param changes - Manifest changes already produced | |
| 1885 | * @param ecosystems - Ecosystem names whose manifests changed | |
| 1886 | * @returns File changes for the regenerated lockfiles | |
| 1887 | */ | |
| 1888 | private async regenerateAdapterLockfiles( | |
| 1889 | changes: Array<{ path: string, content: string, type: 'update' }>, | |
| 1890 | ecosystems: string[], | |
| 1891 | ): Promise<Array<{ path: string, content: string, type: 'update' }>> { | |
| 1892 | if (changes.length === 0 || ecosystems.length === 0) | |
| 1893 | return [] | |
| 1894 | ||
| 1895 | const { regenerateLockfiles } = await import('./ecosystems') | |
| 1896 | ||
| 1897 | try { | |
| 1898 | for (const change of changes) | |
| 1899 | await Bun.write(join(this.projectPath, change.path), change.content) | |
| 1900 | } | |
| 1901 | catch (error) { | |
| 1902 | // Without the manifests on disk the tools would lock the old versions, | |
| 1903 | // which is worse than leaving the lockfile alone. | |
| 1904 | this.logger.warn(`⚠️ Could not stage manifests for lockfile regeneration: ${error}`) | |
| 1905 | return [] | |
| 1906 | } | |
| 1907 | ||
| 1908 | const { regenerated, notes } = await regenerateLockfiles(this.projectPath, ecosystems) | |
| 1909 | ||
| 1910 | // A missing tool is reported rather than swallowed: the pull request will | |
| 1911 | // carry a manifest its lockfile does not match, and that is worth saying. | |
| 1912 | for (const note of notes) | |
| 1913 | this.logger.info(`ℹ️ ${note}`) | |
| 1914 | ||
| 1915 | const locked: Array<{ path: string, content: string, type: 'update' }> = [] | |
| 1916 | ||
| 1917 | for (const lockfile of regenerated) { | |
| 1918 | try { | |
| 1919 | locked.push({ | |
| 1920 | path: lockfile, | |
| 1921 | content: await Bun.file(join(this.projectPath, lockfile)).text(), | |
| 1922 | type: 'update', | |
| 1923 | }) | |
| 1924 | this.logger.info(`🔒 Regenerated ${lockfile}`) | |
| 1925 | } | |
| 1926 | catch (error) { | |
| 1927 | this.logger.warn(`⚠️ Could not read the regenerated ${lockfile}: ${error}`) | |
| 1928 | } | |
| 1929 | } | |
| 1930 | ||
| 1931 | return locked | |
| 1864 | 1932 | } |
| 1865 | 1933 | |
| 1866 | 1934 | /** |
| @@ -0,0 +1,125 @@ | ||
| 1 | import type { PackageUpdate } from '../src/types' | |
| 2 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 3 | import { promises as fs } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { Buddy } from '../src/buddy' | |
| 7 | import { regenerateLockfiles } from '../src/ecosystems/scan' | |
| 8 | ||
| 9 | /** An adapter that records what it was asked to do. */ | |
| 10 | function fakeAdapter(name: string, options: { lockfile?: string, note?: string } = {}) { | |
| 11 | const seen: string[] = [] | |
| 12 | ||
| 13 | return { | |
| 14 | seen, | |
| 15 | adapter: { | |
| 16 | name, | |
| 17 | applyUpdate: (content: string) => content, | |
| 18 | async postWrite(dir: string) { | |
| 19 | seen.push(dir) | |
| 20 | return { | |
| 21 | regenerated: options.lockfile ? [options.lockfile] : [], | |
| 22 | ...(options.note ? { note: options.note } : {}), | |
| 23 | } | |
| 24 | }, | |
| 25 | } as never, | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * `regenerateLockfiles` and every adapter's `postWrite` were written, tested | |
| 31 | * and exported, and nothing called them — so a Python, Rust, Go or Ruby | |
| 32 | * manifest was updated and its lockfile left describing the old version. That | |
| 33 | * is the `lockfile-drift` failure `fix-ci` exists to diagnose, arriving on a | |
| 34 | * pull request buddy opened itself. | |
| 35 | */ | |
| 36 | describe('regenerateLockfiles', () => { | |
| 37 | it('success case - runs the adapters whose manifests changed', async () => { | |
| 38 | const python = fakeAdapter('python', { lockfile: 'uv.lock' }) | |
| 39 | const rust = fakeAdapter('rust', { lockfile: 'Cargo.lock' }) | |
| 40 | ||
| 41 | const result = await regenerateLockfiles('/repo', ['python'], [python.adapter, rust.adapter]) | |
| 42 | ||
| 43 | expect(result.regenerated).toEqual(['uv.lock']) | |
| 44 | expect(python.seen).toEqual(['/repo']) | |
| 45 | expect(rust.seen).toEqual([]) | |
| 46 | }) | |
| 47 | ||
| 48 | it('success case - collects the notes an adapter reports', async () => { | |
| 49 | // A missing tool has to surface: the pull request will carry a manifest | |
| 50 | // its lockfile does not match. | |
| 51 | const python = fakeAdapter('python', { note: 'uv.lock (needs `uv lock`)' }) | |
| 52 | ||
| 53 | const result = await regenerateLockfiles('/repo', ['python'], [python.adapter]) | |
| 54 | ||
| 55 | expect(result.notes).toEqual(['uv.lock (needs `uv lock`)']) | |
| 56 | expect(result.regenerated).toEqual([]) | |
| 57 | }) | |
| 58 | ||
| 59 | it('failure case - an adapter that throws does not stop the others', async () => { | |
| 60 | const broken = { | |
| 61 | name: 'python', | |
| 62 | applyUpdate: (c: string) => c, | |
| 63 | postWrite: async () => { throw new Error('uv exploded') }, | |
| 64 | } as never | |
| 65 | const ruby = fakeAdapter('ruby', { lockfile: 'Gemfile.lock' }) | |
| 66 | ||
| 67 | const result = await regenerateLockfiles('/repo', ['python', 'ruby'], [broken, ruby.adapter]) | |
| 68 | ||
| 69 | expect(result.regenerated).toEqual(['Gemfile.lock']) | |
| 70 | }) | |
| 71 | }) | |
| 72 | ||
| 73 | describe('adapter updates carry their lockfile', () => { | |
| 74 | let testDir: string | |
| 75 | ||
| 76 | // Deliberately no `process.chdir`: `Buddy` takes an explicit project path, | |
| 77 | // and another suite in this run leaves the working directory pointing at a | |
| 78 | // temporary directory it has already removed. | |
| 79 | beforeEach(async () => { | |
| 80 | testDir = await fs.mkdtemp(join(tmpdir(), 'buddy-locks-')) | |
| 81 | }) | |
| 82 | ||
| 83 | afterEach(async () => { | |
| 84 | await fs.rm(testDir, { recursive: true, force: true }) | |
| 85 | }) | |
| 86 | ||
| 87 | it('success case - the updated manifest is on disk before the tool runs', async () => { | |
| 88 | // `postWrite` runs the ecosystem's own tool against the working tree: it | |
| 89 | // reads the manifest, not the change about to be committed. A tool run | |
| 90 | // before the write would lock the version being replaced. | |
| 91 | await fs.writeFile(join(testDir, 'Cargo.toml'), '[dependencies]\nserde = "1.0.100"\n') | |
| 92 | ||
| 93 | const buddy = new Buddy({ packages: { strategy: 'all' } }, testDir) | |
| 94 | const update = { | |
| 95 | name: 'serde', | |
| 96 | currentVersion: '1.0.100', | |
| 97 | newVersion: '1.0.200', | |
| 98 | updateType: 'patch', | |
| 99 | dependencyType: 'rust', | |
| 100 | file: 'Cargo.toml', | |
| 101 | } as PackageUpdate | |
| 102 | ||
| 103 | const changes = await (buddy as never as { | |
| 104 | generateAdapterFileUpdates: (u: PackageUpdate[]) => Promise<Array<{ path: string, content: string }>> | |
| 105 | }).generateAdapterFileUpdates([update]) | |
| 106 | ||
| 107 | const manifest = changes.find(change => change.path === 'Cargo.toml') | |
| 108 | expect(manifest?.content).toContain('1.0.200') | |
| 109 | ||
| 110 | // The staged write is what makes the regeneration meaningful. | |
| 111 | expect(await fs.readFile(join(testDir, 'Cargo.toml'), 'utf-8')).toContain('1.0.200') | |
| 112 | }) | |
| 113 | ||
| 114 | it('edge case - no adapter updates means nothing is written', async () => { | |
| 115 | const buddy = new Buddy({ packages: { strategy: 'all' } }, testDir) | |
| 116 | ||
| 117 | const changes = await (buddy as never as { | |
| 118 | generateAdapterFileUpdates: (u: PackageUpdate[]) => Promise<unknown[]> | |
| 119 | }).generateAdapterFileUpdates([ | |
| 120 | { name: 'react', currentVersion: '1', newVersion: '2', updateType: 'major', dependencyType: 'dependencies', file: 'package.json' } as PackageUpdate, | |
| 121 | ]) | |
| 122 | ||
| 123 | expect(changes).toEqual([]) | |
| 124 | }) | |
| 125 | }) | |